mirror of
https://git.kescher.at/CatCatNya/catstodon.git
synced 2024-11-25 17:51:36 +01:00
Merge branch 'glitch-soc' into develop
This commit is contained in:
commit
c2c2afc294
256 changed files with 3794 additions and 1727 deletions
|
@ -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"],
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
19
.github/workflows/test-migrations-one-step.yml
vendored
19
.github/workflows/test-migrations-one-step.yml
vendored
|
@ -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'
|
||||||
|
|
22
.github/workflows/test-migrations-two-step.yml
vendored
22
.github/workflows/test-migrations-two-step.yml
vendored
|
@ -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:
|
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'
|
|
||||||
|
|
||||||
- name: Run migrations up to v2.4.3
|
|
||||||
run: './bin/rails db:migrate VERSION=20180707154237'
|
|
||||||
env:
|
|
||||||
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:
|
||||||
|
|
16
.github/workflows/test-ruby.yml
vendored
16
.github/workflows/test-ruby.yml
vendored
|
@ -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,38 +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/concerns/account/interactions.rb'
|
|
||||||
- 'app/models/featured_tag.rb'
|
|
||||||
- 'app/models/poll.rb'
|
|
||||||
- 'app/models/session_activation.rb'
|
|
||||||
- 'app/models/status.rb'
|
|
||||||
- 'app/models/user.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?
|
||||||
|
@ -140,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'
|
||||||
|
|
||||||
|
@ -312,13 +279,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
|
||||||
|
|
|
@ -7,15 +7,15 @@
|
||||||
ARG TARGETPLATFORM=${TARGETPLATFORM}
|
ARG TARGETPLATFORM=${TARGETPLATFORM}
|
||||||
ARG BUILDPLATFORM=${BUILDPLATFORM}
|
ARG BUILDPLATFORM=${BUILDPLATFORM}
|
||||||
|
|
||||||
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.2.2"]
|
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.2.3"]
|
||||||
ARG RUBY_VERSION="3.2.2"
|
ARG RUBY_VERSION="3.2.3"
|
||||||
# # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
# # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
||||||
ARG NODE_MAJOR_VERSION="20"
|
ARG NODE_MAJOR_VERSION="20"
|
||||||
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"]
|
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"]
|
||||||
ARG DEBIAN_VERSION="bookworm"
|
ARG DEBIAN_VERSION="bookworm"
|
||||||
# Node image to use for base image based on combined variables (ex: 20-bookworm-slim)
|
# Node image to use for base image based on combined variables (ex: 20-bookworm-slim)
|
||||||
FROM docker.io/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim as node
|
FROM docker.io/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim as node
|
||||||
# Ruby image to use for base image based on combined variables (ex: 3.2.2-slim-bookworm)
|
# Ruby image to use for base image based on combined variables (ex: 3.2.3-slim-bookworm)
|
||||||
FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} as ruby
|
FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} as ruby
|
||||||
|
|
||||||
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA
|
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA
|
||||||
|
|
|
@ -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/)
|
||||||
|
|
31
Gemfile.lock
31
Gemfile.lock
|
@ -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,7 +180,7 @@ 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
|
||||||
|
@ -319,7 +319,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
|
||||||
|
@ -360,7 +360,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)
|
||||||
|
@ -398,12 +398,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)
|
||||||
|
@ -445,7 +445,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)
|
||||||
|
@ -504,7 +504,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)
|
||||||
|
@ -600,8 +600,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)
|
||||||
|
@ -610,7 +610,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)
|
||||||
|
@ -636,7 +636,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)
|
||||||
|
@ -650,11 +650,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)
|
||||||
|
@ -696,7 +696,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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
30
app/controllers/api/v1/annual_reports_controller.rb
Normal file
30
app/controllers/api/v1/annual_reports_controller.rb
Normal file
|
@ -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)
|
||||||
|
|
|
@ -27,7 +27,7 @@ class Api::V1::Peers::SearchController < Api::BaseController
|
||||||
@domains = InstancesIndex.query(function_score: {
|
@domains = InstancesIndex.query(function_score: {
|
||||||
query: {
|
query: {
|
||||||
prefix: {
|
prefix: {
|
||||||
domain: TagManager.instance.normalize_domain(params[:q].strip),
|
domain: normalized_domain,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -37,11 +37,18 @@ class Api::V1::Peers::SearchController < Api::BaseController
|
||||||
},
|
},
|
||||||
}).limit(10).pluck(:domain)
|
}).limit(10).pluck(:domain)
|
||||||
else
|
else
|
||||||
domain = params[:q].strip
|
domain = normalized_domain
|
||||||
domain = TagManager.instance.normalize_domain(domain)
|
@domains = Instance.searchable.domain_starts_with(domain).limit(10).pluck(:domain)
|
||||||
@domains = Instance.searchable.where(Instance.arel_table[:domain].matches("#{Instance.sanitize_sql_like(domain)}%", false, true)).limit(10).pluck(:domain)
|
|
||||||
end
|
end
|
||||||
rescue Addressable::URI::InvalidURIError
|
rescue Addressable::URI::InvalidURIError
|
||||||
@domains = []
|
@domains = []
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def normalized_domain
|
||||||
|
TagManager.instance.normalize_domain(query_value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def query_value
|
||||||
|
params[:q].strip
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
10
app/controllers/redirect/accounts_controller.rb
Normal file
10
app/controllers/redirect/accounts_controller.rb
Normal file
|
@ -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
|
29
app/controllers/redirect/base_controller.rb
Normal file
29
app/controllers/redirect/base_controller.rb
Normal file
|
@ -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
|
10
app/controllers/redirect/statuses_controller.rb
Normal file
10
app/controllers/redirect/statuses_controller.rb
Normal file
|
@ -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
|
|
@ -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);
|
||||||
|
|
||||||
|
@ -198,4 +203,4 @@ export const hydrateSearch = () => (dispatch, getState) => {
|
||||||
if (history !== null) {
|
if (history !== null) {
|
||||||
dispatch(updateSearchHistory(history));
|
dispatch(updateSearchHistory(history));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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';
|
||||||
|
@ -313,7 +315,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 => ({
|
||||||
|
|
|
@ -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';
|
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';
|
||||||
|
@ -257,7 +259,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",
|
||||||
|
|
159
app/javascript/flavours/glitch/locales/fr-CA.json
Normal file
159
app/javascript/flavours/glitch/locales/fr-CA.json
Normal file
|
@ -0,0 +1,159 @@
|
||||||
|
{
|
||||||
|
"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.follows": "Abonnements",
|
||||||
|
"account.follows_you": "Vous suit",
|
||||||
|
"account.joined": "Ici depuis {date}",
|
||||||
|
"account.suspended_disclaimer_full": "Cet utilisateur a été suspendu par un modérateur.",
|
||||||
|
"account.view_full_profile": "Voir le profil complet",
|
||||||
|
"advanced_options.icon_title": "Options avancées",
|
||||||
|
"advanced_options.local-only.long": "Ne pas envoyer aux autres instances",
|
||||||
|
"advanced_options.local-only.short": "Uniquement en local",
|
||||||
|
"advanced_options.local-only.tooltip": "Ce post est uniquement local",
|
||||||
|
"advanced_options.threaded_mode.long": "Ouvre automatiquement une réponse lors de la publication",
|
||||||
|
"advanced_options.threaded_mode.short": "Mode thread",
|
||||||
|
"advanced_options.threaded_mode.tooltip": "Mode thread activé",
|
||||||
|
"boost_modal.missing_description": "Ce post contient des médias sans description",
|
||||||
|
"column.favourited_by": "Ajouté en favori par",
|
||||||
|
"column.heading": "Divers",
|
||||||
|
"column.reblogged_by": "Partagé par",
|
||||||
|
"column.subheading": "Autres options",
|
||||||
|
"column_header.profile": "Profil",
|
||||||
|
"column_subheading.lists": "Listes",
|
||||||
|
"column_subheading.navigation": "Navigation",
|
||||||
|
"community.column_settings.allow_local_only": "Afficher seulement les posts locaux",
|
||||||
|
"compose.attach": "Joindre…",
|
||||||
|
"compose.attach.doodle": "Dessiner quelque chose",
|
||||||
|
"compose.attach.upload": "Téléverser un fichier",
|
||||||
|
"compose.content-type.html": "HTML",
|
||||||
|
"compose.content-type.markdown": "Markdown",
|
||||||
|
"compose.content-type.plain": "Text brut",
|
||||||
|
"compose_form.poll.multiple_choices": "Choix multiples",
|
||||||
|
"compose_form.poll.single_choice": "Choix unique",
|
||||||
|
"compose_form.spoiler": "Cacher le texte derrière un avertissement",
|
||||||
|
"confirmation_modal.do_not_ask_again": "Ne plus demander confirmation",
|
||||||
|
"confirmations.deprecated_settings.confirm": "Utiliser les préférences de Mastodon",
|
||||||
|
"confirmations.deprecated_settings.message": "Certaines {app_settings} de glitch-soc que vous utilisez ont été remplacées par les {preferences} de Mastodon et seront remplacées :",
|
||||||
|
"confirmations.missing_media_description.confirm": "Envoyer quand même",
|
||||||
|
"confirmations.missing_media_description.edit": "Modifier le média",
|
||||||
|
"confirmations.missing_media_description.message": "Au moins un média joint manque d'une description. Pensez à décrire tous les médias attachés pour les malvoyant·e·s avant de publier votre post.",
|
||||||
|
"confirmations.unfilter.author": "Auteur",
|
||||||
|
"confirmations.unfilter.confirm": "Afficher",
|
||||||
|
"confirmations.unfilter.edit_filter": "Modifier le filtre",
|
||||||
|
"confirmations.unfilter.filters": "Correspondance avec {count, plural, one {un filtre} other {plusieurs filtres}}",
|
||||||
|
"content-type.change": "Type de contenu",
|
||||||
|
"direct.group_by_conversations": "Grouper par conversation",
|
||||||
|
"endorsed_accounts_editor.endorsed_accounts": "Comptes mis en avant",
|
||||||
|
"favourite_modal.combo": "Vous pouvez appuyer sur {combo} pour passer ceci la prochaine fois",
|
||||||
|
"firehose.column_settings.allow_local_only": "Afficher les messages locaux dans \"Tous\"",
|
||||||
|
"home.column_settings.advanced": "Avancé",
|
||||||
|
"home.column_settings.filter_regex": "Filtrer par expression régulière",
|
||||||
|
"home.column_settings.show_direct": "Afficher les MPs",
|
||||||
|
"home.settings": "Paramètres de la colonne",
|
||||||
|
"keyboard_shortcuts.bookmark": "ajouter aux marque-pages",
|
||||||
|
"keyboard_shortcuts.secondary_toot": "Envoyer le post en utilisant les paramètres secondaires de confidentialité",
|
||||||
|
"keyboard_shortcuts.toggle_collapse": "Plier/déplier les posts",
|
||||||
|
"media_gallery.sensitive": "Sensible",
|
||||||
|
"moved_to_warning": "Ce compte a déménagé vers {moved_to_link} et ne peut donc plus accepter de nouveaux abonné·e·s.",
|
||||||
|
"navigation_bar.app_settings": "Paramètres de l'application",
|
||||||
|
"navigation_bar.featured_users": "Utilisateurs mis en avant",
|
||||||
|
"navigation_bar.keyboard_shortcuts": "Raccourcis clavier",
|
||||||
|
"navigation_bar.misc": "Autres",
|
||||||
|
"notification.markForDeletion": "Ajouter aux éléments à supprimer",
|
||||||
|
"notification_purge.btn_all": "Sélectionner\ntout",
|
||||||
|
"notification_purge.btn_apply": "Effacer\nla sélection",
|
||||||
|
"notification_purge.btn_invert": "Inverser\nla sélection",
|
||||||
|
"notification_purge.btn_none": "Annuler\nla sélection",
|
||||||
|
"notification_purge.start": "Activer le mode de nettoyage des notifications",
|
||||||
|
"notifications.marked_clear": "Effacer les notifications sélectionnées",
|
||||||
|
"notifications.marked_clear_confirmation": "Voulez-vous vraiment effacer de manière permanente toutes les notifications sélectionnées ?",
|
||||||
|
"settings.always_show_spoilers_field": "Toujours activer le champ de rédaction de l'avertissement de contenu",
|
||||||
|
"settings.auto_collapse": "Repliage automatique",
|
||||||
|
"settings.auto_collapse_all": "Tout",
|
||||||
|
"settings.auto_collapse_height": "Hauteur (en pixels) pour qu'un pouet soit considéré comme long",
|
||||||
|
"settings.auto_collapse_lengthy": "Posts longs",
|
||||||
|
"settings.auto_collapse_media": "Posts avec média",
|
||||||
|
"settings.auto_collapse_notifications": "Notifications",
|
||||||
|
"settings.auto_collapse_reblogs": "Boosts",
|
||||||
|
"settings.auto_collapse_replies": "Réponses",
|
||||||
|
"settings.close": "Fermer",
|
||||||
|
"settings.collapsed_statuses": "Posts repliés",
|
||||||
|
"settings.compose_box_opts": "Zone de rédaction",
|
||||||
|
"settings.confirm_before_clearing_draft": "Afficher une fenêtre de confirmation avant d'écraser le message en cours de rédaction",
|
||||||
|
"settings.confirm_boost_missing_media_description": "Afficher une fenêtre de confirmation avant de partager des posts manquant de description des médias",
|
||||||
|
"settings.confirm_missing_media_description": "Afficher une fenêtre de confirmation avant de publier des posts manquant de description de média",
|
||||||
|
"settings.content_warnings": "Content warnings",
|
||||||
|
"settings.content_warnings.regexp": "Expression rationnelle",
|
||||||
|
"settings.content_warnings_filter": "Avertissement de contenu à ne pas automatiquement déplier :",
|
||||||
|
"settings.content_warnings_media_outside": "Afficher les médias en dehors des avertissements de contenu",
|
||||||
|
"settings.content_warnings_media_outside_hint": "Reproduit le comportement par défaut de Mastodon, les médias attachés ne sont plus affectés par le bouton d'affichage d'un post avec avertissement",
|
||||||
|
"settings.content_warnings_shared_state": "Affiche/cache le contenu de toutes les copies à la fois",
|
||||||
|
"settings.content_warnings_shared_state_hint": "Reproduit le comportement par défaut de Mastodon, le bouton d'avertissement de contenu affecte toutes les copies d'un post à la fois. Cela empêchera le repliement automatique de n'importe quelle copie d'un post avec un avertissement déplié",
|
||||||
|
"settings.content_warnings_unfold_opts": "Options de dépliement automatique",
|
||||||
|
"settings.deprecated_setting": "Cette option est maintenant définie par les {settings_page_link} de Mastodon",
|
||||||
|
"settings.enable_collapsed": "Activer le repliement des posts",
|
||||||
|
"settings.enable_collapsed_hint": "Les posts repliés ont une partie de leur contenu caché pour libérer de l'espace sur l'écran. C'est une option différente de l'avertissement de contenu",
|
||||||
|
"settings.enable_content_warnings_auto_unfold": "Déplier automatiquement les avertissements de contenu",
|
||||||
|
"settings.general": "Général",
|
||||||
|
"settings.hicolor_privacy_icons": "Indicateurs de confidentialité en couleurs",
|
||||||
|
"settings.hicolor_privacy_icons.hint": "Affiche les indicateurs de confidentialité dans des couleurs facilement distinguables",
|
||||||
|
"settings.image_backgrounds": "Images en arrière-plan",
|
||||||
|
"settings.image_backgrounds_media": "Prévisualiser les médias d'un post replié",
|
||||||
|
"settings.image_backgrounds_media_hint": "Si le post a un média attaché, utiliser le premier comme arrière-plan du post",
|
||||||
|
"settings.image_backgrounds_users": "Donner aux posts repliés une image en arrière-plan",
|
||||||
|
"settings.inline_preview_cards": "Cartes d'aperçu pour les liens externes",
|
||||||
|
"settings.layout_opts": "Mise en page",
|
||||||
|
"settings.media": "Média",
|
||||||
|
"settings.media_fullwidth": "Utiliser toute la largeur pour les aperçus",
|
||||||
|
"settings.media_letterbox": "Afficher les médias en Letterbox",
|
||||||
|
"settings.media_letterbox_hint": "Réduit le média et utilise une letterbox pour afficher l'image entière plutôt que de l'étirer et de la rogner",
|
||||||
|
"settings.media_reveal_behind_cw": "Toujours afficher les médias sensibles avec avertissement",
|
||||||
|
"settings.notifications.favicon_badge": "Badge de notifications non lues dans la favicon",
|
||||||
|
"settings.notifications.favicon_badge.hint": "Ajoute un badge dans la favicon pour alerter d'une notification non lue",
|
||||||
|
"settings.notifications.tab_badge": "Badge de notifications non lues",
|
||||||
|
"settings.notifications.tab_badge.hint": "Affiche un badge de notifications non lues dans les icônes des colonnes quand la colonne n'est pas ouverte",
|
||||||
|
"settings.notifications_opts": "Options des notifications",
|
||||||
|
"settings.pop_in_left": "Gauche",
|
||||||
|
"settings.pop_in_player": "Activer le lecteur pop-in",
|
||||||
|
"settings.pop_in_position": "Position du lecteur pop-in :",
|
||||||
|
"settings.pop_in_right": "Droite",
|
||||||
|
"settings.preferences": "Preferences",
|
||||||
|
"settings.prepend_cw_re": "Préfixer les avertissements avec \"re: \" lors d'une réponse",
|
||||||
|
"settings.preselect_on_reply": "Présélectionner les noms d’utilisateur·rices lors de la réponse",
|
||||||
|
"settings.preselect_on_reply_hint": "Présélectionner les noms d'utilisateurs après le premier lors d'une réponse à une conversation à plusieurs participants",
|
||||||
|
"settings.rewrite_mentions": "Réécrire les mentions dans les posts affichés",
|
||||||
|
"settings.rewrite_mentions_acct": "Réécrire avec le nom d'utilisateur·rice et le domaine (lorsque le compte est distant)",
|
||||||
|
"settings.rewrite_mentions_no": "Ne pas réécrire les mentions",
|
||||||
|
"settings.rewrite_mentions_username": "Réécrire avec le nom d’utilisateur·rice",
|
||||||
|
"settings.shared_settings_link": "préférences de l'utilisateur",
|
||||||
|
"settings.show_action_bar": "Afficher les boutons d'action dans les posts repliés",
|
||||||
|
"settings.show_content_type_choice": "Afficher le choix du type de contenu lors de la création des posts",
|
||||||
|
"settings.show_reply_counter": "Afficher une estimation du nombre de réponses",
|
||||||
|
"settings.side_arm": "Bouton secondaire de publication :",
|
||||||
|
"settings.side_arm.none": "Aucun",
|
||||||
|
"settings.side_arm_reply_mode": "Quand vous répondez à un post, le bouton secondaire de publication devrait :",
|
||||||
|
"settings.side_arm_reply_mode.copy": "Copier la confidentialité du post auquel vous répondez",
|
||||||
|
"settings.side_arm_reply_mode.keep": "Garder la confidentialité établie",
|
||||||
|
"settings.side_arm_reply_mode.restrict": "Restreindre la confidentialité de la réponse à celle du post auquel vous répondez",
|
||||||
|
"settings.status_icons": "Icônes des posts",
|
||||||
|
"settings.status_icons_language": "Indicateur de langue",
|
||||||
|
"settings.status_icons_local_only": "Indicateur de post local",
|
||||||
|
"settings.status_icons_media": "Indicateur de médias et sondage",
|
||||||
|
"settings.status_icons_reply": "Indicateur de réponses",
|
||||||
|
"settings.status_icons_visibility": "Indicateur de la confidentialité du post",
|
||||||
|
"settings.swipe_to_change_columns": "Glissement latéral pour changer de colonne (mobile uniquement)",
|
||||||
|
"settings.tag_misleading_links": "Étiqueter les liens trompeurs",
|
||||||
|
"settings.tag_misleading_links.hint": "Ajouter une indication visuelle avec l'hôte cible du lien à chaque lien ne le mentionnant pas explicitement",
|
||||||
|
"settings.wide_view": "Vue élargie (mode ordinateur uniquement)",
|
||||||
|
"settings.wide_view_hint": "Étire les colonnes pour mieux remplir l'espace disponible.",
|
||||||
|
"status.collapse": "Replier",
|
||||||
|
"status.has_audio": "Contient des fichiers audio attachés",
|
||||||
|
"status.has_pictures": "Contient des images attachées",
|
||||||
|
"status.has_preview_card": "Contient une carte de prévisualisation attachée",
|
||||||
|
"status.has_video": "Contient des vidéos attachées",
|
||||||
|
"status.in_reply_to": "Ce post est une réponse",
|
||||||
|
"status.is_poll": "Ce post est un sondage",
|
||||||
|
"status.local_only": "Visible uniquement depuis votre instance",
|
||||||
|
"status.sensitive_toggle": "Cliquer pour voir",
|
||||||
|
"status.uncollapse": "Déplier"
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
||||||
|
@ -207,4 +212,4 @@ export const hydrateSearch = () => (dispatch, getState) => {
|
||||||
if (history !== null) {
|
if (history !== null) {
|
||||||
dispatch(updateSearchHistory(history));
|
dispatch(updateSearchHistory(history));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMouseEnter = ({ currentTarget }) => {
|
export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) => {
|
||||||
|
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));
|
||||||
|
|
||||||
|
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,136 +84,161 @@ 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 {
|
||||||
render () {
|
dispatch(muteStatus(lastStatus.get('id')));
|
||||||
const { accounts, lastStatus, unread, scrollKey, intl } = this.props;
|
|
||||||
|
|
||||||
if (lastStatus === null) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
}, [dispatch, lastStatus]);
|
||||||
|
|
||||||
const menu = [
|
const handleShowMore = useCallback(() => {
|
||||||
{ text: intl.formatMessage(messages.open), action: this.handleClick },
|
if (lastStatus.get('hidden')) {
|
||||||
null,
|
dispatch(revealStatus(lastStatus.get('id')));
|
||||||
];
|
} else {
|
||||||
|
dispatch(hideStatus(lastStatus.get('id')));
|
||||||
menu.push({ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMute });
|
|
||||||
|
|
||||||
if (unread) {
|
|
||||||
menu.push({ text: intl.formatMessage(messages.markAsRead), action: this.handleMarkAsRead });
|
|
||||||
menu.push(null);
|
|
||||||
}
|
}
|
||||||
|
}, [dispatch, lastStatus]);
|
||||||
|
|
||||||
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete });
|
if (!lastStatus) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
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 menu = [
|
||||||
|
{ text: intl.formatMessage(messages.open), action: handleClick },
|
||||||
|
null,
|
||||||
|
{ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: handleConversationMute },
|
||||||
|
];
|
||||||
|
|
||||||
const handlers = {
|
if (unread) {
|
||||||
reply: this.handleReply,
|
menu.push({ text: intl.formatMessage(messages.markAsRead), action: handleMarkAsRead });
|
||||||
open: this.handleClick,
|
menu.push(null);
|
||||||
moveUp: this.handleHotkeyMoveUp,
|
}
|
||||||
moveDown: this.handleHotkeyMoveDown,
|
|
||||||
toggleHidden: this.handleShowMore,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete });
|
||||||
<HotKeys handlers={handlers}>
|
|
||||||
<div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex={0}>
|
|
||||||
<div className='conversation__avatar' onClick={this.handleClick} role='presentation'>
|
|
||||||
<AvatarComposite accounts={accounts} size={48} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='conversation__content'>
|
const names = accounts.map(a => (
|
||||||
<div className='conversation__content__info'>
|
<Link to={`/@${a.get('acct')}`} key={a.get('id')} title={a.get('acct')}>
|
||||||
<div className='conversation__content__relative-time'>
|
<bdi>
|
||||||
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
|
<strong
|
||||||
</div>
|
className='display-name__html'
|
||||||
|
dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }}
|
||||||
|
/>
|
||||||
|
</bdi>
|
||||||
|
</Link>
|
||||||
|
)).reduce((prev, cur) => [prev, ', ', cur]);
|
||||||
|
|
||||||
<div className='conversation__content__names' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
const handlers = {
|
||||||
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
|
reply: handleReply,
|
||||||
</div>
|
open: handleClick,
|
||||||
|
moveUp: handleHotkeyMoveUp,
|
||||||
|
moveDown: handleHotkeyMoveDown,
|
||||||
|
toggleHidden: handleShowMore,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HotKeys handlers={handlers}>
|
||||||
|
<div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex={0}>
|
||||||
|
<div className='conversation__avatar' onClick={handleClick} role='presentation'>
|
||||||
|
<AvatarComposite accounts={accounts} size={48} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='conversation__content'>
|
||||||
|
<div className='conversation__content__info'>
|
||||||
|
<div className='conversation__content__relative-time'>
|
||||||
|
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StatusContent
|
<div className='conversation__content__names' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||||
status={lastStatus}
|
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
|
||||||
onClick={this.handleClick}
|
</div>
|
||||||
expanded={!lastStatus.get('hidden')}
|
</div>
|
||||||
onExpandedToggle={this.handleShowMore}
|
|
||||||
collapsible
|
<StatusContent
|
||||||
|
status={lastStatus}
|
||||||
|
onClick={handleClick}
|
||||||
|
expanded={!lastStatus.get('hidden')}
|
||||||
|
onExpandedToggle={handleShowMore}
|
||||||
|
collapsible
|
||||||
|
/>
|
||||||
|
|
||||||
|
{lastStatus.get('media_attachments').size > 0 && (
|
||||||
|
<AttachmentList
|
||||||
|
compact
|
||||||
|
media={lastStatus.get('media_attachments')}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{lastStatus.get('media_attachments').size > 0 && (
|
<div className='status__action-bar'>
|
||||||
<AttachmentList
|
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={handleReply} />
|
||||||
compact
|
|
||||||
media={lastStatus.get('media_attachments')}
|
<div className='status__action-bar-dropdown'>
|
||||||
|
<DropdownMenuContainer
|
||||||
|
scrollKey={scrollKey}
|
||||||
|
status={lastStatus}
|
||||||
|
items={menu}
|
||||||
|
icon='ellipsis-h'
|
||||||
|
iconComponent={MoreHorizIcon}
|
||||||
|
size={18}
|
||||||
|
direction='right'
|
||||||
|
title={intl.formatMessage(messages.more)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='status__action-bar'>
|
|
||||||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={this.handleReply} />
|
|
||||||
|
|
||||||
<div className='status__action-bar-dropdown'>
|
|
||||||
<DropdownMenuContainer
|
|
||||||
scrollKey={scrollKey}
|
|
||||||
status={lastStatus}
|
|
||||||
items={menu}
|
|
||||||
icon='ellipsis-h'
|
|
||||||
iconComponent={MoreHorizIcon}
|
|
||||||
size={18}
|
|
||||||
direction='right'
|
|
||||||
title={intl.formatMessage(messages.more)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</div>
|
||||||
);
|
</HotKeys>
|
||||||
}
|
);
|
||||||
|
};
|
||||||
|
|
||||||
}
|
Conversation.propTypes = {
|
||||||
|
conversation: ImmutablePropTypes.map.isRequired,
|
||||||
export default withRouter(injectIntl(Conversation));
|
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);
|
if (element) {
|
||||||
|
if (alignTop && node.scrollTop > element.offsetTop) {
|
||||||
handleMoveUp = id => {
|
element.scrollIntoView(true);
|
||||||
const elementIndex = this.getCurrentIndex(id) - 1;
|
} else if (!alignTop && node.scrollTop + node.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||||
this._selectChild(elementIndex, true);
|
element.scrollIntoView(false);
|
||||||
};
|
|
||||||
|
|
||||||
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 (align_top && container.scrollTop > element.offsetTop) {
|
|
||||||
element.scrollIntoView(true);
|
|
||||||
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
|
||||||
element.scrollIntoView(false);
|
|
||||||
}
|
|
||||||
element.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
element.focus();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
setRef = c => {
|
export const ConversationsList = ({ scrollKey, ...other }) => {
|
||||||
this.node = c;
|
const listRef = useRef();
|
||||||
};
|
const conversations = useSelector(state => state.getIn(['conversations', 'items']));
|
||||||
|
const isLoading = useSelector(state => state.getIn(['conversations', 'isLoading'], true));
|
||||||
|
const hasMore = useSelector(state => state.getIn(['conversations', 'hasMore'], false));
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const lastStatusId = conversations.last()?.get('last_status');
|
||||||
|
|
||||||
handleLoadOlder = debounce(() => {
|
const handleMoveUp = useCallback(id => {
|
||||||
const last = this.props.conversations.last();
|
const elementIndex = conversations.findIndex(x => x.get('id') === id) - 1;
|
||||||
|
focusChild(listRef.current.node, elementIndex, true);
|
||||||
|
}, [listRef, conversations]);
|
||||||
|
|
||||||
if (last && last.get('last_status')) {
|
const handleMoveDown = useCallback(id => {
|
||||||
this.props.onLoadMore(last.get('last_status'));
|
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 () {
|
return (
|
||||||
const { conversations, isLoading, onLoadMore, ...other } = this.props;
|
<ScrollableList {...other} scrollKey={scrollKey} isLoading={isLoading} showLoading={isLoading && conversations.isEmpty()} hasMore={hasMore} onLoadMore={handleLoadMore} ref={listRef}>
|
||||||
|
{conversations.map(item => (
|
||||||
|
<Conversation
|
||||||
|
key={item.get('id')}
|
||||||
|
conversation={item}
|
||||||
|
onMoveUp={handleMoveUp}
|
||||||
|
onMoveDown={handleMoveDown}
|
||||||
|
scrollKey={scrollKey}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollableList>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
ConversationsList.propTypes = {
|
||||||
<ScrollableList {...other} isLoading={isLoading} showLoading={isLoading && conversations.isEmpty()} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}>
|
scrollKey: PropTypes.string.isRequired,
|
||||||
{conversations.map(item => (
|
};
|
||||||
<ConversationContainer
|
|
||||||
key={item.get('id')}
|
|
||||||
conversationId={item.get('id')}
|
|
||||||
onMoveUp={this.handleMoveUp}
|
|
||||||
onMoveDown={this.handleMoveDown}
|
|
||||||
scrollKey={this.props.scrollKey}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ScrollableList>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
|
@ -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,103 +14,79 @@ 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 = () => {
|
const handleHeaderClick = useCallback(() => {
|
||||||
this.column.scrollTop();
|
columnRef.current.scrollTop();
|
||||||
};
|
}, [columnRef]);
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
dispatch(mountConversations());
|
dispatch(mountConversations());
|
||||||
dispatch(expandConversations());
|
dispatch(expandConversations());
|
||||||
this.disconnect = dispatch(connectDirectStream());
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount () {
|
const disconnect = dispatch(connectDirectStream());
|
||||||
this.props.dispatch(unmountConversations());
|
|
||||||
|
|
||||||
if (this.disconnect) {
|
return () => {
|
||||||
this.disconnect();
|
dispatch(unmountConversations());
|
||||||
this.disconnect = null;
|
disconnect();
|
||||||
}
|
};
|
||||||
}
|
}, [dispatch]);
|
||||||
|
|
||||||
setRef = c => {
|
return (
|
||||||
this.column = c;
|
<Column bindToDocument={!multiColumn} ref={columnRef} label={intl.formatMessage(messages.title)}>
|
||||||
};
|
<ColumnHeader
|
||||||
|
icon='at'
|
||||||
|
iconComponent={AlternateEmailIcon}
|
||||||
|
title={intl.formatMessage(messages.title)}
|
||||||
|
onPin={handlePin}
|
||||||
|
onMove={handleMove}
|
||||||
|
onClick={handleHeaderClick}
|
||||||
|
pinned={pinned}
|
||||||
|
multiColumn={multiColumn}
|
||||||
|
/>
|
||||||
|
|
||||||
handleLoadMore = maxId => {
|
<ConversationsList
|
||||||
this.props.dispatch(expandConversations({ maxId }));
|
trackScroll={!pinned}
|
||||||
};
|
scrollKey={`direct_timeline-${columnId}`}
|
||||||
|
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}
|
||||||
|
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
|
||||||
|
/>
|
||||||
|
|
||||||
render () {
|
<Helmet>
|
||||||
const { intl, hasUnread, columnId, multiColumn } = this.props;
|
<title>{intl.formatMessage(messages.title)}</title>
|
||||||
const pinned = !!columnId;
|
<meta name='robots' content='noindex' />
|
||||||
|
</Helmet>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
DirectTimeline.propTypes = {
|
||||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
columnId: PropTypes.string,
|
||||||
<ColumnHeader
|
multiColumn: PropTypes.bool,
|
||||||
icon='at'
|
};
|
||||||
iconComponent={AlternateEmailIcon}
|
|
||||||
active={hasUnread}
|
|
||||||
title={intl.formatMessage(messages.title)}
|
|
||||||
onPin={this.handlePin}
|
|
||||||
onMove={this.handleMove}
|
|
||||||
onClick={this.handleHeaderClick}
|
|
||||||
pinned={pinned}
|
|
||||||
multiColumn={multiColumn}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConversationsListContainer
|
export default DirectTimeline;
|
||||||
trackScroll={!pinned}
|
|
||||||
scrollKey={`direct_timeline-${columnId}`}
|
|
||||||
timelineId='direct'
|
|
||||||
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>}
|
|
||||||
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>
|
|
||||||
<title>{intl.formatMessage(messages.title)}</title>
|
|
||||||
<meta name='robots' content='noindex' />
|
|
||||||
</Helmet>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect()(injectIntl(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 l’enquesta per a permetre diverses opcions",
|
"compose_form.poll.switch_to_multiple": "Canvia l’enquesta per a permetre múltiples opcions",
|
||||||
"compose_form.poll.switch_to_single": "Canvia l’enquesta per a permetre una única opció",
|
"compose_form.poll.switch_to_single": "Canvia l’enquesta 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 d’abonament",
|
"account.cancel_follow_request": "Retirar la demanda d’abonament",
|
||||||
|
"account.copy": "Copiar lo ligam del perfil",
|
||||||
"account.direct": "Mencionar @{name} en privat",
|
"account.direct": "Mencionar @{name} en privat",
|
||||||
"account.disable_notifications": "Quitar de m’avisar quand @{name} publica quicòm",
|
"account.disable_notifications": "Quitar de m’avisar 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 d’origina",
|
"account.open_original_page": "Dobrir la pagina d’origina",
|
||||||
"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 d’aquesta persona ?",
|
"mute_modal.hide_notifications": "Rescondre las notificacions d’aquesta 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 d’afichatge",
|
||||||
|
"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 l’estatut",
|
"status.copy": "Copiar lo ligam de l’estatut",
|
||||||
"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 d’utilizaire 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
4
app/javascript/svg-icons/repeat_active.svg
Normal file
4
app/javascript/svg-icons/repeat_active.svg
Normal file
|
@ -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 |
0
app/javascript/svg-icons/repeat_disabled.svg
Executable file → Normal file
0
app/javascript/svg-icons/repeat_disabled.svg
Executable file → Normal file
Before Width: | Height: | Size: 415 B After Width: | Height: | Size: 415 B |
0
app/javascript/svg-icons/repeat_private.svg
Executable file → Normal file
0
app/javascript/svg-icons/repeat_private.svg
Executable file → Normal file
Before Width: | Height: | Size: 879 B After Width: | Height: | Size: 879 B |
6
app/javascript/svg-icons/repeat_private_active.svg
Normal file
6
app/javascript/svg-icons/repeat_private_active.svg
Normal file
|
@ -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)
|
||||||
|
|
||||||
|
@ -326,7 +326,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
|
||||||
|
|
||||||
|
@ -412,7 +412,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
|
||||||
@json = json
|
def initialize(json, **options)
|
||||||
@object = json['object'] || json
|
@json = json
|
||||||
@magic_values = magic_values
|
@object = options[:object] || json['object'] || json
|
||||||
|
@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
|
||||||
|
|
43
app/lib/annual_report.rb
Normal file
43
app/lib/annual_report.rb
Normal file
|
@ -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
|
49
app/lib/annual_report/archetype.rb
Normal file
49
app/lib/annual_report/archetype.rb
Normal file
|
@ -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
|
22
app/lib/annual_report/commonly_interacted_with_accounts.rb
Normal file
22
app/lib/annual_report/commonly_interacted_with_accounts.rb
Normal file
|
@ -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
|
22
app/lib/annual_report/most_reblogged_accounts.rb
Normal file
22
app/lib/annual_report/most_reblogged_accounts.rb
Normal file
|
@ -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
|
22
app/lib/annual_report/most_used_apps.rb
Normal file
22
app/lib/annual_report/most_used_apps.rb
Normal file
|
@ -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
|
62
app/lib/annual_report/percentiles.rb
Normal file
62
app/lib/annual_report/percentiles.rb
Normal file
|
@ -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
|
16
app/lib/annual_report/source.rb
Normal file
16
app/lib/annual_report/source.rb
Normal file
|
@ -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
|
30
app/lib/annual_report/time_series.rb
Normal file
30
app/lib/annual_report/time_series.rb
Normal file
|
@ -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
|
22
app/lib/annual_report/top_hashtags.rb
Normal file
22
app/lib/annual_report/top_hashtags.rb
Normal file
|
@ -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
|
21
app/lib/annual_report/top_statuses.rb
Normal file
21
app/lib/annual_report/top_statuses.rb
Normal file
|
@ -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
|
20
app/lib/annual_report/type_distribution.rb
Normal file
20
app/lib/annual_report/type_distribution.rb
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AnnualReport::TypeDistribution < AnnualReport::Source
|
||||||
|
def generate
|
||||||
|
{
|
||||||
|
type_distribution: {
|
||||||
|
total: base_scope.count,
|
||||||
|
reblogs: base_scope.where.not(reblog_of_id: nil).count,
|
||||||
|
replies: base_scope.where.not(in_reply_to_id: nil).where.not(in_reply_to_account_id: @account.id).count,
|
||||||
|
standalone: base_scope.without_replies.without_reblogs.count,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def base_scope
|
||||||
|
@account.statuses.where(id: year_as_snowflake_range)
|
||||||
|
end
|
||||||
|
end
|
|
@ -28,7 +28,7 @@ class DeliveryFailureTracker
|
||||||
end
|
end
|
||||||
|
|
||||||
def available?
|
def available?
|
||||||
!UnavailableDomain.where(domain: @host).exists?
|
!UnavailableDomain.exists?(domain: @host)
|
||||||
end
|
end
|
||||||
|
|
||||||
def exhausted_deliveries_days
|
def exhausted_deliveries_days
|
||||||
|
|
|
@ -470,8 +470,8 @@ class FeedManager
|
||||||
check_for_blocks = status.active_mentions.pluck(:account_id)
|
check_for_blocks = status.active_mentions.pluck(:account_id)
|
||||||
check_for_blocks.push(status.in_reply_to_account) if status.reply? && !status.in_reply_to_account_id.nil?
|
check_for_blocks.push(status.in_reply_to_account) if status.reply? && !status.in_reply_to_account_id.nil?
|
||||||
|
|
||||||
should_filter = blocks_or_mutes?(receiver_id, check_for_blocks, :mentions) # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked (or muted)
|
should_filter = blocks_or_mutes?(receiver_id, check_for_blocks, :mentions) # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked (or muted)
|
||||||
should_filter ||= status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists? # of if the account is silenced and I'm not following them
|
should_filter ||= status.account.silenced? && !Follow.exists?(account_id: receiver_id, target_account_id: status.account_id) # Filter if the account is silenced and I'm not following them
|
||||||
|
|
||||||
should_filter
|
should_filter
|
||||||
end
|
end
|
||||||
|
@ -494,7 +494,7 @@ class FeedManager
|
||||||
if status.reply? && status.in_reply_to_account_id != status.account_id
|
if status.reply? && status.in_reply_to_account_id != status.account_id
|
||||||
should_filter = status.in_reply_to_account_id != list.account_id
|
should_filter = status.in_reply_to_account_id != list.account_id
|
||||||
should_filter &&= !list.show_followed?
|
should_filter &&= !list.show_followed?
|
||||||
should_filter &&= !(list.show_list? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?)
|
should_filter &&= !(list.show_list? && ListAccount.exists?(list_id: list.id, account_id: status.in_reply_to_account_id))
|
||||||
|
|
||||||
return !!should_filter
|
return !!should_filter
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,17 +5,46 @@ class PermalinkRedirector
|
||||||
|
|
||||||
def initialize(path)
|
def initialize(path)
|
||||||
@path = path
|
@path = path
|
||||||
|
@object = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def object
|
||||||
|
@object ||= begin
|
||||||
|
if at_username_status_request? || statuses_status_request?
|
||||||
|
status = Status.find_by(id: second_segment)
|
||||||
|
status if status&.distributable? && !status&.local?
|
||||||
|
elsif at_username_request?
|
||||||
|
username, domain = first_segment.delete_prefix('@').split('@')
|
||||||
|
domain = nil if TagManager.instance.local_domain?(domain)
|
||||||
|
account = Account.find_remote(username, domain)
|
||||||
|
account unless account&.local?
|
||||||
|
elsif accounts_request? && record_integer_id_request?
|
||||||
|
account = Account.find_by(id: second_segment)
|
||||||
|
account unless account&.local?
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def redirect_path
|
def redirect_path
|
||||||
if at_username_status_request? || statuses_status_request?
|
return ActivityPub::TagManager.instance.url_for(object) if object.present?
|
||||||
find_status_url_by_id(second_segment)
|
|
||||||
elsif at_username_request?
|
@path.delete_prefix('/deck') if @path.start_with?('/deck')
|
||||||
find_account_url_by_name(first_segment)
|
end
|
||||||
elsif accounts_request? && record_integer_id_request?
|
|
||||||
find_account_url_by_id(second_segment)
|
def redirect_uri
|
||||||
elsif @path.start_with?('/deck')
|
return ActivityPub::TagManager.instance.uri_for(object) if object.present?
|
||||||
@path.delete_prefix('/deck')
|
|
||||||
|
@path.delete_prefix('/deck') if @path.start_with?('/deck')
|
||||||
|
end
|
||||||
|
|
||||||
|
def redirect_confirmation_path
|
||||||
|
case object.class.name
|
||||||
|
when 'Account'
|
||||||
|
redirect_account_path(object.id)
|
||||||
|
when 'Status'
|
||||||
|
redirect_status_path(object.id)
|
||||||
|
else
|
||||||
|
@path.delete_prefix('/deck') if @path.start_with?('/deck')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -56,22 +85,4 @@ class PermalinkRedirector
|
||||||
def path_segments
|
def path_segments
|
||||||
@path_segments ||= @path.delete_prefix('/deck').delete_prefix('/').split('/')
|
@path_segments ||= @path.delete_prefix('/deck').delete_prefix('/').split('/')
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_status_url_by_id(id)
|
|
||||||
status = Status.find_by(id: id)
|
|
||||||
ActivityPub::TagManager.instance.url_for(status) if status&.distributable? && !status.account.local?
|
|
||||||
end
|
|
||||||
|
|
||||||
def find_account_url_by_id(id)
|
|
||||||
account = Account.find_by(id: id)
|
|
||||||
ActivityPub::TagManager.instance.url_for(account) if account.present? && !account.local?
|
|
||||||
end
|
|
||||||
|
|
||||||
def find_account_url_by_name(name)
|
|
||||||
username, domain = name.gsub(/\A@/, '').split('@')
|
|
||||||
domain = nil if TagManager.instance.local_domain?(domain)
|
|
||||||
account = Account.find_remote(username, domain)
|
|
||||||
|
|
||||||
ActivityPub::TagManager.instance.url_for(account) if account.present? && !account.local?
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -26,11 +26,11 @@ class StatusCacheHydrator
|
||||||
|
|
||||||
def hydrate_non_reblog_payload(empty_payload, account_id)
|
def hydrate_non_reblog_payload(empty_payload, account_id)
|
||||||
empty_payload.tap do |payload|
|
empty_payload.tap do |payload|
|
||||||
payload[:favourited] = Favourite.where(account_id: account_id, status_id: @status.id).exists?
|
payload[:favourited] = Favourite.exists?(account_id: account_id, status_id: @status.id)
|
||||||
payload[:reblogged] = Status.where(account_id: account_id, reblog_of_id: @status.id).exists?
|
payload[:reblogged] = Status.exists?(account_id: account_id, reblog_of_id: @status.id)
|
||||||
payload[:muted] = ConversationMute.where(account_id: account_id, conversation_id: @status.conversation_id).exists?
|
payload[:muted] = ConversationMute.exists?(account_id: account_id, conversation_id: @status.conversation_id)
|
||||||
payload[:bookmarked] = Bookmark.where(account_id: account_id, status_id: @status.id).exists?
|
payload[:bookmarked] = Bookmark.exists?(account_id: account_id, status_id: @status.id)
|
||||||
payload[:pinned] = StatusPin.where(account_id: account_id, status_id: @status.id).exists? if @status.account_id == account_id
|
payload[:pinned] = StatusPin.exists?(account_id: account_id, status_id: @status.id) if @status.account_id == account_id
|
||||||
payload[:filtered] = mapped_applied_custom_filter(account_id, @status)
|
payload[:filtered] = mapped_applied_custom_filter(account_id, @status)
|
||||||
|
|
||||||
if payload[:poll]
|
if payload[:poll]
|
||||||
|
@ -51,11 +51,11 @@ class StatusCacheHydrator
|
||||||
# used to create the status, we need to hydrate it here too
|
# used to create the status, we need to hydrate it here too
|
||||||
payload[:reblog][:application] = payload_reblog_application if payload[:reblog][:application].nil? && @status.reblog.account_id == account_id
|
payload[:reblog][:application] = payload_reblog_application if payload[:reblog][:application].nil? && @status.reblog.account_id == account_id
|
||||||
|
|
||||||
payload[:reblog][:favourited] = Favourite.where(account_id: account_id, status_id: @status.reblog_of_id).exists?
|
payload[:reblog][:favourited] = Favourite.exists?(account_id: account_id, status_id: @status.reblog_of_id)
|
||||||
payload[:reblog][:reblogged] = Status.where(account_id: account_id, reblog_of_id: @status.reblog_of_id).exists?
|
payload[:reblog][:reblogged] = Status.exists?(account_id: account_id, reblog_of_id: @status.reblog_of_id)
|
||||||
payload[:reblog][:muted] = ConversationMute.where(account_id: account_id, conversation_id: @status.reblog.conversation_id).exists?
|
payload[:reblog][:muted] = ConversationMute.exists?(account_id: account_id, conversation_id: @status.reblog.conversation_id)
|
||||||
payload[:reblog][:bookmarked] = Bookmark.where(account_id: account_id, status_id: @status.reblog_of_id).exists?
|
payload[:reblog][:bookmarked] = Bookmark.exists?(account_id: account_id, status_id: @status.reblog_of_id)
|
||||||
payload[:reblog][:pinned] = StatusPin.where(account_id: account_id, status_id: @status.reblog_of_id).exists? if @status.reblog.account_id == account_id
|
payload[:reblog][:pinned] = StatusPin.exists?(account_id: account_id, status_id: @status.reblog_of_id) if @status.reblog.account_id == account_id
|
||||||
payload[:reblog][:filtered] = payload[:filtered]
|
payload[:reblog][:filtered] = payload[:filtered]
|
||||||
|
|
||||||
if payload[:reblog][:poll]
|
if payload[:reblog][:poll]
|
||||||
|
|
|
@ -19,7 +19,7 @@ class SuspiciousSignInDetector
|
||||||
end
|
end
|
||||||
|
|
||||||
def previously_seen_ip?(request)
|
def previously_seen_ip?(request)
|
||||||
@user.ips.where('ip <<= ?', masked_ip(request)).exists?
|
@user.ips.exists?(['ip <<= ?', masked_ip(request)])
|
||||||
end
|
end
|
||||||
|
|
||||||
def freshly_signed_up?
|
def freshly_signed_up?
|
||||||
|
|
|
@ -27,11 +27,17 @@ class Vacuum::MediaAttachmentsVacuum
|
||||||
end
|
end
|
||||||
|
|
||||||
def media_attachments_past_retention_period
|
def media_attachments_past_retention_period
|
||||||
MediaAttachment.remote.cached.where(MediaAttachment.arel_table[:created_at].lt(@retention_period.ago)).where(MediaAttachment.arel_table[:updated_at].lt(@retention_period.ago))
|
MediaAttachment
|
||||||
|
.remote
|
||||||
|
.cached
|
||||||
|
.created_before(@retention_period.ago)
|
||||||
|
.updated_before(@retention_period.ago)
|
||||||
end
|
end
|
||||||
|
|
||||||
def orphaned_media_attachments
|
def orphaned_media_attachments
|
||||||
MediaAttachment.unattached.where(MediaAttachment.arel_table[:created_at].lt(TTL.ago))
|
MediaAttachment
|
||||||
|
.unattached
|
||||||
|
.created_before(TTL.ago)
|
||||||
end
|
end
|
||||||
|
|
||||||
def retention_period?
|
def retention_period?
|
||||||
|
|
|
@ -191,6 +191,18 @@ class UserMailer < Devise::Mailer
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def failed_2fa(user, remote_ip, user_agent, timestamp)
|
||||||
|
@resource = user
|
||||||
|
@remote_ip = remote_ip
|
||||||
|
@user_agent = user_agent
|
||||||
|
@detection = Browser.new(user_agent)
|
||||||
|
@timestamp = timestamp.to_time.utc
|
||||||
|
|
||||||
|
I18n.with_locale(locale) do
|
||||||
|
mail subject: default_i18n_subject
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def default_devise_subject
|
def default_devise_subject
|
||||||
|
|
|
@ -127,10 +127,11 @@ class Account < ApplicationRecord
|
||||||
scope :bots, -> { where(actor_type: %w(Application Service)) }
|
scope :bots, -> { where(actor_type: %w(Application Service)) }
|
||||||
scope :groups, -> { where(actor_type: 'Group') }
|
scope :groups, -> { where(actor_type: 'Group') }
|
||||||
scope :alphabetic, -> { order(domain: :asc, username: :asc) }
|
scope :alphabetic, -> { order(domain: :asc, username: :asc) }
|
||||||
|
scope :matches_uri_prefix, ->(value) { where(arel_table[:uri].matches("#{sanitize_sql_like(value)}/%", false, true)).or(where(uri: value)) }
|
||||||
scope :matches_username, ->(value) { where('lower((username)::text) LIKE lower(?)', "#{value}%") }
|
scope :matches_username, ->(value) { where('lower((username)::text) LIKE lower(?)', "#{value}%") }
|
||||||
scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
|
scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
|
||||||
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
|
|
||||||
scope :without_unapproved, -> { left_outer_joins(:user).merge(User.approved.confirmed).or(remote) }
|
scope :without_unapproved, -> { left_outer_joins(:user).merge(User.approved.confirmed).or(remote) }
|
||||||
|
scope :auditable, -> { where(id: Admin::ActionLog.select(:account_id).distinct) }
|
||||||
scope :searchable, -> { without_unapproved.without_suspended.where(moved_to_account_id: nil) }
|
scope :searchable, -> { without_unapproved.without_suspended.where(moved_to_account_id: nil) }
|
||||||
scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).joins(:account_stat) }
|
scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).joins(:account_stat) }
|
||||||
scope :by_recent_status, -> { includes(:account_stat).merge(AccountStat.order('last_status_at DESC NULLS LAST')).references(:account_stat) }
|
scope :by_recent_status, -> { includes(:account_stat).merge(AccountStat.order('last_status_at DESC NULLS LAST')).references(:account_stat) }
|
||||||
|
|
|
@ -29,7 +29,7 @@ class AccountSuggestions
|
||||||
# a complicated query on this end.
|
# a complicated query on this end.
|
||||||
|
|
||||||
account_ids = account_ids_with_sources[offset, limit]
|
account_ids = account_ids_with_sources[offset, limit]
|
||||||
accounts_map = Account.where(id: account_ids.map(&:first)).includes(:account_stat).index_by(&:id)
|
accounts_map = Account.where(id: account_ids.map(&:first)).includes(:account_stat, :user).index_by(&:id)
|
||||||
|
|
||||||
account_ids.filter_map do |(account_id, source)|
|
account_ids.filter_map do |(account_id, source)|
|
||||||
next unless accounts_map.key?(account_id)
|
next unless accounts_map.key?(account_id)
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue