Merge branch 'glitch-soc' into develop

This commit is contained in:
Jeremy Kescher 2024-01-28 02:29:46 +01:00
commit c2c2afc294
No known key found for this signature in database
GPG key ID: 80A419A7A613DFA4
256 changed files with 3794 additions and 1727 deletions

View file

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

View file

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

View file

@ -78,23 +78,8 @@ jobs:
- name: Create database
run: './bin/rails db:create'
- name: Run migrations up to v2.0.0
run: './bin/rails db:migrate VERSION=20171010025614'
- 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 historical migrations with data population
run: './bin/rails tests:migrations:prepare_database'
- name: Run all remaining migrations
run: './bin/rails db:migrate'

View file

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

View file

@ -52,7 +52,7 @@ jobs:
run: |
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'
with:
path: |-
@ -117,7 +117,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
- uses: actions/download-artifact@v4
with:
path: './'
name: ${{ github.sha }}
@ -193,7 +193,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
- uses: actions/download-artifact@v4
with:
path: './public'
name: ${{ github.sha }}
@ -213,14 +213,14 @@ jobs:
- run: bundle exec rake spec:system
- name: Archive logs
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: failure()
with:
name: e2e-logs-${{ matrix.ruby-version }}
path: log/
- name: Archive test screenshots
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: failure()
with:
name: e2e-screenshots
@ -297,7 +297,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
- uses: actions/download-artifact@v4
with:
path: './public'
name: ${{ github.sha }}
@ -317,14 +317,14 @@ jobs:
- run: bin/rspec --tag search
- name: Archive logs
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-search-logs-${{ matrix.ruby-version }}
path: log/
- name: Archive test screenshots
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-search-screenshots

View file

@ -1,6 +1,6 @@
# This configuration was generated by
# `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
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
@ -70,38 +70,6 @@ Rails/UniqueValidationWithoutIndex:
- 'app/models/identity.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).
# Configuration parameters: AllowedMethods, AllowedPatterns.
# AllowedMethods: ==, equal?, eql?
@ -140,7 +108,6 @@ Style/FetchEnvVar:
# AllowedMethods: redirect
Style/FormatStringToken:
Exclude:
- 'app/models/privacy_policy.rb'
- 'config/initializers/devise.rb'
- 'lib/paperclip/color_extractor.rb'
@ -312,13 +279,6 @@ Style/StringLiterals:
- 'config/initializers/webauthn.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).
# Configuration parameters: EnforcedStyleForMultiline.
# SupportedStylesForMultiline: comma, consistent_comma, no_comma

View file

@ -7,15 +7,15 @@
ARG TARGETPLATFORM=${TARGETPLATFORM}
ARG BUILDPLATFORM=${BUILDPLATFORM}
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.2.2"]
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.3"
# # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
ARG NODE_MAJOR_VERSION="20"
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"]
ARG DEBIAN_VERSION="bookworm"
# 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
# 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
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA

View file

@ -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.
Supported vocabulary: https://docs.joinmastodon.org/spec/activitypub/
- [Supported ActivityPub vocabulary](https://docs.joinmastodon.org/spec/activitypub/)
### Required extensions
#### Webfinger
#### WebFinger
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.
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
@ -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.
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
- Linked-Data Signatures: https://docs.joinmastodon.org/spec/security/#ld
- 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
- [Linked-Data Signatures](https://docs.joinmastodon.org/spec/security/#ld)
- [Bearcaps](https://docs.joinmastodon.org/spec/bearcaps/)
### Additional documentation
- [Mastodon documentation](https://docs.joinmastodon.org/)

View file

@ -150,7 +150,7 @@ GEM
erubi (~> 1.4)
parser (>= 2.4)
smart_properties
bigdecimal (3.1.5)
bigdecimal (3.1.6)
bindata (2.4.15)
binding_of_caller (1.0.0)
debug_inspector (>= 0.0.1)
@ -180,7 +180,7 @@ GEM
activesupport
cbor (0.5.9.6)
charlock_holmes (0.7.7)
chewy (7.4.0)
chewy (7.5.0)
activesupport (>= 5.2)
elasticsearch (>= 7.12.0, < 7.14.0)
elasticsearch-dsl
@ -319,7 +319,7 @@ GEM
activesupport (>= 5.1)
haml (>= 4.0.6)
railties (>= 5.1)
haml_lint (0.53.0)
haml_lint (0.55.0)
haml (>= 5.0)
parallel (~> 1.10)
rainbow
@ -360,7 +360,7 @@ GEM
rainbow (>= 2.2.2, < 4.0)
terminal-table (>= 1.5.1)
idn-ruby (0.1.5)
io-console (0.7.1)
io-console (0.7.2)
irb (1.11.1)
rdoc
reline (>= 0.4.2)
@ -398,12 +398,12 @@ GEM
activerecord
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
kt-paperclip (7.2.1)
kt-paperclip (7.2.2)
activemodel (>= 4.2.0)
activesupport (>= 4.2.0)
marcel (~> 1.0.1)
mime-types
terrapin (~> 0.6.0)
terrapin (>= 0.6.0, < 2.0)
language_server-protocol (3.17.0.3)
launchy (2.5.2)
addressable (~> 2.8)
@ -445,7 +445,7 @@ GEM
mime-types-data (3.2023.1205)
mini_mime (1.1.5)
mini_portile2 (2.8.5)
minitest (5.20.0)
minitest (5.21.2)
msgpack (1.7.2)
multi_json (1.15.0)
multipart-post (2.3.0)
@ -504,7 +504,7 @@ GEM
orm_adapter (0.5.0)
ox (2.14.17)
parallel (1.24.0)
parser (3.2.2.4)
parser (3.3.0.5)
ast (~> 2.4.1)
racc
parslet (2.0.0)
@ -600,8 +600,8 @@ GEM
rdf (3.3.1)
bcp47_spec (~> 0.2)
link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.6.1)
rdf (~> 3.2)
rdf-normalize (0.7.0)
rdf (~> 3.3)
rdoc (6.6.2)
psych (>= 4.0.0)
redcarpet (3.6.0)
@ -610,7 +610,7 @@ GEM
redis (>= 4)
redlock (1.3.2)
redis (>= 3.0.0, < 6.0)
regexp_parser (2.8.3)
regexp_parser (2.9.0)
reline (0.4.2)
io-console (~> 0.5)
request_store (1.5.1)
@ -636,7 +636,7 @@ GEM
rspec-mocks (3.12.6)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-rails (6.1.0)
rspec-rails (6.1.1)
actionpack (>= 6.1)
activesupport (>= 6.1)
railties (>= 6.1)
@ -650,11 +650,11 @@ GEM
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 8)
rspec-support (3.12.1)
rubocop (1.59.0)
rubocop (1.60.2)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.2.2.4)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
@ -696,7 +696,8 @@ GEM
scenic (1.7.0)
activerecord (>= 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)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)

View file

@ -24,7 +24,7 @@ class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseContro
end
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
def collection_presenter

View file

@ -24,7 +24,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
def unknown_affected_account?
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
false
end

View file

@ -6,7 +6,7 @@ module Admin
def 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
private

View file

@ -38,7 +38,7 @@ module Admin
log_action :create, @email_domain_block
(@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)
log_action :create, other_email_domain_block

View file

@ -21,7 +21,7 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController
return [] if hide_results?
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
end
@ -30,7 +30,7 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController
end
def default_accounts
Account.includes(:active_relationships, :account_stat).references(:active_relationships)
Account.includes(:active_relationships, :account_stat, :user).references(:active_relationships)
end
def paginated_follows

View file

@ -21,7 +21,7 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController
return [] if hide_results?
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
end
@ -30,7 +30,7 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController
end
def default_accounts
Account.includes(:passive_relationships, :account_stat).references(:passive_relationships)
Account.includes(:passive_relationships, :account_stat, :user).references(:passive_relationships)
end
def paginated_follows

View 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

View file

@ -17,7 +17,7 @@ class Api::V1::BlocksController < Api::BaseController
end
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)
.merge(Account.without_suspended)
.where(account: current_account)

View file

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

View file

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

View file

@ -37,7 +37,7 @@ class Api::V1::FollowRequestsController < Api::BaseController
end
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
def paginated_follow_requests

View file

@ -37,9 +37,9 @@ class Api::V1::Lists::AccountsController < Api::BaseController
def load_accounts
if unlimited?
@list.accounts.without_suspended.includes(:account_stat).all
@list.accounts.without_suspended.includes(:account_stat, :user).all
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

View file

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

View file

@ -27,7 +27,7 @@ class Api::V1::Peers::SearchController < Api::BaseController
@domains = InstancesIndex.query(function_score: {
query: {
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)
else
domain = params[:q].strip
domain = TagManager.instance.normalize_domain(domain)
@domains = Instance.searchable.where(Instance.arel_table[:domain].matches("#{Instance.sanitize_sql_like(domain)}%", false, true)).limit(10).pluck(:domain)
domain = normalized_domain
@domains = Instance.searchable.domain_starts_with(domain).limit(10).pluck(:domain)
end
rescue Addressable::URI::InvalidURIError
@domains = []
end
def normalized_domain
TagManager.instance.normalize_domain(query_value)
end
def query_value
params[:q].strip
end
end

View file

@ -14,14 +14,14 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::V1::Statuses::Bas
def load_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
end
def default_accounts
Account
.without_suspended
.includes(:favourites, :account_stat)
.includes(:favourites, :account_stat, :user)
.references(:favourites)
.where(favourites: { status_id: @status.id })
end

View file

@ -14,12 +14,12 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::V1::Statuses::Base
def load_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
end
def default_accounts
Account.without_suspended.includes(:statuses, :account_stat).references(:statuses)
Account.without_suspended.includes(:statuses, :account_stat, :user).references(:statuses)
end
def paginated_statuses

View file

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

View file

@ -1,6 +1,10 @@
# frozen_string_literal: true
class Auth::SessionsController < Devise::SessionsController
include Redisable
MAX_2FA_ATTEMPTS_PER_HOUR = 10
layout 'auth'
skip_before_action :check_self_destruct!
@ -135,9 +139,23 @@ class Auth::SessionsController < Devise::SessionsController
session.delete(:attempt_user_updated_at)
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)
@on_authentication_success_called = true
clear_2fa_attempt_from_user(user)
clear_attempt_from_session
user.update_sign_in!(new_sign_in: true)
@ -168,5 +186,14 @@ class Auth::SessionsController < Devise::SessionsController
ip: request.remote_ip,
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

View file

@ -66,6 +66,11 @@ module Auth::TwoFactorAuthenticationConcern
end
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)
on_authentication_success(user, :otp)
else

View file

@ -22,11 +22,20 @@ module WebAppControllerConcern
def redirect_unauthenticated_to_permalinks!
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
return if redirect_path.blank?
permalink_redirector = PermalinkRedirector.new(request.path)
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?
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
def set_pack

View 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

View 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

View 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

View file

@ -155,7 +155,7 @@ module JsonLdHelper
end
end
def fetch_resource(uri, id, on_behalf_of = nil)
def fetch_resource(uri, id, on_behalf_of = nil, request_options: {})
unless id
json = fetch_resource_without_id_validation(uri, on_behalf_of)
@ -164,14 +164,14 @@ module JsonLdHelper
uri = json['id']
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
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
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
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))
end
def build_request(uri, on_behalf_of = nil)
Request.new(:get, uri).tap do |request|
def build_request(uri, on_behalf_of = nil, options: {})
Request.new(:get, uri, **options).tap do |request|
request.on_behalf_of(on_behalf_of) if on_behalf_of
request.add_headers('Accept' => 'application/activity+json, application/ld+json')
end

View file

@ -170,6 +170,11 @@ export const openURL = routerHistory => (dispatch, getState) => {
export const clickSearchResult = (q, type) => (dispatch, getState) => {
const previous = getState().getIn(['search', 'recent']);
if (previous.some(x => x.get('q') === q && x.get('type') === type)) {
return;
}
const me = getState().getIn(['meta', 'me']);
const current = previous.add(fromJS({ type, q })).takeLast(4);

View file

@ -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 StarBorderIcon from '@/material-icons/400-24px/star.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 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 { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
@ -313,7 +315,7 @@ class StatusActionBar extends ImmutablePureComponent {
if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
reblogIconComponent = publicStatus ? RepeatIcon : RepeatPrivateIcon;
reblogIconComponent = publicStatus ? RepeatActiveIcon : RepeatPrivateActiveIcon;
} else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog);
reblogIconComponent = RepeatIcon;

View file

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

View file

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

View file

@ -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 StarIcon from '@/material-icons/400-24px/star-fill.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 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 { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
@ -257,7 +259,7 @@ class ActionBar extends PureComponent {
if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
reblogIconComponent = publicStatus ? RepeatIcon : RepeatPrivateIcon;
reblogIconComponent = publicStatus ? RepeatActiveIcon : RepeatPrivateActiveIcon;
} else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog);
reblogIconComponent = RepeatIcon;

View file

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

View file

@ -2,6 +2,7 @@
"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.follows": "Folgt",
"account.follows_you": "Folgt dir",
"account.joined": "Beigetreten am {date}",
"account.suspended_disclaimer_full": "Dieser Nutzer wurde durch einen Moderator gesperrt.",
"account.view_full_profile": "Vollständiges Profil anzeigen",

View file

@ -2,6 +2,7 @@
"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.follows": "Sigue",
"account.follows_you": "Te sigue",
"account.joined": "Unido el {date}",
"account.suspended_disclaimer_full": "Este usuario ha sido suspendido por un moderador.",
"account.view_full_profile": "Ver perfil completo",

View file

@ -2,6 +2,7 @@
"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.follows": "Seguir",
"account.follows_you": "Te sigue",
"account.joined": "Unido {date}",
"account.suspended_disclaimer_full": "Este usuario ha sido suspendido por un moderador.",
"account.view_full_profile": "Ver perfil completo",

View file

@ -2,6 +2,7 @@
"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.follows": "Sigue",
"account.follows_you": "Te sigue",
"account.joined": "Se unió el {date}",
"account.suspended_disclaimer_full": "Este usuario ha sido suspendido por un moderador.",
"account.view_full_profile": "Ver perfil completo",

View 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 dutilisateur·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 dutilisateur·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"
}

View file

@ -2,6 +2,7 @@
"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",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -107,3 +107,59 @@
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;
}
}

View file

@ -179,6 +179,11 @@ export const openURL = (value, history, onFailure) => (dispatch, getState) => {
export const clickSearchResult = (q, type) => (dispatch, getState) => {
const previous = getState().getIn(['search', 'recent']);
if (previous.some(x => x.get('q') === q && x.get('type') === type)) {
return;
}
const me = getState().getIn(['meta', 'me']);
const current = previous.add(fromJS({ type, q })).takeLast(4);

View file

@ -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 StarBorderIcon from '@/material-icons/400-24px/star.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 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 { WithRouterPropTypes } from 'mastodon/utils/react_router';
@ -366,7 +368,7 @@ class StatusActionBar extends ImmutablePureComponent {
if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
reblogIconComponent = publicStatus ? RepeatIcon : RepeatPrivateIcon;
reblogIconComponent = publicStatus ? RepeatActiveIcon : RepeatPrivateActiveIcon;
} else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog);
reblogIconComponent = RepeatIcon;

View file

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

View file

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

View file

@ -1,17 +1,24 @@
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 { 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 ImmutablePureComponent from 'react-immutable-pure-component';
import { useDispatch, useSelector } from 'react-redux';
import { HotKeys } from 'react-hotkeys';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.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 AvatarComposite from 'mastodon/components/avatar_composite';
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 DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { autoPlayGif } from 'mastodon/initial_state';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { makeGetStatus } from 'mastodon/selectors';
const messages = defineMessages({
more: { id: 'status.more', defaultMessage: 'More' },
@ -29,25 +36,31 @@ const messages = defineMessages({
delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute 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 = {
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,
};
const getStatus = makeGetStatus();
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) {
return;
}
@ -58,9 +71,9 @@ class Conversation extends ImmutablePureComponent {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-original');
}
};
}, []);
handleMouseLeave = ({ currentTarget }) => {
const handleMouseLeave = useCallback(({ currentTarget }) => {
if (autoPlayGif) {
return;
}
@ -71,85 +84,107 @@ class Conversation extends ImmutablePureComponent {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-static');
}
};
handleClick = () => {
if (!this.props.history) {
return;
}
const { lastStatus, unread, markRead } = this.props;
}, []);
const handleClick = useCallback(() => {
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 = () => {
this.props.markRead();
};
const handleMarkAsRead = useCallback(() => {
dispatch(markConversationRead(id));
}, [dispatch, id]);
handleReply = () => {
this.props.reply(this.props.lastStatus, this.props.history);
};
const handleReply = useCallback(() => {
dispatch((_, getState) => {
let state = getState();
handleDelete = () => {
this.props.delete();
};
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(lastStatus, history)),
},
}));
} else {
dispatch(replyCompose(lastStatus, history));
}
});
}, [dispatch, lastStatus, history, intl]);
handleHotkeyMoveUp = () => {
this.props.onMoveUp(this.props.conversationId);
};
const handleDelete = useCallback(() => {
dispatch(deleteConversation(id));
}, [dispatch, id]);
handleHotkeyMoveDown = () => {
this.props.onMoveDown(this.props.conversationId);
};
const handleHotkeyMoveUp = useCallback(() => {
onMoveUp(id);
}, [id, onMoveUp]);
handleConversationMute = () => {
this.props.onMute(this.props.lastStatus);
};
const handleHotkeyMoveDown = useCallback(() => {
onMoveDown(id);
}, [id, onMoveDown]);
handleShowMore = () => {
this.props.onToggleHidden(this.props.lastStatus);
};
const handleConversationMute = useCallback(() => {
if (lastStatus.get('muted')) {
dispatch(unmuteStatus(lastStatus.get('id')));
} else {
dispatch(muteStatus(lastStatus.get('id')));
}
}, [dispatch, lastStatus]);
render () {
const { accounts, lastStatus, unread, scrollKey, intl } = this.props;
const handleShowMore = useCallback(() => {
if (lastStatus.get('hidden')) {
dispatch(revealStatus(lastStatus.get('id')));
} else {
dispatch(hideStatus(lastStatus.get('id')));
}
}, [dispatch, lastStatus]);
if (lastStatus === null) {
if (!lastStatus) {
return null;
}
const menu = [
{ text: intl.formatMessage(messages.open), action: this.handleClick },
{ text: intl.formatMessage(messages.open), action: handleClick },
null,
{ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: handleConversationMute },
];
menu.push({ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMute });
if (unread) {
menu.push({ text: intl.formatMessage(messages.markAsRead), action: this.handleMarkAsRead });
menu.push({ text: intl.formatMessage(messages.markAsRead), action: handleMarkAsRead });
menu.push(null);
}
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete });
menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete });
const names = accounts.map(a => <Link to={`/@${a.get('acct')}`} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Link>).reduce((prev, cur) => [prev, ', ', cur]);
const names = accounts.map(a => (
<Link to={`/@${a.get('acct')}`} key={a.get('id')} title={a.get('acct')}>
<bdi>
<strong
className='display-name__html'
dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }}
/>
</bdi>
</Link>
)).reduce((prev, cur) => [prev, ', ', cur]);
const handlers = {
reply: this.handleReply,
open: this.handleClick,
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
toggleHidden: this.handleShowMore,
reply: handleReply,
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={this.handleClick} role='presentation'>
<div className='conversation__avatar' onClick={handleClick} role='presentation'>
<AvatarComposite accounts={accounts} size={48} />
</div>
@ -159,16 +194,16 @@ class Conversation extends ImmutablePureComponent {
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
</div>
<div className='conversation__content__names' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<div className='conversation__content__names' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
</div>
</div>
<StatusContent
status={lastStatus}
onClick={this.handleClick}
onClick={handleClick}
expanded={!lastStatus.get('hidden')}
onExpandedToggle={this.handleShowMore}
onExpandedToggle={handleShowMore}
collapsible
/>
@ -180,7 +215,7 @@ class Conversation extends ImmutablePureComponent {
)}
<div className='status__action-bar'>
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={this.handleReply} />
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={handleReply} />
<div className='status__action-bar-dropdown'>
<DropdownMenuContainer
@ -199,8 +234,11 @@ class Conversation extends ImmutablePureComponent {
</div>
</HotKeys>
);
}
};
}
export default withRouter(injectIntl(Conversation));
Conversation.propTypes = {
conversation: ImmutablePropTypes.map.isRequired,
scrollKey: PropTypes.string,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
};

View file

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

View file

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

View file

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

View file

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

View file

@ -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 StarIcon from '@/material-icons/400-24px/star-fill.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 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 { WithRouterPropTypes } from 'mastodon/utils/react_router';
@ -296,7 +298,7 @@ class ActionBar extends PureComponent {
if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
reblogIconComponent = publicStatus ? RepeatIcon : RepeatPrivateIcon;
reblogIconComponent = publicStatus ? RepeatActiveIcon : RepeatPrivateActiveIcon;
} else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog);
reblogIconComponent = RepeatIcon;

View file

@ -3,6 +3,7 @@
"about.contact": "Kontak:",
"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.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.suspended.title": "Opgeskort",
"about.not_available": "Hierdie inligting is nie op hierdie bediener beskikbaar gestel nie.",

View file

@ -116,7 +116,6 @@
"compose_form.publish_form": "Artículu nuevu",
"compose_form.publish_loud": "¡{publish}!",
"compose_form.save_changes": "Guardar los cambeos",
"compose_form.spoiler.unmarked": "Text is not hidden",
"confirmation_modal.cancel": "Encaboxar",
"confirmations.block.block_and_report": "Bloquiar ya informar",
"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.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.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.preview": "Va apaecer asina:",
"emoji_button.activity": "Actividá",
@ -155,6 +155,7 @@
"emoji_button.not_found": "Nun s'atoparon fustaxes que concasen",
"emoji_button.objects": "Oxetos",
"emoji_button.people": "Persones",
"emoji_button.recent": "D'usu frecuente",
"emoji_button.search": "Buscar…",
"emoji_button.search_results": "Resultaos de la busca",
"emoji_button.symbols": "Símbolos",
@ -217,7 +218,6 @@
"hashtag.column_header.tag_mode.any": "o {additional}",
"hashtag.column_header.tag_mode.none": "ensin {additional}",
"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.follow": "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.requests": "Abrir la llista de solicitúes de siguimientu",
"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.toggle_sensitivity": "Amosar/anubrir el conteníu multimedia",
"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.status_search": "Artículos que concasen con {x}",
"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.recent": "Busques de recién",
"search_popout.specific_date": "data específica",
"search_popout.user": "perfil",
"search_results.accounts": "Perfiles",
"search_results.all": "Too",
"search_results.hashtags": "Etiquetes",
"search_results.nothing_found": "Nun se pudo atopar nada con esos términos de busca",
"search_results.see_all": "Ver too",
"search_results.statuses": "Artículos",
"search_results.title": "Busca de: {q}",
"server_banner.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.reply": "Responder",
"status.replyAll": "Responder al filu",
"status.report": "Informar de @{name}",
"status.sensitive_warning": "Conteníu sensible",
"status.show_filter_reason": "Amosar de toes toes",
"status.show_less": "Amosar menos",

View file

@ -150,7 +150,7 @@
"compose_form.poll.duration": "Durada de l'enquesta",
"compose_form.poll.option_placeholder": "Opció {number}",
"compose_form.poll.remove_option": "Elimina aquesta opció",
"compose_form.poll.switch_to_multiple": "Canvia lenquesta per a permetre diverses opcions",
"compose_form.poll.switch_to_multiple": "Canvia lenquesta per a permetre múltiples opcions",
"compose_form.poll.switch_to_single": "Canvia lenquesta per a permetre una única opció",
"compose_form.publish": "Tut",
"compose_form.publish_form": "Nou tut",
@ -521,7 +521,7 @@
"poll.total_people": "{count, plural, one {# persona} other {# persones}}",
"poll.total_votes": "{count, plural, one {# vot} other {# vots}}",
"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_button.add_poll": "Afegeix una enquesta",
"poll_button.remove_poll": "Elimina l'enquesta",
@ -607,7 +607,7 @@
"search.quick_action.status_search": "Tuts coincidint amb {x}",
"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_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.options": "Opcions de cerca",
"search_popout.quick_actions": "Accions ràpides",

View file

@ -683,7 +683,7 @@
"status.show_more": "펼치기",
"status.show_more_all": "모두 펼치기",
"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.translated_from_with": "{provider}에 의해 {lang}에서 번역됨",
"status.uncached_media_warning": "마리보기 허용되지 않음",

View file

@ -328,6 +328,7 @@
"interaction_modal.on_another_server": "En otro 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_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.follow": "Sige a {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_home": "Va a tu linya prinsipala",
"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.profile.discoverable": "Faz ke mi profil apareska en bushkedas",
"onboarding.profile.display_name": "Nombre amostrado",
@ -497,7 +499,9 @@
"onboarding.start.title": "Lo logrates!",
"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.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.setup_profile.body": "Kompleta tu profil para aumentar tus enteraksyones.",
"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.title": "Partaja tu profil de Mastodon",

View file

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

View file

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

View file

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

View file

@ -358,7 +358,7 @@
"keyboard_shortcuts.my_profile": "mở hồ sơ của bạn",
"keyboard_shortcuts.notifications": "mở thông báo",
"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.reply": "trả lời",
"keyboard_shortcuts.requests": "mở danh sách yêu cầu theo dõi",

View file

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

View file

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

View file

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

View file

@ -104,3 +104,59 @@
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;
}
}

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

Before

Width:  |  Height:  |  Size: 879 B

After

Width:  |  Height:  |  Size: 879 B

View 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

View file

@ -108,7 +108,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
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)
@ -326,7 +326,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
already_voted = true
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)
end
@ -412,7 +412,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
return false if local_usernames.empty?
Account.local.where(username: local_usernames).exists?
Account.local.exists?(username: local_usernames)
end
def tombstone_exists?

View file

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

43
app/lib/annual_report.rb Normal file
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View file

@ -28,7 +28,7 @@ class DeliveryFailureTracker
end
def available?
!UnavailableDomain.where(domain: @host).exists?
!UnavailableDomain.exists?(domain: @host)
end
def exhausted_deliveries_days

View file

@ -471,7 +471,7 @@ class FeedManager
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 ||= 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
end
@ -494,7 +494,7 @@ class FeedManager
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 &&= !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
end

View file

@ -5,17 +5,46 @@ class PermalinkRedirector
def initialize(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
def redirect_path
if at_username_status_request? || statuses_status_request?
find_status_url_by_id(second_segment)
elsif at_username_request?
find_account_url_by_name(first_segment)
elsif accounts_request? && record_integer_id_request?
find_account_url_by_id(second_segment)
elsif @path.start_with?('/deck')
@path.delete_prefix('/deck')
return ActivityPub::TagManager.instance.url_for(object) if object.present?
@path.delete_prefix('/deck') if @path.start_with?('/deck')
end
def redirect_uri
return ActivityPub::TagManager.instance.uri_for(object) if object.present?
@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
@ -56,22 +85,4 @@ class PermalinkRedirector
def path_segments
@path_segments ||= @path.delete_prefix('/deck').delete_prefix('/').split('/')
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

View file

@ -26,11 +26,11 @@ class StatusCacheHydrator
def hydrate_non_reblog_payload(empty_payload, account_id)
empty_payload.tap do |payload|
payload[:favourited] = Favourite.where(account_id: account_id, status_id: @status.id).exists?
payload[:reblogged] = Status.where(account_id: account_id, reblog_of_id: @status.id).exists?
payload[:muted] = ConversationMute.where(account_id: account_id, conversation_id: @status.conversation_id).exists?
payload[:bookmarked] = Bookmark.where(account_id: account_id, status_id: @status.id).exists?
payload[:pinned] = StatusPin.where(account_id: account_id, status_id: @status.id).exists? if @status.account_id == account_id
payload[:favourited] = Favourite.exists?(account_id: account_id, status_id: @status.id)
payload[:reblogged] = Status.exists?(account_id: account_id, reblog_of_id: @status.id)
payload[:muted] = ConversationMute.exists?(account_id: account_id, conversation_id: @status.conversation_id)
payload[:bookmarked] = Bookmark.exists?(account_id: account_id, status_id: @status.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)
if payload[:poll]
@ -51,11 +51,11 @@ class StatusCacheHydrator
# 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][:favourited] = Favourite.where(account_id: account_id, status_id: @status.reblog_of_id).exists?
payload[:reblog][:reblogged] = Status.where(account_id: account_id, reblog_of_id: @status.reblog_of_id).exists?
payload[:reblog][:muted] = ConversationMute.where(account_id: account_id, conversation_id: @status.reblog.conversation_id).exists?
payload[:reblog][:bookmarked] = Bookmark.where(account_id: account_id, status_id: @status.reblog_of_id).exists?
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][:favourited] = Favourite.exists?(account_id: account_id, status_id: @status.reblog_of_id)
payload[:reblog][:reblogged] = Status.exists?(account_id: account_id, reblog_of_id: @status.reblog_of_id)
payload[:reblog][:muted] = ConversationMute.exists?(account_id: account_id, conversation_id: @status.reblog.conversation_id)
payload[:reblog][:bookmarked] = Bookmark.exists?(account_id: account_id, status_id: @status.reblog_of_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]
if payload[:reblog][:poll]

View file

@ -19,7 +19,7 @@ class SuspiciousSignInDetector
end
def previously_seen_ip?(request)
@user.ips.where('ip <<= ?', masked_ip(request)).exists?
@user.ips.exists?(['ip <<= ?', masked_ip(request)])
end
def freshly_signed_up?

View file

@ -27,11 +27,17 @@ class Vacuum::MediaAttachmentsVacuum
end
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
def orphaned_media_attachments
MediaAttachment.unattached.where(MediaAttachment.arel_table[:created_at].lt(TTL.ago))
MediaAttachment
.unattached
.created_before(TTL.ago)
end
def retention_period?

View file

@ -191,6 +191,18 @@ class UserMailer < Devise::Mailer
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
def default_devise_subject

View file

@ -127,10 +127,11 @@ class Account < ApplicationRecord
scope :bots, -> { where(actor_type: %w(Application Service)) }
scope :groups, -> { where(actor_type: 'Group') }
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_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 :auditable, -> { where(id: Admin::ActionLog.select(:account_id).distinct) }
scope :searchable, -> { without_unapproved.without_suspended.where(moved_to_account_id: nil) }
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) }

View file

@ -29,7 +29,7 @@ class AccountSuggestions
# a complicated query on this end.
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)|
next unless accounts_map.key?(account_id)

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