mirror of
https://github.com/mastodon/mastodon.git
synced 2025-01-04 17:35:01 +01:00
Merge branch 'main' into attribution-domains-api
# Conflicts: # config/locales/simple_form.nl.yml
This commit is contained in:
commit
c30cb1f63b
@ -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',
|
||||
|
2
.github/workflows/lint-css.yml
vendored
2
.github/workflows/lint-css.yml
vendored
@ -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
|
||||
|
2
.github/workflows/lint-ruby.yml
vendored
2
.github/workflows/lint-ruby.yml
vendored
@ -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'
|
||||
|
4
.github/workflows/test-ruby.yml
vendored
4
.github/workflows/test-ruby.yml
vendored
@ -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:
|
||||
|
@ -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
|
||||
|
||||
|
@ -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'
|
||||
|
42
CHANGELOG.md
42
CHANGELOG.md
@ -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
|
||||
|
4
Gemfile
4
Gemfile
@ -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
|
||||
|
237
Gemfile.lock
237
Gemfile.lock
@ -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,15 +41,15 @@ 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)
|
||||
@ -59,22 +59,22 @@ GEM
|
||||
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
|
||||
@ -94,7 +94,7 @@ GEM
|
||||
ast (2.4.2)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.1017.0)
|
||||
aws-partitions (1.1025.0)
|
||||
aws-sdk-core (3.214.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
@ -103,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.176.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)
|
||||
@ -168,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)
|
||||
@ -199,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)
|
||||
@ -224,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)
|
||||
@ -279,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
|
||||
@ -294,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)
|
||||
@ -318,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)
|
||||
@ -327,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)
|
||||
@ -384,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)
|
||||
@ -406,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.8)
|
||||
minitest (5.25.2)
|
||||
minitest (5.25.4)
|
||||
msgpack (1.7.5)
|
||||
multi_json (1.15.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)
|
||||
@ -426,10 +426,10 @@ GEM
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.16.8)
|
||||
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)
|
||||
@ -460,7 +460,7 @@ 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)
|
||||
@ -475,29 +475,29 @@ GEM
|
||||
opentelemetry-semantic_conventions
|
||||
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.3)
|
||||
opentelemetry-instrumentation-active_model_serializers (0.21.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-active_support (>= 0.6.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_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)
|
||||
@ -510,7 +510,7 @@ GEM
|
||||
opentelemetry-instrumentation-excon (0.22.5)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-faraday (0.24.7)
|
||||
opentelemetry-instrumentation-faraday (0.24.8)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-http (0.23.5)
|
||||
@ -529,14 +529,14 @@ GEM
|
||||
opentelemetry-instrumentation-rack (0.25.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-rails (0.33.1)
|
||||
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)
|
||||
@ -579,7 +579,8 @@ 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.5.0)
|
||||
@ -608,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)
|
||||
@ -633,15 +634,15 @@ GEM
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.1)
|
||||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
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)
|
||||
@ -655,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)
|
||||
@ -663,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)
|
||||
@ -706,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)
|
||||
@ -729,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)
|
||||
@ -743,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)
|
||||
@ -753,14 +754,14 @@ GEM
|
||||
scenic (1.8.0)
|
||||
activerecord (>= 4.0.0)
|
||||
railties (>= 4.0.0)
|
||||
securerandom (0.3.2)
|
||||
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)
|
||||
@ -807,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)
|
||||
@ -836,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)
|
||||
@ -866,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)
|
||||
@ -958,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)
|
||||
@ -967,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)
|
||||
@ -1035,4 +1036,4 @@ RUBY VERSION
|
||||
ruby 3.3.6p108
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.23
|
||||
2.6.1
|
||||
|
2
Vagrantfile
vendored
2
Vagrantfile
vendored
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
36
app/controllers/admin/terms_of_service/drafts_controller.rb
Normal file
36
app/controllers/admin/terms_of_service/drafts_controller.rb
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
17
app/controllers/admin/terms_of_service/tests_controller.rb
Normal file
17
app/controllers/admin/terms_of_service/tests_controller.rb
Normal 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
|
8
app/controllers/admin/terms_of_service_controller.rb
Normal file
8
app/controllers/admin/terms_of_service_controller.rb
Normal 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
|
@ -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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -70,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?
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
11
app/controllers/terms_of_service_controller.rb
Normal file
11
app/controllers/terms_of_service_controller.rb
Normal 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
|
@ -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)
|
||||
|
@ -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(
|
||||
|
@ -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);
|
||||
|
@ -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]))]));
|
||||
};
|
||||
}
|
||||
|
@ -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);
|
||||
|
7
app/javascript/mastodon/actions/importer/polls.ts
Normal file
7
app/javascript/mastodon/actions/importer/polls.ts
Normal 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',
|
||||
);
|
@ -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,
|
||||
});
|
40
app/javascript/mastodon/actions/polls.ts
Normal file
40
app/javascript/mastodon/actions/polls.ts
Normal 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 }));
|
||||
},
|
||||
);
|
@ -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));
|
||||
}
|
||||
};
|
148
app/javascript/mastodon/actions/search.ts
Normal file
148
app/javascript/mastodon/actions/search.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
);
|
11
app/javascript/mastodon/api/instance.ts
Normal file
11
app/javascript/mastodon/api/instance.ts
Normal 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');
|
10
app/javascript/mastodon/api/polls.ts
Normal file
10
app/javascript/mastodon/api/polls.ts
Normal 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,
|
||||
});
|
16
app/javascript/mastodon/api/search.ts
Normal file
16
app/javascript/mastodon/api/search.ts
Normal 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,
|
||||
});
|
9
app/javascript/mastodon/api_types/instance.ts
Normal file
9
app/javascript/mastodon/api_types/instance.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export interface ApiTermsOfServiceJSON {
|
||||
updated_at: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ApiPrivacyPolicyJSON {
|
||||
updated_at: string;
|
||||
content: string;
|
||||
}
|
@ -18,6 +18,6 @@ export interface ApiPollJSON {
|
||||
options: ApiPollOptionJSON[];
|
||||
emojis: ApiCustomEmojiJSON[];
|
||||
|
||||
voted: boolean;
|
||||
own_votes: number[];
|
||||
voted?: boolean;
|
||||
own_votes?: number[];
|
||||
}
|
||||
|
11
app/javascript/mastodon/api_types/search.ts
Normal file
11
app/javascript/mastodon/api_types/search.ts
Normal 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[];
|
||||
}
|
@ -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)' />}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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;
|
||||
|
@ -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}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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…' />
|
||||
<FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default RegenerationIndicator;
|
@ -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>
|
||||
);
|
@ -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'>
|
||||
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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);
|
||||
|
@ -18,7 +18,7 @@ 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 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'>
|
||||
|
@ -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>
|
||||
|
||||
|
@ -144,8 +144,8 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
}));
|
||||
},
|
||||
|
||||
onOpenURL (url, routerHistory, onFailure) {
|
||||
dispatch(openURL(url, routerHistory, onFailure));
|
||||
onOpenURL (url) {
|
||||
return dispatch(openURL({ url }));
|
||||
},
|
||||
|
||||
});
|
||||
|
@ -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)));
|
593
app/javascript/mastodon/features/compose/components/search.tsx
Normal file
593
app/javascript/mastodon/features/compose/components/search.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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 { Account } from 'mastodon/components/account';
|
||||
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 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 => <Account 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>
|
||||
);
|
||||
|
||||
};
|
@ -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);
|
@ -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>
|
||||
);
|
||||
|
@ -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>');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,20 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export const SearchSection = ({ title, onClickMore, children }) => (
|
||||
<div className='search-results__section'>
|
||||
<div className='search-results__section__header'>
|
||||
<h3>{title}</h3>
|
||||
{onClickMore && <button onClick={onClickMore}><FormattedMessage id='search_results.see_all' defaultMessage='See all' /></button>}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
SearchSection.propTypes = {
|
||||
title: PropTypes.node.isRequired,
|
||||
onClickMore: PropTypes.func,
|
||||
children: PropTypes.children,
|
||||
};
|
@ -1,114 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { NavLink, Switch, Route } from 'react-router-dom';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
|
||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||
import Column from 'mastodon/components/column';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import Search from 'mastodon/features/compose/containers/search_container';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { trendsEnabled } from 'mastodon/initial_state';
|
||||
|
||||
import Links from './links';
|
||||
import SearchResults from './results';
|
||||
import Statuses from './statuses';
|
||||
import Suggestions from './suggestions';
|
||||
import Tags from './tags';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'explore.title', defaultMessage: 'Explore' },
|
||||
searchResults: { id: 'explore.search_results', defaultMessage: 'Search results' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
layout: state.getIn(['meta', 'layout']),
|
||||
isSearching: state.getIn(['search', 'submitted']) || !trendsEnabled,
|
||||
});
|
||||
|
||||
class Explore extends PureComponent {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
intl: PropTypes.object.isRequired,
|
||||
multiColumn: PropTypes.bool,
|
||||
isSearching: PropTypes.bool,
|
||||
};
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.column = c;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { intl, multiColumn, isSearching } = this.props;
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||
<ColumnHeader
|
||||
icon={isSearching ? 'search' : 'explore'}
|
||||
iconComponent={isSearching ? SearchIcon : ExploreIcon}
|
||||
title={intl.formatMessage(isSearching ? messages.searchResults : messages.title)}
|
||||
onClick={this.handleHeaderClick}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<div className='explore__search-header'>
|
||||
<Search />
|
||||
</div>
|
||||
|
||||
{isSearching ? (
|
||||
<SearchResults />
|
||||
) : (
|
||||
<>
|
||||
<div className='account__section-headline'>
|
||||
<NavLink exact to='/explore'>
|
||||
<FormattedMessage tagName='div' id='explore.trending_statuses' defaultMessage='Posts' />
|
||||
</NavLink>
|
||||
|
||||
<NavLink exact to='/explore/tags'>
|
||||
<FormattedMessage tagName='div' id='explore.trending_tags' defaultMessage='Hashtags' />
|
||||
</NavLink>
|
||||
|
||||
{signedIn && (
|
||||
<NavLink exact to='/explore/suggestions'>
|
||||
<FormattedMessage tagName='div' id='explore.suggested_follows' defaultMessage='People' />
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
<NavLink exact to='/explore/links'>
|
||||
<FormattedMessage tagName='div' id='explore.trending_links' defaultMessage='News' />
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<Switch>
|
||||
<Route path='/explore/tags' component={Tags} />
|
||||
<Route path='/explore/links' component={Links} />
|
||||
<Route path='/explore/suggestions' component={Suggestions} />
|
||||
<Route exact path={['/explore', '/explore/posts', '/search']}>
|
||||
<Statuses multiColumn={multiColumn} />
|
||||
</Route>
|
||||
</Switch>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content={isSearching ? 'noindex' : 'all'} />
|
||||
</Helmet>
|
||||
</>
|
||||
)}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withIdentity(connect(mapStateToProps)(injectIntl(Explore)));
|
105
app/javascript/mastodon/features/explore/index.tsx
Normal file
105
app/javascript/mastodon/features/explore/index.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { NavLink, Switch, Route } from 'react-router-dom';
|
||||
|
||||
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import type { ColumnRef } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { Search } from 'mastodon/features/compose/components/search';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
|
||||
import Links from './links';
|
||||
import Statuses from './statuses';
|
||||
import Suggestions from './suggestions';
|
||||
import Tags from './tags';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'explore.title', defaultMessage: 'Explore' },
|
||||
});
|
||||
|
||||
const Explore: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
const { signedIn } = useIdentity();
|
||||
const intl = useIntl();
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
ref={columnRef}
|
||||
label={intl.formatMessage(messages.title)}
|
||||
>
|
||||
<ColumnHeader
|
||||
icon={'explore'}
|
||||
iconComponent={ExploreIcon}
|
||||
title={intl.formatMessage(messages.title)}
|
||||
onClick={handleHeaderClick}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<div className='explore__search-header'>
|
||||
<Search singleColumn />
|
||||
</div>
|
||||
|
||||
<div className='account__section-headline'>
|
||||
<NavLink exact to='/explore'>
|
||||
<FormattedMessage
|
||||
tagName='div'
|
||||
id='explore.trending_statuses'
|
||||
defaultMessage='Posts'
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
<NavLink exact to='/explore/tags'>
|
||||
<FormattedMessage
|
||||
tagName='div'
|
||||
id='explore.trending_tags'
|
||||
defaultMessage='Hashtags'
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
{signedIn && (
|
||||
<NavLink exact to='/explore/suggestions'>
|
||||
<FormattedMessage
|
||||
tagName='div'
|
||||
id='explore.suggested_follows'
|
||||
defaultMessage='People'
|
||||
/>
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
<NavLink exact to='/explore/links'>
|
||||
<FormattedMessage
|
||||
tagName='div'
|
||||
id='explore.trending_links'
|
||||
defaultMessage='News'
|
||||
/>
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<Switch>
|
||||
<Route path='/explore/tags' component={Tags} />
|
||||
<Route path='/explore/links' component={Links} />
|
||||
<Route path='/explore/suggestions' component={Suggestions} />
|
||||
<Route exact path={['/explore', '/explore/posts']}>
|
||||
<Statuses multiColumn={multiColumn} />
|
||||
</Route>
|
||||
</Switch>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='all' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default Explore;
|
@ -1,232 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
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 { submitSearch, expandSearch } from 'mastodon/actions/search';
|
||||
import { Account } from 'mastodon/components/account';
|
||||
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import Status from 'mastodon/containers/status_container';
|
||||
|
||||
import { SearchSection } from './components/search_section';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'search_results.title', defaultMessage: 'Search for {q}' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
isLoading: state.getIn(['search', 'isLoading']),
|
||||
results: state.getIn(['search', 'results']),
|
||||
q: state.getIn(['search', 'searchTerm']),
|
||||
submittedType: state.getIn(['search', 'type']),
|
||||
});
|
||||
|
||||
const INITIAL_PAGE_LIMIT = 10;
|
||||
const INITIAL_DISPLAY = 4;
|
||||
|
||||
const hidePeek = list => {
|
||||
if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) {
|
||||
return list.skipLast(1);
|
||||
} else {
|
||||
return list;
|
||||
}
|
||||
};
|
||||
|
||||
const renderAccounts = accounts => hidePeek(accounts).map(id => (
|
||||
<Account key={id} id={id} />
|
||||
));
|
||||
|
||||
const renderHashtags = hashtags => hidePeek(hashtags).map(hashtag => (
|
||||
<Hashtag key={hashtag.get('name')} hashtag={hashtag} />
|
||||
));
|
||||
|
||||
const renderStatuses = statuses => hidePeek(statuses).map(id => (
|
||||
<Status key={id} id={id} />
|
||||
));
|
||||
|
||||
class Results extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
results: ImmutablePropTypes.contains({
|
||||
accounts: ImmutablePropTypes.orderedSet,
|
||||
statuses: ImmutablePropTypes.orderedSet,
|
||||
hashtags: ImmutablePropTypes.orderedSet,
|
||||
}),
|
||||
isLoading: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
q: PropTypes.string,
|
||||
intl: PropTypes.object,
|
||||
submittedType: PropTypes.oneOf(['accounts', 'statuses', 'hashtags']),
|
||||
};
|
||||
|
||||
state = {
|
||||
type: this.props.submittedType || 'all',
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
if (props.submittedType !== state.type) {
|
||||
return {
|
||||
type: props.submittedType || 'all',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
handleSelectAll = () => {
|
||||
const { submittedType, dispatch } = this.props;
|
||||
|
||||
// If we originally searched for a specific type, we need to resubmit
|
||||
// the query to get all types of results
|
||||
if (submittedType) {
|
||||
dispatch(submitSearch());
|
||||
}
|
||||
|
||||
this.setState({ type: 'all' });
|
||||
};
|
||||
|
||||
handleSelectAccounts = () => {
|
||||
const { submittedType, dispatch } = this.props;
|
||||
|
||||
// If we originally searched for something else (but not everything),
|
||||
// we need to resubmit the query for this specific type
|
||||
if (submittedType !== 'accounts') {
|
||||
dispatch(submitSearch('accounts'));
|
||||
}
|
||||
|
||||
this.setState({ type: 'accounts' });
|
||||
};
|
||||
|
||||
handleSelectHashtags = () => {
|
||||
const { submittedType, dispatch } = this.props;
|
||||
|
||||
// If we originally searched for something else (but not everything),
|
||||
// we need to resubmit the query for this specific type
|
||||
if (submittedType !== 'hashtags') {
|
||||
dispatch(submitSearch('hashtags'));
|
||||
}
|
||||
|
||||
this.setState({ type: 'hashtags' });
|
||||
};
|
||||
|
||||
handleSelectStatuses = () => {
|
||||
const { submittedType, dispatch } = this.props;
|
||||
|
||||
// If we originally searched for something else (but not everything),
|
||||
// we need to resubmit the query for this specific type
|
||||
if (submittedType !== 'statuses') {
|
||||
dispatch(submitSearch('statuses'));
|
||||
}
|
||||
|
||||
this.setState({ type: 'statuses' });
|
||||
};
|
||||
|
||||
handleLoadMoreAccounts = () => this._loadMore('accounts');
|
||||
handleLoadMoreStatuses = () => this._loadMore('statuses');
|
||||
handleLoadMoreHashtags = () => this._loadMore('hashtags');
|
||||
|
||||
_loadMore (type) {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(expandSearch(type));
|
||||
}
|
||||
|
||||
handleLoadMore = () => {
|
||||
const { type } = this.state;
|
||||
|
||||
if (type !== 'all') {
|
||||
this._loadMore(type);
|
||||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl, isLoading, q, results } = this.props;
|
||||
const { type } = this.state;
|
||||
|
||||
// We request 1 more result than we display so we can tell if there'd be a next page
|
||||
const hasMore = type !== 'all' ? results.get(type, ImmutableList()).size > INITIAL_PAGE_LIMIT && results.get(type).size % INITIAL_PAGE_LIMIT === 1 : false;
|
||||
|
||||
let filteredResults;
|
||||
|
||||
const accounts = results.get('accounts', ImmutableList());
|
||||
const hashtags = results.get('hashtags', ImmutableList());
|
||||
const statuses = results.get('statuses', ImmutableList());
|
||||
|
||||
switch(type) {
|
||||
case 'all':
|
||||
filteredResults = (accounts.size + hashtags.size + statuses.size) > 0 ? (
|
||||
<>
|
||||
{accounts.size > 0 && (
|
||||
<SearchSection key='accounts' title={<><Icon id='users' icon={PeopleIcon} /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>} onClickMore={this.handleLoadMoreAccounts}>
|
||||
{accounts.take(INITIAL_DISPLAY).map(id => <Account key={id} id={id} />)}
|
||||
</SearchSection>
|
||||
)}
|
||||
|
||||
{hashtags.size > 0 && (
|
||||
<SearchSection key='hashtags' title={<><Icon id='hashtag' icon={TagIcon} /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>} onClickMore={this.handleLoadMoreHashtags}>
|
||||
{hashtags.take(INITIAL_DISPLAY).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
|
||||
</SearchSection>
|
||||
)}
|
||||
|
||||
{statuses.size > 0 && (
|
||||
<SearchSection key='statuses' title={<><Icon id='quote-right' icon={FindInPageIcon} /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>} onClickMore={this.handleLoadMoreStatuses}>
|
||||
{statuses.take(INITIAL_DISPLAY).map(id => <Status key={id} id={id} />)}
|
||||
</SearchSection>
|
||||
)}
|
||||
</>
|
||||
) : [];
|
||||
break;
|
||||
case 'accounts':
|
||||
filteredResults = renderAccounts(accounts);
|
||||
break;
|
||||
case 'hashtags':
|
||||
filteredResults = renderHashtags(hashtags);
|
||||
break;
|
||||
case 'statuses':
|
||||
filteredResults = renderStatuses(statuses);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='account__section-headline'>
|
||||
<button onClick={this.handleSelectAll} className={type === 'all' ? 'active' : undefined}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
|
||||
<button onClick={this.handleSelectAccounts} className={type === 'accounts' ? 'active' : undefined}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button>
|
||||
<button onClick={this.handleSelectHashtags} className={type === 'hashtags' ? 'active' : undefined}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
|
||||
<button onClick={this.handleSelectStatuses} className={type === 'statuses' ? 'active' : undefined}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
|
||||
</div>
|
||||
|
||||
<div className='explore__search-results' data-nosnippet>
|
||||
<ScrollableList
|
||||
scrollKey='search-results'
|
||||
isLoading={isLoading}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
hasMore={hasMore}
|
||||
emptyMessage={<FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />}
|
||||
bindToDocument
|
||||
>
|
||||
{filteredResults}
|
||||
</ScrollableList>
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title, { q })}</title>
|
||||
</Helmet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(Results));
|
@ -85,7 +85,7 @@ class ContentWithRouter extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
link.setAttribute('target', '_blank');
|
||||
link.setAttribute('rel', 'noopener noreferrer');
|
||||
link.setAttribute('rel', 'noopener');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -25,7 +25,7 @@ import StarIcon from '@/material-icons/400-24px/star.svg?react';
|
||||
import { fetchFollowRequests } from 'mastodon/actions/accounts';
|
||||
import Column from 'mastodon/components/column';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import LinkFooter from 'mastodon/features/ui/components/link_footer';
|
||||
import { LinkFooter } from 'mastodon/features/ui/components/link_footer';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions';
|
||||
|
||||
|
@ -1,427 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { throttle, escapeRegExp } from 'lodash';
|
||||
|
||||
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
||||
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
|
||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||
import StarIcon from '@/material-icons/400-24px/star.svg?react';
|
||||
import { openModal, closeModal } from 'mastodon/actions/modal';
|
||||
import api from 'mastodon/api';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { registrationsOpen, sso_redirect } from 'mastodon/initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
loginPrompt: { id: 'interaction_modal.login.prompt', defaultMessage: 'Domain of your home server, e.g. mastodon.social' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, { accountId }) => ({
|
||||
displayNameHtml: state.getIn(['accounts', accountId, 'display_name_html']),
|
||||
signupUrl: state.getIn(['server', 'server', 'registrations', 'url'], null) || '/auth/sign_up',
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
onSignupClick() {
|
||||
dispatch(closeModal({
|
||||
modalType: undefined,
|
||||
ignoreFocus: false,
|
||||
}));
|
||||
dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' }));
|
||||
},
|
||||
});
|
||||
|
||||
const PERSISTENCE_KEY = 'mastodon_home';
|
||||
|
||||
const isValidDomain = value => {
|
||||
const url = new URL('https:///path');
|
||||
url.hostname = value;
|
||||
return url.hostname === value;
|
||||
};
|
||||
|
||||
const valueToDomain = value => {
|
||||
// If the user starts typing an URL
|
||||
if (/^https?:\/\//.test(value)) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
|
||||
// Consider that if there is a path, the URL is more meaningful than a bare domain
|
||||
if (url.pathname.length > 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return url.host;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
// If the user writes their full handle including username
|
||||
} else if (value.includes('@')) {
|
||||
if (value.replace(/^@/, '').split('@').length > 2) {
|
||||
return undefined;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const addInputToOptions = (value, options) => {
|
||||
value = value.trim();
|
||||
|
||||
if (value.includes('.') && isValidDomain(value)) {
|
||||
return [value].concat(options.filter((x) => x !== value));
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
class LoginForm extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
resourceUrl: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
value: localStorage ? (localStorage.getItem(PERSISTENCE_KEY) || '') : '',
|
||||
expanded: false,
|
||||
selectedOption: -1,
|
||||
isLoading: false,
|
||||
isSubmitting: false,
|
||||
error: false,
|
||||
options: [],
|
||||
networkOptions: [],
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.input = c;
|
||||
};
|
||||
|
||||
isValueValid = (value) => {
|
||||
let likelyAcct = false;
|
||||
let url = null;
|
||||
|
||||
if (value.startsWith('/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.startsWith('@')) {
|
||||
value = value.slice(1);
|
||||
likelyAcct = true;
|
||||
}
|
||||
|
||||
// The user is in the middle of typing something, do not error out
|
||||
if (value === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/^https?:\/\//.test(value) && !likelyAcct) {
|
||||
url = value;
|
||||
} else {
|
||||
url = `https://${value}`;
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
handleChange = ({ target }) => {
|
||||
const error = !this.isValueValid(target.value);
|
||||
this.setState(state => ({ error, value: target.value, isLoading: true, options: addInputToOptions(target.value, state.networkOptions) }), () => this._loadOptions());
|
||||
};
|
||||
|
||||
handleMessage = (event) => {
|
||||
const { resourceUrl } = this.props;
|
||||
|
||||
if (event.origin !== window.origin || event.source !== this.iframeRef.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data?.type === 'fetchInteractionURL-failure') {
|
||||
this.setState({ isSubmitting: false, error: true });
|
||||
} else if (event.data?.type === 'fetchInteractionURL-success') {
|
||||
if (/^https?:\/\//.test(event.data.template)) {
|
||||
try {
|
||||
const url = new URL(event.data.template.replace('{uri}', encodeURIComponent(resourceUrl)));
|
||||
|
||||
if (localStorage) {
|
||||
localStorage.setItem(PERSISTENCE_KEY, event.data.uri_or_domain);
|
||||
}
|
||||
|
||||
window.location.href = url;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.setState({ isSubmitting: false, error: true });
|
||||
}
|
||||
} else {
|
||||
this.setState({ isSubmitting: false, error: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
window.addEventListener('message', this.handleMessage);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('message', this.handleMessage);
|
||||
}
|
||||
|
||||
handleSubmit = () => {
|
||||
const { value } = this.state;
|
||||
|
||||
this.setState({ isSubmitting: true });
|
||||
|
||||
this.iframeRef.contentWindow.postMessage({
|
||||
type: 'fetchInteractionURL',
|
||||
uri_or_domain: value.trim(),
|
||||
}, window.origin);
|
||||
};
|
||||
|
||||
setIFrameRef = (iframe) => {
|
||||
this.iframeRef = iframe;
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
this.setState({ expanded: true });
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
this.setState({ expanded: false });
|
||||
};
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
const { options, selectedOption } = this.state;
|
||||
|
||||
switch(e.key) {
|
||||
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.handleSubmit();
|
||||
} else if (options.length > 0) {
|
||||
this.setState({ value: options[selectedOption], error: false }, () => this.handleSubmit());
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
handleOptionClick = e => {
|
||||
const index = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const option = this.state.options[index];
|
||||
|
||||
e.preventDefault();
|
||||
this.setState({ selectedOption: index, value: option, error: false }, () => this.handleSubmit());
|
||||
};
|
||||
|
||||
_loadOptions = throttle(() => {
|
||||
const { value } = this.state;
|
||||
|
||||
const domain = valueToDomain(value.trim());
|
||||
|
||||
if (typeof domain === 'undefined') {
|
||||
this.setState({ options: [], networkOptions: [], isLoading: false, error: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (domain.length === 0) {
|
||||
this.setState({ options: [], networkOptions: [], isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
api().get('/api/v1/peers/search', { params: { q: domain } }).then(({ data }) => {
|
||||
if (!data) {
|
||||
data = [];
|
||||
}
|
||||
|
||||
this.setState((state) => ({ networkOptions: data, options: addInputToOptions(state.value, data), isLoading: false }));
|
||||
}).catch(() => {
|
||||
this.setState({ isLoading: false });
|
||||
});
|
||||
}, 200, { leading: true, trailing: true });
|
||||
|
||||
render () {
|
||||
const { intl } = this.props;
|
||||
const { value, expanded, options, selectedOption, error, isSubmitting } = this.state;
|
||||
const domain = (valueToDomain(value) || '').trim();
|
||||
const domainRegExp = new RegExp(`(${escapeRegExp(domain)})`, 'gi');
|
||||
const hasPopOut = domain.length > 0 && options.length > 0;
|
||||
|
||||
return (
|
||||
<div className={classNames('interaction-modal__login', { focused: expanded, expanded: hasPopOut, invalid: error })}>
|
||||
|
||||
<iframe
|
||||
ref={this.setIFrameRef}
|
||||
style={{display: 'none'}}
|
||||
src='/remote_interaction_helper'
|
||||
sandbox='allow-scripts allow-same-origin'
|
||||
title='remote interaction helper'
|
||||
/>
|
||||
|
||||
<div className='interaction-modal__login__input'>
|
||||
<input
|
||||
ref={this.setRef}
|
||||
type='text'
|
||||
value={value}
|
||||
placeholder={intl.formatMessage(messages.loginPrompt)}
|
||||
aria-label={intl.formatMessage(messages.loginPrompt)}
|
||||
autoFocus
|
||||
onChange={this.handleChange}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
autoComplete='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
/>
|
||||
|
||||
<Button onClick={this.handleSubmit} disabled={isSubmitting || error}><FormattedMessage id='interaction_modal.login.action' defaultMessage='Take me home' /></Button>
|
||||
</div>
|
||||
|
||||
{hasPopOut && (
|
||||
<div className='search__popout'>
|
||||
<div className='search__popout__menu'>
|
||||
{options.map((option, i) => (
|
||||
<button key={option} onMouseDown={this.handleOptionClick} data-index={i} className={classNames('search__popout__menu__item', { selected: selectedOption === i })}>
|
||||
{option.split(domainRegExp).map((part, i) => (
|
||||
part.toLowerCase() === domain.toLowerCase() ? (
|
||||
<mark key={i}>
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i}>
|
||||
{part}
|
||||
</span>
|
||||
)
|
||||
))}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const IntlLoginForm = injectIntl(LoginForm);
|
||||
|
||||
class InteractionModal extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
displayNameHtml: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
type: PropTypes.oneOf(['reply', 'reblog', 'favourite', 'follow', 'vote']),
|
||||
onSignupClick: PropTypes.func.isRequired,
|
||||
signupUrl: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
handleSignupClick = () => {
|
||||
this.props.onSignupClick();
|
||||
};
|
||||
|
||||
render () {
|
||||
const { url, type, displayNameHtml, signupUrl } = this.props;
|
||||
|
||||
const name = <bdi dangerouslySetInnerHTML={{ __html: displayNameHtml }} />;
|
||||
|
||||
let title, actionDescription, icon;
|
||||
|
||||
switch(type) {
|
||||
case 'reply':
|
||||
icon = <Icon id='reply' icon={ReplyIcon} />;
|
||||
title = <FormattedMessage id='interaction_modal.title.reply' defaultMessage="Reply to {name}'s post" values={{ name }} />;
|
||||
actionDescription = <FormattedMessage id='interaction_modal.description.reply' defaultMessage='With an account on Mastodon, you can respond to this post.' />;
|
||||
break;
|
||||
case 'reblog':
|
||||
icon = <Icon id='retweet' icon={RepeatIcon} />;
|
||||
title = <FormattedMessage id='interaction_modal.title.reblog' defaultMessage="Boost {name}'s post" values={{ name }} />;
|
||||
actionDescription = <FormattedMessage id='interaction_modal.description.reblog' defaultMessage='With an account on Mastodon, you can boost this post to share it with your own followers.' />;
|
||||
break;
|
||||
case 'favourite':
|
||||
icon = <Icon id='star' icon={StarIcon} />;
|
||||
title = <FormattedMessage id='interaction_modal.title.favourite' defaultMessage="Favorite {name}'s post" values={{ name }} />;
|
||||
actionDescription = <FormattedMessage id='interaction_modal.description.favourite' defaultMessage='With an account on Mastodon, you can favorite this post to let the author know you appreciate it and save it for later.' />;
|
||||
break;
|
||||
case 'follow':
|
||||
icon = <Icon id='user-plus' icon={PersonAddIcon} />;
|
||||
title = <FormattedMessage id='interaction_modal.title.follow' defaultMessage='Follow {name}' values={{ name }} />;
|
||||
actionDescription = <FormattedMessage id='interaction_modal.description.follow' defaultMessage='With an account on Mastodon, you can follow {name} to receive their posts in your home feed.' values={{ name }} />;
|
||||
break;
|
||||
case 'vote':
|
||||
icon = <Icon id='tasks' icon={InsertChartIcon} />;
|
||||
title = <FormattedMessage id='interaction_modal.title.vote' defaultMessage="Vote in {name}'s poll" values={{ name }} />;
|
||||
actionDescription = <FormattedMessage id='interaction_modal.description.vote' defaultMessage='With an account on Mastodon, you can vote in this poll.' />;
|
||||
break;
|
||||
}
|
||||
|
||||
let signupButton;
|
||||
|
||||
if (sso_redirect) {
|
||||
signupButton = (
|
||||
<a href={sso_redirect} data-method='post' className='link-button'>
|
||||
<FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
|
||||
</a>
|
||||
);
|
||||
} else if (registrationsOpen) {
|
||||
signupButton = (
|
||||
<a href={signupUrl} className='link-button'>
|
||||
<FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
signupButton = (
|
||||
<button className='link-button' onClick={this.handleSignupClick}>
|
||||
<FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal interaction-modal'>
|
||||
<div className='interaction-modal__lead'>
|
||||
<h3><span className='interaction-modal__icon'>{icon}</span> {title}</h3>
|
||||
<p>{actionDescription} <strong><FormattedMessage id='interaction_modal.sign_in' defaultMessage='You are not logged in to this server. Where is your account hosted?' /></strong></p>
|
||||
</div>
|
||||
|
||||
<IntlLoginForm resourceUrl={url} />
|
||||
|
||||
<p className='hint'><FormattedMessage id='interaction_modal.sign_in_hint' defaultMessage="Tip: That's the website where you signed up. If you don't remember, look for the welcome e-mail in your inbox. You can also enter your full username! (e.g. @Mastodon@mastodon.social)" /></p>
|
||||
<p><FormattedMessage id='interaction_modal.no_account_yet' defaultMessage='Not on Mastodon?' /> {signupButton}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(InteractionModal);
|
581
app/javascript/mastodon/features/interaction_modal/index.tsx
Normal file
581
app/javascript/mastodon/features/interaction_modal/index.tsx
Normal file
@ -0,0 +1,581 @@
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { escapeRegExp } from 'lodash';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
||||
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
|
||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||
import StarIcon from '@/material-icons/400-24px/star.svg?react';
|
||||
import { openModal, closeModal } from 'mastodon/actions/modal';
|
||||
import { apiRequest } from 'mastodon/api';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import {
|
||||
domain as localDomain,
|
||||
registrationsOpen,
|
||||
sso_redirect,
|
||||
} from 'mastodon/initial_state';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
loginPrompt: {
|
||||
id: 'interaction_modal.username_prompt',
|
||||
defaultMessage: 'E.g. {example}',
|
||||
},
|
||||
});
|
||||
|
||||
interface LoginFormMessage {
|
||||
type:
|
||||
| 'fetchInteractionURL'
|
||||
| 'fetchInteractionURL-failure'
|
||||
| 'fetchInteractionURL-success';
|
||||
uri_or_domain: string;
|
||||
template?: string;
|
||||
}
|
||||
|
||||
const PERSISTENCE_KEY = 'mastodon_home';
|
||||
|
||||
const EXAMPLE_VALUE = 'username@mastodon.social';
|
||||
|
||||
const isValidDomain = (value: string) => {
|
||||
const url = new URL('https:///path');
|
||||
url.hostname = value;
|
||||
return url.hostname === value;
|
||||
};
|
||||
|
||||
const valueToDomain = (value: string): string | null => {
|
||||
// If the user starts typing an URL
|
||||
if (/^https?:\/\//.test(value)) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
|
||||
return url.host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// If the user writes their full handle including username
|
||||
} else if (value.includes('@')) {
|
||||
const [_, domain, ...other] = value.replace(/^@/, '').split('@');
|
||||
|
||||
if (!domain || other.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return valueToDomain(domain);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const addInputToOptions = (value: string, options: string[]) => {
|
||||
value = value.trim();
|
||||
|
||||
if (value.includes('.') && isValidDomain(value)) {
|
||||
return [value].concat(options.filter((x) => x !== value));
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
const isValueValid = (value: string) => {
|
||||
let likelyAcct = false;
|
||||
let url = null;
|
||||
|
||||
if (value.startsWith('/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.startsWith('@')) {
|
||||
value = value.slice(1);
|
||||
likelyAcct = true;
|
||||
}
|
||||
|
||||
// The user is in the middle of typing something, do not error out
|
||||
if (value === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/^https?:\/\//.test(value) && !likelyAcct) {
|
||||
url = value;
|
||||
} else {
|
||||
url = `https://${value}`;
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const sendToFrame = (frame: HTMLIFrameElement | null, value: string): void => {
|
||||
if (valueToDomain(value.trim()) === localDomain) {
|
||||
window.location.href = '/auth/sign_in';
|
||||
return;
|
||||
}
|
||||
|
||||
frame?.contentWindow?.postMessage(
|
||||
{
|
||||
type: 'fetchInteractionURL',
|
||||
uri_or_domain: value.trim(),
|
||||
},
|
||||
window.origin,
|
||||
);
|
||||
};
|
||||
|
||||
const LoginForm: React.FC<{
|
||||
resourceUrl: string;
|
||||
}> = ({ resourceUrl }) => {
|
||||
const intl = useIntl();
|
||||
const [value, setValue] = useState(
|
||||
localStorage.getItem(PERSISTENCE_KEY) ?? '',
|
||||
);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [selectedOption, setSelectedOption] = useState(-1);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [options, setOptions] = useState<string[]>([]);
|
||||
const [networkOptions, setNetworkOptions] = useState<string[]>([]);
|
||||
const [valueChanged, setValueChanged] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const searchRequestRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent<LoginFormMessage>) => {
|
||||
if (
|
||||
event.origin !== window.origin ||
|
||||
event.source !== iframeRef.current?.contentWindow
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data.type === 'fetchInteractionURL-failure') {
|
||||
setIsSubmitting(false);
|
||||
setError(true);
|
||||
} else if (event.data.type === 'fetchInteractionURL-success') {
|
||||
if (event.data.template && /^https?:\/\//.test(event.data.template)) {
|
||||
try {
|
||||
const url = new URL(
|
||||
event.data.template.replace(
|
||||
'{uri}',
|
||||
encodeURIComponent(resourceUrl),
|
||||
),
|
||||
);
|
||||
|
||||
localStorage.setItem(PERSISTENCE_KEY, event.data.uri_or_domain);
|
||||
|
||||
window.location.href = url.toString();
|
||||
} catch {
|
||||
setIsSubmitting(false);
|
||||
setError(true);
|
||||
}
|
||||
} else {
|
||||
setIsSubmitting(false);
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, [resourceUrl, setIsSubmitting, setError]);
|
||||
|
||||
const handleSearch = useDebouncedCallback(
|
||||
(value: string) => {
|
||||
if (searchRequestRef.current) {
|
||||
searchRequestRef.current.abort();
|
||||
}
|
||||
|
||||
const domain = valueToDomain(value.trim());
|
||||
|
||||
if (domain === null || domain.length === 0) {
|
||||
setOptions([]);
|
||||
setNetworkOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
searchRequestRef.current = new AbortController();
|
||||
|
||||
void apiRequest<string[] | null>('GET', 'v1/peers/search', {
|
||||
signal: searchRequestRef.current.signal,
|
||||
params: {
|
||||
q: domain,
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
setNetworkOptions(data ?? []);
|
||||
setOptions(addInputToOptions(value, data ?? []));
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
// Nothing
|
||||
});
|
||||
},
|
||||
500,
|
||||
{ leading: true, trailing: true },
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(value);
|
||||
setValueChanged(true);
|
||||
setError(!isValueValid(value));
|
||||
setOptions(addInputToOptions(value, networkOptions));
|
||||
handleSearch(value);
|
||||
},
|
||||
[
|
||||
setError,
|
||||
setValue,
|
||||
setValueChanged,
|
||||
setOptions,
|
||||
networkOptions,
|
||||
handleSearch,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
setIsSubmitting(true);
|
||||
sendToFrame(iframeRef.current, value);
|
||||
}, [setIsSubmitting, value]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setExpanded(true);
|
||||
}, [setExpanded]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setExpanded(false);
|
||||
}, [setExpanded]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
const selectedOptionValue = options[selectedOption];
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
|
||||
if (options.length > 0) {
|
||||
setSelectedOption((selectedOption) =>
|
||||
Math.min(selectedOption + 1, options.length - 1),
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
|
||||
if (options.length > 0) {
|
||||
setSelectedOption((selectedOption) =>
|
||||
Math.max(selectedOption - 1, -1),
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedOption === -1) {
|
||||
handleSubmit();
|
||||
} else if (options.length > 0 && selectedOptionValue) {
|
||||
setError(false);
|
||||
setValue(selectedOptionValue);
|
||||
setIsSubmitting(true);
|
||||
sendToFrame(iframeRef.current, selectedOptionValue);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
handleSubmit,
|
||||
setSelectedOption,
|
||||
setError,
|
||||
setValue,
|
||||
selectedOption,
|
||||
options,
|
||||
],
|
||||
);
|
||||
|
||||
const handleOptionClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const index = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const option = options[index];
|
||||
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedOption(index);
|
||||
setValue(option);
|
||||
setError(false);
|
||||
setIsSubmitting(true);
|
||||
sendToFrame(iframeRef.current, option);
|
||||
},
|
||||
[options, setSelectedOption, setValue, setError],
|
||||
);
|
||||
|
||||
const domain = (valueToDomain(value) ?? '').trim();
|
||||
const domainRegExp = new RegExp(`(${escapeRegExp(domain)})`, 'gi');
|
||||
const hasPopOut = valueChanged && domain.length > 0 && options.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('interaction-modal__login', {
|
||||
focused: expanded,
|
||||
expanded: hasPopOut,
|
||||
invalid: error,
|
||||
})}
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
style={{ display: 'none' }}
|
||||
src='/remote_interaction_helper'
|
||||
sandbox='allow-scripts allow-same-origin'
|
||||
title='remote interaction helper'
|
||||
/>
|
||||
|
||||
<div className='interaction-modal__login__input'>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='text'
|
||||
value={value}
|
||||
placeholder={intl.formatMessage(messages.loginPrompt, {
|
||||
example: EXAMPLE_VALUE,
|
||||
})}
|
||||
aria-label={intl.formatMessage(messages.loginPrompt, {
|
||||
example: EXAMPLE_VALUE,
|
||||
})}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoComplete='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
/>
|
||||
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting || error}>
|
||||
<FormattedMessage id='interaction_modal.go' defaultMessage='Go' />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasPopOut && (
|
||||
<div className='search__popout'>
|
||||
<div className='search__popout__menu'>
|
||||
{options.map((option, i) => (
|
||||
<button
|
||||
key={option}
|
||||
onMouseDown={handleOptionClick}
|
||||
data-index={i}
|
||||
className={classNames('search__popout__menu__item', {
|
||||
selected: selectedOption === i,
|
||||
})}
|
||||
>
|
||||
{option
|
||||
.split(domainRegExp)
|
||||
.map((part, i) =>
|
||||
part.toLowerCase() === domain.toLowerCase() ? (
|
||||
<mark key={i}>{part}</mark>
|
||||
) : (
|
||||
<span key={i}>{part}</span>
|
||||
),
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const InteractionModal: React.FC<{
|
||||
accountId: string;
|
||||
url: string;
|
||||
type: 'reply' | 'reblog' | 'favourite' | 'follow' | 'vote';
|
||||
}> = ({ accountId, url, type }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const displayNameHtml = useAppSelector(
|
||||
(state) => state.accounts.get(accountId)?.display_name_html ?? '',
|
||||
);
|
||||
const signupUrl = useAppSelector(
|
||||
(state) =>
|
||||
(state.server.getIn(['server', 'registrations', 'url'], null) ||
|
||||
'/auth/sign_up') as string,
|
||||
);
|
||||
const name = <bdi dangerouslySetInnerHTML={{ __html: displayNameHtml }} />;
|
||||
|
||||
const handleSignupClick = useCallback(() => {
|
||||
dispatch(
|
||||
closeModal({
|
||||
modalType: undefined,
|
||||
ignoreFocus: false,
|
||||
}),
|
||||
);
|
||||
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CLOSED_REGISTRATIONS',
|
||||
modalProps: {},
|
||||
}),
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
let title: React.ReactNode,
|
||||
icon: React.ReactNode,
|
||||
actionPrompt: React.ReactNode;
|
||||
|
||||
switch (type) {
|
||||
case 'reply':
|
||||
icon = <Icon id='reply' icon={ReplyIcon} />;
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.title.reply'
|
||||
defaultMessage="Reply to {name}'s post"
|
||||
values={{ name }}
|
||||
/>
|
||||
);
|
||||
actionPrompt = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.action.reply'
|
||||
defaultMessage='To continue, you need to reply from your account.'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'reblog':
|
||||
icon = <Icon id='retweet' icon={RepeatIcon} />;
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.title.reblog'
|
||||
defaultMessage="Boost {name}'s post"
|
||||
values={{ name }}
|
||||
/>
|
||||
);
|
||||
actionPrompt = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.action.reblog'
|
||||
defaultMessage='To continue, you need to reblog from your account.'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'favourite':
|
||||
icon = <Icon id='star' icon={StarIcon} />;
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.title.favourite'
|
||||
defaultMessage="Favorite {name}'s post"
|
||||
values={{ name }}
|
||||
/>
|
||||
);
|
||||
actionPrompt = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.action.favourite'
|
||||
defaultMessage='To continue, you need to favorite from your account.'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'follow':
|
||||
icon = <Icon id='user-plus' icon={PersonAddIcon} />;
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.title.follow'
|
||||
defaultMessage='Follow {name}'
|
||||
values={{ name }}
|
||||
/>
|
||||
);
|
||||
actionPrompt = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.action.follow'
|
||||
defaultMessage='To continue, you need to follow from your account.'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'vote':
|
||||
icon = <Icon id='tasks' icon={InsertChartIcon} />;
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.title.vote'
|
||||
defaultMessage="Vote in {name}'s poll"
|
||||
values={{ name }}
|
||||
/>
|
||||
);
|
||||
actionPrompt = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.action.vote'
|
||||
defaultMessage='To continue, you need to vote from your account.'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
let signupButton;
|
||||
|
||||
if (sso_redirect) {
|
||||
signupButton = (
|
||||
<a href={sso_redirect} data-method='post' className='link-button'>
|
||||
<FormattedMessage
|
||||
id='sign_in_banner.create_account'
|
||||
defaultMessage='Create account'
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
} else if (registrationsOpen) {
|
||||
signupButton = (
|
||||
<a href={signupUrl} className='link-button'>
|
||||
<FormattedMessage
|
||||
id='sign_in_banner.create_account'
|
||||
defaultMessage='Create account'
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
signupButton = (
|
||||
<button className='link-button' onClick={handleSignupClick}>
|
||||
<FormattedMessage
|
||||
id='sign_in_banner.create_account'
|
||||
defaultMessage='Create account'
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal interaction-modal'>
|
||||
<div className='interaction-modal__lead'>
|
||||
<h3>
|
||||
<span className='interaction-modal__icon'>{icon}</span> {title}
|
||||
</h3>
|
||||
<p>{actionPrompt}</p>
|
||||
</div>
|
||||
|
||||
<LoginForm resourceUrl={url} />
|
||||
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='interaction_modal.no_account_yet'
|
||||
defaultMessage="Don't have an account yet?"
|
||||
/>{' '}
|
||||
{signupButton}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default InteractionModal;
|
@ -28,7 +28,7 @@ export const RelationshipsSeveranceEvent = ({ type, target, followingCount, foll
|
||||
|
||||
<div className='notification-group__main'>
|
||||
<p>{intl.formatMessage(messages[type], { from: <strong>{domain}</strong>, target: <strong>{target}</strong>, followingCount, followersCount })}</p>
|
||||
<a href='/severed_relationships' target='_blank' rel='noopener noreferrer' className='link-button'><FormattedMessage id='notification.relationships_severance_event.learn_more' defaultMessage='Learn more' /></a>
|
||||
<a href='/severed_relationships' target='_blank' rel='noopener' className='link-button'><FormattedMessage id='notification.relationships_severance_event.learn_more' defaultMessage='Learn more' /></a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -55,7 +55,7 @@ class Report extends ImmutablePureComponent {
|
||||
</div>
|
||||
|
||||
<div className='notification__report__actions'>
|
||||
<a href={`/admin/reports/${report.get('id')}`} className='button' target='_blank' rel='noopener noreferrer'>{intl.formatMessage(messages.openReport)}</a>
|
||||
<a href={`/admin/reports/${report.get('id')}`} className='button' target='_blank' rel='noopener'>{intl.formatMessage(messages.openReport)}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -70,7 +70,7 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||
if (button === 0 && !(ctrlKey || metaKey)) {
|
||||
history.push(path);
|
||||
} else if (button === 1 || (button === 0 && (ctrlKey || metaKey))) {
|
||||
window.open(path, '_blank', 'noreferrer noopener');
|
||||
window.open(path, '_blank', 'noopener');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -33,6 +33,34 @@ const labelRenderer: LabelRenderer = (displayedName, total, seeMoreHref) => {
|
||||
);
|
||||
};
|
||||
|
||||
const privateLabelRenderer: LabelRenderer = (
|
||||
displayedName,
|
||||
total,
|
||||
seeMoreHref,
|
||||
) => {
|
||||
if (total === 1)
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='notification.favourite_pm'
|
||||
defaultMessage='{name} favorited your private mention'
|
||||
values={{ name: displayedName }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='notification.favourite_pm.name_and_others_with_link'
|
||||
defaultMessage='{name} and <a>{count, plural, one {# other} other {# others}}</a> favorited your private mention'
|
||||
values={{
|
||||
name: displayedName,
|
||||
count: total - 1,
|
||||
a: (chunks) =>
|
||||
seeMoreHref ? <Link to={seeMoreHref}>{chunks}</Link> : chunks,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const NotificationFavourite: React.FC<{
|
||||
notification: NotificationGroupFavourite;
|
||||
unread: boolean;
|
||||
@ -44,6 +72,10 @@ export const NotificationFavourite: React.FC<{
|
||||
?.acct,
|
||||
);
|
||||
|
||||
const isPrivateMention = useAppSelector(
|
||||
(state) => state.statuses.getIn([statusId, 'visibility']) === 'direct',
|
||||
);
|
||||
|
||||
return (
|
||||
<NotificationGroupWithStatus
|
||||
type='favourite'
|
||||
@ -53,7 +85,7 @@ export const NotificationFavourite: React.FC<{
|
||||
statusId={notification.statusId}
|
||||
timestamp={notification.latest_page_notification_at}
|
||||
count={notification.notifications_count}
|
||||
labelRenderer={labelRenderer}
|
||||
labelRenderer={isPrivateMention ? privateLabelRenderer : labelRenderer}
|
||||
labelSeeMoreHref={
|
||||
statusAccount ? `/@${statusAccount}/${statusId}/favourites` : undefined
|
||||
}
|
||||
|
@ -1,65 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { FormattedMessage, FormattedDate, injectIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import api from 'mastodon/api';
|
||||
import Column from 'mastodon/components/column';
|
||||
import { Skeleton } from 'mastodon/components/skeleton';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'privacy_policy.title', defaultMessage: 'Privacy Policy' },
|
||||
});
|
||||
|
||||
class PrivacyPolicy extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
content: null,
|
||||
lastUpdated: null,
|
||||
isLoading: true,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
api().get('/api/v1/instance/privacy_policy').then(({ data }) => {
|
||||
this.setState({ content: data.content, lastUpdated: data.updated_at, isLoading: false });
|
||||
}).catch(() => {
|
||||
this.setState({ isLoading: false });
|
||||
});
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, multiColumn } = this.props;
|
||||
const { isLoading, content, lastUpdated } = this.state;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
|
||||
<div className='scrollable privacy-policy'>
|
||||
<div className='column-title'>
|
||||
<h3><FormattedMessage id='privacy_policy.title' defaultMessage='Privacy Policy' /></h3>
|
||||
<p><FormattedMessage id='privacy_policy.last_updated' defaultMessage='Last updated {date}' values={{ date: isLoading ? <Skeleton width='10ch' /> : <FormattedDate value={lastUpdated} year='numeric' month='short' day='2-digit' /> }} /></p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='privacy-policy__body prose'
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='all' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(PrivacyPolicy);
|
90
app/javascript/mastodon/features/privacy_policy/index.tsx
Normal file
90
app/javascript/mastodon/features/privacy_policy/index.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import {
|
||||
FormattedMessage,
|
||||
FormattedDate,
|
||||
useIntl,
|
||||
defineMessages,
|
||||
} from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { apiGetPrivacyPolicy } from 'mastodon/api/instance';
|
||||
import type { ApiPrivacyPolicyJSON } from 'mastodon/api_types/instance';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { Skeleton } from 'mastodon/components/skeleton';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'privacy_policy.title', defaultMessage: 'Privacy Policy' },
|
||||
});
|
||||
|
||||
const PrivacyPolicy: React.FC<{
|
||||
multiColumn: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const [response, setResponse] = useState<ApiPrivacyPolicyJSON>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
apiGetPrivacyPolicy()
|
||||
.then((data) => {
|
||||
setResponse(data);
|
||||
setLoading(false);
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
label={intl.formatMessage(messages.title)}
|
||||
>
|
||||
<div className='scrollable privacy-policy'>
|
||||
<div className='column-title'>
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id='privacy_policy.title'
|
||||
defaultMessage='Privacy Policy'
|
||||
/>
|
||||
</h3>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='privacy_policy.last_updated'
|
||||
defaultMessage='Last updated {date}'
|
||||
values={{
|
||||
date: loading ? (
|
||||
<Skeleton width='10ch' />
|
||||
) : (
|
||||
<FormattedDate
|
||||
value={response?.updated_at}
|
||||
year='numeric'
|
||||
month='short'
|
||||
day='2-digit'
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{response && (
|
||||
<div
|
||||
className='privacy-policy__body prose'
|
||||
dangerouslySetInnerHTML={{ __html: response.content }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='all' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default PrivacyPolicy;
|
@ -0,0 +1,23 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export const SearchSection: React.FC<{
|
||||
title: React.ReactNode;
|
||||
onClickMore?: () => void;
|
||||
children: React.ReactNode;
|
||||
}> = ({ title, onClickMore, children }) => (
|
||||
<div className='search-results__section'>
|
||||
<div className='search-results__section__header'>
|
||||
<h3>{title}</h3>
|
||||
{onClickMore && (
|
||||
<button onClick={onClickMore}>
|
||||
<FormattedMessage
|
||||
id='search_results.see_all'
|
||||
defaultMessage='See all'
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
304
app/javascript/mastodon/features/search/index.tsx
Normal file
304
app/javascript/mastodon/features/search/index.tsx
Normal file
@ -0,0 +1,304 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { useSearchParam } from '@/hooks/useSearchParam';
|
||||
import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react';
|
||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||
import { submitSearch, expandSearch } from 'mastodon/actions/search';
|
||||
import type { ApiSearchType } from 'mastodon/api_types/search';
|
||||
import { Account } from 'mastodon/components/account';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import type { ColumnRef } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { CompatibilityHashtag as Hashtag } from 'mastodon/components/hashtag';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import Status from 'mastodon/containers/status_container';
|
||||
import { Search } from 'mastodon/features/compose/components/search';
|
||||
import type { Hashtag as HashtagType } from 'mastodon/models/tags';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { SearchSection } from './components/search_section';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'search_results.title', defaultMessage: 'Search for "{q}"' },
|
||||
});
|
||||
|
||||
const INITIAL_PAGE_LIMIT = 10;
|
||||
const INITIAL_DISPLAY = 4;
|
||||
|
||||
const hidePeek = <T,>(list: T[]) => {
|
||||
if (
|
||||
list.length > INITIAL_PAGE_LIMIT &&
|
||||
list.length % INITIAL_PAGE_LIMIT === 1
|
||||
) {
|
||||
return list.slice(0, -2);
|
||||
} else {
|
||||
return list;
|
||||
}
|
||||
};
|
||||
|
||||
const renderAccounts = (accountIds: string[]) =>
|
||||
hidePeek<string>(accountIds).map((id) => <Account key={id} id={id} />);
|
||||
|
||||
const renderHashtags = (hashtags: HashtagType[]) =>
|
||||
hidePeek<HashtagType>(hashtags).map((hashtag) => (
|
||||
<Hashtag key={hashtag.name} hashtag={hashtag} />
|
||||
));
|
||||
|
||||
const renderStatuses = (statusIds: string[]) =>
|
||||
hidePeek<string>(statusIds).map((id) => (
|
||||
// @ts-expect-error inferred props are wrong
|
||||
<Status key={id} id={id} />
|
||||
));
|
||||
|
||||
type SearchType = 'all' | ApiSearchType;
|
||||
|
||||
const typeFromParam = (param?: string): SearchType => {
|
||||
if (param && ['all', 'accounts', 'statuses', 'hashtags'].includes(param)) {
|
||||
return param as SearchType;
|
||||
} else {
|
||||
return 'all';
|
||||
}
|
||||
};
|
||||
|
||||
export const SearchResults: React.FC<{ multiColumn: boolean }> = ({
|
||||
multiColumn,
|
||||
}) => {
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
const intl = useIntl();
|
||||
const [q] = useSearchParam('q');
|
||||
const [type, setType] = useSearchParam('type');
|
||||
const isLoading = useAppSelector((state) => state.search.loading);
|
||||
const results = useAppSelector((state) => state.search.results);
|
||||
const dispatch = useAppDispatch();
|
||||
const mappedType = typeFromParam(type);
|
||||
const trimmedValue = q?.trim() ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (trimmedValue.length > 0) {
|
||||
void dispatch(
|
||||
submitSearch({
|
||||
q: trimmedValue,
|
||||
type: mappedType === 'all' ? undefined : mappedType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [dispatch, trimmedValue, mappedType]);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, []);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
setType(null);
|
||||
}, [setType]);
|
||||
|
||||
const handleSelectAccounts = useCallback(() => {
|
||||
setType('accounts');
|
||||
}, [setType]);
|
||||
|
||||
const handleSelectHashtags = useCallback(() => {
|
||||
setType('hashtags');
|
||||
}, [setType]);
|
||||
|
||||
const handleSelectStatuses = useCallback(() => {
|
||||
setType('statuses');
|
||||
}, [setType]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (mappedType !== 'all') {
|
||||
void dispatch(expandSearch({ type: mappedType }));
|
||||
}
|
||||
}, [dispatch, mappedType]);
|
||||
|
||||
// We request 1 more result than we display so we can tell if there'd be a next page
|
||||
const hasMore =
|
||||
mappedType !== 'all' && results
|
||||
? results[mappedType].length > INITIAL_PAGE_LIMIT &&
|
||||
results[mappedType].length % INITIAL_PAGE_LIMIT === 1
|
||||
: false;
|
||||
|
||||
let filteredResults;
|
||||
|
||||
if (results) {
|
||||
switch (mappedType) {
|
||||
case 'all':
|
||||
filteredResults =
|
||||
results.accounts.length +
|
||||
results.hashtags.length +
|
||||
results.statuses.length >
|
||||
0 ? (
|
||||
<>
|
||||
{results.accounts.length > 0 && (
|
||||
<SearchSection
|
||||
key='accounts'
|
||||
title={
|
||||
<>
|
||||
<Icon id='users' icon={PeopleIcon} />
|
||||
<FormattedMessage
|
||||
id='search_results.accounts'
|
||||
defaultMessage='Profiles'
|
||||
/>
|
||||
</>
|
||||
}
|
||||
onClickMore={handleSelectAccounts}
|
||||
>
|
||||
{results.accounts.slice(0, INITIAL_DISPLAY).map((id) => (
|
||||
<Account key={id} id={id} />
|
||||
))}
|
||||
</SearchSection>
|
||||
)}
|
||||
|
||||
{results.hashtags.length > 0 && (
|
||||
<SearchSection
|
||||
key='hashtags'
|
||||
title={
|
||||
<>
|
||||
<Icon id='hashtag' icon={TagIcon} />
|
||||
<FormattedMessage
|
||||
id='search_results.hashtags'
|
||||
defaultMessage='Hashtags'
|
||||
/>
|
||||
</>
|
||||
}
|
||||
onClickMore={handleSelectHashtags}
|
||||
>
|
||||
{results.hashtags.slice(0, INITIAL_DISPLAY).map((hashtag) => (
|
||||
<Hashtag key={hashtag.name} hashtag={hashtag} />
|
||||
))}
|
||||
</SearchSection>
|
||||
)}
|
||||
|
||||
{results.statuses.length > 0 && (
|
||||
<SearchSection
|
||||
key='statuses'
|
||||
title={
|
||||
<>
|
||||
<Icon id='quote-right' icon={FindInPageIcon} />
|
||||
<FormattedMessage
|
||||
id='search_results.statuses'
|
||||
defaultMessage='Posts'
|
||||
/>
|
||||
</>
|
||||
}
|
||||
onClickMore={handleSelectStatuses}
|
||||
>
|
||||
{results.statuses.slice(0, INITIAL_DISPLAY).map((id) => (
|
||||
// @ts-expect-error inferred props are wrong
|
||||
<Status key={id} id={id} />
|
||||
))}
|
||||
</SearchSection>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
[]
|
||||
);
|
||||
break;
|
||||
case 'accounts':
|
||||
filteredResults = renderAccounts(results.accounts);
|
||||
break;
|
||||
case 'hashtags':
|
||||
filteredResults = renderHashtags(results.hashtags);
|
||||
break;
|
||||
case 'statuses':
|
||||
filteredResults = renderStatuses(results.statuses);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
ref={columnRef}
|
||||
label={intl.formatMessage(messages.title, { q })}
|
||||
>
|
||||
<ColumnHeader
|
||||
icon={'search'}
|
||||
iconComponent={SearchIcon}
|
||||
title={intl.formatMessage(messages.title, { q })}
|
||||
onClick={handleHeaderClick}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<div className='explore__search-header'>
|
||||
<Search singleColumn initialValue={trimmedValue} />
|
||||
</div>
|
||||
|
||||
<div className='account__section-headline'>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className={mappedType === 'all' ? 'active' : undefined}
|
||||
>
|
||||
<FormattedMessage id='search_results.all' defaultMessage='All' />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSelectAccounts}
|
||||
className={mappedType === 'accounts' ? 'active' : undefined}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='search_results.accounts'
|
||||
defaultMessage='Profiles'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSelectHashtags}
|
||||
className={mappedType === 'hashtags' ? 'active' : undefined}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='search_results.hashtags'
|
||||
defaultMessage='Hashtags'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSelectStatuses}
|
||||
className={mappedType === 'statuses' ? 'active' : undefined}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='search_results.statuses'
|
||||
defaultMessage='Posts'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='explore__search-results' data-nosnippet>
|
||||
<ScrollableList
|
||||
scrollKey='search-results'
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && !results}
|
||||
onLoadMore={handleLoadMore}
|
||||
hasMore={hasMore}
|
||||
emptyMessage={
|
||||
trimmedValue.length > 0 ? (
|
||||
<FormattedMessage
|
||||
id='search_results.no_results'
|
||||
defaultMessage='No results.'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='search_results.no_search_yet'
|
||||
defaultMessage='Try searching for posts, profiles or hashtags.'
|
||||
/>
|
||||
)
|
||||
}
|
||||
bindToDocument
|
||||
>
|
||||
{filteredResults}
|
||||
</ScrollableList>
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title, { q })}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default SearchResults;
|
@ -61,7 +61,7 @@ const Embed: React.FC<{ id: string }> = ({ id }) => {
|
||||
className='embed__overlay'
|
||||
href={permalink}
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
rel='noopener'
|
||||
aria-label=''
|
||||
/>
|
||||
</div>
|
||||
|
@ -208,7 +208,7 @@ export default class Card extends PureComponent {
|
||||
<div className='status-card__actions' onClick={this.handleEmbedClick} role='none'>
|
||||
<div>
|
||||
<button type='button' onClick={this.handleEmbedClick}><Icon id='play' icon={PlayArrowIcon} /></button>
|
||||
<a href={card.get('url')} onClick={this.handleExternalLinkClick} target='_blank' rel='noopener noreferrer'><Icon id='external-link' icon={OpenInNewIcon} /></a>
|
||||
<a href={card.get('url')} onClick={this.handleExternalLinkClick} target='_blank' rel='noopener'><Icon id='external-link' icon={OpenInNewIcon} /></a>
|
||||
</div>
|
||||
</div>
|
||||
) : spoilerButton}
|
||||
@ -219,7 +219,7 @@ export default class Card extends PureComponent {
|
||||
return (
|
||||
<div className={classNames('status-card', { expanded: largeImage })} ref={this.setRef} onClick={revealed ? null : this.handleReveal} role={revealed ? 'button' : null}>
|
||||
{embed}
|
||||
<a href={card.get('url')} target='_blank' rel='noopener noreferrer'>{description}</a>
|
||||
<a href={card.get('url')} target='_blank' rel='noopener'>{description}</a>
|
||||
</div>
|
||||
);
|
||||
} else if (card.get('image')) {
|
||||
@ -239,7 +239,7 @@ export default class Card extends PureComponent {
|
||||
|
||||
return (
|
||||
<>
|
||||
<a href={card.get('url')} className={classNames('status-card', { expanded: largeImage, bottomless: showAuthor })} target='_blank' rel='noopener noreferrer' ref={this.setRef}>
|
||||
<a href={card.get('url')} className={classNames('status-card', { expanded: largeImage, bottomless: showAuthor })} target='_blank' rel='noopener' ref={this.setRef}>
|
||||
{embed}
|
||||
{description}
|
||||
</a>
|
||||
|
95
app/javascript/mastodon/features/terms_of_service/index.tsx
Normal file
95
app/javascript/mastodon/features/terms_of_service/index.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import {
|
||||
FormattedMessage,
|
||||
FormattedDate,
|
||||
useIntl,
|
||||
defineMessages,
|
||||
} from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { apiGetTermsOfService } from 'mastodon/api/instance';
|
||||
import type { ApiTermsOfServiceJSON } from 'mastodon/api_types/instance';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { Skeleton } from 'mastodon/components/skeleton';
|
||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'terms_of_service.title', defaultMessage: 'Terms of Service' },
|
||||
});
|
||||
|
||||
const TermsOfService: React.FC<{
|
||||
multiColumn: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const [response, setResponse] = useState<ApiTermsOfServiceJSON>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
apiGetTermsOfService()
|
||||
.then((data) => {
|
||||
setResponse(data);
|
||||
setLoading(false);
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!loading && !response) {
|
||||
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
label={intl.formatMessage(messages.title)}
|
||||
>
|
||||
<div className='scrollable privacy-policy'>
|
||||
<div className='column-title'>
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id='terms_of_service.title'
|
||||
defaultMessage='Terms of Service'
|
||||
/>
|
||||
</h3>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='privacy_policy.last_updated'
|
||||
defaultMessage='Last updated {date}'
|
||||
values={{
|
||||
date: loading ? (
|
||||
<Skeleton width='10ch' />
|
||||
) : (
|
||||
<FormattedDate
|
||||
value={response?.updated_at}
|
||||
year='numeric'
|
||||
month='short'
|
||||
day='2-digit'
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{response && (
|
||||
<div
|
||||
className='privacy-policy__body prose'
|
||||
dangerouslySetInnerHTML={{ __html: response.content }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='all' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default TermsOfService;
|
@ -24,7 +24,7 @@ export default class ActionsModal extends ImmutablePureComponent {
|
||||
|
||||
return (
|
||||
<li key={`${text}-${i}`}>
|
||||
<a href={href} target='_blank' rel='noopener noreferrer' onClick={this.props.onClick} data-index={i} className={classNames({ active })}>
|
||||
<a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={classNames({ active })}>
|
||||
{icon && <IconButton title={text} icon={icon} iconComponent={iconComponent} role='presentation' tabIndex={-1} inverted />}
|
||||
<div>
|
||||
<div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>
|
||||
|
@ -5,12 +5,11 @@ import { connect } from 'react-redux';
|
||||
|
||||
import { changeComposing, mountCompose, unmountCompose } from 'mastodon/actions/compose';
|
||||
import ServerBanner from 'mastodon/components/server_banner';
|
||||
import { Search } from 'mastodon/features/compose/components/search';
|
||||
import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
|
||||
import SearchContainer from 'mastodon/features/compose/containers/search_container';
|
||||
import { LinkFooter } from 'mastodon/features/ui/components/link_footer';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
|
||||
import LinkFooter from './link_footer';
|
||||
|
||||
class ComposePanel extends PureComponent {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
@ -42,7 +41,7 @@ class ComposePanel extends PureComponent {
|
||||
|
||||
return (
|
||||
<div className='compose-panel' onFocus={this.onFocus}>
|
||||
<SearchContainer openInRoute />
|
||||
<Search openInRoute />
|
||||
|
||||
{!signedIn && (
|
||||
<>
|
||||
|
@ -1,95 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { FormattedMessage, injectIntl } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { domain, version, source_url, statusPageUrl, profile_directory as profileDirectory } from 'mastodon/initial_state';
|
||||
import { PERMISSION_INVITE_USERS } from 'mastodon/permissions';
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
onLogout () {
|
||||
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' }));
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
class LinkFooter extends PureComponent {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
multiColumn: PropTypes.bool,
|
||||
onLogout: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleLogoutClick = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.props.onLogout();
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
render () {
|
||||
const { signedIn, permissions } = this.props.identity;
|
||||
const { multiColumn } = this.props;
|
||||
|
||||
const canInvite = signedIn && ((permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS);
|
||||
const canProfileDirectory = profileDirectory;
|
||||
|
||||
const DividingCircle = <span aria-hidden>{' · '}</span>;
|
||||
|
||||
return (
|
||||
<div className='link-footer'>
|
||||
<p>
|
||||
<strong>{domain}</strong>:
|
||||
{' '}
|
||||
<Link to='/about' target={multiColumn ? '_blank' : undefined}><FormattedMessage id='footer.about' defaultMessage='About' /></Link>
|
||||
{statusPageUrl && (
|
||||
<>
|
||||
{DividingCircle}
|
||||
<a href={statusPageUrl} target='_blank' rel='noopener'><FormattedMessage id='footer.status' defaultMessage='Status' /></a>
|
||||
</>
|
||||
)}
|
||||
{canInvite && (
|
||||
<>
|
||||
{DividingCircle}
|
||||
<a href='/invites' target='_blank'><FormattedMessage id='footer.invite' defaultMessage='Invite people' /></a>
|
||||
</>
|
||||
)}
|
||||
{canProfileDirectory && (
|
||||
<>
|
||||
{DividingCircle}
|
||||
<Link to='/directory'><FormattedMessage id='footer.directory' defaultMessage='Profiles directory' /></Link>
|
||||
</>
|
||||
)}
|
||||
{DividingCircle}
|
||||
<Link to='/privacy-policy' target={multiColumn ? '_blank' : undefined}><FormattedMessage id='footer.privacy_policy' defaultMessage='Privacy policy' /></Link>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Mastodon</strong>:
|
||||
{' '}
|
||||
<a href='https://joinmastodon.org' target='_blank'><FormattedMessage id='footer.about' defaultMessage='About' /></a>
|
||||
{DividingCircle}
|
||||
<a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='footer.get_app' defaultMessage='Get the app' /></a>
|
||||
{DividingCircle}
|
||||
<Link to='/keyboard-shortcuts'><FormattedMessage id='footer.keyboard_shortcuts' defaultMessage='Keyboard shortcuts' /></Link>
|
||||
{DividingCircle}
|
||||
<a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='footer.source_code' defaultMessage='View source code' /></a>
|
||||
{DividingCircle}
|
||||
<span className='version'>v{version}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(withIdentity(connect(null, mapDispatchToProps)(LinkFooter)));
|
101
app/javascript/mastodon/features/ui/components/link_footer.tsx
Normal file
101
app/javascript/mastodon/features/ui/components/link_footer.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
domain,
|
||||
version,
|
||||
source_url,
|
||||
statusPageUrl,
|
||||
profile_directory as canProfileDirectory,
|
||||
termsOfServiceEnabled,
|
||||
} from 'mastodon/initial_state';
|
||||
|
||||
const DividingCircle: React.FC = () => <span aria-hidden>{' · '}</span>;
|
||||
|
||||
export const LinkFooter: React.FC<{
|
||||
multiColumn: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
return (
|
||||
<div className='link-footer'>
|
||||
<p>
|
||||
<strong>{domain}</strong>:{' '}
|
||||
<Link to='/about' target={multiColumn ? '_blank' : undefined}>
|
||||
<FormattedMessage id='footer.about' defaultMessage='About' />
|
||||
</Link>
|
||||
{statusPageUrl && (
|
||||
<>
|
||||
<DividingCircle />
|
||||
<a href={statusPageUrl} target='_blank' rel='noopener'>
|
||||
<FormattedMessage id='footer.status' defaultMessage='Status' />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{canProfileDirectory && (
|
||||
<>
|
||||
<DividingCircle />
|
||||
<Link to='/directory'>
|
||||
<FormattedMessage
|
||||
id='footer.directory'
|
||||
defaultMessage='Profiles directory'
|
||||
/>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<DividingCircle />
|
||||
<Link
|
||||
to='/privacy-policy'
|
||||
target={multiColumn ? '_blank' : undefined}
|
||||
rel='privacy-policy'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='footer.privacy_policy'
|
||||
defaultMessage='Privacy policy'
|
||||
/>
|
||||
</Link>
|
||||
{termsOfServiceEnabled && (
|
||||
<>
|
||||
<DividingCircle />
|
||||
<Link
|
||||
to='/terms-of-service'
|
||||
target={multiColumn ? '_blank' : undefined}
|
||||
rel='terms-of-service'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='footer.terms_of_service'
|
||||
defaultMessage='Terms of service'
|
||||
/>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Mastodon</strong>:{' '}
|
||||
<a href='https://joinmastodon.org' target='_blank' rel='noopener'>
|
||||
<FormattedMessage id='footer.about' defaultMessage='About' />
|
||||
</a>
|
||||
<DividingCircle />
|
||||
<a href='https://joinmastodon.org/apps' target='_blank' rel='noopener'>
|
||||
<FormattedMessage id='footer.get_app' defaultMessage='Get the app' />
|
||||
</a>
|
||||
<DividingCircle />
|
||||
<Link to='/keyboard-shortcuts'>
|
||||
<FormattedMessage
|
||||
id='footer.keyboard_shortcuts'
|
||||
defaultMessage='Keyboard shortcuts'
|
||||
/>
|
||||
</Link>
|
||||
<DividingCircle />
|
||||
<a href={source_url} rel='noopener' target='_blank'>
|
||||
<FormattedMessage
|
||||
id='footer.source_code'
|
||||
defaultMessage='View source code'
|
||||
/>
|
||||
</a>
|
||||
<DividingCircle />
|
||||
<span className='version'>v{version}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -69,8 +69,10 @@ import {
|
||||
OnboardingProfile,
|
||||
OnboardingFollows,
|
||||
Explore,
|
||||
Search,
|
||||
About,
|
||||
PrivacyPolicy,
|
||||
TermsOfService,
|
||||
} from './util/async-components';
|
||||
import { ColumnsContextProvider } from './util/columns_context';
|
||||
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
|
||||
@ -198,6 +200,7 @@ class SwitchingColumnsArea extends PureComponent {
|
||||
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
|
||||
<WrappedRoute path='/about' component={About} content={children} />
|
||||
<WrappedRoute path='/privacy-policy' component={PrivacyPolicy} content={children} />
|
||||
<WrappedRoute path='/terms-of-service' component={TermsOfService} content={children} />
|
||||
|
||||
<WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} />
|
||||
<Redirect from='/timelines/public' to='/public' exact />
|
||||
@ -223,7 +226,8 @@ class SwitchingColumnsArea extends PureComponent {
|
||||
<WrappedRoute path={['/start', '/start/profile']} exact component={OnboardingProfile} content={children} />
|
||||
<WrappedRoute path='/start/follows' component={OnboardingFollows} content={children} />
|
||||
<WrappedRoute path='/directory' component={Directory} content={children} />
|
||||
<WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />
|
||||
<WrappedRoute path='/explore' component={Explore} content={children} />
|
||||
<WrappedRoute path='/search' component={Search} content={children} />
|
||||
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
|
||||
|
||||
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
|
||||
|
@ -174,6 +174,10 @@ export function Explore () {
|
||||
return import(/* webpackChunkName: "features/explore" */'../../explore');
|
||||
}
|
||||
|
||||
export function Search () {
|
||||
return import(/* webpackChunkName: "features/explore" */'../../search');
|
||||
}
|
||||
|
||||
export function FilterModal () {
|
||||
return import(/*webpackChunkName: "modals/filter_modal" */'../components/filter_modal');
|
||||
}
|
||||
@ -198,6 +202,10 @@ export function PrivacyPolicy () {
|
||||
return import(/*webpackChunkName: "features/privacy_policy" */'../../privacy_policy');
|
||||
}
|
||||
|
||||
export function TermsOfService () {
|
||||
return import(/*webpackChunkName: "features/terms_of_service" */'../../terms_of_service');
|
||||
}
|
||||
|
||||
export function NotificationRequests () {
|
||||
return import(/*webpackChunkName: "features/notifications/requests" */'../../notifications/requests');
|
||||
}
|
||||
|
@ -43,6 +43,8 @@
|
||||
* @property {boolean=} use_pending_items
|
||||
* @property {string} version
|
||||
* @property {string} sso_redirect
|
||||
* @property {string} status_page_url
|
||||
* @property {boolean} terms_of_service_enabled
|
||||
*/
|
||||
|
||||
/**
|
||||
@ -115,10 +117,9 @@ export const usePendingItems = getMeta('use_pending_items');
|
||||
export const version = getMeta('version');
|
||||
export const languages = initialState?.languages;
|
||||
export const criticalUpdatesPending = initialState?.critical_updates_pending;
|
||||
// @ts-expect-error
|
||||
export const statusPageUrl = getMeta('status_page_url');
|
||||
export const sso_redirect = getMeta('sso_redirect');
|
||||
|
||||
export const termsOfServiceEnabled = getMeta('terms_of_service_enabled');
|
||||
/**
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
|
@ -152,7 +152,6 @@
|
||||
"empty_column.home": "Your home timeline is empty! Follow more people to fill it up. {suggestions}",
|
||||
"empty_column.list": "Hierdie lys is nog leeg. Nuwe plasings deur lyslede sal voortaan hier verskyn.",
|
||||
"empty_column.notifications": "Jy het nog geen kennisgewings nie. Interaksie van ander mense met jou, sal hier vertoon.",
|
||||
"explore.search_results": "Soekresultate",
|
||||
"explore.suggested_follows": "Mense",
|
||||
"explore.trending_links": "Nuus",
|
||||
"filter_modal.added.settings_link": "instellings bladsy",
|
||||
@ -162,7 +161,6 @@
|
||||
"footer.about": "Oor",
|
||||
"footer.directory": "Profielgids",
|
||||
"footer.get_app": "Kry die app",
|
||||
"footer.invite": "Nooi ander",
|
||||
"footer.keyboard_shortcuts": "Kortpadsleutels",
|
||||
"footer.privacy_policy": "Privaatheidsbeleid",
|
||||
"footer.source_code": "Wys bronkode",
|
||||
@ -174,8 +172,6 @@
|
||||
"hashtag.column_settings.tag_toggle": "Voeg meer etikette by hierdie kolom",
|
||||
"hashtag.follow": "Volg hutsetiket",
|
||||
"home.column_settings.show_reblogs": "Wys aangestuurde plasings",
|
||||
"interaction_modal.description.reblog": "Met 'n rekening op Mastodon kan jy hierdie plasing aanstuur om dit met jou volgers te deel.",
|
||||
"interaction_modal.description.reply": "Met 'n rekening op Mastodon kan jy op hierdie plasing reageer.",
|
||||
"interaction_modal.title.follow": "Volg {name}",
|
||||
"interaction_modal.title.reblog": "Stuur {name} se plasing aan",
|
||||
"interaction_modal.title.reply": "Reageer op {name} se plasing",
|
||||
@ -249,7 +245,6 @@
|
||||
"privacy.public.short": "Publiek",
|
||||
"privacy_policy.last_updated": "Laaste bywerking op {date}",
|
||||
"privacy_policy.title": "Privaatheidsbeleid",
|
||||
"regeneration_indicator.sublabel": "Jou tuis-voer word voorberei!",
|
||||
"reply_indicator.cancel": "Kanselleer",
|
||||
"report.placeholder": "Type or paste additional comments",
|
||||
"report.submit": "Submit report",
|
||||
@ -259,9 +254,7 @@
|
||||
"search.search_or_paste": "Soek of plak URL",
|
||||
"search_results.all": "Alles",
|
||||
"search_results.hashtags": "Hutsetiket",
|
||||
"search_results.nothing_found": "Hierdie soekwoorde lewer niks op nie",
|
||||
"search_results.statuses": "Plasings",
|
||||
"search_results.title": "Soek {q}",
|
||||
"server_banner.administered_by": "Administrasie deur:",
|
||||
"sign_in_banner.sign_in": "Sign in",
|
||||
"status.admin_status": "Open hierdie plasing as moderator",
|
||||
|
@ -192,7 +192,6 @@
|
||||
"error.unexpected_crash.next_steps_addons": "Intenta deshabilitar-los y recarga la pachina. Si ixo no aduya, podrías usar Mastodon a traviés d'un navegador web diferent u aplicación nativa.",
|
||||
"errors.unexpected_crash.copy_stacktrace": "Copiar lo seguimiento de pila en o portafuellas",
|
||||
"errors.unexpected_crash.report_issue": "Informar d'un problema/error",
|
||||
"explore.search_results": "Resultaus de busqueda",
|
||||
"explore.title": "Explorar",
|
||||
"explore.trending_links": "Noticias",
|
||||
"explore.trending_statuses": "Publicacions",
|
||||
@ -219,7 +218,6 @@
|
||||
"footer.about": "Sobre",
|
||||
"footer.directory": "Directorio de perfils",
|
||||
"footer.get_app": "Obtener l'aplicación",
|
||||
"footer.invite": "Convidar chent",
|
||||
"footer.keyboard_shortcuts": "Alcorces de teclau",
|
||||
"footer.privacy_policy": "Politica de privacidat",
|
||||
"footer.source_code": "Veyer codigo fuent",
|
||||
@ -240,9 +238,6 @@
|
||||
"home.column_settings.show_replies": "Amostrar respuestas",
|
||||
"home.hide_announcements": "Amagar anuncios",
|
||||
"home.show_announcements": "Amostrar anuncios",
|
||||
"interaction_modal.description.follow": "Con una cuenta en Mastodon, puetz seguir {name} pa recibir las suyas publicacions en a tuya linia temporal d'inicio.",
|
||||
"interaction_modal.description.reblog": "Con una cuenta en Mastodon, puetz empentar esta publicación pa compartir-la con os tuyos propios seguidores.",
|
||||
"interaction_modal.description.reply": "Con una cuenta en Mastodon, puetz responder a esta publicación.",
|
||||
"interaction_modal.on_another_server": "En un servidor diferent",
|
||||
"interaction_modal.on_this_server": "En este servidor",
|
||||
"interaction_modal.title.follow": "Seguir a {name}",
|
||||
@ -371,8 +366,6 @@
|
||||
"privacy_policy.last_updated": "Ultima vegada actualizau {date}",
|
||||
"privacy_policy.title": "Politica de Privacidat",
|
||||
"refresh": "Actualizar",
|
||||
"regeneration_indicator.label": "Cargando…",
|
||||
"regeneration_indicator.sublabel": "La tuya historia d'inicio se ye preparando!",
|
||||
"relative_time.days": "{number} d",
|
||||
"relative_time.full.days": "fa {number, plural, one {# día} other {# días}}",
|
||||
"relative_time.full.hours": "fa {number, plural, one {# hora} other {# horas}}",
|
||||
@ -432,9 +425,7 @@
|
||||
"search_popout.full_text_search_logged_out_message": "Nomás disponible iniciando la sesión.",
|
||||
"search_results.all": "Totz",
|
||||
"search_results.hashtags": "Etiquetas",
|
||||
"search_results.nothing_found": "No se podió trobar cosa pa estes termins de busqueda",
|
||||
"search_results.statuses": "Publicacions",
|
||||
"search_results.title": "Buscar {q}",
|
||||
"server_banner.about_active_users": "Usuarios activos en o servidor entre los zaguers 30 días (Usuarios Activos Mensuals)",
|
||||
"server_banner.active_users": "usuarios activos",
|
||||
"server_banner.administered_by": "Administrau per:",
|
||||
|
@ -274,7 +274,6 @@
|
||||
"error.unexpected_crash.next_steps_addons": "حاول تعطيلهم وإنعاش الصفحة. إن لم ينجح ذلك، يمكنك دائمًا استخدام ماستدون عبر متصفح آخر أو تطبيق أصلي.",
|
||||
"errors.unexpected_crash.copy_stacktrace": "انسخ تتبع الارتباطات إلى الحافظة",
|
||||
"errors.unexpected_crash.report_issue": "الإبلاغ عن خلل",
|
||||
"explore.search_results": "نتائج البحث",
|
||||
"explore.suggested_follows": "أشخاص",
|
||||
"explore.title": "استكشف",
|
||||
"explore.trending_links": "المُستجدّات",
|
||||
@ -322,7 +321,6 @@
|
||||
"footer.about": "عن",
|
||||
"footer.directory": "دليل الصفحات التعريفية",
|
||||
"footer.get_app": "احصل على التطبيق",
|
||||
"footer.invite": "دعوة أشخاص",
|
||||
"footer.keyboard_shortcuts": "اختصارات لوحة المفاتيح",
|
||||
"footer.privacy_policy": "سياسة الخصوصية",
|
||||
"footer.source_code": "الاطلاع على الشفرة المصدرية",
|
||||
@ -360,17 +358,8 @@
|
||||
"ignore_notifications_modal.ignore": "تجاهل الإشعارات",
|
||||
"ignore_notifications_modal.limited_accounts_title": "تجاهل الإشعارات من الحسابات التي هي تحت الإشراف؟",
|
||||
"ignore_notifications_modal.new_accounts_title": "تجاهل الإشعارات الصادرة من الحسابات الجديدة؟",
|
||||
"interaction_modal.description.favourite": "بفضل حساب على ماستدون، يمكنك إضافة هذا المنشور إلى مفضلتك لإبلاغ الناشر عن تقديرك وكذا للاحتفاظ بالمنشور إلى وقت لاحق.",
|
||||
"interaction_modal.description.follow": "بفضل حساب في ماستدون، يمكنك متابعة {name} وتلقي منشوراته في موجزات خيطك الرئيس.",
|
||||
"interaction_modal.description.reblog": "مع حساب في ماستدون، يمكنك تعزيز هذا المنشور ومشاركته مع مُتابِعيك.",
|
||||
"interaction_modal.description.reply": "مع حساب في ماستدون، يمكنك الرد على هذا المنشور.",
|
||||
"interaction_modal.login.action": "خذني إلى خادمي",
|
||||
"interaction_modal.login.prompt": "نطاق الخادم الخاص بك، على سبيل المثال mastodon.social",
|
||||
"interaction_modal.no_account_yet": "ليست على ماستدون بعد؟",
|
||||
"interaction_modal.on_another_server": "على خادم مختلف",
|
||||
"interaction_modal.on_this_server": "على هذا الخادم",
|
||||
"interaction_modal.sign_in": "لم تقم بتسجيل الدخول إلى هذا الخادم. أين هو مستضاف حسابك؟",
|
||||
"interaction_modal.sign_in_hint": "تلميح: هذا هو الموقع الذي أنشأت فيه حسابك. إن لم تتذكّر/ين اسم الموقع، يمكنك البحث عن الرسالة الترحيبيّة في بريدك الإلكتروني. كما يمكنك أيضاً استخدام اسم المستخدم/ـة الكامل! (مثلاً: @Mastodon@mastodon.social)",
|
||||
"interaction_modal.title.favourite": "إضافة منشور {name} إلى المفضلة",
|
||||
"interaction_modal.title.follow": "اتبع {name}",
|
||||
"interaction_modal.title.reblog": "إعادة نشر منشور {name}",
|
||||
@ -596,8 +585,6 @@
|
||||
"privacy_policy.title": "سياسة الخصوصية",
|
||||
"recommended": "موصى به",
|
||||
"refresh": "أنعِش",
|
||||
"regeneration_indicator.label": "جارٍ التحميل…",
|
||||
"regeneration_indicator.sublabel": "جارٍ تجهيز موجزات خيطك الرئيس!",
|
||||
"relative_time.days": "{number}ي",
|
||||
"relative_time.full.days": "منذ {number, plural, zero {} one {# يوم} two {# يومين} few {# أيام} many {# أيام} other {# يوم}}",
|
||||
"relative_time.full.hours": "منذ {number, plural, zero {} one {ساعة واحدة} two {ساعتَيْن} few {# ساعات} many {# ساعة} other {# ساعة}}",
|
||||
@ -681,10 +668,8 @@
|
||||
"search_results.accounts": "الصفحات التعريفية",
|
||||
"search_results.all": "الكل",
|
||||
"search_results.hashtags": "الوُسوم",
|
||||
"search_results.nothing_found": "تعذر العثور على نتائج تتضمن هذه المصطلحات",
|
||||
"search_results.see_all": "رؤية الكل",
|
||||
"search_results.statuses": "المنشورات",
|
||||
"search_results.title": "البحث عن {q}",
|
||||
"server_banner.about_active_users": "الأشخاص الذين يستخدمون هذا الخادم خلال الأيام الثلاثين الأخيرة (المستخدمون النشطون شهريًا)",
|
||||
"server_banner.active_users": "مستخدم نشط",
|
||||
"server_banner.administered_by": "يُديره:",
|
||||
|
@ -159,7 +159,6 @@
|
||||
"error.unexpected_crash.explanation_addons": "Esta páxina nun se pudo amosar correutamente. Ye probable que dalgún complementu del restolador o dalguna ferramienta de traducción automática produxere esti error.",
|
||||
"error.unexpected_crash.next_steps": "Prueba a anovar la páxina. Si nun sirve, ye posible que tovía seyas a usar Mastodon pente otru restolador o una aplicación nativa.",
|
||||
"error.unexpected_crash.next_steps_addons": "Prueba a desactivalos y a anovar la páxina. Si nun sirve, ye posible que tovía seyas a usar Mastodon pente otru restolador o una aplicación nativa.",
|
||||
"explore.search_results": "Resultaos de la busca",
|
||||
"explore.suggested_follows": "Perfiles",
|
||||
"explore.title": "Esploración",
|
||||
"explore.trending_links": "Noticies",
|
||||
@ -196,7 +195,6 @@
|
||||
"footer.about": "Tocante a",
|
||||
"footer.directory": "Direutoriu de perfiles",
|
||||
"footer.get_app": "Consiguir l'aplicación",
|
||||
"footer.invite": "Convidar a persones",
|
||||
"footer.keyboard_shortcuts": "Atayos del tecláu",
|
||||
"footer.privacy_policy": "Política de privacidá",
|
||||
"footer.source_code": "Ver el códigu fonte",
|
||||
@ -214,9 +212,6 @@
|
||||
"home.column_settings.show_reblogs": "Amosar los artículos compartíos",
|
||||
"home.column_settings.show_replies": "Amosar les rempuestes",
|
||||
"home.pending_critical_update.body": "¡Anueva'l sirvidor de Mastodon namás que puedas!",
|
||||
"interaction_modal.description.follow": "Con una cuenta de Mastodon, pues siguir a {name} pa recibir los artículos de so nel to feed d'aniciu.",
|
||||
"interaction_modal.description.reblog": "Con una cuenta de Mastodon, pues compartir esti artículu colos perfiles que te sigan.",
|
||||
"interaction_modal.description.reply": "Con una cuenta de Mastodon, pues responder a esti artículu.",
|
||||
"interaction_modal.on_another_server": "N'otru sirvidor",
|
||||
"interaction_modal.on_this_server": "Nesti sirvidor",
|
||||
"interaction_modal.title.reply": "Rempuesta al artículu de: {name}",
|
||||
@ -323,7 +318,6 @@
|
||||
"privacy_policy.last_updated": "Data del últimu anovamientu: {date}",
|
||||
"privacy_policy.title": "Política de privacidá",
|
||||
"refresh": "Anovar",
|
||||
"regeneration_indicator.label": "Cargando…",
|
||||
"relative_time.days": "{number} d",
|
||||
"relative_time.full.days": "hai {number, plural, one {# día} other {# díes}}",
|
||||
"relative_time.full.hours": "hai {number, plural, one {# hora} other {# hores}}",
|
||||
@ -389,10 +383,8 @@
|
||||
"search_results.accounts": "Perfiles",
|
||||
"search_results.all": "Too",
|
||||
"search_results.hashtags": "Etiquetes",
|
||||
"search_results.nothing_found": "Nun se pudo atopar nada con esos términos de busca",
|
||||
"search_results.see_all": "Ver too",
|
||||
"search_results.statuses": "Artículos",
|
||||
"search_results.title": "Busca de: {q}",
|
||||
"server_banner.server_stats": "Estadístiques del sirvidor:",
|
||||
"sign_in_banner.create_account": "Crear una cuenta",
|
||||
"sign_in_banner.sso_redirect": "Aniciar la sesión o rexistrase",
|
||||
|
1
app/javascript/mastodon/locales/az.json
Normal file
1
app/javascript/mastodon/locales/az.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
@ -85,7 +85,7 @@
|
||||
"alert.rate_limited.title": "Ліміт перавышаны",
|
||||
"alert.unexpected.message": "Узнікла нечаканая памылка.",
|
||||
"alert.unexpected.title": "Вой!",
|
||||
"alt_text_badge.title": "Альтернативный текст",
|
||||
"alt_text_badge.title": "Альтэрнатыўны тэкст",
|
||||
"announcement.announcement": "Аб'ява",
|
||||
"attachments_list.unprocessed": "(неапрацаваны)",
|
||||
"audio.hide": "Схаваць аўдыя",
|
||||
@ -219,7 +219,7 @@
|
||||
"domain_block_modal.they_wont_know": "Карыстальнік не будзе ведаць пра блакіроўку.",
|
||||
"domain_block_modal.title": "Заблакіраваць дамен?",
|
||||
"domain_block_modal.you_will_lose_num_followers": "Вы страціце {followersCount, plural, one {{followersCountDisplay} падпісчыка} other {{followersCountDisplay} падпісчыкаў}} і {followingCount, plural, one {{followingCountDisplay} чалавека, на якога падпісаны} other {{followingCountDisplay} людзей, на якіх падпісаны}}.",
|
||||
"domain_block_modal.you_will_lose_relationships": "Вы страціце ўсех падпісчыкаў і людзей на якіх падпісаны на гэтым.",
|
||||
"domain_block_modal.you_will_lose_relationships": "Вы страціце ўсіх падпісчыкаў і людзей на якіх падпісаны на гэтым серверы.",
|
||||
"domain_block_modal.you_wont_see_posts": "Вы не ўбачыце допісаў і апавяшчэнняў ад карыстальнікаў з гэтага сервера.",
|
||||
"domain_pill.activitypub_lets_connect": "Ён дазваляе вам узаемадзейнічаць не толькі з карыстальнікамі Mastodon, але і розных іншых сацыяльных платформ.",
|
||||
"domain_pill.activitypub_like_language": "ActivityPub — гэта мова, на якой Mastodon размаўляе з іншымі сацыяльнымі сеткамі.",
|
||||
@ -278,7 +278,6 @@
|
||||
"error.unexpected_crash.next_steps_addons": "Паспрабуйце выключыць іх і аднавіць старонку. Калі гэта не дапаможа, вы можаце карыстацца Мастадонт праз другі браўзер ці аплікацыю.",
|
||||
"errors.unexpected_crash.copy_stacktrace": "Дадаць дыягнастычны стэк у буфер абмену",
|
||||
"errors.unexpected_crash.report_issue": "Паведаміць аб праблеме",
|
||||
"explore.search_results": "Вынікі пошуку",
|
||||
"explore.suggested_follows": "Людзі",
|
||||
"explore.title": "Агляд",
|
||||
"explore.trending_links": "Навіны",
|
||||
@ -328,7 +327,6 @@
|
||||
"footer.about": "Пра нас",
|
||||
"footer.directory": "Дырэкторыя профіляў",
|
||||
"footer.get_app": "Спампаваць праграму",
|
||||
"footer.invite": "Запрасіць людзей",
|
||||
"footer.keyboard_shortcuts": "Спалучэнні клавіш",
|
||||
"footer.privacy_policy": "Палітыка прыватнасці",
|
||||
"footer.source_code": "Прагледзець зыходны код",
|
||||
@ -361,17 +359,8 @@
|
||||
"home.pending_critical_update.title": "Даступна крытычнае абнаўленне бяспекі!",
|
||||
"home.show_announcements": "Паказаць аб'явы",
|
||||
"ignore_notifications_modal.not_following_title": "Ігнараваць апавяшчэнні ад людзей на якіх вы не падпісаны?",
|
||||
"interaction_modal.description.favourite": "Маючы ўліковы запіс Mastodon, вы можаце ўпадабаць гэты допіс, каб паведаміць аўтару, што ён вам падабаецца, і захаваць яго на будучыню.",
|
||||
"interaction_modal.description.follow": "Маючы акаўнт у Mastodon, вы можаце падпісацца на {name}, каб бачыць яго/яе допісы ў сваёй хатняй стужцы.",
|
||||
"interaction_modal.description.reblog": "З уліковым запісам Mastodon, вы можаце пашырыць гэты пост, каб падзяліцца ім са сваімі падпісчыкамі.",
|
||||
"interaction_modal.description.reply": "Маючы акаўнт у Mastodon, вы можаце адказаць на гэты пост.",
|
||||
"interaction_modal.login.action": "Вярніце мяне дадому",
|
||||
"interaction_modal.login.prompt": "Дамен вашага хатняга сервера, напрыклад, mastodon.social",
|
||||
"interaction_modal.no_account_yet": "Яшчэ не ў Mastodon?",
|
||||
"interaction_modal.on_another_server": "На іншым серверы",
|
||||
"interaction_modal.on_this_server": "На гэтым серверы",
|
||||
"interaction_modal.sign_in": "Вы не выканалі ўваход на гэтым серверы. Дзе размешчаны ваш уліковы запіс?",
|
||||
"interaction_modal.sign_in_hint": "Падказка: гэта сайт, на якім вы зарэгістраваліся. Калі вы не памятаеце, знайдзіце ліст у паштовай скрыні. Вы таксама можаце ўвесці сваё поўнае імя карыстальніка! (напрыклад, @Mastodon@mastodon.social)",
|
||||
"interaction_modal.title.favourite": "Упадабаць допіс {name}",
|
||||
"interaction_modal.title.follow": "Падпісацца на {name}",
|
||||
"interaction_modal.title.reblog": "Пашырыць допіс ад {name}",
|
||||
@ -601,8 +590,6 @@
|
||||
"privacy_policy.title": "Палітыка канфідэнцыйнасці",
|
||||
"recommended": "Рэкамендуем",
|
||||
"refresh": "Абнавiць",
|
||||
"regeneration_indicator.label": "Загрузка…",
|
||||
"regeneration_indicator.sublabel": "Пачакайце, рыхтуем вашу стужку!",
|
||||
"relative_time.days": "{number} д",
|
||||
"relative_time.full.days": "{number, plural, one {# дзень} few {# дні} many {# дзён} other {# дня}} таму",
|
||||
"relative_time.full.hours": "{number, plural, one {# гадзіна} few {# гадзіны} many {# гадзін} other {# гадзіны}} таму",
|
||||
@ -686,10 +673,8 @@
|
||||
"search_results.accounts": "Профілі",
|
||||
"search_results.all": "Усё",
|
||||
"search_results.hashtags": "Хэштэгі",
|
||||
"search_results.nothing_found": "Па дадзенаму запыту нічога не знойдзена",
|
||||
"search_results.see_all": "Праглядзець усе",
|
||||
"search_results.statuses": "Допісы",
|
||||
"search_results.title": "Пошук {q}",
|
||||
"server_banner.about_active_users": "Людзі, якія карыстаюцца гэтым сервера на працягу апошніх 30 дзён (Штомесячна Актыўныя Карыстальнікі)",
|
||||
"server_banner.active_users": "актыўныя карыстальнікі",
|
||||
"server_banner.administered_by": "Адміністратар:",
|
||||
|
@ -108,7 +108,7 @@
|
||||
"annual_report.summary.thanks": "Благодарим, че сте част от Mastodon!",
|
||||
"attachments_list.unprocessed": "(необработено)",
|
||||
"audio.hide": "Скриване на звука",
|
||||
"block_modal.remote_users_caveat": "Ще поискаме сървърът {domain} да почита решението ви. Съгласието обаче не се гарантира откак някои сървъри могат да боравят с блоковете по различен начин. Обществените публикации още може да се виждат от невлезли в системата потребители.",
|
||||
"block_modal.remote_users_caveat": "Ще приканим сървъра {domain} да уважава решението ви. За съжаление не можем да гарантираме това защото някои сървъри могат да третират блокиранията по различен начин. Публичните постове може да продължат да бъдат видими за потребители, които не са се регистрирали.",
|
||||
"block_modal.show_less": "Повече на показ",
|
||||
"block_modal.show_more": "По-малко на показ",
|
||||
"block_modal.they_cant_mention": "Те не могат да ви споменават или последват.",
|
||||
@ -255,14 +255,14 @@
|
||||
"domain_pill.activitypub_lets_connect": "Позволява ви да се свързвате и взаимодействате с хора не само в Mastodon, но и през различни социални приложения.",
|
||||
"domain_pill.activitypub_like_language": "ActivityPub е като език на Mastodon, говорещ с други социални мрежи.",
|
||||
"domain_pill.server": "Сървър",
|
||||
"domain_pill.their_handle": "Тяхната ръчка:",
|
||||
"domain_pill.their_handle": "Техният адрес:",
|
||||
"domain_pill.their_server": "Цифровият им дом, където живеят всичките им публикации.",
|
||||
"domain_pill.their_username": "Неповторимият им идентификатор на сървъра им. Възможно е да се намерят потребители със същото потребителско име на други сървъри.",
|
||||
"domain_pill.username": "Потребителско име",
|
||||
"domain_pill.whats_in_a_handle": "Какво е в ръчката?",
|
||||
"domain_pill.who_they_are": "Откак ръчките казват кой кой е и къде е, то може да взаимодействате с хора през социаното уебпространство на <button>захранваните платформи от ActivityPub</button>.",
|
||||
"domain_pill.who_you_are": "Тъй като вашата ръчка казва кои сте и къде сте, то може да взаимодействате с хора през социаното уебпространство на <button>захранваните платформи от ActivityPub</button>.",
|
||||
"domain_pill.your_handle": "Вашата ръчка:",
|
||||
"domain_pill.whats_in_a_handle": "Как се съставя адресът?",
|
||||
"domain_pill.who_they_are": "Адресът показва за някой кой е той и къде се намира. Това ви позволява да общувате с всички в социалната мрежа от <button>платформите поддържащи ActivityPub</button>.",
|
||||
"domain_pill.who_you_are": "Адресът ви показва кой сте и къде се намирате. Това ви позволява да общувате с всички в социалната мрежа от <button>платформите поддържащи ActivityPub</button>.",
|
||||
"domain_pill.your_handle": "Вашият адрес:",
|
||||
"domain_pill.your_server": "Цифровият ви дом, където живеят всичките ви публикации. Не харесвате ли този? Прехвърляте се на сървъри по всяко време и докарвате последователите си също.",
|
||||
"domain_pill.your_username": "Неповторимият ви идентификатор на този сървър. Възможно е да се намерят потребители със същото потребителско име на други сървъри.",
|
||||
"embed.instructions": "Вградете публикацията в уебсайта си, копирайки кода долу.",
|
||||
@ -309,7 +309,6 @@
|
||||
"error.unexpected_crash.next_steps_addons": "Опитайте се да ги изключите и да опресните страницата. Ако това не помогне, то още може да използвате Mastodon чрез различен браузър или приложение.",
|
||||
"errors.unexpected_crash.copy_stacktrace": "Копиране на трасето на стека в буферната памет",
|
||||
"errors.unexpected_crash.report_issue": "Сигнал за проблем",
|
||||
"explore.search_results": "Резултати от търсенето",
|
||||
"explore.suggested_follows": "Хора",
|
||||
"explore.title": "Разглеждане",
|
||||
"explore.trending_links": "Новини",
|
||||
@ -359,11 +358,11 @@
|
||||
"footer.about": "Относно",
|
||||
"footer.directory": "Директория на профилите",
|
||||
"footer.get_app": "Вземане на приложението",
|
||||
"footer.invite": "Поканване на хора",
|
||||
"footer.keyboard_shortcuts": "Клавишни комбинации",
|
||||
"footer.privacy_policy": "Политика за поверителност",
|
||||
"footer.source_code": "Преглед на изходния код",
|
||||
"footer.status": "Състояние",
|
||||
"footer.terms_of_service": "Условия на услугата",
|
||||
"generic.saved": "Запазено",
|
||||
"getting_started.heading": "Първи стъпки",
|
||||
"hashtag.admin_moderation": "Отваряне на модериращия интерфейс за #{name}",
|
||||
@ -408,23 +407,21 @@
|
||||
"ignore_notifications_modal.not_followers_title": "Пренебрегвате ли известията от хора, които не са ви последвали?",
|
||||
"ignore_notifications_modal.not_following_title": "Пренебрегвате ли известията от хора, които не сте последвали?",
|
||||
"ignore_notifications_modal.private_mentions_title": "Пренебрегвате ли известия от непоискани лични споменавания?",
|
||||
"interaction_modal.description.favourite": "Имайки акаунт в Mastodon, може да сложите тази публикации в любими, за да позволите на автора да узнае, че я цените и да я запазите за по-късно.",
|
||||
"interaction_modal.description.follow": "С акаунт в Mastodon може да последвате {name}, за да получавате публикациите от този акаунт в началния си инфоканал.",
|
||||
"interaction_modal.description.reblog": "С акаунт в Mastodon може да подсилите тази публикация, за да я споделите с последователите си.",
|
||||
"interaction_modal.description.reply": "С акаунт в Mastodon може да добавите отговор към тази публикация.",
|
||||
"interaction_modal.description.vote": "Имайки акаунт в Mastodon, можете да гласувате в тази анкета.",
|
||||
"interaction_modal.login.action": "Към началото",
|
||||
"interaction_modal.login.prompt": "Домейнът на сървъра ви, примерно, mastodon.social",
|
||||
"interaction_modal.no_account_yet": "Още ли не сте в Mastodon?",
|
||||
"interaction_modal.action.favourite": "Трябва да направите любимо от акаунта си, за да продължите.",
|
||||
"interaction_modal.action.follow": "Трябва да последвате от акаунта си, за да продължите.",
|
||||
"interaction_modal.action.reblog": "Трябва да разпространите нечий блог от акаунта си, за да продължите.",
|
||||
"interaction_modal.action.reply": "Трябва да отговорите от акаунта си, за да продължите.",
|
||||
"interaction_modal.action.vote": "Трябва да гласувате от акаунта си, за да продължите.",
|
||||
"interaction_modal.go": "Напред",
|
||||
"interaction_modal.no_account_yet": "Още ли нямате акаунт?",
|
||||
"interaction_modal.on_another_server": "На различен сървър",
|
||||
"interaction_modal.on_this_server": "На този сървър",
|
||||
"interaction_modal.sign_in": "Не сте влезли в този сървър. Къде се хоства акаунтът ви?",
|
||||
"interaction_modal.sign_in_hint": "Съвет: Ето уебсайта, където сте се регистрирали. Ако не помните, то погледнете е-писмо за добре дошли във входящата си поща. Може също да въведете пълното си потребителско име! (примерно: @Mastodon@mastodon.social)",
|
||||
"interaction_modal.title.favourite": "Означавам публикация на {name} като любима",
|
||||
"interaction_modal.title.follow": "Последване на {name}",
|
||||
"interaction_modal.title.reblog": "Подсилване на публикацията на {name}",
|
||||
"interaction_modal.title.reply": "Отговаряне на публикацията на {name}",
|
||||
"interaction_modal.title.vote": "Гласувайте в анкетата на {name}",
|
||||
"interaction_modal.username_prompt": "Напр. {example}",
|
||||
"intervals.full.days": "{number, plural, one {# ден} other {# дни}}",
|
||||
"intervals.full.hours": "{number, plural, one {# час} other {# часа}}",
|
||||
"intervals.full.minutes": "{number, plural, one {# минута} other {# минути}}",
|
||||
@ -550,6 +547,8 @@
|
||||
"notification.annual_report.view": "Преглед на #Wrapstodon",
|
||||
"notification.favourite": "{name} направи любима публикацията ви",
|
||||
"notification.favourite.name_and_others_with_link": "{name} и <a>{count, plural, one {# друг} other {# други}}</a> направиха любима ваша публикация",
|
||||
"notification.favourite_pm": "{name} хареса вашето лично споменаване",
|
||||
"notification.favourite_pm.name_and_others_with_link": "{name} и <a>{count, plural, one {# друг} other {# други}}</a> харесаха вашето частно споменаване",
|
||||
"notification.follow": "{name} ви последва",
|
||||
"notification.follow.name_and_others": "{name} и <a>{count, plural, one {# друг} other {# други}}</a> ви последваха",
|
||||
"notification.follow_request": "{name} поиска да ви последва",
|
||||
@ -696,8 +695,8 @@
|
||||
"privacy_policy.title": "Политика за поверителност",
|
||||
"recommended": "Препоръчано",
|
||||
"refresh": "Опресняване",
|
||||
"regeneration_indicator.label": "Зареждане…",
|
||||
"regeneration_indicator.sublabel": "Подготовка на началния ви инфоканал!",
|
||||
"regeneration_indicator.please_stand_by": "Изчакайте.",
|
||||
"regeneration_indicator.preparing_your_home_feed": "Подготовка на вашия инфоканал начало…",
|
||||
"relative_time.days": "{number} д.",
|
||||
"relative_time.full.days": "преди {number, plural, one {# ден} other {# дни}}",
|
||||
"relative_time.full.hours": "преди {number, plural, one {# час} other {# часа}}",
|
||||
@ -781,10 +780,11 @@
|
||||
"search_results.accounts": "Профили",
|
||||
"search_results.all": "Всичко",
|
||||
"search_results.hashtags": "Хаштагове",
|
||||
"search_results.nothing_found": "Не може да се намери каквото и да било за тези термини при търсене",
|
||||
"search_results.no_results": "Няма намерени резултати.",
|
||||
"search_results.no_search_yet": "Опитайте да потърсите постове, профили или хаштагове.",
|
||||
"search_results.see_all": "Поглед на всички",
|
||||
"search_results.statuses": "Публикации",
|
||||
"search_results.title": "Търсене за {q}",
|
||||
"search_results.title": "Търсене на \"{q}\"",
|
||||
"server_banner.about_active_users": "Ползващите сървъра през последните 30 дни (дейните месечно потребители)",
|
||||
"server_banner.active_users": "дейни потребители",
|
||||
"server_banner.administered_by": "Администрира се от:",
|
||||
@ -857,6 +857,7 @@
|
||||
"subscribed_languages.target": "Промяна на абонираните езици за {target}",
|
||||
"tabs_bar.home": "Начало",
|
||||
"tabs_bar.notifications": "Известия",
|
||||
"terms_of_service.title": "Условия на услугата",
|
||||
"time_remaining.days": "{number, plural, one {остава # ден} other {остават # дни}}",
|
||||
"time_remaining.hours": "{number, plural, one {остава # час} other {остават # часа}}",
|
||||
"time_remaining.minutes": "{number, plural, one {остава # минута} other {остават # минути}}",
|
||||
|
@ -337,8 +337,6 @@
|
||||
"privacy.change": "লেখার গোপনীয়তা অবস্থা ঠিক করতে",
|
||||
"privacy.public.short": "সর্বজনীন প্রকাশ্য",
|
||||
"refresh": "সতেজ করা",
|
||||
"regeneration_indicator.label": "আসছে…",
|
||||
"regeneration_indicator.sublabel": "আপনার বাড়ির-সময়রেখা প্রস্তূত করা হচ্ছে!",
|
||||
"relative_time.days": "{number} দিন",
|
||||
"relative_time.full.just_now": "এইমাত্র",
|
||||
"relative_time.hours": "{number} ঘন্টা",
|
||||
|
@ -230,7 +230,6 @@
|
||||
"error.unexpected_crash.next_steps_addons": "Klaskit azbevaat ar bajenn. Ma n'ez a ket en-dro e c'hallit klask ober gant Mastodon dre ur merdeer disheñvel pe dre an arload genidik.",
|
||||
"errors.unexpected_crash.copy_stacktrace": "Eilañ ar roudoù diveugañ er golver",
|
||||
"errors.unexpected_crash.report_issue": "Danevellañ ur fazi",
|
||||
"explore.search_results": "Disoc'hoù an enklask",
|
||||
"explore.suggested_follows": "Tud",
|
||||
"explore.title": "Furchal",
|
||||
"explore.trending_links": "Keleier",
|
||||
@ -263,7 +262,6 @@
|
||||
"footer.about": "Diwar-benn",
|
||||
"footer.directory": "Kavlec'h ar profiloù",
|
||||
"footer.get_app": "Pellgargañ an arload",
|
||||
"footer.invite": "Pediñ tud",
|
||||
"footer.keyboard_shortcuts": "Berradennoù klavier",
|
||||
"footer.privacy_policy": "Reolennoù prevezded",
|
||||
"footer.source_code": "Gwelet ar c'hod mammenn",
|
||||
@ -290,10 +288,6 @@
|
||||
"home.pending_critical_update.body": "Hizivait ho servijer Mastodon kerkent ha ma c'hallit mar plij!",
|
||||
"home.pending_critical_update.link": "Gwelet an hizivadennoù",
|
||||
"home.show_announcements": "Diskouez ar c'hemennoù",
|
||||
"interaction_modal.description.follow": "Gant ur gont Mastodon e c'hellit heuliañ {name} evit resev an toudoù a embann war ho red degemer.",
|
||||
"interaction_modal.description.reblog": "Gant ur gont Mastodon e c'hellit skignañ an toud-mañ evit rannañ anezhañ gant ho heulierien·ezed.",
|
||||
"interaction_modal.description.reply": "Gant ur gont Mastodon e c'hellit respont d'an toud-mañ.",
|
||||
"interaction_modal.no_account_yet": "N'emañ ket war vMastodon?",
|
||||
"interaction_modal.on_another_server": "War ur servijer all",
|
||||
"interaction_modal.on_this_server": "War ar servijer-mañ",
|
||||
"interaction_modal.title.favourite": "Ouzhpennañ embannadur {name} d'ar re vuiañ-karet",
|
||||
@ -449,8 +443,6 @@
|
||||
"privacy_policy.title": "Reolennoù Prevezded",
|
||||
"recommended": "Erbedet",
|
||||
"refresh": "Freskaat",
|
||||
"regeneration_indicator.label": "O kargañ…",
|
||||
"regeneration_indicator.sublabel": "War brientiñ emañ ho red degemer!",
|
||||
"relative_time.days": "{number}d",
|
||||
"relative_time.full.days": "{number, plural, one {# devezh} two {# zevezh} few {# devezh} many {# a devezh} other {# devezh}} zo",
|
||||
"relative_time.full.hours": "{number, plural, one {# eurvezh} two {# eurvezh} few {# eurvezh} many {# eur} other {# eurvezh}} zo",
|
||||
@ -524,10 +516,8 @@
|
||||
"search_results.accounts": "Profiloù",
|
||||
"search_results.all": "Pep tra",
|
||||
"search_results.hashtags": "Hashtagoù",
|
||||
"search_results.nothing_found": "Disoc'h ebet gant ar gerioù-se",
|
||||
"search_results.see_all": "Gwelet pep tra",
|
||||
"search_results.statuses": "Toudoù",
|
||||
"search_results.title": "Klask {q}",
|
||||
"server_banner.active_users": "implijerien·ezed oberiant",
|
||||
"server_banner.administered_by": "Meret gant :",
|
||||
"server_banner.server_stats": "Stadegoù ar servijer :",
|
||||
|
@ -309,7 +309,6 @@
|
||||
"error.unexpected_crash.next_steps_addons": "Prova de desactivar-los i actualitza la pàgina. Si això no serveix, és possible que encara puguis fer servir Mastodon amb un altre navegador o una aplicació nativa.",
|
||||
"errors.unexpected_crash.copy_stacktrace": "Copia stacktrace al porta-retalls",
|
||||
"errors.unexpected_crash.report_issue": "Informa d'un problema",
|
||||
"explore.search_results": "Resultats de la cerca",
|
||||
"explore.suggested_follows": "Persones",
|
||||
"explore.title": "Explora",
|
||||
"explore.trending_links": "Notícies",
|
||||
@ -359,11 +358,11 @@
|
||||
"footer.about": "Quant a",
|
||||
"footer.directory": "Directori de perfils",
|
||||
"footer.get_app": "Aconsegueix l'app",
|
||||
"footer.invite": "Convida persones",
|
||||
"footer.keyboard_shortcuts": "Dreceres de teclat",
|
||||
"footer.privacy_policy": "Política de privadesa",
|
||||
"footer.source_code": "Mostra el codi font",
|
||||
"footer.status": "Estat",
|
||||
"footer.terms_of_service": "Condicions de servei",
|
||||
"generic.saved": "Desat",
|
||||
"getting_started.heading": "Primeres passes",
|
||||
"hashtag.admin_moderation": "Obre la interfície de moderació per a #{name}",
|
||||
@ -408,23 +407,16 @@
|
||||
"ignore_notifications_modal.not_followers_title": "Voleu ignorar les notificacions de qui no us segueix?",
|
||||
"ignore_notifications_modal.not_following_title": "Voleu ignorar les notificacions de qui no seguiu?",
|
||||
"ignore_notifications_modal.private_mentions_title": "Voleu ignorar les notificacions de mencions privades no sol·licitades?",
|
||||
"interaction_modal.description.favourite": "Amb un compte a Mastodon pots afavorir aquest tut perquè l'autor sàpiga que t'ha agradat i desar-lo per a més endavant.",
|
||||
"interaction_modal.description.follow": "Amb un compte a Mastodon, pots seguir a {name} per a rebre els seus tuts en la teva línia de temps d'Inici.",
|
||||
"interaction_modal.description.reblog": "Amb un compte a Mastodon, pots impulsar aquest tut per a compartir-lo amb els teus seguidors.",
|
||||
"interaction_modal.description.reply": "Amb un compte a Mastodon, pots respondre aquest tut.",
|
||||
"interaction_modal.description.vote": "Si teniu compte a Mastodon, podeu votar aquesta enquesta.",
|
||||
"interaction_modal.login.action": "Torna a l'inici",
|
||||
"interaction_modal.login.prompt": "Domini del teu servidor domèstic, p.ex. mastodon.social",
|
||||
"interaction_modal.no_account_yet": "No a Mastodon?",
|
||||
"interaction_modal.go": "Endavant",
|
||||
"interaction_modal.no_account_yet": "Encara no teniu cap compte?",
|
||||
"interaction_modal.on_another_server": "A un altre servidor",
|
||||
"interaction_modal.on_this_server": "En aquest servidor",
|
||||
"interaction_modal.sign_in": "No has iniciat sessió en aquest servidor. On tens el teu compte?",
|
||||
"interaction_modal.sign_in_hint": "Ajuda: Aquesta és la web on vas registrar-te. Si no ho recordes, mira el correu electrònic de benvinguda en la teva safata d'entrada. També pots introduïr el teu nom d'usuari complet! (per ex. @Mastodon@mastodon.social)",
|
||||
"interaction_modal.title.favourite": "Afavoreix el tut de {name}",
|
||||
"interaction_modal.title.follow": "Segueix {name}",
|
||||
"interaction_modal.title.reblog": "Impulsa el tut de {name}",
|
||||
"interaction_modal.title.reply": "Respon al tut de {name}",
|
||||
"interaction_modal.title.vote": "Voteu l'enquesta de {name}",
|
||||
"interaction_modal.username_prompt": "P. ex. {example}",
|
||||
"intervals.full.days": "{number, plural, one {# dia} other {# dies}}",
|
||||
"intervals.full.hours": "{number, plural, one {# hora} other {# hores}}",
|
||||
"intervals.full.minutes": "{number, plural, one {# minut} other {# minuts}}",
|
||||
@ -550,6 +542,8 @@
|
||||
"notification.annual_report.view": "Visualitzeu #Wrapstodon",
|
||||
"notification.favourite": "{name} ha afavorit el teu tut",
|
||||
"notification.favourite.name_and_others_with_link": "{name} i <a>{count, plural, one {# altre} other {# altres}}</a> han afavorit la vostra publicació",
|
||||
"notification.favourite_pm": "{name} ha afavorit la vostra menció privada",
|
||||
"notification.favourite_pm.name_and_others_with_link": "{name} i <a>{count, plural, one {un altre} other {# altres}}</a> han afavorit la vostra menció",
|
||||
"notification.follow": "{name} et segueix",
|
||||
"notification.follow.name_and_others": "{name} i <a>{count, plural, one {# altre} other {# altres}}</a> us han seguit",
|
||||
"notification.follow_request": "{name} ha sol·licitat de seguir-te",
|
||||
@ -696,8 +690,8 @@
|
||||
"privacy_policy.title": "Política de Privacitat",
|
||||
"recommended": "Recomanat",
|
||||
"refresh": "Actualitza",
|
||||
"regeneration_indicator.label": "Es carrega…",
|
||||
"regeneration_indicator.sublabel": "Es prepara la vostra pantalla d'Inici!",
|
||||
"regeneration_indicator.please_stand_by": "Espereu.",
|
||||
"regeneration_indicator.preparing_your_home_feed": "Pantalla d'inici en preparació…",
|
||||
"relative_time.days": "{number}d",
|
||||
"relative_time.full.days": "fa {number, plural, one {# dia} other {# dies}}",
|
||||
"relative_time.full.hours": "fa {number, plural, one {# hora} other {# hores}}",
|
||||
@ -781,10 +775,11 @@
|
||||
"search_results.accounts": "Perfils",
|
||||
"search_results.all": "Tots",
|
||||
"search_results.hashtags": "Etiquetes",
|
||||
"search_results.nothing_found": "No s'ha pogut trobar res per a aquests termes de cerca",
|
||||
"search_results.no_results": "Cap resultat.",
|
||||
"search_results.no_search_yet": "Proveu de cercar publicacions, perfils o etiquetes.",
|
||||
"search_results.see_all": "Veure'ls tots",
|
||||
"search_results.statuses": "Tuts",
|
||||
"search_results.title": "Cerca de {q}",
|
||||
"search_results.title": "Cerca de “{q}”",
|
||||
"server_banner.about_active_users": "Gent que ha fet servir aquest servidor en els darrers 30 dies (Usuaris Actius Mensuals)",
|
||||
"server_banner.active_users": "usuaris actius",
|
||||
"server_banner.administered_by": "Administrat per:",
|
||||
@ -857,6 +852,7 @@
|
||||
"subscribed_languages.target": "Canvia les llengües subscrites per a {target}",
|
||||
"tabs_bar.home": "Inici",
|
||||
"tabs_bar.notifications": "Notificacions",
|
||||
"terms_of_service.title": "Condicions de servei",
|
||||
"time_remaining.days": "{number, plural, one {# dia restant} other {# dies restants}}",
|
||||
"time_remaining.hours": "{number, plural, one {# hora restant} other {# hores restants}}",
|
||||
"time_remaining.minutes": "{number, plural, one {# minut restant} other {# minuts restants}}",
|
||||
|
@ -226,7 +226,6 @@
|
||||
"error.unexpected_crash.next_steps_addons": "هەوڵدە لەکاریان بخەیت و لاپەڕەکە تازە بکەوە. ئەگەر ئەمە یارمەتیدەر نەبوو، لەوانەیە هێشتا بتوانیت ماستۆدۆن بەکاربێنیت لە ڕێگەی وێبگەڕەکانی دیکە یان نەرمەکالاکانی ئەسڵی.",
|
||||
"errors.unexpected_crash.copy_stacktrace": "کۆپیکردنی ستێکتراسی بۆ کلیپ بۆرد",
|
||||
"errors.unexpected_crash.report_issue": "کێشەی گوزارشت",
|
||||
"explore.search_results": "ئەنجامەکانی گەڕان",
|
||||
"explore.suggested_follows": "خەڵک",
|
||||
"explore.title": "گەڕان",
|
||||
"explore.trending_links": "هەواڵەکان",
|
||||
@ -262,7 +261,6 @@
|
||||
"footer.about": "دەربارە",
|
||||
"footer.directory": "ڕابەری پەڕەی ناساندن",
|
||||
"footer.get_app": "بەرنامەکە بەدەست بێنە",
|
||||
"footer.invite": "بانگهێشتکردنی خەڵک",
|
||||
"footer.keyboard_shortcuts": "کورتەڕێکانی تەختەکلیک",
|
||||
"footer.privacy_policy": "سیاسەتی تایبەتمەندێتی",
|
||||
"footer.source_code": "پیشاندانی کۆدی سەرچاوە",
|
||||
@ -285,9 +283,6 @@
|
||||
"home.column_settings.show_replies": "وەڵامدانەوەکان پیشان بدە",
|
||||
"home.hide_announcements": "شاردنەوەی راگەیەنراوەکان",
|
||||
"home.show_announcements": "پیشاندانی راگەیەنراوەکان",
|
||||
"interaction_modal.description.follow": "بە هەژمارێک لەسەر ماستدۆن، ئەتوانیت شوێن {name} بکەویت بۆ ئەوەی بڵاوکراوەکانی بگاتە پەڕەی سەرەکیت.",
|
||||
"interaction_modal.description.reblog": "بە هەژمارێک لەسەر ماستدۆن، ئەتوانیت ئەم بڵاوکراوەیە بەرزبکەیتەوە تاوەکو بەژداری پێبکەیت لەگەل شوێنکەوتوانت.",
|
||||
"interaction_modal.description.reply": "بە هەژمارێک لەسەر ماستدۆن، ئەتوانیت وەڵامی ئەم بڵاوکراوەیە بدەیتەوە.",
|
||||
"interaction_modal.on_another_server": "لەسەر ڕاژەیەکی جیا",
|
||||
"interaction_modal.on_this_server": "لەسەر ئەم ڕاژەیە",
|
||||
"interaction_modal.title.follow": "دوای {name} بکەوە",
|
||||
@ -418,8 +413,6 @@
|
||||
"privacy_policy.last_updated": "دوایین نوێکردنەوە {date}",
|
||||
"privacy_policy.title": "سیاسەتی تایبەتێتی",
|
||||
"refresh": "نوێکردنەوە",
|
||||
"regeneration_indicator.label": "بارکردن…",
|
||||
"regeneration_indicator.sublabel": "ڕاگەیەنەری ماڵەوەت ئامادە دەکرێت!",
|
||||
"relative_time.days": "{number}ڕۆژ",
|
||||
"relative_time.full.days": "{number, plural, one {# ڕۆژ} other {# ڕۆژ}} ماوە",
|
||||
"relative_time.full.hours": "{number, plural, one {# کاتژمێر} other {# کاتژمێر}} ماوە",
|
||||
@ -487,9 +480,7 @@
|
||||
"search_results.accounts": "پرۆفایلەکان",
|
||||
"search_results.all": "هەموو",
|
||||
"search_results.hashtags": "هەشتاگ",
|
||||
"search_results.nothing_found": "هیچ بۆ ئەم زاراوە گەڕانانە نەدۆزراوەتەوە",
|
||||
"search_results.statuses": "توتەکان",
|
||||
"search_results.title": "گەڕان بەدوای {q}",
|
||||
"server_banner.about_active_users": "ئەو کەسانەی لە ماوەی ٣٠ ڕۆژی ڕابردوودا ئەم سێرڤەرە بەکاردەهێنن (بەکارهێنەرانی چالاک مانگانە)",
|
||||
"server_banner.active_users": "بەکارهێنەرانی چالاک",
|
||||
"server_banner.administered_by": "بەڕێوەبردن لەلایەن:",
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user