Merge branch 'kb_migration' into kb_development

This commit is contained in:
KMY 2023-08-26 21:01:34 +09:00
commit cd950f80ad
160 changed files with 2861 additions and 508 deletions

View file

@ -2,3 +2,7 @@ VAGRANT=true
LOCAL_DOMAIN=mastodon.local LOCAL_DOMAIN=mastodon.local
BIND=0.0.0.0 BIND=0.0.0.0
DB_HOST=/var/run/postgresql/ DB_HOST=/var/run/postgresql/
ES_ENABLED=true
ES_HOST=localhost
ES_PORT=9200

View file

@ -8,7 +8,9 @@ on:
type: boolean type: boolean
push_to_images: push_to_images:
type: string type: string
version_suffix: version_prerelease:
type: string
version_metadata:
type: string type: string
flavor: flavor:
type: string type: string
@ -83,7 +85,7 @@ jobs:
- uses: docker/build-push-action@v4 - uses: docker/build-push-action@v4
with: with:
context: . context: .
build-args: MASTODON_VERSION_SUFFIX=${{ inputs.version_suffix }} build-args: MASTODON_VERSION_PRERELEASE=${{ inputs.version_prerelease }} MASTODON_VERSION_METADATA=${{ inputs.version_metadata }}
platforms: ${{ inputs.platforms }} platforms: ${{ inputs.platforms }}
provenance: false provenance: false
builder: ${{ steps.buildx.outputs.name || steps.buildx-native.outputs.name }} builder: ${{ steps.buildx.outputs.name || steps.buildx-native.outputs.name }}

View file

@ -16,9 +16,9 @@ jobs:
env: env:
TZ: Etc/UTC TZ: Etc/UTC
run: | run: |
echo mastodon_version_suffix=nightly-$(date +'%Y-%m-%d')>> $GITHUB_OUTPUT echo mastodon_version_prerelease=nightly.$(date +'%Y-%m-%d')>> $GITHUB_OUTPUT
outputs: outputs:
suffix: ${{ steps.version_vars.outputs.mastodon_version_suffix }} prerelease: ${{ steps.version_vars.outputs.mastodon_version_prerelease }}
build-image: build-image:
needs: compute-suffix needs: compute-suffix
@ -29,8 +29,7 @@ jobs:
push_to_images: | push_to_images: |
tootsuite/mastodon tootsuite/mastodon
ghcr.io/mastodon/mastodon ghcr.io/mastodon/mastodon
# The `+` is important here, result will be v4.1.2+nightly-2022-03-05 version_prerelease: ${{ needs.compute-suffix.outputs.prerelease }}
version_suffix: +${{ needs.compute-suffix.outputs.suffix }}
labels: | labels: |
org.opencontainers.image.description=Nightly build image used for testing purposes org.opencontainers.image.description=Nightly build image used for testing purposes
flavor: | flavor: |
@ -38,5 +37,5 @@ jobs:
tags: | tags: |
type=raw,value=edge type=raw,value=edge
type=raw,value=nightly type=raw,value=nightly
type=schedule,pattern=${{ needs.compute-suffix.outputs.suffix }} type=schedule,pattern=${{ needs.compute-suffix.outputs.prerelease }}
secrets: inherit secrets: inherit

View file

@ -21,9 +21,9 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- id: version_vars - id: version_vars
run: | run: |
echo mastodon_version_suffix=+pr-${{ github.event.pull_request.number }}-$(git rev-parse --short HEAD) >> $GITHUB_OUTPUT echo mastodon_version_metadata=pr-${{ github.event.pull_request.number }}-$(git rev-parse --short HEAD) >> $GITHUB_OUTPUT
outputs: outputs:
suffix: ${{ steps.version_vars.outputs.mastodon_version_suffix }} metadata: ${{ steps.version_vars.outputs.mastodon_version_metadata }}
build-image: build-image:
needs: compute-suffix needs: compute-suffix
@ -33,7 +33,7 @@ jobs:
use_native_arm64_builder: true use_native_arm64_builder: true
push_to_images: | push_to_images: |
ghcr.io/mastodon/mastodon ghcr.io/mastodon/mastodon
version_suffix: ${{ needs.compute-suffix.outputs.suffix }} version_metadata: ${{ needs.compute-suffix.outputs.metadata }}
flavor: | flavor: |
latest=auto latest=auto
tags: | tags: |

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.54.2. # using RuboCop version 1.56.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
@ -61,38 +61,8 @@ Lint/EmptyBlock:
- 'spec/fabricators/access_token_fabricator.rb' - 'spec/fabricators/access_token_fabricator.rb'
- 'spec/fabricators/conversation_fabricator.rb' - 'spec/fabricators/conversation_fabricator.rb'
- 'spec/fabricators/system_key_fabricator.rb' - 'spec/fabricators/system_key_fabricator.rb'
- 'spec/helpers/admin/action_logs_helper_spec.rb'
- 'spec/lib/activitypub/adapter_spec.rb' - 'spec/lib/activitypub/adapter_spec.rb'
- 'spec/models/account_alias_spec.rb'
- 'spec/models/account_deletion_request_spec.rb'
- 'spec/models/account_moderation_note_spec.rb'
- 'spec/models/announcement_mute_spec.rb'
- 'spec/models/announcement_reaction_spec.rb'
- 'spec/models/announcement_spec.rb'
- 'spec/models/backup_spec.rb'
- 'spec/models/conversation_mute_spec.rb'
- 'spec/models/custom_filter_keyword_spec.rb'
- 'spec/models/custom_filter_spec.rb'
- 'spec/models/device_spec.rb'
- 'spec/models/encrypted_message_spec.rb'
- 'spec/models/featured_tag_spec.rb'
- 'spec/models/follow_recommendation_suppression_spec.rb'
- 'spec/models/list_account_spec.rb'
- 'spec/models/list_spec.rb'
- 'spec/models/login_activity_spec.rb'
- 'spec/models/mute_spec.rb'
- 'spec/models/preview_card_spec.rb'
- 'spec/models/preview_card_trend_spec.rb'
- 'spec/models/relay_spec.rb'
- 'spec/models/scheduled_status_spec.rb'
- 'spec/models/status_stat_spec.rb'
- 'spec/models/status_trend_spec.rb'
- 'spec/models/system_key_spec.rb'
- 'spec/models/tag_follow_spec.rb'
- 'spec/models/unavailable_domain_spec.rb'
- 'spec/models/user_invite_request_spec.rb'
- 'spec/models/user_role_spec.rb' - 'spec/models/user_role_spec.rb'
- 'spec/models/web/setting_spec.rb'
Lint/NonLocalExitFromIterator: Lint/NonLocalExitFromIterator:
Exclude: Exclude:
@ -135,7 +105,7 @@ Lint/UselessAssignment:
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
Metrics/AbcSize: Metrics/AbcSize:
Max: 146 Max: 144
# Configuration parameters: CountBlocks, Max. # Configuration parameters: CountBlocks, Max.
Metrics/BlockNesting: Metrics/BlockNesting:
@ -164,6 +134,19 @@ Naming/VariableNumber:
- 'spec/models/domain_block_spec.rb' - 'spec/models/domain_block_spec.rb'
- 'spec/models/user_spec.rb' - 'spec/models/user_spec.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: SafeMultiline.
Performance/DeletePrefix:
Exclude:
- 'app/models/featured_tag.rb'
Performance/MapMethodChain:
Exclude:
- 'app/models/feed.rb'
- 'lib/mastodon/cli/maintenance.rb'
- 'spec/services/bulk_import_service_spec.rb'
- 'spec/services/import_service_spec.rb'
RSpec/AnyInstance: RSpec/AnyInstance:
Exclude: Exclude:
- 'spec/controllers/activitypub/inboxes_controller_spec.rb' - 'spec/controllers/activitypub/inboxes_controller_spec.rb'
@ -768,6 +751,15 @@ Style/RedundantFetchBlock:
- 'config/initializers/paperclip.rb' - 'config/initializers/paperclip.rb'
- 'config/puma.rb' - 'config/puma.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowMultipleReturnValues.
Style/RedundantReturn:
Exclude:
- 'app/controllers/api/v1/directories_controller.rb'
- 'app/controllers/auth/confirmations_controller.rb'
- 'app/lib/ostatus/tag_manager.rb'
- 'app/models/form/import.rb'
# This cop supports unsafe autocorrection (--autocorrect-all). # This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods, MaxChainLength. # Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods, MaxChainLength.
# AllowedMethods: present?, blank?, presence, try, try! # AllowedMethods: present?, blank?, presence, try, try!

View file

@ -101,7 +101,7 @@ The following changelog entries focus on changes visible to users, administrator
- **Change translation feature to cover Content Warnings, poll options and media descriptions** ([c960657](https://github.com/mastodon/mastodon/pull/24175), [S-H-GAMELINKS](https://github.com/mastodon/mastodon/pull/25251), [c960657](https://github.com/mastodon/mastodon/pull/26168), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26452)) - **Change translation feature to cover Content Warnings, poll options and media descriptions** ([c960657](https://github.com/mastodon/mastodon/pull/24175), [S-H-GAMELINKS](https://github.com/mastodon/mastodon/pull/25251), [c960657](https://github.com/mastodon/mastodon/pull/26168), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26452))
- **Change account search to match by text when opted-in** ([jsgoldstein](https://github.com/mastodon/mastodon/pull/25599), [Gargron](https://github.com/mastodon/mastodon/pull/26378)) - **Change account search to match by text when opted-in** ([jsgoldstein](https://github.com/mastodon/mastodon/pull/25599), [Gargron](https://github.com/mastodon/mastodon/pull/26378))
- **Change import feature to be clearer, less error-prone and more reliable** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21054), [mgmn](https://github.com/mastodon/mastodon/pull/24874)) - **Change import feature to be clearer, less error-prone and more reliable** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21054), [mgmn](https://github.com/mastodon/mastodon/pull/24874))
- **Change local and federated timelines to be in a single “Live feeds” column** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25641), [Gargron](https://github.com/mastodon/mastodon/pull/25683), [mgmn](https://github.com/mastodon/mastodon/pull/25694), [Plastikmensch](https://github.com/mastodon/mastodon/pull/26247)) - **Change local and federated timelines to be tabs of a single “Live feeds” column** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25641), [Gargron](https://github.com/mastodon/mastodon/pull/25683), [mgmn](https://github.com/mastodon/mastodon/pull/25694), [Plastikmensch](https://github.com/mastodon/mastodon/pull/26247))
- **Change user archive export to be faster and more reliable, and export `.zip` archives instead of `.tar.gz` ones** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23360), [TheEssem](https://github.com/mastodon/mastodon/pull/25034)) - **Change user archive export to be faster and more reliable, and export `.zip` archives instead of `.tar.gz` ones** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23360), [TheEssem](https://github.com/mastodon/mastodon/pull/25034))
- **Change `mastodon-streaming` systemd unit files to be templated** ([e-nomem](https://github.com/mastodon/mastodon/pull/24751)) - **Change `mastodon-streaming` systemd unit files to be templated** ([e-nomem](https://github.com/mastodon/mastodon/pull/24751))
- **Change `statsd` integration to disable sidekiq metrics by default** ([mjankowski](https://github.com/mastodon/mastodon/pull/25265), [mjankowski](https://github.com/mastodon/mastodon/pull/25336), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26310)) - **Change `statsd` integration to disable sidekiq metrics by default** ([mjankowski](https://github.com/mastodon/mastodon/pull/25265), [mjankowski](https://github.com/mastodon/mastodon/pull/25336), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26310))
@ -189,6 +189,7 @@ The following changelog entries focus on changes visible to users, administrator
- **Fix log-in flow when involving both OAuth and external authentication** ([CSDUMMI](https://github.com/mastodon/mastodon/pull/24073)) - **Fix log-in flow when involving both OAuth and external authentication** ([CSDUMMI](https://github.com/mastodon/mastodon/pull/24073))
- **Fix broken links in account gallery** ([c960657](https://github.com/mastodon/mastodon/pull/24218)) - **Fix broken links in account gallery** ([c960657](https://github.com/mastodon/mastodon/pull/24218))
- **Fix blocking subdomains of an already-blocked domain** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26392)) - **Fix blocking subdomains of an already-blocked domain** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26392))
- **Fix migration handler not updating lists** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24808))
- Fix uploading of video files for which `ffprobe` reports `0/0` average framerate ([NicolaiSoeborg](https://github.com/mastodon/mastodon/pull/26500)) - Fix uploading of video files for which `ffprobe` reports `0/0` average framerate ([NicolaiSoeborg](https://github.com/mastodon/mastodon/pull/26500))
- Fix cached posts including stale stats ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26409)) - Fix cached posts including stale stats ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26409))
- Fix adding column with default value taking longer on Postgres >= 11 ([Gargron](https://github.com/mastodon/mastodon/pull/26375)) - Fix adding column with default value taking longer on Postgres >= 11 ([Gargron](https://github.com/mastodon/mastodon/pull/26375))

View file

@ -42,8 +42,8 @@ RUN apt-get update && \
FROM node:${NODE_VERSION} FROM node:${NODE_VERSION}
# Use those args to specify your own version flags & suffixes # Use those args to specify your own version flags & suffixes
ARG MASTODON_VERSION_FLAGS="" ARG MASTODON_VERSION_PRERELEASE=""
ARG MASTODON_VERSION_SUFFIX="" ARG MASTODON_VERSION_METADATA=""
ARG UID="991" ARG UID="991"
ARG GID="991" ARG GID="991"
@ -89,8 +89,8 @@ ENV RAILS_ENV="production" \
NODE_ENV="production" \ NODE_ENV="production" \
RAILS_SERVE_STATIC_FILES="true" \ RAILS_SERVE_STATIC_FILES="true" \
BIND="0.0.0.0" \ BIND="0.0.0.0" \
MASTODON_VERSION_FLAGS="${MASTODON_VERSION_FLAGS}" \ MASTODON_VERSION_PRERELEASE="${MASTODON_VERSION_PRERELEASE}" \
MASTODON_VERSION_SUFFIX="${MASTODON_VERSION_SUFFIX}" MASTODON_VERSION_METADATA="${MASTODON_VERSION_METADATA}"
# Set the run user # Set the run user
USER mastodon USER mastodon

View file

@ -110,7 +110,7 @@ group :test do
gem 'fuubar', '~> 2.5' gem 'fuubar', '~> 2.5'
# Extra RSpec extenion methods and helpers for sidekiq # Extra RSpec extenion methods and helpers for sidekiq
gem 'rspec-sidekiq', '~> 3.1' gem 'rspec-sidekiq', '~> 4.0'
# Browser integration testing # Browser integration testing
gem 'capybara', '~> 3.39' gem 'capybara', '~> 3.39'

View file

@ -39,47 +39,47 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.0.7) actioncable (7.0.7.2)
actionpack (= 7.0.7) actionpack (= 7.0.7.2)
activesupport (= 7.0.7) activesupport (= 7.0.7.2)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (7.0.7) actionmailbox (7.0.7.2)
actionpack (= 7.0.7) actionpack (= 7.0.7.2)
activejob (= 7.0.7) activejob (= 7.0.7.2)
activerecord (= 7.0.7) activerecord (= 7.0.7.2)
activestorage (= 7.0.7) activestorage (= 7.0.7.2)
activesupport (= 7.0.7) activesupport (= 7.0.7.2)
mail (>= 2.7.1) mail (>= 2.7.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
actionmailer (7.0.7) actionmailer (7.0.7.2)
actionpack (= 7.0.7) actionpack (= 7.0.7.2)
actionview (= 7.0.7) actionview (= 7.0.7.2)
activejob (= 7.0.7) activejob (= 7.0.7.2)
activesupport (= 7.0.7) activesupport (= 7.0.7.2)
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.0)
actionpack (7.0.7) actionpack (7.0.7.2)
actionview (= 7.0.7) actionview (= 7.0.7.2)
activesupport (= 7.0.7) activesupport (= 7.0.7.2)
rack (~> 2.0, >= 2.2.4) rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.7) actiontext (7.0.7.2)
actionpack (= 7.0.7) actionpack (= 7.0.7.2)
activerecord (= 7.0.7) activerecord (= 7.0.7.2)
activestorage (= 7.0.7) activestorage (= 7.0.7.2)
activesupport (= 7.0.7) activesupport (= 7.0.7.2)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.0.7) actionview (7.0.7.2)
activesupport (= 7.0.7) activesupport (= 7.0.7.2)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
@ -89,22 +89,22 @@ GEM
activemodel (>= 4.1, < 7.1) activemodel (>= 4.1, < 7.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.7) activejob (7.0.7.2)
activesupport (= 7.0.7) activesupport (= 7.0.7.2)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.0.7) activemodel (7.0.7.2)
activesupport (= 7.0.7) activesupport (= 7.0.7.2)
activerecord (7.0.7) activerecord (7.0.7.2)
activemodel (= 7.0.7) activemodel (= 7.0.7.2)
activesupport (= 7.0.7) activesupport (= 7.0.7.2)
activestorage (7.0.7) activestorage (7.0.7.2)
actionpack (= 7.0.7) actionpack (= 7.0.7.2)
activejob (= 7.0.7) activejob (= 7.0.7.2)
activerecord (= 7.0.7) activerecord (= 7.0.7.2)
activesupport (= 7.0.7) activesupport (= 7.0.7.2)
marcel (~> 1.0) marcel (~> 1.0)
mini_mime (>= 1.1.0) mini_mime (>= 1.1.0)
activesupport (7.0.7) activesupport (7.0.7.2)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
minitest (>= 5.1) minitest (>= 5.1)
@ -147,6 +147,7 @@ GEM
faraday_middleware (~> 1.0, >= 1.0.0.rc1) faraday_middleware (~> 1.0, >= 1.0.0.rc1)
net-http-persistent (~> 4.0) net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8) nokogiri (~> 1, >= 1.10.8)
base64 (0.1.1)
bcrypt (3.1.18) bcrypt (3.1.18)
better_errors (2.10.1) better_errors (2.10.1)
erubi (>= 1.0.0) erubi (>= 1.0.0)
@ -451,7 +452,7 @@ GEM
hashie (~> 5.0) hashie (~> 5.0)
memory_profiler (1.0.1) memory_profiler (1.0.1)
method_source (1.0.0) method_source (1.0.0)
mime-types (3.5.0) 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)
mini_mime (1.1.5) mini_mime (1.1.5)
@ -481,7 +482,7 @@ GEM
nokogiri (1.15.4) nokogiri (1.15.4)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
oj (3.15.0) oj (3.16.0)
omniauth (2.1.1) omniauth (2.1.1)
hashie (>= 3.4.6) hashie (>= 3.4.6)
rack (>= 2.2.3) rack (>= 2.2.3)
@ -555,20 +556,20 @@ GEM
rack rack
rack-test (2.1.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rails (7.0.7) rails (7.0.7.2)
actioncable (= 7.0.7) actioncable (= 7.0.7.2)
actionmailbox (= 7.0.7) actionmailbox (= 7.0.7.2)
actionmailer (= 7.0.7) actionmailer (= 7.0.7.2)
actionpack (= 7.0.7) actionpack (= 7.0.7.2)
actiontext (= 7.0.7) actiontext (= 7.0.7.2)
actionview (= 7.0.7) actionview (= 7.0.7.2)
activejob (= 7.0.7) activejob (= 7.0.7.2)
activemodel (= 7.0.7) activemodel (= 7.0.7.2)
activerecord (= 7.0.7) activerecord (= 7.0.7.2)
activestorage (= 7.0.7) activestorage (= 7.0.7.2)
activesupport (= 7.0.7) activesupport (= 7.0.7.2)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.0.7) railties (= 7.0.7.2)
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)
@ -583,9 +584,9 @@ GEM
rails-i18n (7.0.7) rails-i18n (7.0.7)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8) railties (>= 6.0.0, < 8)
railties (7.0.7) railties (7.0.7.2)
actionpack (= 7.0.7) actionpack (= 7.0.7.2)
activesupport (= 7.0.7) activesupport (= 7.0.7.2)
method_source method_source
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0)
@ -634,12 +635,15 @@ GEM
rspec-support (~> 3.12) rspec-support (~> 3.12)
rspec-retry (0.6.2) rspec-retry (0.6.2)
rspec-core (> 3.3) rspec-core (> 3.3)
rspec-sidekiq (3.1.0) rspec-sidekiq (4.0.1)
rspec-core (~> 3.0, >= 3.0.0) rspec-core (~> 3.0)
sidekiq (>= 2.4.0) rspec-expectations (~> 3.0)
rspec-support (3.12.0) rspec-mocks (~> 3.0)
sidekiq (>= 5, < 8)
rspec-support (3.12.1)
rspec_chunked (0.6) rspec_chunked (0.6)
rubocop (1.54.2) rubocop (1.56.1)
base64 (~> 0.1.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (>= 3.17.0)
parallel (~> 1.10) parallel (~> 1.10)
@ -647,7 +651,7 @@ GEM
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0) rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.28.0, < 2.0) rubocop-ast (>= 1.28.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0) unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.29.0) rubocop-ast (1.29.0)
@ -656,14 +660,14 @@ GEM
rubocop (~> 1.41) rubocop (~> 1.41)
rubocop-factory_bot (2.23.1) rubocop-factory_bot (2.23.1)
rubocop (~> 1.33) rubocop (~> 1.33)
rubocop-performance (1.18.0) rubocop-performance (1.19.0)
rubocop (>= 1.7.0, < 2.0) rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0) rubocop-ast (>= 0.4.0)
rubocop-rails (2.20.2) rubocop-rails (2.20.2)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0) rubocop (>= 1.33.0, < 2.0)
rubocop-rspec (2.22.0) rubocop-rspec (2.23.2)
rubocop (~> 1.33) rubocop (~> 1.33)
rubocop-capybara (~> 2.17) rubocop-capybara (~> 2.17)
rubocop-factory_bot (~> 2.22) rubocop-factory_bot (~> 2.22)
@ -912,7 +916,7 @@ DEPENDENCIES
rqrcode (~> 2.2) rqrcode (~> 2.2)
rspec-rails (~> 6.0) rspec-rails (~> 6.0)
rspec-retry (>= 0.6.2) rspec-retry (>= 0.6.2)
rspec-sidekiq (~> 3.1) rspec-sidekiq (~> 4.0)
rspec_chunked (~> 0.6) rspec_chunked (~> 0.6)
rubocop rubocop
rubocop-capybara rubocop-capybara

View file

@ -1,8 +1,11 @@
# Security Policy # Security Policy
If you believe you've identified a security vulnerability in Mastodon (a bug that allows something to happen that shouldn't be possible), you can reach us at <security@joinmastodon.org>. If you believe you've identified a security vulnerability in Mastodon (a bug that allows something to happen that shouldn't be possible), you can either:
You should _not_ report such issues on GitHub or in other public spaces to give us time to publish a fix for the issue without exposing Mastodon's users to increased risk. - open a [Github security issue on the Mastodon project](https://github.com/mastodon/mastodon/security/advisories/new)
- reach us at <security@joinmastodon.org>
You should _not_ report such issues on public GitHub issues or in other public spaces to give us time to publish a fix for the issue without exposing Mastodon's users to increased risk.
## Scope ## Scope

43
Vagrantfile vendored
View file

@ -60,6 +60,37 @@ sudo usermod -a -G rvm $USER
SCRIPT SCRIPT
$provisionElasticsearch = <<SCRIPT
# Install Elastic Search
sudo apt install openjdk-17-jre-headless -y
sudo wget -O /usr/share/keyrings/elasticsearch.asc https://artifacts.elastic.co/GPG-KEY-elasticsearch
sudo sh -c 'echo "deb [signed-by=/usr/share/keyrings/elasticsearch.asc] https://artifacts.elastic.co/packages/7.x/apt stable main" > /etc/apt/sources.list.d/elastic-7.x.list'
sudo apt update
sudo apt install elasticsearch -y
sudo systemctl daemon-reload
sudo systemctl enable --now elasticsearch
echo 'path.data: /var/lib/elasticsearch
path.logs: /var/log/elasticsearch
network.host: 0.0.0.0
http.port: 9200
discovery.seed_hosts: ["localhost"]
cluster.initial_master_nodes: ["node-1"]' > /etc/elasticsearch/elasticsearch.yml
sudo systemctl restart elasticsearch
# Install Kibana
sudo apt install kibana -y
sudo systemctl enable --now kibana
echo 'server.host: "0.0.0.0"
elasticsearch.hosts: ["http://localhost:9200"]' > /etc/kibana/kibana.yml
sudo systemctl restart kibana
SCRIPT
$provisionB = <<SCRIPT $provisionB = <<SCRIPT
source "/etc/profile.d/rvm.sh" source "/etc/profile.d/rvm.sh"
@ -102,10 +133,8 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.provider :virtualbox do |vb| config.vm.provider :virtualbox do |vb|
vb.name = "mastodon" vb.name = "mastodon"
vb.customize ["modifyvm", :id, "--memory", "2048"] vb.customize ["modifyvm", :id, "--memory", "8192"]
# Increase the number of CPUs. Uncomment and adjust to vb.customize ["modifyvm", :id, "--cpus", "3"]
# increase performance
# vb.customize ["modifyvm", :id, "--cpus", "3"]
# Disable VirtualBox DNS proxy to skip long-delay IPv6 resolutions. # Disable VirtualBox DNS proxy to skip long-delay IPv6 resolutions.
# https://github.com/mitchellh/vagrant/issues/1172 # https://github.com/mitchellh/vagrant/issues/1172
@ -141,9 +170,15 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.network :forwarded_port, guest: 3000, host: 3000 config.vm.network :forwarded_port, guest: 3000, host: 3000
config.vm.network :forwarded_port, guest: 4000, host: 4000 config.vm.network :forwarded_port, guest: 4000, host: 4000
config.vm.network :forwarded_port, guest: 8080, host: 8080 config.vm.network :forwarded_port, guest: 8080, host: 8080
config.vm.network :forwarded_port, guest: 9200, host: 9200
config.vm.network :forwarded_port, guest: 9300, host: 9300
config.vm.network :forwarded_port, guest: 9243, host: 9243
config.vm.network :forwarded_port, guest: 5601, host: 5601
# Full provisioning script, only runs on first 'vagrant up' or with 'vagrant provision' # Full provisioning script, only runs on first 'vagrant up' or with 'vagrant provision'
config.vm.provision :shell, inline: $provisionA, privileged: false, reset: true config.vm.provision :shell, inline: $provisionA, privileged: false, reset: true
# Run with elevated privileges for Elasticsearch installation
config.vm.provision :shell, inline: $provisionElasticsearch, privileged: true
config.vm.provision :shell, inline: $provisionB, privileged: false config.vm.provision :shell, inline: $provisionB, privileged: false
config.vm.post_up_message = <<MESSAGE config.vm.post_up_message = <<MESSAGE

View file

@ -60,8 +60,9 @@ class AccountsIndex < Chewy::Index
field(:followers_count, type: 'long', value: ->(account) { account.public_followers_count }) field(:followers_count, type: 'long', value: ->(account) { account.public_followers_count })
field(:properties, type: 'keyword', value: ->(account) { account.searchable_properties }) field(:properties, type: 'keyword', value: ->(account) { account.searchable_properties })
field(:last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at }) field(:last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at })
field(:domain, type: 'keyword', value: ->(account) { account.domain || '' })
field(:display_name, type: 'text', analyzer: 'verbatim') { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' } field(:display_name, type: 'text', analyzer: 'verbatim') { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' }
field(:username, type: 'text', analyzer: 'verbatim', value: ->(account) { [account.username, account.domain].compact.join('@') }) { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' } field(:username, type: 'text', analyzer: 'verbatim', value: ->(account) { [account.username, account.domain].compact.join('@') }) { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' }
field(:text, type: 'text', value: ->(account) { account.searchable_text }) { field :stemmed, type: 'text', analyzer: 'natural' } field(:text, type: 'text', analyzer: 'whitespace', value: ->(account) { account.searchable_text }) { field :stemmed, type: 'text', analyzer: 'natural' }
end end
end end

View file

@ -0,0 +1,51 @@
# frozen_string_literal: true
class PublicStatusesIndex < Chewy::Index
settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: {
filter: {
english_stop: {
type: 'stop',
stopwords: '_english_',
},
english_stemmer: {
type: 'stemmer',
language: 'english',
},
english_possessive_stemmer: {
type: 'stemmer',
language: 'possessive_english',
},
},
analyzer: {
content: {
tokenizer: 'uax_url_email',
filter: %w(
english_possessive_stemmer
lowercase
asciifolding
cjk_width
english_stop
english_stemmer
),
},
},
}
index_scope ::Status.unscoped
.kept
.indexable
.includes(:media_attachments, :preloadable_poll, :preview_cards)
root date_detection: false do
field(:id, type: 'keyword')
field(:account_id, type: 'long')
field(:text, type: 'text', analyzer: 'whitespace', value: ->(status) { status.searchable_text }) { field(:stemmed, type: 'text', analyzer: 'content') }
field(:language, type: 'keyword')
field(:domain, type: 'keyword', value: ->(status) { status.account.domain || '' })
field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties })
field(:created_at, type: 'date')
end
end

View file

@ -1,23 +1,24 @@
# frozen_string_literal: true # frozen_string_literal: true
class StatusesIndex < Chewy::Index class StatusesIndex < Chewy::Index
include FormattingHelper
settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: { settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: {
filter: { filter: {
english_stop: { english_stop: {
type: 'stop', type: 'stop',
stopwords: '_english_', stopwords: '_english_',
}, },
english_stemmer: { english_stemmer: {
type: 'stemmer', type: 'stemmer',
language: 'english', language: 'english',
}, },
english_possessive_stemmer: { english_possessive_stemmer: {
type: 'stemmer', type: 'stemmer',
language: 'possessive_english', language: 'possessive_english',
}, },
}, },
analyzer: { analyzer: {
content: { content: {
tokenizer: 'uax_url_email', tokenizer: 'uax_url_email',
@ -35,7 +36,7 @@ class StatusesIndex < Chewy::Index
# We do not use delete_if option here because it would call a method that we # We do not use delete_if option here because it would call a method that we
# expect to be called with crutches without crutches, causing n+1 queries # expect to be called with crutches without crutches, causing n+1 queries
index_scope ::Status.unscoped.kept.without_reblogs.includes(:media_attachments, :preloadable_poll) index_scope ::Status.unscoped.kept.without_reblogs.includes(:media_attachments, :preloadable_poll, :preview_cards)
crutch :mentions do |collection| crutch :mentions do |collection|
data = ::Mention.where(status_id: collection.map(&:id)).where(account: Account.local, silent: false).pluck(:status_id, :account_id) data = ::Mention.where(status_id: collection.map(&:id)).where(account: Account.local, silent: false).pluck(:status_id, :account_id)
@ -52,6 +53,11 @@ class StatusesIndex < Chewy::Index
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) } data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end end
crutch :status_references do |collection|
data = ::StatusReference.joins(:status).where(target_status_id: collection.map(&:id), status: { account: Account.local }).pluck(:target_status_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end
crutch :reblogs do |collection| crutch :reblogs do |collection|
data = ::Status.where(reblog_of_id: collection.map(&:id)).where(account: Account.local).pluck(:reblog_of_id, :account_id) data = ::Status.where(reblog_of_id: collection.map(&:id)).where(account: Account.local).pluck(:reblog_of_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) } data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
@ -68,14 +74,14 @@ class StatusesIndex < Chewy::Index
end end
root date_detection: false do root date_detection: false do
field :id, type: 'long' field(:id, type: 'keyword')
field :account_id, type: 'long' field(:account_id, type: 'long')
field(:text, type: 'text', analyzer: 'whitespace', value: ->(status) { status.searchable_text }) { field(:stemmed, type: 'text', analyzer: 'content') }
field :text, type: 'text', value: ->(status) { status.searchable_text } do field(:searchable_by, type: 'long', value: ->(status, crutches) { status.searchable_by(crutches) })
field :stemmed, type: 'text', analyzer: 'content' field(:searchability, type: 'keyword', value: ->(status) { status.compute_searchability })
end field(:language, type: 'keyword')
field(:domain, type: 'keyword', value: ->(status) { status.account.domain || '' })
field :searchable_by, type: 'long', value: ->(status, crutches) { status.searchable_by(crutches) } field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties })
field :searchability, type: 'keyword', value: ->(status) { status.compute_searchability } field(:created_at, type: 'date')
end end
end end

View file

@ -32,6 +32,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
:searchability, :searchability,
:dissubscribable, :dissubscribable,
:hide_collections, :hide_collections,
:indexable,
fields_attributes: [:name, :value] fields_attributes: [:name, :value]
) )
end end

View file

@ -0,0 +1,94 @@
# frozen_string_literal: true
class Api::V1::BookmarkCategories::StatusesController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show]
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show]
before_action :require_user!
before_action :set_bookmark_category
after_action :insert_pagination_headers, only: :show
def show
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer
end
def create
ApplicationRecord.transaction do
bookmark_category_statuses.each do |status|
Bookmark.find_or_create_by!(account: current_account, status: status)
@bookmark_category.statuses << status
end
end
render_empty
end
def destroy
BookmarkCategoryStatus.where(bookmark_category: @bookmark_category, status_id: status_ids).destroy_all
render_empty
end
private
def set_bookmark_category
@bookmark_category = current_account.bookmark_categories.find(params[:bookmark_category_id])
end
def load_statuses
if unlimited?
@bookmark_category.statuses.includes(:status_stat).all
else
@bookmark_category.statuses.includes(:status_stat).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
end
end
def bookmark_category_statuses
Status.find(status_ids)
end
def status_ids
Array(resource_params[:status_ids])
end
def resource_params
params.permit(status_ids: [])
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
return if unlimited?
api_v1_bookmark_category_statuses_url pagination_params(max_id: pagination_max_id) if records_continue?
end
def prev_path
return if unlimited?
api_v1_bookmark_category_statuses_url pagination_params(since_id: pagination_since_id) unless @statuses.empty?
end
def pagination_max_id
@statuses.last.id
end
def pagination_since_id
@statuses.first.id
end
def records_continue?
@statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
end
def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
def unlimited?
params[:limit] == '0'
end
end

View file

@ -0,0 +1,47 @@
# frozen_string_literal: true
class Api::V1::BookmarkCategoriesController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index, :show]
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:index, :show]
before_action :require_user!
before_action :set_bookmark_category, except: [:index, :create]
rescue_from ArgumentError do |e|
render json: { error: e.to_s }, status: 422
end
def index
@bookmark_categories = BookmarkCategory.where(account: current_account).all
render json: @bookmark_categories, each_serializer: REST::BookmarkCategorySerializer
end
def show
render json: @bookmark_category, serializer: REST::BookmarkCategorySerializer
end
def create
@bookmark_category = BookmarkCategory.create!(bookmark_category_params.merge(account: current_account))
render json: @bookmark_category, serializer: REST::BookmarkCategorySerializer
end
def update
@bookmark_category.update!(bookmark_category_params)
render json: @bookmark_category, serializer: REST::BookmarkCategorySerializer
end
def destroy
@bookmark_category.destroy!
render_empty
end
private
def set_bookmark_category
@bookmark_category = BookmarkCategory.where(account: current_account).find(params[:id])
end
def bookmark_category_params
params.permit(:title)
end
end

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
class Api::V1::Statuses::BookmarkCategoriesController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:lists' }
before_action :require_user!
before_action :set_status
def index
@statuses = @status.deleted_at.present? ? [] : @status.joined_bookmark_categories.where(account: current_account)
render json: @statuses, each_serializer: REST::BookmarkCategorySerializer
end
private
def set_status
@status = Status.find(params[:status_id])
end
end

View file

@ -18,7 +18,7 @@ class Settings::PrivacyController < Settings::BaseController
private private
def account_params def account_params
params.require(:account).permit(:discoverable, :unlocked, :show_collections, :dissubscribable, settings: UserSettings.keys) params.require(:account).permit(:discoverable, :unlocked, :indexable, :show_collections, :dissubscribable, settings: UserSettings.keys)
end end
def set_account def set_account

View file

@ -21,6 +21,7 @@ module ContextHelper
blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' }, blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' }, discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
indexable: { 'toot' => 'http://joinmastodon.org/ns#', 'indexable' => 'toot:indexable' }, indexable: { 'toot' => 'http://joinmastodon.org/ns#', 'indexable' => 'toot:indexable' },
memorial: { 'toot' => 'http://joinmastodon.org/ns#', 'memorial' => 'toot:memorial' },
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' }, voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
emoji_reactions: { 'fedibird' => 'http://fedibird.com/ns#', 'emojiReactions' => { '@id' => 'fedibird:emojiReactions', '@type' => '@id' } }, emoji_reactions: { 'fedibird' => 'http://fedibird.com/ns#', 'emojiReactions' => { '@id' => 'fedibird:emojiReactions', '@type' => '@id' } },
searchable_by: { 'fedibird' => 'http://fedibird.com/ns#', 'searchableBy' => { '@id' => 'fedibird:searchableBy', '@type' => '@id' } }, searchable_by: { 'fedibird' => 'http://fedibird.com/ns#', 'searchableBy' => { '@id' => 'fedibird:searchableBy', '@type' => '@id' } },

View file

@ -14,6 +14,10 @@ module FormattingHelper
end end
module_function :extract_status_plain_text module_function :extract_status_plain_text
def extract_status_plain_text_with_spoiler_text(status)
PlainTextFormatter.new("#{status.spoiler_text}\n#{status.text}", status.local?).to_s
end
def status_content_format(status) def status_content_format(status)
html_aware_format(status.text, status.local?, markdown: status.markdown, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : [])) html_aware_format(status.text, status.local?, markdown: status.markdown, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []))
end end

View file

@ -188,6 +188,7 @@ module LanguagesHelper
ISO_639_3 = { ISO_639_3 = {
ast: ['Asturian', 'Asturianu'].freeze, ast: ['Asturian', 'Asturianu'].freeze,
chr: ['Cherokee', 'ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ'].freeze,
ckb: ['Sorani (Kurdish)', 'سۆرانی'].freeze, ckb: ['Sorani (Kurdish)', 'سۆرانی'].freeze,
cnr: ['Montenegrin', 'crnogorski'].freeze, cnr: ['Montenegrin', 'crnogorski'].freeze,
jbo: ['Lojban', 'la .lojban.'].freeze, jbo: ['Lojban', 'la .lojban.'].freeze,
@ -200,6 +201,7 @@ module LanguagesHelper
smj: ['Lule Sami', 'Julevsámegiella'].freeze, smj: ['Lule Sami', 'Julevsámegiella'].freeze,
szl: ['Silesian', 'ślůnsko godka'].freeze, szl: ['Silesian', 'ślůnsko godka'].freeze,
tok: ['Toki Pona', 'toki pona'].freeze, tok: ['Toki Pona', 'toki pona'].freeze,
xal: ['Kalmyk', 'Хальмг келн'].freeze,
zba: ['Balaibalan', 'باليبلن'].freeze, zba: ['Balaibalan', 'باليبلن'].freeze,
zgh: ['Standard Moroccan Tamazight', 'ⵜⴰⵎⴰⵣⵉⵖⵜ'].freeze, zgh: ['Standard Moroccan Tamazight', 'ⵜⴰⵎⴰⵣⵉⵖⵜ'].freeze,
}.freeze }.freeze

View file

@ -0,0 +1,393 @@
import { bookmarkCategoryNeeded } from 'mastodon/initial_state';
import { makeGetStatus } from 'mastodon/selectors';
import api, { getLinks } from '../api';
import { importFetchedStatuses } from './importer';
import { unbookmark } from './interactions';
export const BOOKMARK_CATEGORY_FETCH_REQUEST = 'BOOKMARK_CATEGORY_FETCH_REQUEST';
export const BOOKMARK_CATEGORY_FETCH_SUCCESS = 'BOOKMARK_CATEGORY_FETCH_SUCCESS';
export const BOOKMARK_CATEGORY_FETCH_FAIL = 'BOOKMARK_CATEGORY_FETCH_FAIL';
export const BOOKMARK_CATEGORIES_FETCH_REQUEST = 'BOOKMARK_CATEGORIES_FETCH_REQUEST';
export const BOOKMARK_CATEGORIES_FETCH_SUCCESS = 'BOOKMARK_CATEGORIES_FETCH_SUCCESS';
export const BOOKMARK_CATEGORIES_FETCH_FAIL = 'BOOKMARK_CATEGORIES_FETCH_FAIL';
export const BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE = 'BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE';
export const BOOKMARK_CATEGORY_EDITOR_RESET = 'BOOKMARK_CATEGORY_EDITOR_RESET';
export const BOOKMARK_CATEGORY_EDITOR_SETUP = 'BOOKMARK_CATEGORY_EDITOR_SETUP';
export const BOOKMARK_CATEGORY_CREATE_REQUEST = 'BOOKMARK_CATEGORY_CREATE_REQUEST';
export const BOOKMARK_CATEGORY_CREATE_SUCCESS = 'BOOKMARK_CATEGORY_CREATE_SUCCESS';
export const BOOKMARK_CATEGORY_CREATE_FAIL = 'BOOKMARK_CATEGORY_CREATE_FAIL';
export const BOOKMARK_CATEGORY_UPDATE_REQUEST = 'BOOKMARK_CATEGORY_UPDATE_REQUEST';
export const BOOKMARK_CATEGORY_UPDATE_SUCCESS = 'BOOKMARK_CATEGORY_UPDATE_SUCCESS';
export const BOOKMARK_CATEGORY_UPDATE_FAIL = 'BOOKMARK_CATEGORY_UPDATE_FAIL';
export const BOOKMARK_CATEGORY_DELETE_REQUEST = 'BOOKMARK_CATEGORY_DELETE_REQUEST';
export const BOOKMARK_CATEGORY_DELETE_SUCCESS = 'BOOKMARK_CATEGORY_DELETE_SUCCESS';
export const BOOKMARK_CATEGORY_DELETE_FAIL = 'BOOKMARK_CATEGORY_DELETE_FAIL';
export const BOOKMARK_CATEGORY_STATUSES_FETCH_REQUEST = 'BOOKMARK_CATEGORY_STATUSES_FETCH_REQUEST';
export const BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS = 'BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS';
export const BOOKMARK_CATEGORY_STATUSES_FETCH_FAIL = 'BOOKMARK_CATEGORY_STATUSES_FETCH_FAIL';
export const BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST = 'BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST';
export const BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS = 'BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS';
export const BOOKMARK_CATEGORY_EDITOR_ADD_FAIL = 'BOOKMARK_CATEGORY_EDITOR_ADD_FAIL';
export const BOOKMARK_CATEGORY_EDITOR_REMOVE_REQUEST = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_REQUEST';
export const BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS';
export const BOOKMARK_CATEGORY_EDITOR_REMOVE_FAIL = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_FAIL';
export const BOOKMARK_CATEGORY_ADDER_RESET = 'BOOKMARK_CATEGORY_ADDER_RESET';
export const BOOKMARK_CATEGORY_ADDER_SETUP = 'BOOKMARK_CATEGORY_ADDER_SETUP';
export const BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST = 'BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST';
export const BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS = 'BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS';
export const BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL = 'BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL';
export const BOOKMARK_CATEGORY_STATUSES_EXPAND_REQUEST = 'BOOKMARK_CATEGORY_STATUSES_EXPAND_REQUEST';
export const BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS = 'BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS';
export const BOOKMARK_CATEGORY_STATUSES_EXPAND_FAIL = 'BOOKMARK_CATEGORY_STATUSES_EXPAND_FAIL';
export const fetchBookmarkCategory = id => (dispatch, getState) => {
if (getState().getIn(['bookmark_categories', id])) {
return;
}
dispatch(fetchBookmarkCategoryRequest(id));
api(getState).get(`/api/v1/bookmark_categories/${id}`)
.then(({ data }) => dispatch(fetchBookmarkCategorySuccess(data)))
.catch(err => dispatch(fetchBookmarkCategoryFail(id, err)));
};
export const fetchBookmarkCategoryRequest = id => ({
type: BOOKMARK_CATEGORY_FETCH_REQUEST,
id,
});
export const fetchBookmarkCategorySuccess = bookmarkCategory => ({
type: BOOKMARK_CATEGORY_FETCH_SUCCESS,
bookmarkCategory,
});
export const fetchBookmarkCategoryFail = (id, error) => ({
type: BOOKMARK_CATEGORY_FETCH_FAIL,
id,
error,
});
export const fetchBookmarkCategories = () => (dispatch, getState) => {
dispatch(fetchBookmarkCategoriesRequest());
api(getState).get('/api/v1/bookmark_categories')
.then(({ data }) => dispatch(fetchBookmarkCategoriesSuccess(data)))
.catch(err => dispatch(fetchBookmarkCategoriesFail(err)));
};
export const fetchBookmarkCategoriesRequest = () => ({
type: BOOKMARK_CATEGORIES_FETCH_REQUEST,
});
export const fetchBookmarkCategoriesSuccess = bookmarkCategories => ({
type: BOOKMARK_CATEGORIES_FETCH_SUCCESS,
bookmarkCategories,
});
export const fetchBookmarkCategoriesFail = error => ({
type: BOOKMARK_CATEGORIES_FETCH_FAIL,
error,
});
export const submitBookmarkCategoryEditor = shouldReset => (dispatch, getState) => {
const bookmarkCategoryId = getState().getIn(['bookmarkCategoryEditor', 'bookmarkCategoryId']);
const title = getState().getIn(['bookmarkCategoryEditor', 'title']);
if (bookmarkCategoryId === null) {
dispatch(createBookmarkCategory(title, shouldReset));
} else {
dispatch(updateBookmarkCategory(bookmarkCategoryId, title, shouldReset));
}
};
export const setupBookmarkCategoryEditor = bookmarkCategoryId => (dispatch, getState) => {
dispatch({
type: BOOKMARK_CATEGORY_EDITOR_SETUP,
bookmarkCategory: getState().getIn(['bookmark_categories', bookmarkCategoryId]),
});
dispatch(fetchBookmarkCategoryStatuses(bookmarkCategoryId));
};
export const changeBookmarkCategoryEditorTitle = value => ({
type: BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE,
value,
});
export const createBookmarkCategory = (title, shouldReset) => (dispatch, getState) => {
dispatch(createBookmarkCategoryRequest());
api(getState).post('/api/v1/bookmark_categories', { title }).then(({ data }) => {
dispatch(createBookmarkCategorySuccess(data));
if (shouldReset) {
dispatch(resetBookmarkCategoryEditor());
}
}).catch(err => dispatch(createBookmarkCategoryFail(err)));
};
export const createBookmarkCategoryRequest = () => ({
type: BOOKMARK_CATEGORY_CREATE_REQUEST,
});
export const createBookmarkCategorySuccess = bookmarkCategory => ({
type: BOOKMARK_CATEGORY_CREATE_SUCCESS,
bookmarkCategory,
});
export const createBookmarkCategoryFail = error => ({
type: BOOKMARK_CATEGORY_CREATE_FAIL,
error,
});
export const updateBookmarkCategory = (id, title, shouldReset) => (dispatch, getState) => {
dispatch(updateBookmarkCategoryRequest(id));
api(getState).put(`/api/v1/bookmark_categories/${id}`, { title }).then(({ data }) => {
dispatch(updateBookmarkCategorySuccess(data));
if (shouldReset) {
dispatch(resetBookmarkCategoryEditor());
}
}).catch(err => dispatch(updateBookmarkCategoryFail(id, err)));
};
export const updateBookmarkCategoryRequest = id => ({
type: BOOKMARK_CATEGORY_UPDATE_REQUEST,
id,
});
export const updateBookmarkCategorySuccess = bookmarkCategory => ({
type: BOOKMARK_CATEGORY_UPDATE_SUCCESS,
bookmarkCategory,
});
export const updateBookmarkCategoryFail = (id, error) => ({
type: BOOKMARK_CATEGORY_UPDATE_FAIL,
id,
error,
});
export const resetBookmarkCategoryEditor = () => ({
type: BOOKMARK_CATEGORY_EDITOR_RESET,
});
export const deleteBookmarkCategory = id => (dispatch, getState) => {
dispatch(deleteBookmarkCategoryRequest(id));
api(getState).delete(`/api/v1/bookmark_categories/${id}`)
.then(() => dispatch(deleteBookmarkCategorySuccess(id)))
.catch(err => dispatch(deleteBookmarkCategoryFail(id, err)));
};
export const deleteBookmarkCategoryRequest = id => ({
type: BOOKMARK_CATEGORY_DELETE_REQUEST,
id,
});
export const deleteBookmarkCategorySuccess = id => ({
type: BOOKMARK_CATEGORY_DELETE_SUCCESS,
id,
});
export const deleteBookmarkCategoryFail = (id, error) => ({
type: BOOKMARK_CATEGORY_DELETE_FAIL,
id,
error,
});
export const fetchBookmarkCategoryStatuses = bookmarkCategoryId => (dispatch, getState) => {
dispatch(fetchBookmarkCategoryStatusesRequest(bookmarkCategoryId));
api(getState).get(`/api/v1/bookmark_categories/${bookmarkCategoryId}/statuses`, { params: { limit: 0 } }).then(({ data }) => {
dispatch(importFetchedStatuses(data));
dispatch(fetchBookmarkCategoryStatusesSuccess(bookmarkCategoryId, data));
}).catch(err => dispatch(fetchBookmarkCategoryStatusesFail(bookmarkCategoryId, err)));
};
export const fetchBookmarkCategoryStatusesRequest = id => ({
type: BOOKMARK_CATEGORY_STATUSES_FETCH_REQUEST,
id,
});
export const fetchBookmarkCategoryStatusesSuccess = (id, statuses, next) => ({
type: BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS,
id,
statuses,
next,
});
export const fetchBookmarkCategoryStatusesFail = (id, error) => ({
type: BOOKMARK_CATEGORY_STATUSES_FETCH_FAIL,
id,
error,
});
export const addToBookmarkCategory = (bookmarkCategoryId, statusId) => (dispatch, getState) => {
dispatch(addToBookmarkCategoryRequest(bookmarkCategoryId, statusId));
api(getState).post(`/api/v1/bookmark_categories/${bookmarkCategoryId}/statuses`, { status_ids: [statusId] })
.then(() => dispatch(addToBookmarkCategorySuccess(bookmarkCategoryId, statusId)))
.catch(err => dispatch(addToBookmarkCategoryFail(bookmarkCategoryId, statusId, err)));
};
export const addToBookmarkCategoryRequest = (bookmarkCategoryId, statusId) => ({
type: BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST,
bookmarkCategoryId,
statusId,
});
export const addToBookmarkCategorySuccess = (bookmarkCategoryId, statusId) => ({
type: BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS,
bookmarkCategoryId,
statusId,
});
export const addToBookmarkCategoryFail = (bookmarkCategoryId, statusId, error) => ({
type: BOOKMARK_CATEGORY_EDITOR_ADD_FAIL,
bookmarkCategoryId,
statusId,
error,
});
export const removeFromBookmarkCategory = (bookmarkCategoryId, statusId) => (dispatch, getState) => {
dispatch(removeFromBookmarkCategoryRequest(bookmarkCategoryId, statusId));
api(getState).delete(`/api/v1/bookmark_categories/${bookmarkCategoryId}/statuses`, { params: { status_ids: [statusId] } })
.then(() => dispatch(removeFromBookmarkCategorySuccess(bookmarkCategoryId, statusId)))
.catch(err => dispatch(removeFromBookmarkCategoryFail(bookmarkCategoryId, statusId, err)));
};
export const removeFromBookmarkCategoryRequest = (bookmarkCategoryId, statusId) => ({
type: BOOKMARK_CATEGORY_EDITOR_REMOVE_REQUEST,
bookmarkCategoryId,
statusId,
});
export const removeFromBookmarkCategorySuccess = (bookmarkCategoryId, statusId) => ({
type: BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS,
bookmarkCategoryId,
statusId,
});
export const removeFromBookmarkCategoryFail = (bookmarkCategoryId, statusId, error) => ({
type: BOOKMARK_CATEGORY_EDITOR_REMOVE_FAIL,
bookmarkCategoryId,
statusId,
error,
});
export const resetBookmarkCategoryAdder = () => ({
type: BOOKMARK_CATEGORY_ADDER_RESET,
});
export const setupBookmarkCategoryAdder = statusId => (dispatch, getState) => {
dispatch({
type: BOOKMARK_CATEGORY_ADDER_SETUP,
status: getState().getIn(['statuses', statusId]),
});
dispatch(fetchBookmarkCategories());
dispatch(fetchStatusBookmarkCategories(statusId));
};
export const fetchStatusBookmarkCategories = statusId => (dispatch, getState) => {
dispatch(fetchStatusBookmarkCategoriesRequest(statusId));
api(getState).get(`/api/v1/statuses/${statusId}/bookmark_categories`)
.then(({ data }) => dispatch(fetchStatusBookmarkCategoriesSuccess(statusId, data)))
.catch(err => dispatch(fetchStatusBookmarkCategoriesFail(statusId, err)));
};
export const fetchStatusBookmarkCategoriesRequest = id => ({
type:BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST,
id,
});
export const fetchStatusBookmarkCategoriesSuccess = (id, bookmarkCategories) => ({
type: BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS,
id,
bookmarkCategories,
});
export const fetchStatusBookmarkCategoriesFail = (id, err) => ({
type: BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL,
id,
err,
});
export const addToBookmarkCategoryAdder = bookmarkCategoryId => (dispatch, getState) => {
dispatch(addToBookmarkCategory(bookmarkCategoryId, getState().getIn(['bookmarkCategoryAdder', 'statusId'])));
};
export const removeFromBookmarkCategoryAdder = bookmarkCategoryId => (dispatch, getState) => {
if (bookmarkCategoryNeeded) {
const categories = getState().getIn(['bookmarkCategoryAdder', 'bookmarkCategories', 'items']);
if (categories && categories.count() <= 1) {
const status = makeGetStatus()(getState(), { id: getState().getIn(['bookmarkCategoryAdder', 'statusId']) });
dispatch(unbookmark(status));
} else {
dispatch(removeFromBookmarkCategory(bookmarkCategoryId, getState().getIn(['bookmarkCategoryAdder', 'statusId'])));
}
} else {
dispatch(removeFromBookmarkCategory(bookmarkCategoryId, getState().getIn(['bookmarkCategoryAdder', 'statusId'])));
}
};
export function expandBookmarkCategoryStatuses(bookmarkCategoryId) {
return (dispatch, getState) => {
const url = getState().getIn(['bookmark_categories', bookmarkCategoryId, 'next'], null);
if (url === null || getState().getIn(['bookmark_categories', bookmarkCategoryId, 'isLoading'])) {
return;
}
dispatch(expandBookmarkCategoryStatusesRequest(bookmarkCategoryId));
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(expandBookmarkCategoryStatusesSuccess(bookmarkCategoryId, response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandBookmarkCategoryStatusesFail(bookmarkCategoryId, error));
});
};
}
export function expandBookmarkCategoryStatusesRequest(id) {
return {
type: BOOKMARK_CATEGORY_STATUSES_EXPAND_REQUEST,
id,
};
}
export function expandBookmarkCategoryStatusesSuccess(id, statuses, next) {
return {
type: BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS,
id,
statuses,
next,
};
}
export function expandBookmarkCategoryStatusesFail(id, error) {
return {
type: BOOKMARK_CATEGORY_STATUSES_EXPAND_FAIL,
id,
error,
};
}

View file

@ -90,6 +90,7 @@ const messages = defineMessages({
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
open: { id: 'compose.published.open', defaultMessage: 'Open' }, open: { id: 'compose.published.open', defaultMessage: 'Open' },
published: { id: 'compose.published.body', defaultMessage: 'Post published.' }, published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
}); });
export const ensureComposeIsVisible = (getState, routerHistory) => { export const ensureComposeIsVisible = (getState, routerHistory) => {
@ -253,7 +254,7 @@ export function submitCompose(routerHistory) {
} }
dispatch(showAlert({ dispatch(showAlert({
message: messages.published, message: statusId === null ? messages.published : messages.saved,
action: messages.open, action: messages.open,
dismissAfter: 10000, dismissAfter: 10000,
onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`), onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`),

View file

@ -105,6 +105,21 @@ describe('computeHashtagBarForStatus', () => {
); );
}); });
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', () => { it('does not display in bar a hashtag in content with a case difference', () => {
const status = createStatus( const status = createStatus(
'<p>Text <a href="test">#Éaa</a></p><p><a href="test">#éaa</a></p>', '<p>Text <a href="test">#Éaa</a></p><p><a href="test">#éaa</a></p>',

View file

@ -16,7 +16,19 @@ export default class Column extends PureComponent {
}; };
scrollTop () { scrollTop () {
const scrollable = this.props.bindToDocument ? document.scrollingElement : this.node.querySelector('.scrollable'); let scrollable = null;
if (this.props.bindToDocument) {
scrollable = document.scrollingElement;
} else {
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) {
return; return;

View file

@ -23,8 +23,9 @@ export type StatusLike = Record<{
}>; }>;
function normalizeHashtag(hashtag: string) { function normalizeHashtag(hashtag: string) {
if (hashtag && hashtag.startsWith('#')) return hashtag.slice(1); return (
else return hashtag; hashtag && hashtag.startsWith('#') ? hashtag.slice(1) : hashtag
).normalize('NFKC');
} }
function isNodeLinkHashtag(element: Node): element is HTMLLinkElement { function isNodeLinkHashtag(element: Node): element is HTMLLinkElement {
@ -70,9 +71,16 @@ function uniqueHashtagsWithCaseHandling(hashtags: string[]) {
} }
// Create the collator once, this is much more efficient // Create the collator once, this is much more efficient
const collator = new Intl.Collator(undefined, { sensitivity: 'accent' }); 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) { function localeAwareInclude(collection: string[], value: string) {
return collection.find((item) => collator.compare(item, value) === 0); 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 // We use an intermediate function here to make it easier to test
@ -121,11 +129,13 @@ export function computeHashtagBarForStatus(status: StatusLike): {
// try to see if the last line is only hashtags // try to see if the last line is only hashtags
let onlyHashtags = true; let onlyHashtags = true;
const normalizedTagNames = tagNames.map((tag) => tag.normalize('NFKC'));
Array.from(lastChild.childNodes).forEach((node) => { Array.from(lastChild.childNodes).forEach((node) => {
if (isNodeLinkHashtag(node) && node.textContent) { if (isNodeLinkHashtag(node) && node.textContent) {
const normalized = normalizeHashtag(node.textContent); const normalized = normalizeHashtag(node.textContent);
if (!localeAwareInclude(tagNames, normalized)) { if (!localeAwareInclude(normalizedTagNames, normalized)) {
// stop here, this is not a real hashtag, so consider it as text // stop here, this is not a real hashtag, so consider it as text
onlyHashtags = false; onlyHashtags = false;
return; return;
@ -140,12 +150,14 @@ export function computeHashtagBarForStatus(status: StatusLike): {
} }
}); });
const hashtagsInBar = tagNames.filter( const hashtagsInBar = tagNames.filter((tag) => {
(tag) => const normalizedTag = tag.normalize('NFKC');
// the tag does not appear at all in the status content, it is an out-of-band tag // the tag does not appear at all in the status content, it is an out-of-band tag
!localeAwareInclude(contentHashtags, tag) && return (
!localeAwareInclude(lastLineHashtags, tag), !localeAwareInclude(contentHashtags, normalizedTag) &&
); !localeAwareInclude(lastLineHashtags, normalizedTag)
);
});
const isOnlyOneLine = contentWithoutLastLine.content.childElementCount === 0; const isOnlyOneLine = contentWithoutLastLine.content.childElementCount === 0;
const hasMedia = status.get('media_attachments').size > 0; const hasMedia = status.get('media_attachments').size > 0;
@ -204,7 +216,7 @@ const HashtagBar: React.FC<{
<div className='hashtag-bar'> <div className='hashtag-bar'>
{revealedHashtags.map((hashtag) => ( {revealedHashtags.map((hashtag) => (
<Link key={hashtag} to={`/tags/${hashtag}`}> <Link key={hashtag} to={`/tags/${hashtag}`}>
#{hashtag} #<span>{hashtag}</span>
</Link> </Link>
))} ))}

View file

@ -583,10 +583,11 @@ class Status extends ImmutablePureComponent {
} }
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
const expanded = !status.get('hidden')
return ( return (
<HotKeys handlers={handlers}> <HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility_ex')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}> <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility_ex')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
{prepend} {prepend}
<div className={classNames('status', `status-${status.get('visibility_ex')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}> <div className={classNames('status', `status-${status.get('visibility_ex')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}>
@ -611,7 +612,7 @@ class Status extends ImmutablePureComponent {
<StatusContent <StatusContent
status={status} status={status}
onClick={this.handleClick} onClick={this.handleClick}
expanded={!status.get('hidden')} expanded={expanded}
onExpandedToggle={this.handleExpandedToggle} onExpandedToggle={this.handleExpandedToggle}
onTranslate={this.handleTranslate} onTranslate={this.handleTranslate}
collapsible collapsible
@ -621,7 +622,7 @@ class Status extends ImmutablePureComponent {
{(!isCardMediaWithSensitive || !status.get('hidden')) && media} {(!isCardMediaWithSensitive || !status.get('hidden')) && media}
{hashtagBar} {expanded && hashtagBar}
{emojiReactionsBar} {emojiReactionsBar}

View file

@ -12,7 +12,7 @@ import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/
import DropdownMenuContainer from '../containers/dropdown_menu_container'; import DropdownMenuContainer from '../containers/dropdown_menu_container';
import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container'; import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container';
import { me } from '../initial_state'; import { bookmarkCategoryNeeded, me } from '../initial_state';
import { IconButton } from './icon_button'; import { IconButton } from './icon_button';
@ -37,6 +37,7 @@ const messages = defineMessages({
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' }, favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
emojiReaction: { id: 'status.emoji_reaction', defaultMessage: 'Stamp' }, emojiReaction: { id: 'status.emoji_reaction', defaultMessage: 'Stamp' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
bookmarkCategory: { id: 'status.bookmark_category', defaultMessage: 'Bookmark category' },
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' }, removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
open: { id: 'status.open', defaultMessage: 'Expand this status' }, open: { id: 'status.open', defaultMessage: 'Expand this status' },
report: { id: 'status.report', defaultMessage: 'Report @{name}' }, report: { id: 'status.report', defaultMessage: 'Report @{name}' },
@ -92,6 +93,7 @@ class StatusActionBar extends ImmutablePureComponent {
onMuteConversation: PropTypes.func, onMuteConversation: PropTypes.func,
onPin: PropTypes.func, onPin: PropTypes.func,
onBookmark: PropTypes.func, onBookmark: PropTypes.func,
onBookmarkCategoryAdder: PropTypes.func,
onFilter: PropTypes.func, onFilter: PropTypes.func,
onAddFilter: PropTypes.func, onAddFilter: PropTypes.func,
onInteractionModal: PropTypes.func, onInteractionModal: PropTypes.func,
@ -164,6 +166,18 @@ class StatusActionBar extends ImmutablePureComponent {
}; };
handleBookmarkClick = () => { handleBookmarkClick = () => {
if (bookmarkCategoryNeeded) {
this.handleBookmarkCategoryAdderClick();
} else {
this.props.onBookmark(this.props.status);
}
};
handleBookmarkCategoryAdderClick = () => {
this.props.onBookmarkCategoryAdder(this.props.status);
};
handleBookmarkClickOriginal = () => {
this.props.onBookmark(this.props.status); this.props.onBookmark(this.props.status);
}; };
@ -299,7 +313,8 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.reference), action: this.handleReference }); menu.push({ text: intl.formatMessage(messages.reference), action: this.handleReference });
} }
menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick }); menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClickOriginal });
menu.push({ text: intl.formatMessage(messages.bookmarkCategory), action: this.handleBookmarkCategoryAdderClick });
if (writtenByMe && pinnableStatus) { if (writtenByMe && pinnableStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });

View file

@ -241,7 +241,7 @@ class StatusContent extends PureComponent {
const renderReadMore = this.props.onClick && status.get('collapsed'); const renderReadMore = this.props.onClick && status.get('collapsed');
const contentLocale = intl.locale.replace(/[_-].*/, ''); const contentLocale = intl.locale.replace(/[_-].*/, '');
const targetLanguages = this.props.languages?.get(status.get('language') || 'und'); const targetLanguages = this.props.languages?.get(status.get('language') || 'und');
const renderTranslate = this.props.onTranslate && this.context.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('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 && status.get('language') && targetLanguages?.includes(contentLocale);
const content = { __html: statusContent ?? getStatusContent(status) }; 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') };

View file

@ -142,6 +142,15 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
} }
}, },
onBookmarkCategoryAdder (status) {
dispatch(openModal({
modalType: 'BOOKMARK_CATEGORY_ADDER',
modalProps: {
statusId: status.get('id'),
},
}));
},
onPin (status) { onPin (status) {
if (status.get('pinned')) { if (status.get('pinned')) {
dispatch(unpin(status)); dispatch(unpin(status));

View file

@ -0,0 +1,80 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { changeBookmarkCategoryEditorTitle, submitBookmarkCategoryEditor } from 'mastodon/actions/bookmark_categories';
import Button from 'mastodon/components/button';
const messages = defineMessages({
label: { id: 'bookmark_categories.new.title_placeholder', defaultMessage: 'New category title' },
title: { id: 'bookmark_categories.new.create', defaultMessage: 'Add category' },
});
const mapStateToProps = state => ({
value: state.getIn(['bookmarkCategoryEditor', 'title']),
disabled: state.getIn(['bookmarkCategoryEditor', 'isSubmitting']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeBookmarkCategoryEditorTitle(value)),
onSubmit: () => dispatch(submitBookmarkCategoryEditor(true)),
});
class NewBookmarkCategoryForm extends PureComponent {
static propTypes = {
value: PropTypes.string.isRequired,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
};
handleSubmit = e => {
e.preventDefault();
this.props.onSubmit();
};
handleClick = () => {
this.props.onSubmit();
};
render () {
const { value, disabled, intl } = this.props;
const label = intl.formatMessage(messages.label);
const title = intl.formatMessage(messages.title);
return (
<form className='column-inline-form' onSubmit={this.handleSubmit}>
<label>
<span style={{ display: 'none' }}>{label}</span>
<input
className='setting-text'
value={value}
disabled={disabled}
onChange={this.handleChange}
placeholder={label}
/>
</label>
<Button
disabled={disabled || !value}
text={title}
onClick={this.handleClick}
/>
</form>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(NewBookmarkCategoryForm));

View file

@ -0,0 +1,95 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchBookmarkCategories } from 'mastodon/actions/bookmark_categories';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list';
import ColumnLink from 'mastodon/features/ui/components/column_link';
import ColumnSubheading from 'mastodon/features/ui/components/column_subheading';
import NewListForm from './components/new_bookmark_category_form';
const messages = defineMessages({
heading: { id: 'column.bookmark_categories', defaultMessage: 'Bookmark categories' },
subheading: { id: 'bookmark_categories.subheading', defaultMessage: 'Your categories' },
allBookmarks: { id: 'bookmark_categories.all_bookmarks', defaultMessage: 'All bookmarks' },
});
const getOrderedCategories = createSelector([state => state.get('bookmark_categories')], categories => {
if (!categories) {
return categories;
}
return categories.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
categories: getOrderedCategories(state),
});
class BookmarkCategories extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
categories: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchBookmarkCategories());
}
render () {
const { intl, categories, multiColumn } = this.props;
if (!categories) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.bookmark_categories' defaultMessage="You don't have any categories yet. When you create one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.heading)}>
<ColumnHeader title={intl.formatMessage(messages.heading)} icon='list-ul' multiColumn={multiColumn} />
<NewListForm />
<ColumnLink to='/bookmarks' icon='bookmark' text={intl.formatMessage(messages.allBookmarks)} />,
<ScrollableList
scrollKey='bookmark_categories'
emptyMessage={emptyMessage}
prepend={<ColumnSubheading text={intl.formatMessage(messages.subheading)} />}
bindToDocument={!multiColumn}
>
{categories.map(category =>
<ColumnLink key={category.get('id')} to={`/bookmark_categories/${category.get('id')}`} icon='bookmark' text={category.get('title')} />,
)}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(BookmarkCategories));

View file

@ -0,0 +1,43 @@
import { injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import { makeGetAccount } from '../../../selectors';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId }) => ({
account: getAccount(state, accountId),
});
return mapStateToProps;
};
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
};
render () {
const { account } = this.props;
return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</div>
</div>
</div>
);
}
}
export default connect(makeMapStateToProps)(injectIntl(Account));

View file

@ -0,0 +1,72 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { Icon } from 'mastodon/components/icon';
import { removeFromBookmarkCategoryAdder, addToBookmarkCategoryAdder } from '../../../actions/bookmark_categories';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
remove: { id: 'bookmark_categories.status.remove', defaultMessage: 'Remove from bookmark category' },
add: { id: 'bookmark_categories.status.add', defaultMessage: 'Add to bookmark category' },
});
const MapStateToProps = (state, { bookmarkCategoryId, added }) => ({
bookmarkCategory: state.get('bookmark_categories').get(bookmarkCategoryId),
added: typeof added === 'undefined' ? state.getIn(['bookmarkCategoryAdder', 'bookmarkCategories', 'items']).includes(bookmarkCategoryId) : added,
});
const mapDispatchToProps = (dispatch, { bookmarkCategoryId }) => ({
onRemove: () => dispatch(removeFromBookmarkCategoryAdder(bookmarkCategoryId)),
onAdd: () => dispatch(addToBookmarkCategoryAdder(bookmarkCategoryId)),
});
class BookmarkCategory extends ImmutablePureComponent {
static propTypes = {
bookmarkCategory: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
added: PropTypes.bool,
};
static defaultProps = {
added: false,
};
render () {
const { bookmarkCategory, intl, onRemove, onAdd, added } = this.props;
let button;
if (added) {
button = <IconButton icon='times' title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
} else {
button = <IconButton icon='plus' title={intl.formatMessage(messages.add)} onClick={onAdd} />;
}
return (
<div className='list'>
<div className='list__wrapper'>
<div className='list__display-name'>
<Icon id='user-bookmarkCategory' className='column-link__icon' fixedWidth />
{bookmarkCategory.get('title')}
</div>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}
export default connect(MapStateToProps, mapDispatchToProps)(injectIntl(BookmarkCategory));

View file

@ -0,0 +1,77 @@
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setupBookmarkCategoryAdder, resetBookmarkCategoryAdder } from '../../actions/bookmark_categories';
import NewBookmarkCategoryForm from '../bookmark_categories/components/new_bookmark_category_form';
// import Account from './components/account';
import BookmarkCategory from './components/bookmark_category';
const getOrderedBookmarkCategories = createSelector([state => state.get('bookmark_categories')], bookmarkCategories => {
if (!bookmarkCategories) {
return bookmarkCategories;
}
return bookmarkCategories.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
bookmarkCategoryIds: getOrderedBookmarkCategories(state).map(bookmarkCategory=>bookmarkCategory.get('id')),
});
const mapDispatchToProps = dispatch => ({
onInitialize: statusId => dispatch(setupBookmarkCategoryAdder(statusId)),
onReset: () => dispatch(resetBookmarkCategoryAdder()),
});
class BookmarkCategoryAdder extends ImmutablePureComponent {
static propTypes = {
statusId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
bookmarkCategoryIds: ImmutablePropTypes.list.isRequired,
};
componentDidMount () {
const { onInitialize, statusId } = this.props;
onInitialize(statusId);
}
componentWillUnmount () {
const { onReset } = this.props;
onReset();
}
render () {
const { bookmarkCategoryIds } = this.props;
return (
<div className='modal-root__modal list-adder'>
{/*
<div className='list-adder__account'>
<Account accountId={accountId} />
</div>
*/}
<NewBookmarkCategoryForm />
<div className='list-adder__lists'>
{bookmarkCategoryIds.map(BookmarkCategoryId => <BookmarkCategory key={BookmarkCategoryId} bookmarkCategoryId={BookmarkCategoryId} />)}
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(BookmarkCategoryAdder));

View file

@ -0,0 +1,73 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { changeBookmarkCategoryEditorTitle, submitBookmarkCategoryEditor } from '../../../actions/bookmark_categories';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
title: { id: 'bookmark_categories.edit.submit', defaultMessage: 'Change title' },
});
const mapStateToProps = state => ({
value: state.getIn(['bookmarkCategoryEditor', 'title']),
disabled: !state.getIn(['bookmarkCategoryEditor', 'isChanged']) || !state.getIn(['bookmarkCategoryEditor', 'title']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeBookmarkCategoryEditorTitle(value)),
onSubmit: () => dispatch(submitBookmarkCategoryEditor(false)),
});
class EditBookmarkCategoryForm extends PureComponent {
static propTypes = {
value: PropTypes.string.isRequired,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
};
handleSubmit = e => {
e.preventDefault();
this.props.onSubmit();
};
handleClick = () => {
this.props.onSubmit();
};
render () {
const { value, disabled, intl } = this.props;
const title = intl.formatMessage(messages.title);
return (
<form className='column-inline-form' onSubmit={this.handleSubmit}>
<input
className='setting-text'
value={value}
onChange={this.handleChange}
/>
<IconButton
disabled={disabled}
icon='check'
title={title}
onClick={this.handleClick}
/>
</form>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(EditBookmarkCategoryForm));

View file

@ -0,0 +1,187 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { deleteBookmarkCategory, expandBookmarkCategoryStatuses, fetchBookmarkCategory, fetchBookmarkCategoryStatuses , setupBookmarkCategoryEditor } from 'mastodon/actions/bookmark_categories';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { openModal } from 'mastodon/actions/modal';
import ColumnHeader from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import StatusList from 'mastodon/components/status_list';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import Column from 'mastodon/features/ui/components/column';
import { getBookmarkCategoryStatusList } from 'mastodon/selectors';
import EditBookmarkCategoryForm from './components/edit_bookmark_category_form';
const messages = defineMessages({
deleteMessage: { id: 'confirmations.delete_bookmary_category.message', defaultMessage: 'Are you sure you want to permanently delete this category?' },
deleteConfirm: { id: 'confirmations.delete_bookmark_category.confirm', defaultMessage: 'Delete' },
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
});
const mapStateToProps = (state, { params }) => ({
bookmarkCategory: state.getIn(['bookmark_categories', params.id]),
statusIds: getBookmarkCategoryStatusList(state, params.id),
isLoading: state.getIn(['bookmark_categories', params.id, 'isLoading'], true),
isEditing: state.getIn(['bookmarkCategoryEditor', 'bookmarkCategoryId']) === params.id,
hasMore: !!state.getIn(['bookmark_categories', params.id, 'next']),
});
class BookmarkCategoryStatuses extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
bookmarkCategory: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]),
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
isEditing: PropTypes.bool,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchBookmarkCategory(this.props.params.id));
this.props.dispatch(fetchBookmarkCategoryStatuses(this.props.params.id));
}
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('BOOKMARKS_EX', {}));
}
};
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
};
handleHeaderClick = () => {
this.column.scrollTop();
};
handleEditClick = () => {
this.props.dispatch(setupBookmarkCategoryEditor(this.props.params.id));
};
handleDeleteClick = () => {
const { dispatch, columnId, intl } = this.props;
const { id } = this.props.params;
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => {
dispatch(deleteBookmarkCategory(id));
if (columnId) {
dispatch(removeColumn(columnId));
} else {
this.context.router.history.push('/bookmark_categories');
}
},
},
}));
};
setRef = c => {
this.column = c;
};
handleLoadMore = debounce(() => {
this.props.dispatch(expandBookmarkCategoryStatuses());
}, 300, { leading: true });
render () {
const { intl, bookmarkCategory, statusIds, columnId, multiColumn, hasMore, isLoading, isEditing } = this.props;
const pinned = !!columnId;
if (typeof bookmarkCategory === 'undefined') {
return (
<Column>
<div className='scrollable'>
<LoadingIndicator />
</div>
</Column>
);
} else if (bookmarkCategory === false) {
return (
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
);
}
const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here." />;
const editor = isEditing && (
<EditBookmarkCategoryForm />
);
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
<ColumnHeader
icon='bookmark'
title={bookmarkCategory.get('title')}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<div className='column-settings__row column-header__links'>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditClick}>
<Icon id='pencil' /> <FormattedMessage id='bookmark_categories.edit' defaultMessage='Edit category' />
</button>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
<Icon id='trash' /> <FormattedMessage id='bookmark_categories.delete' defaultMessage='Delete category' />
</button>
{editor}
</div>
</ColumnHeader>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`bookmark_ex_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(BookmarkCategoryStatuses));

View file

@ -108,10 +108,10 @@ class Results extends PureComponent {
return ( return (
<> <>
<div className='account__section-headline'> <div className='account__section-headline'>
<button onClick={this.handleSelectAll} className={type === 'all' && 'active'}><FormattedMessage id='search_results.all' defaultMessage='All' /></button> <button onClick={this.handleSelectAll} className={type === 'all' ? 'active' : undefined}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
<button onClick={this.handleSelectAccounts} className={type === 'accounts' && 'active'}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button> <button onClick={this.handleSelectAccounts} className={type === 'accounts' ? 'active' : undefined}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button>
<button onClick={this.handleSelectHashtags} className={type === 'hashtags' && 'active'}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button> <button onClick={this.handleSelectHashtags} className={type === 'hashtags' ? 'active' : undefined}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
<button onClick={this.handleSelectStatuses} className={type === 'statuses' && 'active'}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button> <button onClick={this.handleSelectStatuses} className={type === 'statuses' ? 'active' : undefined}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
</div> </div>
<div className='explore__search-results'> <div className='explore__search-results'>

View file

@ -147,7 +147,7 @@ class ListTimeline extends PureComponent {
handleEditAntennaClick = (e) => { handleEditAntennaClick = (e) => {
const id = e.currentTarget.getAttribute('data-id'); const id = e.currentTarget.getAttribute('data-id');
this.context.router.history.push(`/antennasw/${id}/edit`); this.context.router.history.push(`/antennasw/${id}/edit`);
} };
handleRepliesPolicyChange = ({ target }) => { handleRepliesPolicyChange = ({ target }) => {
const { dispatch } = this.props; const { dispatch } = this.props;

View file

@ -12,7 +12,7 @@ import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/
import { IconButton } from '../../../components/icon_button'; import { IconButton } from '../../../components/icon_button';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { me } from '../../../initial_state'; import { bookmarkCategoryNeeded, me } from '../../../initial_state';
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container'; import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
const messages = defineMessages({ const messages = defineMessages({
@ -72,6 +72,7 @@ class ActionBar extends PureComponent {
onEmojiReact: PropTypes.func.isRequired, onEmojiReact: PropTypes.func.isRequired,
onReference: PropTypes.func.isRequired, onReference: PropTypes.func.isRequired,
onBookmark: PropTypes.func.isRequired, onBookmark: PropTypes.func.isRequired,
onBookmarkCategoryAdder: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired,
onEdit: PropTypes.func.isRequired, onEdit: PropTypes.func.isRequired,
onDirect: PropTypes.func.isRequired, onDirect: PropTypes.func.isRequired,
@ -106,7 +107,11 @@ class ActionBar extends PureComponent {
}; };
handleBookmarkClick = (e) => { handleBookmarkClick = (e) => {
this.props.onBookmark(this.props.status, e); if (bookmarkCategoryNeeded) {
this.props.onBookmarkCategoryAdder(this.props.status);
} else {
this.props.onBookmark(this.props.status, e);
}
}; };
handleDeleteClick = () => { handleDeleteClick = () => {

View file

@ -372,6 +372,7 @@ class DetailedStatus extends ImmutablePureComponent {
} }
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
const expanded = !status.get('hidden')
return ( return (
<div style={outerStyle}> <div style={outerStyle}>
@ -397,7 +398,7 @@ class DetailedStatus extends ImmutablePureComponent {
{(!isCardMediaWithSensitive || !status.get('hidden')) && media} {(!isCardMediaWithSensitive || !status.get('hidden')) && media}
{hashtagBar} {expanded && hashtagBar}
{emojiReactionsBar} {emojiReactionsBar}

View file

@ -63,7 +63,7 @@ import {
import ColumnHeader from '../../components/column_header'; import ColumnHeader from '../../components/column_header';
import { textForScreenReader, defaultMediaVisibility } from '../../components/status'; import { textForScreenReader, defaultMediaVisibility } from '../../components/status';
import StatusContainer from '../../containers/status_container'; import StatusContainer from '../../containers/status_container';
import { boostModal, deleteModal } from '../../initial_state'; import { bookmarkCategoryNeeded, boostModal, deleteModal } from '../../initial_state';
import { makeGetStatus, makeGetPictureInPicture } from '../../selectors'; import { makeGetStatus, makeGetPictureInPicture } from '../../selectors';
import Column from '../ui/components/column'; import Column from '../ui/components/column';
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
@ -367,6 +367,11 @@ class Status extends ImmutablePureComponent {
}; };
handleBookmarkClick = (status) => { handleBookmarkClick = (status) => {
if (bookmarkCategoryNeeded) {
this.handleBookmarkCategoryAdderClick(status);
return;
}
if (status.get('bookmarked')) { if (status.get('bookmarked')) {
this.props.dispatch(unbookmark(status)); this.props.dispatch(unbookmark(status));
} else { } else {
@ -374,6 +379,15 @@ class Status extends ImmutablePureComponent {
} }
}; };
handleBookmarkCategoryAdderClick = (status) => {
this.props.dispatch(openModal({
modalType: 'BOOKMARK_CATEGORY_ADDER',
modalProps: {
statusId: status.get('id'),
},
}));
};
handleDeleteClick = (status, history, withRedraft = false) => { handleDeleteClick = (status, history, withRedraft = false) => {
const { dispatch, intl } = this.props; const { dispatch, intl } = this.props;
@ -609,7 +623,7 @@ class Status extends ImmutablePureComponent {
onMoveUp={this.handleMoveUp} onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown} onMoveDown={this.handleMoveDown}
contextType='thread' contextType='thread'
previousId={i > 0 && list.get(i - 1)} previousId={i > 0 ? list.get(i - 1) : undefined}
nextId={list.get(i + 1) || (ancestors && statusId)} nextId={list.get(i + 1) || (ancestors && statusId)}
rootId={statusId} rootId={statusId}
/> />
@ -737,6 +751,7 @@ class Status extends ImmutablePureComponent {
onReblogForceModal={this.handleReblogForceModalClick} onReblogForceModal={this.handleReblogForceModalClick}
onReference={this.handleReference} onReference={this.handleReference}
onBookmark={this.handleBookmarkClick} onBookmark={this.handleBookmarkClick}
onBookmarkCategoryAdder={this.handleBookmarkCategoryAdderClick}
onDelete={this.handleDeleteClick} onDelete={this.handleDeleteClick}
onEdit={this.handleEditClick} onEdit={this.handleEditClick}
onDirect={this.handleDirectClick} onDirect={this.handleDirectClick}

View file

@ -15,6 +15,7 @@ import {
AntennaAdder, AntennaAdder,
CircleEditor, CircleEditor,
CircleAdder, CircleAdder,
BookmarkCategoryAdder,
CompareHistoryModal, CompareHistoryModal,
FilterModal, FilterModal,
InteractionModal, InteractionModal,
@ -55,6 +56,7 @@ export const MODAL_COMPONENTS = {
'LIST_ADDER': ListAdder, 'LIST_ADDER': ListAdder,
'ANTENNA_ADDER': AntennaAdder, 'ANTENNA_ADDER': AntennaAdder,
'CIRCLE_ADDER': CircleAdder, 'CIRCLE_ADDER': CircleAdder,
'BOOKMARK_CATEGORY_ADDER': BookmarkCategoryAdder,
'COMPARE_HISTORY': CompareHistoryModal, 'COMPARE_HISTORY': CompareHistoryModal,
'FILTER': FilterModal, 'FILTER': FilterModal,
'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal, 'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal,
@ -123,7 +125,10 @@ export default class ModalRoot extends PureComponent {
{visible && ( {visible && (
<> <>
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}> <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
{(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />} {(SpecificComponent) => {
const ref = typeof SpecificComponent !== 'function' ? this.setModalRef : undefined;
return <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={ref} />
}}
</BundleContainer> </BundleContainer>
<Helmet> <Helmet>

View file

@ -117,7 +117,7 @@ class NavigationPanel extends Component {
{signedIn && ( {signedIn && (
<> <>
<ColumnLink transparent to='/bookmarks' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} /> <ColumnLink transparent to='/bookmark_categories' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} />
<ColumnLink transparent to='/favourites' icon='star' text={intl.formatMessage(messages.favourites)} /> <ColumnLink transparent to='/favourites' icon='star' text={intl.formatMessage(messages.favourites)} />
<hr /> <hr />

View file

@ -62,7 +62,7 @@ class ReportModal extends ImmutablePureComponent {
dispatch(submitReport({ dispatch(submitReport({
account_id: accountId, account_id: accountId,
status_ids: selectedStatusIds.toArray(), status_ids: selectedStatusIds.toArray(),
selected_domains: selectedDomains.toArray(), forward_to_domains: selectedDomains.toArray(),
comment, comment,
forward: selectedDomains.size > 0, forward: selectedDomains.size > 0,
category, category,

View file

@ -54,6 +54,8 @@ import {
FavouritedStatuses, FavouritedStatuses,
EmojiReactedStatuses, EmojiReactedStatuses,
BookmarkedStatuses, BookmarkedStatuses,
BookmarkCategories,
BookmarkCategoryStatuses,
FollowedTags, FollowedTags,
ListTimeline, ListTimeline,
Blocks, Blocks,
@ -218,6 +220,8 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/emoji_reactions' component={EmojiReactedStatuses} content={children} /> <WrappedRoute path='/emoji_reactions' component={EmojiReactedStatuses} content={children} />
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} /> <WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
<WrappedRoute path='/bookmark_categories/:id' component={BookmarkCategoryStatuses} content={children} />
<WrappedRoute path='/bookmark_categories' component={BookmarkCategories} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} /> <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
<WrappedRoute path='/reaction_deck' component={ReactionDeck} content={children} /> <WrappedRoute path='/reaction_deck' component={ReactionDeck} content={children} />

View file

@ -122,6 +122,18 @@ export function BookmarkedStatuses () {
return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses'); return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses');
} }
export function BookmarkCategories () {
return import(/* webpackChunkName: "features/bookmark_categories" */'../../bookmark_categories');
}
export function BookmarkCategoryStatuses () {
return import(/* webpackChunkName: "features/bookmark_category_statuses" */'../../bookmark_category_statuses');
}
export function BookmarkCategoryAdder () {
return import(/* webpackChunkName: "features/bookmark_category_adder" */'../../bookmark_category_adder');
}
export function Blocks () { export function Blocks () {
return import(/* webpackChunkName: "features/blocks" */'../../blocks'); return import(/* webpackChunkName: "features/blocks" */'../../blocks');
} }

View file

@ -50,6 +50,7 @@
* @property {boolean} auto_play_gif * @property {boolean} auto_play_gif
* @property {boolean} activity_api_enabled * @property {boolean} activity_api_enabled
* @property {string} admin * @property {string} admin
* @property {boolean} bookmark_category_needed
* @property {boolean=} boost_modal * @property {boolean=} boost_modal
* @property {boolean=} delete_modal * @property {boolean=} delete_modal
* @property {boolean=} disable_swiping * @property {boolean=} disable_swiping
@ -113,6 +114,7 @@ const getMeta = (prop) => initialState?.meta && initialState.meta[prop];
export const activityApiEnabled = getMeta('activity_api_enabled'); export const activityApiEnabled = getMeta('activity_api_enabled');
export const autoPlayGif = getMeta('auto_play_gif'); export const autoPlayGif = getMeta('auto_play_gif');
export const bookmarkCategoryNeeded = getMeta('bookmark_category_needed');
export const boostModal = getMeta('boost_modal'); export const boostModal = getMeta('boost_modal');
export const deleteModal = getMeta('delete_modal'); export const deleteModal = getMeta('delete_modal');
export const disableSwiping = getMeta('disable_swiping'); export const disableSwiping = getMeta('disable_swiping');

View file

@ -142,6 +142,7 @@
"compose.language.search": "Search languages...", "compose.language.search": "Search languages...",
"compose.published.body": "Post published.", "compose.published.body": "Post published.",
"compose.published.open": "Open", "compose.published.open": "Open",
"compose.saved.body": "Post saved.",
"compose_form.direct_message_warning_learn_more": "Learn more", "compose_form.direct_message_warning_learn_more": "Learn more",
"compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any sensitive information over Mastodon.", "compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any sensitive information over Mastodon.",
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is not public. Only public posts can be searched by hashtag.", "compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is not public. Only public posts can be searched by hashtag.",

View file

@ -0,0 +1,93 @@
import { Map as ImmutableMap, fromJS, OrderedSet as ImmutableOrderedSet } from 'immutable';
import {
BOOKMARK_CATEGORY_FETCH_SUCCESS,
BOOKMARK_CATEGORY_FETCH_FAIL,
BOOKMARK_CATEGORIES_FETCH_SUCCESS,
BOOKMARK_CATEGORY_CREATE_SUCCESS,
BOOKMARK_CATEGORY_UPDATE_SUCCESS,
BOOKMARK_CATEGORY_DELETE_SUCCESS,
BOOKMARK_CATEGORY_STATUSES_FETCH_REQUEST,
BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS,
BOOKMARK_CATEGORY_STATUSES_FETCH_FAIL,
BOOKMARK_CATEGORY_STATUSES_EXPAND_REQUEST,
BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS,
BOOKMARK_CATEGORY_STATUSES_EXPAND_FAIL,
} from '../actions/bookmark_categories';
import {
UNBOOKMARK_SUCCESS,
} from '../actions/interactions';
const initialState = ImmutableMap();
const normalizeBookmarkCategory = (state, category) => {
const old = state.get(category.id);
state = state.set(category.id, fromJS(category));
if (old) {
state = state.setIn([category.id, 'items'], old.get('items'));
}
return state;
};
const normalizeBookmarkCategories = (state, bookmarkCategories) => {
bookmarkCategories.forEach(bookmarkCategory => {
state = normalizeBookmarkCategory(state, bookmarkCategory);
});
return state;
};
const normalizeBookmarkCategoryStatuses = (state, bookmarkCategoryId, statuses, next) => {
return state.update(bookmarkCategoryId, listMap => listMap.withMutations(map => {
map.set('next', next);
map.set('loaded', true);
map.set('isLoading', false);
map.set('items', ImmutableOrderedSet(statuses.map(item => item.id)));
}));
};
const appendToBookmarkCategoryStatuses = (state, bookmarkCategoryId, statuses, next) => {
return state.update(bookmarkCategoryId, listMap => listMap.withMutations(map => {
map.set('next', next);
map.set('isLoading', false);
map.set('items', map.get('items').union(statuses.map(item => item.id)));
}));
};
const removeStatusFromAllBookmarkCategories = (state, status) => {
state.toList().forEach((bookmarkCategory) => {
if (state.getIn([bookmarkCategory.get('id'), 'items'])) {
state = state.updateIn([bookmarkCategory.get('id'), 'items'], items => items.delete(status.get('id')));
}
});
return state;
};
export default function bookmarkCategories(state = initialState, action) {
switch(action.type) {
case BOOKMARK_CATEGORY_FETCH_SUCCESS:
case BOOKMARK_CATEGORY_CREATE_SUCCESS:
return normalizeBookmarkCategory(state, action.bookmarkCategory);
case BOOKMARK_CATEGORY_UPDATE_SUCCESS:
return state.setIn([action.bookmarkCategory.id, 'title'], action.bookmarkCategory.title);
case BOOKMARK_CATEGORIES_FETCH_SUCCESS:
return normalizeBookmarkCategories(state, action.bookmarkCategories);
case BOOKMARK_CATEGORY_DELETE_SUCCESS:
case BOOKMARK_CATEGORY_FETCH_FAIL:
return state.set(action.id, false);
case BOOKMARK_CATEGORY_STATUSES_FETCH_REQUEST:
case BOOKMARK_CATEGORY_STATUSES_EXPAND_REQUEST:
return state.setIn([action.id, 'isLoading'], true);
case BOOKMARK_CATEGORY_STATUSES_FETCH_FAIL:
case BOOKMARK_CATEGORY_STATUSES_EXPAND_FAIL:
return state.setIn([action.id, 'isLoading'], false);
case BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS:
return normalizeBookmarkCategoryStatuses(state, action.id, action.statuses, action.next);
case BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS:
return appendToBookmarkCategoryStatuses(state, action.id, action.statuses, action.next);
case UNBOOKMARK_SUCCESS:
return removeStatusFromAllBookmarkCategories(state, action.status);
default:
return state;
}
}

View file

@ -0,0 +1,53 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import {
BOOKMARK_CATEGORY_ADDER_RESET,
BOOKMARK_CATEGORY_ADDER_SETUP,
BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST,
BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS,
BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL,
BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS,
BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS,
} from '../actions/bookmark_categories';
import {
UNBOOKMARK_SUCCESS,
} from '../actions/interactions';
const initialState = ImmutableMap({
statusId: null,
bookmarkCategories: ImmutableMap({
items: ImmutableList(),
loaded: false,
isLoading: false,
}),
});
export default function bookmarkCategoryAdderReducer(state = initialState, action) {
switch(action.type) {
case BOOKMARK_CATEGORY_ADDER_RESET:
return initialState;
case BOOKMARK_CATEGORY_ADDER_SETUP:
return state.withMutations(map => {
map.set('statusId', action.status.get('id'));
});
case BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST:
return state.setIn(['bookmarkCategories', 'isLoading'], true);
case BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL:
return state.setIn(['bookmarkCategories', 'isLoading'], false);
case BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS:
return state.update('bookmarkCategories', bookmarkCategories => bookmarkCategories.withMutations(map => {
map.set('isLoading', false);
map.set('loaded', true);
map.set('items', ImmutableList(action.bookmarkCategories.map(item => item.id)));
}));
case BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS:
return state.updateIn(['bookmarkCategories', 'items'], bookmarkCategory => bookmarkCategory.unshift(action.bookmarkCategoryId));
case BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS:
return state.updateIn(['bookmarkCategories', 'items'], bookmarkCategory => bookmarkCategory.filterNot(item => item === action.bookmarkCategoryId));
case UNBOOKMARK_SUCCESS:
return action.status.get('id') === state.get('statusId') ? state.setIn(['bookmarkCategories', 'items'], ImmutableList()) : state;
default:
return state;
}
}

View file

@ -0,0 +1,67 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import {
BOOKMARK_CATEGORY_CREATE_REQUEST,
BOOKMARK_CATEGORY_CREATE_FAIL,
BOOKMARK_CATEGORY_CREATE_SUCCESS,
BOOKMARK_CATEGORY_UPDATE_REQUEST,
BOOKMARK_CATEGORY_UPDATE_FAIL,
BOOKMARK_CATEGORY_UPDATE_SUCCESS,
BOOKMARK_CATEGORY_EDITOR_RESET,
BOOKMARK_CATEGORY_EDITOR_SETUP,
BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE,
} from '../actions/bookmark_categories';
const initialState = ImmutableMap({
bookmarkCategoryId: null,
isSubmitting: false,
isChanged: false,
title: '',
isExclusive: false,
statuses: ImmutableMap({
items: ImmutableList(),
loaded: false,
isLoading: false,
}),
suggestions: ImmutableMap({
value: '',
items: ImmutableList(),
}),
});
export default function bookmarkCategoryEditorReducer(state = initialState, action) {
switch(action.type) {
case BOOKMARK_CATEGORY_EDITOR_RESET:
return initialState;
case BOOKMARK_CATEGORY_EDITOR_SETUP:
return state.withMutations(map => {
map.set('bookmarkCategoryId', action.bookmarkCategory.get('id'));
map.set('title', action.bookmarkCategory.get('title'));
map.set('isSubmitting', false);
});
case BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE:
return state.withMutations(map => {
map.set('title', action.value);
map.set('isChanged', true);
});
case BOOKMARK_CATEGORY_CREATE_REQUEST:
case BOOKMARK_CATEGORY_UPDATE_REQUEST:
return state.withMutations(map => {
map.set('isSubmitting', true);
map.set('isChanged', false);
});
case BOOKMARK_CATEGORY_CREATE_FAIL:
case BOOKMARK_CATEGORY_UPDATE_FAIL:
return state.set('isSubmitting', false);
case BOOKMARK_CATEGORY_CREATE_SUCCESS:
case BOOKMARK_CATEGORY_UPDATE_SUCCESS:
return state.withMutations(map => {
map.set('isSubmitting', false);
map.set('bookmarkCategoryId', action.bookmarkCategory.id);
});
default:
return state;
}
}

View file

@ -12,6 +12,9 @@ import antennaAdder from './antenna_adder';
import antennaEditor from './antenna_editor'; import antennaEditor from './antenna_editor';
import antennas from './antennas'; import antennas from './antennas';
import blocks from './blocks'; import blocks from './blocks';
import bookmark_categories from './bookmark_categories';
import bookmarkCategoryAdder from './bookmark_category_adder';
import bookmarkCategoryEditor from './bookmark_category_editor';
import boosts from './boosts'; import boosts from './boosts';
import circleAdder from './circle_adder'; import circleAdder from './circle_adder';
import circleEditor from './circle_editor'; import circleEditor from './circle_editor';
@ -89,6 +92,9 @@ const reducers = {
circles, circles,
circleEditor, circleEditor,
circleAdder, circleAdder,
bookmark_categories,
bookmarkCategoryEditor,
bookmarkCategoryAdder,
filters, filters,
conversations, conversations,
suggestions, suggestions,

View file

@ -1,5 +1,8 @@
import { Map as ImmutableMap, fromJS } from 'immutable'; import { Map as ImmutableMap, fromJS } from 'immutable';
import { ANTENNA_DELETE_SUCCESS, ANTENNA_FETCH_FAIL } from 'mastodon/actions/antennas';
import { CIRCLE_DELETE_SUCCESS, CIRCLE_FETCH_FAIL } from 'mastodon/actions/circles';
import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE, COLUMN_PARAMS_CHANGE } from '../actions/columns'; import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE, COLUMN_PARAMS_CHANGE } from '../actions/columns';
import { EMOJI_USE } from '../actions/emojis'; import { EMOJI_USE } from '../actions/emojis';
import { LANGUAGE_USE } from '../actions/languages'; import { LANGUAGE_USE } from '../actions/languages';
@ -142,6 +145,10 @@ const updateFrequentLanguages = (state, language) => state.update('frequentlyUse
const filterDeadListColumns = (state, listId) => state.update('columns', columns => columns.filterNot(column => column.get('id') === 'LIST' && column.get('params').get('id') === listId)); const filterDeadListColumns = (state, listId) => state.update('columns', columns => columns.filterNot(column => column.get('id') === 'LIST' && column.get('params').get('id') === listId));
const filterDeadAntennaColumns = (state, antennaId) => state.update('columns', columns => columns.filterNot(column => column.get('id') === 'ANTENNA' && column.get('params').get('id') === antennaId));
const filterDeadCircleColumns = (state, circleId) => state.update('columns', columns => columns.filterNot(column => column.get('id') === 'CIRCLE' && column.get('params').get('id') === circleId));
export default function settings(state = initialState, action) { export default function settings(state = initialState, action) {
switch(action.type) { switch(action.type) {
case STORE_HYDRATE: case STORE_HYDRATE:
@ -173,6 +180,14 @@ export default function settings(state = initialState, action) {
return action.error.response.status === 404 ? filterDeadListColumns(state, action.id) : state; return action.error.response.status === 404 ? filterDeadListColumns(state, action.id) : state;
case LIST_DELETE_SUCCESS: case LIST_DELETE_SUCCESS:
return filterDeadListColumns(state, action.id); return filterDeadListColumns(state, action.id);
case ANTENNA_FETCH_FAIL:
return action.error.response.status === 404 ? filterDeadAntennaColumns(state, action.id) : state;
case ANTENNA_DELETE_SUCCESS:
return filterDeadAntennaColumns(state, action.id);
case CIRCLE_FETCH_FAIL:
return action.error.response.status === 404 ? filterDeadCircleColumns(state, action.id) : state;
case CIRCLE_DELETE_SUCCESS:
return filterDeadCircleColumns(state, action.id);
default: default:
return state; return state;
} }

View file

@ -4,6 +4,9 @@ import {
ACCOUNT_BLOCK_SUCCESS, ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_MUTE_SUCCESS, ACCOUNT_MUTE_SUCCESS,
} from '../actions/accounts'; } from '../actions/accounts';
import {
BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS,
} from '../actions/bookmark_categories';
import { import {
BOOKMARKED_STATUSES_FETCH_REQUEST, BOOKMARKED_STATUSES_FETCH_REQUEST,
BOOKMARKED_STATUSES_FETCH_SUCCESS, BOOKMARKED_STATUSES_FETCH_SUCCESS,
@ -98,11 +101,15 @@ const appendToList = (state, listType, statuses, next) => {
}; };
const prependOneToList = (state, listType, status) => { const prependOneToList = (state, listType, status) => {
return prependOneToListById(state, listType, status.get('id'));
};
const prependOneToListById = (state, listType, statusId) => {
return state.updateIn([listType, 'items'], (list) => { return state.updateIn([listType, 'items'], (list) => {
if (list.includes(status.get('id'))) { if (list.includes(statusId)) {
return list; return list;
} else { } else {
return ImmutableOrderedSet([status.get('id')]).union(list); return ImmutableOrderedSet([statusId]).union(list);
} }
}); });
}; };
@ -163,6 +170,8 @@ export default function statusLists(state = initialState, action) {
return removeOneFromList(state, 'emoji_reactions', action.status); return removeOneFromList(state, 'emoji_reactions', action.status);
case BOOKMARK_SUCCESS: case BOOKMARK_SUCCESS:
return prependOneToList(state, 'bookmarks', action.status); return prependOneToList(state, 'bookmarks', action.status);
case BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS:
return prependOneToListById(state, 'bookmarks', action.statusId);
case UNBOOKMARK_SUCCESS: case UNBOOKMARK_SUCCESS:
return removeOneFromList(state, 'bookmarks', action.status); return removeOneFromList(state, 'bookmarks', action.status);
case PINNED_STATUSES_FETCH_SUCCESS: case PINNED_STATUSES_FETCH_SUCCESS:

View file

@ -1,5 +1,9 @@
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import {
BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST,
BOOKMARK_CATEGORY_EDITOR_ADD_FAIL,
} from '../actions/bookmark_categories';
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer'; import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
import { normalizeStatusTranslation } from '../actions/importer/normalizer'; import { normalizeStatusTranslation } from '../actions/importer/normalizer';
import { import {
@ -111,6 +115,10 @@ export default function statuses(state = initialState, action) {
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], true); return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], true);
case BOOKMARK_FAIL: case BOOKMARK_FAIL:
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], false); return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], false);
case BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST:
return state.get(action.statusId) === undefined ? state : state.setIn([action.statusId, 'bookmarked'], true);
case BOOKMARK_CATEGORY_EDITOR_ADD_FAIL:
return state.get(action.statusId) === undefined ? state : state.setIn([action.statusId, 'bookmarked'], false);
case UNBOOKMARK_REQUEST: case UNBOOKMARK_REQUEST:
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], false); return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], false);
case UNBOOKMARK_FAIL: case UNBOOKMARK_FAIL:

View file

@ -131,3 +131,7 @@ export const getAccountHidden = createSelector([
export const getStatusList = createSelector([ export const getStatusList = createSelector([
(state, type) => state.getIn(['status_lists', type, 'items']), (state, type) => state.getIn(['status_lists', type, 'items']),
], (items) => items.toList()); ], (items) => items.toList());
export const getBookmarkCategoryStatusList = createSelector([
(state, bookmarkCategoryId) => state.getIn(['bookmark_categories', bookmarkCategoryId, 'items']),
], (items) => items ? items.toList() : ImmutableList());

View file

@ -5338,6 +5338,7 @@ a.status-card {
&.active { &.active {
transform: rotate(90deg); transform: rotate(90deg);
opacity: 1;
} }
&:hover { &:hover {
@ -8898,6 +8899,44 @@ noscript {
} }
} }
&__choices {
display: flex;
gap: 40px;
&__choice {
flex: 1;
box-sizing: border-box;
h3 {
margin-bottom: 20px;
}
p {
color: $darker-text-color;
margin-bottom: 20px;
font-size: 15px;
}
.button {
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
}
}
@media screen and (max-width: $no-gap-breakpoint - 1px) {
&__choices {
flex-direction: column;
&__choice {
margin-top: 40px;
}
}
}
.link-button { .link-button {
font-size: inherit; font-size: inherit;
display: inline; display: inline;
@ -9620,16 +9659,15 @@ noscript {
a { a {
display: inline-flex; display: inline-flex;
border-radius: 4px; color: $dark-text-color;
background: rgba($highlight-text-color, 0.2);
color: $highlight-text-color;
padding: 0.4em 0.6em;
text-decoration: none; text-decoration: none;
&:hover, &:hover {
&:focus, text-decoration: none;
&:active {
background: rgba($highlight-text-color, 0.3); span {
text-decoration: underline;
}
} }
} }
} }

View file

@ -10,7 +10,7 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim
protected protected
def perform_query def perform_query
[mastodon_version, ruby_version, postgresql_version, redis_version] [mastodon_version, ruby_version, postgresql_version, redis_version, elasticsearch_version].compact
end end
def mastodon_version def mastodon_version
@ -57,6 +57,22 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim
} }
end end
def elasticsearch_version
return unless Chewy.enabled?
client_info = Chewy.client.info
version = client_info.dig('version', 'number')
{
key: 'elasticsearch',
human_key: client_info.dig('version', 'distribution') == 'opensearch' ? 'OpenSearch' : 'Elasticsearch',
value: version,
human_value: version,
}
rescue Faraday::ConnectionFailed, Elasticsearch::Transport::Transport::Error
nil
end
def redis_info def redis_info
@redis_info ||= if redis.is_a?(Redis::Namespace) @redis_info ||= if redis.is_a?(Redis::Namespace)
redis.redis.info redis.redis.info

View file

@ -6,6 +6,7 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
AccountsIndex, AccountsIndex,
TagsIndex, TagsIndex,
StatusesIndex, StatusesIndex,
PublicStatusesIndex,
].freeze ].freeze
def skip? def skip?
@ -41,7 +42,7 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
Admin::SystemCheck::Message.new(:elasticsearch_health_red) Admin::SystemCheck::Message.new(:elasticsearch_health_red)
elsif cluster_health['number_of_nodes'] < 2 && es_preset != 'single_node_cluster' elsif cluster_health['number_of_nodes'] < 2 && es_preset != 'single_node_cluster'
Admin::SystemCheck::Message.new(:elasticsearch_preset_single_node, nil, 'https://docs.joinmastodon.org/admin/optional/elasticsearch/#scaling') Admin::SystemCheck::Message.new(:elasticsearch_preset_single_node, nil, 'https://docs.joinmastodon.org/admin/optional/elasticsearch/#scaling')
elsif Chewy.client.indices.get_settings['chewy_specifications'].dig('settings', 'index', 'number_of_replicas')&.to_i&.positive? && es_preset == 'single_node_cluster' elsif Chewy.client.indices.get_settings[Chewy::Stash::Specification.index_name]&.dig('settings', 'index', 'number_of_replicas')&.to_i&.positive? && es_preset == 'single_node_cluster'
Admin::SystemCheck::Message.new(:elasticsearch_reset_chewy) Admin::SystemCheck::Message.new(:elasticsearch_reset_chewy)
elsif cluster_health['status'] == 'yellow' elsif cluster_health['status'] == 'yellow'
Admin::SystemCheck::Message.new(:elasticsearch_health_yellow) Admin::SystemCheck::Message.new(:elasticsearch_health_yellow)
@ -85,7 +86,7 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
def mismatched_indexes def mismatched_indexes
@mismatched_indexes ||= INDEXES.filter_map do |klass| @mismatched_indexes ||= INDEXES.filter_map do |klass|
klass.index_name if Chewy.client.indices.get_mapping[klass.index_name]&.deep_symbolize_keys != klass.mappings_hash klass.base_name if Chewy.client.indices.get_mapping[klass.index_name]&.deep_symbolize_keys != klass.mappings_hash
end end
end end

View file

@ -68,8 +68,8 @@ class Importer::BaseImporter
protected protected
def in_work_unit(*args, &block) def in_work_unit(...)
work_unit = Concurrent::Promises.future_on(@executor, *args, &block) work_unit = Concurrent::Promises.future_on(@executor, ...)
work_unit.on_fulfillment!(&@on_progress) work_unit.on_fulfillment!(&@on_progress)
work_unit.on_rejection!(&@on_failure) work_unit.on_rejection!(&@on_failure)

View file

@ -0,0 +1,41 @@
# frozen_string_literal: true
class Importer::PublicStatusesIndexImporter < Importer::BaseImporter
def import!
indexable_statuses_scope.find_in_batches(batch_size: @batch_size) do |batch|
in_work_unit(batch.map(&:status_id)) do |status_ids|
bulk = ActiveRecord::Base.connection_pool.with_connection do
Chewy::Index::Import::BulkBuilder.new(index, to_index: Status.includes(:media_attachments, :preloadable_poll).where(id: status_ids)).bulk_body
end
indexed = 0
deleted = 0
bulk.map! do |entry|
if entry[:index]
indexed += 1
else
deleted += 1
end
entry
end
Chewy::Index::Import::BulkRequest.new(index).perform(bulk)
[indexed, deleted]
end
end
wait!
end
private
def index
PublicStatusesIndex
end
def indexable_statuses_scope
Status.indexable.select('"statuses"."id", COALESCE("statuses"."reblog_of_id", "statuses"."id") AS status_id')
end
end

View file

@ -36,7 +36,7 @@ class SearchQueryTransformer < Parslet::Transform
def clause_to_filter(clause) def clause_to_filter(clause)
case clause case clause
when PrefixClause when PrefixClause
{ term: { clause.filter => clause.term } } { clause.type => { clause.filter => clause.term } }
else else
raise "Unexpected clause type: #{clause}" raise "Unexpected clause type: #{clause}"
end end
@ -47,12 +47,10 @@ class SearchQueryTransformer < Parslet::Transform
class << self class << self
def symbol(str) def symbol(str)
case str case str
when '+' when '+', nil
:must :must
when '-' when '-'
:must_not :must_not
when nil
:should
else else
raise "Unknown operator: #{str}" raise "Unknown operator: #{str}"
end end
@ -81,23 +79,52 @@ class SearchQueryTransformer < Parslet::Transform
end end
class PrefixClause class PrefixClause
attr_reader :filter, :operator, :term attr_reader :type, :filter, :operator, :term
def initialize(prefix, term) def initialize(prefix, term)
@operator = :filter @operator = :filter
case prefix case prefix
when 'has', 'is'
@filter = :properties
@type = :term
@term = term
when 'language'
@filter = :language
@type = :term
@term = term
when 'from' when 'from'
@filter = :account_id @filter = :account_id
@type = :term
username, domain = term.gsub(/\A@/, '').split('@') @term = account_id_from_term(term)
domain = nil if TagManager.instance.local_domain?(domain) when 'before'
account = Account.find_remote!(username, domain) @filter = :created_at
@type = :range
@term = account.id @term = { lt: term }
when 'after'
@filter = :created_at
@type = :range
@term = { gt: term }
when 'during'
@filter = :created_at
@type = :range
@term = { gte: term, lte: term }
else else
raise Mastodon::SyntaxError raise Mastodon::SyntaxError
end end
end end
private
def account_id_from_term(term)
username, domain = term.gsub(/\A@/, '').split('@')
domain = nil if TagManager.instance.local_domain?(domain)
account = Account.find_remote(username, domain)
# If the account is not found, we want to return empty results, so return
# an ID that does not exist
account&.id || -1
end
end end
rule(clause: subtree(:clause)) do rule(clause: subtree(:clause)) do

View file

@ -20,7 +20,10 @@ class Vacuum::StatusesVacuum
statuses.direct_visibility statuses.direct_visibility
.includes(mentions: :account) .includes(mentions: :account)
.find_each(&:unlink_from_conversations!) .find_each(&:unlink_from_conversations!)
remove_from_search_index(statuses.ids) if Chewy.enabled? if Chewy.enabled?
remove_from_index(statuses.ids, 'chewy:queue:StatusesIndex')
remove_from_index(statuses.ids, 'chewy:queue:PublicStatusesIndex')
end
# Foreign keys take care of most associated records for us. # Foreign keys take care of most associated records for us.
# Media attachments will be orphaned. # Media attachments will be orphaned.
@ -38,7 +41,7 @@ class Vacuum::StatusesVacuum
Mastodon::Snowflake.id_at(@retention_period.ago, with_random: false) Mastodon::Snowflake.id_at(@retention_period.ago, with_random: false)
end end
def remove_from_search_index(status_ids) def remove_from_index(status_ids, index)
with_redis { |redis| redis.sadd('chewy:queue:StatusesIndex', status_ids) } with_redis { |redis| redis.sadd(index, status_ids) }
end end
end end

View file

@ -86,6 +86,7 @@ class Account < ApplicationRecord
include DomainMaterializable include DomainMaterializable
include AccountMerging include AccountMerging
include AccountSearch include AccountSearch
include AccountStatusesSearch
enum protocol: { ostatus: 0, activitypub: 1 } enum protocol: { ostatus: 0, activitypub: 1 }
enum suspension_origin: { local: 0, remote: 1 }, _prefix: true enum suspension_origin: { local: 0, remote: 1 }, _prefix: true

View file

@ -121,7 +121,7 @@ class Antenna < ApplicationRecord
end end
def tags_raw def tags_raw
antenna_tags.where(exclude: false).map(&:tag).map(&:name).join("\n") antenna_tags.where(exclude: false).map { |tag| tag.tag.name }.join("\n")
end end
def tags_raw=(raw) def tags_raw=(raw)

View file

@ -0,0 +1,29 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: bookmark_categories
#
# id :bigint(8) not null, primary key
# account_id :bigint(8) not null
# title :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class BookmarkCategory < ApplicationRecord
include Paginable
PER_CATEGORY_LIMIT = 20
belongs_to :account
has_many :bookmark_category_statuses, inverse_of: :bookmark_category, dependent: :destroy
has_many :statuses, through: :bookmark_category_statuses
validates :title, presence: true
validates_each :account_id, on: :create do |record, _attr, value|
record.errors.add(:base, I18n.t('bookmark_categories.errors.limit')) if BookmarkCategory.where(account_id: value).count >= PER_CATEGORY_LIMIT
end
end

View file

@ -0,0 +1,34 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: bookmark_category_statuses
#
# id :bigint(8) not null, primary key
# bookmark_category_id :bigint(8) not null
# status_id :bigint(8) not null
# bookmark_id :bigint(8)
# created_at :datetime not null
# updated_at :datetime not null
#
class BookmarkCategoryStatus < ApplicationRecord
belongs_to :bookmark_category
belongs_to :status
belongs_to :bookmark
validates :status_id, uniqueness: { scope: :bookmark_category_id }
validate :validate_relationship
before_validation :set_bookmark
private
def set_bookmark
self.bookmark = Bookmark.find_by!(account_id: bookmark_category.account_id, status_id: status_id)
end
def validate_relationship
errors.add(:account_id, 'bookmark relationship missing') if bookmark_id.blank?
end
end

View file

@ -15,6 +15,9 @@ module AccountAssociations
has_many :favourites, inverse_of: :account, dependent: :destroy has_many :favourites, inverse_of: :account, dependent: :destroy
has_many :emoji_reactions, inverse_of: :account, dependent: :destroy has_many :emoji_reactions, inverse_of: :account, dependent: :destroy
has_many :bookmarks, inverse_of: :account, dependent: :destroy has_many :bookmarks, inverse_of: :account, dependent: :destroy
has_many :bookmark_categories, inverse_of: :account, dependent: :destroy
has_many :circles, inverse_of: :account, dependent: :destroy
has_many :antennas, inverse_of: :account, dependent: :destroy
has_many :mentions, inverse_of: :account, dependent: :destroy has_many :mentions, inverse_of: :account, dependent: :destroy
has_many :notifications, inverse_of: :account, dependent: :destroy has_many :notifications, inverse_of: :account, dependent: :destroy
has_many :conversations, class_name: 'AccountConversation', dependent: :destroy, inverse_of: :account has_many :conversations, class_name: 'AccountConversation', dependent: :destroy, inverse_of: :account

View file

@ -0,0 +1,44 @@
# frozen_string_literal: true
module AccountStatusesSearch
extend ActiveSupport::Concern
included do
after_update_commit :enqueue_update_public_statuses_index, if: :saved_change_to_indexable?
after_destroy_commit :enqueue_remove_from_public_statuses_index, if: :indexable?
end
def enqueue_update_public_statuses_index
if indexable?
enqueue_add_to_public_statuses_index
else
enqueue_remove_from_public_statuses_index
end
end
def enqueue_add_to_public_statuses_index
return unless Chewy.enabled?
AddToPublicStatusesIndexWorker.perform_async(id)
end
def enqueue_remove_from_public_statuses_index
return unless Chewy.enabled?
RemoveFromPublicStatusesIndexWorker.perform_async(id)
end
def add_to_public_statuses_index!
return unless Chewy.enabled?
statuses.indexable.find_in_batches do |batch|
PublicStatusesIndex.import(query: batch)
end
end
def remove_from_public_statuses_index!
return unless Chewy.enabled?
PublicStatusesIndex.filter(term: { account_id: id }).delete_all
end
end

View file

@ -31,6 +31,10 @@ module HasUserSettings
settings['web.enable_login_privacy'] settings['web.enable_login_privacy']
end end
def setting_bookmark_category_needed
settings['web.bookmark_category_needed']
end
def setting_hide_recent_emojis def setting_hide_recent_emojis
settings['web.hide_recent_emojis'] settings['web.hide_recent_emojis']
end end

View file

@ -0,0 +1,59 @@
# frozen_string_literal: true
module StatusSearchConcern
extend ActiveSupport::Concern
included do
scope :indexable, -> { without_reblogs.where(visibility: [:public, :login], searchability: nil).joins(:account).where(account: { indexable: true }) }
end
def searchable_by(preloaded = nil)
ids = []
ids << account_id if local?
if preloaded.nil?
ids += mentions.joins(:account).merge(Account.local).active.pluck(:account_id)
ids += favourites.joins(:account).merge(Account.local).pluck(:account_id)
ids += emoji_reactions.joins(:account).merge(Account.local).pluck(:account_id)
ids += references.joins(:account).merge(Account.local).pluck(:account_id)
ids += reblogs.joins(:account).merge(Account.local).pluck(:account_id)
ids += bookmarks.joins(:account).merge(Account.local).pluck(:account_id)
ids += poll.votes.joins(:account).merge(Account.local).pluck(:account_id) if poll.present?
else
ids += preloaded.mentions[id] || []
ids += preloaded.favourites[id] || []
ids += preloaded.emoji_reactions[id] || []
ids += preloaded.status_references[id] || []
ids += preloaded.reblogs[id] || []
ids += preloaded.bookmarks[id] || []
ids += preloaded.votes[id] || []
end
ids.uniq
end
def searchable_text
[
spoiler_text,
FormattingHelper.extract_status_plain_text(self),
preloadable_poll&.options&.join("\n\n"),
ordered_media_attachments.map(&:description).join("\n\n"),
].compact.join("\n\n")
end
def searchable_properties
[].tap do |properties|
properties << 'image' if ordered_media_attachments.any?(&:image?)
properties << 'video' if ordered_media_attachments.any?(&:video?)
properties << 'audio' if ordered_media_attachments.any?(&:audio?)
properties << 'media' if with_media?
properties << 'poll' if with_poll?
properties << 'link' if with_preview_card?
properties << 'embed' if preview_cards.any?(&:video?)
properties << 'sensitive' if sensitive?
properties << 'reply' if reply?
properties << 'reference' if with_status_reference?
end
end
end

View file

@ -46,11 +46,11 @@ class PublicFeed
end end
def local_only? def local_only?
options[:local] options[:local] && !options[:remote]
end end
def remote_only? def remote_only?
options[:remote] options[:remote] && !options[:local]
end end
def hide_local_users? def hide_local_users?

View file

@ -42,6 +42,7 @@ class Status < ApplicationRecord
include StatusSnapshotConcern include StatusSnapshotConcern
include RateLimitable include RateLimitable
include StatusSafeReblogInsert include StatusSafeReblogInsert
include StatusSearchConcern
rate_limit by: :account, family: :statuses rate_limit by: :account, family: :statuses
@ -52,6 +53,7 @@ class Status < ApplicationRecord
attr_accessor :override_timestamps attr_accessor :override_timestamps
update_index('statuses', :proper) update_index('statuses', :proper)
update_index('public_statuses', :proper)
enum visibility: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4, public_unlisted: 10, login: 11 }, _suffix: :visibility enum visibility: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4, public_unlisted: 10, login: 11 }, _suffix: :visibility
enum searchability: { public: 0, private: 1, direct: 2, limited: 3, unsupported: 4, public_unlisted: 10 }, _suffix: :searchability enum searchability: { public: 0, private: 1, direct: 2, limited: 3, unsupported: 4, public_unlisted: 10 }, _suffix: :searchability
@ -82,6 +84,8 @@ class Status < ApplicationRecord
has_many :referenced_by_status_objects, foreign_key: 'target_status_id', class_name: 'StatusReference', inverse_of: :target_status, dependent: :destroy has_many :referenced_by_status_objects, foreign_key: 'target_status_id', class_name: 'StatusReference', inverse_of: :target_status, dependent: :destroy
has_many :referenced_by_statuses, through: :referenced_by_status_objects, class_name: 'Status', source: :status has_many :referenced_by_statuses, through: :referenced_by_status_objects, class_name: 'Status', source: :status
has_many :capability_tokens, class_name: 'StatusCapabilityToken', inverse_of: :status, dependent: :destroy has_many :capability_tokens, class_name: 'StatusCapabilityToken', inverse_of: :status, dependent: :destroy
has_many :bookmark_category_relationships, class_name: 'BookmarkCategoryStatus', inverse_of: :status, dependent: :destroy
has_many :joined_bookmark_categories, class_name: 'BookmarkCategory', through: :bookmark_category_relationships, source: :bookmark_category
has_and_belongs_to_many :tags has_and_belongs_to_many :tags
has_and_belongs_to_many :preview_cards has_and_belongs_to_many :preview_cards
@ -183,39 +187,6 @@ class Status < ApplicationRecord
"v3:#{super}" "v3:#{super}"
end end
def searchable_by(preloaded = nil)
ids = []
ids << account_id if local?
if preloaded.nil?
ids += mentions.joins(:account).merge(Account.local).active.pluck(:account_id)
ids += favourites.joins(:account).merge(Account.local).pluck(:account_id)
ids += emoji_reactions.joins(:account).merge(Account.local).pluck(:account_id)
ids += reblogs.joins(:account).merge(Account.local).pluck(:account_id)
ids += bookmarks.joins(:account).merge(Account.local).pluck(:account_id)
ids += poll.votes.joins(:account).merge(Account.local).pluck(:account_id) if poll.present?
else
ids += preloaded.mentions[id] || []
ids += preloaded.favourites[id] || []
ids += preloaded.emoji_reactions[id] || []
ids += preloaded.reblogs[id] || []
ids += preloaded.bookmarks[id] || []
ids += preloaded.votes[id] || []
end
ids.uniq
end
def searchable_text
[
spoiler_text,
FormattingHelper.extract_status_plain_text(self),
preloadable_poll ? preloadable_poll.options.join("\n\n") : nil,
ordered_media_attachments.map(&:description).join("\n\n"),
].compact.join("\n\n")
end
def to_log_human_identifier def to_log_human_identifier
account.acct account.acct
end end
@ -295,6 +266,14 @@ class Status < ApplicationRecord
preview_cards.any? preview_cards.any?
end end
def with_poll?
preloadable_poll.present?
end
def with_status_reference?
reference_objects.any?
end
def non_sensitive_with_media? def non_sensitive_with_media?
!sensitive? && with_media? !sensitive? && with_media?
end end

View file

@ -42,6 +42,7 @@ class UserSettings
setting :use_blurhash, default: true setting :use_blurhash, default: true
setting :use_pending_items, default: false setting :use_pending_items, default: false
setting :use_system_font, default: false setting :use_system_font, default: false
setting :bookmark_category_needed, default: false
setting :disable_swiping, default: false setting :disable_swiping, default: false
setting :delete_modal, default: true setting :delete_modal, default: true
setting :enable_login_privacy, default: false setting :enable_login_privacy, default: false

View file

@ -8,13 +8,13 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
context_extensions :manually_approves_followers, :featured, :also_known_as, context_extensions :manually_approves_followers, :featured, :also_known_as,
:moved_to, :property_value, :discoverable, :olm, :suspended, :searchable_by, :subscribable_by, :moved_to, :property_value, :discoverable, :olm, :suspended, :searchable_by, :subscribable_by,
:other_setting :other_setting, :memorial, :indexable
attributes :id, :type, :following, :followers, attributes :id, :type, :following, :followers,
:inbox, :outbox, :featured, :featured_tags, :inbox, :outbox, :featured, :featured_tags,
:preferred_username, :name, :summary, :preferred_username, :name, :summary,
:url, :manually_approves_followers, :url, :manually_approves_followers,
:discoverable, :published, :searchable_by, :subscribable_by, :other_setting :discoverable, :indexable, :published, :memorial, :searchable_by, :subscribable_by, :other_setting
has_one :public_key, serializer: ActivityPub::PublicKeySerializer has_one :public_key, serializer: ActivityPub::PublicKeySerializer
@ -107,6 +107,10 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
object.suspended? ? false : (object.discoverable || false) object.suspended? ? false : (object.discoverable || false)
end end
def indexable
object.suspended? ? false : (object.indexable || false)
end
def name def name
object.suspended? ? object.username : (object.display_name.presence || object.username) object.suspended? ? object.username : (object.display_name.presence || object.username)
end end

View file

@ -52,6 +52,7 @@ class InitialStateSerializer < ActiveModel::Serializer
store[:use_blurhash] = object.current_account.user.setting_use_blurhash store[:use_blurhash] = object.current_account.user.setting_use_blurhash
store[:use_pending_items] = object.current_account.user.setting_use_pending_items store[:use_pending_items] = object.current_account.user.setting_use_pending_items
store[:show_trends] = Setting.trends && object.current_account.user.setting_trends store[:show_trends] = Setting.trends && object.current_account.user.setting_trends
store[:bookmark_category_needed] = object.current_account.user.setting_bookmark_category_needed
else else
store[:auto_play_gif] = Setting.auto_play_gif store[:auto_play_gif] = Setting.auto_play_gif
store[:display_media] = Setting.display_media store[:display_media] = Setting.display_media

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class REST::BookmarkCategorySerializer < ActiveModel::Serializer
attributes :id, :title
def id
object.id.to_s
end
end

View file

@ -122,6 +122,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
:visibility_limited, :visibility_limited,
:kmyblue_limited_scope, :kmyblue_limited_scope,
:kmyblue_antenna, :kmyblue_antenna,
:kmyblue_bookmark_category,
] ]
capabilities << :profile_search unless Chewy.enabled? capabilities << :profile_search unless Chewy.enabled?

View file

@ -131,6 +131,7 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
:visibility_limited, :visibility_limited,
:kmyblue_limited_scope, :kmyblue_limited_scope,
:kmyblue_antenna, :kmyblue_antenna,
:kmyblue_bookmark_category,
] ]
capabilities << :profile_search unless Chewy.enabled? capabilities << :profile_search unless Chewy.enabled?

View file

@ -147,7 +147,7 @@ class AccountSearchService < BaseService
multi_match: { multi_match: {
query: @query, query: @query,
type: 'bool_prefix', type: 'bool_prefix',
fields: %w(username username.* display_name display_name.*), fields: %w(username^2 username.*^2 display_name display_name.*),
}, },
} }
end end

View file

@ -127,6 +127,7 @@ class ActivityPub::ProcessAccountService < BaseService
@account.searchability = searchability_from_audience @account.searchability = searchability_from_audience
@account.dissubscribable = !subscribable(@account.note) @account.dissubscribable = !subscribable(@account.note)
@account.settings = other_settings @account.settings = other_settings
@account.memorial = @json['memorial'] || false
end end
def valid_account? def valid_account?

View file

@ -35,7 +35,10 @@ class BatchedRemoveStatusService < BaseService
# Since we skipped all callbacks, we also need to manually # Since we skipped all callbacks, we also need to manually
# deindex the statuses # deindex the statuses
Chewy.strategy.current.update(StatusesIndex, statuses_and_reblogs) if Chewy.enabled? if Chewy.enabled?
Chewy.strategy.current.update(StatusesIndex, statuses_and_reblogs)
Chewy.strategy.current.update(PublicStatusesIndex, statuses_and_reblogs)
end
return if options[:skip_side_effects] return if options[:skip_side_effects]

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class DeliveryAntennaService class DeliveryAntennaService
include FormattingHelper
def call(status, update, stl_home) def call(status, update, stl_home)
@status = status @status = status
@account = @status.account @account = @status.account
@ -37,12 +39,13 @@ class DeliveryAntennaService
antennas = antennas.where(stl: false) antennas = antennas.where(stl: false)
collection = AntennaCollection.new(@status, @update, false) collection = AntennaCollection.new(@status, @update, false)
content = extract_status_plain_text_with_spoiler_text(@status)
antennas.in_batches do |ans| antennas.in_batches do |ans|
ans.each do |antenna| ans.each do |antenna|
next unless antenna.enabled? next unless antenna.enabled?
next if antenna.keywords&.any? && antenna.keywords&.none? { |keyword| @status.text.include?(keyword) } next if antenna.keywords&.any? && antenna.keywords&.none? { |keyword| content.include?(keyword) }
next if antenna.exclude_keywords&.any? { |keyword| @status.text.include?(keyword) } next if antenna.exclude_keywords&.any? { |keyword| content.include?(keyword) }
next if antenna.exclude_accounts&.include?(@status.account_id) next if antenna.exclude_accounts&.include?(@status.account_id)
next if antenna.exclude_domains&.include?(domain) next if antenna.exclude_domains&.include?(domain)
next if antenna.exclude_tags&.any? { |tag_id| tag_ids.include?(tag_id) } next if antenna.exclude_tags&.any? { |tag_id| tag_ids.include?(tag_id) }

View file

@ -41,41 +41,16 @@ class SearchService < BaseService
end end
def perform_statuses_search! def perform_statuses_search!
privacy_definition = parsed_query.apply(StatusesIndex.filter(terms: { searchability: %w(public private direct) }).filter(term: { searchable_by: @account.id })) StatusesSearchService.new.call(
@query,
# 'direct' searchability posts are NOT in here because it's already added at previous line. @account,
case @searchability limit: @limit,
when 'public' offset: @offset,
privacy_definition = privacy_definition.or(StatusesIndex.filter(term: { searchability: 'public' })) account_id: @options[:account_id],
privacy_definition = privacy_definition.or(StatusesIndex.filter(term: { searchability: 'private' }).filter(terms: { account_id: following_account_ids })) unless following_account_ids.empty? min_id: @options[:min_id],
privacy_definition = privacy_definition.or(StatusesIndex.filter(term: { searchability: 'limited' }).filter(term: { account_id: @account.id })) max_id: @options[:max_id],
when 'private', 'direct' searchability: @searchability
privacy_definition = privacy_definition.or(StatusesIndex.filter(terms: { searchability: %w(public private) }).filter(terms: { account_id: following_account_ids })) unless following_account_ids.empty? )
privacy_definition = privacy_definition.or(StatusesIndex.filter(term: { searchability: 'limited' }).filter(term: { account_id: @account.id }))
when 'limited'
privacy_definition = privacy_definition.or(StatusesIndex.filter(term: { searchability: 'limited' }).filter(term: { account_id: @account.id }))
end
definition = parsed_query.apply(StatusesIndex).order(id: :desc)
definition = definition.filter(term: { account_id: @options[:account_id] }) if @options[:account_id].present?
definition = definition.and(privacy_definition)
if @options[:min_id].present? || @options[:max_id].present?
range = {}
range[:gt] = @options[:min_id].to_i if @options[:min_id].present?
range[:lt] = @options[:max_id].to_i if @options[:max_id].present?
definition = definition.filter(range: { id: range })
end
results = definition.limit(@limit).offset(@offset).objects.compact
account_ids = results.map(&:account_id)
account_domains = results.map(&:account_domain)
account_relations = @account.relations_map(account_ids, account_domains) # variable old name: preloaded_relations
results.reject { |status| StatusFilter.new(status, @account, account_relations).filtered? }
rescue Faraday::ConnectionFailed, Parslet::ParseFailed
[]
end end
def perform_hashtags_search! def perform_hashtags_search!
@ -132,17 +107,4 @@ class SearchService < BaseService
def statuses_search? def statuses_search?
@options[:type].blank? || @options[:type] == 'statuses' @options[:type].blank? || @options[:type] == 'statuses'
end end
def parsed_query
SearchQueryTransformer.new.apply(SearchQueryParser.new.parse(@query))
end
def following_account_ids
return @following_account_ids if defined?(@following_account_ids)
account_exists_sql = Account.where('accounts.id = follows.target_account_id').where(searchability: %w(public private)).reorder(nil).select(1).to_sql
status_exists_sql = Status.where('statuses.account_id = follows.target_account_id').where(reblog_of_id: nil).where(searchability: %w(public private)).reorder(nil).select(1).to_sql
following_accounts = Follow.where(account_id: @account.id).merge(Account.where("EXISTS (#{account_exists_sql})").or(Account.where("EXISTS (#{status_exists_sql})")))
@following_account_ids = following_accounts.pluck(:target_account_id)
end
end end

View file

@ -0,0 +1,145 @@
# frozen_string_literal: true
class StatusesSearchService < BaseService
def call(query, account = nil, options = {})
@query = query&.strip
@account = account
@options = options
@limit = options[:limit].to_i
@offset = options[:offset].to_i
@searchability = options[:searchability]&.to_sym
status_search_results
end
private
def status_search_results
definition_should = [
publicly_searchable,
non_publicly_searchable,
searchability_limited,
]
definition_should << searchability_public if %i(public).include?(@searchability)
definition_should << searchability_private if %i(public private).include?(@searchability)
definition = parsed_query.apply(
Chewy::Search::Request.new(StatusesIndex, PublicStatusesIndex).filter(
bool: {
should: definition_should,
minimum_should_match: 1,
}
)
)
results = definition.collapse(field: :id).order(id: { order: :desc }).limit(@limit).offset(@offset).objects.compact
account_ids = results.map(&:account_id)
account_domains = results.map(&:account_domain)
preloaded_relations = @account.relations_map(account_ids, account_domains)
results.reject { |status| StatusFilter.new(status, @account, preloaded_relations).filtered? }
rescue Faraday::ConnectionFailed, Parslet::ParseFailed
[]
end
def publicly_searchable
{
term: { _index: PublicStatusesIndex.index_name },
}
end
def non_publicly_searchable
{
bool: {
must: [
{
term: { _index: StatusesIndex.index_name },
},
{
exists: {
field: 'searchability',
},
},
{
term: { searchable_by: @account.id },
},
],
must_not: [
{
term: { searchability: 'limited' },
},
],
},
}
end
def searchability_public
{
bool: {
must: [
{
exists: {
field: 'searchability',
},
},
{
term: { searchability: 'public' },
},
],
},
}
end
def searchability_private
{
bool: {
must: [
{
exists: {
field: 'searchability',
},
},
{
term: { searchability: 'private' },
},
{
terms: { account_id: following_account_ids },
},
],
},
}
end
def searchability_limited
{
bool: {
must: [
{
exists: {
field: 'searchability',
},
},
{
term: { searchability: 'limited' },
},
{
term: { account_id: @account.id },
},
],
},
}
end
def following_account_ids
return @following_account_ids if defined?(@following_account_ids)
account_exists_sql = Account.where('accounts.id = follows.target_account_id').where(searchability: %w(public private)).reorder(nil).select(1).to_sql
status_exists_sql = Status.where('statuses.account_id = follows.target_account_id').where(reblog_of_id: nil).where(searchability: %w(public private)).reorder(nil).select(1).to_sql
following_accounts = Follow.where(account_id: @account.id).merge(Account.where("EXISTS (#{account_exists_sql})").or(Account.where("EXISTS (#{status_exists_sql})")))
@following_account_ids = following_accounts.pluck(:target_account_id)
end
def parsed_query
SearchQueryTransformer.new.apply(SearchQueryParser.new.parse(@query))
end
end

View file

@ -1,9 +1,6 @@
- content_for :page_title do - content_for :page_title do
= t('admin.accounts.title') = t('admin.accounts.title')
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
= form_tag admin_accounts_url, method: 'GET', class: 'simple_form' do = form_tag admin_accounts_url, method: 'GET', class: 'simple_form' do
.filters .filters
.filter-subset.filter-subset--with-select .filter-subset.filter-subset--with-select

View file

@ -1,9 +1,6 @@
- content_for :page_title do - content_for :page_title do
= t('admin.action_logs.title') = t('admin.action_logs.title')
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
= form_tag admin_action_logs_url, method: 'GET', class: 'simple_form' do = form_tag admin_action_logs_url, method: 'GET', class: 'simple_form' do
= hidden_field_tag :target_account_id, params[:target_account_id] if params[:target_account_id].present? = hidden_field_tag :target_account_id, params[:target_account_id] if params[:target_account_id].present?

View file

@ -1,9 +1,6 @@
- content_for :page_title do - content_for :page_title do
= t('.title') = t('.title')
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
= simple_form_for @announcement, url: admin_announcement_path(@announcement), html: { novalidate: false } do |f| = simple_form_for @announcement, url: admin_announcement_path(@announcement), html: { novalidate: false } do |f|
= render 'shared/error_messages', object: @announcement = render 'shared/error_messages', object: @announcement

View file

@ -1,9 +1,6 @@
- content_for :page_title do - content_for :page_title do
= t('.title') = t('.title')
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
= simple_form_for @announcement, url: admin_announcements_path, html: { novalidate: false } do |f| = simple_form_for @announcement, url: admin_announcements_path, html: { novalidate: false } do |f|
= render 'shared/error_messages', object: @announcement = render 'shared/error_messages', object: @announcement

View file

@ -1,9 +1,6 @@
- content_for :page_title do - content_for :page_title do
= t('admin.custom_emojis.title') = t('admin.custom_emojis.title')
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
- if can?(:create, :custom_emoji) - if can?(:create, :custom_emoji)
- content_for :heading_actions do - content_for :heading_actions do
= link_to t('admin.custom_emojis.upload'), new_admin_custom_emoji_path, class: 'button' = link_to t('admin.custom_emojis.upload'), new_admin_custom_emoji_path, class: 'button'

View file

@ -1,6 +1,3 @@
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
- content_for :page_title do - content_for :page_title do
= t('admin.dashboard.title') = t('admin.dashboard.title')

View file

@ -1,9 +1,6 @@
- content_for :page_title do - content_for :page_title do
= t('admin.disputes.appeals.title') = t('admin.disputes.appeals.title')
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
.filters .filters
.filter-subset .filter-subset
%strong= t('admin.tags.review') %strong= t('admin.tags.review')

View file

@ -1,6 +1,3 @@
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
- content_for :page_title do - content_for :page_title do
= t('admin.domain_allows.add_new') = t('admin.domain_allows.add_new')

View file

@ -1,6 +1,3 @@
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
- content_for :page_title do - content_for :page_title do
= t('.title', domain: Addressable::IDNA.to_unicode(@domain_block.domain)) = t('.title', domain: Addressable::IDNA.to_unicode(@domain_block.domain))

View file

@ -1,6 +1,3 @@
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
- content_for :page_title do - content_for :page_title do
= t('admin.domain_blocks.edit') = t('admin.domain_blocks.edit')

View file

@ -1,6 +1,3 @@
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
- content_for :page_title do - content_for :page_title do
= t('.title') = t('.title')

View file

@ -4,9 +4,6 @@
- content_for :heading_actions do - content_for :heading_actions do
= link_to t('admin.email_domain_blocks.add_new'), new_admin_email_domain_block_path, class: 'button' = link_to t('admin.email_domain_blocks.add_new'), new_admin_email_domain_block_path, class: 'button'
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
= form_for(@form, url: batch_admin_email_domain_blocks_path) do |f| = form_for(@form, url: batch_admin_email_domain_blocks_path) do |f|
= hidden_field_tag :page, params[:page] || 1 = hidden_field_tag :page, params[:page] || 1

View file

@ -1,9 +1,6 @@
- content_for :page_title do - content_for :page_title do
= t('.title') = t('.title')
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
= simple_form_for @email_domain_block, url: admin_email_domain_blocks_path do |f| = simple_form_for @email_domain_block, url: admin_email_domain_blocks_path do |f|
= render 'shared/error_messages', object: @email_domain_block = render 'shared/error_messages', object: @email_domain_block

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