th: Merge remote-tracking branch 'glitch/main'

th-downstream
Kouhai 9 months ago
commit e9dbe31776

@ -5,7 +5,7 @@
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"features": { "features": {
"ghcr.io/devcontainers/features/sshd:1": {} "ghcr.io/devcontainers/features/sshd:1": {},
}, },
"runServices": ["app", "db", "redis"], "runServices": ["app", "db", "redis"],
@ -15,16 +15,16 @@
"portsAttributes": { "portsAttributes": {
"3000": { "3000": {
"label": "web", "label": "web",
"onAutoForward": "notify" "onAutoForward": "notify",
}, },
"4000": { "4000": {
"label": "stream", "label": "stream",
"onAutoForward": "silent" "onAutoForward": "silent",
} },
}, },
"otherPortsAttributes": { "otherPortsAttributes": {
"onAutoForward": "silent" "onAutoForward": "silent",
}, },
"remoteEnv": { "remoteEnv": {
@ -33,7 +33,7 @@
"STREAMING_API_BASE_URL": "https://${localEnv:CODESPACE_NAME}-4000.app.github.dev", "STREAMING_API_BASE_URL": "https://${localEnv:CODESPACE_NAME}-4000.app.github.dev",
"DISABLE_FORGERY_REQUEST_PROTECTION": "true", "DISABLE_FORGERY_REQUEST_PROTECTION": "true",
"ES_ENABLED": "", "ES_ENABLED": "",
"LIBRE_TRANSLATE_ENDPOINT": "" "LIBRE_TRANSLATE_ENDPOINT": "",
}, },
"onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", "onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
@ -43,7 +43,7 @@
"customizations": { "customizations": {
"vscode": { "vscode": {
"settings": {}, "settings": {},
"extensions": ["EditorConfig.EditorConfig", "webben.browserslist"] "extensions": ["EditorConfig.EditorConfig", "webben.browserslist"],
} },
} },
} }

@ -5,7 +5,7 @@
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"features": { "features": {
"ghcr.io/devcontainers/features/sshd:1": {} "ghcr.io/devcontainers/features/sshd:1": {},
}, },
"forwardPorts": [3000, 4000], "forwardPorts": [3000, 4000],
@ -14,17 +14,17 @@
"3000": { "3000": {
"label": "web", "label": "web",
"onAutoForward": "notify", "onAutoForward": "notify",
"requireLocalPort": true "requireLocalPort": true,
}, },
"4000": { "4000": {
"label": "stream", "label": "stream",
"onAutoForward": "silent", "onAutoForward": "silent",
"requireLocalPort": true "requireLocalPort": true,
} },
}, },
"otherPortsAttributes": { "otherPortsAttributes": {
"onAutoForward": "silent" "onAutoForward": "silent",
}, },
"onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", "onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
@ -34,7 +34,7 @@
"customizations": { "customizations": {
"vscode": { "vscode": {
"settings": {}, "settings": {},
"extensions": ["EditorConfig.EditorConfig", "webben.browserslist"] "extensions": ["EditorConfig.EditorConfig", "webben.browserslist"],
} },
} },
} }

@ -78,23 +78,8 @@ jobs:
- name: Create database - name: Create database
run: './bin/rails db:create' run: './bin/rails db:create'
- name: Run migrations up to v2.0.0 - name: Run historical migrations with data population
run: './bin/rails db:migrate VERSION=20171010025614' run: './bin/rails tests:migrations:prepare_database'
- name: Populate database with test data
run: './bin/rails tests:migrations:populate_v2'
- name: Run migrations up to v2.4.0
run: './bin/rails db:migrate VERSION=20180514140000'
- name: Populate database with test data
run: './bin/rails tests:migrations:populate_v2_4'
- name: Run migrations up to v2.4.3
run: './bin/rails db:migrate VERSION=20180707154237'
- name: Populate database with test data
run: './bin/rails tests:migrations:populate_v2_4_3'
- name: Run all remaining migrations - name: Run all remaining migrations
run: './bin/rails db:migrate' run: './bin/rails db:migrate'

@ -45,6 +45,7 @@ jobs:
--health-retries 5 --health-retries 5
ports: ports:
- 5432:5432 - 5432:5432
redis: redis:
image: redis:7-alpine image: redis:7-alpine
options: >- options: >-
@ -77,28 +78,11 @@ jobs:
- name: Create database - name: Create database
run: './bin/rails db:create' run: './bin/rails db:create'
- name: Run migrations up to v2.0.0 - name: Run historical migrations with data population
run: './bin/rails db:migrate VERSION=20171010025614' run: './bin/rails tests:migrations:prepare_database'
- name: Populate database with test data
run: './bin/rails tests:migrations:populate_v2'
- name: Run pre-deployment migrations up to v2.4.0
run: './bin/rails db:migrate VERSION=20180514140000'
env:
SKIP_POST_DEPLOYMENT_MIGRATIONS: true
- name: Populate database with test data
run: './bin/rails tests:migrations:populate_v2_4'
- name: Run migrations up to v2.4.3
run: './bin/rails db:migrate VERSION=20180707154237'
env: env:
SKIP_POST_DEPLOYMENT_MIGRATIONS: true SKIP_POST_DEPLOYMENT_MIGRATIONS: true
- name: Populate database with test data
run: './bin/rails tests:migrations:populate_v2_4_3'
- name: Run all remaining pre-deployment migrations - name: Run all remaining pre-deployment migrations
run: './bin/rails db:migrate' run: './bin/rails db:migrate'
env: env:

@ -52,7 +52,7 @@ jobs:
run: | run: |
tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs* tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs*
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v4
if: matrix.mode == 'test' if: matrix.mode == 'test'
with: with:
path: |- path: |-
@ -117,7 +117,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v4
with: with:
path: './' path: './'
name: ${{ github.sha }} name: ${{ github.sha }}
@ -193,7 +193,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v4
with: with:
path: './public' path: './public'
name: ${{ github.sha }} name: ${{ github.sha }}
@ -213,14 +213,14 @@ jobs:
- run: bundle exec rake spec:system - run: bundle exec rake spec:system
- name: Archive logs - name: Archive logs
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
if: failure() if: failure()
with: with:
name: e2e-logs-${{ matrix.ruby-version }} name: e2e-logs-${{ matrix.ruby-version }}
path: log/ path: log/
- name: Archive test screenshots - name: Archive test screenshots
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
if: failure() if: failure()
with: with:
name: e2e-screenshots name: e2e-screenshots
@ -297,7 +297,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v4
with: with:
path: './public' path: './public'
name: ${{ github.sha }} name: ${{ github.sha }}
@ -317,14 +317,14 @@ jobs:
- run: bin/rspec --tag search - run: bin/rspec --tag search
- name: Archive logs - name: Archive logs
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
if: failure() if: failure()
with: with:
name: test-search-logs-${{ matrix.ruby-version }} name: test-search-logs-${{ matrix.ruby-version }}
path: log/ path: log/
- name: Archive test screenshots - name: Archive test screenshots
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
if: failure() if: failure()
with: with:
name: test-search-screenshots name: test-search-screenshots

@ -1,6 +1,6 @@
# This configuration was generated by # This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp` # `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp`
# using RuboCop version 1.59.0. # using RuboCop version 1.60.2.
# The point is for the user to remove these configuration records # The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base. # one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new # Note that changes in the inspected code, or installation of new
@ -70,35 +70,6 @@ Rails/UniqueValidationWithoutIndex:
- 'app/models/identity.rb' - 'app/models/identity.rb'
- 'app/models/webauthn_credential.rb' - 'app/models/webauthn_credential.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: EnforcedStyle.
# SupportedStyles: exists, where
Rails/WhereExists:
Exclude:
- 'app/controllers/activitypub/inboxes_controller.rb'
- 'app/controllers/admin/email_domain_blocks_controller.rb'
- 'app/lib/activitypub/activity/create.rb'
- 'app/lib/delivery_failure_tracker.rb'
- 'app/lib/feed_manager.rb'
- 'app/lib/status_cache_hydrator.rb'
- 'app/lib/suspicious_sign_in_detector.rb'
- 'app/models/poll.rb'
- 'app/models/session_activation.rb'
- 'app/models/status.rb'
- 'app/policies/status_policy.rb'
- 'app/serializers/rest/announcement_serializer.rb'
- 'app/serializers/rest/tag_serializer.rb'
- 'app/services/activitypub/fetch_remote_status_service.rb'
- 'app/services/vote_service.rb'
- 'app/validators/reaction_validator.rb'
- 'app/validators/vote_validator.rb'
- 'app/workers/move_worker.rb'
- 'lib/tasks/tests.rake'
- 'spec/models/account_spec.rb'
- 'spec/services/activitypub/process_collection_service_spec.rb'
- 'spec/services/purge_domain_service_spec.rb'
- 'spec/services/unallow_domain_service_spec.rb'
# This cop supports unsafe autocorrection (--autocorrect-all). # This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: AllowedMethods, AllowedPatterns. # Configuration parameters: AllowedMethods, AllowedPatterns.
# AllowedMethods: ==, equal?, eql? # AllowedMethods: ==, equal?, eql?
@ -137,7 +108,6 @@ Style/FetchEnvVar:
# AllowedMethods: redirect # AllowedMethods: redirect
Style/FormatStringToken: Style/FormatStringToken:
Exclude: Exclude:
- 'app/models/privacy_policy.rb'
- 'config/initializers/devise.rb' - 'config/initializers/devise.rb'
- 'lib/paperclip/color_extractor.rb' - 'lib/paperclip/color_extractor.rb'
@ -151,10 +121,6 @@ Style/GlobalStdStream:
# Configuration parameters: MinBodyLength, AllowConsecutiveConditionals. # Configuration parameters: MinBodyLength, AllowConsecutiveConditionals.
Style/GuardClause: Style/GuardClause:
Exclude: Exclude:
- 'app/controllers/admin/confirmations_controller.rb'
- 'app/controllers/auth/confirmations_controller.rb'
- 'app/controllers/auth/passwords_controller.rb'
- 'app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb'
- 'app/lib/activitypub/activity/block.rb' - 'app/lib/activitypub/activity/block.rb'
- 'app/lib/request.rb' - 'app/lib/request.rb'
- 'app/lib/request_pool.rb' - 'app/lib/request_pool.rb'
@ -309,13 +275,6 @@ Style/StringLiterals:
- 'config/initializers/webauthn.rb' - 'config/initializers/webauthn.rb'
- 'config/routes.rb' - 'config/routes.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyle, AllowSafeAssignment.
# SupportedStyles: require_parentheses, require_no_parentheses, require_parentheses_when_complex
Style/TernaryParentheses:
Exclude:
- 'config/environments/development.rb'
# This cop supports safe autocorrection (--autocorrect). # This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyleForMultiline. # Configuration parameters: EnforcedStyleForMultiline.
# SupportedStylesForMultiline: comma, consistent_comma, no_comma # SupportedStylesForMultiline: comma, consistent_comma, no_comma

@ -1 +1 @@
3.2.2 3.2.3

@ -1,19 +1,35 @@
## ActivityPub federation in Mastodon # Federation
## Supported federation protocols and standards
- [ActivityPub](https://www.w3.org/TR/activitypub/) (Server-to-Server)
- [WebFinger](https://webfinger.net/)
- [Http Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures)
- [NodeInfo](https://nodeinfo.diaspora.software/)
## Supported FEPs
- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md)
- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)
- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)
- [FEP-5feb: Search indexing consent for actors](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md)
## ActivityPub in Mastodon
Mastodon largely follows the ActivityPub server-to-server specification but it makes uses of some non-standard extensions, some of which are required for interacting with Mastodon at all. Mastodon largely follows the ActivityPub server-to-server specification but it makes uses of some non-standard extensions, some of which are required for interacting with Mastodon at all.
Supported vocabulary: https://docs.joinmastodon.org/spec/activitypub/ - [Supported ActivityPub vocabulary](https://docs.joinmastodon.org/spec/activitypub/)
### Required extensions ### Required extensions
#### Webfinger #### WebFinger
In Mastodon, users are identified by a `username` and `domain` pair (e.g., `Gargron@mastodon.social`). In Mastodon, users are identified by a `username` and `domain` pair (e.g., `Gargron@mastodon.social`).
This is used both for discovery and for unambiguously mentioning users across the fediverse. Furthermore, this is part of Mastodon's database design from its very beginnings. This is used both for discovery and for unambiguously mentioning users across the fediverse. Furthermore, this is part of Mastodon's database design from its very beginnings.
As a result, Mastodon requires that each ActivityPub actor uniquely maps back to an `acct:` URI that can be resolved via WebFinger. As a result, Mastodon requires that each ActivityPub actor uniquely maps back to an `acct:` URI that can be resolved via WebFinger.
More information and examples are available at: https://docs.joinmastodon.org/spec/webfinger/ - [WebFinger information and examples](https://docs.joinmastodon.org/spec/webfinger/)
#### HTTP Signatures #### HTTP Signatures
@ -21,11 +37,13 @@ In order to authenticate activities, Mastodon relies on HTTP Signatures, signing
Mastodon requires all `POST` requests to be signed, and MAY require `GET` requests to be signed, depending on the configuration of the Mastodon server. Mastodon requires all `POST` requests to be signed, and MAY require `GET` requests to be signed, depending on the configuration of the Mastodon server.
More information on HTTP Signatures, as well as examples, can be found here: https://docs.joinmastodon.org/spec/security/#http - [HTTP Signatures information and examples](https://docs.joinmastodon.org/spec/security/#http)
### Optional extensions ### Optional extensions
- Linked-Data Signatures: https://docs.joinmastodon.org/spec/security/#ld - [Linked-Data Signatures](https://docs.joinmastodon.org/spec/security/#ld)
- Bearcaps: https://docs.joinmastodon.org/spec/bearcaps/ - [Bearcaps](https://docs.joinmastodon.org/spec/bearcaps/)
- Followers collection synchronization: https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md
- Search indexing consent for actors: https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md ### Additional documentation
- [Mastodon documentation](https://docs.joinmastodon.org/)

@ -123,7 +123,7 @@ group :test do
gem 'database_cleaner-active_record' gem 'database_cleaner-active_record'
# Used to mock environment variables # Used to mock environment variables
gem 'climate_control', '~> 0.2' gem 'climate_control'
# Generating fake data for specs # Generating fake data for specs
gem 'faker', '~> 3.2' gem 'faker', '~> 3.2'

@ -150,7 +150,7 @@ GEM
erubi (~> 1.4) erubi (~> 1.4)
parser (>= 2.4) parser (>= 2.4)
smart_properties smart_properties
bigdecimal (3.1.5) bigdecimal (3.1.6)
bindata (2.4.15) bindata (2.4.15)
binding_of_caller (1.0.0) binding_of_caller (1.0.0)
debug_inspector (>= 0.0.1) debug_inspector (>= 0.0.1)
@ -180,12 +180,12 @@ GEM
activesupport activesupport
cbor (0.5.9.6) cbor (0.5.9.6)
charlock_holmes (0.7.7) charlock_holmes (0.7.7)
chewy (7.4.0) chewy (7.5.0)
activesupport (>= 5.2) activesupport (>= 5.2)
elasticsearch (>= 7.12.0, < 7.14.0) elasticsearch (>= 7.12.0, < 7.14.0)
elasticsearch-dsl elasticsearch-dsl
chunky_png (1.4.0) chunky_png (1.4.0)
climate_control (0.2.0) climate_control (1.2.0)
cocoon (1.2.15) cocoon (1.2.15)
color_diff (0.1) color_diff (0.1)
concurrent-ruby (1.2.3) concurrent-ruby (1.2.3)
@ -320,7 +320,7 @@ GEM
activesupport (>= 5.1) activesupport (>= 5.1)
haml (>= 4.0.6) haml (>= 4.0.6)
railties (>= 5.1) railties (>= 5.1)
haml_lint (0.53.0) haml_lint (0.55.0)
haml (>= 5.0) haml (>= 5.0)
parallel (~> 1.10) parallel (~> 1.10)
rainbow rainbow
@ -361,7 +361,7 @@ GEM
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
terminal-table (>= 1.5.1) terminal-table (>= 1.5.1)
idn-ruby (0.1.5) idn-ruby (0.1.5)
io-console (0.7.1) io-console (0.7.2)
irb (1.11.1) irb (1.11.1)
rdoc rdoc
reline (>= 0.4.2) reline (>= 0.4.2)
@ -399,12 +399,12 @@ GEM
activerecord activerecord
kaminari-core (= 1.2.2) kaminari-core (= 1.2.2)
kaminari-core (1.2.2) kaminari-core (1.2.2)
kt-paperclip (7.2.1) kt-paperclip (7.2.2)
activemodel (>= 4.2.0) activemodel (>= 4.2.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
marcel (~> 1.0.1) marcel (~> 1.0.1)
mime-types mime-types
terrapin (~> 0.6.0) terrapin (>= 0.6.0, < 2.0)
language_server-protocol (3.17.0.3) language_server-protocol (3.17.0.3)
launchy (2.5.2) launchy (2.5.2)
addressable (~> 2.8) addressable (~> 2.8)
@ -446,7 +446,7 @@ GEM
mime-types-data (3.2023.1205) mime-types-data (3.2023.1205)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.5) mini_portile2 (2.8.5)
minitest (5.20.0) minitest (5.21.2)
msgpack (1.7.2) msgpack (1.7.2)
multi_json (1.15.0) multi_json (1.15.0)
multipart-post (2.3.0) multipart-post (2.3.0)
@ -505,7 +505,7 @@ GEM
orm_adapter (0.5.0) orm_adapter (0.5.0)
ox (2.14.17) ox (2.14.17)
parallel (1.24.0) parallel (1.24.0)
parser (3.2.2.4) parser (3.3.0.5)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
parslet (2.0.0) parslet (2.0.0)
@ -601,8 +601,8 @@ GEM
rdf (3.3.1) rdf (3.3.1)
bcp47_spec (~> 0.2) bcp47_spec (~> 0.2)
link_header (~> 0.0, >= 0.0.8) link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.6.1) rdf-normalize (0.7.0)
rdf (~> 3.2) rdf (~> 3.3)
rdoc (6.6.2) rdoc (6.6.2)
psych (>= 4.0.0) psych (>= 4.0.0)
redcarpet (3.6.0) redcarpet (3.6.0)
@ -611,7 +611,7 @@ GEM
redis (>= 4) redis (>= 4)
redlock (1.3.2) redlock (1.3.2)
redis (>= 3.0.0, < 6.0) redis (>= 3.0.0, < 6.0)
regexp_parser (2.8.3) regexp_parser (2.9.0)
reline (0.4.2) reline (0.4.2)
io-console (~> 0.5) io-console (~> 0.5)
request_store (1.5.1) request_store (1.5.1)
@ -637,7 +637,7 @@ GEM
rspec-mocks (3.12.6) rspec-mocks (3.12.6)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.12.0)
rspec-rails (6.1.0) rspec-rails (6.1.1)
actionpack (>= 6.1) actionpack (>= 6.1)
activesupport (>= 6.1) activesupport (>= 6.1)
railties (>= 6.1) railties (>= 6.1)
@ -651,11 +651,11 @@ GEM
rspec-mocks (~> 3.0) rspec-mocks (~> 3.0)
sidekiq (>= 5, < 8) sidekiq (>= 5, < 8)
rspec-support (3.12.1) rspec-support (3.12.1)
rubocop (1.59.0) rubocop (1.60.2)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (>= 3.17.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.2.2.4) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0) rexml (>= 3.2.5, < 4.0)
@ -697,7 +697,8 @@ GEM
scenic (1.7.0) scenic (1.7.0)
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
railties (>= 4.0.0) railties (>= 4.0.0)
selenium-webdriver (4.16.0) selenium-webdriver (4.17.0)
base64 (~> 0.2)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0) rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0) websocket (~> 1.0)
@ -746,8 +747,8 @@ GEM
temple (0.10.3) temple (0.10.3)
terminal-table (3.0.2) terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3) unicode-display_width (>= 1.1.1, < 3)
terrapin (0.6.0) terrapin (1.0.1)
climate_control (>= 0.0.3, < 1.0) climate_control
test-prof (1.3.1) test-prof (1.3.1)
thor (1.3.0) thor (1.3.0)
tilt (2.3.0) tilt (2.3.0)
@ -836,7 +837,7 @@ DEPENDENCIES
capybara (~> 3.39) capybara (~> 3.39)
charlock_holmes (~> 0.7.7) charlock_holmes (~> 0.7.7)
chewy (~> 7.3) chewy (~> 7.3)
climate_control (~> 0.2) climate_control
cocoon (~> 1.2) cocoon (~> 1.2)
color_diff (~> 0.1) color_diff (~> 0.1)
concurrent-ruby concurrent-ruby

@ -24,7 +24,7 @@ class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseContro
end end
def set_items def set_items
@items = @account.followers.where(Account.arel_table[:uri].matches("#{Account.sanitize_sql_like(uri_prefix)}/%", false, true)).or(@account.followers.where(uri: uri_prefix)).pluck(:uri) @items = @account.followers.matches_uri_prefix(uri_prefix).pluck(:uri)
end end
def collection_presenter def collection_presenter

@ -24,7 +24,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
def unknown_affected_account? def unknown_affected_account?
json = Oj.load(body, mode: :strict) json = Oj.load(body, mode: :strict)
json.is_a?(Hash) && %w(Delete Update).include?(json['type']) && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.where(uri: json['actor']).exists? json.is_a?(Hash) && %w(Delete Update).include?(json['type']) && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.exists?(uri: json['actor'])
rescue Oj::ParseError rescue Oj::ParseError
false false
end end

@ -6,7 +6,7 @@ module Admin
def index def index
authorize :audit_log, :index? authorize :audit_log, :index?
@auditable_accounts = Account.where(id: Admin::ActionLog.select('distinct account_id')).select(:id, :username) @auditable_accounts = Account.auditable.select(:id, :username)
end end
private private

@ -3,7 +3,7 @@
module Admin module Admin
class ConfirmationsController < BaseController class ConfirmationsController < BaseController
before_action :set_user before_action :set_user
before_action :check_confirmation, only: [:resend] before_action :redirect_confirmed_user, only: [:resend], if: :user_confirmed?
def create def create
authorize @user, :confirm? authorize @user, :confirm?
@ -25,11 +25,13 @@ module Admin
private private
def check_confirmation def redirect_confirmed_user
if @user.confirmed?
flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed') flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed')
redirect_to admin_accounts_path redirect_to admin_accounts_path
end end
def user_confirmed?
@user.confirmed?
end end
end end
end end

@ -38,7 +38,7 @@ module Admin
log_action :create, @email_domain_block log_action :create, @email_domain_block
(@email_domain_block.other_domains || []).uniq.each do |domain| (@email_domain_block.other_domains || []).uniq.each do |domain|
next if EmailDomainBlock.where(domain: domain).exists? next if EmailDomainBlock.exists?(domain: domain)
other_email_domain_block = EmailDomainBlock.create!(domain: domain, allow_with_approval: @email_domain_block.allow_with_approval, parent: @email_domain_block) other_email_domain_block = EmailDomainBlock.create!(domain: domain, allow_with_approval: @email_domain_block.allow_with_approval, parent: @email_domain_block)
log_action :create, other_email_domain_block log_action :create, other_email_domain_block

@ -49,7 +49,7 @@ module Admin
next next
end end
@warning_domains = Instance.where(domain: @domain_blocks.map(&:domain)).where('EXISTS (SELECT 1 FROM follows JOIN accounts ON follows.account_id = accounts.id OR follows.target_account_id = accounts.id WHERE accounts.domain = instances.domain)').pluck(:domain) @warning_domains = instances_from_imported_blocks.pluck(:domain)
rescue ActionController::ParameterMissing rescue ActionController::ParameterMissing
flash.now[:alert] = I18n.t('admin.export_domain_blocks.no_file') flash.now[:alert] = I18n.t('admin.export_domain_blocks.no_file')
set_dummy_import! set_dummy_import!
@ -58,6 +58,10 @@ module Admin
private private
def instances_from_imported_blocks
Instance.with_domain_follows(@domain_blocks.map(&:domain))
end
def export_filename def export_filename
'domain_blocks.csv' 'domain_blocks.csv'
end end

@ -21,7 +21,7 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController
return [] if hide_results? return [] if hide_results?
scope = default_accounts scope = default_accounts
scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? || current_account.id == @account.id scope = scope.not_excluded_by_account(current_account) unless current_account.nil? || current_account.id == @account.id
scope.merge(paginated_follows).to_a scope.merge(paginated_follows).to_a
end end
@ -30,7 +30,7 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController
end end
def default_accounts def default_accounts
Account.includes(:active_relationships, :account_stat).references(:active_relationships) Account.includes(:active_relationships, :account_stat, :user).references(:active_relationships)
end end
def paginated_follows def paginated_follows

@ -21,7 +21,7 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController
return [] if hide_results? return [] if hide_results?
scope = default_accounts scope = default_accounts
scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? || current_account.id == @account.id scope = scope.not_excluded_by_account(current_account) unless current_account.nil? || current_account.id == @account.id
scope.merge(paginated_follows).to_a scope.merge(paginated_follows).to_a
end end
@ -30,7 +30,7 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController
end end
def default_accounts def default_accounts
Account.includes(:passive_relationships, :account_stat).references(:passive_relationships) Account.includes(:passive_relationships, :account_stat, :user).references(:passive_relationships)
end end
def paginated_follows def paginated_follows

@ -0,0 +1,30 @@
# frozen_string_literal: true
class Api::V1::AnnualReportsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
before_action :require_user!
before_action :set_annual_report, except: :index
def index
with_read_replica do
@presenter = AnnualReportsPresenter.new(GeneratedAnnualReport.where(account_id: current_account.id).pending)
@relationships = StatusRelationshipsPresenter.new(@presenter.statuses, current_account.id)
end
render json: @presenter,
serializer: REST::AnnualReportsSerializer,
relationships: @relationships
end
def read
@annual_report.view!
render_empty
end
private
def set_annual_report
@annual_report = GeneratedAnnualReport.find_by!(account_id: current_account.id, year: params[:id])
end
end

@ -17,7 +17,7 @@ class Api::V1::BlocksController < Api::BaseController
end end
def paginated_blocks def paginated_blocks
@paginated_blocks ||= Block.eager_load(target_account: :account_stat) @paginated_blocks ||= Block.eager_load(target_account: [:account_stat, :user])
.joins(:target_account) .joins(:target_account)
.merge(Account.without_suspended) .merge(Account.without_suspended)
.where(account: current_account) .where(account: current_account)

@ -27,7 +27,7 @@ class Api::V1::DirectoriesController < Api::BaseController
scope.merge!(local_account_scope) if local_accounts? scope.merge!(local_account_scope) if local_accounts?
scope.merge!(account_exclusion_scope) if current_account scope.merge!(account_exclusion_scope) if current_account
scope.merge!(account_domain_block_scope) if current_account && !local_accounts? scope.merge!(account_domain_block_scope) if current_account && !local_accounts?
end end.includes(:account_stat, user: :role)
end end
def local_accounts? def local_accounts?

@ -25,7 +25,7 @@ class Api::V1::EndorsementsController < Api::BaseController
end end
def endorsed_accounts def endorsed_accounts
current_account.endorsed_accounts.includes(:account_stat).without_suspended current_account.endorsed_accounts.includes(:account_stat, :user).without_suspended
end end
def insert_pagination_headers def insert_pagination_headers

@ -37,7 +37,7 @@ class Api::V1::FollowRequestsController < Api::BaseController
end end
def default_accounts def default_accounts
Account.without_suspended.includes(:follow_requests, :account_stat).references(:follow_requests) Account.without_suspended.includes(:follow_requests, :account_stat, :user).references(:follow_requests)
end end
def paginated_follow_requests def paginated_follow_requests

@ -37,9 +37,9 @@ class Api::V1::Lists::AccountsController < Api::BaseController
def load_accounts def load_accounts
if unlimited? if unlimited?
@list.accounts.without_suspended.includes(:account_stat).all @list.accounts.without_suspended.includes(:account_stat, :user).all
else else
@list.accounts.without_suspended.includes(:account_stat).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) @list.accounts.without_suspended.includes(:account_stat, :user).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
end end
end end

@ -17,7 +17,7 @@ class Api::V1::MutesController < Api::BaseController
end end
def paginated_mutes def paginated_mutes
@paginated_mutes ||= Mute.eager_load(:target_account) @paginated_mutes ||= Mute.eager_load(target_account: [:account_stat, :user])
.joins(:target_account) .joins(:target_account)
.merge(Account.without_suspended) .merge(Account.without_suspended)
.where(account: current_account) .where(account: current_account)

@ -14,14 +14,14 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::V1::Statuses::Bas
def load_accounts def load_accounts
scope = default_accounts scope = default_accounts
scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? scope = scope.not_excluded_by_account(current_account) unless current_account.nil?
scope.merge(paginated_favourites).to_a scope.merge(paginated_favourites).to_a
end end
def default_accounts def default_accounts
Account Account
.without_suspended .without_suspended
.includes(:favourites, :account_stat) .includes(:favourites, :account_stat, :user)
.references(:favourites) .references(:favourites)
.where(favourites: { status_id: @status.id }) .where(favourites: { status_id: @status.id })
end end

@ -14,12 +14,12 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::V1::Statuses::Base
def load_accounts def load_accounts
scope = default_accounts scope = default_accounts
scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? scope = scope.not_excluded_by_account(current_account) unless current_account.nil?
scope.merge(paginated_statuses).to_a scope.merge(paginated_statuses).to_a
end end
def default_accounts def default_accounts
Account.without_suspended.includes(:statuses, :account_stat).references(:statuses) Account.without_suspended.includes(:statuses, :account_stat, :user).references(:statuses)
end end
def paginated_statuses def paginated_statuses

@ -35,7 +35,7 @@ class Api::V2::FiltersController < Api::BaseController
private private
def set_filters def set_filters
@filters = current_account.custom_filters.includes(:keywords) @filters = current_account.custom_filters.includes(:keywords, :statuses)
end end
def set_filter def set_filter

@ -8,7 +8,7 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
before_action :set_body_classes before_action :set_body_classes
before_action :set_pack before_action :set_pack
before_action :set_confirmation_user!, only: [:show, :confirm_captcha] before_action :set_confirmation_user!, only: [:show, :confirm_captcha]
before_action :require_unconfirmed! before_action :redirect_confirmed_user, if: :signed_in_confirmed_user?
before_action :extend_csp_for_captcha!, only: [:show, :confirm_captcha] before_action :extend_csp_for_captcha!, only: [:show, :confirm_captcha]
before_action :require_captcha_if_needed!, only: [:show] before_action :require_captcha_if_needed!, only: [:show]
@ -70,10 +70,12 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
use_pack 'auth' use_pack 'auth'
end end
def require_unconfirmed! def redirect_confirmed_user
if user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank?
redirect_to(current_user.approved? ? root_path : edit_user_registration_path) redirect_to(current_user.approved? ? root_path : edit_user_registration_path)
end end
def signed_in_confirmed_user?
user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank?
end end
def set_body_classes def set_body_classes

@ -2,7 +2,7 @@
class Auth::PasswordsController < Devise::PasswordsController class Auth::PasswordsController < Devise::PasswordsController
skip_before_action :check_self_destruct! skip_before_action :check_self_destruct!
before_action :check_validity_of_reset_password_token, only: :edit before_action :redirect_invalid_reset_token, only: :edit, unless: :reset_password_token_is_valid?
before_action :set_pack before_action :set_pack
before_action :set_body_classes before_action :set_body_classes
@ -20,12 +20,10 @@ class Auth::PasswordsController < Devise::PasswordsController
private private
def check_validity_of_reset_password_token def redirect_invalid_reset_token
unless reset_password_token_is_valid?
flash[:error] = I18n.t('auth.invalid_reset_password_token') flash[:error] = I18n.t('auth.invalid_reset_password_token')
redirect_to new_password_path(resource_name) redirect_to new_password_path(resource_name)
end end
end
def set_body_classes def set_body_classes
@body_classes = 'lighter' @body_classes = 'lighter'

@ -1,6 +1,10 @@
# frozen_string_literal: true # frozen_string_literal: true
class Auth::SessionsController < Devise::SessionsController class Auth::SessionsController < Devise::SessionsController
include Redisable
MAX_2FA_ATTEMPTS_PER_HOUR = 10
layout 'auth' layout 'auth'
skip_before_action :check_self_destruct! skip_before_action :check_self_destruct!
@ -135,9 +139,23 @@ class Auth::SessionsController < Devise::SessionsController
session.delete(:attempt_user_updated_at) session.delete(:attempt_user_updated_at)
end end
def clear_2fa_attempt_from_user(user)
redis.del(second_factor_attempts_key(user))
end
def check_second_factor_rate_limits(user)
attempts, = redis.multi do |multi|
multi.incr(second_factor_attempts_key(user))
multi.expire(second_factor_attempts_key(user), 1.hour)
end
attempts >= MAX_2FA_ATTEMPTS_PER_HOUR
end
def on_authentication_success(user, security_measure) def on_authentication_success(user, security_measure)
@on_authentication_success_called = true @on_authentication_success_called = true
clear_2fa_attempt_from_user(user)
clear_attempt_from_session clear_attempt_from_session
user.update_sign_in!(new_sign_in: true) user.update_sign_in!(new_sign_in: true)
@ -168,5 +186,14 @@ class Auth::SessionsController < Devise::SessionsController
ip: request.remote_ip, ip: request.remote_ip,
user_agent: request.user_agent user_agent: request.user_agent
) )
# Only send a notification email every hour at most
return if redis.set("2fa_failure_notification:#{user.id}", '1', ex: 1.hour, get: true).present?
UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
end
def second_factor_attempts_key(user)
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
end end
end end

@ -66,6 +66,11 @@ module Auth::TwoFactorAuthenticationConcern
end end
def authenticate_with_two_factor_via_otp(user) def authenticate_with_two_factor_via_otp(user)
if check_second_factor_rate_limits(user)
flash.now[:alert] = I18n.t('users.rate_limited')
return prompt_for_two_factor(user)
end
if valid_otp_attempt?(user) if valid_otp_attempt?(user)
on_authentication_success(user, :otp) on_authentication_success(user, :otp)
else else

@ -22,11 +22,20 @@ module WebAppControllerConcern
def redirect_unauthenticated_to_permalinks! def redirect_unauthenticated_to_permalinks!
return if user_signed_in? # NOTE: Different from upstream because we allow moved users to log in return if user_signed_in? # NOTE: Different from upstream because we allow moved users to log in
redirect_path = PermalinkRedirector.new(request.path).redirect_path permalink_redirector = PermalinkRedirector.new(request.path)
return if redirect_path.blank? return if permalink_redirector.redirect_path.blank?
expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in? expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in?
redirect_to(redirect_path)
respond_to do |format|
format.html do
redirect_to(permalink_redirector.redirect_confirmation_path, allow_other_host: false)
end
format.json do
redirect_to(permalink_redirector.redirect_uri, allow_other_host: true)
end
end
end end
def set_pack def set_pack

@ -0,0 +1,10 @@
# frozen_string_literal: true
class Redirect::AccountsController < Redirect::BaseController
private
def set_resource
@resource = Account.find(params[:id])
not_found if @resource.local?
end
end

@ -0,0 +1,29 @@
# frozen_string_literal: true
class Redirect::BaseController < ApplicationController
vary_by 'Accept-Language'
before_action :set_pack
before_action :set_resource
before_action :set_app_body_class
def show
@redirect_path = ActivityPub::TagManager.instance.url_for(@resource)
render 'redirects/show', layout: 'application'
end
private
def set_app_body_class
@body_classes = 'app-body'
end
def set_resource
raise NotImplementedError
end
def set_pack
use_pack 'public'
end
end

@ -0,0 +1,10 @@
# frozen_string_literal: true
class Redirect::StatusesController < Redirect::BaseController
private
def set_resource
@resource = Status.find(params[:id])
not_found if @resource.local? || !@resource.distributable?
end
end

@ -6,8 +6,8 @@ module Settings
skip_before_action :check_self_destruct! skip_before_action :check_self_destruct!
skip_before_action :require_functional! skip_before_action :require_functional!
before_action :require_otp_enabled before_action :redirect_invalid_otp, unless: -> { current_user.otp_enabled? }
before_action :require_webauthn_enabled, only: [:index, :destroy] before_action :redirect_invalid_webauthn, only: [:index, :destroy], unless: -> { current_user.webauthn_enabled? }
def index; end def index; end
def new; end def new; end
@ -89,19 +89,15 @@ module Settings
use_pack 'auth' use_pack 'auth'
end end
def require_otp_enabled def redirect_invalid_otp
unless current_user.otp_enabled?
flash[:error] = t('webauthn_credentials.otp_required') flash[:error] = t('webauthn_credentials.otp_required')
redirect_to settings_two_factor_authentication_methods_path redirect_to settings_two_factor_authentication_methods_path
end end
end
def require_webauthn_enabled def redirect_invalid_webauthn
unless current_user.webauthn_enabled?
flash[:error] = t('webauthn_credentials.not_enabled') flash[:error] = t('webauthn_credentials.not_enabled')
redirect_to settings_two_factor_authentication_methods_path redirect_to settings_two_factor_authentication_methods_path
end end
end end
end end
end end
end

@ -155,7 +155,7 @@ module JsonLdHelper
end end
end end
def fetch_resource(uri, id, on_behalf_of = nil) def fetch_resource(uri, id, on_behalf_of = nil, request_options: {})
unless id unless id
json = fetch_resource_without_id_validation(uri, on_behalf_of) json = fetch_resource_without_id_validation(uri, on_behalf_of)
@ -164,14 +164,14 @@ module JsonLdHelper
uri = json['id'] uri = json['id']
end end
json = fetch_resource_without_id_validation(uri, on_behalf_of) json = fetch_resource_without_id_validation(uri, on_behalf_of, request_options: request_options)
json.present? && json['id'] == uri ? json : nil json.present? && json['id'] == uri ? json : nil
end end
def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false) def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false, request_options: {})
on_behalf_of ||= Account.representative on_behalf_of ||= Account.representative
build_request(uri, on_behalf_of).perform do |response| build_request(uri, on_behalf_of, options: request_options).perform do |response|
raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error
body_to_json(response.body_with_limit) if response.code == 200 body_to_json(response.body_with_limit) if response.code == 200
@ -204,8 +204,8 @@ module JsonLdHelper
response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code)) response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code))
end end
def build_request(uri, on_behalf_of = nil) def build_request(uri, on_behalf_of = nil, options: {})
Request.new(:get, uri).tap do |request| Request.new(:get, uri, **options).tap do |request|
request.on_behalf_of(on_behalf_of) if on_behalf_of request.on_behalf_of(on_behalf_of) if on_behalf_of
request.add_headers('Accept' => 'application/activity+json, application/ld+json') request.add_headers('Accept' => 'application/activity+json, application/ld+json')
end end

@ -170,6 +170,11 @@ export const openURL = routerHistory => (dispatch, getState) => {
export const clickSearchResult = (q, type) => (dispatch, getState) => { export const clickSearchResult = (q, type) => (dispatch, getState) => {
const previous = getState().getIn(['search', 'recent']); 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 me = getState().getIn(['meta', 'me']);
const current = previous.add(fromJS({ type, q })).takeLast(4); const current = previous.add(fromJS({ type, q })).takeLast(4);

@ -18,8 +18,10 @@ import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import StarBorderIcon from '@/material-icons/400-24px/star.svg?react'; import StarBorderIcon from '@/material-icons/400-24px/star.svg?react';
import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react'; import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react';
import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react';
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg'; import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg';
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg'; import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg';
import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions';
import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links'; import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
@ -319,7 +321,7 @@ class StatusActionBar extends ImmutablePureComponent {
if (status.get('reblogged')) { if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private); reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
reblogIconComponent = publicStatus ? RepeatIcon : RepeatPrivateIcon; reblogIconComponent = publicStatus ? RepeatActiveIcon : RepeatPrivateActiveIcon;
} else if (publicStatus) { } else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog); reblogTitle = intl.formatMessage(messages.reblog);
reblogIconComponent = RepeatIcon; reblogIconComponent = RepeatIcon;

@ -63,14 +63,14 @@ class Search extends PureComponent {
}; };
defaultOptions = [ defaultOptions = [
{ label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:'); } }, { key: 'prompt-has', label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:'); } },
{ label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:'); } }, { key: 'prompt-is', label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:'); } },
{ label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:'); } }, { key: 'prompt-language', label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:'); } },
{ label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:'); } }, { key: 'prompt-from', label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:'); } },
{ label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:'); } }, { key: 'prompt-before', label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:'); } },
{ label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:'); } }, { key: 'prompt-during', label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:'); } },
{ label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:'); } }, { key: 'prompt-after', label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:'); } },
{ label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library', 'public']} /></>, action: e => { e.preventDefault(); this._insertText('in:'); } } { key: 'prompt-in', label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library', 'public']} /></>, action: e => { e.preventDefault(); this._insertText('in:'); } }
]; ];
setRef = c => { setRef = c => {
@ -263,6 +263,8 @@ class Search extends PureComponent {
const { recent } = this.props; const { recent } = this.props;
return recent.toArray().map(search => ({ return recent.toArray().map(search => ({
key: `${search.get('type')}/${search.get('q')}`,
label: labelForRecentSearch(search), label: labelForRecentSearch(search),
action: () => this.handleRecentSearchClick(search), action: () => this.handleRecentSearchClick(search),
@ -347,8 +349,8 @@ class Search extends PureComponent {
<h4><FormattedMessage id='search_popout.recent' defaultMessage='Recent searches' /></h4> <h4><FormattedMessage id='search_popout.recent' defaultMessage='Recent searches' /></h4>
<div className='search__popout__menu'> <div className='search__popout__menu'>
{recent.size > 0 ? this._getOptions().map(({ label, action, forget }, i) => ( {recent.size > 0 ? this._getOptions().map(({ label, key, action, forget }, i) => (
<button key={label} onMouseDown={action} className={classNames('search__popout__menu__item search__popout__menu__item--flex', { selected: selectedOption === i })}> <button key={key} onMouseDown={action} className={classNames('search__popout__menu__item search__popout__menu__item--flex', { selected: selectedOption === i })}>
<span>{label}</span> <span>{label}</span>
<button className='icon-button' onMouseDown={forget}><Icon id='times' icon={CloseIcon} /></button> <button className='icon-button' onMouseDown={forget}><Icon id='times' icon={CloseIcon} /></button>
</button> </button>

@ -1,3 +1,4 @@
import { createSelector } from '@reduxjs/toolkit';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { import {
@ -12,10 +13,15 @@ import {
import Search from '../components/search'; import Search from '../components/search';
const getRecentSearches = createSelector(
state => state.getIn(['search', 'recent']),
recent => recent.reverse(),
);
const mapStateToProps = state => ({ const mapStateToProps = state => ({
value: state.getIn(['search', 'value']), value: state.getIn(['search', 'value']),
submitted: state.getIn(['search', 'submitted']), submitted: state.getIn(['search', 'submitted']),
recent: state.getIn(['search', 'recent']).reverse(), recent: getRecentSearches(state),
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({

@ -1,17 +1,24 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useCallback, useState } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { withRouter } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { createSelector } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import { useDispatch, useSelector } from 'react-redux';
import { HotKeys } from 'react-hotkeys'; import { HotKeys } from 'react-hotkeys';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import { replyCompose } from 'flavours/glitch/actions/compose';
import { markConversationRead, deleteConversation } from 'flavours/glitch/actions/conversations';
import { openModal } from 'flavours/glitch/actions/modal';
import { muteStatus, unmuteStatus, revealStatus, hideStatus } from 'flavours/glitch/actions/statuses';
import AttachmentList from 'flavours/glitch/components/attachment_list'; import AttachmentList from 'flavours/glitch/components/attachment_list';
import AvatarComposite from 'flavours/glitch/components/avatar_composite'; import AvatarComposite from 'flavours/glitch/components/avatar_composite';
import { IconButton } from 'flavours/glitch/components/icon_button'; import { IconButton } from 'flavours/glitch/components/icon_button';
@ -20,7 +27,7 @@ import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp
import StatusContent from 'flavours/glitch/components/status_content'; import StatusContent from 'flavours/glitch/components/status_content';
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
import { autoPlayGif } from 'flavours/glitch/initial_state'; import { autoPlayGif } from 'flavours/glitch/initial_state';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; import { makeGetStatus } from 'flavours/glitch/selectors';
const messages = defineMessages({ const messages = defineMessages({
more: { id: 'status.more', defaultMessage: 'More' }, more: { id: 'status.more', defaultMessage: 'More' },
@ -30,45 +37,48 @@ const messages = defineMessages({
delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' }, delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
}); });
class Conversation extends ImmutablePureComponent { const getAccounts = createSelector(
(state) => state.get('accounts'),
(_, accountIds) => accountIds,
(accounts, accountIds) =>
accountIds.map(id => accounts.get(id))
);
static propTypes = { const getStatus = makeGetStatus();
conversationId: PropTypes.string.isRequired,
accounts: ImmutablePropTypes.list.isRequired,
lastStatus: ImmutablePropTypes.map,
unread:PropTypes.bool.isRequired,
scrollKey: PropTypes.string,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
markRead: PropTypes.func.isRequired,
delete: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
...WithRouterPropTypes,
};
state = { export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) => {
isExpanded: undefined, const id = conversation.get('id');
}; const unread = conversation.get('unread');
const lastStatusId = conversation.get('last_status');
const accountIds = conversation.get('accounts');
const intl = useIntl();
const dispatch = useDispatch();
const history = useHistory();
const lastStatus = useSelector(state => getStatus(state, { id: lastStatusId }));
const accounts = useSelector(state => getAccounts(state, accountIds));
parseClick = (e, destination) => { // glitch-soc additions
const { history, lastStatus, unread, markRead } = this.props; const sharedCWState = useSelector(state => state.getIn(['state', 'content_warnings', 'shared_state']));
if (!history) return; const [expanded, setExpanded] = useState(undefined);
const parseClick = useCallback((e, destination) => {
if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) { if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
if (destination === undefined) { if (destination === undefined) {
if (unread) { if (unread) {
markRead(); dispatch(markConversationRead(id));
} }
destination = `/statuses/${lastStatus.get('id')}`; destination = `/statuses/${lastStatus.get('id')}`;
} }
history.push(destination); history.push(destination);
e.preventDefault(); e.preventDefault();
} }
}; }, [dispatch, history, unread, id, lastStatus]);
handleMouseEnter = ({ currentTarget }) => { const handleMouseEnter = useCallback(({ currentTarget }) => {
if (autoPlayGif) { if (autoPlayGif) {
return; return;
} }
@ -79,9 +89,9 @@ class Conversation extends ImmutablePureComponent {
let emoji = emojis[i]; let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-original'); emoji.src = emoji.getAttribute('data-original');
} }
}; }, []);
handleMouseLeave = ({ currentTarget }) => { const handleMouseLeave = useCallback(({ currentTarget }) => {
if (autoPlayGif) { if (autoPlayGif) {
return; return;
} }
@ -92,89 +102,105 @@ class Conversation extends ImmutablePureComponent {
let emoji = emojis[i]; let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-static'); emoji.src = emoji.getAttribute('data-static');
} }
}; }, []);
handleClick = () => {
if (!this.props.history) {
return;
}
const { lastStatus, unread, markRead } = this.props;
const handleClick = useCallback(() => {
if (unread) { if (unread) {
markRead(); dispatch(markConversationRead(id));
} }
this.props.history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`); history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`);
}; }, [dispatch, history, unread, id, lastStatus]);
handleMarkAsRead = () => { const handleMarkAsRead = useCallback(() => {
this.props.markRead(); dispatch(markConversationRead(id));
}; }, [dispatch, id]);
handleReply = () => { const handleReply = useCallback(() => {
this.props.reply(this.props.lastStatus, this.props.history); dispatch((_, getState) => {
}; let state = getState();
handleDelete = () => { if (state.getIn(['compose', 'text']).trim().length !== 0) {
this.props.delete(); dispatch(openModal({
}; modalType: 'CONFIRM',
modalProps: {
handleHotkeyMoveUp = () => { message: intl.formatMessage(messages.replyMessage),
this.props.onMoveUp(this.props.conversationId); confirm: intl.formatMessage(messages.replyConfirm),
}; onConfirm: () => dispatch(replyCompose(lastStatus, history)),
},
}));
} else {
dispatch(replyCompose(lastStatus, history));
}
});
}, [dispatch, lastStatus, history, intl]);
handleHotkeyMoveDown = () => { const handleDelete = useCallback(() => {
this.props.onMoveDown(this.props.conversationId); dispatch(deleteConversation(id));
}; }, [dispatch, id]);
handleConversationMute = () => { const handleHotkeyMoveUp = useCallback(() => {
this.props.onMute(this.props.lastStatus); onMoveUp(id);
}; }, [id, onMoveUp]);
handleShowMore = () => { const handleHotkeyMoveDown = useCallback(() => {
this.props.onToggleHidden(this.props.lastStatus); onMoveDown(id);
}, [id, onMoveDown]);
if (this.props.lastStatus.get('spoiler_text')) { const handleConversationMute = useCallback(() => {
this.setExpansion(!this.state.isExpanded); if (lastStatus.get('muted')) {
dispatch(unmuteStatus(lastStatus.get('id')));
} else {
dispatch(muteStatus(lastStatus.get('id')));
} }
}; }, [dispatch, lastStatus]);
setExpansion = value => { const handleShowMore = useCallback(() => {
this.setState({ isExpanded: value }); if (lastStatus.get('hidden')) {
}; dispatch(revealStatus(lastStatus.get('id')));
} else {
dispatch(hideStatus(lastStatus.get('id')));
}
render () { if (lastStatus.get('spoiler_text')) {
const { accounts, lastStatus, unread, scrollKey, intl } = this.props; setExpanded(!expanded);
}
}, [dispatch, lastStatus, expanded]);
if (lastStatus === null) { if (!lastStatus) {
return null; return null;
} }
const isExpanded = this.props.settings.getIn(['content_warnings', 'shared_state']) ? !lastStatus.get('hidden') : this.state.isExpanded;
const menu = [ const menu = [
{ text: intl.formatMessage(messages.open), action: this.handleClick }, { text: intl.formatMessage(messages.open), action: handleClick },
null, null,
{ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: handleConversationMute },
]; ];
menu.push({ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMute });
if (unread) { if (unread) {
menu.push({ text: intl.formatMessage(messages.markAsRead), action: this.handleMarkAsRead }); menu.push({ text: intl.formatMessage(messages.markAsRead), action: handleMarkAsRead });
menu.push(null); menu.push(null);
} }
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete }); menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete });
const names = accounts.map(a => <Permalink to={`/@${a.get('acct')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]); const names = accounts.map(a => (
<Permalink to={`/@${a.get('acct')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}>
<bdi>
<strong
className='display-name__html'
dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }}
/>
</bdi>
</Permalink>
)).reduce((prev, cur) => [prev, ', ', cur]);
const handlers = { const handlers = {
reply: this.handleReply, reply: handleReply,
open: this.handleClick, open: handleClick,
moveUp: this.handleHotkeyMoveUp, moveUp: handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown, moveDown: handleHotkeyMoveDown,
toggleHidden: this.handleShowMore, toggleHidden: handleShowMore,
}; };
let media = null; let media = null;
@ -185,7 +211,7 @@ class Conversation extends ImmutablePureComponent {
return ( return (
<HotKeys handlers={handlers}> <HotKeys handlers={handlers}>
<div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex={0}> <div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex={0}>
<div className='conversation__avatar' onClick={this.handleClick} role='presentation'> <div className='conversation__avatar' onClick={handleClick} role='presentation'>
<AvatarComposite accounts={accounts} size={48} /> <AvatarComposite accounts={accounts} size={48} />
</div> </div>
@ -195,22 +221,22 @@ class Conversation extends ImmutablePureComponent {
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} /> {unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
</div> </div>
<div className='conversation__content__names' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <div className='conversation__content__names' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} /> <FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
</div> </div>
</div> </div>
<StatusContent <StatusContent
status={lastStatus} status={lastStatus}
parseClick={this.parseClick} parseClick={parseClick}
expanded={isExpanded} expanded={sharedCWState ? lastStatus.get('hidden') : expanded}
onExpandedToggle={this.handleShowMore} onExpandedToggle={handleShowMore}
collapsible collapsible
media={media} media={media}
/> />
<div className='status__action-bar'> <div className='status__action-bar'>
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={this.handleReply} /> <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={handleReply} />
<div className='status__action-bar-dropdown'> <div className='status__action-bar-dropdown'>
<DropdownMenuContainer <DropdownMenuContainer
@ -229,8 +255,11 @@ class Conversation extends ImmutablePureComponent {
</div> </div>
</HotKeys> </HotKeys>
); );
} };
}
export default withRouter(injectIntl(Conversation)); Conversation.propTypes = {
conversation: ImmutablePropTypes.map.isRequired,
scrollKey: PropTypes.string,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
};

@ -1,77 +1,72 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useRef, useMemo, useCallback } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import { useSelector, useDispatch } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import ScrollableList from '../../../components/scrollable_list'; import { expandConversations } from 'flavours/glitch/actions/conversations';
import ConversationContainer from '../containers/conversation_container'; import ScrollableList from 'flavours/glitch/components/scrollable_list';
export default class ConversationsList extends ImmutablePureComponent { import { Conversation } from './conversation';
static propTypes = { const focusChild = (node, index, alignTop) => {
conversations: ImmutablePropTypes.list.isRequired, const element = node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
scrollKey: PropTypes.string.isRequired,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
onLoadMore: PropTypes.func,
};
getCurrentIndex = id => this.props.conversations.findIndex(x => x.get('id') === id);
handleMoveUp = id => {
const elementIndex = this.getCurrentIndex(id) - 1;
this._selectChild(elementIndex, true);
};
handleMoveDown = id => {
const elementIndex = this.getCurrentIndex(id) + 1;
this._selectChild(elementIndex, false);
};
_selectChild (index, align_top) {
const container = this.node.node;
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) { if (element) {
if (align_top && container.scrollTop > element.offsetTop) { if (alignTop && node.scrollTop > element.offsetTop) {
element.scrollIntoView(true); element.scrollIntoView(true);
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { } else if (!alignTop && node.scrollTop + node.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false); element.scrollIntoView(false);
} }
element.focus(); element.focus();
} }
}
setRef = c => {
this.node = c;
}; };
handleLoadOlder = debounce(() => { export const ConversationsList = ({ scrollKey, ...other }) => {
const last = this.props.conversations.last(); const listRef = useRef();
const conversations = useSelector(state => state.getIn(['conversations', 'items']));
if (last && last.get('last_status')) { const isLoading = useSelector(state => state.getIn(['conversations', 'isLoading'], true));
this.props.onLoadMore(last.get('last_status')); const hasMore = useSelector(state => state.getIn(['conversations', 'hasMore'], false));
const dispatch = useDispatch();
const lastStatusId = conversations.last()?.get('last_status');
const handleMoveUp = useCallback(id => {
const elementIndex = conversations.findIndex(x => x.get('id') === id) - 1;
focusChild(listRef.current.node, elementIndex, true);
}, [listRef, conversations]);
const handleMoveDown = useCallback(id => {
const elementIndex = conversations.findIndex(x => x.get('id') === id) + 1;
focusChild(listRef.current.node, elementIndex, false);
}, [listRef, conversations]);
const debouncedLoadMore = useMemo(() => debounce(id => {
dispatch(expandConversations({ maxId: id }));
}, 300, { leading: true }), [dispatch]);
const handleLoadMore = useCallback(() => {
if (lastStatusId) {
debouncedLoadMore(lastStatusId);
} }
}, 300, { leading: true }); }, [debouncedLoadMore, lastStatusId]);
render () {
const { conversations, isLoading, onLoadMore, ...other } = this.props;
return ( return (
<ScrollableList {...other} isLoading={isLoading} showLoading={isLoading && conversations.isEmpty()} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}> <ScrollableList {...other} scrollKey={scrollKey} isLoading={isLoading} showLoading={isLoading && conversations.isEmpty()} hasMore={hasMore} onLoadMore={handleLoadMore} ref={listRef}>
{conversations.map(item => ( {conversations.map(item => (
<ConversationContainer <Conversation
key={item.get('id')} key={item.get('id')}
conversationId={item.get('id')} conversation={item}
onMoveUp={this.handleMoveUp} onMoveUp={handleMoveUp}
onMoveDown={this.handleMoveDown} onMoveDown={handleMoveDown}
scrollKey={this.props.scrollKey} scrollKey={scrollKey}
/> />
))} ))}
</ScrollableList> </ScrollableList>
); );
} };
} ConversationsList.propTypes = {
scrollKey: PropTypes.string.isRequired,
};

@ -1,81 +0,0 @@
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { replyCompose } from 'flavours/glitch/actions/compose';
import { markConversationRead, deleteConversation } from 'flavours/glitch/actions/conversations';
import { openModal } from 'flavours/glitch/actions/modal';
import { muteStatus, unmuteStatus, hideStatus, revealStatus } from 'flavours/glitch/actions/statuses';
import { makeGetStatus } from 'flavours/glitch/selectors';
import Conversation from '../components/conversation';
const messages = defineMessages({
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
});
const mapStateToProps = () => {
const getStatus = makeGetStatus();
return (state, { conversationId }) => {
const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
const lastStatusId = conversation.get('last_status', null);
return {
accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
unread: conversation.get('unread'),
lastStatus: lastStatusId && getStatus(state, { id: lastStatusId }),
settings: state.get('local_settings'),
};
};
};
const mapDispatchToProps = (dispatch, { intl, conversationId }) => ({
markRead () {
dispatch(markConversationRead(conversationId));
},
reply (status, router) {
dispatch((_, getState) => {
let state = getState();
if (state.getIn(['compose', 'text']).trim().length !== 0) {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),
onConfirm: () => dispatch(replyCompose(status, router)),
},
}));
} else {
dispatch(replyCompose(status, router));
}
});
},
delete () {
dispatch(deleteConversation(conversationId));
},
onMute (status) {
if (status.get('muted')) {
dispatch(unmuteStatus(status.get('id')));
} else {
dispatch(muteStatus(status.get('id')));
}
},
onToggleHidden (status) {
if (status.get('hidden')) {
dispatch(revealStatus(status.get('id')));
} else {
dispatch(hideStatus(status.get('id')));
}
},
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Conversation));

@ -1,16 +0,0 @@
import { connect } from 'react-redux';
import { expandConversations } from '../../../actions/conversations';
import ConversationsList from '../components/conversations_list';
const mapStateToProps = state => ({
conversations: state.getIn(['conversations', 'items']),
isLoading: state.getIn(['conversations', 'isLoading'], true),
hasMore: state.getIn(['conversations', 'hasMore'], false),
});
const mapDispatchToProps = dispatch => ({
onLoadMore: maxId => dispatch(expandConversations({ maxId })),
});
export default connect(mapStateToProps, mapDispatchToProps)(ConversationsList);

@ -1,12 +1,11 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { useRef, useCallback, useEffect } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { connect } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import MailIcon from '@/material-icons/400-24px/mail.svg?react'; import MailIcon from '@/material-icons/400-24px/mail.svg?react';
import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
@ -17,51 +16,44 @@ import Column from 'flavours/glitch/components/column';
import ColumnHeader from 'flavours/glitch/components/column_header'; import ColumnHeader from 'flavours/glitch/components/column_header';
import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
import { ConversationsList } from './components/conversations_list';
import ColumnSettingsContainer from './containers/column_settings_container'; import ColumnSettingsContainer from './containers/column_settings_container';
import ConversationsListContainer from './containers/conversations_list_container';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.direct', defaultMessage: 'Private mentions' }, title: { id: 'column.direct', defaultMessage: 'Private mentions' },
}); });
const mapStateToProps = state => ({ const DirectTimeline = ({ columnId, multiColumn }) => {
hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0, const columnRef = useRef();
conversationsMode: state.getIn(['settings', 'direct', 'conversations']), const intl = useIntl();
}); const dispatch = useDispatch();
const pinned = !!columnId;
class DirectTimeline extends PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string,
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool,
conversationsMode: PropTypes.bool,
};
handlePin = () => { // glitch-soc additions
const { columnId, dispatch } = this.props; const hasUnread = useSelector(state => state.getIn(['timelines', 'direct', 'unread']) > 0);
const conversationsMode = useSelector(state => state.getIn(['settings', 'direct', 'conversations']));
const handlePin = useCallback(() => {
if (columnId) { if (columnId) {
dispatch(removeColumn(columnId)); dispatch(removeColumn(columnId));
} else { } else {
dispatch(addColumn('DIRECT', {})); dispatch(addColumn('DIRECT', {}));
} }
}; }, [dispatch, columnId]);
handleMove = (dir) => { const handleMove = useCallback((dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir)); dispatch(moveColumn(columnId, dir));
}; }, [dispatch, columnId]);
handleHeaderClick = () => { const handleHeaderClick = useCallback(() => {
this.column.scrollTop(); columnRef.current.scrollTop();
}; }, [columnRef]);
componentDidMount () { const handleLoadMoreTimeline = useCallback(maxId => {
const { dispatch, conversationsMode } = this.props; dispatch(expandDirectTimeline({ maxId }));
}, [dispatch]);
useEffect(() => {
dispatch(mountConversations()); dispatch(mountConversations());
if (conversationsMode) { if (conversationsMode) {
@ -70,90 +62,55 @@ class DirectTimeline extends PureComponent {
dispatch(expandDirectTimeline()); dispatch(expandDirectTimeline());
} }
this.disconnect = dispatch(connectDirectStream()); const disconnect = dispatch(connectDirectStream());
}
componentDidUpdate(prevProps) {
const { dispatch, conversationsMode } = this.props;
if (prevProps.conversationsMode && !conversationsMode) {
dispatch(expandDirectTimeline());
} else if (!prevProps.conversationsMode && conversationsMode) {
dispatch(expandConversations());
}
}
componentWillUnmount () {
this.props.dispatch(unmountConversations());
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
}
setRef = c => {
this.column = c;
};
handleLoadMoreTimeline = maxId => {
this.props.dispatch(expandDirectTimeline({ maxId }));
};
handleLoadMoreConversations = maxId => { return () => {
this.props.dispatch(expandConversations({ maxId })); dispatch(unmountConversations());
disconnect();
}; };
}, [dispatch, conversationsMode]);
render () { return (
const { intl, hasUnread, columnId, multiColumn, conversationsMode } = this.props; <Column bindToDocument={!multiColumn} ref={columnRef} label={intl.formatMessage(messages.title)}>
const pinned = !!columnId; <ColumnHeader
icon='envelope'
iconComponent={MailIcon}
active={hasUnread}
title={intl.formatMessage(messages.title)}
onPin={handlePin}
onMove={handleMove}
onClick={handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<ColumnSettingsContainer />
</ColumnHeader>
let contents; {conversationsMode ? (
if (conversationsMode) { <ConversationsList
contents = (
<ConversationsListContainer
trackScroll={!pinned} trackScroll={!pinned}
scrollKey={`direct_timeline-${columnId}`} scrollKey={`direct_timeline-${columnId}`}
timelineId='direct' emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any private mentions yet. When you send or receive one, it will show up here." />}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
onLoadMore={this.handleLoadMore}
prepend={<div className='follow_requests-unlocked_explanation'><span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span></div>} prepend={<div className='follow_requests-unlocked_explanation'><span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span></div>}
alwaysPrepend alwaysPrepend
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any private mentions yet. When you send or receive one, it will show up here." />}
/> />
); ) : (
} else {
contents = (
<StatusListContainer <StatusListContainer
trackScroll={!pinned} trackScroll={!pinned}
scrollKey={`direct_timeline-${columnId}`} scrollKey={`direct_timeline-${columnId}`}
timelineId='direct' timelineId='direct'
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
onLoadMore={this.handleLoadMoreTimeline} onLoadMore={handleLoadMoreTimeline}
prepend={<div className='follow_requests-unlocked_explanation'><span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span></div>} prepend={
<div className='follow_requests-unlocked_explanation'>
<span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span>
</div>
}
alwaysPrepend alwaysPrepend
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any private mentions yet. When you send or receive one, it will show up here." />} emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any private mentions yet. When you send or receive one, it will show up here." />}
/> />
); )}
}
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='envelope'
iconComponent={MailIcon}
active={hasUnread}
title={intl.formatMessage(messages.title)}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<ColumnSettingsContainer />
</ColumnHeader>
{contents}
<Helmet> <Helmet>
<title>{intl.formatMessage(messages.title)}</title> <title>{intl.formatMessage(messages.title)}</title>
@ -161,8 +118,11 @@ class DirectTimeline extends PureComponent {
</Helmet> </Helmet>
</Column> </Column>
); );
} };
} DirectTimeline.propTypes = {
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
};
export default connect(mapStateToProps)(injectIntl(DirectTimeline)); export default DirectTimeline;

@ -16,8 +16,10 @@ import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react'; import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import StarBorderIcon from '@/material-icons/400-24px/star.svg?react'; import StarBorderIcon from '@/material-icons/400-24px/star.svg?react';
import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react';
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg'; import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg';
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg'; import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg';
import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions';
import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links'; import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
@ -241,7 +243,7 @@ class ActionBar extends PureComponent {
if (status.get('reblogged')) { if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private); reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
reblogIconComponent = publicStatus ? RepeatIcon : RepeatPrivateIcon; reblogIconComponent = publicStatus ? RepeatActiveIcon : RepeatPrivateActiveIcon;
} else if (publicStatus) { } else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog); reblogTitle = intl.formatMessage(messages.reblog);
reblogIconComponent = RepeatIcon; reblogIconComponent = RepeatIcon;

@ -32,11 +32,13 @@
"compose_form.spoiler": "إخفاء النص خلف تحذير", "compose_form.spoiler": "إخفاء النص خلف تحذير",
"confirmation_modal.do_not_ask_again": "لا تطلب التأكيد مرة أخرى", "confirmation_modal.do_not_ask_again": "لا تطلب التأكيد مرة أخرى",
"confirmations.deprecated_settings.confirm": "استخدام تفضيلات ماستدون", "confirmations.deprecated_settings.confirm": "استخدام تفضيلات ماستدون",
"confirmations.deprecated_settings.message": "تم استبدال بعض من الجهاز الخاص بالماستدون {preferences} الذي تستخدمه {app_settings} الخاص بجهاز ماستدون سيتم تجاوزه:",
"confirmations.missing_media_description.confirm": "أرسل على أيّة حال", "confirmations.missing_media_description.confirm": "أرسل على أيّة حال",
"confirmations.missing_media_description.edit": "تعديل الوسائط", "confirmations.missing_media_description.edit": "تعديل الوسائط",
"confirmations.unfilter.author": "المؤلف", "confirmations.unfilter.author": "المؤلف",
"confirmations.unfilter.confirm": "عرض", "confirmations.unfilter.confirm": "عرض",
"confirmations.unfilter.edit_filter": "تعديل عامل التصفية", "confirmations.unfilter.edit_filter": "تعديل عامل التصفية",
"confirmations.unfilter.filters": "مطابقة {count, plural, zero {}one {فلتر} two {فلاتر} few {فلاتر} many {فلاتر} other {فلاتر}}",
"content-type.change": "نوع المحتوى", "content-type.change": "نوع المحتوى",
"direct.group_by_conversations": "تجميع حسب المحادثة", "direct.group_by_conversations": "تجميع حسب المحادثة",
"endorsed_accounts_editor.endorsed_accounts": "الحسابات المميزة", "endorsed_accounts_editor.endorsed_accounts": "الحسابات المميزة",
@ -61,6 +63,10 @@
"notification_purge.start": "أدخل وضع تنظيف الإشعارات", "notification_purge.start": "أدخل وضع تنظيف الإشعارات",
"notifications.marked_clear": "مسح الإشعارات المحددة", "notifications.marked_clear": "مسح الإشعارات المحددة",
"notifications.marked_clear_confirmation": "هل أنت متأكد من أنك تريد مسح جميع الإشعارات المحددة نهائياً؟", "notifications.marked_clear_confirmation": "هل أنت متأكد من أنك تريد مسح جميع الإشعارات المحددة نهائياً؟",
"settings.always_show_spoilers_field": "تمكين دائما حقل تحذير المحتوى",
"settings.auto_collapse_height": "الارتفاع (بالبكسل) لاعتبار التبويق طويل",
"settings.auto_collapse_reblogs": "دفع",
"settings.auto_collapse_replies": "ردود {{count}}",
"settings.close": "إغلاق", "settings.close": "إغلاق",
"settings.content_warnings": "Content warnings", "settings.content_warnings": "Content warnings",
"settings.preferences": "Preferences" "settings.preferences": "Preferences"

@ -2,6 +2,7 @@
"about.fork_disclaimer": "Glitch-soc ist freie, quelloffene Software geforkt von Mastodon.", "about.fork_disclaimer": "Glitch-soc ist freie, quelloffene Software geforkt von Mastodon.",
"account.disclaimer_full": "Die folgenden Informationen könnten das Profil des Nutzers unvollständig wiedergeben.", "account.disclaimer_full": "Die folgenden Informationen könnten das Profil des Nutzers unvollständig wiedergeben.",
"account.follows": "Folgt", "account.follows": "Folgt",
"account.follows_you": "Folgt dir",
"account.joined": "Beigetreten am {date}", "account.joined": "Beigetreten am {date}",
"account.suspended_disclaimer_full": "Dieser Nutzer wurde durch einen Moderator gesperrt.", "account.suspended_disclaimer_full": "Dieser Nutzer wurde durch einen Moderator gesperrt.",
"account.view_full_profile": "Vollständiges Profil anzeigen", "account.view_full_profile": "Vollständiges Profil anzeigen",

@ -2,6 +2,7 @@
"about.fork_disclaimer": "Glitch-soc es software gratuito, de código abierto, bifurcado de Mastodon.", "about.fork_disclaimer": "Glitch-soc es software gratuito, de código abierto, bifurcado de Mastodon.",
"account.disclaimer_full": "La información aquí presentada puede reflejar de manera incompleta el perfil del usuario.", "account.disclaimer_full": "La información aquí presentada puede reflejar de manera incompleta el perfil del usuario.",
"account.follows": "Sigue", "account.follows": "Sigue",
"account.follows_you": "Te sigue",
"account.joined": "Unido el {date}", "account.joined": "Unido el {date}",
"account.suspended_disclaimer_full": "Este usuario ha sido suspendido por un moderador.", "account.suspended_disclaimer_full": "Este usuario ha sido suspendido por un moderador.",
"account.view_full_profile": "Ver perfil completo", "account.view_full_profile": "Ver perfil completo",

@ -2,6 +2,7 @@
"about.fork_disclaimer": "Glitch-soc es software gratuito, de código abierto, bifurcado de Mastodon.", "about.fork_disclaimer": "Glitch-soc es software gratuito, de código abierto, bifurcado de Mastodon.",
"account.disclaimer_full": "La información aquí presentada puede reflejar de manera incompleta el perfil del usuario.", "account.disclaimer_full": "La información aquí presentada puede reflejar de manera incompleta el perfil del usuario.",
"account.follows": "Seguir", "account.follows": "Seguir",
"account.follows_you": "Te sigue",
"account.joined": "Unido {date}", "account.joined": "Unido {date}",
"account.suspended_disclaimer_full": "Este usuario ha sido suspendido por un moderador.", "account.suspended_disclaimer_full": "Este usuario ha sido suspendido por un moderador.",
"account.view_full_profile": "Ver perfil completo", "account.view_full_profile": "Ver perfil completo",

@ -2,6 +2,7 @@
"about.fork_disclaimer": "Glitch-soc es software gratuito, de código abierto, bifurcado de Mastodon.", "about.fork_disclaimer": "Glitch-soc es software gratuito, de código abierto, bifurcado de Mastodon.",
"account.disclaimer_full": "La información que figura a continuación puede reflejar el perfil de la cuenta de forma incompleta.", "account.disclaimer_full": "La información que figura a continuación puede reflejar el perfil de la cuenta de forma incompleta.",
"account.follows": "Sigue", "account.follows": "Sigue",
"account.follows_you": "Te sigue",
"account.joined": "Se unió el {date}", "account.joined": "Se unió el {date}",
"account.suspended_disclaimer_full": "Este usuario ha sido suspendido por un moderador.", "account.suspended_disclaimer_full": "Este usuario ha sido suspendido por un moderador.",
"account.view_full_profile": "Ver perfil completo", "account.view_full_profile": "Ver perfil completo",

@ -2,6 +2,7 @@
"about.fork_disclaimer": "Glitch-soc est un logiciel gratuit et open source, fork de Mastodon.", "about.fork_disclaimer": "Glitch-soc est un logiciel gratuit et open source, fork de Mastodon.",
"account.disclaimer_full": "Les informations ci-dessous peuvent être incomplètes.", "account.disclaimer_full": "Les informations ci-dessous peuvent être incomplètes.",
"account.follows": "Abonnements", "account.follows": "Abonnements",
"account.follows_you": "Vous suit",
"account.joined": "Ici depuis {date}", "account.joined": "Ici depuis {date}",
"account.suspended_disclaimer_full": "Cet utilisateur a été suspendu par un modérateur.", "account.suspended_disclaimer_full": "Cet utilisateur a été suspendu par un modérateur.",
"account.view_full_profile": "Voir le profil complet", "account.view_full_profile": "Voir le profil complet",

@ -2,6 +2,7 @@
"about.fork_disclaimer": "Glitch-soc est un logiciel gratuit et open source, fork de Mastodon.", "about.fork_disclaimer": "Glitch-soc est un logiciel gratuit et open source, fork de Mastodon.",
"account.disclaimer_full": "Les informations ci-dessous peuvent être incomplètes.", "account.disclaimer_full": "Les informations ci-dessous peuvent être incomplètes.",
"account.follows": "Abonnements", "account.follows": "Abonnements",
"account.follows_you": "Vous suit",
"account.joined": "Ici depuis {date}", "account.joined": "Ici depuis {date}",
"account.suspended_disclaimer_full": "Cet utilisateur a été suspendu par un modérateur.", "account.suspended_disclaimer_full": "Cet utilisateur a été suspendu par un modérateur.",
"account.view_full_profile": "Voir le profil complet", "account.view_full_profile": "Voir le profil complet",

@ -2,6 +2,7 @@
"about.fork_disclaimer": "글리치는 마스토돈에서 포크한 자유 오픈소스 소프트웨어입니다.", "about.fork_disclaimer": "글리치는 마스토돈에서 포크한 자유 오픈소스 소프트웨어입니다.",
"account.disclaimer_full": "아래에 있는 정보들은 사용자의 프로필을 완벽하게 나타내지 못하고 있을 수도 있습니다.", "account.disclaimer_full": "아래에 있는 정보들은 사용자의 프로필을 완벽하게 나타내지 못하고 있을 수도 있습니다.",
"account.follows": "팔로우", "account.follows": "팔로우",
"account.follows_you": "날 팔로우합니다",
"account.joined": "{date}에 가입함", "account.joined": "{date}에 가입함",
"account.suspended_disclaimer_full": "이 사용자는 중재자에 의해 정지되었습니다.", "account.suspended_disclaimer_full": "이 사용자는 중재자에 의해 정지되었습니다.",
"account.view_full_profile": "전체 프로필 보기", "account.view_full_profile": "전체 프로필 보기",
@ -44,6 +45,7 @@
"direct.group_by_conversations": "대화별로 묶기", "direct.group_by_conversations": "대화별로 묶기",
"endorsed_accounts_editor.endorsed_accounts": "추천하는 계정들", "endorsed_accounts_editor.endorsed_accounts": "추천하는 계정들",
"favourite_modal.combo": "다음엔 {combo}를 눌러 건너뛸 수 있습니다", "favourite_modal.combo": "다음엔 {combo}를 눌러 건너뛸 수 있습니다",
"firehose.column_settings.allow_local_only": "\"모두\" 탭에서 로컬 전용 글 보여주기",
"home.column_settings.advanced": "고급", "home.column_settings.advanced": "고급",
"home.column_settings.filter_regex": "정규표현식으로 필터", "home.column_settings.filter_regex": "정규표현식으로 필터",
"home.column_settings.show_direct": "DM 보여주기", "home.column_settings.show_direct": "DM 보여주기",

@ -1,4 +1 @@
{ {}
"settings.content_warnings": "Content warnings",
"settings.preferences": "Preferences"
}

@ -1,7 +1,8 @@
{ {
"about.fork_disclaimer": "Glitch-soc是从Mastodon派生的自由开源软件。", "about.fork_disclaimer": "Glitch-soc是从Mastodon生成的免费开源软件。",
"account.disclaimer_full": "以下信息可能无法完整代表你的个人资料。", "account.disclaimer_full": "下面的信息可能不完全反映用户的个人资料。",
"account.follows": "正在关注", "account.follows": "正在关注",
"account.follows_you": "关注了你",
"account.joined": "加入于 {date}", "account.joined": "加入于 {date}",
"account.suspended_disclaimer_full": "该用户已被管理员封禁。", "account.suspended_disclaimer_full": "该用户已被管理员封禁。",
"account.view_full_profile": "查看完整资料", "account.view_full_profile": "查看完整资料",

@ -2,6 +2,7 @@
"about.fork_disclaimer": "Glitch-soc 是從 Mastodon 分支出來的自由開源軟體。", "about.fork_disclaimer": "Glitch-soc 是從 Mastodon 分支出來的自由開源軟體。",
"account.disclaimer_full": "下面的資訊可能不完全反映使用者的個人資料。", "account.disclaimer_full": "下面的資訊可能不完全反映使用者的個人資料。",
"account.follows": "跟隨", "account.follows": "跟隨",
"account.follows_you": "跟隨了您",
"account.joined": "加入於 {date}", "account.joined": "加入於 {date}",
"account.suspended_disclaimer_full": "使用者已被管理者停權。", "account.suspended_disclaimer_full": "使用者已被管理者停權。",
"account.view_full_profile": "查看完整個人資料", "account.view_full_profile": "查看完整個人資料",

@ -1,12 +1,11 @@
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import type { TypedUseSelectorHook } from 'react-redux';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports // eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import type { AppDispatch, RootState } from './store'; import type { AppDispatch, RootState } from './store';
export const useAppDispatch: () => AppDispatch = useDispatch; export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector; export const useAppSelector = useSelector.withTypes<RootState>();
export const createAppAsyncThunk = createAsyncThunk.withTypes<{ export const createAppAsyncThunk = createAsyncThunk.withTypes<{
state: RootState; state: RootState;

@ -107,3 +107,59 @@
margin-inline-start: 10px; margin-inline-start: 10px;
} }
} }
.redirect {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 14px;
line-height: 18px;
&__logo {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 30px;
img {
height: 48px;
}
}
&__message {
text-align: center;
h1 {
font-size: 17px;
line-height: 22px;
font-weight: 700;
margin-bottom: 30px;
}
p {
margin-bottom: 30px;
&:last-child {
margin-bottom: 0;
}
}
a {
color: $highlight-text-color;
font-weight: 500;
text-decoration: none;
&:hover,
&:focus,
&:active {
text-decoration: underline;
}
}
}
&__link {
margin-top: 15px;
}
}

@ -179,6 +179,11 @@ export const openURL = (value, history, onFailure) => (dispatch, getState) => {
export const clickSearchResult = (q, type) => (dispatch, getState) => { export const clickSearchResult = (q, type) => (dispatch, getState) => {
const previous = getState().getIn(['search', 'recent']); 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 me = getState().getIn(['meta', 'me']);
const current = previous.add(fromJS({ type, q })).takeLast(4); const current = previous.add(fromJS({ type, q })).takeLast(4);

@ -18,8 +18,10 @@ import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import StarBorderIcon from '@/material-icons/400-24px/star.svg?react'; import StarBorderIcon from '@/material-icons/400-24px/star.svg?react';
import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react'; import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react';
import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react';
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react'; import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react'; import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { WithRouterPropTypes } from 'mastodon/utils/react_router';
@ -366,7 +368,7 @@ class StatusActionBar extends ImmutablePureComponent {
if (status.get('reblogged')) { if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private); reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
reblogIconComponent = publicStatus ? RepeatIcon : RepeatPrivateIcon; reblogIconComponent = publicStatus ? RepeatActiveIcon : RepeatPrivateActiveIcon;
} else if (publicStatus) { } else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog); reblogTitle = intl.formatMessage(messages.reblog);
reblogIconComponent = RepeatIcon; reblogIconComponent = RepeatIcon;

@ -62,14 +62,14 @@ class Search extends PureComponent {
}; };
defaultOptions = [ defaultOptions = [
{ label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:'); } }, { key: 'prompt-has', label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:'); } },
{ label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:'); } }, { key: 'prompt-is', label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:'); } },
{ label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:'); } }, { key: 'prompt-language', label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:'); } },
{ label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:'); } }, { key: 'prompt-from', label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:'); } },
{ label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:'); } }, { key: 'prompt-before', label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:'); } },
{ label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:'); } }, { key: 'prompt-during', label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:'); } },
{ label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:'); } }, { key: 'prompt-after', label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:'); } },
{ label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library', 'public']} /></>, action: e => { e.preventDefault(); this._insertText('in:'); } } { key: 'prompt-in', label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library', 'public']} /></>, action: e => { e.preventDefault(); this._insertText('in:'); } }
]; ];
setRef = c => { setRef = c => {
@ -262,6 +262,8 @@ class Search extends PureComponent {
const { recent } = this.props; const { recent } = this.props;
return recent.toArray().map(search => ({ return recent.toArray().map(search => ({
key: `${search.get('type')}/${search.get('q')}`,
label: labelForRecentSearch(search), label: labelForRecentSearch(search),
action: () => this.handleRecentSearchClick(search), action: () => this.handleRecentSearchClick(search),
@ -346,8 +348,8 @@ class Search extends PureComponent {
<h4><FormattedMessage id='search_popout.recent' defaultMessage='Recent searches' /></h4> <h4><FormattedMessage id='search_popout.recent' defaultMessage='Recent searches' /></h4>
<div className='search__popout__menu'> <div className='search__popout__menu'>
{recent.size > 0 ? this._getOptions().map(({ label, action, forget }, i) => ( {recent.size > 0 ? this._getOptions().map(({ label, key, action, forget }, i) => (
<button key={label} onMouseDown={action} className={classNames('search__popout__menu__item search__popout__menu__item--flex', { selected: selectedOption === i })}> <button key={key} onMouseDown={action} className={classNames('search__popout__menu__item search__popout__menu__item--flex', { selected: selectedOption === i })}>
<span>{label}</span> <span>{label}</span>
<button className='icon-button' onMouseDown={forget}><Icon id='times' icon={CloseIcon} /></button> <button className='icon-button' onMouseDown={forget}><Icon id='times' icon={CloseIcon} /></button>
</button> </button>

@ -1,3 +1,4 @@
import { createSelector } from '@reduxjs/toolkit';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { import {
@ -12,10 +13,15 @@ import {
import Search from '../components/search'; import Search from '../components/search';
const getRecentSearches = createSelector(
state => state.getIn(['search', 'recent']),
recent => recent.reverse(),
);
const mapStateToProps = state => ({ const mapStateToProps = state => ({
value: state.getIn(['search', 'value']), value: state.getIn(['search', 'value']),
submitted: state.getIn(['search', 'submitted']), submitted: state.getIn(['search', 'submitted']),
recent: state.getIn(['search', 'recent']).reverse(), recent: getRecentSearches(state),
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({

@ -1,17 +1,24 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useCallback } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { Link, withRouter } from 'react-router-dom'; import { Link, useHistory } from 'react-router-dom';
import { createSelector } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import { useDispatch, useSelector } from 'react-redux';
import { HotKeys } from 'react-hotkeys'; import { HotKeys } from 'react-hotkeys';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import { replyCompose } from 'mastodon/actions/compose';
import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
import { openModal } from 'mastodon/actions/modal';
import { muteStatus, unmuteStatus, revealStatus, hideStatus } from 'mastodon/actions/statuses';
import AttachmentList from 'mastodon/components/attachment_list'; import AttachmentList from 'mastodon/components/attachment_list';
import AvatarComposite from 'mastodon/components/avatar_composite'; import AvatarComposite from 'mastodon/components/avatar_composite';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
@ -19,7 +26,7 @@ import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import StatusContent from 'mastodon/components/status_content'; import StatusContent from 'mastodon/components/status_content';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { autoPlayGif } from 'mastodon/initial_state'; import { autoPlayGif } from 'mastodon/initial_state';
import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { makeGetStatus } from 'mastodon/selectors';
const messages = defineMessages({ const messages = defineMessages({
more: { id: 'status.more', defaultMessage: 'More' }, more: { id: 'status.more', defaultMessage: 'More' },
@ -29,25 +36,31 @@ const messages = defineMessages({
delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' }, delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
}); });
class Conversation extends ImmutablePureComponent { const getAccounts = createSelector(
(state) => state.get('accounts'),
(_, accountIds) => accountIds,
(accounts, accountIds) =>
accountIds.map(id => accounts.get(id))
);
static propTypes = { const getStatus = makeGetStatus();
conversationId: PropTypes.string.isRequired,
accounts: ImmutablePropTypes.list.isRequired, export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) => {
lastStatus: ImmutablePropTypes.map, const id = conversation.get('id');
unread:PropTypes.bool.isRequired, const unread = conversation.get('unread');
scrollKey: PropTypes.string, const lastStatusId = conversation.get('last_status');
onMoveUp: PropTypes.func, const accountIds = conversation.get('accounts');
onMoveDown: PropTypes.func, const intl = useIntl();
markRead: PropTypes.func.isRequired, const dispatch = useDispatch();
delete: PropTypes.func.isRequired, const history = useHistory();
intl: PropTypes.object.isRequired, const lastStatus = useSelector(state => getStatus(state, { id: lastStatusId }));
...WithRouterPropTypes, const accounts = useSelector(state => getAccounts(state, accountIds));
};
handleMouseEnter = ({ currentTarget }) => { const handleMouseEnter = useCallback(({ currentTarget }) => {
if (autoPlayGif) { if (autoPlayGif) {
return; return;
} }
@ -58,9 +71,9 @@ class Conversation extends ImmutablePureComponent {
let emoji = emojis[i]; let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-original'); emoji.src = emoji.getAttribute('data-original');
} }
}; }, []);
handleMouseLeave = ({ currentTarget }) => { const handleMouseLeave = useCallback(({ currentTarget }) => {
if (autoPlayGif) { if (autoPlayGif) {
return; return;
} }
@ -71,85 +84,107 @@ class Conversation extends ImmutablePureComponent {
let emoji = emojis[i]; let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-static'); emoji.src = emoji.getAttribute('data-static');
} }
}; }, []);
handleClick = () => {
if (!this.props.history) {
return;
}
const { lastStatus, unread, markRead } = this.props;
const handleClick = useCallback(() => {
if (unread) { if (unread) {
markRead(); dispatch(markConversationRead(id));
} }
this.props.history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`); history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`);
}; }, [dispatch, history, unread, id, lastStatus]);
handleMarkAsRead = () => { const handleMarkAsRead = useCallback(() => {
this.props.markRead(); dispatch(markConversationRead(id));
}; }, [dispatch, id]);
handleReply = () => { const handleReply = useCallback(() => {
this.props.reply(this.props.lastStatus, this.props.history); dispatch((_, getState) => {
}; let state = getState();
handleDelete = () => { if (state.getIn(['compose', 'text']).trim().length !== 0) {
this.props.delete(); dispatch(openModal({
}; modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),
onConfirm: () => dispatch(replyCompose(lastStatus, history)),
},
}));
} else {
dispatch(replyCompose(lastStatus, history));
}
});
}, [dispatch, lastStatus, history, intl]);
handleHotkeyMoveUp = () => { const handleDelete = useCallback(() => {
this.props.onMoveUp(this.props.conversationId); dispatch(deleteConversation(id));
}; }, [dispatch, id]);
handleHotkeyMoveDown = () => { const handleHotkeyMoveUp = useCallback(() => {
this.props.onMoveDown(this.props.conversationId); onMoveUp(id);
}; }, [id, onMoveUp]);
handleConversationMute = () => { const handleHotkeyMoveDown = useCallback(() => {
this.props.onMute(this.props.lastStatus); onMoveDown(id);
}; }, [id, onMoveDown]);
handleShowMore = () => { const handleConversationMute = useCallback(() => {
this.props.onToggleHidden(this.props.lastStatus); if (lastStatus.get('muted')) {
}; dispatch(unmuteStatus(lastStatus.get('id')));
} else {
dispatch(muteStatus(lastStatus.get('id')));
}
}, [dispatch, lastStatus]);
render () { const handleShowMore = useCallback(() => {
const { accounts, lastStatus, unread, scrollKey, intl } = this.props; if (lastStatus.get('hidden')) {
dispatch(revealStatus(lastStatus.get('id')));
} else {
dispatch(hideStatus(lastStatus.get('id')));
}
}, [dispatch, lastStatus]);
if (lastStatus === null) { if (!lastStatus) {
return null; return null;
} }
const menu = [ const menu = [
{ text: intl.formatMessage(messages.open), action: this.handleClick }, { text: intl.formatMessage(messages.open), action: handleClick },
null, null,
{ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: handleConversationMute },
]; ];
menu.push({ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMute });
if (unread) { if (unread) {
menu.push({ text: intl.formatMessage(messages.markAsRead), action: this.handleMarkAsRead }); menu.push({ text: intl.formatMessage(messages.markAsRead), action: handleMarkAsRead });
menu.push(null); menu.push(null);
} }
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete }); menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete });
const names = accounts.map(a => <Link to={`/@${a.get('acct')}`} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Link>).reduce((prev, cur) => [prev, ', ', cur]); const names = accounts.map(a => (
<Link to={`/@${a.get('acct')}`} key={a.get('id')} title={a.get('acct')}>
<bdi>
<strong
className='display-name__html'
dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }}
/>
</bdi>
</Link>
)).reduce((prev, cur) => [prev, ', ', cur]);
const handlers = { const handlers = {
reply: this.handleReply, reply: handleReply,
open: this.handleClick, open: handleClick,
moveUp: this.handleHotkeyMoveUp, moveUp: handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown, moveDown: handleHotkeyMoveDown,
toggleHidden: this.handleShowMore, toggleHidden: handleShowMore,
}; };
return ( return (
<HotKeys handlers={handlers}> <HotKeys handlers={handlers}>
<div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex={0}> <div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex={0}>
<div className='conversation__avatar' onClick={this.handleClick} role='presentation'> <div className='conversation__avatar' onClick={handleClick} role='presentation'>
<AvatarComposite accounts={accounts} size={48} /> <AvatarComposite accounts={accounts} size={48} />
</div> </div>
@ -159,16 +194,16 @@ class Conversation extends ImmutablePureComponent {
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} /> {unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
</div> </div>
<div className='conversation__content__names' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <div className='conversation__content__names' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} /> <FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
</div> </div>
</div> </div>
<StatusContent <StatusContent
status={lastStatus} status={lastStatus}
onClick={this.handleClick} onClick={handleClick}
expanded={!lastStatus.get('hidden')} expanded={!lastStatus.get('hidden')}
onExpandedToggle={this.handleShowMore} onExpandedToggle={handleShowMore}
collapsible collapsible
/> />
@ -180,7 +215,7 @@ class Conversation extends ImmutablePureComponent {
)} )}
<div className='status__action-bar'> <div className='status__action-bar'>
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={this.handleReply} /> <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={handleReply} />
<div className='status__action-bar-dropdown'> <div className='status__action-bar-dropdown'>
<DropdownMenuContainer <DropdownMenuContainer
@ -199,8 +234,11 @@ class Conversation extends ImmutablePureComponent {
</div> </div>
</HotKeys> </HotKeys>
); );
} };
}
export default withRouter(injectIntl(Conversation)); Conversation.propTypes = {
conversation: ImmutablePropTypes.map.isRequired,
scrollKey: PropTypes.string,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
};

@ -1,77 +1,72 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useRef, useMemo, useCallback } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import { useSelector, useDispatch } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import ScrollableList from '../../../components/scrollable_list'; import { expandConversations } from 'mastodon/actions/conversations';
import ConversationContainer from '../containers/conversation_container'; import ScrollableList from 'mastodon/components/scrollable_list';
export default class ConversationsList extends ImmutablePureComponent { import { Conversation } from './conversation';
static propTypes = { const focusChild = (node, index, alignTop) => {
conversations: ImmutablePropTypes.list.isRequired, const element = node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
scrollKey: PropTypes.string.isRequired,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
onLoadMore: PropTypes.func,
};
getCurrentIndex = id => this.props.conversations.findIndex(x => x.get('id') === id);
handleMoveUp = id => {
const elementIndex = this.getCurrentIndex(id) - 1;
this._selectChild(elementIndex, true);
};
handleMoveDown = id => {
const elementIndex = this.getCurrentIndex(id) + 1;
this._selectChild(elementIndex, false);
};
_selectChild (index, align_top) {
const container = this.node.node;
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) { if (element) {
if (align_top && container.scrollTop > element.offsetTop) { if (alignTop && node.scrollTop > element.offsetTop) {
element.scrollIntoView(true); element.scrollIntoView(true);
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { } else if (!alignTop && node.scrollTop + node.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false); element.scrollIntoView(false);
} }
element.focus(); element.focus();
} }
}
setRef = c => {
this.node = c;
}; };
handleLoadOlder = debounce(() => { export const ConversationsList = ({ scrollKey, ...other }) => {
const last = this.props.conversations.last(); const listRef = useRef();
const conversations = useSelector(state => state.getIn(['conversations', 'items']));
if (last && last.get('last_status')) { const isLoading = useSelector(state => state.getIn(['conversations', 'isLoading'], true));
this.props.onLoadMore(last.get('last_status')); const hasMore = useSelector(state => state.getIn(['conversations', 'hasMore'], false));
const dispatch = useDispatch();
const lastStatusId = conversations.last()?.get('last_status');
const handleMoveUp = useCallback(id => {
const elementIndex = conversations.findIndex(x => x.get('id') === id) - 1;
focusChild(listRef.current.node, elementIndex, true);
}, [listRef, conversations]);
const handleMoveDown = useCallback(id => {
const elementIndex = conversations.findIndex(x => x.get('id') === id) + 1;
focusChild(listRef.current.node, elementIndex, false);
}, [listRef, conversations]);
const debouncedLoadMore = useMemo(() => debounce(id => {
dispatch(expandConversations({ maxId: id }));
}, 300, { leading: true }), [dispatch]);
const handleLoadMore = useCallback(() => {
if (lastStatusId) {
debouncedLoadMore(lastStatusId);
} }
}, 300, { leading: true }); }, [debouncedLoadMore, lastStatusId]);
render () {
const { conversations, isLoading, onLoadMore, ...other } = this.props;
return ( return (
<ScrollableList {...other} isLoading={isLoading} showLoading={isLoading && conversations.isEmpty()} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}> <ScrollableList {...other} scrollKey={scrollKey} isLoading={isLoading} showLoading={isLoading && conversations.isEmpty()} hasMore={hasMore} onLoadMore={handleLoadMore} ref={listRef}>
{conversations.map(item => ( {conversations.map(item => (
<ConversationContainer <Conversation
key={item.get('id')} key={item.get('id')}
conversationId={item.get('id')} conversation={item}
onMoveUp={this.handleMoveUp} onMoveUp={handleMoveUp}
onMoveDown={this.handleMoveDown} onMoveDown={handleMoveDown}
scrollKey={this.props.scrollKey} scrollKey={scrollKey}
/> />
))} ))}
</ScrollableList> </ScrollableList>
); );
} };
} ConversationsList.propTypes = {
scrollKey: PropTypes.string.isRequired,
};

@ -1,80 +0,0 @@
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { replyCompose } from 'mastodon/actions/compose';
import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
import { openModal } from 'mastodon/actions/modal';
import { muteStatus, unmuteStatus, hideStatus, revealStatus } from 'mastodon/actions/statuses';
import { makeGetStatus } from 'mastodon/selectors';
import Conversation from '../components/conversation';
const messages = defineMessages({
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
});
const mapStateToProps = () => {
const getStatus = makeGetStatus();
return (state, { conversationId }) => {
const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
const lastStatusId = conversation.get('last_status', null);
return {
accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
unread: conversation.get('unread'),
lastStatus: lastStatusId && getStatus(state, { id: lastStatusId }),
};
};
};
const mapDispatchToProps = (dispatch, { intl, conversationId }) => ({
markRead () {
dispatch(markConversationRead(conversationId));
},
reply (status, router) {
dispatch((_, getState) => {
let state = getState();
if (state.getIn(['compose', 'text']).trim().length !== 0) {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),
onConfirm: () => dispatch(replyCompose(status, router)),
},
}));
} else {
dispatch(replyCompose(status, router));
}
});
},
delete () {
dispatch(deleteConversation(conversationId));
},
onMute (status) {
if (status.get('muted')) {
dispatch(unmuteStatus(status.get('id')));
} else {
dispatch(muteStatus(status.get('id')));
}
},
onToggleHidden (status) {
if (status.get('hidden')) {
dispatch(revealStatus(status.get('id')));
} else {
dispatch(hideStatus(status.get('id')));
}
},
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Conversation));

@ -1,16 +0,0 @@
import { connect } from 'react-redux';
import { expandConversations } from '../../../actions/conversations';
import ConversationsList from '../components/conversations_list';
const mapStateToProps = state => ({
conversations: state.getIn(['conversations', 'items']),
isLoading: state.getIn(['conversations', 'isLoading'], true),
hasMore: state.getIn(['conversations', 'hasMore'], false),
});
const mapDispatchToProps = dispatch => ({
onLoadMore: maxId => dispatch(expandConversations({ maxId })),
});
export default connect(mapStateToProps, mapDispatchToProps)(ConversationsList);

@ -1,11 +1,11 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { useRef, useCallback, useEffect } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { connect } from 'react-redux'; import { useDispatch } from 'react-redux';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
@ -14,93 +14,66 @@ import { connectDirectStream } from 'mastodon/actions/streaming';
import Column from 'mastodon/components/column'; import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header'; import ColumnHeader from 'mastodon/components/column_header';
import ConversationsListContainer from './containers/conversations_list_container'; import { ConversationsList } from './components/conversations_list';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.direct', defaultMessage: 'Private mentions' }, title: { id: 'column.direct', defaultMessage: 'Private mentions' },
}); });
class DirectTimeline extends PureComponent { const DirectTimeline = ({ columnId, multiColumn }) => {
const columnRef = useRef();
static propTypes = { const intl = useIntl();
dispatch: PropTypes.func.isRequired, const dispatch = useDispatch();
columnId: PropTypes.string, const pinned = !!columnId;
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool,
};
handlePin = () => {
const { columnId, dispatch } = this.props;
const handlePin = useCallback(() => {
if (columnId) { if (columnId) {
dispatch(removeColumn(columnId)); dispatch(removeColumn(columnId));
} else { } else {
dispatch(addColumn('DIRECT', {})); dispatch(addColumn('DIRECT', {}));
} }
}; }, [dispatch, columnId]);
handleMove = (dir) => { const handleMove = useCallback((dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir)); dispatch(moveColumn(columnId, dir));
}; }, [dispatch, columnId]);
handleHeaderClick = () => {
this.column.scrollTop();
};
componentDidMount () { const handleHeaderClick = useCallback(() => {
const { dispatch } = this.props; columnRef.current.scrollTop();
}, [columnRef]);
useEffect(() => {
dispatch(mountConversations()); dispatch(mountConversations());
dispatch(expandConversations()); dispatch(expandConversations());
this.disconnect = dispatch(connectDirectStream());
}
componentWillUnmount () {
this.props.dispatch(unmountConversations());
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
}
setRef = c => { const disconnect = dispatch(connectDirectStream());
this.column = c;
};
handleLoadMore = maxId => { return () => {
this.props.dispatch(expandConversations({ maxId })); dispatch(unmountConversations());
disconnect();
}; };
}, [dispatch]);
render () {
const { intl, hasUnread, columnId, multiColumn } = this.props;
const pinned = !!columnId;
return ( return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> <Column bindToDocument={!multiColumn} ref={columnRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader <ColumnHeader
icon='at' icon='at'
iconComponent={AlternateEmailIcon} iconComponent={AlternateEmailIcon}
active={hasUnread}
title={intl.formatMessage(messages.title)} title={intl.formatMessage(messages.title)}
onPin={this.handlePin} onPin={handlePin}
onMove={this.handleMove} onMove={handleMove}
onClick={this.handleHeaderClick} onClick={handleHeaderClick}
pinned={pinned} pinned={pinned}
multiColumn={multiColumn} multiColumn={multiColumn}
/> />
<ConversationsListContainer <ConversationsList
trackScroll={!pinned} trackScroll={!pinned}
scrollKey={`direct_timeline-${columnId}`} scrollKey={`direct_timeline-${columnId}`}
timelineId='direct' emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any private mentions yet. When you send or receive one, it will show up here." />}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
onLoadMore={this.handleLoadMore}
prepend={<div className='follow_requests-unlocked_explanation'><span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span></div>} prepend={<div className='follow_requests-unlocked_explanation'><span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span></div>}
alwaysPrepend alwaysPrepend
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any private mentions yet. When you send or receive one, it will show up here." />}
/> />
<Helmet> <Helmet>
@ -109,8 +82,11 @@ class DirectTimeline extends PureComponent {
</Helmet> </Helmet>
</Column> </Column>
); );
} };
} DirectTimeline.propTypes = {
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
};
export default connect()(injectIntl(DirectTimeline)); export default DirectTimeline;

@ -17,8 +17,10 @@ import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react'; import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import StarBorderIcon from '@/material-icons/400-24px/star.svg?react'; import StarBorderIcon from '@/material-icons/400-24px/star.svg?react';
import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react';
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react'; import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react'; import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { WithRouterPropTypes } from 'mastodon/utils/react_router';
@ -296,7 +298,7 @@ class ActionBar extends PureComponent {
if (status.get('reblogged')) { if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private); reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
reblogIconComponent = publicStatus ? RepeatIcon : RepeatPrivateIcon; reblogIconComponent = publicStatus ? RepeatActiveIcon : RepeatPrivateActiveIcon;
} else if (publicStatus) { } else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog); reblogTitle = intl.formatMessage(messages.reblog);
reblogIconComponent = RepeatIcon; reblogIconComponent = RepeatIcon;

@ -3,6 +3,7 @@
"about.contact": "Kontak:", "about.contact": "Kontak:",
"about.disclaimer": "Mastodon is gratis oopbronsagteware en n handelsmerk van Mastodon gGmbH.", "about.disclaimer": "Mastodon is gratis oopbronsagteware en n handelsmerk van Mastodon gGmbH.",
"about.domain_blocks.no_reason_available": "Rede nie beskikbaar nie", "about.domain_blocks.no_reason_available": "Rede nie beskikbaar nie",
"about.domain_blocks.preamble": "Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.",
"about.domain_blocks.silenced.title": "Beperk", "about.domain_blocks.silenced.title": "Beperk",
"about.domain_blocks.suspended.title": "Opgeskort", "about.domain_blocks.suspended.title": "Opgeskort",
"about.not_available": "Hierdie inligting is nie op hierdie bediener beskikbaar gestel nie.", "about.not_available": "Hierdie inligting is nie op hierdie bediener beskikbaar gestel nie.",

@ -116,7 +116,6 @@
"compose_form.publish_form": "Artículu nuevu", "compose_form.publish_form": "Artículu nuevu",
"compose_form.publish_loud": "¡{publish}!", "compose_form.publish_loud": "¡{publish}!",
"compose_form.save_changes": "Guardar los cambeos", "compose_form.save_changes": "Guardar los cambeos",
"compose_form.spoiler.unmarked": "Text is not hidden",
"confirmation_modal.cancel": "Encaboxar", "confirmation_modal.cancel": "Encaboxar",
"confirmations.block.block_and_report": "Bloquiar ya informar", "confirmations.block.block_and_report": "Bloquiar ya informar",
"confirmations.block.confirm": "Bloquiar", "confirmations.block.confirm": "Bloquiar",
@ -146,6 +145,7 @@
"dismissable_banner.community_timeline": "Esta seición contién los artículos públicos más actuales de los perfiles agospiaos nel dominiu {domain}.", "dismissable_banner.community_timeline": "Esta seición contién los artículos públicos más actuales de los perfiles agospiaos nel dominiu {domain}.",
"dismissable_banner.dismiss": "Escartar", "dismissable_banner.dismiss": "Escartar",
"dismissable_banner.explore_tags": "Esta seición contién les etiquetes del fediversu que tán ganando popularidá güei. Les etiquetes más usaes polos perfiles apaecen no cimero.", "dismissable_banner.explore_tags": "Esta seición contién les etiquetes del fediversu que tán ganando popularidá güei. Les etiquetes más usaes polos perfiles apaecen no cimero.",
"dismissable_banner.public_timeline": "Esta seición contién los artículos más nuevos de les persones na web social que les persones de {domain} siguen.",
"embed.instructions": "Empotra esti artículu nel to sitiu web pente la copia del códigu d'abaxo.", "embed.instructions": "Empotra esti artículu nel to sitiu web pente la copia del códigu d'abaxo.",
"embed.preview": "Va apaecer asina:", "embed.preview": "Va apaecer asina:",
"emoji_button.activity": "Actividá", "emoji_button.activity": "Actividá",
@ -155,6 +155,7 @@
"emoji_button.not_found": "Nun s'atoparon fustaxes que concasen", "emoji_button.not_found": "Nun s'atoparon fustaxes que concasen",
"emoji_button.objects": "Oxetos", "emoji_button.objects": "Oxetos",
"emoji_button.people": "Persones", "emoji_button.people": "Persones",
"emoji_button.recent": "D'usu frecuente",
"emoji_button.search": "Buscar…", "emoji_button.search": "Buscar…",
"emoji_button.search_results": "Resultaos de la busca", "emoji_button.search_results": "Resultaos de la busca",
"emoji_button.symbols": "Símbolos", "emoji_button.symbols": "Símbolos",
@ -217,7 +218,6 @@
"hashtag.column_header.tag_mode.any": "o {additional}", "hashtag.column_header.tag_mode.any": "o {additional}",
"hashtag.column_header.tag_mode.none": "ensin {additional}", "hashtag.column_header.tag_mode.none": "ensin {additional}",
"hashtag.column_settings.select.no_options_message": "Nun s'atopó nenguna suxerencia", "hashtag.column_settings.select.no_options_message": "Nun s'atopó nenguna suxerencia",
"hashtag.column_settings.tag_toggle": "Include additional tags in this column",
"hashtag.counter_by_accounts": "{count, plural, one {{counter} participante} other {{counter} participantes}}", "hashtag.counter_by_accounts": "{count, plural, one {{counter} participante} other {{counter} participantes}}",
"hashtag.follow": "Siguir a la etiqueta", "hashtag.follow": "Siguir a la etiqueta",
"hashtag.unfollow": "Dexar de siguir a la etiqueta", "hashtag.unfollow": "Dexar de siguir a la etiqueta",
@ -259,7 +259,6 @@
"keyboard_shortcuts.reply": "Responder a un artículu", "keyboard_shortcuts.reply": "Responder a un artículu",
"keyboard_shortcuts.requests": "Abrir la llista de solicitúes de siguimientu", "keyboard_shortcuts.requests": "Abrir la llista de solicitúes de siguimientu",
"keyboard_shortcuts.search": "Enfocar la barra de busca", "keyboard_shortcuts.search": "Enfocar la barra de busca",
"keyboard_shortcuts.spoilers": "to show/hide CW field",
"keyboard_shortcuts.start": "Abrir la columna «Entamar»", "keyboard_shortcuts.start": "Abrir la columna «Entamar»",
"keyboard_shortcuts.toggle_sensitivity": "Amosar/anubrir el conteníu multimedia", "keyboard_shortcuts.toggle_sensitivity": "Amosar/anubrir el conteníu multimedia",
"keyboard_shortcuts.toot": "Comenzar un artículu nuevu", "keyboard_shortcuts.toot": "Comenzar un artículu nuevu",
@ -412,12 +411,16 @@
"search.quick_action.go_to_hashtag": "Dir a la etiqueta {x}", "search.quick_action.go_to_hashtag": "Dir a la etiqueta {x}",
"search.quick_action.status_search": "Artículos que concasen con {x}", "search.quick_action.status_search": "Artículos que concasen con {x}",
"search.search_or_paste": "Busca o apiega una URL", "search.search_or_paste": "Busca o apiega una URL",
"search_popout.language_code": "códigu de llingua ISO",
"search_popout.quick_actions": "Aiciones rápides", "search_popout.quick_actions": "Aiciones rápides",
"search_popout.recent": "Busques de recién", "search_popout.recent": "Busques de recién",
"search_popout.specific_date": "data específica",
"search_popout.user": "perfil",
"search_results.accounts": "Perfiles", "search_results.accounts": "Perfiles",
"search_results.all": "Too", "search_results.all": "Too",
"search_results.hashtags": "Etiquetes", "search_results.hashtags": "Etiquetes",
"search_results.nothing_found": "Nun se pudo atopar nada con esos términos de busca", "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.statuses": "Artículos",
"search_results.title": "Busca de: {q}", "search_results.title": "Busca de: {q}",
"server_banner.introduction": "{domain} ye parte de la rede social descentralizada que tien la teunoloxía de {mastodon}.", "server_banner.introduction": "{domain} ye parte de la rede social descentralizada que tien la teunoloxía de {mastodon}.",
@ -460,6 +463,7 @@
"status.replied_to": "En rempuesta a {name}", "status.replied_to": "En rempuesta a {name}",
"status.reply": "Responder", "status.reply": "Responder",
"status.replyAll": "Responder al filu", "status.replyAll": "Responder al filu",
"status.report": "Informar de @{name}",
"status.sensitive_warning": "Conteníu sensible", "status.sensitive_warning": "Conteníu sensible",
"status.show_filter_reason": "Amosar de toes toes", "status.show_filter_reason": "Amosar de toes toes",
"status.show_less": "Amosar menos", "status.show_less": "Amosar menos",

@ -150,7 +150,7 @@
"compose_form.poll.duration": "Durada de l'enquesta", "compose_form.poll.duration": "Durada de l'enquesta",
"compose_form.poll.option_placeholder": "Opció {number}", "compose_form.poll.option_placeholder": "Opció {number}",
"compose_form.poll.remove_option": "Elimina aquesta opció", "compose_form.poll.remove_option": "Elimina aquesta opció",
"compose_form.poll.switch_to_multiple": "Canvia lenquesta per a permetre diverses opcions", "compose_form.poll.switch_to_multiple": "Canvia lenquesta per a permetre múltiples opcions",
"compose_form.poll.switch_to_single": "Canvia lenquesta per a permetre una única opció", "compose_form.poll.switch_to_single": "Canvia lenquesta per a permetre una única opció",
"compose_form.publish": "Tut", "compose_form.publish": "Tut",
"compose_form.publish_form": "Nou tut", "compose_form.publish_form": "Nou tut",
@ -521,7 +521,7 @@
"poll.total_people": "{count, plural, one {# persona} other {# persones}}", "poll.total_people": "{count, plural, one {# persona} other {# persones}}",
"poll.total_votes": "{count, plural, one {# vot} other {# vots}}", "poll.total_votes": "{count, plural, one {# vot} other {# vots}}",
"poll.vote": "Vota", "poll.vote": "Vota",
"poll.voted": "Vas votar per aquesta resposta", "poll.voted": "Vau votar aquesta resposta",
"poll.votes": "{votes, plural, one {# vot} other {# vots}}", "poll.votes": "{votes, plural, one {# vot} other {# vots}}",
"poll_button.add_poll": "Afegeix una enquesta", "poll_button.add_poll": "Afegeix una enquesta",
"poll_button.remove_poll": "Elimina l'enquesta", "poll_button.remove_poll": "Elimina l'enquesta",
@ -607,7 +607,7 @@
"search.quick_action.status_search": "Tuts coincidint amb {x}", "search.quick_action.status_search": "Tuts coincidint amb {x}",
"search.search_or_paste": "Cerca o escriu l'URL", "search.search_or_paste": "Cerca o escriu l'URL",
"search_popout.full_text_search_disabled_message": "No disponible a {domain}.", "search_popout.full_text_search_disabled_message": "No disponible a {domain}.",
"search_popout.full_text_search_logged_out_message": "Només disponible en iniciar la sessió.", "search_popout.full_text_search_logged_out_message": "Només disponible amb la sessió iniciada.",
"search_popout.language_code": "Codi de llengua ISO", "search_popout.language_code": "Codi de llengua ISO",
"search_popout.options": "Opcions de cerca", "search_popout.options": "Opcions de cerca",
"search_popout.quick_actions": "Accions ràpides", "search_popout.quick_actions": "Accions ràpides",

@ -683,7 +683,7 @@
"status.show_more": "펼치기", "status.show_more": "펼치기",
"status.show_more_all": "모두 펼치기", "status.show_more_all": "모두 펼치기",
"status.show_original": "원본 보기", "status.show_original": "원본 보기",
"status.title.with_attachments": "{user} 님이 {attachmentCount, plural, one {첨부} other {{attachmentCount}개 첨부}}하여 게시", "status.title.with_attachments": "{user} 님이 {attachmentCount, plural, one {첨부파일} other {{attachmentCount}개의 첨부파일}}과 함께 게시함",
"status.translate": "번역", "status.translate": "번역",
"status.translated_from_with": "{provider}에 의해 {lang}에서 번역됨", "status.translated_from_with": "{provider}에 의해 {lang}에서 번역됨",
"status.uncached_media_warning": "마리보기 허용되지 않음", "status.uncached_media_warning": "마리보기 허용되지 않음",

@ -328,6 +328,7 @@
"interaction_modal.on_another_server": "En otro sirvidor", "interaction_modal.on_another_server": "En otro sirvidor",
"interaction_modal.on_this_server": "En este sirvidor", "interaction_modal.on_this_server": "En este sirvidor",
"interaction_modal.sign_in": "No estas konektado kon este sirvidor. Ande tyenes tu kuento?", "interaction_modal.sign_in": "No estas konektado kon este sirvidor. Ande tyenes tu kuento?",
"interaction_modal.sign_in_hint": "Konsejo: Akel es el sitio adonde te enrejistrates. Si no lo akodras, bushka el mesaj de posta elektronika de bienvenida en tu kuti de arivo. Tambien puedes eskrivir tu nombre de utilizador kompleto (por enshemplo @Mastodon@mastodon.social)",
"interaction_modal.title.favourite": "Endika ke te plaze publikasyon de {name}", "interaction_modal.title.favourite": "Endika ke te plaze publikasyon de {name}",
"interaction_modal.title.follow": "Sige a {name}", "interaction_modal.title.follow": "Sige a {name}",
"interaction_modal.title.reblog": "Repartaja publikasyon de {name}", "interaction_modal.title.reblog": "Repartaja publikasyon de {name}",
@ -478,6 +479,7 @@
"onboarding.actions.go_to_explore": "Va a los trendes", "onboarding.actions.go_to_explore": "Va a los trendes",
"onboarding.actions.go_to_home": "Va a tu linya prinsipala", "onboarding.actions.go_to_home": "Va a tu linya prinsipala",
"onboarding.compose.template": "Ke haber, #Mastodon?", "onboarding.compose.template": "Ke haber, #Mastodon?",
"onboarding.follows.empty": "Malorozamente, no se pueden amostrar rezultados en este momento. Puedes aprovar uzar la bushkeda o navigar por la pajina de eksplorasyon para topar personas a las que segir, o aprovarlo de muevo mas tadre.",
"onboarding.follows.title": "Personaliza tu linya prinsipala", "onboarding.follows.title": "Personaliza tu linya prinsipala",
"onboarding.profile.discoverable": "Faz ke mi profil apareska en bushkedas", "onboarding.profile.discoverable": "Faz ke mi profil apareska en bushkedas",
"onboarding.profile.display_name": "Nombre amostrado", "onboarding.profile.display_name": "Nombre amostrado",
@ -497,7 +499,9 @@
"onboarding.start.title": "Lo logrates!", "onboarding.start.title": "Lo logrates!",
"onboarding.steps.follow_people.body": "El buto de Mastodon es segir a djente interesante.", "onboarding.steps.follow_people.body": "El buto de Mastodon es segir a djente interesante.",
"onboarding.steps.follow_people.title": "Personaliza tu linya prinsipala", "onboarding.steps.follow_people.title": "Personaliza tu linya prinsipala",
"onboarding.steps.publish_status.body": "Puedes introdusirte al mundo con teksto, fotos, videos o anketas {emoji}",
"onboarding.steps.publish_status.title": "Eskrive tu primera publikasyon", "onboarding.steps.publish_status.title": "Eskrive tu primera publikasyon",
"onboarding.steps.setup_profile.body": "Kompleta tu profil para aumentar tus enteraksyones.",
"onboarding.steps.setup_profile.title": "Personaliza tu profil", "onboarding.steps.setup_profile.title": "Personaliza tu profil",
"onboarding.steps.share_profile.body": "Informe a tus amigos komo toparte en Mastodon", "onboarding.steps.share_profile.body": "Informe a tus amigos komo toparte en Mastodon",
"onboarding.steps.share_profile.title": "Partaja tu profil de Mastodon", "onboarding.steps.share_profile.title": "Partaja tu profil de Mastodon",

@ -18,6 +18,7 @@
"account.blocked": "Blocat", "account.blocked": "Blocat",
"account.browse_more_on_origin_server": "Navigar sul perfil original", "account.browse_more_on_origin_server": "Navigar sul perfil original",
"account.cancel_follow_request": "Retirar la demanda dabonament", "account.cancel_follow_request": "Retirar la demanda dabonament",
"account.copy": "Copiar lo ligam del perfil",
"account.direct": "Mencionar @{name} en privat", "account.direct": "Mencionar @{name} en privat",
"account.disable_notifications": "Quitar de mavisar quand @{name} publica quicòm", "account.disable_notifications": "Quitar de mavisar quand @{name} publica quicòm",
"account.domain_blocked": "Domeni amagat", "account.domain_blocked": "Domeni amagat",
@ -28,6 +29,7 @@
"account.featured_tags.last_status_never": "Cap de publicacion", "account.featured_tags.last_status_never": "Cap de publicacion",
"account.featured_tags.title": "Etiquetas en avant de {name}", "account.featured_tags.title": "Etiquetas en avant de {name}",
"account.follow": "Sègre", "account.follow": "Sègre",
"account.follow_back": "Sègre en retorn",
"account.followers": "Seguidors", "account.followers": "Seguidors",
"account.followers.empty": "Degun sèc pas aqueste utilizaire pel moment.", "account.followers.empty": "Degun sèc pas aqueste utilizaire pel moment.",
"account.followers_counter": "{count, plural, one {{counter} Seguidor} other {{counter} Seguidors}}", "account.followers_counter": "{count, plural, one {{counter} Seguidor} other {{counter} Seguidors}}",
@ -48,6 +50,7 @@
"account.mute_notifications_short": "Amudir las notificacions", "account.mute_notifications_short": "Amudir las notificacions",
"account.mute_short": "Amudir", "account.mute_short": "Amudir",
"account.muted": "Mes en silenci", "account.muted": "Mes en silenci",
"account.mutual": "Mutual",
"account.no_bio": "Cap de descripcion pas fornida.", "account.no_bio": "Cap de descripcion pas fornida.",
"account.open_original_page": "Dobrir la pagina dorigina", "account.open_original_page": "Dobrir la pagina dorigina",
"account.posts": "Tuts", "account.posts": "Tuts",
@ -172,6 +175,7 @@
"conversation.mark_as_read": "Marcar coma legida", "conversation.mark_as_read": "Marcar coma legida",
"conversation.open": "Veire la conversacion", "conversation.open": "Veire la conversacion",
"conversation.with": "Amb {names}", "conversation.with": "Amb {names}",
"copy_icon_button.copied": "Copiat al quichapapièr",
"copypaste.copied": "Copiat", "copypaste.copied": "Copiat",
"copypaste.copy_to_clipboard": "Copiar al quichapapièr", "copypaste.copy_to_clipboard": "Copiar al quichapapièr",
"directory.federated": "Del fediverse conegut", "directory.federated": "Del fediverse conegut",
@ -294,6 +298,8 @@
"keyboard_shortcuts.direct": "to open direct messages column", "keyboard_shortcuts.direct": "to open direct messages column",
"keyboard_shortcuts.down": "far davalar dins la lista", "keyboard_shortcuts.down": "far davalar dins la lista",
"keyboard_shortcuts.enter": "dobrir los estatuts", "keyboard_shortcuts.enter": "dobrir los estatuts",
"keyboard_shortcuts.favourite": "Marcar coma favorit",
"keyboard_shortcuts.favourites": "Dobrir la lista dels favorits",
"keyboard_shortcuts.federated": "dobrir lo flux public global", "keyboard_shortcuts.federated": "dobrir lo flux public global",
"keyboard_shortcuts.heading": "Acorchis clavièr", "keyboard_shortcuts.heading": "Acorchis clavièr",
"keyboard_shortcuts.home": "dobrir lo flux public local", "keyboard_shortcuts.home": "dobrir lo flux public local",
@ -339,6 +345,7 @@
"lists.search": "Cercar demest lo mond que seguètz", "lists.search": "Cercar demest lo mond que seguètz",
"lists.subheading": "Vòstras listas", "lists.subheading": "Vòstras listas",
"load_pending": "{count, plural, one {# nòu element} other {# nòu elements}}", "load_pending": "{count, plural, one {# nòu element} other {# nòu elements}}",
"loading_indicator.label": "Cargament…",
"media_gallery.toggle_visible": "Modificar la visibilitat", "media_gallery.toggle_visible": "Modificar la visibilitat",
"mute_modal.duration": "Durada", "mute_modal.duration": "Durada",
"mute_modal.hide_notifications": "Rescondre las notificacions daquesta persona?", "mute_modal.hide_notifications": "Rescondre las notificacions daquesta persona?",
@ -371,6 +378,7 @@
"not_signed_in_indicator.not_signed_in": "Devètz vos connectar per accedir a aquesta ressorsa.", "not_signed_in_indicator.not_signed_in": "Devètz vos connectar per accedir a aquesta ressorsa.",
"notification.admin.report": "{name} senhalèt {target}", "notification.admin.report": "{name} senhalèt {target}",
"notification.admin.sign_up": "{name} se marquèt", "notification.admin.sign_up": "{name} se marquèt",
"notification.favourite": "{name} a mes vòstre estatut en favorit",
"notification.follow": "{name} vos sèc", "notification.follow": "{name} vos sèc",
"notification.follow_request": "{name} a demandat a vos sègre", "notification.follow_request": "{name} a demandat a vos sègre",
"notification.mention": "{name} vos a mencionat", "notification.mention": "{name} vos a mencionat",
@ -423,6 +431,8 @@
"onboarding.compose.template": "Adiu #Mastodon !", "onboarding.compose.template": "Adiu #Mastodon !",
"onboarding.follows.lead": "You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!", "onboarding.follows.lead": "You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!",
"onboarding.follows.title": "Popular on Mastodon", "onboarding.follows.title": "Popular on Mastodon",
"onboarding.profile.display_name": "Nom dafichatge",
"onboarding.profile.note": "Biografia",
"onboarding.share.title": "Partejar vòstre perfil", "onboarding.share.title": "Partejar vòstre perfil",
"onboarding.start.lead": "Your new Mastodon account is ready to go. Here's how you can make the most of it:", "onboarding.start.lead": "Your new Mastodon account is ready to go. Here's how you can make the most of it:",
"onboarding.start.skip": "Want to skip right ahead?", "onboarding.start.skip": "Want to skip right ahead?",
@ -504,6 +514,7 @@
"report_notification.categories.spam": "Messatge indesirable", "report_notification.categories.spam": "Messatge indesirable",
"report_notification.categories.violation": "Violacion de las règlas", "report_notification.categories.violation": "Violacion de las règlas",
"report_notification.open": "Dobrir lo senhalament", "report_notification.open": "Dobrir lo senhalament",
"search.no_recent_searches": "Cap de recèrcas recentas",
"search.placeholder": "Recercar", "search.placeholder": "Recercar",
"search.search_or_paste": "Recercar o picar una URL", "search.search_or_paste": "Recercar o picar una URL",
"search_popout.language_code": "Còdi ISO de lenga", "search_popout.language_code": "Còdi ISO de lenga",
@ -536,6 +547,7 @@
"status.copy": "Copiar lo ligam de lestatut", "status.copy": "Copiar lo ligam de lestatut",
"status.delete": "Escafar", "status.delete": "Escafar",
"status.detailed_status": "Vista detalhada de la convèrsa", "status.detailed_status": "Vista detalhada de la convèrsa",
"status.direct": "Mencionar @{name} en privat",
"status.direct_indicator": "Mencion privada", "status.direct_indicator": "Mencion privada",
"status.edit": "Modificar", "status.edit": "Modificar",
"status.edited": "Modificat {date}", "status.edited": "Modificat {date}",
@ -626,6 +638,7 @@
"upload_modal.preview_label": "Apercebut ({ratio})", "upload_modal.preview_label": "Apercebut ({ratio})",
"upload_progress.label": "Mandadís…", "upload_progress.label": "Mandadís…",
"upload_progress.processing": "Tractament…", "upload_progress.processing": "Tractament…",
"username.taken": "Aqueste nom dutilizaire es pres. Ensajatz-ne un autre",
"video.close": "Tampar la vidèo", "video.close": "Tampar la vidèo",
"video.download": "Telecargar lo fichièr", "video.download": "Telecargar lo fichièr",
"video.exit_fullscreen": "Sortir plen ecran", "video.exit_fullscreen": "Sortir plen ecran",

@ -32,6 +32,7 @@
"account.featured_tags.last_status_never": "Sem publicações", "account.featured_tags.last_status_never": "Sem publicações",
"account.featured_tags.title": "Hashtags em destaque de {name}", "account.featured_tags.title": "Hashtags em destaque de {name}",
"account.follow": "Seguir", "account.follow": "Seguir",
"account.follow_back": "Seguir de volta",
"account.followers": "Seguidores", "account.followers": "Seguidores",
"account.followers.empty": "Nada aqui.", "account.followers.empty": "Nada aqui.",
"account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidores}}", "account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidores}}",
@ -52,6 +53,7 @@
"account.mute_notifications_short": "Silenciar notificações", "account.mute_notifications_short": "Silenciar notificações",
"account.mute_short": "Silenciar", "account.mute_short": "Silenciar",
"account.muted": "Silenciado", "account.muted": "Silenciado",
"account.mutual": "Mútuo",
"account.no_bio": "Nenhuma descrição fornecida.", "account.no_bio": "Nenhuma descrição fornecida.",
"account.open_original_page": "Abrir a página original", "account.open_original_page": "Abrir a página original",
"account.posts": "Toots", "account.posts": "Toots",

@ -314,7 +314,7 @@
"home.explore_prompt.body": "ฟีดหน้าแรกของคุณจะมีการผสมผสานของโพสต์จากแฮชแท็กที่คุณได้เลือกติดตาม, ผู้คนที่คุณได้เลือกติดตาม และโพสต์ที่เขาดัน หากนั่นรู้สึกเงียบเกินไป คุณอาจต้องการ:", "home.explore_prompt.body": "ฟีดหน้าแรกของคุณจะมีการผสมผสานของโพสต์จากแฮชแท็กที่คุณได้เลือกติดตาม, ผู้คนที่คุณได้เลือกติดตาม และโพสต์ที่เขาดัน หากนั่นรู้สึกเงียบเกินไป คุณอาจต้องการ:",
"home.explore_prompt.title": "นี่คือฐานหน้าแรกของคุณภายใน Mastodon", "home.explore_prompt.title": "นี่คือฐานหน้าแรกของคุณภายใน Mastodon",
"home.hide_announcements": "ซ่อนประกาศ", "home.hide_announcements": "ซ่อนประกาศ",
"home.pending_critical_update.body": "โปรดอัปเดตเซิร์ฟเวอร์ Mastodon ของคุณโดยเร็วที่สุดเท่าที่จะทำได้!", "home.pending_critical_update.body": "โปรดอัปเดตเซิร์ฟเวอร์ Mastodon ของคุณโดยเร็วที่สุดเท่าที่จะเป็นไปได้!",
"home.pending_critical_update.link": "ดูการอัปเดต", "home.pending_critical_update.link": "ดูการอัปเดต",
"home.pending_critical_update.title": "มีการอัปเดตความปลอดภัยสำคัญพร้อมใช้งาน!", "home.pending_critical_update.title": "มีการอัปเดตความปลอดภัยสำคัญพร้อมใช้งาน!",
"home.show_announcements": "แสดงประกาศ", "home.show_announcements": "แสดงประกาศ",

@ -358,7 +358,7 @@
"keyboard_shortcuts.my_profile": "mở hồ sơ của bạn", "keyboard_shortcuts.my_profile": "mở hồ sơ của bạn",
"keyboard_shortcuts.notifications": "mở thông báo", "keyboard_shortcuts.notifications": "mở thông báo",
"keyboard_shortcuts.open_media": "mở ảnh hoặc video", "keyboard_shortcuts.open_media": "mở ảnh hoặc video",
"keyboard_shortcuts.pinned": "mở những tút đã ghim", "keyboard_shortcuts.pinned": "Open pinned posts list",
"keyboard_shortcuts.profile": "mở trang của người đăng tút", "keyboard_shortcuts.profile": "mở trang của người đăng tút",
"keyboard_shortcuts.reply": "trả lời", "keyboard_shortcuts.reply": "trả lời",
"keyboard_shortcuts.requests": "mở danh sách yêu cầu theo dõi", "keyboard_shortcuts.requests": "mở danh sách yêu cầu theo dõi",

@ -48,7 +48,7 @@
"account.locked_info": "此帳號的隱私狀態設定為鎖定。該擁有者會手動審核能跟隨此帳號的人。", "account.locked_info": "此帳號的隱私狀態設定為鎖定。該擁有者會手動審核能跟隨此帳號的人。",
"account.media": "媒體", "account.media": "媒體",
"account.mention": "提及 @{name}", "account.mention": "提及 @{name}",
"account.moved_to": "{name} 現在的新帳號為:", "account.moved_to": "{name} 目前的新帳號為:",
"account.mute": "靜音 @{name}", "account.mute": "靜音 @{name}",
"account.mute_notifications_short": "靜音推播通知", "account.mute_notifications_short": "靜音推播通知",
"account.mute_short": "靜音", "account.mute_short": "靜音",
@ -59,7 +59,7 @@
"account.posts": "嘟文", "account.posts": "嘟文",
"account.posts_with_replies": "嘟文與回覆", "account.posts_with_replies": "嘟文與回覆",
"account.report": "檢舉 @{name}", "account.report": "檢舉 @{name}",
"account.requested": "正在等待核准。按一下以取消跟隨請求", "account.requested": "正在等候審核。按一下以取消跟隨請求",
"account.requested_follow": "{name} 要求跟隨您", "account.requested_follow": "{name} 要求跟隨您",
"account.share": "分享 @{name} 的個人檔案", "account.share": "分享 @{name} 的個人檔案",
"account.show_reblogs": "顯示來自 @{name} 的嘟文", "account.show_reblogs": "顯示來自 @{name} 的嘟文",
@ -84,7 +84,7 @@
"admin.impact_report.title": "影響總結", "admin.impact_report.title": "影響總結",
"alert.rate_limited.message": "請於 {retry_time, time, medium} 後重試。", "alert.rate_limited.message": "請於 {retry_time, time, medium} 後重試。",
"alert.rate_limited.title": "已限速", "alert.rate_limited.title": "已限速",
"alert.unexpected.message": "發生非預期的錯誤。", "alert.unexpected.message": "發生非預期的錯誤。",
"alert.unexpected.title": "哎呀!", "alert.unexpected.title": "哎呀!",
"announcement.announcement": "公告", "announcement.announcement": "公告",
"attachments_list.unprocessed": "(未經處理)", "attachments_list.unprocessed": "(未經處理)",
@ -241,7 +241,7 @@
"empty_column.followed_tags": "您還沒有跟隨任何主題標籤。當您跟隨主題標籤時,它們將於此顯示。", "empty_column.followed_tags": "您還沒有跟隨任何主題標籤。當您跟隨主題標籤時,它們將於此顯示。",
"empty_column.hashtag": "這個主題標籤下什麼也沒有。", "empty_column.hashtag": "這個主題標籤下什麼也沒有。",
"empty_column.home": "您的首頁時間軸是空的!跟隨更多人來將它填滿吧!", "empty_column.home": "您的首頁時間軸是空的!跟隨更多人來將它填滿吧!",
"empty_column.list": "這份列表下什麼也沒有。當此列表的成員嘟出新的嘟文時,它們將顯示於此。", "empty_column.list": "這份列表下什麼也沒有。當此列表的成員嘟出新的嘟文時,它們將顯示於此。",
"empty_column.lists": "您還沒有建立任何列表。當您建立列表時,它將於此顯示。", "empty_column.lists": "您還沒有建立任何列表。當您建立列表時,它將於此顯示。",
"empty_column.mutes": "您尚未靜音任何使用者。", "empty_column.mutes": "您尚未靜音任何使用者。",
"empty_column.notifications": "您還沒有收到任何通知,當您與別人開始互動時,它將於此顯示。", "empty_column.notifications": "您還沒有收到任何通知,當您與別人開始互動時,它將於此顯示。",
@ -303,8 +303,8 @@
"hashtag.counter_by_accounts": "{count, plural, one {{counter} 名} other {{counter} 名}}參與者", "hashtag.counter_by_accounts": "{count, plural, one {{counter} 名} other {{counter} 名}}參與者",
"hashtag.counter_by_uses": "{count, plural, one {{counter} 則} other {{counter} 則}}嘟文", "hashtag.counter_by_uses": "{count, plural, one {{counter} 則} other {{counter} 則}}嘟文",
"hashtag.counter_by_uses_today": "本日有 {count, plural, one {{counter} 則} other {{counter} 則}}嘟文", "hashtag.counter_by_uses_today": "本日有 {count, plural, one {{counter} 則} other {{counter} 則}}嘟文",
"hashtag.follow": "追蹤主題標籤", "hashtag.follow": "跟隨主題標籤",
"hashtag.unfollow": "取消追蹤主題標籤", "hashtag.unfollow": "取消跟隨主題標籤",
"hashtags.and_other": "…及其他 {count, plural, other {# 個}}", "hashtags.and_other": "…及其他 {count, plural, other {# 個}}",
"home.actions.go_to_explore": "看看發生什麼新鮮事", "home.actions.go_to_explore": "看看發生什麼新鮮事",
"home.actions.go_to_suggestions": "尋找一些人來跟隨", "home.actions.go_to_suggestions": "尋找一些人來跟隨",

@ -1,12 +1,11 @@
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import type { TypedUseSelectorHook } from 'react-redux';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports // eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import type { AppDispatch, RootState } from './store'; import type { AppDispatch, RootState } from './store';
export const useAppDispatch: () => AppDispatch = useDispatch; export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector; export const useAppSelector = useSelector.withTypes<RootState>();
export const createAppAsyncThunk = createAsyncThunk.withTypes<{ export const createAppAsyncThunk = createAsyncThunk.withTypes<{
state: RootState; state: RootState;

@ -100,9 +100,8 @@ table + p {
border-top-right-radius: 12px; border-top-right-radius: 12px;
height: 140px; height: 140px;
vertical-align: bottom; vertical-align: bottom;
background-color: #f3f2f5; background-position: center !important;
background-position: center; background-size: cover !important;
background-size: cover;
} }
.email-account-banner-inner-td { .email-account-banner-inner-td {

@ -104,3 +104,59 @@
margin-inline-start: 10px; margin-inline-start: 10px;
} }
} }
.redirect {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 14px;
line-height: 18px;
&__logo {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 30px;
img {
height: 48px;
}
}
&__message {
text-align: center;
h1 {
font-size: 17px;
line-height: 22px;
font-weight: 700;
margin-bottom: 30px;
}
p {
margin-bottom: 30px;
&:last-child {
margin-bottom: 0;
}
}
a {
color: $highlight-text-color;
font-weight: 500;
text-decoration: none;
&:hover,
&:focus,
&:active {
text-decoration: underline;
}
}
}
&__link {
margin-top: 15px;
}
}

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 22L3 18L7 14L8.4 15.45L6.85 17H17V13H19V19H6.85L8.4 20.55L7 22ZM5 11V5H17.15L15.6 3.45L17 2L21 6L17 10L15.6 8.55L17.15 7H7V11H5Z"/>
<path d="M9 9H15V15H9V9Z"/>
</svg>

After

Width:  |  Height:  |  Size: 275 B

Before

Width:  |  Height:  |  Size: 415 B

After

Width:  |  Height:  |  Size: 415 B

Before

Width:  |  Height:  |  Size: 879 B

After

Width:  |  Height:  |  Size: 879 B

@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.4 15.45L7 14L3 18L7 22L8.4 20.55L6.85 19H13.5V18C13.5 17.6567 13.5638 17.3171 13.6988 17H6.85L8.4 15.45Z"/>
<path d="M15 14.1883C14.8435 14.443 14.7232 14.7147 14.6398 15H9V9H15V14.1883Z"/>
<path d="M5 5V11H7V7H17.15L15.6 8.55L17 10L21 6L17 2L15.6 3.45L17.15 5H5Z"/>
<path d="M16 22C15.7167 22 15.475 21.9083 15.275 21.725C15.0917 21.525 15 21.2833 15 21V18C15 17.7167 15.0917 17.4833 15.275 17.3C15.475 17.1 15.7167 17 16 17V16C16 15.45 16.1917 14.9833 16.575 14.6C16.975 14.2 17.45 14 18 14C18.55 14 19.0167 14.2 19.4 14.6C19.8 14.9833 20 15.45 20 16V17C20.2833 17 20.5167 17.1 20.7 17.3C20.9 17.4833 21 17.7167 21 18V21C21 21.2833 20.9 21.525 20.7 21.725C20.5167 21.9083 20.2833 22 20 22H16ZM17 17H19V16C19 15.7167 18.9 15.4833 18.7 15.3C18.5167 15.1 18.2833 15 18 15C17.7167 15 17.475 15.1 17.275 15.3C17.0917 15.4833 17 15.7167 17 16V17Z"/>
</svg>

After

Width:  |  Height:  |  Size: 961 B

@ -108,7 +108,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end end
def process_status_params def process_status_params
@status_parser = ActivityPub::Parser::StatusParser.new(@json, followers_collection: @account.followers_url) @status_parser = ActivityPub::Parser::StatusParser.new(@json, followers_collection: @account.followers_url, object: @object)
attachment_ids = process_attachments.take(4).map(&:id) attachment_ids = process_attachments.take(4).map(&:id)
@ -321,7 +321,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
already_voted = true already_voted = true
with_redis_lock("vote:#{replied_to_status.poll_id}:#{@account.id}") do with_redis_lock("vote:#{replied_to_status.poll_id}:#{@account.id}") do
already_voted = poll.votes.where(account: @account).exists? already_voted = poll.votes.exists?(account: @account)
poll.votes.create!(account: @account, choice: poll.options.index(@object['name']), uri: object_uri) poll.votes.create!(account: @account, choice: poll.options.index(@object['name']), uri: object_uri)
end end
@ -407,7 +407,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
return false if local_usernames.empty? return false if local_usernames.empty?
Account.local.where(username: local_usernames).exists? Account.local.exists?(username: local_usernames)
end end
def tombstone_exists? def tombstone_exists?

@ -4,12 +4,13 @@ class ActivityPub::Parser::StatusParser
include JsonLdHelper include JsonLdHelper
# @param [Hash] json # @param [Hash] json
# @param [Hash] magic_values # @param [Hash] options
# @option magic_values [String] :followers_collection # @option options [String] :followers_collection
def initialize(json, magic_values = {}) # @option options [Hash] :object
def initialize(json, **options)
@json = json @json = json
@object = json['object'] || json @object = options[:object] || json['object'] || json
@magic_values = magic_values @options = options
end end
def uri def uri
@ -78,7 +79,7 @@ class ActivityPub::Parser::StatusParser
:public :public
elsif audience_cc.any? { |cc| ActivityPub::TagManager.instance.public_collection?(cc) } elsif audience_cc.any? { |cc| ActivityPub::TagManager.instance.public_collection?(cc) }
:unlisted :unlisted
elsif audience_to.include?(@magic_values[:followers_collection]) elsif audience_to.include?(@options[:followers_collection])
:private :private
elsif direct_message == false elsif direct_message == false
:limited :limited

@ -0,0 +1,43 @@
# frozen_string_literal: true
class AnnualReport
include DatabaseHelper
SOURCES = [
AnnualReport::Archetype,
AnnualReport::TypeDistribution,
AnnualReport::TopStatuses,
AnnualReport::MostUsedApps,
AnnualReport::CommonlyInteractedWithAccounts,
AnnualReport::TimeSeries,
AnnualReport::TopHashtags,
AnnualReport::MostRebloggedAccounts,
AnnualReport::Percentiles,
].freeze
SCHEMA = 1
def initialize(account, year)
@account = account
@year = year
end
def generate
return if GeneratedAnnualReport.exists?(account: @account, year: @year)
GeneratedAnnualReport.create(
account: @account,
year: @year,
schema_version: SCHEMA,
data: data
)
end
private
def data
with_read_replica do
SOURCES.each_with_object({}) { |klass, hsh| hsh.merge!(klass.new(@account, @year).generate) }
end
end
end

@ -0,0 +1,49 @@
# frozen_string_literal: true
class AnnualReport::Archetype < AnnualReport::Source
# Average number of posts (including replies and reblogs) made by
# each active user in a single year (2023)
AVERAGE_PER_YEAR = 113
def generate
{
archetype: archetype,
}
end
private
def archetype
if (standalone_count + replies_count + reblogs_count) < AVERAGE_PER_YEAR
:lurker
elsif reblogs_count > (standalone_count * 2)
:booster
elsif polls_count > (standalone_count * 0.1) # standalone_count includes posts with polls
:pollster
elsif replies_count > (standalone_count * 2)
:replier
else
:oracle
end
end
def polls_count
@polls_count ||= base_scope.where.not(poll_id: nil).count
end
def reblogs_count
@reblogs_count ||= base_scope.where.not(reblog_of_id: nil).count
end
def replies_count
@replies_count ||= base_scope.where.not(in_reply_to_id: nil).where.not(in_reply_to_account_id: @account.id).count
end
def standalone_count
@standalone_count ||= base_scope.without_replies.without_reblogs.count
end
def base_scope
@account.statuses.where(id: year_as_snowflake_range)
end
end

@ -0,0 +1,22 @@
# frozen_string_literal: true
class AnnualReport::CommonlyInteractedWithAccounts < AnnualReport::Source
SET_SIZE = 40
def generate
{
commonly_interacted_with_accounts: commonly_interacted_with_accounts.map do |(account_id, count)|
{
account_id: account_id,
count: count,
}
end,
}
end
private
def commonly_interacted_with_accounts
@account.statuses.reorder(nil).where(id: year_as_snowflake_range).where.not(in_reply_to_account_id: @account.id).group(:in_reply_to_account_id).having('count(*) > 1').order(total: :desc).limit(SET_SIZE).pluck(Arel.sql('in_reply_to_account_id, count(*) AS total'))
end
end

@ -0,0 +1,22 @@
# frozen_string_literal: true
class AnnualReport::MostRebloggedAccounts < AnnualReport::Source
SET_SIZE = 10
def generate
{
most_reblogged_accounts: most_reblogged_accounts.map do |(account_id, count)|
{
account_id: account_id,
count: count,
}
end,
}
end
private
def most_reblogged_accounts
@account.statuses.reorder(nil).where(id: year_as_snowflake_range).where.not(reblog_of_id: nil).joins(reblog: :account).group('accounts.id').having('count(*) > 1').order(total: :desc).limit(SET_SIZE).pluck(Arel.sql('accounts.id, count(*) as total'))
end
end

@ -0,0 +1,22 @@
# frozen_string_literal: true
class AnnualReport::MostUsedApps < AnnualReport::Source
SET_SIZE = 10
def generate
{
most_used_apps: most_used_apps.map do |(name, count)|
{
name: name,
count: count,
}
end,
}
end
private
def most_used_apps
@account.statuses.reorder(nil).where(id: year_as_snowflake_range).joins(:application).group('oauth_applications.name').order(total: :desc).limit(SET_SIZE).pluck(Arel.sql('oauth_applications.name, count(*) as total'))
end
end

@ -0,0 +1,62 @@
# frozen_string_literal: true
class AnnualReport::Percentiles < AnnualReport::Source
def generate
{
percentiles: {
followers: (total_with_fewer_followers / (total_with_any_followers + 1.0)) * 100,
statuses: (total_with_fewer_statuses / (total_with_any_statuses + 1.0)) * 100,
},
}
end
private
def followers_gained
@followers_gained ||= @account.passive_relationships.where("date_part('year', follows.created_at) = ?", @year).count
end
def statuses_created
@statuses_created ||= @account.statuses.where(id: year_as_snowflake_range).count
end
def total_with_fewer_followers
@total_with_fewer_followers ||= Follow.find_by_sql([<<~SQL.squish, { year: @year, comparison: followers_gained }]).first.total
WITH tmp0 AS (
SELECT follows.target_account_id
FROM follows
INNER JOIN accounts ON accounts.id = follows.target_account_id
WHERE date_part('year', follows.created_at) = :year
AND accounts.domain IS NULL
GROUP BY follows.target_account_id
HAVING COUNT(*) < :comparison
)
SELECT count(*) AS total
FROM tmp0
SQL
end
def total_with_fewer_statuses
@total_with_fewer_statuses ||= Status.find_by_sql([<<~SQL.squish, { comparison: statuses_created, min_id: year_as_snowflake_range.first, max_id: year_as_snowflake_range.last }]).first.total
WITH tmp0 AS (
SELECT statuses.account_id
FROM statuses
INNER JOIN accounts ON accounts.id = statuses.account_id
WHERE statuses.id BETWEEN :min_id AND :max_id
AND accounts.domain IS NULL
GROUP BY statuses.account_id
HAVING count(*) < :comparison
)
SELECT count(*) AS total
FROM tmp0
SQL
end
def total_with_any_followers
@total_with_any_followers ||= Follow.where("date_part('year', follows.created_at) = ?", @year).joins(:target_account).merge(Account.local).count('distinct follows.target_account_id')
end
def total_with_any_statuses
@total_with_any_statuses ||= Status.where(id: year_as_snowflake_range).joins(:account).merge(Account.local).count('distinct statuses.account_id')
end
end

@ -0,0 +1,16 @@
# frozen_string_literal: true
class AnnualReport::Source
attr_reader :account, :year
def initialize(account, year)
@account = account
@year = year
end
protected
def year_as_snowflake_range
(Mastodon::Snowflake.id_at(DateTime.new(year, 1, 1))..Mastodon::Snowflake.id_at(DateTime.new(year, 12, 31)))
end
end

@ -0,0 +1,30 @@
# frozen_string_literal: true
class AnnualReport::TimeSeries < AnnualReport::Source
def generate
{
time_series: (1..12).map do |month|
{
month: month,
statuses: statuses_per_month[month] || 0,
following: following_per_month[month] || 0,
followers: followers_per_month[month] || 0,
}
end,
}
end
private
def statuses_per_month
@statuses_per_month ||= @account.statuses.reorder(nil).where(id: year_as_snowflake_range).group(:period).pluck(Arel.sql("date_part('month', created_at)::int AS period, count(*)")).to_h
end
def following_per_month
@following_per_month ||= @account.active_relationships.where("date_part('year', created_at) = ?", @year).group(:period).pluck(Arel.sql("date_part('month', created_at)::int AS period, count(*)")).to_h
end
def followers_per_month
@followers_per_month ||= @account.passive_relationships.where("date_part('year', created_at) = ?", @year).group(:period).pluck(Arel.sql("date_part('month', created_at)::int AS period, count(*)")).to_h
end
end

@ -0,0 +1,22 @@
# frozen_string_literal: true
class AnnualReport::TopHashtags < AnnualReport::Source
SET_SIZE = 40
def generate
{
top_hashtags: top_hashtags.map do |(name, count)|
{
name: name,
count: count,
}
end,
}
end
private
def top_hashtags
Tag.joins(:statuses).where(statuses: { id: @account.statuses.where(id: year_as_snowflake_range).reorder(nil).select(:id) }).group(:id).having('count(*) > 1').order(total: :desc).limit(SET_SIZE).pluck(Arel.sql('COALESCE(tags.display_name, tags.name), count(*) AS total'))
end
end

@ -0,0 +1,21 @@
# frozen_string_literal: true
class AnnualReport::TopStatuses < AnnualReport::Source
def generate
top_reblogs = base_scope.order(reblogs_count: :desc).first&.id
top_favourites = base_scope.where.not(id: top_reblogs).order(favourites_count: :desc).first&.id
top_replies = base_scope.where.not(id: [top_reblogs, top_favourites]).order(replies_count: :desc).first&.id
{
top_statuses: {
by_reblogs: top_reblogs,
by_favourites: top_favourites,
by_replies: top_replies,
},
}
end
def base_scope
@account.statuses.with_public_visibility.joins(:status_stat).where(id: year_as_snowflake_range).reorder(nil)
end
end

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

Loading…
Cancel
Save