Merge branch 'main' into feature/allow-mixture-media

This commit is contained in:
Tianwei Dong 2024-12-23 23:00:21 +00:00 committed by GitHub
commit 8c2d4af789
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
898 changed files with 17792 additions and 13285 deletions

View File

@ -109,7 +109,7 @@ module.exports = defineConfig({
'react/jsx-equals-spacing': 'error',
'react/jsx-no-bind': 'error',
'react/jsx-no-useless-fragment': 'error',
'react/jsx-no-target-blank': 'off',
'react/jsx-no-target-blank': ['error', { allowReferrer: true }],
'react/jsx-tag-spacing': 'error',
'react/jsx-uses-react': 'off', // not needed with new JSX transform
'react/jsx-wrap-multilines': 'error',

View File

@ -36,4 +36,4 @@ jobs:
bundler-cache: true
- name: Run bundler-audit
run: bundle exec bundler-audit check --update
run: bin/bundler-audit check --update

View File

@ -35,18 +35,18 @@ jobs:
git diff --exit-code
- name: Check locale file normalization
run: bundle exec i18n-tasks check-normalized
run: bin/i18n-tasks check-normalized
- name: Check for unused strings
run: bundle exec i18n-tasks unused
run: bin/i18n-tasks unused
- name: Check for missing strings in English YML
run: |
bundle exec i18n-tasks add-missing -l en
bin/i18n-tasks add-missing -l en
git diff --exit-code
- name: Check for wrong string interpolations
run: bundle exec i18n-tasks check-consistent-interpolations
run: bin/i18n-tasks check-consistent-interpolations
- name: Check that all required locale files exist
run: bundle exec rake repo:check_locales_files
run: bin/rake repo:check_locales_files

View File

@ -46,7 +46,7 @@ jobs:
uses: ./.github/actions/setup-ruby
- name: Run i18n normalize task
run: bundle exec i18n-tasks normalize
run: bin/i18n-tasks normalize
# Create or update the pull request
- name: Create Pull Request

View File

@ -48,7 +48,7 @@ jobs:
uses: ./.github/actions/setup-ruby
- name: Run i18n normalize task
run: bundle exec i18n-tasks normalize
run: bin/i18n-tasks normalize
# Create or update the pull request
- name: Create Pull Request

View File

@ -40,4 +40,4 @@ jobs:
uses: ./.github/actions/setup-javascript
- name: Stylelint
run: yarn lint:css -f github
run: yarn lint:css --custom-formatter @csstools/stylelint-formatter-github

View File

@ -43,4 +43,4 @@ jobs:
- name: Run haml-lint
run: |
echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json"
bundle exec haml-lint --reporter github
bin/haml-lint --reporter github

View File

@ -9,6 +9,7 @@ on:
- 'Gemfile*'
- '.rubocop*.yml'
- '.ruby-version'
- 'bin/rubocop'
- 'config/brakeman.ignore'
- '**/*.rb'
- '**/*.rake'
@ -19,6 +20,7 @@ on:
- 'Gemfile*'
- '.rubocop*.yml'
- '.ruby-version'
- 'bin/rubocop'
- 'config/brakeman.ignore'
- '**/*.rb'
- '**/*.rake'

View File

@ -12,6 +12,7 @@ on:
- '**/*.rb'
- '.github/workflows/test-migrations.yml'
- 'lib/tasks/tests.rake'
- 'lib/tasks/db.rake'
pull_request:
paths:
@ -90,6 +91,11 @@ jobs:
bin/rails db:drop
bin/rails db:create
SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails tests:migrations:prepare_database
# Migrate up to v4.2.0 breakpoint
bin/rails db:migrate VERSION=20230907150100
# Migrate the rest
SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails db:migrate
bin/rails db:migrate
bin/rails tests:migrations:check_database

View File

@ -166,7 +166,7 @@ jobs:
- name: Upload coverage reports to Codecov
if: matrix.ruby-version == '.ruby-version'
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
files: coverage/lcov/*.lcov
env:
@ -252,7 +252,7 @@ jobs:
- name: Upload coverage reports to Codecov
if: matrix.ruby-version == '.ruby-version'
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
files: coverage/lcov/mastodon.lcov
env:

2
.nvmrc
View File

@ -1 +1 @@
22.11
22.12

View File

@ -1,4 +1,7 @@
---
Style/ArrayIntersect:
Enabled: false
Style/ClassAndModuleChildren:
Enabled: false
@ -19,6 +22,16 @@ Style/HashSyntax:
EnforcedShorthandSyntax: either
EnforcedStyle: ruby19_no_mixed_keys
Style/IfUnlessModifier:
Exclude:
- '**/*.haml'
Style/KeywordArgumentsMerging:
Enabled: false
Style/MultipleComparison:
Enabled: false
Style/NumericLiterals:
AllowedPatterns:
- \d{4}_\d{2}_\d{2}_\d{6}
@ -37,6 +50,9 @@ Style/RedundantFetchBlock:
Style/RescueStandardError:
EnforcedStyle: implicit
Style/SafeNavigationChainLength:
Enabled: false
Style/SymbolArray:
Enabled: false

View File

@ -1,6 +1,6 @@
# This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
# using RuboCop version 1.66.1.
# using RuboCop version 1.69.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
@ -35,7 +35,6 @@ Rails/OutputSafety:
# Configuration parameters: AllowedVars.
Style/FetchEnvVar:
Exclude:
- 'app/lib/translation_service.rb'
- 'config/environments/production.rb'
- 'config/initializers/2_limited_federation_mode.rb'
- 'config/initializers/3_omniauth.rb'

View File

@ -2,6 +2,48 @@
All notable changes to this project will be documented in this file.
## [4.3.2] - 2024-12-03
### Added
- Add `tootctl feeds vacuum` (#33065 by @ClearlyClaire)
- Add error message when user tries to follow their own account (#31910 by @lenikadali)
- Add client_secret_expires_at to OAuth Applications (#30317 by @ThisIsMissEm)
### Changed
- Change design of Content Warnings and filters (#32543 by @ClearlyClaire)
### Fixed
- Fix processing incoming post edits with mentions to unresolvable accounts (#33129 by @ClearlyClaire)
- Fix error when including multiple instances of `embed.js` (#33107 by @YKWeyer)
- Fix inactive users' timelines being backfilled on follow and unsuspend (#33094 by @ClearlyClaire)
- Fix direct inbox delivery pushing posts into inactive followers' timelines (#33067 by @ClearlyClaire)
- Fix `TagFollow` records not being correctly handled in account operations (#33063 by @ClearlyClaire)
- Fix pushing hashtag-followed posts to feeds of inactive users (#33018 by @Gargron)
- Fix duplicate notifications in notification groups when using slow mode (#33014 by @ClearlyClaire)
- Fix posts made in the future being allowed to trend (#32996 by @ClearlyClaire)
- Fix uploading higher-than-wide GIF profile picture with libvips enabled (#32911 by @ClearlyClaire)
- Fix domain attribution field having autocorrect and autocapitalize enabled (#32903 by @ClearlyClaire)
- Fix titles being escaped twice (#32889 by @ClearlyClaire)
- Fix list creation limit check (#32869 by @ClearlyClaire)
- Fix error in `tootctl email_domain_blocks` when supplying `--with-dns-records` (#32863 by @mjankowski)
- Fix `min_id` and `max_id` causing error in search API (#32857 by @Gargron)
- Fix inefficiencies when processing removal of posts that use featured tags (#32787 by @ClearlyClaire)
- Fix alt-text pop-in not using the translated description (#32766 by @ClearlyClaire)
- Fix preview cards with long titles erroneously causing layout changes (#32678 by @ClearlyClaire)
- Fix embed modal layout on mobile (#32641 by @DismalShadowX)
- Fix and improve batch attachment deletion handling when using OpenStack Swift (#32637 by @hugogameiro)
- Fix blocks not being applied on link timeline (#32625 by @tribela)
- Fix follow counters being incorrectly changed (#32622 by @oneiros)
- Fix 'unknown' media attachment type rendering (#32613 and #32713 by @ThisIsMissEm and @renatolond)
- Fix tl language native name (#32606 by @seav)
### Security
- Update dependencies
## [4.3.1] - 2024-10-21
### Added
@ -68,7 +110,7 @@ The following changelog entries focus on changes visible to users, administrator
- `GET /api/v2/notifications`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-grouped
- `GET /api/v2/notifications/:group_key`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-notification-group
- `GET /api/v2/notifications/:group_key/accounts`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-group-accounts
- `POST /api/v2/notifications/:group_key/dimsiss`: https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group
- `POST /api/v2/notifications/:group_key/dismiss`: https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group
- `GET /api/v2/notifications/:unread_count`: https://docs.joinmastodon.org/methods/grouped_notifications/#unread-group-count
- **Add notification policies, filtered notifications and notification requests** (#29366, #29529, #29433, #29565, #29567, #29572, #29575, #29588, #29646, #29652, #29658, #29666, #29693, #29699, #29737, #29706, #29570, #29752, #29810, #29826, #30114, #30251, #30559, #29868, #31008, #31011, #30996, #31149, #31220, #31222, #31225, #31242, #31262, #31250, #31273, #31310, #31316, #31322, #31329, #31324, #31331, #31343, #31342, #31309, #31358, #31378, #31406, #31256, #31456, #31419, #31457, #31508, #31540, #31541, #31723, #32062 and #32281 by @ClearlyClaire, @Gargron, @TheEssem, @mgmn, @oneiros, and @renchap)\
The old “Block notifications from non-followers”, “Block notifications from people you don't follow” and “Block direct messages from people you don't follow” notification settings have been replaced by a new set of settings found directly in the notification column.\
@ -399,7 +441,7 @@ The following changelog entries focus on changes visible to users, administrator
- Fix empty environment variables not using default nil value (#27400 by @renchap)
- Fix language sorting in settings (#27158 by @gunchleoc)
## |4.2.11] - 2024-08-16
## [4.2.11] - 2024-08-16
### Added

View File

@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1.10
# syntax=docker/dockerfile:1.12
# This file is designed for production server deployment, not local development work
# For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/README.md#docker

View File

@ -105,7 +105,7 @@ gem 'opentelemetry-api', '~> 1.4.0'
group :opentelemetry do
gem 'opentelemetry-exporter-otlp', '~> 0.29.0', require: false
gem 'opentelemetry-instrumentation-active_job', '~> 0.7.1', require: false
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.20.1', require: false
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.21.0', require: false
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.21.2', require: false
gem 'opentelemetry-instrumentation-excon', '~> 0.22.0', require: false
gem 'opentelemetry-instrumentation-faraday', '~> 0.24.1', require: false
@ -114,7 +114,7 @@ group :opentelemetry do
gem 'opentelemetry-instrumentation-net_http', '~> 0.22.4', require: false
gem 'opentelemetry-instrumentation-pg', '~> 0.29.0', require: false
gem 'opentelemetry-instrumentation-rack', '~> 0.25.0', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.33.0', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.34.0', require: false
gem 'opentelemetry-instrumentation-redis', '~> 0.25.3', require: false
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.25.2', require: false
gem 'opentelemetry-sdk', '~> 1.4', require: false

View File

@ -10,29 +10,29 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.2.2)
actionpack (= 7.2.2)
activesupport (= 7.2.2)
actioncable (7.2.2.1)
actionpack (= 7.2.2.1)
activesupport (= 7.2.2.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.2.2)
actionpack (= 7.2.2)
activejob (= 7.2.2)
activerecord (= 7.2.2)
activestorage (= 7.2.2)
activesupport (= 7.2.2)
actionmailbox (7.2.2.1)
actionpack (= 7.2.2.1)
activejob (= 7.2.2.1)
activerecord (= 7.2.2.1)
activestorage (= 7.2.2.1)
activesupport (= 7.2.2.1)
mail (>= 2.8.0)
actionmailer (7.2.2)
actionpack (= 7.2.2)
actionview (= 7.2.2)
activejob (= 7.2.2)
activesupport (= 7.2.2)
actionmailer (7.2.2.1)
actionpack (= 7.2.2.1)
actionview (= 7.2.2.1)
activejob (= 7.2.2.1)
activesupport (= 7.2.2.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.2)
actionview (= 7.2.2)
activesupport (= 7.2.2)
actionpack (7.2.2.1)
actionview (= 7.2.2.1)
activesupport (= 7.2.2.1)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4, < 3.2)
@ -41,40 +41,40 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (7.2.2)
actionpack (= 7.2.2)
activerecord (= 7.2.2)
activestorage (= 7.2.2)
activesupport (= 7.2.2)
actiontext (7.2.2.1)
actionpack (= 7.2.2.1)
activerecord (= 7.2.2.1)
activestorage (= 7.2.2.1)
activesupport (= 7.2.2.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.2)
activesupport (= 7.2.2)
actionview (7.2.2.1)
activesupport (= 7.2.2.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
active_model_serializers (0.10.14)
active_model_serializers (0.10.15)
actionpack (>= 4.1)
activemodel (>= 4.1)
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (7.2.2)
activesupport (= 7.2.2)
activejob (7.2.2.1)
activesupport (= 7.2.2.1)
globalid (>= 0.3.6)
activemodel (7.2.2)
activesupport (= 7.2.2)
activerecord (7.2.2)
activemodel (= 7.2.2)
activesupport (= 7.2.2)
activemodel (7.2.2.1)
activesupport (= 7.2.2.1)
activerecord (7.2.2.1)
activemodel (= 7.2.2.1)
activesupport (= 7.2.2.1)
timeout (>= 0.4.0)
activestorage (7.2.2)
actionpack (= 7.2.2)
activejob (= 7.2.2)
activerecord (= 7.2.2)
activesupport (= 7.2.2)
activestorage (7.2.2.1)
actionpack (= 7.2.2.1)
activejob (= 7.2.2.1)
activerecord (= 7.2.2.1)
activesupport (= 7.2.2.1)
marcel (~> 1.0)
activesupport (7.2.2)
activesupport (7.2.2.1)
base64
benchmark (>= 0.3)
bigdecimal
@ -93,10 +93,9 @@ GEM
annotaterb (4.13.0)
ast (2.4.2)
attr_required (1.0.2)
awrence (1.2.1)
aws-eventstream (1.3.0)
aws-partitions (1.1009.0)
aws-sdk-core (3.213.0)
aws-partitions (1.1025.0)
aws-sdk-core (3.214.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@ -104,13 +103,13 @@ GEM
aws-sdk-kms (1.96.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.172.0)
aws-sdk-s3 (1.176.1)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.10.1)
aws-eventstream (~> 1, >= 1.0.2)
azure-blob (0.5.3)
azure-blob (0.5.4)
rexml
base64 (0.2.0)
bcp47_spec (0.2.1)
@ -129,7 +128,7 @@ GEM
msgpack (~> 1.2)
brakeman (6.2.2)
racc
browser (6.1.0)
browser (6.2.0)
brpoplpush-redis_script (0.1.3)
concurrent-ruby (~> 1.0, >= 1.0.5)
redis (>= 1.0, < 6)
@ -169,15 +168,15 @@ GEM
bigdecimal
rexml
crass (1.0.6)
css_parser (1.19.1)
css_parser (1.21.0)
addressable
csv (3.3.0)
csv (3.3.1)
database_cleaner-active_record (2.2.0)
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
date (3.4.0)
debug (1.9.2)
date (3.4.1)
debug (1.10.0)
irb (~> 1.10)
reline (>= 0.3.8)
debug_inspector (1.2.0)
@ -200,9 +199,9 @@ GEM
activerecord (>= 4.2, < 9.0)
docile (1.4.1)
domain_name (0.6.20240107)
doorkeeper (5.8.0)
doorkeeper (5.8.1)
railties (>= 5)
dotenv (3.1.4)
dotenv (3.1.7)
drb (2.2.1)
elasticsearch (7.17.11)
elasticsearch-api (= 7.17.11)
@ -225,14 +224,14 @@ GEM
fabrication (2.31.0)
faker (3.5.1)
i18n (>= 1.8.11, < 2)
faraday (2.12.0)
faraday-net_http (>= 2.0, < 3.4)
faraday (2.12.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-httpclient (2.0.1)
httpclient (>= 2.2)
faraday-net_http (3.3.0)
net-http
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
fast_blank (1.0.1)
fastimage (2.3.1)
ffi (1.17.0)
@ -280,7 +279,7 @@ GEM
rainbow
rubocop (>= 1.0)
sysexits (~> 1.1)
hashdiff (1.1.1)
hashdiff (1.1.2)
hashie (5.0.0)
hcaptcha (7.1.0)
json
@ -295,7 +294,7 @@ GEM
http-cookie (~> 1.0)
http-form_data (~> 2.2)
llhttp-ffi (~> 0.5.0)
http-cookie (1.0.5)
http-cookie (1.0.8)
domain_name (~> 0.5)
http-form_data (2.3.0)
http_accept_language (2.1.1)
@ -319,8 +318,8 @@ GEM
inline_svg (1.10.0)
activesupport (>= 3.0)
nokogiri (>= 1.6)
io-console (0.7.2)
irb (1.14.1)
io-console (0.8.0)
irb (1.14.3)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jd-paperclip-azure (3.0.0)
@ -328,7 +327,7 @@ GEM
azure-blob (~> 0.5.2)
hashie (~> 5.0)
jmespath (1.6.2)
json (2.8.1)
json (2.9.1)
json-canonicalization (1.0.0)
json-jwt (1.15.3.1)
activesupport (>= 4.2)
@ -346,10 +345,12 @@ GEM
json-ld-preloaded (3.3.1)
json-ld (~> 3.3)
rdf (~> 3.3)
json-schema (5.1.0)
json-schema (5.1.1)
addressable (~> 2.8)
bigdecimal (~> 3.1)
jsonapi-renderer (0.2.2)
jwt (2.7.1)
jwt (2.9.3)
base64
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
@ -383,7 +384,7 @@ GEM
llhttp-ffi (0.5.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
logger (1.6.1)
logger (1.6.3)
lograge (0.14.0)
actionpack (>= 4)
activesupport (>= 4)
@ -405,16 +406,16 @@ GEM
mime-types (3.6.0)
logger
mime-types-data (~> 3.2015)
mime-types-data (3.2024.1105)
mime-types-data (3.2024.1203)
mini_mime (1.1.5)
mini_portile2 (2.8.7)
minitest (5.25.1)
mini_portile2 (2.8.8)
minitest (5.25.4)
msgpack (1.7.5)
multi_json (1.15.0)
mutex_m (0.2.0)
mutex_m (0.3.0)
net-http (0.5.0)
uri
net-imap (0.5.1)
net-imap (0.5.2)
date
net-protocol
net-ldap (0.19.0)
@ -424,11 +425,11 @@ GEM
timeout
net-smtp (0.5.0)
net-protocol
nio4r (2.7.3)
nokogiri (1.16.7)
nio4r (2.7.4)
nokogiri (1.17.2)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
oj (3.16.7)
oj (3.16.8)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.2)
@ -459,43 +460,44 @@ GEM
validate_email
validate_url
webfinger (~> 1.2)
openssl (3.2.0)
openssl (3.2.1)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
opentelemetry-api (1.4.0)
opentelemetry-common (0.21.0)
opentelemetry-api (~> 1.0)
opentelemetry-exporter-otlp (0.29.0)
opentelemetry-exporter-otlp (0.29.1)
google-protobuf (>= 3.18)
googleapis-common-protos-types (~> 1.3)
opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20)
opentelemetry-sdk (~> 1.2)
opentelemetry-semantic_conventions
opentelemetry-helpers-sql-obfuscation (0.2.0)
opentelemetry-helpers-sql-obfuscation (0.2.1)
opentelemetry-common (~> 0.21)
opentelemetry-instrumentation-action_mailer (0.2.0)
opentelemetry-instrumentation-action_mailer (0.3.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.1)
opentelemetry-instrumentation-active_support (~> 0.7)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-action_pack (0.10.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-rack (~> 0.21)
opentelemetry-instrumentation-action_view (0.7.3)
opentelemetry-instrumentation-action_view (0.8.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.6)
opentelemetry-instrumentation-active_support (~> 0.7)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-active_job (0.7.8)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-active_model_serializers (0.20.2)
opentelemetry-instrumentation-active_model_serializers (0.21.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (>= 0.7.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-active_record (0.8.1)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-active_record (0.8.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-active_support (0.6.0)
opentelemetry-instrumentation-active_support (0.7.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-base (0.22.6)
@ -505,36 +507,36 @@ GEM
opentelemetry-instrumentation-concurrent_ruby (0.21.4)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-excon (0.22.4)
opentelemetry-instrumentation-excon (0.22.5)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-faraday (0.24.6)
opentelemetry-instrumentation-faraday (0.24.8)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-http (0.23.4)
opentelemetry-instrumentation-http (0.23.5)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-http_client (0.22.7)
opentelemetry-instrumentation-http_client (0.22.8)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-net_http (0.22.7)
opentelemetry-instrumentation-net_http (0.22.8)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-pg (0.29.0)
opentelemetry-instrumentation-pg (0.29.1)
opentelemetry-api (~> 1.0)
opentelemetry-helpers-sql-obfuscation
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-rack (0.25.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-rails (0.33.0)
opentelemetry-instrumentation-rails (0.34.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-action_mailer (~> 0.2.0)
opentelemetry-instrumentation-action_mailer (~> 0.3.0)
opentelemetry-instrumentation-action_pack (~> 0.10.0)
opentelemetry-instrumentation-action_view (~> 0.7.0)
opentelemetry-instrumentation-action_view (~> 0.8.0)
opentelemetry-instrumentation-active_job (~> 0.7.0)
opentelemetry-instrumentation-active_record (~> 0.8.0)
opentelemetry-instrumentation-active_support (~> 0.6.0)
opentelemetry-instrumentation-active_support (~> 0.7.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-redis (0.25.7)
opentelemetry-api (~> 1.0)
@ -544,7 +546,7 @@ GEM
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-registry (0.3.1)
opentelemetry-api (~> 1.1)
opentelemetry-sdk (1.5.0)
opentelemetry-sdk (1.6.0)
opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20)
opentelemetry-registry (~> 0.2)
@ -577,10 +579,11 @@ GEM
activesupport (>= 7.0.0)
rack
railties (>= 7.0.0)
psych (5.2.0)
psych (5.2.2)
date
stringio
public_suffix (6.0.1)
puma (6.4.3)
puma (6.5.0)
nio4r (~> 2.0)
pundit (2.4.0)
activesupport (>= 3.0.0)
@ -606,23 +609,23 @@ GEM
rack (< 3)
rack-test (2.1.0)
rack (>= 1.3)
rackup (1.0.0)
rackup (1.0.1)
rack (< 3)
webrick
rails (7.2.2)
actioncable (= 7.2.2)
actionmailbox (= 7.2.2)
actionmailer (= 7.2.2)
actionpack (= 7.2.2)
actiontext (= 7.2.2)
actionview (= 7.2.2)
activejob (= 7.2.2)
activemodel (= 7.2.2)
activerecord (= 7.2.2)
activestorage (= 7.2.2)
activesupport (= 7.2.2)
rails (7.2.2.1)
actioncable (= 7.2.2.1)
actionmailbox (= 7.2.2.1)
actionmailer (= 7.2.2.1)
actionpack (= 7.2.2.1)
actiontext (= 7.2.2.1)
actionview (= 7.2.2.1)
activejob (= 7.2.2.1)
activemodel (= 7.2.2.1)
activerecord (= 7.2.2.1)
activestorage (= 7.2.2.1)
activesupport (= 7.2.2.1)
bundler (>= 1.15.0)
railties (= 7.2.2)
railties (= 7.2.2.1)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
@ -631,15 +634,15 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (~> 1.14)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (7.0.10)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
railties (7.2.2)
actionpack (= 7.2.2)
activesupport (= 7.2.2)
railties (7.2.2.1)
actionpack (= 7.2.2.1)
activesupport (= 7.2.2.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@ -653,7 +656,7 @@ GEM
link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.7.0)
rdf (~> 3.3)
rdoc (6.7.0)
rdoc (6.10.0)
psych (>= 4.0.0)
redcarpet (3.6.0)
redis (4.8.1)
@ -661,15 +664,15 @@ GEM
redis (>= 4)
redlock (1.3.2)
redis (>= 3.0.0, < 6.0)
regexp_parser (2.9.2)
reline (0.5.11)
regexp_parser (2.9.3)
reline (0.6.0)
io-console (~> 0.5)
request_store (1.6.0)
request_store (1.7.0)
rack (>= 1.4)
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
rexml (3.3.9)
rexml (3.4.0)
rotp (6.3.0)
rouge (4.5.1)
rpam2 (4.0.2)
@ -704,22 +707,22 @@ GEM
rspec-expectations (~> 3.0)
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 8)
rspec-support (3.13.1)
rubocop (1.66.1)
rspec-support (3.13.2)
rubocop (1.69.2)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.4, < 3.0)
rubocop-ast (>= 1.32.2, < 2.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.36.2, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.32.3)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.37.0)
parser (>= 3.3.1.0)
rubocop-capybara (2.21.0)
rubocop (~> 1.41)
rubocop-performance (1.22.1)
rubocop-performance (1.23.0)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails (2.27.0)
@ -727,7 +730,7 @@ GEM
rack (>= 1.1)
rubocop (>= 1.52.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rspec (3.2.0)
rubocop-rspec (3.3.0)
rubocop (~> 1.61)
rubocop-rspec_rails (2.30.0)
rubocop (~> 1.61)
@ -741,8 +744,8 @@ GEM
ffi (~> 1.12)
logger
rubyzip (2.3.2)
rufus-scheduler (3.9.1)
fugit (~> 1.1, >= 1.1.6)
rufus-scheduler (3.9.2)
fugit (~> 1.1, >= 1.11.1)
safety_net_attestation (0.4.0)
jwt (~> 2.0)
sanitize (6.1.3)
@ -751,14 +754,14 @@ GEM
scenic (1.8.0)
activerecord (>= 4.0.0)
railties (>= 4.0.0)
securerandom (0.3.2)
selenium-webdriver (4.26.0)
securerandom (0.4.1)
selenium-webdriver (4.27.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
semantic_range (3.0.0)
semantic_range (3.1.0)
shoulda-matchers (6.4.0)
activesupport (>= 5.2.0)
sidekiq (6.5.12)
@ -805,10 +808,10 @@ GEM
unicode-display_width (>= 1.1.1, < 3)
terrapin (1.0.1)
climate_control
test-prof (1.4.2)
test-prof (1.4.3)
thor (1.3.2)
tilt (2.4.0)
timeout (0.4.2)
timeout (0.4.3)
tpm-key_attestation (0.12.1)
bindata (~> 2.4)
openssl (> 2.0)
@ -834,8 +837,8 @@ GEM
unf_ext
unf_ext (0.0.9.1)
unicode-display_width (2.6.0)
uri (0.13.1)
useragent (0.16.10)
uri (1.0.2)
useragent (0.16.11)
validate_email (0.1.6)
activemodel (>= 3.0)
mail (>= 2.2.5)
@ -844,9 +847,8 @@ GEM
public_suffix
warden (1.2.9)
rack (>= 2.0.9)
webauthn (3.1.0)
webauthn (3.2.2)
android_key_attestation (~> 0.3.0)
awrence (~> 1.1)
bindata (~> 2.4)
cbor (~> 0.5.9)
cose (~> 1.1)
@ -865,7 +867,7 @@ GEM
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
webrick (1.9.0)
webrick (1.9.1)
websocket (1.2.11)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
@ -957,7 +959,7 @@ DEPENDENCIES
opentelemetry-api (~> 1.4.0)
opentelemetry-exporter-otlp (~> 0.29.0)
opentelemetry-instrumentation-active_job (~> 0.7.1)
opentelemetry-instrumentation-active_model_serializers (~> 0.20.1)
opentelemetry-instrumentation-active_model_serializers (~> 0.21.0)
opentelemetry-instrumentation-concurrent_ruby (~> 0.21.2)
opentelemetry-instrumentation-excon (~> 0.22.0)
opentelemetry-instrumentation-faraday (~> 0.24.1)
@ -966,7 +968,7 @@ DEPENDENCIES
opentelemetry-instrumentation-net_http (~> 0.22.4)
opentelemetry-instrumentation-pg (~> 0.29.0)
opentelemetry-instrumentation-rack (~> 0.25.0)
opentelemetry-instrumentation-rails (~> 0.33.0)
opentelemetry-instrumentation-rails (~> 0.34.0)
opentelemetry-instrumentation-redis (~> 0.25.3)
opentelemetry-instrumentation-sidekiq (~> 0.25.2)
opentelemetry-sdk (~> 1.4)
@ -1031,7 +1033,7 @@ DEPENDENCIES
xorcist (~> 1.1)
RUBY VERSION
ruby 3.3.5p100
ruby 3.3.6p108
BUNDLED WITH
2.5.22
2.6.1

2
Vagrantfile vendored
View File

@ -174,7 +174,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
if config.vm.networks.any? { |type, options| type == :private_network }
config.vm.synced_folder ".", "/vagrant", type: "nfs", mount_options: ['rw', 'actimeo=1']
else
config.vm.synced_folder ".", "/vagrant"
config.vm.synced_folder ".", "/vagrant", type: "rsync", create: true, rsync__args: ["--verbose", "--archive", "--delete", "-z"]
end
# Otherwise, you can access the site at http://localhost:3000 and http://localhost:4000 , http://localhost:8080

View File

@ -8,6 +8,7 @@ module Admin
layout 'admin'
before_action :set_cache_headers
before_action :set_referrer_policy_header
after_action :verify_authorized
@ -17,6 +18,10 @@ module Admin
response.cache_control.replace(private: true, no_store: true)
end
def set_referrer_policy_header
response.headers['Referrer-Policy'] = 'same-origin'
end
def set_user
@user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound)
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class Admin::TermsOfService::DistributionsController < Admin::BaseController
before_action :set_terms_of_service
def create
authorize @terms_of_service, :distribute?
@terms_of_service.touch(:notification_sent_at)
Admin::DistributeTermsOfServiceNotificationWorker.perform_async(@terms_of_service.id)
redirect_to admin_terms_of_service_index_path
end
private
def set_terms_of_service
@terms_of_service = TermsOfService.find(params[:terms_of_service_id])
end
end

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
class Admin::TermsOfService::DraftsController < Admin::BaseController
before_action :set_terms_of_service
def show
authorize :terms_of_service, :create?
end
def update
authorize @terms_of_service, :update?
@terms_of_service.published_at = Time.now.utc if params[:action_type] == 'publish'
if @terms_of_service.update(resource_params)
log_action(:publish, @terms_of_service) if @terms_of_service.published?
redirect_to @terms_of_service.published? ? admin_terms_of_service_index_path : admin_terms_of_service_draft_path
else
render :show
end
end
private
def set_terms_of_service
@terms_of_service = TermsOfService.draft.first || TermsOfService.new(text: current_terms_of_service&.text)
end
def current_terms_of_service
TermsOfService.live.first
end
def resource_params
params.require(:terms_of_service).permit(:text, :changelog)
end
end

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
class Admin::TermsOfService::GeneratesController < Admin::BaseController
before_action :set_instance_presenter
def show
authorize :terms_of_service, :create?
@generator = TermsOfService::Generator.new(
domain: @instance_presenter.domain,
admin_email: @instance_presenter.contact.email
)
end
def create
authorize :terms_of_service, :create?
@generator = TermsOfService::Generator.new(resource_params)
if @generator.valid?
TermsOfService.create!(text: @generator.render)
redirect_to admin_terms_of_service_draft_path
else
render :show
end
end
private
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
def resource_params
params.require(:terms_of_service_generator).permit(*TermsOfService::Generator::VARIABLES)
end
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class Admin::TermsOfService::HistoriesController < Admin::BaseController
def show
authorize :terms_of_service, :index?
@terms_of_service = TermsOfService.published.all
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class Admin::TermsOfService::PreviewsController < Admin::BaseController
before_action :set_terms_of_service
def show
authorize @terms_of_service, :distribute?
@user_count = @terms_of_service.scope_for_notification.count
end
private
def set_terms_of_service
@terms_of_service = TermsOfService.find(params[:terms_of_service_id])
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
class Admin::TermsOfService::TestsController < Admin::BaseController
before_action :set_terms_of_service
def create
authorize @terms_of_service, :distribute?
UserMailer.terms_of_service_changed(current_user, @terms_of_service).deliver_later!
redirect_to admin_terms_of_service_preview_path(@terms_of_service)
end
private
def set_terms_of_service
@terms_of_service = TermsOfService.find(params[:terms_of_service_id])
end
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class Admin::TermsOfServiceController < Admin::BaseController
def index
authorize :terms_of_service, :index?
@terms_of_service = TermsOfService.live.first
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class Api::V1::Instances::TermsOfServicesController < Api::V1::Instances::BaseController
before_action :set_terms_of_service
def show
cache_even_if_authenticated!
render json: @terms_of_service, serializer: REST::PrivacyPolicySerializer
end
private
def set_terms_of_service
@terms_of_service = TermsOfService.live.first!
end
end

View File

@ -15,7 +15,7 @@ class Api::V1::Polls::VotesController < Api::BaseController
private
def set_poll
@poll = Poll.attached.find(params[:poll_id])
@poll = Poll.find(params[:poll_id])
authorize @poll.status, :show?
rescue Mastodon::NotPermittedError
not_found

View File

@ -15,7 +15,7 @@ class Api::V1::PollsController < Api::BaseController
private
def set_poll
@poll = Poll.attached.find(params[:id])
@poll = Poll.find(params[:id])
authorize @poll.status, :show?
rescue Mastodon::NotPermittedError
not_found

View File

@ -27,7 +27,9 @@ class Api::V1::Trends::TagsController < Api::BaseController
end
def tags_from_trends
Trends.tags.query.allowed
scope = Trends.tags.query.allowed.in_locale(content_locale)
scope = scope.filtered_for(current_account) if user_signed_in?
scope
end
def next_path

View File

@ -22,7 +22,6 @@ class ApplicationController < ActionController::Base
helper_method :use_seamless_external_login?
helper_method :sso_account_settings
helper_method :limited_federation_mode?
helper_method :body_class_string
helper_method :skip_csrf_meta_tags?
rescue_from ActionController::ParameterMissing, Paperclip::AdapterRegistry::NoHandlerError, with: :bad_request
@ -71,7 +70,13 @@ class ApplicationController < ActionController::Base
end
def require_functional!
redirect_to edit_user_registration_path unless current_user.functional?
return if current_user.functional?
if current_user.confirmed?
redirect_to edit_user_registration_path
else
redirect_to auth_setup_path
end
end
def skip_csrf_meta_tags?
@ -158,10 +163,6 @@ class ApplicationController < ActionController::Base
current_user.setting_theme
end
def body_class_string
@body_classes || ''
end
def respond_with_error(code)
respond_to do |format|
format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] }

View File

@ -142,4 +142,12 @@ class Auth::RegistrationsController < Devise::RegistrationsController
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
end
def is_flashing_format? # rubocop:disable Naming/PredicateName
if params[:action] == 'create'
false # Disable flash messages for sign-up
else
super
end
end
end

View File

@ -28,7 +28,7 @@ module CacheConcern
def render_with_cache(**options)
raise ArgumentError, 'Only JSON render calls are supported' unless options.key?(:json) || block_given?
key = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields].nil? ? nil : options[:fields].join(',')].compact.join(':')
key = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields]&.join(',')].compact.join(':')
expires_in = options.delete(:expires_in) || 3.minutes
body = Rails.cache.read(key, raw: true)

View File

@ -7,6 +7,7 @@ module WebAppControllerConcern
vary_by 'Accept, Accept-Language, Cookie'
before_action :redirect_unauthenticated_to_permalinks!
before_action :set_referer_header
content_security_policy do |p|
policy = ContentSecurityPolicy.new
@ -41,4 +42,10 @@ module WebAppControllerConcern
end
end
end
protected
def set_referer_header
response.set_header('Referrer-Policy', Setting.allow_referrer_origin ? 'origin' : 'same-origin')
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class TermsOfServiceController < ApplicationController
include WebAppControllerConcern
skip_before_action :require_functional!
def show
expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in?
end
end

View File

@ -143,10 +143,11 @@ module ApplicationHelper
end
def body_classes
output = body_class_string.split
output = []
output << content_for(:body_classes)
output << "theme-#{current_theme.parameterize}"
output << 'system-font' if current_account&.user&.setting_system_font_ui
output << 'custom-scrollbars' unless current_account&.user&.setting_system_scrollbars_ui
output << (current_account&.user&.setting_reduce_motion ? 'reduce-motion' : 'no-reduce-motion')
output << 'rtl' if locale_direction == 'rtl'
output.compact_blank.join(' ')

View File

@ -64,6 +64,10 @@ module FormattingHelper
end
end
def markdown(text)
Redcarpet::Markdown.new(Redcarpet::Render::HTML, escape_html: true, no_images: true).render(text).html_safe # rubocop:disable Rails/OutputSafety
end
private
def wrapped_status_content_format(status)

View File

@ -60,6 +60,10 @@ window.addEventListener('message', (e) => {
const data = e.data;
// Only set overflow to `hidden` once we got the expected `message` so the post can still be scrolled if
// embedded without parent Javascript support
document.body.style.overflow = 'hidden';
// We use a timeout to allow for the React page to render before calculating the height
afterInitialRender(() => {
window.parent.postMessage(

View File

@ -230,62 +230,6 @@ function loaded() {
}
},
);
Rails.delegate(
document,
'button.status__content__spoiler-link',
'click',
function () {
if (!(this instanceof HTMLButtonElement)) return;
const statusEl = this.parentNode?.parentNode;
if (
!(
statusEl instanceof HTMLDivElement &&
statusEl.classList.contains('.status__content')
)
)
return;
if (statusEl.dataset.spoiler === 'expanded') {
statusEl.dataset.spoiler = 'folded';
this.textContent = new IntlMessageFormat(
localeData['status.show_more'] ?? 'Show more',
locale,
).format() as string;
} else {
statusEl.dataset.spoiler = 'expanded';
this.textContent = new IntlMessageFormat(
localeData['status.show_less'] ?? 'Show less',
locale,
).format() as string;
}
},
);
document
.querySelectorAll<HTMLButtonElement>('button.status__content__spoiler-link')
.forEach((spoilerLink) => {
const statusEl = spoilerLink.parentNode?.parentNode;
if (
!(
statusEl instanceof HTMLDivElement &&
statusEl.classList.contains('.status__content')
)
)
return;
const message =
statusEl.dataset.spoiler === 'expanded'
? (localeData['status.show_less'] ?? 'Show less')
: (localeData['status.show_more'] ?? 'Show more');
spoilerLink.textContent = new IntlMessageFormat(
message,
locale,
).format() as string;
});
}
Rails.delegate(
@ -439,6 +383,24 @@ Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {
});
});
Rails.delegate(document, '.rules-list button', 'click', ({ target }) => {
if (!(target instanceof HTMLElement)) {
return;
}
const button = target.closest('button');
if (!button) {
return;
}
if (button.ariaExpanded === 'true') {
button.ariaExpanded = 'false';
} else {
button.ariaExpanded = 'true';
}
});
function main() {
ready(loaded).catch((error: unknown) => {
console.error(error);

View File

@ -2,6 +2,8 @@ import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { isFulfilled, isRejected } from '@reduxjs/toolkit';
import { openURL } from 'mastodon/actions/search';
import { useAppDispatch } from 'mastodon/store';
@ -28,12 +30,22 @@ export const useLinks = () => {
);
const handleMentionClick = useCallback(
(element: HTMLAnchorElement) => {
dispatch(
openURL(element.href, history, () => {
async (element: HTMLAnchorElement) => {
const result = await dispatch(openURL({ url: element.href }));
if (isFulfilled(result)) {
if (result.payload.accounts[0]) {
history.push(`/@${result.payload.accounts[0].acct}`);
} else if (result.payload.statuses[0]) {
history.push(
`/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`,
);
} else {
window.location.href = element.href;
}),
);
}
} else if (isRejected(result)) {
window.location.href = element.href;
}
},
[dispatch, history],
);
@ -48,7 +60,7 @@ export const useLinks = () => {
if (isMentionClick(target)) {
e.preventDefault();
handleMentionClick(target);
void handleMentionClick(target);
} else if (isHashtagClick(target)) {
e.preventDefault();
handleHashtagClick(target);

View File

@ -1,66 +0,0 @@
import { defineMessages } from 'react-intl';
import { AxiosError } from 'axios';
const messages = defineMessages({
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' },
rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' },
});
export const ALERT_SHOW = 'ALERT_SHOW';
export const ALERT_DISMISS = 'ALERT_DISMISS';
export const ALERT_CLEAR = 'ALERT_CLEAR';
export const ALERT_NOOP = 'ALERT_NOOP';
export const dismissAlert = alert => ({
type: ALERT_DISMISS,
alert,
});
export const clearAlert = () => ({
type: ALERT_CLEAR,
});
export const showAlert = alert => ({
type: ALERT_SHOW,
alert,
});
export const showAlertForError = (error, skipNotFound = false) => {
if (error.response) {
const { data, status, statusText, headers } = error.response;
// Skip these errors as they are reflected in the UI
if (skipNotFound && (status === 404 || status === 410)) {
return { type: ALERT_NOOP };
}
// Rate limit errors
if (status === 429 && headers['x-ratelimit-reset']) {
return showAlert({
title: messages.rateLimitedTitle,
message: messages.rateLimitedMessage,
values: { 'retry_time': new Date(headers['x-ratelimit-reset']) },
});
}
return showAlert({
title: `${status}`,
message: data.error || statusText,
});
}
// An aborted request, e.g. due to reloading the browser window, it not really error
if (error.code === AxiosError.ECONNABORTED) {
return { type: ALERT_NOOP };
}
console.error(error);
return showAlert({
title: messages.unexpectedTitle,
message: messages.unexpectedMessage,
});
};

View File

@ -0,0 +1,90 @@
import { defineMessages } from 'react-intl';
import type { MessageDescriptor } from 'react-intl';
import { AxiosError } from 'axios';
import type { AxiosResponse } from 'axios';
interface Alert {
title: string | MessageDescriptor;
message: string | MessageDescriptor;
values?: Record<string, string | number | Date>;
}
interface ApiErrorResponse {
error?: string;
}
const messages = defineMessages({
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
unexpectedMessage: {
id: 'alert.unexpected.message',
defaultMessage: 'An unexpected error occurred.',
},
rateLimitedTitle: {
id: 'alert.rate_limited.title',
defaultMessage: 'Rate limited',
},
rateLimitedMessage: {
id: 'alert.rate_limited.message',
defaultMessage: 'Please retry after {retry_time, time, medium}.',
},
});
export const ALERT_SHOW = 'ALERT_SHOW';
export const ALERT_DISMISS = 'ALERT_DISMISS';
export const ALERT_CLEAR = 'ALERT_CLEAR';
export const ALERT_NOOP = 'ALERT_NOOP';
export const dismissAlert = (alert: Alert) => ({
type: ALERT_DISMISS,
alert,
});
export const clearAlert = () => ({
type: ALERT_CLEAR,
});
export const showAlert = (alert: Alert) => ({
type: ALERT_SHOW,
alert,
});
export const showAlertForError = (error: unknown, skipNotFound = false) => {
if (error instanceof AxiosError && error.response) {
const { status, statusText, headers } = error.response;
const { data } = error.response as AxiosResponse<ApiErrorResponse>;
// Skip these errors as they are reflected in the UI
if (skipNotFound && (status === 404 || status === 410)) {
return { type: ALERT_NOOP };
}
// Rate limit errors
if (status === 429 && headers['x-ratelimit-reset']) {
return showAlert({
title: messages.rateLimitedTitle,
message: messages.rateLimitedMessage,
values: {
retry_time: new Date(headers['x-ratelimit-reset'] as string),
},
});
}
return showAlert({
title: `${status}`,
message: data.error ?? statusText,
});
}
// An aborted request, e.g. due to reloading the browser window, it not really error
if (error instanceof AxiosError && error.code === AxiosError.ECONNABORTED) {
return { type: ALERT_NOOP };
}
console.error(error);
return showAlert({
title: messages.unexpectedTitle,
message: messages.unexpectedMessage,
});
};

View File

@ -1,10 +1,12 @@
import { createPollFromServerJSON } from 'mastodon/models/poll';
import { importAccounts } from '../accounts_typed';
import { normalizeStatus, normalizePoll } from './normalizer';
import { normalizeStatus } from './normalizer';
import { importPolls } from './polls';
export const STATUS_IMPORT = 'STATUS_IMPORT';
export const STATUSES_IMPORT = 'STATUSES_IMPORT';
export const POLLS_IMPORT = 'POLLS_IMPORT';
export const FILTERS_IMPORT = 'FILTERS_IMPORT';
function pushUnique(array, object) {
@ -25,10 +27,6 @@ export function importFilters(filters) {
return { type: FILTERS_IMPORT, filters };
}
export function importPolls(polls) {
return { type: POLLS_IMPORT, polls };
}
export function importFetchedAccount(account) {
return importFetchedAccounts([account]);
}
@ -73,7 +71,7 @@ export function importFetchedStatuses(statuses) {
}
if (status.poll?.id) {
pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id])));
pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls.get(status.poll.id)));
}
if (status.card) {
@ -83,15 +81,9 @@ export function importFetchedStatuses(statuses) {
statuses.forEach(processStatus);
dispatch(importPolls(polls));
dispatch(importPolls({ polls }));
dispatch(importFetchedAccounts(accounts));
dispatch(importStatuses(normalStatuses));
dispatch(importFilters(filters));
};
}
export function importFetchedPoll(poll) {
return (dispatch, getState) => {
dispatch(importPolls([normalizePoll(poll, getState().getIn(['polls', poll.id]))]));
};
}

View File

@ -1,15 +1,12 @@
import escapeTextContentForBrowser from 'escape-html';
import { makeEmojiMap } from 'mastodon/models/custom_emoji';
import emojify from '../../features/emoji/emoji';
import { expandSpoilers } from '../../initial_state';
const domParser = new DOMParser();
const makeEmojiMap = emojis => emojis.reduce((obj, emoji) => {
obj[`:${emoji.shortcode}:`] = emoji;
return obj;
}, {});
export function searchTextFromRawStatus (status) {
const spoilerText = status.spoiler_text || '';
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
@ -112,38 +109,6 @@ export function normalizeStatusTranslation(translation, status) {
return normalTranslation;
}
export function normalizePoll(poll, normalOldPoll) {
const normalPoll = { ...poll };
const emojiMap = makeEmojiMap(poll.emojis);
normalPoll.options = poll.options.map((option, index) => {
const normalOption = {
...option,
voted: poll.own_votes && poll.own_votes.includes(index),
titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap),
};
if (normalOldPoll && normalOldPoll.getIn(['options', index, 'title']) === option.title) {
normalOption.translation = normalOldPoll.getIn(['options', index, 'translation']);
}
return normalOption;
});
return normalPoll;
}
export function normalizePollOptionTranslation(translation, poll) {
const emojiMap = makeEmojiMap(poll.get('emojis').toJS());
const normalTranslation = {
...translation,
titleHtml: emojify(escapeTextContentForBrowser(translation.title), emojiMap),
};
return normalTranslation;
}
export function normalizeAnnouncement(announcement) {
const normalAnnouncement = { ...announcement };
const emojiMap = makeEmojiMap(normalAnnouncement.emojis);

View File

@ -0,0 +1,7 @@
import { createAction } from '@reduxjs/toolkit';
import type { Poll } from 'mastodon/models/poll';
export const importPolls = createAction<{ polls: Poll[] }>(
'poll/importMultiple',
);

View File

@ -1,61 +0,0 @@
import api from '../api';
import { importFetchedPoll } from './importer';
export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST';
export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS';
export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL';
export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST';
export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS';
export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL';
export const vote = (pollId, choices) => (dispatch) => {
dispatch(voteRequest());
api().post(`/api/v1/polls/${pollId}/votes`, { choices })
.then(({ data }) => {
dispatch(importFetchedPoll(data));
dispatch(voteSuccess(data));
})
.catch(err => dispatch(voteFail(err)));
};
export const fetchPoll = pollId => (dispatch) => {
dispatch(fetchPollRequest());
api().get(`/api/v1/polls/${pollId}`)
.then(({ data }) => {
dispatch(importFetchedPoll(data));
dispatch(fetchPollSuccess(data));
})
.catch(err => dispatch(fetchPollFail(err)));
};
export const voteRequest = () => ({
type: POLL_VOTE_REQUEST,
});
export const voteSuccess = poll => ({
type: POLL_VOTE_SUCCESS,
poll,
});
export const voteFail = error => ({
type: POLL_VOTE_FAIL,
error,
});
export const fetchPollRequest = () => ({
type: POLL_FETCH_REQUEST,
});
export const fetchPollSuccess = poll => ({
type: POLL_FETCH_SUCCESS,
poll,
});
export const fetchPollFail = error => ({
type: POLL_FETCH_FAIL,
error,
});

View File

@ -0,0 +1,40 @@
import { apiGetPoll, apiPollVote } from 'mastodon/api/polls';
import type { ApiPollJSON } from 'mastodon/api_types/polls';
import { createPollFromServerJSON } from 'mastodon/models/poll';
import {
createAppAsyncThunk,
createDataLoadingThunk,
} from 'mastodon/store/typed_functions';
import { importPolls } from './importer/polls';
export const importFetchedPoll = createAppAsyncThunk(
'poll/importFetched',
(args: { poll: ApiPollJSON }, { dispatch, getState }) => {
const { poll } = args;
dispatch(
importPolls({
polls: [createPollFromServerJSON(poll, getState().polls.get(poll.id))],
}),
);
},
);
export const vote = createDataLoadingThunk(
'poll/vote',
({ pollId, choices }: { pollId: string; choices: string[] }) =>
apiPollVote(pollId, choices),
async (poll, { dispatch, discardLoadData }) => {
await dispatch(importFetchedPoll({ poll }));
return discardLoadData;
},
);
export const fetchPoll = createDataLoadingThunk(
'poll/fetch',
({ pollId }: { pollId: string }) => apiGetPoll(pollId),
async (poll, { dispatch }) => {
await dispatch(importFetchedPoll({ poll }));
},
);

View File

@ -1,215 +0,0 @@
import { fromJS } from 'immutable';
import { searchHistory } from 'mastodon/settings';
import api from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatuses } from './importer';
export const SEARCH_CHANGE = 'SEARCH_CHANGE';
export const SEARCH_CLEAR = 'SEARCH_CLEAR';
export const SEARCH_SHOW = 'SEARCH_SHOW';
export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST';
export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL';
export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST';
export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS';
export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL';
export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE';
export function changeSearch(value) {
return {
type: SEARCH_CHANGE,
value,
};
}
export function clearSearch() {
return {
type: SEARCH_CLEAR,
};
}
export function submitSearch(type) {
return (dispatch, getState) => {
const value = getState().getIn(['search', 'value']);
const signedIn = !!getState().getIn(['meta', 'me']);
if (value.length === 0) {
dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '', type));
return;
}
dispatch(fetchSearchRequest(type));
api().get('/api/v2/search', {
params: {
q: value,
resolve: signedIn,
limit: 11,
type,
},
}).then(response => {
if (response.data.accounts) {
dispatch(importFetchedAccounts(response.data.accounts));
}
if (response.data.statuses) {
dispatch(importFetchedStatuses(response.data.statuses));
}
dispatch(fetchSearchSuccess(response.data, value, type));
dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
}).catch(error => {
dispatch(fetchSearchFail(error));
});
};
}
export function fetchSearchRequest(searchType) {
return {
type: SEARCH_FETCH_REQUEST,
searchType,
};
}
export function fetchSearchSuccess(results, searchTerm, searchType) {
return {
type: SEARCH_FETCH_SUCCESS,
results,
searchType,
searchTerm,
};
}
export function fetchSearchFail(error) {
return {
type: SEARCH_FETCH_FAIL,
error,
};
}
export const expandSearch = type => (dispatch, getState) => {
const value = getState().getIn(['search', 'value']);
const offset = getState().getIn(['search', 'results', type]).size - 1;
dispatch(expandSearchRequest(type));
api().get('/api/v2/search', {
params: {
q: value,
type,
offset,
limit: 11,
},
}).then(({ data }) => {
if (data.accounts) {
dispatch(importFetchedAccounts(data.accounts));
}
if (data.statuses) {
dispatch(importFetchedStatuses(data.statuses));
}
dispatch(expandSearchSuccess(data, value, type));
dispatch(fetchRelationships(data.accounts.map(item => item.id)));
}).catch(error => {
dispatch(expandSearchFail(error));
});
};
export const expandSearchRequest = (searchType) => ({
type: SEARCH_EXPAND_REQUEST,
searchType,
});
export const expandSearchSuccess = (results, searchTerm, searchType) => ({
type: SEARCH_EXPAND_SUCCESS,
results,
searchTerm,
searchType,
});
export const expandSearchFail = error => ({
type: SEARCH_EXPAND_FAIL,
error,
});
export const showSearch = () => ({
type: SEARCH_SHOW,
});
export const openURL = (value, history, onFailure) => (dispatch, getState) => {
const signedIn = !!getState().getIn(['meta', 'me']);
if (!signedIn) {
if (onFailure) {
onFailure();
}
return;
}
dispatch(fetchSearchRequest());
api().get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => {
if (response.data.accounts?.length > 0) {
dispatch(importFetchedAccounts(response.data.accounts));
history.push(`/@${response.data.accounts[0].acct}`);
} else if (response.data.statuses?.length > 0) {
dispatch(importFetchedStatuses(response.data.statuses));
history.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`);
} else if (onFailure) {
onFailure();
}
dispatch(fetchSearchSuccess(response.data, value));
}).catch(err => {
dispatch(fetchSearchFail(err));
if (onFailure) {
onFailure();
}
});
};
export const clickSearchResult = (q, type) => (dispatch, getState) => {
const previous = getState().getIn(['search', 'recent']);
if (previous.some(x => x.get('q') === q && x.get('type') === type)) {
return;
}
const me = getState().getIn(['meta', 'me']);
const current = previous.add(fromJS({ type, q })).takeLast(4);
searchHistory.set(me, current.toJS());
dispatch(updateSearchHistory(current));
};
export const forgetSearchResult = q => (dispatch, getState) => {
const previous = getState().getIn(['search', 'recent']);
const me = getState().getIn(['meta', 'me']);
const current = previous.filterNot(result => result.get('q') === q);
searchHistory.set(me, current.toJS());
dispatch(updateSearchHistory(current));
};
export const updateSearchHistory = recent => ({
type: SEARCH_HISTORY_UPDATE,
recent,
});
export const hydrateSearch = () => (dispatch, getState) => {
const me = getState().getIn(['meta', 'me']);
const history = searchHistory.get(me);
if (history !== null) {
dispatch(updateSearchHistory(history));
}
};

View File

@ -0,0 +1,148 @@
import { createAction } from '@reduxjs/toolkit';
import { apiGetSearch } from 'mastodon/api/search';
import type { ApiSearchType } from 'mastodon/api_types/search';
import type {
RecentSearch,
SearchType as RecentSearchType,
} from 'mastodon/models/search';
import { searchHistory } from 'mastodon/settings';
import {
createDataLoadingThunk,
createAppAsyncThunk,
} from 'mastodon/store/typed_functions';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatuses } from './importer';
export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE';
export const submitSearch = createDataLoadingThunk(
'search/submit',
async ({ q, type }: { q: string; type?: ApiSearchType }, { getState }) => {
const signedIn = !!getState().meta.get('me');
return apiGetSearch({
q,
type,
resolve: signedIn,
limit: 11,
});
},
(data, { dispatch }) => {
if (data.accounts.length > 0) {
dispatch(importFetchedAccounts(data.accounts));
dispatch(fetchRelationships(data.accounts.map((account) => account.id)));
}
if (data.statuses.length > 0) {
dispatch(importFetchedStatuses(data.statuses));
}
return data;
},
{
useLoadingBar: false,
},
);
export const expandSearch = createDataLoadingThunk(
'search/expand',
async ({ type }: { type: ApiSearchType }, { getState }) => {
const q = getState().search.q;
const results = getState().search.results;
const offset = results?.[type].length;
return apiGetSearch({
q,
type,
limit: 11,
offset,
});
},
(data, { dispatch }) => {
if (data.accounts.length > 0) {
dispatch(importFetchedAccounts(data.accounts));
dispatch(fetchRelationships(data.accounts.map((account) => account.id)));
}
if (data.statuses.length > 0) {
dispatch(importFetchedStatuses(data.statuses));
}
return data;
},
{
useLoadingBar: true,
},
);
export const openURL = createDataLoadingThunk(
'search/openURL',
({ url }: { url: string }) =>
apiGetSearch({
q: url,
resolve: true,
limit: 1,
}),
(data, { dispatch }) => {
if (data.accounts.length > 0) {
dispatch(importFetchedAccounts(data.accounts));
} else if (data.statuses.length > 0) {
dispatch(importFetchedStatuses(data.statuses));
}
return data;
},
{
useLoadingBar: true,
},
);
export const clickSearchResult = createAppAsyncThunk(
'search/clickResult',
(
{ q, type }: { q: string; type?: RecentSearchType },
{ dispatch, getState },
) => {
const previous = getState().search.recent;
if (previous.some((x) => x.q === q && x.type === type)) {
return;
}
const me = getState().meta.get('me') as string;
const current = [{ type, q }, ...previous].slice(0, 4);
searchHistory.set(me, current);
dispatch(updateSearchHistory(current));
},
);
export const forgetSearchResult = createAppAsyncThunk(
'search/forgetResult',
(q: string, { dispatch, getState }) => {
const previous = getState().search.recent;
const me = getState().meta.get('me') as string;
const current = previous.filter((result) => result.q !== q);
searchHistory.set(me, current);
dispatch(updateSearchHistory(current));
},
);
export const updateSearchHistory = createAction<RecentSearch[]>(
'search/updateHistory',
);
export const hydrateSearch = createAppAsyncThunk(
'search/hydrate',
(_args, { dispatch, getState }) => {
const me = getState().meta.get('me') as string;
const history = searchHistory.get(me) as RecentSearch[] | null;
if (history !== null) {
dispatch(updateSearchHistory(history));
}
},
);

View File

@ -1,58 +0,0 @@
import api from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL';
export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS';
export function fetchSuggestions(withRelationships = false) {
return (dispatch) => {
dispatch(fetchSuggestionsRequest());
api().get('/api/v2/suggestions', { params: { limit: 20 } }).then(response => {
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
dispatch(fetchSuggestionsSuccess(response.data));
if (withRelationships) {
dispatch(fetchRelationships(response.data.map(item => item.account.id)));
}
}).catch(error => dispatch(fetchSuggestionsFail(error)));
};
}
export function fetchSuggestionsRequest() {
return {
type: SUGGESTIONS_FETCH_REQUEST,
skipLoading: true,
};
}
export function fetchSuggestionsSuccess(suggestions) {
return {
type: SUGGESTIONS_FETCH_SUCCESS,
suggestions,
skipLoading: true,
};
}
export function fetchSuggestionsFail(error) {
return {
type: SUGGESTIONS_FETCH_FAIL,
error,
skipLoading: true,
skipAlert: true,
};
}
export const dismissSuggestion = accountId => (dispatch) => {
dispatch({
type: SUGGESTIONS_DISMISS,
id: accountId,
});
api().delete(`/api/v1/suggestions/${accountId}`).catch(() => {});
};

View File

@ -0,0 +1,24 @@
import {
apiGetSuggestions,
apiDeleteSuggestion,
} from 'mastodon/api/suggestions';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
export const fetchSuggestions = createDataLoadingThunk(
'suggestions/fetch',
() => apiGetSuggestions(20),
(data, { dispatch }) => {
dispatch(importFetchedAccounts(data.map((x) => x.account)));
dispatch(fetchRelationships(data.map((x) => x.account.id)));
return data;
},
);
export const dismissSuggestion = createDataLoadingThunk(
'suggestions/dismiss',
({ accountId }: { accountId: string }) => apiDeleteSuggestion(accountId),
);

View File

@ -1,9 +1,5 @@
import api, { getLinks } from '../api';
export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
@ -12,39 +8,6 @@ export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUES
export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
export const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST';
export const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS';
export const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL';
export const fetchHashtag = name => (dispatch) => {
dispatch(fetchHashtagRequest());
api().get(`/api/v1/tags/${name}`).then(({ data }) => {
dispatch(fetchHashtagSuccess(name, data));
}).catch(err => {
dispatch(fetchHashtagFail(err));
});
};
export const fetchHashtagRequest = () => ({
type: HASHTAG_FETCH_REQUEST,
});
export const fetchHashtagSuccess = (name, tag) => ({
type: HASHTAG_FETCH_SUCCESS,
name,
tag,
});
export const fetchHashtagFail = error => ({
type: HASHTAG_FETCH_FAIL,
error,
});
export const fetchFollowedHashtags = () => (dispatch) => {
dispatch(fetchFollowedHashtagsRequest());
@ -116,57 +79,3 @@ export function expandFollowedHashtagsFail(error) {
error,
};
}
export const followHashtag = name => (dispatch) => {
dispatch(followHashtagRequest(name));
api().post(`/api/v1/tags/${name}/follow`).then(({ data }) => {
dispatch(followHashtagSuccess(name, data));
}).catch(err => {
dispatch(followHashtagFail(name, err));
});
};
export const followHashtagRequest = name => ({
type: HASHTAG_FOLLOW_REQUEST,
name,
});
export const followHashtagSuccess = (name, tag) => ({
type: HASHTAG_FOLLOW_SUCCESS,
name,
tag,
});
export const followHashtagFail = (name, error) => ({
type: HASHTAG_FOLLOW_FAIL,
name,
error,
});
export const unfollowHashtag = name => (dispatch) => {
dispatch(unfollowHashtagRequest(name));
api().post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => {
dispatch(unfollowHashtagSuccess(name, data));
}).catch(err => {
dispatch(unfollowHashtagFail(name, err));
});
};
export const unfollowHashtagRequest = name => ({
type: HASHTAG_UNFOLLOW_REQUEST,
name,
});
export const unfollowHashtagSuccess = (name, tag) => ({
type: HASHTAG_UNFOLLOW_SUCCESS,
name,
tag,
});
export const unfollowHashtagFail = (name, error) => ({
type: HASHTAG_UNFOLLOW_FAIL,
name,
error,
});

View File

@ -0,0 +1,17 @@
import { apiGetTag, apiFollowTag, apiUnfollowTag } from 'mastodon/api/tags';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
export const fetchHashtag = createDataLoadingThunk(
'tags/fetch',
({ tagId }: { tagId: string }) => apiGetTag(tagId),
);
export const followHashtag = createDataLoadingThunk(
'tags/follow',
({ tagId }: { tagId: string }) => apiFollowTag(tagId),
);
export const unfollowHashtag = createDataLoadingThunk(
'tags/unfollow',
({ tagId }: { tagId: string }) => apiUnfollowTag(tagId),
);

View File

@ -5,3 +5,16 @@ export const apiSubmitAccountNote = (id: string, value: string) =>
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/note`, {
comment: value,
});
export const apiFollowAccount = (
id: string,
params?: {
reblogs: boolean;
},
) =>
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/follow`, {
...params,
});
export const apiUnfollowAccount = (id: string) =>
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/unfollow`);

View File

@ -0,0 +1,11 @@
import { apiRequestGet } from 'mastodon/api';
import type {
ApiTermsOfServiceJSON,
ApiPrivacyPolicyJSON,
} from 'mastodon/api_types/instance';
export const apiGetTermsOfService = () =>
apiRequestGet<ApiTermsOfServiceJSON>('v1/instance/terms_of_service');
export const apiGetPrivacyPolicy = () =>
apiRequestGet<ApiPrivacyPolicyJSON>('v1/instance/privacy_policy');

View File

@ -0,0 +1,10 @@
import { apiRequestGet, apiRequestPost } from 'mastodon/api';
import type { ApiPollJSON } from 'mastodon/api_types/polls';
export const apiGetPoll = (pollId: string) =>
apiRequestGet<ApiPollJSON>(`/v1/polls/${pollId}`);
export const apiPollVote = (pollId: string, choices: string[]) =>
apiRequestPost<ApiPollJSON>(`/v1/polls/${pollId}/votes`, {
choices,
});

View File

@ -0,0 +1,16 @@
import { apiRequestGet } from 'mastodon/api';
import type {
ApiSearchType,
ApiSearchResultsJSON,
} from 'mastodon/api_types/search';
export const apiGetSearch = (params: {
q: string;
resolve?: boolean;
type?: ApiSearchType;
limit?: number;
offset?: number;
}) =>
apiRequestGet<ApiSearchResultsJSON>('v2/search', {
...params,
});

View File

@ -0,0 +1,8 @@
import { apiRequestGet, apiRequestDelete } from 'mastodon/api';
import type { ApiSuggestionJSON } from 'mastodon/api_types/suggestions';
export const apiGetSuggestions = (limit: number) =>
apiRequestGet<ApiSuggestionJSON[]>('v2/suggestions', { limit });
export const apiDeleteSuggestion = (accountId: string) =>
apiRequestDelete(`v1/suggestions/${accountId}`);

View File

@ -0,0 +1,11 @@
import { apiRequestPost, apiRequestGet } from 'mastodon/api';
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
export const apiGetTag = (tagId: string) =>
apiRequestGet<ApiHashtagJSON>(`v1/tags/${tagId}`);
export const apiFollowTag = (tagId: string) =>
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/follow`);
export const apiUnfollowTag = (tagId: string) =>
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/unfollow`);

View File

@ -0,0 +1,9 @@
export interface ApiTermsOfServiceJSON {
updated_at: string;
content: string;
}
export interface ApiPrivacyPolicyJSON {
updated_at: string;
content: string;
}

View File

@ -18,6 +18,6 @@ export interface ApiPollJSON {
options: ApiPollOptionJSON[];
emojis: ApiCustomEmojiJSON[];
voted: boolean;
own_votes: number[];
voted?: boolean;
own_votes?: number[];
}

View File

@ -0,0 +1,11 @@
import type { ApiAccountJSON } from './accounts';
import type { ApiStatusJSON } from './statuses';
import type { ApiHashtagJSON } from './tags';
export type ApiSearchType = 'accounts' | 'statuses' | 'hashtags';
export interface ApiSearchResultsJSON {
accounts: ApiAccountJSON[];
statuses: ApiStatusJSON[];
hashtags: ApiHashtagJSON[];
}

View File

@ -0,0 +1,13 @@
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
export type ApiSuggestionSourceJSON =
| 'featured'
| 'most_followed'
| 'most_interactions'
| 'similar_to_recently_followed'
| 'friends_of_friends';
export interface ApiSuggestionJSON {
sources: [ApiSuggestionSourceJSON, ...ApiSuggestionSourceJSON[]];
account: ApiAccountJSON;
}

View File

@ -0,0 +1,13 @@
interface ApiHistoryJSON {
day: string;
accounts: string;
uses: string;
}
export interface ApiHashtagJSON {
id: string;
name: string;
url: string;
history: [ApiHistoryJSON, ...ApiHistoryJSON[]];
following?: boolean;
}

View File

@ -1,181 +0,0 @@
import PropTypes from 'prop-types';
import { useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { EmptyAccount } from 'mastodon/components/empty_account';
import { ShortNumber } from 'mastodon/components/short_number';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import DropdownMenuContainer from '../containers/dropdown_menu_container';
import { me } from '../initial_state';
import { Avatar } from './avatar';
import { Button } from './button';
import { FollowersCounter } from './counters';
import { DisplayName } from './display_name';
import { RelativeTimestamp } from './relative_timestamp';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' },
unmute_notifications: { id: 'account.unmute_notifications_short', defaultMessage: 'Unmute notifications' },
mute: { id: 'account.mute_short', defaultMessage: 'Mute' },
block: { id: 'account.block_short', defaultMessage: 'Block' },
more: { id: 'status.more', defaultMessage: 'More' },
});
const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifications, hidden, minimal, defaultAction, withBio }) => {
const intl = useIntl();
const handleFollow = useCallback(() => {
onFollow(account);
}, [onFollow, account]);
const handleBlock = useCallback(() => {
onBlock(account);
}, [onBlock, account]);
const handleMute = useCallback(() => {
onMute(account);
}, [onMute, account]);
const handleMuteNotifications = useCallback(() => {
onMuteNotifications(account, true);
}, [onMuteNotifications, account]);
const handleUnmuteNotifications = useCallback(() => {
onMuteNotifications(account, false);
}, [onMuteNotifications, account]);
if (!account) {
return <EmptyAccount size={size} minimal={minimal} />;
}
if (hidden) {
return (
<>
{account.get('display_name')}
{account.get('username')}
</>
);
}
let buttons;
if (account.get('id') !== me && account.get('relationship', null) !== null) {
const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']);
const muting = account.getIn(['relationship', 'muting']);
if (requested) {
buttons = <Button text={intl.formatMessage(messages.cancel_follow_request)} onClick={handleFollow} />;
} else if (blocking) {
buttons = <Button text={intl.formatMessage(messages.unblock)} onClick={handleBlock} />;
} else if (muting) {
let menu;
if (account.getIn(['relationship', 'muting_notifications'])) {
menu = [{ text: intl.formatMessage(messages.unmute_notifications), action: handleUnmuteNotifications }];
} else {
menu = [{ text: intl.formatMessage(messages.mute_notifications), action: handleMuteNotifications }];
}
buttons = (
<>
<DropdownMenuContainer
items={menu}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
<Button text={intl.formatMessage(messages.unmute)} onClick={handleMute} />
</>
);
} else if (defaultAction === 'mute') {
buttons = <Button text={intl.formatMessage(messages.mute)} onClick={handleMute} />;
} else if (defaultAction === 'block') {
buttons = <Button text={intl.formatMessage(messages.block)} onClick={handleBlock} />;
} else if (!account.get('suspended') && !account.get('moved') || following) {
buttons = <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={handleFollow} />;
}
}
let muteTimeRemaining;
if (account.get('mute_expires_at')) {
muteTimeRemaining = <>· <RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></>;
}
let verification;
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
if (firstVerifiedField) {
verification = <VerifiedBadge link={firstVerifiedField.get('value')} />;
}
return (
<div className={classNames('account', { 'account--minimal': minimal })}>
<div className='account__wrapper'>
<Link key={account.get('id')} className='account__display-name' title={account.get('acct')} to={`/@${account.get('acct')}`} data-hover-card-account={account.get('id')}>
<div className='account__avatar-wrapper'>
<Avatar account={account} size={size} />
</div>
<div className='account__contents'>
<DisplayName account={account} />
{!minimal && (
<div className='account__details'>
<ShortNumber value={account.get('followers_count')} renderer={FollowersCounter} /> {verification} {muteTimeRemaining}
</div>
)}
</div>
</Link>
{!minimal && (
<div className='account__relationship'>
{buttons}
</div>
)}
</div>
{withBio && (account.get('note').length > 0 ? (
<div
className='account__note translate'
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
/>
) : (
<div className='account__note account__note--missing'><FormattedMessage id='account.no_bio' defaultMessage='No description provided.' /></div>
))}
</div>
);
};
Account.propTypes = {
size: PropTypes.number,
account: ImmutablePropTypes.record,
onFollow: PropTypes.func,
onBlock: PropTypes.func,
onMute: PropTypes.func,
onMuteNotifications: PropTypes.func,
hidden: PropTypes.bool,
minimal: PropTypes.bool,
defaultAction: PropTypes.string,
withBio: PropTypes.bool,
};
export default Account;

View File

@ -0,0 +1,235 @@
import { useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import {
blockAccount,
unblockAccount,
muteAccount,
unmuteAccount,
} from 'mastodon/actions/accounts';
import { initMuteModal } from 'mastodon/actions/mutes';
import { Avatar } from 'mastodon/components/avatar';
import { Button } from 'mastodon/components/button';
import { FollowersCounter } from 'mastodon/components/counters';
import { DisplayName } from 'mastodon/components/display_name';
import { FollowButton } from 'mastodon/components/follow_button';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import DropdownMenu from 'mastodon/containers/dropdown_menu_container';
import { me } from 'mastodon/initial_state';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
cancel_follow_request: {
id: 'account.cancel_follow_request',
defaultMessage: 'Withdraw follow request',
},
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
mute_notifications: {
id: 'account.mute_notifications_short',
defaultMessage: 'Mute notifications',
},
unmute_notifications: {
id: 'account.unmute_notifications_short',
defaultMessage: 'Unmute notifications',
},
mute: { id: 'account.mute_short', defaultMessage: 'Mute' },
block: { id: 'account.block_short', defaultMessage: 'Block' },
more: { id: 'status.more', defaultMessage: 'More' },
});
export const Account: React.FC<{
size?: number;
id: string;
hidden?: boolean;
minimal?: boolean;
defaultAction?: 'block' | 'mute';
withBio?: boolean;
}> = ({ id, size = 46, hidden, minimal, defaultAction, withBio }) => {
const intl = useIntl();
const account = useAppSelector((state) => state.accounts.get(id));
const relationship = useAppSelector((state) => state.relationships.get(id));
const dispatch = useAppDispatch();
const handleBlock = useCallback(() => {
if (relationship?.blocking) {
dispatch(unblockAccount(id));
} else {
dispatch(blockAccount(id));
}
}, [dispatch, id, relationship]);
const handleMute = useCallback(() => {
if (relationship?.muting) {
dispatch(unmuteAccount(id));
} else {
dispatch(initMuteModal(account));
}
}, [dispatch, id, account, relationship]);
const handleMuteNotifications = useCallback(() => {
dispatch(muteAccount(id, true));
}, [dispatch, id]);
const handleUnmuteNotifications = useCallback(() => {
dispatch(muteAccount(id, false));
}, [dispatch, id]);
if (hidden) {
return (
<>
{account?.display_name}
{account?.username}
</>
);
}
let buttons;
if (account && account.id !== me && relationship) {
const { requested, blocking, muting } = relationship;
if (requested) {
buttons = <FollowButton accountId={id} />;
} else if (blocking) {
buttons = (
<Button
text={intl.formatMessage(messages.unblock)}
onClick={handleBlock}
/>
);
} else if (muting) {
const menu = [
{
text: intl.formatMessage(
relationship.muting_notifications
? messages.unmute_notifications
: messages.mute_notifications,
),
action: relationship.muting_notifications
? handleUnmuteNotifications
: handleMuteNotifications,
},
];
buttons = (
<>
<DropdownMenu
items={menu}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
<Button
text={intl.formatMessage(messages.unmute)}
onClick={handleMute}
/>
</>
);
} else if (defaultAction === 'mute') {
buttons = (
<Button text={intl.formatMessage(messages.mute)} onClick={handleMute} />
);
} else if (defaultAction === 'block') {
buttons = (
<Button
text={intl.formatMessage(messages.block)}
onClick={handleBlock}
/>
);
} else {
buttons = <FollowButton accountId={id} />;
}
} else {
buttons = <FollowButton accountId={id} />;
}
let muteTimeRemaining;
if (account?.mute_expires_at) {
muteTimeRemaining = (
<>
· <RelativeTimestamp timestamp={account.mute_expires_at} futureDate />
</>
);
}
let verification;
const firstVerifiedField = account?.fields.find((item) => !!item.verified_at);
if (firstVerifiedField) {
verification = <VerifiedBadge link={firstVerifiedField.value} />;
}
return (
<div className={classNames('account', { 'account--minimal': minimal })}>
<div className='account__wrapper'>
<Link
className='account__display-name'
title={account?.acct}
to={`/@${account?.acct}`}
data-hover-card-account={id}
>
<div className='account__avatar-wrapper'>
{account ? (
<Avatar account={account} size={size} />
) : (
<Skeleton width={size} height={size} />
)}
</div>
<div className='account__contents'>
<DisplayName account={account} />
{!minimal && (
<div className='account__details'>
{account ? (
<>
<ShortNumber
value={account.followers_count}
renderer={FollowersCounter}
/>{' '}
{verification} {muteTimeRemaining}
</>
) : (
<Skeleton width='7ch' />
)}
</div>
)}
</div>
</Link>
{!minimal && <div className='account__relationship'>{buttons}</div>}
</div>
{account &&
withBio &&
(account.note.length > 0 ? (
<div
className='account__note translate'
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
/>
) : (
<div className='account__note account__note--missing'>
<FormattedMessage
id='account.no_bio'
defaultMessage='No description provided.'
/>
</div>
))}
</div>
);
};

View File

@ -36,7 +36,7 @@ export default class AttachmentList extends ImmutablePureComponent {
return (
<li key={attachment.get('id')}>
<a href={displayUrl} target='_blank' rel='noopener noreferrer'>
<a href={displayUrl} target='_blank' rel='noopener'>
{compact && <Icon id='link' icon={LinkIcon} />}
{compact && ' ' }
{displayUrl ? filename(displayUrl) : <FormattedMessage id='attachments_list.unprocessed' defaultMessage='(unprocessed)' />}

View File

@ -1,72 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { supportsPassiveEvents } from 'detect-passive-events';
import { scrollTop } from '../scroll';
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
export default class Column extends PureComponent {
static propTypes = {
children: PropTypes.node,
label: PropTypes.string,
bindToDocument: PropTypes.bool,
};
scrollTop () {
let scrollable = null;
if (this.props.bindToDocument) {
scrollable = document.scrollingElement;
} else {
scrollable = this.node.querySelector('.scrollable');
}
if (!scrollable) {
return;
}
this._interruptScrollAnimation = scrollTop(scrollable);
}
handleWheel = () => {
if (typeof this._interruptScrollAnimation !== 'function') {
return;
}
this._interruptScrollAnimation();
};
setRef = c => {
this.node = c;
};
componentDidMount () {
if (this.props.bindToDocument) {
document.addEventListener('wheel', this.handleWheel, listenerOptions);
} else {
this.node.addEventListener('wheel', this.handleWheel, listenerOptions);
}
}
componentWillUnmount () {
if (this.props.bindToDocument) {
document.removeEventListener('wheel', this.handleWheel, listenerOptions);
} else {
this.node.removeEventListener('wheel', this.handleWheel, listenerOptions);
}
}
render () {
const { label, children } = this.props;
return (
<div role='region' aria-label={label} className='column' ref={this.setRef}>
{children}
</div>
);
}
}

View File

@ -0,0 +1,52 @@
import { forwardRef, useRef, useImperativeHandle } from 'react';
import type { Ref } from 'react';
import { scrollTop } from 'mastodon/scroll';
export interface ColumnRef {
scrollTop: () => void;
node: HTMLDivElement | null;
}
interface ColumnProps {
children?: React.ReactNode;
label?: string;
bindToDocument?: boolean;
}
export const Column = forwardRef<ColumnRef, ColumnProps>(
({ children, label, bindToDocument }, ref: Ref<ColumnRef>) => {
const nodeRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => ({
node: nodeRef.current,
scrollTop() {
let scrollable = null;
if (bindToDocument) {
scrollable = document.scrollingElement;
} else {
scrollable = nodeRef.current?.querySelector('.scrollable');
}
if (!scrollable) {
return;
}
scrollTop(scrollable);
},
}));
return (
<div role='region' aria-label={label} className='column' ref={nodeRef}>
{children}
</div>
);
},
);
Column.displayName = 'Column';
// eslint-disable-next-line import/no-default-export
export default Column;

View File

@ -24,7 +24,7 @@ function useHandleClick(onClick?: OnClickCallback) {
}, [history, onClick]);
}
export const ColumnBackButton: React.FC<{ onClick: OnClickCallback }> = ({
export const ColumnBackButton: React.FC<{ onClick?: OnClickCallback }> = ({
onClick,
}) => {
const handleClick = useHandleClick(onClick);

View File

@ -0,0 +1,67 @@
import { useCallback, useState, useEffect, useRef } from 'react';
import { FormattedMessage } from 'react-intl';
export const ColumnSearchHeader: React.FC<{
onBack: () => void;
onSubmit: (value: string) => void;
onActivate: () => void;
placeholder: string;
active: boolean;
}> = ({ onBack, onActivate, onSubmit, placeholder, active }) => {
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState('');
useEffect(() => {
if (!active) {
setValue('');
}
}, [active]);
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setValue(value);
onSubmit(value);
},
[setValue, onSubmit],
);
const handleKeyUp = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
e.preventDefault();
onBack();
inputRef.current?.blur();
}
},
[onBack],
);
const handleFocus = useCallback(() => {
onActivate();
}, [onActivate]);
const handleSubmit = useCallback(() => {
onSubmit(value);
}, [onSubmit, value]);
return (
<form className='column-search-header' onSubmit={handleSubmit}>
<input
ref={inputRef}
type='search'
value={value}
onChange={handleChange}
onKeyUp={handleKeyUp}
placeholder={placeholder}
onFocus={handleFocus}
/>
{active && (
<button type='button' className='link-button' onClick={onBack}>
<FormattedMessage id='column_search.cancel' defaultMessage='Cancel' />
</button>
)}
</form>
);
};

View File

@ -124,7 +124,7 @@ class DropdownMenu extends PureComponent {
return (
<li className={classNames('dropdown-menu__item', { 'dropdown-menu__item--dangerous': dangerous })} key={`${text}-${i}`}>
<a href={href} target={target} data-method={method} rel='noopener noreferrer' role='button' tabIndex={0} ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
<a href={href} target={target} data-method={method} rel='noopener' role='button' tabIndex={0} ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
{text}
</a>
</li>

View File

@ -1,33 +0,0 @@
import React from 'react';
import classNames from 'classnames';
import { DisplayName } from 'mastodon/components/display_name';
import { Skeleton } from 'mastodon/components/skeleton';
interface Props {
size?: number;
minimal?: boolean;
}
export const EmptyAccount: React.FC<Props> = ({
size = 46,
minimal = false,
}) => {
return (
<div className={classNames('account', { 'account--minimal': minimal })}>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'>
<Skeleton width={size} height={size} />
</div>
<div>
<DisplayName />
<Skeleton width='7ch' />
</div>
</div>
</div>
</div>
);
};

View File

@ -98,7 +98,7 @@ export default class ErrorBoundary extends PureComponent {
)}
</p>
<p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
<p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
</div>
<Helmet>

View File

@ -88,7 +88,7 @@ export const FollowButton: React.FC<{
<a
href='/settings/profile'
target='_blank'
rel='noreferrer noopener'
rel='noopener'
className='button button-secondary'
>
{label}
@ -99,7 +99,12 @@ export const FollowButton: React.FC<{
return (
<Button
onClick={handleClick}
disabled={relationship?.blocked_by || relationship?.blocking}
disabled={
relationship?.blocked_by ||
relationship?.blocking ||
(!(relationship?.following || relationship?.requested) &&
(account?.suspended || !!account?.moved))
}
secondary={following}
className={following ? 'button--destructive' : undefined}
>

View File

@ -0,0 +1,22 @@
import { useHovering } from '@/hooks/useHovering';
import { autoPlayGif } from 'mastodon/initial_state';
export const GIF: React.FC<{
src: string;
staticSrc: string;
className: string;
animate?: boolean;
}> = ({ src, staticSrc, className, animate = autoPlayGif }) => {
const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(animate);
return (
<img
className={className}
src={hovering || animate ? src : staticSrc}
alt=''
role='presentation'
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
);
};

View File

@ -12,6 +12,7 @@ import { Sparklines, SparklinesCurve } from 'react-sparklines';
import { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton';
import type { Hashtag as HashtagType } from 'mastodon/models/tags';
interface SilentErrorBoundaryProps {
children: React.ReactNode;
@ -80,6 +81,22 @@ export const ImmutableHashtag = ({ hashtag }: ImmutableHashtagProps) => (
/>
);
export const CompatibilityHashtag: React.FC<{
hashtag: HashtagType;
}> = ({ hashtag }) => (
<Hashtag
name={hashtag.name}
to={`/tags/${hashtag.name}`}
people={
(hashtag.history[0].accounts as unknown as number) * 1 +
((hashtag.history[1]?.accounts ?? 0) as unknown as number) * 1
}
history={hashtag.history
.map((day) => (day.uses as unknown as number) * 1)
.reverse()}
/>
);
export interface HashtagProps {
className?: string;
description?: React.ReactNode;

View File

@ -106,7 +106,7 @@ class Item extends PureComponent {
if (attachment.get('type') === 'unknown') {
return (
<div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={description} lang={lang} target='_blank' rel='noopener noreferrer'>
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={description} lang={lang} target='_blank' rel='noopener'>
<Blurhash
hash={attachment.get('blurhash')}
className='media-gallery__preview'
@ -138,7 +138,7 @@ class Item extends PureComponent {
href={attachment.get('remote_url') || originalUrl}
onClick={this.handleClick}
target='_blank'
rel='noopener noreferrer'
rel='noopener'
>
<img
src={previewUrl}

View File

@ -33,15 +33,10 @@ const messages = defineMessages({
},
});
const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
return obj;
}, {});
class Poll extends ImmutablePureComponent {
static propTypes = {
identity: identityContextPropShape,
poll: ImmutablePropTypes.map.isRequired,
poll: ImmutablePropTypes.record.isRequired,
status: ImmutablePropTypes.map.isRequired,
lang: PropTypes.string,
intl: PropTypes.object.isRequired,
@ -150,7 +145,7 @@ class Poll extends ImmutablePureComponent {
let titleHtml = option.getIn(['translation', 'titleHtml']) || option.get('titleHtml');
if (!titleHtml) {
const emojiMap = makeEmojiMap(poll);
const emojiMap = emojiMap(poll);
titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap);
}

View File

@ -1,18 +0,0 @@
import { FormattedMessage } from 'react-intl';
import illustration from '@/images/elephant_ui_working.svg';
const RegenerationIndicator = () => (
<div className='regeneration-indicator'>
<div className='regeneration-indicator__figure'>
<img src={illustration} alt='' />
</div>
<div className='regeneration-indicator__label'>
<FormattedMessage id='regeneration_indicator.label' tagName='strong' defaultMessage='Loading&hellip;' />
<FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' />
</div>
</div>
);
export default RegenerationIndicator;

View File

@ -0,0 +1,26 @@
import { FormattedMessage } from 'react-intl';
import { GIF } from './gif';
export const RegenerationIndicator: React.FC = () => (
<div className='regeneration-indicator'>
<GIF
src='/loading.gif'
staticSrc='/loading.png'
className='regeneration-indicator__figure'
/>
<div className='regeneration-indicator__label'>
<strong>
<FormattedMessage
id='regeneration_indicator.preparing_your_home_feed'
defaultMessage='Preparing your home feed…'
/>
</strong>
<FormattedMessage
id='regeneration_indicator.please_stand_by'
defaultMessage='Please stand by.'
/>
</div>
</div>
);

View File

@ -8,10 +8,10 @@ import { Link } from 'react-router-dom';
import { connect } from 'react-redux';
import { fetchServer } from 'mastodon/actions/server';
import { Account } from 'mastodon/components/account';
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
import { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton';
import Account from 'mastodon/containers/account_container';
import { domain } from 'mastodon/initial_state';
const messages = defineMessages({
@ -42,7 +42,7 @@ class ServerBanner extends PureComponent {
return (
<div className='server-banner'>
<div className='server-banner__introduction'>
<FormattedMessage id='server_banner.is_one_of_many' defaultMessage='{domain} is one of the many independent Mastodon servers you can use to participate in the fediverse.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank'>Mastodon</a> }} />
<FormattedMessage id='server_banner.is_one_of_many' defaultMessage='{domain} is one of the many independent Mastodon servers you can use to participate in the fediverse.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank' rel='noopener'>Mastodon</a> }} />
</div>
<Link to='/about'>

View File

@ -173,7 +173,7 @@ class Status extends ImmutablePureComponent {
handleMouseUp = e => {
// Only handle clicks on the empty space above the content
if (e.target !== e.currentTarget) {
if (e.target !== e.currentTarget && e.detail >= 1) {
return;
}
@ -293,7 +293,7 @@ class Status extends ImmutablePureComponent {
if (e?.button === 0 && !(e?.ctrlKey || e?.metaKey)) {
history.push(path);
} else if (e?.button === 1 || (e?.button === 0 && (e?.ctrlKey || e?.metaKey))) {
window.open(path, '_blank', 'noreferrer noopener');
window.open(path, '_blank', 'noopener');
}
};

View File

@ -204,7 +204,7 @@ class StatusContent extends PureComponent {
element = element.parentNode;
}
if (deltaX + deltaY < 5 && (e.button === 0 || e.button === 1) && this.props.onClick) {
if (deltaX + deltaY < 5 && (e.button === 0 || e.button === 1) && e.detail >= 1 && this.props.onClick) {
this.props.onClick(e);
}

View File

@ -6,7 +6,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { debounce } from 'lodash';
import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines';
import RegenerationIndicator from 'mastodon/components/regeneration_indicator';
import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator';
import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions';
import StatusContainer from '../containers/status_container';

View File

@ -1,60 +0,0 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { openModal } from 'mastodon/actions/modal';
import {
followAccount,
blockAccount,
unblockAccount,
muteAccount,
unmuteAccount,
} from '../actions/accounts';
import { initMuteModal } from '../actions/mutes';
import Account from '../components/account';
import { makeGetAccount } from '../selectors';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, props) => ({
account: getAccount(state, props.id),
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch) => ({
onFollow (account) {
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
dispatch(openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }));
} else {
dispatch(followAccount(account.get('id')));
}
},
onBlock (account) {
if (account.getIn(['relationship', 'blocking'])) {
dispatch(unblockAccount(account.get('id')));
} else {
dispatch(blockAccount(account.get('id')));
}
},
onMute (account) {
if (account.getIn(['relationship', 'muting'])) {
dispatch(unmuteAccount(account.get('id')));
} else {
dispatch(initMuteModal(account));
}
},
onMuteNotifications (account, notifications) {
dispatch(muteAccount(account.get('id'), notifications));
},
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));

View File

@ -9,14 +9,14 @@ import Poll from 'mastodon/components/poll';
const mapDispatchToProps = (dispatch, { pollId }) => ({
refresh: debounce(
() => {
dispatch(fetchPoll(pollId));
dispatch(fetchPoll({ pollId }));
},
1000,
{ leading: true },
),
onVote (choices) {
dispatch(vote(pollId, choices));
dispatch(vote({ pollId, choices }));
},
onInteractionModal (type, status) {
@ -32,7 +32,7 @@ const mapDispatchToProps = (dispatch, { pollId }) => ({
});
const mapStateToProps = (state, { pollId }) => ({
poll: state.getIn(['polls', pollId]),
poll: state.polls.get(pollId),
});
export default connect(mapStateToProps, mapDispatchToProps)(Poll);

View File

@ -13,12 +13,12 @@ import { connect } from 'react-redux';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import ExpandMoreIcon from '@/material-icons/400-24px/expand_more.svg?react';
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'mastodon/actions/server';
import { Account } from 'mastodon/components/account';
import Column from 'mastodon/components/column';
import { Icon } from 'mastodon/components/icon';
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
import { Skeleton } from 'mastodon/components/skeleton';
import Account from 'mastodon/containers/account_container';
import LinkFooter from 'mastodon/features/ui/components/link_footer';
import { LinkFooter} from 'mastodon/features/ui/components/link_footer';
const messages = defineMessages({
title: { id: 'column.about', defaultMessage: 'About' },
@ -123,7 +123,7 @@ class About extends PureComponent {
<div className='about__header'>
<ServerHeroImage blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} srcSet={server.getIn(['thumbnail', 'versions'])?.map((value, key) => `${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' />
<h1>{isLoading ? <Skeleton width='10ch' /> : server.get('domain')}</h1>
<p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank'>Mastodon</a> }} /></p>
<p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank' rel='noopener'>Mastodon</a> }} /></p>
</div>
<div className='about__meta'>

View File

@ -6,6 +6,7 @@ import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { NavLink, withRouter } from 'react-router-dom';
import { isFulfilled, isRejected } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
@ -215,8 +216,20 @@ class Header extends ImmutablePureComponent {
const link = e.currentTarget;
onOpenURL(link.href, history, () => {
window.location = link.href;
onOpenURL(link.href).then((result) => {
if (isFulfilled(result)) {
if (result.payload.accounts[0]) {
history.push(`/@${result.payload.accounts[0].acct}`);
} else if (result.payload.statuses[0]) {
history.push(`/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`);
} else {
window.location = link.href;
}
} else if (isRejected(result)) {
window.location = link.href;
}
}).catch(() => {
// Nothing
});
}
};
@ -421,7 +434,7 @@ class Header extends ImmutablePureComponent {
<div className='account__header__bar'>
<div className='account__header__tabs'>
<a className='avatar' href={account.get('avatar')} rel='noopener noreferrer' target='_blank' onClick={this.handleAvatarClick}>
<a className='avatar' href={account.get('avatar')} rel='noopener' target='_blank' onClick={this.handleAvatarClick}>
<Avatar account={suspended || hidden ? undefined : account} size={90} />
</a>

View File

@ -144,8 +144,8 @@ const mapDispatchToProps = (dispatch) => ({
}));
},
onOpenURL (url, routerHistory, onFailure) {
dispatch(openURL(url, routerHistory, onFailure));
onOpenURL (url) {
return dispatch(openURL({ url }));
},
});

View File

@ -1,6 +1,7 @@
/* eslint-disable react/jsx-no-useless-fragment */
import { FormattedMessage, FormattedNumber } from 'react-intl';
import { domain } from 'mastodon/initial_state';
import type { Percentiles } from 'mastodon/models/annual_report';
export const Percentile: React.FC<{
@ -12,7 +13,7 @@ export const Percentile: React.FC<{
<div className='annual-report__bento__box annual-report__summary__percentile'>
<FormattedMessage
id='annual_report.summary.percentile.text'
defaultMessage='<topLabel>That puts you in the top</topLabel><percentage></percentage><bottomLabel>of Mastodon users.</bottomLabel>'
defaultMessage='<topLabel>That puts you in the top</topLabel><percentage></percentage><bottomLabel>of {domain} users.</bottomLabel>'
values={{
topLabel: (str) => (
<div className='annual-report__summary__percentile__label'>
@ -44,6 +45,8 @@ export const Percentile: React.FC<{
)}
</div>
),
domain,
}}
>
{(message) => <>{message}</>}

View File

@ -9,11 +9,11 @@ import { connect } from 'react-redux';
import { debounce } from 'lodash';
import BlockIcon from '@/material-icons/400-24px/block-fill.svg?react';
import { Account } from 'mastodon/components/account';
import { fetchBlocks, expandBlocks } from '../../actions/blocks';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';
import AccountContainer from '../../containers/account_container';
import Column from '../ui/components/column';
const messages = defineMessages({
@ -70,7 +70,7 @@ class Blocks extends ImmutablePureComponent {
bindToDocument={!multiColumn}
>
{accountIds.map(id =>
<AccountContainer key={id} id={id} defaultAction='block' />,
<Account key={id} id={id} defaultAction='block' />,
)}
</ScrollableList>
</Column>

View File

@ -6,7 +6,7 @@ import { useSelector, useDispatch } from 'react-redux';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { cancelReplyCompose } from 'mastodon/actions/compose';
import Account from 'mastodon/components/account';
import { Account } from 'mastodon/components/account';
import { IconButton } from 'mastodon/components/icon_button';
import { me } from 'mastodon/initial_state';
@ -20,7 +20,6 @@ const messages = defineMessages({
export const NavigationBar = () => {
const dispatch = useDispatch();
const intl = useIntl();
const account = useSelector(state => state.getIn(['accounts', me]));
const isReplying = useSelector(state => !!state.getIn(['compose', 'in_reply_to']));
const handleCancelClick = useCallback(() => {
@ -29,7 +28,7 @@ export const NavigationBar = () => {
return (
<div className='navigation-bar'>
<Account account={account} minimal />
<Account id={me} minimal />
{isReplying ? <IconButton title={intl.formatMessage(messages.cancel)} iconComponent={CloseIcon} onClick={handleCancelClick} /> : <ActionBar />}
</div>
);

View File

@ -1,402 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl, FormattedMessage, FormattedList } from 'react-intl';
import classNames from 'classnames';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import { Icon } from 'mastodon/components/icon';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { domain, searchEnabled } from 'mastodon/initial_state';
import { HASHTAG_REGEX } from 'mastodon/utils/hashtags';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' },
});
const labelForRecentSearch = search => {
switch(search.get('type')) {
case 'account':
return `@${search.get('q')}`;
case 'hashtag':
return `#${search.get('q')}`;
default:
return search.get('q');
}
};
class Search extends PureComponent {
static propTypes = {
identity: identityContextPropShape,
value: PropTypes.string.isRequired,
recent: ImmutablePropTypes.orderedSet,
submitted: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onOpenURL: PropTypes.func.isRequired,
onClickSearchResult: PropTypes.func.isRequired,
onForgetSearchResult: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
onShow: PropTypes.func.isRequired,
openInRoute: PropTypes.bool,
intl: PropTypes.object.isRequired,
singleColumn: PropTypes.bool,
...WithRouterPropTypes,
};
state = {
expanded: false,
selectedOption: -1,
options: [],
};
defaultOptions = [
{ key: 'prompt-has', label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:'); } },
{ key: 'prompt-is', label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:'); } },
{ key: 'prompt-language', label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:'); } },
{ key: 'prompt-from', label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:'); } },
{ key: 'prompt-before', label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:'); } },
{ key: 'prompt-during', label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:'); } },
{ key: 'prompt-after', label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:'); } },
{ key: 'prompt-in', label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library', 'public']} /></>, action: e => { e.preventDefault(); this._insertText('in:'); } }
];
setRef = c => {
this.searchForm = c;
};
handleChange = ({ target }) => {
const { onChange } = this.props;
onChange(target.value);
this._calculateOptions(target.value);
};
handleClear = e => {
const { value, submitted, onClear } = this.props;
e.preventDefault();
if (value.length > 0 || submitted) {
onClear();
this.setState({ options: [], selectedOption: -1 });
}
};
handleKeyDown = (e) => {
const { selectedOption } = this.state;
const options = searchEnabled ? this._getOptions().concat(this.defaultOptions) : this._getOptions();
switch(e.key) {
case 'Escape':
e.preventDefault();
this._unfocus();
break;
case 'ArrowDown':
e.preventDefault();
if (options.length > 0) {
this.setState({ selectedOption: Math.min(selectedOption + 1, options.length - 1) });
}
break;
case 'ArrowUp':
e.preventDefault();
if (options.length > 0) {
this.setState({ selectedOption: Math.max(selectedOption - 1, -1) });
}
break;
case 'Enter':
e.preventDefault();
if (selectedOption === -1) {
this._submit();
} else if (options.length > 0) {
options[selectedOption].action(e);
}
break;
case 'Delete':
if (selectedOption > -1 && options.length > 0) {
const search = options[selectedOption];
if (typeof search.forget === 'function') {
e.preventDefault();
search.forget(e);
}
}
break;
}
};
handleFocus = () => {
const { onShow, singleColumn } = this.props;
this.setState({ expanded: true, selectedOption: -1 });
onShow();
if (this.searchForm && !singleColumn) {
const { left, right } = this.searchForm.getBoundingClientRect();
if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
this.searchForm.scrollIntoView();
}
}
};
handleBlur = () => {
this.setState({ expanded: false, selectedOption: -1 });
};
handleHashtagClick = () => {
const { value, onClickSearchResult, history } = this.props;
const query = value.trim().replace(/^#/, '');
history.push(`/tags/${query}`);
onClickSearchResult(query, 'hashtag');
this._unfocus();
};
handleAccountClick = () => {
const { value, onClickSearchResult, history } = this.props;
const query = value.trim().replace(/^@/, '');
history.push(`/@${query}`);
onClickSearchResult(query, 'account');
this._unfocus();
};
handleURLClick = () => {
const { value, onOpenURL, history } = this.props;
onOpenURL(value, history);
this._unfocus();
};
handleStatusSearch = () => {
this._submit('statuses');
};
handleAccountSearch = () => {
this._submit('accounts');
};
handleRecentSearchClick = search => {
const { onChange, history } = this.props;
if (search.get('type') === 'account') {
history.push(`/@${search.get('q')}`);
} else if (search.get('type') === 'hashtag') {
history.push(`/tags/${search.get('q')}`);
} else {
onChange(search.get('q'));
this._submit(search.get('type'));
}
this._unfocus();
};
handleForgetRecentSearchClick = search => {
const { onForgetSearchResult } = this.props;
onForgetSearchResult(search.get('q'));
};
_unfocus () {
document.querySelector('.ui').parentElement.focus();
}
_insertText (text) {
const { value, onChange } = this.props;
if (value === '') {
onChange(text);
} else if (value[value.length - 1] === ' ') {
onChange(`${value}${text}`);
} else {
onChange(`${value} ${text}`);
}
}
_submit (type) {
const { onSubmit, openInRoute, value, onClickSearchResult, history } = this.props;
onSubmit(type);
if (value) {
onClickSearchResult(value, type);
}
if (openInRoute) {
history.push('/search');
}
this._unfocus();
}
_getOptions () {
const { options } = this.state;
if (options.length > 0) {
return options;
}
const { recent } = this.props;
return recent.toArray().map(search => ({
key: `${search.get('type')}/${search.get('q')}`,
label: labelForRecentSearch(search),
action: () => this.handleRecentSearchClick(search),
forget: e => {
e.stopPropagation();
this.handleForgetRecentSearchClick(search);
},
}));
}
_calculateOptions (value) {
const { signedIn } = this.props.identity;
const trimmedValue = value.trim();
const options = [];
if (trimmedValue.length > 0) {
const couldBeURL = trimmedValue.startsWith('https://') && !trimmedValue.includes(' ');
if (couldBeURL) {
options.push({ key: 'open-url', label: <FormattedMessage id='search.quick_action.open_url' defaultMessage='Open URL in Mastodon' />, action: this.handleURLClick });
}
const couldBeHashtag = (trimmedValue.startsWith('#') && trimmedValue.length > 1) || trimmedValue.match(HASHTAG_REGEX);
if (couldBeHashtag) {
options.push({ key: 'go-to-hashtag', label: <FormattedMessage id='search.quick_action.go_to_hashtag' defaultMessage='Go to hashtag {x}' values={{ x: <mark>#{trimmedValue.replace(/^#/, '')}</mark> }} />, action: this.handleHashtagClick });
}
const couldBeUsername = trimmedValue.match(/^@?[a-z0-9_-]+(@[^\s]+)?$/i);
if (couldBeUsername) {
options.push({ key: 'go-to-account', label: <FormattedMessage id='search.quick_action.go_to_account' defaultMessage='Go to profile {x}' values={{ x: <mark>@{trimmedValue.replace(/^@/, '')}</mark> }} />, action: this.handleAccountClick });
}
const couldBeStatusSearch = searchEnabled;
if (couldBeStatusSearch && signedIn) {
options.push({ key: 'status-search', label: <FormattedMessage id='search.quick_action.status_search' defaultMessage='Posts matching {x}' values={{ x: <mark>{trimmedValue}</mark> }} />, action: this.handleStatusSearch });
}
const couldBeUserSearch = true;
if (couldBeUserSearch) {
options.push({ key: 'account-search', label: <FormattedMessage id='search.quick_action.account_search' defaultMessage='Profiles matching {x}' values={{ x: <mark>{trimmedValue}</mark> }} />, action: this.handleAccountSearch });
}
}
this.setState({ options });
}
render () {
const { intl, value, submitted, recent } = this.props;
const { expanded, options, selectedOption } = this.state;
const { signedIn } = this.props.identity;
const hasValue = value.length > 0 || submitted;
return (
<div className={classNames('search', { active: expanded })}>
<input
ref={this.setRef}
className='search__input'
type='text'
placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
value={value}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
/>
<div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}>
<Icon id='search' icon={SearchIcon} className={hasValue ? '' : 'active'} />
<Icon id='times-circle' icon={CancelIcon} className={hasValue ? 'active' : ''} aria-label={intl.formatMessage(messages.placeholder)} />
</div>
<div className='search__popout'>
{options.length === 0 && (
<>
<h4><FormattedMessage id='search_popout.recent' defaultMessage='Recent searches' /></h4>
<div className='search__popout__menu'>
{recent.size > 0 ? this._getOptions().map(({ label, key, action, forget }, i) => (
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item search__popout__menu__item--flex', { selected: selectedOption === i })}>
<span>{label}</span>
<button className='icon-button' onMouseDown={forget}><Icon id='times' icon={CloseIcon} /></button>
</button>
)) : (
<div className='search__popout__menu__message'>
<FormattedMessage id='search.no_recent_searches' defaultMessage='No recent searches' />
</div>
)}
</div>
</>
)}
{options.length > 0 && (
<>
<h4><FormattedMessage id='search_popout.quick_actions' defaultMessage='Quick actions' /></h4>
<div className='search__popout__menu'>
{options.map(({ key, label, action }, i) => (
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === i })}>
{label}
</button>
))}
</div>
</>
)}
<h4><FormattedMessage id='search_popout.options' defaultMessage='Search options' /></h4>
{searchEnabled && signedIn ? (
<div className='search__popout__menu'>
{this.defaultOptions.map(({ key, label, action }, i) => (
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === ((options.length || recent.size) + i) })}>
{label}
</button>
))}
</div>
) : (
<div className='search__popout__menu__message'>
{searchEnabled ? (
<FormattedMessage id='search_popout.full_text_search_logged_out_message' defaultMessage='Only available when logged in.' />
) : (
<FormattedMessage id='search_popout.full_text_search_disabled_message' defaultMessage='Not available on {domain}.' values={{ domain }} />
)}
</div>
)}
</div>
</div>
);
}
}
export default withRouter(withIdentity(injectIntl(Search)));

View File

@ -0,0 +1,593 @@
import { useCallback, useState, useRef } from 'react';
import {
defineMessages,
useIntl,
FormattedMessage,
FormattedList,
} from 'react-intl';
import classNames from 'classnames';
import { useHistory } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import {
clickSearchResult,
forgetSearchResult,
openURL,
} from 'mastodon/actions/search';
import { Icon } from 'mastodon/components/icon';
import { useIdentity } from 'mastodon/identity_context';
import { domain, searchEnabled } from 'mastodon/initial_state';
import type { RecentSearch, SearchType } from 'mastodon/models/search';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { HASHTAG_REGEX } from 'mastodon/utils/hashtags';
const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
placeholderSignedIn: {
id: 'search.search_or_paste',
defaultMessage: 'Search or paste URL',
},
});
const labelForRecentSearch = (search: RecentSearch) => {
switch (search.type) {
case 'account':
return `@${search.q}`;
case 'hashtag':
return `#${search.q}`;
default:
return search.q;
}
};
const unfocus = () => {
document.querySelector('.ui')?.parentElement?.focus();
};
interface SearchOption {
key: string;
label: React.ReactNode;
action: (e: React.MouseEvent | React.KeyboardEvent) => void;
forget?: (e: React.MouseEvent | React.KeyboardEvent) => void;
}
export const Search: React.FC<{
singleColumn: boolean;
initialValue?: string;
}> = ({ singleColumn, initialValue }) => {
const intl = useIntl();
const recent = useAppSelector((state) => state.search.recent);
const { signedIn } = useIdentity();
const dispatch = useAppDispatch();
const history = useHistory();
const searchInputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState(initialValue ?? '');
const hasValue = value.length > 0;
const [expanded, setExpanded] = useState(false);
const [selectedOption, setSelectedOption] = useState(-1);
const [quickActions, setQuickActions] = useState<SearchOption[]>([]);
const searchOptions: SearchOption[] = [];
if (searchEnabled) {
searchOptions.push(
{
key: 'prompt-has',
label: (
<>
<mark>has:</mark>{' '}
<FormattedList
type='disjunction'
value={['media', 'poll', 'embed']}
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('has:');
},
},
{
key: 'prompt-is',
label: (
<>
<mark>is:</mark>{' '}
<FormattedList type='disjunction' value={['reply', 'sensitive']} />
</>
),
action: (e) => {
e.preventDefault();
insertText('is:');
},
},
{
key: 'prompt-language',
label: (
<>
<mark>language:</mark>{' '}
<FormattedMessage
id='search_popout.language_code'
defaultMessage='ISO language code'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('language:');
},
},
{
key: 'prompt-from',
label: (
<>
<mark>from:</mark>{' '}
<FormattedMessage id='search_popout.user' defaultMessage='user' />
</>
),
action: (e) => {
e.preventDefault();
insertText('from:');
},
},
{
key: 'prompt-before',
label: (
<>
<mark>before:</mark>{' '}
<FormattedMessage
id='search_popout.specific_date'
defaultMessage='specific date'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('before:');
},
},
{
key: 'prompt-during',
label: (
<>
<mark>during:</mark>{' '}
<FormattedMessage
id='search_popout.specific_date'
defaultMessage='specific date'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('during:');
},
},
{
key: 'prompt-after',
label: (
<>
<mark>after:</mark>{' '}
<FormattedMessage
id='search_popout.specific_date'
defaultMessage='specific date'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('after:');
},
},
{
key: 'prompt-in',
label: (
<>
<mark>in:</mark>{' '}
<FormattedList
type='disjunction'
value={['all', 'library', 'public']}
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('in:');
},
},
);
}
const recentOptions: SearchOption[] = recent.map((search) => ({
key: `${search.type}/${search.q}`,
label: labelForRecentSearch(search),
action: () => {
setValue(search.q);
if (search.type === 'account') {
history.push(`/@${search.q}`);
} else if (search.type === 'hashtag') {
history.push(`/tags/${search.q}`);
} else {
const queryParams = new URLSearchParams({ q: search.q });
if (search.type) queryParams.set('type', search.type);
history.push({ pathname: '/search', search: queryParams.toString() });
}
unfocus();
},
forget: (e) => {
e.stopPropagation();
void dispatch(forgetSearchResult(search.q));
},
}));
const navigableOptions = hasValue
? quickActions.concat(searchOptions)
: recentOptions.concat(quickActions, searchOptions);
const insertText = (text: string) => {
setValue((currentValue) => {
if (currentValue === '') {
return text;
} else if (currentValue.endsWith(' ')) {
return `${currentValue}${text}`;
} else {
return `${currentValue} ${text}`;
}
});
};
const submit = useCallback(
(q: string, type?: SearchType) => {
void dispatch(clickSearchResult({ q, type }));
const queryParams = new URLSearchParams({ q });
if (type) queryParams.set('type', type);
history.push({ pathname: '/search', search: queryParams.toString() });
unfocus();
},
[dispatch, history],
);
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setValue(value);
const trimmedValue = value.trim();
const newQuickActions = [];
if (trimmedValue.length > 0) {
const couldBeURL =
trimmedValue.startsWith('https://') && !trimmedValue.includes(' ');
if (couldBeURL) {
newQuickActions.push({
key: 'open-url',
label: (
<FormattedMessage
id='search.quick_action.open_url'
defaultMessage='Open URL in Mastodon'
/>
),
action: async () => {
const result = await dispatch(openURL({ url: trimmedValue }));
if (isFulfilled(result)) {
if (result.payload.accounts[0]) {
history.push(`/@${result.payload.accounts[0].acct}`);
} else if (result.payload.statuses[0]) {
history.push(
`/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`,
);
}
}
unfocus();
},
});
}
const couldBeHashtag =
(trimmedValue.startsWith('#') && trimmedValue.length > 1) ||
trimmedValue.match(HASHTAG_REGEX);
if (couldBeHashtag) {
newQuickActions.push({
key: 'go-to-hashtag',
label: (
<FormattedMessage
id='search.quick_action.go_to_hashtag'
defaultMessage='Go to hashtag {x}'
values={{ x: <mark>#{trimmedValue.replace(/^#/, '')}</mark> }}
/>
),
action: () => {
const query = trimmedValue.replace(/^#/, '');
history.push(`/tags/${query}`);
void dispatch(clickSearchResult({ q: query, type: 'hashtag' }));
unfocus();
},
});
}
const couldBeUsername = /^@?[a-z0-9_-]+(@[^\s]+)?$/i.exec(trimmedValue);
if (couldBeUsername) {
newQuickActions.push({
key: 'go-to-account',
label: (
<FormattedMessage
id='search.quick_action.go_to_account'
defaultMessage='Go to profile {x}'
values={{ x: <mark>@{trimmedValue.replace(/^@/, '')}</mark> }}
/>
),
action: () => {
const query = trimmedValue.replace(/^@/, '');
history.push(`/@${query}`);
void dispatch(clickSearchResult({ q: query, type: 'account' }));
unfocus();
},
});
}
const couldBeStatusSearch = searchEnabled;
if (couldBeStatusSearch && signedIn) {
newQuickActions.push({
key: 'status-search',
label: (
<FormattedMessage
id='search.quick_action.status_search'
defaultMessage='Posts matching {x}'
values={{ x: <mark>{trimmedValue}</mark> }}
/>
),
action: () => {
submit(trimmedValue, 'statuses');
},
});
}
newQuickActions.push({
key: 'account-search',
label: (
<FormattedMessage
id='search.quick_action.account_search'
defaultMessage='Profiles matching {x}'
values={{ x: <mark>{trimmedValue}</mark> }}
/>
),
action: () => {
submit(trimmedValue, 'accounts');
},
});
}
setQuickActions(newQuickActions);
},
[dispatch, history, signedIn, setValue, setQuickActions, submit],
);
const handleClear = useCallback(() => {
setValue('');
setQuickActions([]);
setSelectedOption(-1);
}, [setValue, setQuickActions, setSelectedOption]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'Escape':
e.preventDefault();
unfocus();
break;
case 'ArrowDown':
e.preventDefault();
if (navigableOptions.length > 0) {
setSelectedOption(
Math.min(selectedOption + 1, navigableOptions.length - 1),
);
}
break;
case 'ArrowUp':
e.preventDefault();
if (navigableOptions.length > 0) {
setSelectedOption(Math.max(selectedOption - 1, -1));
}
break;
case 'Enter':
e.preventDefault();
if (selectedOption === -1) {
submit(value);
} else if (navigableOptions.length > 0) {
navigableOptions[selectedOption]?.action(e);
}
break;
case 'Delete':
if (selectedOption > -1 && navigableOptions.length > 0) {
const search = navigableOptions[selectedOption];
if (typeof search?.forget === 'function') {
e.preventDefault();
search.forget(e);
}
}
break;
}
},
[navigableOptions, value, selectedOption, setSelectedOption, submit],
);
const handleFocus = useCallback(() => {
setExpanded(true);
setSelectedOption(-1);
if (searchInputRef.current && !singleColumn) {
const { left, right } = searchInputRef.current.getBoundingClientRect();
if (
left < 0 ||
right > (window.innerWidth || document.documentElement.clientWidth)
) {
searchInputRef.current.scrollIntoView();
}
}
}, [setExpanded, setSelectedOption, singleColumn]);
const handleBlur = useCallback(() => {
setExpanded(false);
setSelectedOption(-1);
}, [setExpanded, setSelectedOption]);
return (
<form className={classNames('search', { active: expanded })}>
<input
ref={searchInputRef}
className='search__input'
type='text'
placeholder={intl.formatMessage(
signedIn ? messages.placeholderSignedIn : messages.placeholder,
)}
aria-label={intl.formatMessage(
signedIn ? messages.placeholderSignedIn : messages.placeholder,
)}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
/>
<button type='button' className='search__icon' onClick={handleClear}>
<Icon
id='search'
icon={SearchIcon}
className={hasValue ? '' : 'active'}
/>
<Icon
id='times-circle'
icon={CancelIcon}
className={hasValue ? 'active' : ''}
aria-label={intl.formatMessage(messages.placeholder)}
/>
</button>
<div className='search__popout'>
{!hasValue && (
<>
<h4>
<FormattedMessage
id='search_popout.recent'
defaultMessage='Recent searches'
/>
</h4>
<div className='search__popout__menu'>
{recentOptions.length > 0 ? (
recentOptions.map(({ label, key, action, forget }, i) => (
<button
key={key}
onMouseDown={action}
className={classNames(
'search__popout__menu__item search__popout__menu__item--flex',
{ selected: selectedOption === i },
)}
>
<span>{label}</span>
<button className='icon-button' onMouseDown={forget}>
<Icon id='times' icon={CloseIcon} />
</button>
</button>
))
) : (
<div className='search__popout__menu__message'>
<FormattedMessage
id='search.no_recent_searches'
defaultMessage='No recent searches'
/>
</div>
)}
</div>
</>
)}
{quickActions.length > 0 && (
<>
<h4>
<FormattedMessage
id='search_popout.quick_actions'
defaultMessage='Quick actions'
/>
</h4>
<div className='search__popout__menu'>
{quickActions.map(({ key, label, action }, i) => (
<button
key={key}
onMouseDown={action}
className={classNames('search__popout__menu__item', {
selected: selectedOption === i,
})}
>
{label}
</button>
))}
</div>
</>
)}
<h4>
<FormattedMessage
id='search_popout.options'
defaultMessage='Search options'
/>
</h4>
{searchEnabled && signedIn ? (
<div className='search__popout__menu'>
{searchOptions.map(({ key, label, action }, i) => (
<button
key={key}
onMouseDown={action}
className={classNames('search__popout__menu__item', {
selected:
selectedOption ===
(quickActions.length || recent.length) + i,
})}
>
{label}
</button>
))}
</div>
) : (
<div className='search__popout__menu__message'>
{searchEnabled ? (
<FormattedMessage
id='search_popout.full_text_search_logged_out_message'
defaultMessage='Only available when logged in.'
/>
) : (
<FormattedMessage
id='search_popout.full_text_search_disabled_message'
defaultMessage='Not available on {domain}.'
values={{ domain }}
/>
)}
</div>
)}
</div>
</form>
);
};

View File

@ -1,93 +0,0 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
import { expandSearch } from 'mastodon/actions/search';
import { Icon } from 'mastodon/components/icon';
import { LoadMore } from 'mastodon/components/load_more';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { SearchSection } from 'mastodon/features/explore/components/search_section';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { ImmutableHashtag as Hashtag } from '../../../components/hashtag';
import AccountContainer from '../../../containers/account_container';
import StatusContainer from '../../../containers/status_container';
const INITIAL_PAGE_LIMIT = 10;
const withoutLastResult = list => {
if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) {
return list.skipLast(1);
} else {
return list;
}
};
export const SearchResults = () => {
const results = useAppSelector((state) => state.getIn(['search', 'results']));
const isLoading = useAppSelector((state) => state.getIn(['search', 'isLoading']));
const dispatch = useAppDispatch();
const handleLoadMoreAccounts = useCallback(() => {
dispatch(expandSearch('accounts'));
}, [dispatch]);
const handleLoadMoreStatuses = useCallback(() => {
dispatch(expandSearch('statuses'));
}, [dispatch]);
const handleLoadMoreHashtags = useCallback(() => {
dispatch(expandSearch('hashtags'));
}, [dispatch]);
let accounts, statuses, hashtags;
if (results.get('accounts') && results.get('accounts').size > 0) {
accounts = (
<SearchSection title={<><Icon id='users' icon={PeopleIcon} /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>}>
{withoutLastResult(results.get('accounts')).map(accountId => <AccountContainer key={accountId} id={accountId} />)}
{(results.get('accounts').size > INITIAL_PAGE_LIMIT && results.get('accounts').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreAccounts} />}
</SearchSection>
);
}
if (results.get('hashtags') && results.get('hashtags').size > 0) {
hashtags = (
<SearchSection title={<><Icon id='hashtag' icon={TagIcon} /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>}>
{withoutLastResult(results.get('hashtags')).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
{(results.get('hashtags').size > INITIAL_PAGE_LIMIT && results.get('hashtags').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreHashtags} />}
</SearchSection>
);
}
if (results.get('statuses') && results.get('statuses').size > 0) {
statuses = (
<SearchSection title={<><Icon id='quote-right' icon={FindInPageIcon} /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>}>
{withoutLastResult(results.get('statuses')).map(statusId => <StatusContainer key={statusId} id={statusId} />)}
{(results.get('statuses').size > INITIAL_PAGE_LIMIT && results.get('statuses').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreStatuses} />}
</SearchSection>
);
}
return (
<div className='search-results'>
{!accounts && !hashtags && !statuses && (
isLoading ? (
<LoadingIndicator />
) : (
<div className='empty-column-indicator'>
<FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />
</div>
)
)}
{accounts}
{hashtags}
{statuses}
</div>
);
};

View File

@ -1,59 +0,0 @@
import { createSelector } from '@reduxjs/toolkit';
import { connect } from 'react-redux';
import {
changeSearch,
clearSearch,
submitSearch,
showSearch,
openURL,
clickSearchResult,
forgetSearchResult,
} from 'mastodon/actions/search';
import Search from '../components/search';
const getRecentSearches = createSelector(
state => state.getIn(['search', 'recent']),
recent => recent.reverse(),
);
const mapStateToProps = state => ({
value: state.getIn(['search', 'value']),
submitted: state.getIn(['search', 'submitted']),
recent: getRecentSearches(state),
});
const mapDispatchToProps = dispatch => ({
onChange (value) {
dispatch(changeSearch(value));
},
onClear () {
dispatch(clearSearch());
},
onSubmit (type) {
dispatch(submitSearch(type));
},
onShow () {
dispatch(showSearch());
},
onOpenURL (q, routerHistory) {
dispatch(openURL(q, routerHistory));
},
onClickSearchResult (q, type) {
dispatch(clickSearchResult(q, type));
},
onForgetSearchResult (q) {
dispatch(forgetSearchResult(q));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Search);

View File

@ -9,8 +9,6 @@ import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import spring from 'react-motion/lib/spring';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
@ -26,11 +24,9 @@ import elephantUIPlane from '../../../images/elephant_ui_plane.svg';
import { changeComposing, mountCompose, unmountCompose } from '../../actions/compose';
import { mascot } from '../../initial_state';
import { isMobile } from '../../is_mobile';
import Motion from '../ui/util/optional_motion';
import { SearchResults } from './components/search_results';
import { Search } from './components/search';
import ComposeFormContainer from './containers/compose_form_container';
import SearchContainer from './containers/search_container';
const messages = defineMessages({
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
@ -43,9 +39,8 @@ const messages = defineMessages({
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
});
const mapStateToProps = (state, ownProps) => ({
const mapStateToProps = (state) => ({
columns: state.getIn(['settings', 'columns']),
showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false,
});
class Compose extends PureComponent {
@ -54,7 +49,6 @@ class Compose extends PureComponent {
dispatch: PropTypes.func.isRequired,
columns: ImmutablePropTypes.list.isRequired,
multiColumn: PropTypes.bool,
showSearch: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
@ -88,7 +82,7 @@ class Compose extends PureComponent {
};
render () {
const { multiColumn, showSearch, intl } = this.props;
const { multiColumn, intl } = this.props;
if (multiColumn) {
const { columns } = this.props;
@ -113,7 +107,7 @@ class Compose extends PureComponent {
<a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' icon={LogoutIcon} /></a>
</nav>
{multiColumn && <SearchContainer /> }
{multiColumn && <Search /> }
<div className='drawer__pager'>
<div className='drawer__inner' onFocus={this.onFocus}>
@ -123,14 +117,6 @@ class Compose extends PureComponent {
<img alt='' draggable='false' src={mascot || elephantUIPlane} />
</div>
</div>
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
{({ x }) => (
<div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
<SearchResults />
</div>
)}
</Motion>
</div>
</div>
);

View File

@ -15,7 +15,8 @@ import {
changeColumnParams,
} from 'mastodon/actions/columns';
import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory';
import Column from 'mastodon/components/column';
import { Column } from 'mastodon/components/column';
import type { ColumnRef } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { LoadMore } from 'mastodon/components/load_more';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
@ -49,7 +50,7 @@ export const Directory: React.FC<{
const intl = useIntl();
const dispatch = useAppDispatch();
const column = useRef<Column>(null);
const column = useRef<ColumnRef>(null);
const [orderParam, setOrderParam] = useSearchParam('order');
const [localParam, setLocalParam] = useSearchParam('local');

View File

@ -90,8 +90,8 @@ describe('emoji', () => {
});
it('keeps ordering as expected (issue fixed by PR 20677)', () => {
expect(emojify('<p>💕 <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener noreferrer" target="_blank">#<span>foo</span></a> test: foo.</p>'))
.toEqual('<p><picture><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg"></picture> <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener noreferrer" target="_blank">#<span>foo</span></a> test: foo.</p>');
expect(emojify('<p>💕 <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener" target="_blank">#<span>foo</span></a> test: foo.</p>'))
.toEqual('<p><picture><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg"></picture> <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener" target="_blank">#<span>foo</span></a> test: foo.</p>');
});
});
});

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