Merge branch 'main' into attribution-domains-api

# Conflicts:
#	config/locales/simple_form.nl.yml
This commit is contained in:
Christian Schmidt 2024-12-20 17:19:03 +01:00
commit c30cb1f63b
661 changed files with 11118 additions and 5876 deletions

View File

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

View File

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

View File

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

View File

@ -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:

View File

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

View File

@ -1,6 +1,6 @@
# This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
# using RuboCop version 1.66.1.
# using RuboCop version 1.69.1.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
@ -35,7 +35,6 @@ Rails/OutputSafety:
# Configuration parameters: AllowedVars.
Style/FetchEnvVar:
Exclude:
- 'app/lib/translation_service.rb'
- 'config/environments/production.rb'
- 'config/initializers/2_limited_federation_mode.rb'
- 'config/initializers/3_omniauth.rb'

View File

@ -2,6 +2,48 @@
All notable changes to this project will be documented in this file.
## [4.3.2] - 2024-12-03
### Added
- Add `tootctl feeds vacuum` (#33065 by @ClearlyClaire)
- Add error message when user tries to follow their own account (#31910 by @lenikadali)
- Add client_secret_expires_at to OAuth Applications (#30317 by @ThisIsMissEm)
### Changed
- Change design of Content Warnings and filters (#32543 by @ClearlyClaire)
### Fixed
- Fix processing incoming post edits with mentions to unresolvable accounts (#33129 by @ClearlyClaire)
- Fix error when including multiple instances of `embed.js` (#33107 by @YKWeyer)
- Fix inactive users' timelines being backfilled on follow and unsuspend (#33094 by @ClearlyClaire)
- Fix direct inbox delivery pushing posts into inactive followers' timelines (#33067 by @ClearlyClaire)
- Fix `TagFollow` records not being correctly handled in account operations (#33063 by @ClearlyClaire)
- Fix pushing hashtag-followed posts to feeds of inactive users (#33018 by @Gargron)
- Fix duplicate notifications in notification groups when using slow mode (#33014 by @ClearlyClaire)
- Fix posts made in the future being allowed to trend (#32996 by @ClearlyClaire)
- Fix uploading higher-than-wide GIF profile picture with libvips enabled (#32911 by @ClearlyClaire)
- Fix domain attribution field having autocorrect and autocapitalize enabled (#32903 by @ClearlyClaire)
- Fix titles being escaped twice (#32889 by @ClearlyClaire)
- Fix list creation limit check (#32869 by @ClearlyClaire)
- Fix error in `tootctl email_domain_blocks` when supplying `--with-dns-records` (#32863 by @mjankowski)
- Fix `min_id` and `max_id` causing error in search API (#32857 by @Gargron)
- Fix inefficiencies when processing removal of posts that use featured tags (#32787 by @ClearlyClaire)
- Fix alt-text pop-in not using the translated description (#32766 by @ClearlyClaire)
- Fix preview cards with long titles erroneously causing layout changes (#32678 by @ClearlyClaire)
- Fix embed modal layout on mobile (#32641 by @DismalShadowX)
- Fix and improve batch attachment deletion handling when using OpenStack Swift (#32637 by @hugogameiro)
- Fix blocks not being applied on link timeline (#32625 by @tribela)
- Fix follow counters being incorrectly changed (#32622 by @oneiros)
- Fix 'unknown' media attachment type rendering (#32613 and #32713 by @ThisIsMissEm and @renatolond)
- Fix tl language native name (#32606 by @seav)
### Security
- Update dependencies
## [4.3.1] - 2024-10-21
### Added

View File

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

View File

@ -10,29 +10,29 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.2.2)
actionpack (= 7.2.2)
activesupport (= 7.2.2)
actioncable (7.2.2.1)
actionpack (= 7.2.2.1)
activesupport (= 7.2.2.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.2.2)
actionpack (= 7.2.2)
activejob (= 7.2.2)
activerecord (= 7.2.2)
activestorage (= 7.2.2)
activesupport (= 7.2.2)
actionmailbox (7.2.2.1)
actionpack (= 7.2.2.1)
activejob (= 7.2.2.1)
activerecord (= 7.2.2.1)
activestorage (= 7.2.2.1)
activesupport (= 7.2.2.1)
mail (>= 2.8.0)
actionmailer (7.2.2)
actionpack (= 7.2.2)
actionview (= 7.2.2)
activejob (= 7.2.2)
activesupport (= 7.2.2)
actionmailer (7.2.2.1)
actionpack (= 7.2.2.1)
actionview (= 7.2.2.1)
activejob (= 7.2.2.1)
activesupport (= 7.2.2.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.2)
actionview (= 7.2.2)
activesupport (= 7.2.2)
actionpack (7.2.2.1)
actionview (= 7.2.2.1)
activesupport (= 7.2.2.1)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4, < 3.2)
@ -41,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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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?

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -88,7 +88,7 @@ export const FollowButton: React.FC<{
<a
href='/settings/profile'
target='_blank'
rel='noreferrer noopener'
rel='noopener'
className='button button-secondary'
>
{label}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -42,7 +42,7 @@ class ServerBanner extends PureComponent {
return (
<div className='server-banner'>
<div className='server-banner__introduction'>
<FormattedMessage id='server_banner.is_one_of_many' defaultMessage='{domain} is one of the many independent Mastodon servers you can use to participate in the fediverse.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank'>Mastodon</a> }} />
<FormattedMessage id='server_banner.is_one_of_many' defaultMessage='{domain} is one of the many independent Mastodon servers you can use to participate in the fediverse.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank' rel='noopener'>Mastodon</a> }} />
</div>
<Link to='/about'>

View File

@ -293,7 +293,7 @@ class Status extends ImmutablePureComponent {
if (e?.button === 0 && !(e?.ctrlKey || e?.metaKey)) {
history.push(path);
} else if (e?.button === 1 || (e?.button === 0 && (e?.ctrlKey || e?.metaKey))) {
window.open(path, '_blank', 'noreferrer noopener');
window.open(path, '_blank', 'noopener');
}
};

View File

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

View File

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

View File

@ -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'>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,93 +0,0 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
import { expandSearch } from 'mastodon/actions/search';
import { 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>
);
};

View File

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

View File

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

View File

@ -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>');
});
});
});

View File

@ -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,
};

View File

@ -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)));

View 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;

View File

@ -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));

View File

@ -85,7 +85,7 @@ class ContentWithRouter extends ImmutablePureComponent {
}
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener noreferrer');
link.setAttribute('rel', 'noopener');
}
}

View File

@ -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';

View File

@ -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);

View 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;

View File

@ -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>
);

View File

@ -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>

View File

@ -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');
}
}

View File

@ -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
}

View File

@ -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);

View 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;

View File

@ -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>
);

View 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;

View File

@ -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>

View File

@ -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>

View 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;

View File

@ -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>

View File

@ -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 && (
<>

View File

@ -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)));

View 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>
);
};

View File

@ -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} />

View File

@ -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');
}

View File

@ -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}
*/

View File

@ -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",

View File

@ -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:",

View File

@ -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": "يُديره:",

View File

@ -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",

View File

@ -0,0 +1 @@
{}

View File

@ -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": "Адміністратар:",

View File

@ -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 {остават # минути}}",

View File

@ -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} ঘন্টা",

View File

@ -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 :",

View File

@ -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}}",

View File

@ -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