Merge branch 'kb_migration' into kb_development
This commit is contained in:
commit
cd950f80ad
160 changed files with 2861 additions and 508 deletions
|
@ -2,3 +2,7 @@ VAGRANT=true
|
|||
LOCAL_DOMAIN=mastodon.local
|
||||
BIND=0.0.0.0
|
||||
DB_HOST=/var/run/postgresql/
|
||||
|
||||
ES_ENABLED=true
|
||||
ES_HOST=localhost
|
||||
ES_PORT=9200
|
6
.github/workflows/build-container-image.yml
vendored
6
.github/workflows/build-container-image.yml
vendored
|
@ -8,7 +8,9 @@ on:
|
|||
type: boolean
|
||||
push_to_images:
|
||||
type: string
|
||||
version_suffix:
|
||||
version_prerelease:
|
||||
type: string
|
||||
version_metadata:
|
||||
type: string
|
||||
flavor:
|
||||
type: string
|
||||
|
@ -83,7 +85,7 @@ jobs:
|
|||
- uses: docker/build-push-action@v4
|
||||
with:
|
||||
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 }}
|
||||
provenance: false
|
||||
builder: ${{ steps.buildx.outputs.name || steps.buildx-native.outputs.name }}
|
||||
|
|
9
.github/workflows/build-nightly.yml
vendored
9
.github/workflows/build-nightly.yml
vendored
|
@ -16,9 +16,9 @@ jobs:
|
|||
env:
|
||||
TZ: Etc/UTC
|
||||
run: |
|
||||
echo mastodon_version_suffix=nightly-$(date +'%Y-%m-%d')>> $GITHUB_OUTPUT
|
||||
echo mastodon_version_prerelease=nightly.$(date +'%Y-%m-%d')>> $GITHUB_OUTPUT
|
||||
outputs:
|
||||
suffix: ${{ steps.version_vars.outputs.mastodon_version_suffix }}
|
||||
prerelease: ${{ steps.version_vars.outputs.mastodon_version_prerelease }}
|
||||
|
||||
build-image:
|
||||
needs: compute-suffix
|
||||
|
@ -29,8 +29,7 @@ jobs:
|
|||
push_to_images: |
|
||||
tootsuite/mastodon
|
||||
ghcr.io/mastodon/mastodon
|
||||
# The `+` is important here, result will be v4.1.2+nightly-2022-03-05
|
||||
version_suffix: +${{ needs.compute-suffix.outputs.suffix }}
|
||||
version_prerelease: ${{ needs.compute-suffix.outputs.prerelease }}
|
||||
labels: |
|
||||
org.opencontainers.image.description=Nightly build image used for testing purposes
|
||||
flavor: |
|
||||
|
@ -38,5 +37,5 @@ jobs:
|
|||
tags: |
|
||||
type=raw,value=edge
|
||||
type=raw,value=nightly
|
||||
type=schedule,pattern=${{ needs.compute-suffix.outputs.suffix }}
|
||||
type=schedule,pattern=${{ needs.compute-suffix.outputs.prerelease }}
|
||||
secrets: inherit
|
||||
|
|
6
.github/workflows/build-push-pr.yml
vendored
6
.github/workflows/build-push-pr.yml
vendored
|
@ -21,9 +21,9 @@ jobs:
|
|||
uses: actions/checkout@v3
|
||||
- id: version_vars
|
||||
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:
|
||||
suffix: ${{ steps.version_vars.outputs.mastodon_version_suffix }}
|
||||
metadata: ${{ steps.version_vars.outputs.mastodon_version_metadata }}
|
||||
|
||||
build-image:
|
||||
needs: compute-suffix
|
||||
|
@ -33,7 +33,7 @@ jobs:
|
|||
use_native_arm64_builder: true
|
||||
push_to_images: |
|
||||
ghcr.io/mastodon/mastodon
|
||||
version_suffix: ${{ needs.compute-suffix.outputs.suffix }}
|
||||
version_metadata: ${{ needs.compute-suffix.outputs.metadata }}
|
||||
flavor: |
|
||||
latest=auto
|
||||
tags: |
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# This configuration was generated by
|
||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp`
|
||||
# using RuboCop version 1.54.2.
|
||||
# using RuboCop version 1.56.1.
|
||||
# The point is for the user to remove these configuration records
|
||||
# one by one as the offenses are removed from the code base.
|
||||
# Note that changes in the inspected code, or installation of new
|
||||
|
@ -61,38 +61,8 @@ Lint/EmptyBlock:
|
|||
- 'spec/fabricators/access_token_fabricator.rb'
|
||||
- 'spec/fabricators/conversation_fabricator.rb'
|
||||
- 'spec/fabricators/system_key_fabricator.rb'
|
||||
- 'spec/helpers/admin/action_logs_helper_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/web/setting_spec.rb'
|
||||
|
||||
Lint/NonLocalExitFromIterator:
|
||||
Exclude:
|
||||
|
@ -135,7 +105,7 @@ Lint/UselessAssignment:
|
|||
|
||||
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
|
||||
Metrics/AbcSize:
|
||||
Max: 146
|
||||
Max: 144
|
||||
|
||||
# Configuration parameters: CountBlocks, Max.
|
||||
Metrics/BlockNesting:
|
||||
|
@ -164,6 +134,19 @@ Naming/VariableNumber:
|
|||
- 'spec/models/domain_block_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:
|
||||
Exclude:
|
||||
- 'spec/controllers/activitypub/inboxes_controller_spec.rb'
|
||||
|
@ -768,6 +751,15 @@ Style/RedundantFetchBlock:
|
|||
- 'config/initializers/paperclip.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).
|
||||
# Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods, MaxChainLength.
|
||||
# AllowedMethods: present?, blank?, presence, try, try!
|
||||
|
|
|
@ -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 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 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 `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))
|
||||
|
@ -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 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 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 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))
|
||||
|
|
|
@ -42,8 +42,8 @@ RUN apt-get update && \
|
|||
FROM node:${NODE_VERSION}
|
||||
|
||||
# Use those args to specify your own version flags & suffixes
|
||||
ARG MASTODON_VERSION_FLAGS=""
|
||||
ARG MASTODON_VERSION_SUFFIX=""
|
||||
ARG MASTODON_VERSION_PRERELEASE=""
|
||||
ARG MASTODON_VERSION_METADATA=""
|
||||
|
||||
ARG UID="991"
|
||||
ARG GID="991"
|
||||
|
@ -89,8 +89,8 @@ ENV RAILS_ENV="production" \
|
|||
NODE_ENV="production" \
|
||||
RAILS_SERVE_STATIC_FILES="true" \
|
||||
BIND="0.0.0.0" \
|
||||
MASTODON_VERSION_FLAGS="${MASTODON_VERSION_FLAGS}" \
|
||||
MASTODON_VERSION_SUFFIX="${MASTODON_VERSION_SUFFIX}"
|
||||
MASTODON_VERSION_PRERELEASE="${MASTODON_VERSION_PRERELEASE}" \
|
||||
MASTODON_VERSION_METADATA="${MASTODON_VERSION_METADATA}"
|
||||
|
||||
# Set the run user
|
||||
USER mastodon
|
||||
|
|
2
Gemfile
2
Gemfile
|
@ -110,7 +110,7 @@ group :test do
|
|||
gem 'fuubar', '~> 2.5'
|
||||
|
||||
# Extra RSpec extenion methods and helpers for sidekiq
|
||||
gem 'rspec-sidekiq', '~> 3.1'
|
||||
gem 'rspec-sidekiq', '~> 4.0'
|
||||
|
||||
# Browser integration testing
|
||||
gem 'capybara', '~> 3.39'
|
||||
|
|
132
Gemfile.lock
132
Gemfile.lock
|
@ -39,47 +39,47 @@ GIT
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
actioncable (7.0.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
activejob (= 7.0.7)
|
||||
activerecord (= 7.0.7)
|
||||
activestorage (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
actionmailbox (7.0.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
activejob (= 7.0.7.2)
|
||||
activerecord (= 7.0.7.2)
|
||||
activestorage (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailer (7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
actionview (= 7.0.7)
|
||||
activejob (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
actionmailer (7.0.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
actionview (= 7.0.7.2)
|
||||
activejob (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (7.0.7)
|
||||
actionview (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
actionpack (7.0.7.2)
|
||||
actionview (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
rack (~> 2.0, >= 2.2.4)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
activerecord (= 7.0.7)
|
||||
activestorage (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
actiontext (7.0.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
activerecord (= 7.0.7.2)
|
||||
activestorage (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
actionview (7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
|
@ -89,22 +89,22 @@ GEM
|
|||
activemodel (>= 4.1, < 7.1)
|
||||
case_transform (>= 0.2)
|
||||
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
||||
activejob (7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
activejob (7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
activerecord (7.0.7)
|
||||
activemodel (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
activestorage (7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
activejob (= 7.0.7)
|
||||
activerecord (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
activemodel (7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
activerecord (7.0.7.2)
|
||||
activemodel (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
activestorage (7.0.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
activejob (= 7.0.7.2)
|
||||
activerecord (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
marcel (~> 1.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (7.0.7)
|
||||
activesupport (7.0.7.2)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
|
@ -147,6 +147,7 @@ GEM
|
|||
faraday_middleware (~> 1.0, >= 1.0.0.rc1)
|
||||
net-http-persistent (~> 4.0)
|
||||
nokogiri (~> 1, >= 1.10.8)
|
||||
base64 (0.1.1)
|
||||
bcrypt (3.1.18)
|
||||
better_errors (2.10.1)
|
||||
erubi (>= 1.0.0)
|
||||
|
@ -451,7 +452,7 @@ GEM
|
|||
hashie (~> 5.0)
|
||||
memory_profiler (1.0.1)
|
||||
method_source (1.0.0)
|
||||
mime-types (3.5.0)
|
||||
mime-types (3.5.1)
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2023.0808)
|
||||
mini_mime (1.1.5)
|
||||
|
@ -481,7 +482,7 @@ GEM
|
|||
nokogiri (1.15.4)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
oj (3.15.0)
|
||||
oj (3.16.0)
|
||||
omniauth (2.1.1)
|
||||
hashie (>= 3.4.6)
|
||||
rack (>= 2.2.3)
|
||||
|
@ -555,20 +556,20 @@ GEM
|
|||
rack
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rails (7.0.7)
|
||||
actioncable (= 7.0.7)
|
||||
actionmailbox (= 7.0.7)
|
||||
actionmailer (= 7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
actiontext (= 7.0.7)
|
||||
actionview (= 7.0.7)
|
||||
activejob (= 7.0.7)
|
||||
activemodel (= 7.0.7)
|
||||
activerecord (= 7.0.7)
|
||||
activestorage (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
rails (7.0.7.2)
|
||||
actioncable (= 7.0.7.2)
|
||||
actionmailbox (= 7.0.7.2)
|
||||
actionmailer (= 7.0.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
actiontext (= 7.0.7.2)
|
||||
actionview (= 7.0.7.2)
|
||||
activejob (= 7.0.7.2)
|
||||
activemodel (= 7.0.7.2)
|
||||
activerecord (= 7.0.7.2)
|
||||
activestorage (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.0.7)
|
||||
railties (= 7.0.7.2)
|
||||
rails-controller-testing (1.0.5)
|
||||
actionpack (>= 5.0.1.rc1)
|
||||
actionview (>= 5.0.1.rc1)
|
||||
|
@ -583,9 +584,9 @@ GEM
|
|||
rails-i18n (7.0.7)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 6.0.0, < 8)
|
||||
railties (7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
railties (7.0.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
method_source
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0)
|
||||
|
@ -634,12 +635,15 @@ GEM
|
|||
rspec-support (~> 3.12)
|
||||
rspec-retry (0.6.2)
|
||||
rspec-core (> 3.3)
|
||||
rspec-sidekiq (3.1.0)
|
||||
rspec-core (~> 3.0, >= 3.0.0)
|
||||
sidekiq (>= 2.4.0)
|
||||
rspec-support (3.12.0)
|
||||
rspec-sidekiq (4.0.1)
|
||||
rspec-core (~> 3.0)
|
||||
rspec-expectations (~> 3.0)
|
||||
rspec-mocks (~> 3.0)
|
||||
sidekiq (>= 5, < 8)
|
||||
rspec-support (3.12.1)
|
||||
rspec_chunked (0.6)
|
||||
rubocop (1.54.2)
|
||||
rubocop (1.56.1)
|
||||
base64 (~> 0.1.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
|
@ -647,7 +651,7 @@ GEM
|
|||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 1.8, < 3.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)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.29.0)
|
||||
|
@ -656,14 +660,14 @@ GEM
|
|||
rubocop (~> 1.41)
|
||||
rubocop-factory_bot (2.23.1)
|
||||
rubocop (~> 1.33)
|
||||
rubocop-performance (1.18.0)
|
||||
rubocop-performance (1.19.0)
|
||||
rubocop (>= 1.7.0, < 2.0)
|
||||
rubocop-ast (>= 0.4.0)
|
||||
rubocop-rails (2.20.2)
|
||||
activesupport (>= 4.2.0)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.33.0, < 2.0)
|
||||
rubocop-rspec (2.22.0)
|
||||
rubocop-rspec (2.23.2)
|
||||
rubocop (~> 1.33)
|
||||
rubocop-capybara (~> 2.17)
|
||||
rubocop-factory_bot (~> 2.22)
|
||||
|
@ -912,7 +916,7 @@ DEPENDENCIES
|
|||
rqrcode (~> 2.2)
|
||||
rspec-rails (~> 6.0)
|
||||
rspec-retry (>= 0.6.2)
|
||||
rspec-sidekiq (~> 3.1)
|
||||
rspec-sidekiq (~> 4.0)
|
||||
rspec_chunked (~> 0.6)
|
||||
rubocop
|
||||
rubocop-capybara
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
# 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
|
||||
|
||||
|
|
43
Vagrantfile
vendored
43
Vagrantfile
vendored
|
@ -60,6 +60,37 @@ sudo usermod -a -G rvm $USER
|
|||
|
||||
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
|
||||
|
||||
source "/etc/profile.d/rvm.sh"
|
||||
|
@ -102,10 +133,8 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
|
|||
|
||||
config.vm.provider :virtualbox do |vb|
|
||||
vb.name = "mastodon"
|
||||
vb.customize ["modifyvm", :id, "--memory", "2048"]
|
||||
# Increase the number of CPUs. Uncomment and adjust to
|
||||
# increase performance
|
||||
# vb.customize ["modifyvm", :id, "--cpus", "3"]
|
||||
vb.customize ["modifyvm", :id, "--memory", "8192"]
|
||||
vb.customize ["modifyvm", :id, "--cpus", "3"]
|
||||
|
||||
# Disable VirtualBox DNS proxy to skip long-delay IPv6 resolutions.
|
||||
# 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: 4000, host: 4000
|
||||
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'
|
||||
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.post_up_message = <<MESSAGE
|
||||
|
|
|
@ -60,8 +60,9 @@ class AccountsIndex < Chewy::Index
|
|||
field(:followers_count, type: 'long', value: ->(account) { account.public_followers_count })
|
||||
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(: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(: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
|
||||
|
|
51
app/chewy/public_statuses_index.rb
Normal file
51
app/chewy/public_statuses_index.rb
Normal 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
|
|
@ -1,23 +1,24 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class StatusesIndex < Chewy::Index
|
||||
include FormattingHelper
|
||||
|
||||
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',
|
||||
|
@ -35,7 +36,7 @@ class StatusesIndex < Chewy::Index
|
|||
|
||||
# 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
|
||||
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|
|
||||
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) }
|
||||
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|
|
||||
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) }
|
||||
|
@ -68,14 +74,14 @@ class StatusesIndex < Chewy::Index
|
|||
end
|
||||
|
||||
root date_detection: false do
|
||||
field :id, type: 'long'
|
||||
field :account_id, type: 'long'
|
||||
|
||||
field :text, type: 'text', value: ->(status) { status.searchable_text } do
|
||||
field :stemmed, type: 'text', analyzer: 'content'
|
||||
end
|
||||
|
||||
field :searchable_by, type: 'long', value: ->(status, crutches) { status.searchable_by(crutches) }
|
||||
field :searchability, type: 'keyword', value: ->(status) { status.compute_searchability }
|
||||
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(:searchable_by, type: 'long', value: ->(status, crutches) { status.searchable_by(crutches) })
|
||||
field(:searchability, type: 'keyword', value: ->(status) { status.compute_searchability })
|
||||
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
|
||||
|
|
|
@ -32,6 +32,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
|
|||
:searchability,
|
||||
:dissubscribable,
|
||||
:hide_collections,
|
||||
:indexable,
|
||||
fields_attributes: [:name, :value]
|
||||
)
|
||||
end
|
||||
|
|
|
@ -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
|
47
app/controllers/api/v1/bookmark_categories_controller.rb
Normal file
47
app/controllers/api/v1/bookmark_categories_controller.rb
Normal 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
|
|
@ -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
|
|
@ -18,7 +18,7 @@ class Settings::PrivacyController < Settings::BaseController
|
|||
private
|
||||
|
||||
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
|
||||
|
||||
def set_account
|
||||
|
|
|
@ -21,6 +21,7 @@ module ContextHelper
|
|||
blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
|
||||
discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
|
||||
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' },
|
||||
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' } },
|
||||
|
|
|
@ -14,6 +14,10 @@ module FormattingHelper
|
|||
end
|
||||
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)
|
||||
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
|
||||
|
|
|
@ -188,6 +188,7 @@ module LanguagesHelper
|
|||
|
||||
ISO_639_3 = {
|
||||
ast: ['Asturian', 'Asturianu'].freeze,
|
||||
chr: ['Cherokee', 'ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ'].freeze,
|
||||
ckb: ['Sorani (Kurdish)', 'سۆرانی'].freeze,
|
||||
cnr: ['Montenegrin', 'crnogorski'].freeze,
|
||||
jbo: ['Lojban', 'la .lojban.'].freeze,
|
||||
|
@ -200,6 +201,7 @@ module LanguagesHelper
|
|||
smj: ['Lule Sami', 'Julevsámegiella'].freeze,
|
||||
szl: ['Silesian', 'ślůnsko godka'].freeze,
|
||||
tok: ['Toki Pona', 'toki pona'].freeze,
|
||||
xal: ['Kalmyk', 'Хальмг келн'].freeze,
|
||||
zba: ['Balaibalan', 'باليبلن'].freeze,
|
||||
zgh: ['Standard Moroccan Tamazight', 'ⵜⴰⵎⴰⵣⵉⵖⵜ'].freeze,
|
||||
}.freeze
|
||||
|
|
393
app/javascript/mastodon/actions/bookmark_categories.js
Normal file
393
app/javascript/mastodon/actions/bookmark_categories.js
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -90,6 +90,7 @@ const messages = defineMessages({
|
|||
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
|
||||
open: { id: 'compose.published.open', defaultMessage: 'Open' },
|
||||
published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
|
||||
saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
|
||||
});
|
||||
|
||||
export const ensureComposeIsVisible = (getState, routerHistory) => {
|
||||
|
@ -253,7 +254,7 @@ export function submitCompose(routerHistory) {
|
|||
}
|
||||
|
||||
dispatch(showAlert({
|
||||
message: messages.published,
|
||||
message: statusId === null ? messages.published : messages.saved,
|
||||
action: messages.open,
|
||||
dismissAfter: 10000,
|
||||
onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`),
|
||||
|
|
|
@ -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', () => {
|
||||
const status = createStatus(
|
||||
'<p>Text <a href="test">#Éaa</a></p><p><a href="test">#éaa</a></p>',
|
||||
|
|
|
@ -16,7 +16,19 @@ export default class Column extends PureComponent {
|
|||
};
|
||||
|
||||
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) {
|
||||
return;
|
||||
|
|
|
@ -23,8 +23,9 @@ export type StatusLike = Record<{
|
|||
}>;
|
||||
|
||||
function normalizeHashtag(hashtag: string) {
|
||||
if (hashtag && hashtag.startsWith('#')) return hashtag.slice(1);
|
||||
else return hashtag;
|
||||
return (
|
||||
hashtag && hashtag.startsWith('#') ? hashtag.slice(1) : hashtag
|
||||
).normalize('NFKC');
|
||||
}
|
||||
|
||||
function isNodeLinkHashtag(element: Node): element is HTMLLinkElement {
|
||||
|
@ -70,9 +71,16 @@ function uniqueHashtagsWithCaseHandling(hashtags: string[]) {
|
|||
}
|
||||
|
||||
// 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) {
|
||||
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
|
||||
|
@ -121,11 +129,13 @@ export function computeHashtagBarForStatus(status: StatusLike): {
|
|||
// try to see if the last line is only hashtags
|
||||
let onlyHashtags = true;
|
||||
|
||||
const normalizedTagNames = tagNames.map((tag) => tag.normalize('NFKC'));
|
||||
|
||||
Array.from(lastChild.childNodes).forEach((node) => {
|
||||
if (isNodeLinkHashtag(node) && node.textContent) {
|
||||
const normalized = normalizeHashtag(node.textContent);
|
||||
|
||||
if (!localeAwareInclude(tagNames, normalized)) {
|
||||
if (!localeAwareInclude(normalizedTagNames, normalized)) {
|
||||
// stop here, this is not a real hashtag, so consider it as text
|
||||
onlyHashtags = false;
|
||||
return;
|
||||
|
@ -140,12 +150,14 @@ export function computeHashtagBarForStatus(status: StatusLike): {
|
|||
}
|
||||
});
|
||||
|
||||
const hashtagsInBar = tagNames.filter(
|
||||
(tag) =>
|
||||
// the tag does not appear at all in the status content, it is an out-of-band tag
|
||||
!localeAwareInclude(contentHashtags, tag) &&
|
||||
!localeAwareInclude(lastLineHashtags, tag),
|
||||
);
|
||||
const hashtagsInBar = tagNames.filter((tag) => {
|
||||
const normalizedTag = tag.normalize('NFKC');
|
||||
// the tag does not appear at all in the status content, it is an out-of-band tag
|
||||
return (
|
||||
!localeAwareInclude(contentHashtags, normalizedTag) &&
|
||||
!localeAwareInclude(lastLineHashtags, normalizedTag)
|
||||
);
|
||||
});
|
||||
|
||||
const isOnlyOneLine = contentWithoutLastLine.content.childElementCount === 0;
|
||||
const hasMedia = status.get('media_attachments').size > 0;
|
||||
|
@ -204,7 +216,7 @@ const HashtagBar: React.FC<{
|
|||
<div className='hashtag-bar'>
|
||||
{revealedHashtags.map((hashtag) => (
|
||||
<Link key={hashtag} to={`/tags/${hashtag}`}>
|
||||
#{hashtag}
|
||||
#<span>{hashtag}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
|
|
|
@ -583,10 +583,11 @@ class Status extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
||||
const expanded = !status.get('hidden')
|
||||
|
||||
return (
|
||||
<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}
|
||||
|
||||
<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
|
||||
status={status}
|
||||
onClick={this.handleClick}
|
||||
expanded={!status.get('hidden')}
|
||||
expanded={expanded}
|
||||
onExpandedToggle={this.handleExpandedToggle}
|
||||
onTranslate={this.handleTranslate}
|
||||
collapsible
|
||||
|
@ -621,7 +622,7 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
{(!isCardMediaWithSensitive || !status.get('hidden')) && media}
|
||||
|
||||
{hashtagBar}
|
||||
{expanded && hashtagBar}
|
||||
|
||||
{emojiReactionsBar}
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/
|
|||
|
||||
import DropdownMenuContainer from '../containers/dropdown_menu_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';
|
||||
|
||||
|
@ -37,6 +37,7 @@ const messages = defineMessages({
|
|||
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
|
||||
emojiReaction: { id: 'status.emoji_reaction', defaultMessage: 'Stamp' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||
bookmarkCategory: { id: 'status.bookmark_category', defaultMessage: 'Bookmark category' },
|
||||
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
|
||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
|
||||
|
@ -92,6 +93,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
onMuteConversation: PropTypes.func,
|
||||
onPin: PropTypes.func,
|
||||
onBookmark: PropTypes.func,
|
||||
onBookmarkCategoryAdder: PropTypes.func,
|
||||
onFilter: PropTypes.func,
|
||||
onAddFilter: PropTypes.func,
|
||||
onInteractionModal: PropTypes.func,
|
||||
|
@ -164,6 +166,18 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
|
@ -299,7 +313,8 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
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) {
|
||||
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
|
||||
|
|
|
@ -241,7 +241,7 @@ class StatusContent extends PureComponent {
|
|||
const renderReadMore = this.props.onClick && status.get('collapsed');
|
||||
const contentLocale = intl.locale.replace(/[_-].*/, '');
|
||||
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 spoilerContent = { __html: status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml') };
|
||||
|
|
|
@ -142,6 +142,15 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
|
|||
}
|
||||
},
|
||||
|
||||
onBookmarkCategoryAdder (status) {
|
||||
dispatch(openModal({
|
||||
modalType: 'BOOKMARK_CATEGORY_ADDER',
|
||||
modalProps: {
|
||||
statusId: status.get('id'),
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
onPin (status) {
|
||||
if (status.get('pinned')) {
|
||||
dispatch(unpin(status));
|
||||
|
|
|
@ -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));
|
|
@ -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));
|
|
@ -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));
|
|
@ -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));
|
|
@ -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));
|
|
@ -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));
|
|
@ -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));
|
|
@ -108,10 +108,10 @@ class Results extends PureComponent {
|
|||
return (
|
||||
<>
|
||||
<div className='account__section-headline'>
|
||||
<button onClick={this.handleSelectAll} className={type === 'all' && 'active'}><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.handleSelectHashtags} className={type === 'hashtags' && 'active'}><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.handleSelectAll} className={type === 'all' ? 'active' : undefined}><FormattedMessage id='search_results.all' defaultMessage='All' /></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' : undefined}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
|
||||
<button onClick={this.handleSelectStatuses} className={type === 'statuses' ? 'active' : undefined}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
|
||||
</div>
|
||||
|
||||
<div className='explore__search-results'>
|
||||
|
|
|
@ -147,7 +147,7 @@ class ListTimeline extends PureComponent {
|
|||
handleEditAntennaClick = (e) => {
|
||||
const id = e.currentTarget.getAttribute('data-id');
|
||||
this.context.router.history.push(`/antennasw/${id}/edit`);
|
||||
}
|
||||
};
|
||||
|
||||
handleRepliesPolicyChange = ({ target }) => {
|
||||
const { dispatch } = this.props;
|
||||
|
|
|
@ -12,7 +12,7 @@ import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/
|
|||
|
||||
import { IconButton } from '../../../components/icon_button';
|
||||
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';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -72,6 +72,7 @@ class ActionBar extends PureComponent {
|
|||
onEmojiReact: PropTypes.func.isRequired,
|
||||
onReference: PropTypes.func.isRequired,
|
||||
onBookmark: PropTypes.func.isRequired,
|
||||
onBookmarkCategoryAdder: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
onDirect: PropTypes.func.isRequired,
|
||||
|
@ -106,7 +107,11 @@ class ActionBar extends PureComponent {
|
|||
};
|
||||
|
||||
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 = () => {
|
||||
|
|
|
@ -372,6 +372,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
||||
const expanded = !status.get('hidden')
|
||||
|
||||
return (
|
||||
<div style={outerStyle}>
|
||||
|
@ -397,7 +398,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
|
||||
{(!isCardMediaWithSensitive || !status.get('hidden')) && media}
|
||||
|
||||
{hashtagBar}
|
||||
{expanded && hashtagBar}
|
||||
|
||||
{emojiReactionsBar}
|
||||
|
||||
|
|
|
@ -63,7 +63,7 @@ import {
|
|||
import ColumnHeader from '../../components/column_header';
|
||||
import { textForScreenReader, defaultMediaVisibility } from '../../components/status';
|
||||
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 Column from '../ui/components/column';
|
||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
|
||||
|
@ -367,6 +367,11 @@ class Status extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
handleBookmarkClick = (status) => {
|
||||
if (bookmarkCategoryNeeded) {
|
||||
this.handleBookmarkCategoryAdderClick(status);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.get('bookmarked')) {
|
||||
this.props.dispatch(unbookmark(status));
|
||||
} 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) => {
|
||||
const { dispatch, intl } = this.props;
|
||||
|
||||
|
@ -609,7 +623,7 @@ class Status extends ImmutablePureComponent {
|
|||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
contextType='thread'
|
||||
previousId={i > 0 && list.get(i - 1)}
|
||||
previousId={i > 0 ? list.get(i - 1) : undefined}
|
||||
nextId={list.get(i + 1) || (ancestors && statusId)}
|
||||
rootId={statusId}
|
||||
/>
|
||||
|
@ -737,6 +751,7 @@ class Status extends ImmutablePureComponent {
|
|||
onReblogForceModal={this.handleReblogForceModalClick}
|
||||
onReference={this.handleReference}
|
||||
onBookmark={this.handleBookmarkClick}
|
||||
onBookmarkCategoryAdder={this.handleBookmarkCategoryAdderClick}
|
||||
onDelete={this.handleDeleteClick}
|
||||
onEdit={this.handleEditClick}
|
||||
onDirect={this.handleDirectClick}
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
AntennaAdder,
|
||||
CircleEditor,
|
||||
CircleAdder,
|
||||
BookmarkCategoryAdder,
|
||||
CompareHistoryModal,
|
||||
FilterModal,
|
||||
InteractionModal,
|
||||
|
@ -55,6 +56,7 @@ export const MODAL_COMPONENTS = {
|
|||
'LIST_ADDER': ListAdder,
|
||||
'ANTENNA_ADDER': AntennaAdder,
|
||||
'CIRCLE_ADDER': CircleAdder,
|
||||
'BOOKMARK_CATEGORY_ADDER': BookmarkCategoryAdder,
|
||||
'COMPARE_HISTORY': CompareHistoryModal,
|
||||
'FILTER': FilterModal,
|
||||
'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal,
|
||||
|
@ -123,7 +125,10 @@ export default class ModalRoot extends PureComponent {
|
|||
{visible && (
|
||||
<>
|
||||
<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>
|
||||
|
||||
<Helmet>
|
||||
|
|
|
@ -117,7 +117,7 @@ class NavigationPanel extends Component {
|
|||
|
||||
{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)} />
|
||||
<hr />
|
||||
|
||||
|
|
|
@ -62,7 +62,7 @@ class ReportModal extends ImmutablePureComponent {
|
|||
dispatch(submitReport({
|
||||
account_id: accountId,
|
||||
status_ids: selectedStatusIds.toArray(),
|
||||
selected_domains: selectedDomains.toArray(),
|
||||
forward_to_domains: selectedDomains.toArray(),
|
||||
comment,
|
||||
forward: selectedDomains.size > 0,
|
||||
category,
|
||||
|
|
|
@ -54,6 +54,8 @@ import {
|
|||
FavouritedStatuses,
|
||||
EmojiReactedStatuses,
|
||||
BookmarkedStatuses,
|
||||
BookmarkCategories,
|
||||
BookmarkCategoryStatuses,
|
||||
FollowedTags,
|
||||
ListTimeline,
|
||||
Blocks,
|
||||
|
@ -218,6 +220,8 @@ class SwitchingColumnsArea extends PureComponent {
|
|||
<WrappedRoute path='/emoji_reactions' component={EmojiReactedStatuses} 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='/reaction_deck' component={ReactionDeck} content={children} />
|
||||
|
|
|
@ -122,6 +122,18 @@ export function BookmarkedStatuses () {
|
|||
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 () {
|
||||
return import(/* webpackChunkName: "features/blocks" */'../../blocks');
|
||||
}
|
||||
|
|
|
@ -50,6 +50,7 @@
|
|||
* @property {boolean} auto_play_gif
|
||||
* @property {boolean} activity_api_enabled
|
||||
* @property {string} admin
|
||||
* @property {boolean} bookmark_category_needed
|
||||
* @property {boolean=} boost_modal
|
||||
* @property {boolean=} delete_modal
|
||||
* @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 autoPlayGif = getMeta('auto_play_gif');
|
||||
export const bookmarkCategoryNeeded = getMeta('bookmark_category_needed');
|
||||
export const boostModal = getMeta('boost_modal');
|
||||
export const deleteModal = getMeta('delete_modal');
|
||||
export const disableSwiping = getMeta('disable_swiping');
|
||||
|
|
|
@ -142,6 +142,7 @@
|
|||
"compose.language.search": "Search languages...",
|
||||
"compose.published.body": "Post published.",
|
||||
"compose.published.open": "Open",
|
||||
"compose.saved.body": "Post saved.",
|
||||
"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.hashtag_warning": "This post won't be listed under any hashtag as it is not public. Only public posts can be searched by hashtag.",
|
||||
|
|
93
app/javascript/mastodon/reducers/bookmark_categories.js
Normal file
93
app/javascript/mastodon/reducers/bookmark_categories.js
Normal 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;
|
||||
}
|
||||
}
|
53
app/javascript/mastodon/reducers/bookmark_category_adder.js
Normal file
53
app/javascript/mastodon/reducers/bookmark_category_adder.js
Normal 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;
|
||||
}
|
||||
}
|
67
app/javascript/mastodon/reducers/bookmark_category_editor.js
Normal file
67
app/javascript/mastodon/reducers/bookmark_category_editor.js
Normal 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;
|
||||
}
|
||||
}
|
|
@ -12,6 +12,9 @@ import antennaAdder from './antenna_adder';
|
|||
import antennaEditor from './antenna_editor';
|
||||
import antennas from './antennas';
|
||||
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 circleAdder from './circle_adder';
|
||||
import circleEditor from './circle_editor';
|
||||
|
@ -89,6 +92,9 @@ const reducers = {
|
|||
circles,
|
||||
circleEditor,
|
||||
circleAdder,
|
||||
bookmark_categories,
|
||||
bookmarkCategoryEditor,
|
||||
bookmarkCategoryAdder,
|
||||
filters,
|
||||
conversations,
|
||||
suggestions,
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
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 { EMOJI_USE } from '../actions/emojis';
|
||||
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 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) {
|
||||
switch(action.type) {
|
||||
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;
|
||||
case LIST_DELETE_SUCCESS:
|
||||
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:
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -4,6 +4,9 @@ import {
|
|||
ACCOUNT_BLOCK_SUCCESS,
|
||||
ACCOUNT_MUTE_SUCCESS,
|
||||
} from '../actions/accounts';
|
||||
import {
|
||||
BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS,
|
||||
} from '../actions/bookmark_categories';
|
||||
import {
|
||||
BOOKMARKED_STATUSES_FETCH_REQUEST,
|
||||
BOOKMARKED_STATUSES_FETCH_SUCCESS,
|
||||
|
@ -98,11 +101,15 @@ const appendToList = (state, listType, statuses, next) => {
|
|||
};
|
||||
|
||||
const prependOneToList = (state, listType, status) => {
|
||||
return prependOneToListById(state, listType, status.get('id'));
|
||||
};
|
||||
|
||||
const prependOneToListById = (state, listType, statusId) => {
|
||||
return state.updateIn([listType, 'items'], (list) => {
|
||||
if (list.includes(status.get('id'))) {
|
||||
if (list.includes(statusId)) {
|
||||
return list;
|
||||
} 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);
|
||||
case BOOKMARK_SUCCESS:
|
||||
return prependOneToList(state, 'bookmarks', action.status);
|
||||
case BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS:
|
||||
return prependOneToListById(state, 'bookmarks', action.statusId);
|
||||
case UNBOOKMARK_SUCCESS:
|
||||
return removeOneFromList(state, 'bookmarks', action.status);
|
||||
case PINNED_STATUSES_FETCH_SUCCESS:
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
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 { normalizeStatusTranslation } from '../actions/importer/normalizer';
|
||||
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);
|
||||
case BOOKMARK_FAIL:
|
||||
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:
|
||||
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], false);
|
||||
case UNBOOKMARK_FAIL:
|
||||
|
|
|
@ -131,3 +131,7 @@ export const getAccountHidden = createSelector([
|
|||
export const getStatusList = createSelector([
|
||||
(state, type) => state.getIn(['status_lists', type, 'items']),
|
||||
], (items) => items.toList());
|
||||
|
||||
export const getBookmarkCategoryStatusList = createSelector([
|
||||
(state, bookmarkCategoryId) => state.getIn(['bookmark_categories', bookmarkCategoryId, 'items']),
|
||||
], (items) => items ? items.toList() : ImmutableList());
|
||||
|
|
|
@ -5338,6 +5338,7 @@ a.status-card {
|
|||
|
||||
&.active {
|
||||
transform: rotate(90deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&: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 {
|
||||
font-size: inherit;
|
||||
display: inline;
|
||||
|
@ -9620,16 +9659,15 @@ noscript {
|
|||
|
||||
a {
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
background: rgba($highlight-text-color, 0.2);
|
||||
color: $highlight-text-color;
|
||||
padding: 0.4em 0.6em;
|
||||
color: $dark-text-color;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: rgba($highlight-text-color, 0.3);
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
|
||||
span {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim
|
|||
protected
|
||||
|
||||
def perform_query
|
||||
[mastodon_version, ruby_version, postgresql_version, redis_version]
|
||||
[mastodon_version, ruby_version, postgresql_version, redis_version, elasticsearch_version].compact
|
||||
end
|
||||
|
||||
def mastodon_version
|
||||
|
@ -57,6 +57,22 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim
|
|||
}
|
||||
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
|
||||
@redis_info ||= if redis.is_a?(Redis::Namespace)
|
||||
redis.redis.info
|
||||
|
|
|
@ -6,6 +6,7 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
|
|||
AccountsIndex,
|
||||
TagsIndex,
|
||||
StatusesIndex,
|
||||
PublicStatusesIndex,
|
||||
].freeze
|
||||
|
||||
def skip?
|
||||
|
@ -41,7 +42,7 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
|
|||
Admin::SystemCheck::Message.new(:elasticsearch_health_red)
|
||||
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')
|
||||
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)
|
||||
elsif cluster_health['status'] == 'yellow'
|
||||
Admin::SystemCheck::Message.new(:elasticsearch_health_yellow)
|
||||
|
@ -85,7 +86,7 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
|
|||
|
||||
def mismatched_indexes
|
||||
@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
|
||||
|
||||
|
|
|
@ -68,8 +68,8 @@ class Importer::BaseImporter
|
|||
|
||||
protected
|
||||
|
||||
def in_work_unit(*args, &block)
|
||||
work_unit = Concurrent::Promises.future_on(@executor, *args, &block)
|
||||
def in_work_unit(...)
|
||||
work_unit = Concurrent::Promises.future_on(@executor, ...)
|
||||
|
||||
work_unit.on_fulfillment!(&@on_progress)
|
||||
work_unit.on_rejection!(&@on_failure)
|
||||
|
|
41
app/lib/importer/public_statuses_index_importer.rb
Normal file
41
app/lib/importer/public_statuses_index_importer.rb
Normal 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
|
|
@ -36,7 +36,7 @@ class SearchQueryTransformer < Parslet::Transform
|
|||
def clause_to_filter(clause)
|
||||
case clause
|
||||
when PrefixClause
|
||||
{ term: { clause.filter => clause.term } }
|
||||
{ clause.type => { clause.filter => clause.term } }
|
||||
else
|
||||
raise "Unexpected clause type: #{clause}"
|
||||
end
|
||||
|
@ -47,12 +47,10 @@ class SearchQueryTransformer < Parslet::Transform
|
|||
class << self
|
||||
def symbol(str)
|
||||
case str
|
||||
when '+'
|
||||
when '+', nil
|
||||
:must
|
||||
when '-'
|
||||
:must_not
|
||||
when nil
|
||||
:should
|
||||
else
|
||||
raise "Unknown operator: #{str}"
|
||||
end
|
||||
|
@ -81,23 +79,52 @@ class SearchQueryTransformer < Parslet::Transform
|
|||
end
|
||||
|
||||
class PrefixClause
|
||||
attr_reader :filter, :operator, :term
|
||||
attr_reader :type, :filter, :operator, :term
|
||||
|
||||
def initialize(prefix, term)
|
||||
@operator = :filter
|
||||
|
||||
case prefix
|
||||
when 'has', 'is'
|
||||
@filter = :properties
|
||||
@type = :term
|
||||
@term = term
|
||||
when 'language'
|
||||
@filter = :language
|
||||
@type = :term
|
||||
@term = term
|
||||
when 'from'
|
||||
@filter = :account_id
|
||||
|
||||
username, domain = term.gsub(/\A@/, '').split('@')
|
||||
domain = nil if TagManager.instance.local_domain?(domain)
|
||||
account = Account.find_remote!(username, domain)
|
||||
|
||||
@term = account.id
|
||||
@type = :term
|
||||
@term = account_id_from_term(term)
|
||||
when 'before'
|
||||
@filter = :created_at
|
||||
@type = :range
|
||||
@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
|
||||
raise Mastodon::SyntaxError
|
||||
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
|
||||
|
||||
rule(clause: subtree(:clause)) do
|
||||
|
|
|
@ -20,7 +20,10 @@ class Vacuum::StatusesVacuum
|
|||
statuses.direct_visibility
|
||||
.includes(mentions: :account)
|
||||
.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.
|
||||
# Media attachments will be orphaned.
|
||||
|
@ -38,7 +41,7 @@ class Vacuum::StatusesVacuum
|
|||
Mastodon::Snowflake.id_at(@retention_period.ago, with_random: false)
|
||||
end
|
||||
|
||||
def remove_from_search_index(status_ids)
|
||||
with_redis { |redis| redis.sadd('chewy:queue:StatusesIndex', status_ids) }
|
||||
def remove_from_index(status_ids, index)
|
||||
with_redis { |redis| redis.sadd(index, status_ids) }
|
||||
end
|
||||
end
|
||||
|
|
|
@ -86,6 +86,7 @@ class Account < ApplicationRecord
|
|||
include DomainMaterializable
|
||||
include AccountMerging
|
||||
include AccountSearch
|
||||
include AccountStatusesSearch
|
||||
|
||||
enum protocol: { ostatus: 0, activitypub: 1 }
|
||||
enum suspension_origin: { local: 0, remote: 1 }, _prefix: true
|
||||
|
|
|
@ -121,7 +121,7 @@ class Antenna < ApplicationRecord
|
|||
end
|
||||
|
||||
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
|
||||
|
||||
def tags_raw=(raw)
|
||||
|
|
29
app/models/bookmark_category.rb
Normal file
29
app/models/bookmark_category.rb
Normal 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
|
34
app/models/bookmark_category_status.rb
Normal file
34
app/models/bookmark_category_status.rb
Normal 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
|
|
@ -15,6 +15,9 @@ module AccountAssociations
|
|||
has_many :favourites, inverse_of: :account, dependent: :destroy
|
||||
has_many :emoji_reactions, 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 :notifications, inverse_of: :account, dependent: :destroy
|
||||
has_many :conversations, class_name: 'AccountConversation', dependent: :destroy, inverse_of: :account
|
||||
|
|
44
app/models/concerns/account_statuses_search.rb
Normal file
44
app/models/concerns/account_statuses_search.rb
Normal 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
|
|
@ -31,6 +31,10 @@ module HasUserSettings
|
|||
settings['web.enable_login_privacy']
|
||||
end
|
||||
|
||||
def setting_bookmark_category_needed
|
||||
settings['web.bookmark_category_needed']
|
||||
end
|
||||
|
||||
def setting_hide_recent_emojis
|
||||
settings['web.hide_recent_emojis']
|
||||
end
|
||||
|
|
59
app/models/concerns/status_search_concern.rb
Normal file
59
app/models/concerns/status_search_concern.rb
Normal 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
|
|
@ -46,11 +46,11 @@ class PublicFeed
|
|||
end
|
||||
|
||||
def local_only?
|
||||
options[:local]
|
||||
options[:local] && !options[:remote]
|
||||
end
|
||||
|
||||
def remote_only?
|
||||
options[:remote]
|
||||
options[:remote] && !options[:local]
|
||||
end
|
||||
|
||||
def hide_local_users?
|
||||
|
|
|
@ -42,6 +42,7 @@ class Status < ApplicationRecord
|
|||
include StatusSnapshotConcern
|
||||
include RateLimitable
|
||||
include StatusSafeReblogInsert
|
||||
include StatusSearchConcern
|
||||
|
||||
rate_limit by: :account, family: :statuses
|
||||
|
||||
|
@ -52,6 +53,7 @@ class Status < ApplicationRecord
|
|||
attr_accessor :override_timestamps
|
||||
|
||||
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 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_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 :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 :preview_cards
|
||||
|
@ -183,39 +187,6 @@ class Status < ApplicationRecord
|
|||
"v3:#{super}"
|
||||
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
|
||||
account.acct
|
||||
end
|
||||
|
@ -295,6 +266,14 @@ class Status < ApplicationRecord
|
|||
preview_cards.any?
|
||||
end
|
||||
|
||||
def with_poll?
|
||||
preloadable_poll.present?
|
||||
end
|
||||
|
||||
def with_status_reference?
|
||||
reference_objects.any?
|
||||
end
|
||||
|
||||
def non_sensitive_with_media?
|
||||
!sensitive? && with_media?
|
||||
end
|
||||
|
|
|
@ -42,6 +42,7 @@ class UserSettings
|
|||
setting :use_blurhash, default: true
|
||||
setting :use_pending_items, default: false
|
||||
setting :use_system_font, default: false
|
||||
setting :bookmark_category_needed, default: false
|
||||
setting :disable_swiping, default: false
|
||||
setting :delete_modal, default: true
|
||||
setting :enable_login_privacy, default: false
|
||||
|
|
|
@ -8,13 +8,13 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
|
|||
|
||||
context_extensions :manually_approves_followers, :featured, :also_known_as,
|
||||
:moved_to, :property_value, :discoverable, :olm, :suspended, :searchable_by, :subscribable_by,
|
||||
:other_setting
|
||||
:other_setting, :memorial, :indexable
|
||||
|
||||
attributes :id, :type, :following, :followers,
|
||||
:inbox, :outbox, :featured, :featured_tags,
|
||||
:preferred_username, :name, :summary,
|
||||
: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
|
||||
|
||||
|
@ -107,6 +107,10 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
|
|||
object.suspended? ? false : (object.discoverable || false)
|
||||
end
|
||||
|
||||
def indexable
|
||||
object.suspended? ? false : (object.indexable || false)
|
||||
end
|
||||
|
||||
def name
|
||||
object.suspended? ? object.username : (object.display_name.presence || object.username)
|
||||
end
|
||||
|
|
|
@ -52,6 +52,7 @@ class InitialStateSerializer < ActiveModel::Serializer
|
|||
store[:use_blurhash] = object.current_account.user.setting_use_blurhash
|
||||
store[:use_pending_items] = object.current_account.user.setting_use_pending_items
|
||||
store[:show_trends] = Setting.trends && object.current_account.user.setting_trends
|
||||
store[:bookmark_category_needed] = object.current_account.user.setting_bookmark_category_needed
|
||||
else
|
||||
store[:auto_play_gif] = Setting.auto_play_gif
|
||||
store[:display_media] = Setting.display_media
|
||||
|
|
9
app/serializers/rest/bookmark_category_serializer.rb
Normal file
9
app/serializers/rest/bookmark_category_serializer.rb
Normal 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
|
|
@ -122,6 +122,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
|||
:visibility_limited,
|
||||
:kmyblue_limited_scope,
|
||||
:kmyblue_antenna,
|
||||
:kmyblue_bookmark_category,
|
||||
]
|
||||
|
||||
capabilities << :profile_search unless Chewy.enabled?
|
||||
|
|
|
@ -131,6 +131,7 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
|
|||
:visibility_limited,
|
||||
:kmyblue_limited_scope,
|
||||
:kmyblue_antenna,
|
||||
:kmyblue_bookmark_category,
|
||||
]
|
||||
|
||||
capabilities << :profile_search unless Chewy.enabled?
|
||||
|
|
|
@ -147,7 +147,7 @@ class AccountSearchService < BaseService
|
|||
multi_match: {
|
||||
query: @query,
|
||||
type: 'bool_prefix',
|
||||
fields: %w(username username.* display_name display_name.*),
|
||||
fields: %w(username^2 username.*^2 display_name display_name.*),
|
||||
},
|
||||
}
|
||||
end
|
||||
|
|
|
@ -127,6 +127,7 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||
@account.searchability = searchability_from_audience
|
||||
@account.dissubscribable = !subscribable(@account.note)
|
||||
@account.settings = other_settings
|
||||
@account.memorial = @json['memorial'] || false
|
||||
end
|
||||
|
||||
def valid_account?
|
||||
|
|
|
@ -35,7 +35,10 @@ class BatchedRemoveStatusService < BaseService
|
|||
|
||||
# Since we skipped all callbacks, we also need to manually
|
||||
# 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]
|
||||
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class DeliveryAntennaService
|
||||
include FormattingHelper
|
||||
|
||||
def call(status, update, stl_home)
|
||||
@status = status
|
||||
@account = @status.account
|
||||
|
@ -37,12 +39,13 @@ class DeliveryAntennaService
|
|||
antennas = antennas.where(stl: false)
|
||||
|
||||
collection = AntennaCollection.new(@status, @update, false)
|
||||
content = extract_status_plain_text_with_spoiler_text(@status)
|
||||
|
||||
antennas.in_batches do |ans|
|
||||
ans.each do |antenna|
|
||||
next unless antenna.enabled?
|
||||
next if antenna.keywords&.any? && antenna.keywords&.none? { |keyword| @status.text.include?(keyword) }
|
||||
next if antenna.exclude_keywords&.any? { |keyword| @status.text.include?(keyword) }
|
||||
next if antenna.keywords&.any? && antenna.keywords&.none? { |keyword| content.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_domains&.include?(domain)
|
||||
next if antenna.exclude_tags&.any? { |tag_id| tag_ids.include?(tag_id) }
|
||||
|
|
|
@ -41,41 +41,16 @@ class SearchService < BaseService
|
|||
end
|
||||
|
||||
def perform_statuses_search!
|
||||
privacy_definition = parsed_query.apply(StatusesIndex.filter(terms: { searchability: %w(public private direct) }).filter(term: { searchable_by: @account.id }))
|
||||
|
||||
# 'direct' searchability posts are NOT in here because it's already added at previous line.
|
||||
case @searchability
|
||||
when 'public'
|
||||
privacy_definition = privacy_definition.or(StatusesIndex.filter(term: { searchability: 'public' }))
|
||||
privacy_definition = privacy_definition.or(StatusesIndex.filter(term: { searchability: '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 'private', 'direct'
|
||||
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
|
||||
[]
|
||||
StatusesSearchService.new.call(
|
||||
@query,
|
||||
@account,
|
||||
limit: @limit,
|
||||
offset: @offset,
|
||||
account_id: @options[:account_id],
|
||||
min_id: @options[:min_id],
|
||||
max_id: @options[:max_id],
|
||||
searchability: @searchability
|
||||
)
|
||||
end
|
||||
|
||||
def perform_hashtags_search!
|
||||
|
@ -132,17 +107,4 @@ class SearchService < BaseService
|
|||
def statuses_search?
|
||||
@options[:type].blank? || @options[:type] == 'statuses'
|
||||
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
|
||||
|
|
145
app/services/statuses_search_service.rb
Normal file
145
app/services/statuses_search_service.rb
Normal 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
|
|
@ -1,9 +1,6 @@
|
|||
- content_for :page_title do
|
||||
= 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
|
||||
.filters
|
||||
.filter-subset.filter-subset--with-select
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
- content_for :page_title do
|
||||
= 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
|
||||
= hidden_field_tag :target_account_id, params[:target_account_id] if params[:target_account_id].present?
|
||||
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
- content_for :page_title do
|
||||
= 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|
|
||||
= render 'shared/error_messages', object: @announcement
|
||||
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
- content_for :page_title do
|
||||
= 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|
|
||||
= render 'shared/error_messages', object: @announcement
|
||||
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
- content_for :page_title do
|
||||
= t('admin.custom_emojis.title')
|
||||
|
||||
- content_for :header_tags do
|
||||
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
|
||||
|
||||
- if can?(:create, :custom_emoji)
|
||||
- content_for :heading_actions do
|
||||
= link_to t('admin.custom_emojis.upload'), new_admin_custom_emoji_path, class: 'button'
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
- content_for :header_tags do
|
||||
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
|
||||
|
||||
- content_for :page_title do
|
||||
= t('admin.dashboard.title')
|
||||
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
- content_for :page_title do
|
||||
= t('admin.disputes.appeals.title')
|
||||
|
||||
- content_for :header_tags do
|
||||
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
|
||||
|
||||
.filters
|
||||
.filter-subset
|
||||
%strong= t('admin.tags.review')
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
- content_for :header_tags do
|
||||
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
|
||||
|
||||
- content_for :page_title do
|
||||
= t('admin.domain_allows.add_new')
|
||||
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
- content_for :header_tags do
|
||||
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
|
||||
|
||||
- content_for :page_title do
|
||||
= t('.title', domain: Addressable::IDNA.to_unicode(@domain_block.domain))
|
||||
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
- content_for :header_tags do
|
||||
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
|
||||
|
||||
- content_for :page_title do
|
||||
= t('admin.domain_blocks.edit')
|
||||
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
- content_for :header_tags do
|
||||
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
|
||||
|
||||
- content_for :page_title do
|
||||
= t('.title')
|
||||
|
||||
|
|
|
@ -4,9 +4,6 @@
|
|||
- content_for :heading_actions do
|
||||
= 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|
|
||||
= hidden_field_tag :page, params[:page] || 1
|
||||
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
- content_for :page_title do
|
||||
= 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|
|
||||
= render 'shared/error_messages', object: @email_domain_block
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue