Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Essem 2024-01-27 15:31:24 -06:00
commit 3add1ded91
No known key found for this signature in database
GPG key ID: 7D497397CC3A2A8C
97 changed files with 1061 additions and 727 deletions

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
# This configuration was generated by # This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp` # `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp`
# using RuboCop version 1.59.0. # using RuboCop version 1.60.2.
# The point is for the user to remove these configuration records # The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base. # one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new # Note that changes in the inspected code, or installation of new
@ -70,25 +70,6 @@ Rails/UniqueValidationWithoutIndex:
- 'app/models/identity.rb' - 'app/models/identity.rb'
- 'app/models/webauthn_credential.rb' - 'app/models/webauthn_credential.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: EnforcedStyle.
# SupportedStyles: exists, where
Rails/WhereExists:
Exclude:
- 'app/controllers/activitypub/inboxes_controller.rb'
- 'app/controllers/admin/email_domain_blocks_controller.rb'
- 'app/lib/activitypub/activity/create.rb'
- 'app/lib/delivery_failure_tracker.rb'
- 'app/lib/feed_manager.rb'
- 'app/lib/suspicious_sign_in_detector.rb'
- 'app/policies/status_policy.rb'
- 'app/serializers/rest/announcement_serializer.rb'
- 'app/workers/move_worker.rb'
- 'spec/models/account_spec.rb'
- 'spec/services/activitypub/process_collection_service_spec.rb'
- 'spec/services/purge_domain_service_spec.rb'
- 'spec/services/unallow_domain_service_spec.rb'
# This cop supports unsafe autocorrection (--autocorrect-all). # This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: AllowedMethods, AllowedPatterns. # Configuration parameters: AllowedMethods, AllowedPatterns.
# AllowedMethods: ==, equal?, eql? # AllowedMethods: ==, equal?, eql?
@ -298,13 +279,6 @@ Style/StringLiterals:
- 'config/initializers/webauthn.rb' - 'config/initializers/webauthn.rb'
- 'config/routes.rb' - 'config/routes.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyle, AllowSafeAssignment.
# SupportedStyles: require_parentheses, require_no_parentheses, require_parentheses_when_complex
Style/TernaryParentheses:
Exclude:
- 'config/environments/development.rb'
# This cop supports safe autocorrection (--autocorrect). # This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyleForMultiline. # Configuration parameters: EnforcedStyleForMultiline.
# SupportedStylesForMultiline: comma, consistent_comma, no_comma # SupportedStylesForMultiline: comma, consistent_comma, no_comma

View file

@ -180,7 +180,7 @@ GEM
activesupport activesupport
cbor (0.5.9.6) cbor (0.5.9.6)
charlock_holmes (0.7.7) charlock_holmes (0.7.7)
chewy (7.4.0) chewy (7.5.0)
activesupport (>= 5.2) activesupport (>= 5.2)
elasticsearch (>= 7.12.0, < 7.14.0) elasticsearch (>= 7.12.0, < 7.14.0)
elasticsearch-dsl elasticsearch-dsl
@ -319,7 +319,7 @@ GEM
activesupport (>= 5.1) activesupport (>= 5.1)
haml (>= 4.0.6) haml (>= 4.0.6)
railties (>= 5.1) railties (>= 5.1)
haml_lint (0.53.0) haml_lint (0.55.0)
haml (>= 5.0) haml (>= 5.0)
parallel (~> 1.10) parallel (~> 1.10)
rainbow rainbow
@ -360,7 +360,7 @@ GEM
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
terminal-table (>= 1.5.1) terminal-table (>= 1.5.1)
idn-ruby (0.1.5) idn-ruby (0.1.5)
io-console (0.7.1) io-console (0.7.2)
irb (1.11.1) irb (1.11.1)
rdoc rdoc
reline (>= 0.4.2) reline (>= 0.4.2)
@ -445,7 +445,7 @@ GEM
mime-types-data (3.2023.1205) mime-types-data (3.2023.1205)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.5) mini_portile2 (2.8.5)
minitest (5.20.0) minitest (5.21.2)
msgpack (1.7.2) msgpack (1.7.2)
multi_json (1.15.0) multi_json (1.15.0)
multipart-post (2.3.0) multipart-post (2.3.0)
@ -504,7 +504,7 @@ GEM
orm_adapter (0.5.0) orm_adapter (0.5.0)
ox (2.14.17) ox (2.14.17)
parallel (1.24.0) parallel (1.24.0)
parser (3.2.2.4) parser (3.3.0.5)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
parslet (2.0.0) parslet (2.0.0)
@ -610,7 +610,7 @@ GEM
redis (>= 4) redis (>= 4)
redlock (1.3.2) redlock (1.3.2)
redis (>= 3.0.0, < 6.0) redis (>= 3.0.0, < 6.0)
regexp_parser (2.8.3) regexp_parser (2.9.0)
reline (0.4.2) reline (0.4.2)
io-console (~> 0.5) io-console (~> 0.5)
request_store (1.5.1) request_store (1.5.1)
@ -636,7 +636,7 @@ GEM
rspec-mocks (3.12.6) rspec-mocks (3.12.6)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.12.0)
rspec-rails (6.1.0) rspec-rails (6.1.1)
actionpack (>= 6.1) actionpack (>= 6.1)
activesupport (>= 6.1) activesupport (>= 6.1)
railties (>= 6.1) railties (>= 6.1)
@ -650,11 +650,11 @@ GEM
rspec-mocks (~> 3.0) rspec-mocks (~> 3.0)
sidekiq (>= 5, < 8) sidekiq (>= 5, < 8)
rspec-support (3.12.1) rspec-support (3.12.1)
rubocop (1.59.0) rubocop (1.60.2)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (>= 3.17.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.2.2.4) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0) rexml (>= 3.2.5, < 4.0)
@ -696,7 +696,8 @@ GEM
scenic (1.7.0) scenic (1.7.0)
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
railties (>= 4.0.0) railties (>= 4.0.0)
selenium-webdriver (4.16.0) selenium-webdriver (4.17.0)
base64 (~> 0.2)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0) rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0) websocket (~> 1.0)

View file

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

View file

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

View file

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

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

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

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

View file

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

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

View file

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

View file

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

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

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

View file

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

View file

@ -521,7 +521,7 @@
"poll.total_people": "{count, plural, one {# persona} other {# persones}}", "poll.total_people": "{count, plural, one {# persona} other {# persones}}",
"poll.total_votes": "{count, plural, one {# vot} other {# vots}}", "poll.total_votes": "{count, plural, one {# vot} other {# vots}}",
"poll.vote": "Vota", "poll.vote": "Vota",
"poll.voted": "Vas votar per aquesta resposta", "poll.voted": "Vau votar aquesta resposta",
"poll.votes": "{votes, plural, one {# vot} other {# vots}}", "poll.votes": "{votes, plural, one {# vot} other {# vots}}",
"poll_button.add_poll": "Afegeix una enquesta", "poll_button.add_poll": "Afegeix una enquesta",
"poll_button.remove_poll": "Elimina l'enquesta", "poll_button.remove_poll": "Elimina l'enquesta",

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -470,8 +470,8 @@ class FeedManager
check_for_blocks = status.active_mentions.pluck(:account_id) check_for_blocks = status.active_mentions.pluck(:account_id)
check_for_blocks.push(status.in_reply_to_account) if status.reply? && !status.in_reply_to_account_id.nil? check_for_blocks.push(status.in_reply_to_account) if status.reply? && !status.in_reply_to_account_id.nil?
should_filter = blocks_or_mutes?(receiver_id, check_for_blocks, :mentions) # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked (or muted) should_filter = blocks_or_mutes?(receiver_id, check_for_blocks, :mentions) # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked (or muted)
should_filter ||= status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists? # of if the account is silenced and I'm not following them should_filter ||= status.account.silenced? && !Follow.exists?(account_id: receiver_id, target_account_id: status.account_id) # Filter if the account is silenced and I'm not following them
should_filter should_filter
end end
@ -494,7 +494,7 @@ class FeedManager
if status.reply? && status.in_reply_to_account_id != status.account_id if status.reply? && status.in_reply_to_account_id != status.account_id
should_filter = status.in_reply_to_account_id != list.account_id should_filter = status.in_reply_to_account_id != list.account_id
should_filter &&= !list.show_followed? should_filter &&= !list.show_followed?
should_filter &&= !(list.show_list? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?) should_filter &&= !(list.show_list? && ListAccount.exists?(list_id: list.id, account_id: status.in_reply_to_account_id))
return !!should_filter return !!should_filter
end end

View file

@ -5,17 +5,46 @@ class PermalinkRedirector
def initialize(path) def initialize(path)
@path = path @path = path
@object = nil
end
def object
@object ||= begin
if at_username_status_request? || statuses_status_request?
status = Status.find_by(id: second_segment)
status if status&.distributable? && !status&.local?
elsif at_username_request?
username, domain = first_segment.delete_prefix('@').split('@')
domain = nil if TagManager.instance.local_domain?(domain)
account = Account.find_remote(username, domain)
account unless account&.local?
elsif accounts_request? && record_integer_id_request?
account = Account.find_by(id: second_segment)
account unless account&.local?
end
end
end end
def redirect_path def redirect_path
if at_username_status_request? || statuses_status_request? return ActivityPub::TagManager.instance.url_for(object) if object.present?
find_status_url_by_id(second_segment)
elsif at_username_request? @path.delete_prefix('/deck') if @path.start_with?('/deck')
find_account_url_by_name(first_segment) end
elsif accounts_request? && record_integer_id_request?
find_account_url_by_id(second_segment) def redirect_uri
elsif @path.start_with?('/deck') return ActivityPub::TagManager.instance.uri_for(object) if object.present?
@path.delete_prefix('/deck')
@path.delete_prefix('/deck') if @path.start_with?('/deck')
end
def redirect_confirmation_path
case object.class.name
when 'Account'
redirect_account_path(object.id)
when 'Status'
redirect_status_path(object.id)
else
@path.delete_prefix('/deck') if @path.start_with?('/deck')
end end
end end
@ -56,22 +85,4 @@ class PermalinkRedirector
def path_segments def path_segments
@path_segments ||= @path.delete_prefix('/deck').delete_prefix('/').split('/') @path_segments ||= @path.delete_prefix('/deck').delete_prefix('/').split('/')
end end
def find_status_url_by_id(id)
status = Status.find_by(id: id)
ActivityPub::TagManager.instance.url_for(status) if status&.distributable? && !status.account.local?
end
def find_account_url_by_id(id)
account = Account.find_by(id: id)
ActivityPub::TagManager.instance.url_for(account) if account.present? && !account.local?
end
def find_account_url_by_name(name)
username, domain = name.gsub(/\A@/, '').split('@')
domain = nil if TagManager.instance.local_domain?(domain)
account = Account.find_remote(username, domain)
ActivityPub::TagManager.instance.url_for(account) if account.present? && !account.local?
end
end end

View file

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

View file

@ -68,16 +68,7 @@ class CustomFilter < ApplicationRecord
scope = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account_id: account_id }).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()')) scope = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account_id: account_id }).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()'))
scope.to_a.group_by(&:custom_filter).each do |filter, keywords| scope.to_a.group_by(&:custom_filter).each do |filter, keywords|
keywords.map! do |keyword| keywords.map!(&:to_regex)
if keyword.whole_word
sb = /\A[[:word:]]/.match?(keyword.keyword) ? '\b' : ''
eb = /[[:word:]]\z/.match?(keyword.keyword) ? '\b' : ''
/(?mix:#{sb}#{Regexp.escape(keyword.keyword)}#{eb})/
else
/#{Regexp.escape(keyword.keyword)}/i
end
end
filters_hash[filter.id] = { keywords: Regexp.union(keywords), filter: filter } filters_hash[filter.id] = { keywords: Regexp.union(keywords), filter: filter }
end.to_h end.to_h

View file

@ -23,8 +23,24 @@ class CustomFilterKeyword < ApplicationRecord
before_destroy :prepare_cache_invalidation! before_destroy :prepare_cache_invalidation!
after_commit :invalidate_cache! after_commit :invalidate_cache!
def to_regex
if whole_word?
/(?mix:#{to_regex_sb}#{Regexp.escape(keyword)}#{to_regex_eb})/
else
/#{Regexp.escape(keyword)}/i
end
end
private private
def to_regex_sb
/\A[[:word:]]/.match?(keyword) ? '\b' : ''
end
def to_regex_eb
/[[:word:]]\z/.match?(keyword) ? '\b' : ''
end
def prepare_cache_invalidation! def prepare_cache_invalidation!
custom_filter.prepare_cache_invalidation! custom_filter.prepare_cache_invalidation!
end end

View file

@ -13,12 +13,12 @@ class Instance < ApplicationRecord
attr_accessor :failure_days attr_accessor :failure_days
has_many :accounts, foreign_key: :domain, primary_key: :domain, inverse_of: false
with_options foreign_key: :domain, primary_key: :domain, inverse_of: false do with_options foreign_key: :domain, primary_key: :domain, inverse_of: false do
belongs_to :domain_block belongs_to :domain_block
belongs_to :domain_allow belongs_to :domain_allow
belongs_to :unavailable_domain # skipcq: RB-RL1031 belongs_to :unavailable_domain
has_many :accounts, dependent: nil
end end
scope :searchable, -> { where.not(domain: DomainBlock.select(:domain)) } scope :searchable, -> { where.not(domain: DomainBlock.select(:domain)) }

View file

@ -62,7 +62,7 @@ class StatusPolicy < ApplicationPolicy
if record.mentions.loaded? if record.mentions.loaded?
record.mentions.any? { |mention| mention.account_id == current_account.id } record.mentions.any? { |mention| mention.account_id == current_account.id }
else else
record.mentions.where(account: current_account).exists? record.mentions.exists?(account: current_account)
end end
end end

View file

@ -23,7 +23,7 @@ class REST::AnnouncementSerializer < ActiveModel::Serializer
end end
def read def read
object.announcement_mutes.where(account: current_user.account).exists? object.announcement_mutes.exists?(account: current_user.account)
end end
def content def content

View file

@ -0,0 +1,8 @@
.redirect
.redirect__logo
= link_to render_logo, root_path
.redirect__message
%h1= t('redirects.title', instance: site_hostname)
%p= t('redirects.prompt')
%p= link_to @redirect_path, @redirect_path, rel: 'noreferrer noopener'

View file

@ -123,7 +123,7 @@ class MoveWorker
end end
def add_account_note_if_needed!(account, id) def add_account_note_if_needed!(account, id)
unless AccountNote.where(account: account, target_account: @target_account).exists? unless AccountNote.exists?(account: account, target_account: @target_account)
text = I18n.with_locale(account.user&.locale.presence || I18n.default_locale) do text = I18n.with_locale(account.user&.locale.presence || I18n.default_locale) do
I18n.t(id, acct: @source_account.acct) I18n.t(id, acct: @source_account.acct)
end end

View file

@ -85,7 +85,7 @@ Rails.application.configure do
# If using a Heroku, Vagrant or generic remote development environment, # If using a Heroku, Vagrant or generic remote development environment,
# use letter_opener_web, accessible at /letter_opener. # use letter_opener_web, accessible at /letter_opener.
# Otherwise, use letter_opener, which launches a browser window to view sent mail. # Otherwise, use letter_opener, which launches a browser window to view sent mail.
config.action_mailer.delivery_method = (ENV['HEROKU'] || ENV['VAGRANT'] || ENV['REMOTE_DEV']) ? :letter_opener_web : :letter_opener config.action_mailer.delivery_method = ENV['HEROKU'] || ENV['VAGRANT'] || ENV['REMOTE_DEV'] ? :letter_opener_web : :letter_opener
# We provide a default secret for the development environment here. # We provide a default secret for the development environment here.
# This value should not be used in production environments! # This value should not be used in production environments!

View file

@ -443,6 +443,9 @@ br:
preferences: preferences:
other: All other: All
posting_defaults: Arventennoù embann dre ziouer posting_defaults: Arventennoù embann dre ziouer
redirects:
prompt: M'ho peus fiziañs el liamm-mañ, klikit warnañ evit kenderc'hel.
title: O kuitaat %{instance} emaoc'h.
relationships: relationships:
dormant: O kousket dormant: O kousket
followers: Heulier·ezed·ien followers: Heulier·ezed·ien

View file

@ -1546,6 +1546,9 @@ ca:
errors: errors:
limit_reached: Límit de diferents reaccions assolit limit_reached: Límit de diferents reaccions assolit
unrecognized_emoji: no és un emoji reconegut unrecognized_emoji: no és un emoji reconegut
redirects:
prompt: Si confieu en aquest enllaç, feu-hi clic per a continuar.
title: Esteu sortint de %{instance}.
relationships: relationships:
activity: Activitat del compte activity: Activitat del compte
confirm_follow_selected_followers: Segur que vols seguir els seguidors seleccionats? confirm_follow_selected_followers: Segur que vols seguir els seguidors seleccionats?

View file

@ -1546,6 +1546,9 @@ da:
errors: errors:
limit_reached: Grænse for forskellige reaktioner nået limit_reached: Grænse for forskellige reaktioner nået
unrecognized_emoji: er ikke en genkendt emoji unrecognized_emoji: er ikke en genkendt emoji
redirects:
prompt: Er der tillid til dette link, så klik på det for at fortsætte.
title: Nu forlades %{instance}.
relationships: relationships:
activity: Kontoaktivitet activity: Kontoaktivitet
confirm_follow_selected_followers: Sikker på, at de valgte følgere skal følges? confirm_follow_selected_followers: Sikker på, at de valgte følgere skal følges?

View file

@ -1546,6 +1546,9 @@ de:
errors: errors:
limit_reached: Limit für verschiedene Reaktionen erreicht limit_reached: Limit für verschiedene Reaktionen erreicht
unrecognized_emoji: ist ein unbekanntes Emoji unrecognized_emoji: ist ein unbekanntes Emoji
redirects:
prompt: Wenn du diesem Link vertraust, dann klicke ihn an, um fortzufahren.
title: Du verlässt %{instance}.
relationships: relationships:
activity: Kontoaktivität activity: Kontoaktivität
confirm_follow_selected_followers: Möchtest du den ausgewählten Followern folgen? confirm_follow_selected_followers: Möchtest du den ausgewählten Followern folgen?

View file

@ -73,9 +73,13 @@ fr-CA:
subject: 'Mastodon: Clé de sécurité supprimée' subject: 'Mastodon: Clé de sécurité supprimée'
title: Une de vos clés de sécurité a été supprimée title: Une de vos clés de sécurité a été supprimée
webauthn_disabled: webauthn_disabled:
explanation: L'authentification avec les clés de sécurité a été désactivée pour votre compte.
extra: La connexion est maintenant possible en utilisant uniquement le jeton généré par l'application TOTP associée.
subject: 'Mastodon: Authentification avec clés de sécurité désactivée' subject: 'Mastodon: Authentification avec clés de sécurité désactivée'
title: Clés de sécurité désactivées title: Clés de sécurité désactivées
webauthn_enabled: webauthn_enabled:
explanation: L'authentification par clé de sécurité a été activée pour votre compte.
extra: Votre clé de sécurité peut maintenant être utilisée pour vous connecter.
subject: 'Mastodon: Authentification de la clé de sécurité activée' subject: 'Mastodon: Authentification de la clé de sécurité activée'
title: Clés de sécurité activées title: Clés de sécurité activées
omniauth_callbacks: omniauth_callbacks:

View file

@ -73,9 +73,13 @@ fr:
subject: 'Mastodon: Clé de sécurité supprimée' subject: 'Mastodon: Clé de sécurité supprimée'
title: Une de vos clés de sécurité a été supprimée title: Une de vos clés de sécurité a été supprimée
webauthn_disabled: webauthn_disabled:
explanation: L'authentification avec les clés de sécurité a été désactivée pour votre compte.
extra: La connexion est maintenant possible en utilisant uniquement le jeton généré par l'application TOTP associée.
subject: 'Mastodon: Authentification avec clés de sécurité désactivée' subject: 'Mastodon: Authentification avec clés de sécurité désactivée'
title: Clés de sécurité désactivées title: Clés de sécurité désactivées
webauthn_enabled: webauthn_enabled:
explanation: L'authentification par clé de sécurité a été activée pour votre compte.
extra: Votre clé de sécurité peut maintenant être utilisée pour vous connecter.
subject: 'Mastodon: Authentification de la clé de sécurité activée' subject: 'Mastodon: Authentification de la clé de sécurité activée'
title: Clés de sécurité activées title: Clés de sécurité activées
omniauth_callbacks: omniauth_callbacks:

View file

@ -77,6 +77,7 @@ sv:
subject: 'Mastodon: Autentisering med säkerhetsnycklar är inaktiverat' subject: 'Mastodon: Autentisering med säkerhetsnycklar är inaktiverat'
title: Säkerhetsnycklar inaktiverade title: Säkerhetsnycklar inaktiverade
webauthn_enabled: webauthn_enabled:
extra: Din säkerhetsnyckel kan nu användas för inloggning.
subject: 'Mastodon: Autentisering med säkerhetsnyckel är aktiverat' subject: 'Mastodon: Autentisering med säkerhetsnyckel är aktiverat'
title: Säkerhetsnycklar aktiverade title: Säkerhetsnycklar aktiverade
omniauth_callbacks: omniauth_callbacks:

View file

@ -47,14 +47,14 @@ zh-TW:
subject: Mastodon重設密碼指引 subject: Mastodon重設密碼指引
title: 重設密碼 title: 重設密碼
two_factor_disabled: two_factor_disabled:
explanation: 現在僅可使用電子郵件地址與密碼登入。 explanation: 目前僅可使用電子郵件地址與密碼登入。
subject: Mastodon已停用兩階段驗證 subject: Mastodon已停用兩階段驗證
subtitle: 您帳號的兩步驟驗證已停用。 subtitle: 您帳號之兩階段驗證已停用。
title: 已停用兩階段驗證 title: 已停用兩階段驗證
two_factor_enabled: two_factor_enabled:
explanation: 登入時需要配對的 TOTP 應用程式產生的權杖 explanation: 登入時需要配對的 TOTP 應用程式產生之 token
subject: Mastodon已啟用兩階段驗證 subject: Mastodon已啟用兩階段驗證
subtitle: 您的帳號已啟用兩步驟驗證 subtitle: 您的帳號之兩階段驗證已啟用。
title: 已啟用兩階段驗證 title: 已啟用兩階段驗證
two_factor_recovery_codes_changed: two_factor_recovery_codes_changed:
explanation: 之前的備用驗證碼已經失效,且已產生新的。 explanation: 之前的備用驗證碼已經失效,且已產生新的。
@ -74,12 +74,12 @@ zh-TW:
title: 您的一支安全密鑰已經被移除 title: 您的一支安全密鑰已經被移除
webauthn_disabled: webauthn_disabled:
explanation: 您的帳號已停用安全金鑰身份驗證。 explanation: 您的帳號已停用安全金鑰身份驗證。
extra: 現在僅可使用配對的 TOTP 應用程式產生的權杖登入。 extra: 現在僅可使用配對的 TOTP 應用程式產生之 token 登入。
subject: Mastodon安全密鑰認證方式已停用 subject: Mastodon安全密鑰認證方式已停用
title: 已停用安全密鑰 title: 已停用安全密鑰
webauthn_enabled: webauthn_enabled:
explanation: 您的帳號已啟用安全金鑰驗證。 explanation: 您的帳號已啟用安全金鑰身分驗證。
extra: 您的安全金鑰現在可用於登入。 extra: 您的安全金鑰現在可用於登入。
subject: Mastodon已啟用安全密鑰認證 subject: Mastodon已啟用安全密鑰認證
title: 已啟用安全密鑰 title: 已啟用安全密鑰
omniauth_callbacks: omniauth_callbacks:

View file

@ -1547,6 +1547,9 @@ en:
errors: errors:
limit_reached: Limit of different reactions reached limit_reached: Limit of different reactions reached
unrecognized_emoji: is not a recognized emoji unrecognized_emoji: is not a recognized emoji
redirects:
prompt: If you trust this link, click it to continue.
title: You are leaving %{instance}.
relationships: relationships:
activity: Account activity activity: Account activity
confirm_follow_selected_followers: Are you sure you want to follow selected followers? confirm_follow_selected_followers: Are you sure you want to follow selected followers?

View file

@ -1546,6 +1546,9 @@ es-AR:
errors: errors:
limit_reached: Se alcanzó el límite de reacciones diferentes limit_reached: Se alcanzó el límite de reacciones diferentes
unrecognized_emoji: no es un emoji conocido unrecognized_emoji: no es un emoji conocido
redirects:
prompt: Si confiás en este enlace, dale clic o un toque para continuar.
title: Estás dejando %{instance}.
relationships: relationships:
activity: Actividad de la cuenta activity: Actividad de la cuenta
confirm_follow_selected_followers: "¿Estás seguro que querés seguir a los seguidores seleccionados?" confirm_follow_selected_followers: "¿Estás seguro que querés seguir a los seguidores seleccionados?"

View file

@ -1546,6 +1546,9 @@ es-MX:
errors: errors:
limit_reached: Límite de reacciones diferentes alcanzado limit_reached: Límite de reacciones diferentes alcanzado
unrecognized_emoji: no es un emoji conocido unrecognized_emoji: no es un emoji conocido
redirects:
prompt: Si confías en este enlace, púlsalo para continuar.
title: Vas a salir de %{instance}.
relationships: relationships:
activity: Actividad de la cuenta activity: Actividad de la cuenta
confirm_follow_selected_followers: "¿Estás seguro de que quieres seguir a las cuentas seleccionadas?" confirm_follow_selected_followers: "¿Estás seguro de que quieres seguir a las cuentas seleccionadas?"

View file

@ -1546,6 +1546,9 @@ es:
errors: errors:
limit_reached: Límite de reacciones diferentes alcanzado limit_reached: Límite de reacciones diferentes alcanzado
unrecognized_emoji: no es un emoji conocido unrecognized_emoji: no es un emoji conocido
redirects:
prompt: Si confías en este enlace, púlsalo para continuar.
title: Vas a salir de %{instance}.
relationships: relationships:
activity: Actividad de la cuenta activity: Actividad de la cuenta
confirm_follow_selected_followers: "¿Estás seguro de que quieres seguir a las cuentas seleccionadas?" confirm_follow_selected_followers: "¿Estás seguro de que quieres seguir a las cuentas seleccionadas?"

View file

@ -1550,6 +1550,9 @@ eu:
errors: errors:
limit_reached: Erreakzio desberdinen muga gaindituta limit_reached: Erreakzio desberdinen muga gaindituta
unrecognized_emoji: ez da emoji ezaguna unrecognized_emoji: ez da emoji ezaguna
redirects:
prompt: Esteka honetan fidatzen bazara, egin klik jarraitzeko.
title: "%{instance} instantziatik zoaz."
relationships: relationships:
activity: Kontuaren aktibitatea activity: Kontuaren aktibitatea
confirm_follow_selected_followers: Ziur hautatutako jarraitzaileei jarraitu nahi dituzula? confirm_follow_selected_followers: Ziur hautatutako jarraitzaileei jarraitu nahi dituzula?

View file

@ -1546,6 +1546,9 @@ fi:
errors: errors:
limit_reached: Erilaisten reaktioiden raja saavutettu limit_reached: Erilaisten reaktioiden raja saavutettu
unrecognized_emoji: ei ole tunnistettu emoji unrecognized_emoji: ei ole tunnistettu emoji
redirects:
prompt: Jos luotat tähän linkkiin, jatka napsauttamalla.
title: Olet poistumassa palvelimelta %{instance}.
relationships: relationships:
activity: Tilin aktiivisuus activity: Tilin aktiivisuus
confirm_follow_selected_followers: Haluatko varmasti seurata valittuja seuraajia? confirm_follow_selected_followers: Haluatko varmasti seurata valittuja seuraajia?
@ -1791,8 +1794,8 @@ fi:
subject: Arkisto on valmiina ladattavaksi subject: Arkisto on valmiina ladattavaksi
title: Arkiston tallennus title: Arkiston tallennus
failed_2fa: failed_2fa:
details: 'Tässä on tiedot kirjautumisyrityksestä:' details: 'Tässä on tietoja kirjautumisyrityksestä:'
explanation: Joku on yrittänyt kirjautua tilillesi, mutta antanut virheellisen kaksivaiheisen todennuksen. explanation: Joku on yrittänyt kirjautua tilillesi mutta on antanut virheellisen toisen vaiheen todennustekijän.
further_actions_html: Jos se et ollut sinä, suosittelemme, että %{action} välittömästi, sillä se on saattanut vaarantua. further_actions_html: Jos se et ollut sinä, suosittelemme, että %{action} välittömästi, sillä se on saattanut vaarantua.
subject: Kaksivaiheisen todennuksen virhe subject: Kaksivaiheisen todennuksen virhe
title: Epäonnistunut kaksivaiheinen todennus title: Epäonnistunut kaksivaiheinen todennus

View file

@ -1546,6 +1546,9 @@ fo:
errors: errors:
limit_reached: Mark fyri ymisk aftursvar rokkið limit_reached: Mark fyri ymisk aftursvar rokkið
unrecognized_emoji: er ikki eitt kenslutekn, sum kennist aftur unrecognized_emoji: er ikki eitt kenslutekn, sum kennist aftur
redirects:
prompt: Um tú lítir á hetta leinkið, so kanst tú klikkja á tað fyri at halda fram.
title: Tú fer burtur úr %{instance}.
relationships: relationships:
activity: Kontuvirksemi activity: Kontuvirksemi
confirm_follow_selected_followers: Vil tú veruliga fylgja valdu fylgjarunum? confirm_follow_selected_followers: Vil tú veruliga fylgja valdu fylgjarunum?

View file

@ -1546,6 +1546,9 @@ fr-CA:
errors: errors:
limit_reached: Limite de réactions différentes atteinte limit_reached: Limite de réactions différentes atteinte
unrecognized_emoji: nest pas un émoji reconnu unrecognized_emoji: nest pas un émoji reconnu
redirects:
prompt: Si vous faites confiance à ce lien, cliquez pour continuer.
title: Vous quittez %{instance}.
relationships: relationships:
activity: Activité du compte activity: Activité du compte
confirm_follow_selected_followers: Voulez-vous vraiment suivre les abonné⋅e⋅s sélectionné⋅e⋅s ? confirm_follow_selected_followers: Voulez-vous vraiment suivre les abonné⋅e⋅s sélectionné⋅e⋅s ?
@ -1790,6 +1793,12 @@ fr-CA:
extra: Elle est maintenant prête à être téléchargée ! extra: Elle est maintenant prête à être téléchargée !
subject: Votre archive est prête à être téléchargée subject: Votre archive est prête à être téléchargée
title: Récupération de larchive title: Récupération de larchive
failed_2fa:
details: 'Voici les détails de la tentative de connexion :'
explanation: Quelqu'un a essayé de se connecter à votre compte mais a fourni un second facteur d'authentification invalide.
further_actions_html: Si ce n'était pas vous, nous vous recommandons %{action} immédiatement car il pourrait être compromis.
subject: Échec de l'authentification à double facteur
title: Échec de l'authentification à double facteur
suspicious_sign_in: suspicious_sign_in:
change_password: changer votre mot de passe change_password: changer votre mot de passe
details: 'Voici les détails de la connexion :' details: 'Voici les détails de la connexion :'
@ -1843,6 +1852,7 @@ fr-CA:
go_to_sso_account_settings: Accédez aux paramètres du compte de votre fournisseur d'identité go_to_sso_account_settings: Accédez aux paramètres du compte de votre fournisseur d'identité
invalid_otp_token: Le code dauthentification à deux facteurs est invalide invalid_otp_token: Le code dauthentification à deux facteurs est invalide
otp_lost_help_html: Si vous perdez accès aux deux, vous pouvez contacter %{email} otp_lost_help_html: Si vous perdez accès aux deux, vous pouvez contacter %{email}
rate_limited: Trop de tentatives d'authentification, réessayez plus tard.
seamless_external_login: Vous êtes connecté via un service externe, donc les paramètres concernant le mot de passe et le courriel ne sont pas disponibles. seamless_external_login: Vous êtes connecté via un service externe, donc les paramètres concernant le mot de passe et le courriel ne sont pas disponibles.
signed_in_as: 'Connecté·e en tant que:' signed_in_as: 'Connecté·e en tant que:'
verification: verification:

View file

@ -1546,6 +1546,9 @@ fr:
errors: errors:
limit_reached: Limite de réactions différentes atteinte limit_reached: Limite de réactions différentes atteinte
unrecognized_emoji: nest pas un émoji reconnu unrecognized_emoji: nest pas un émoji reconnu
redirects:
prompt: Si vous faites confiance à ce lien, cliquez pour continuer.
title: Vous quittez %{instance}.
relationships: relationships:
activity: Activité du compte activity: Activité du compte
confirm_follow_selected_followers: Voulez-vous vraiment suivre les abonné⋅e⋅s sélectionné⋅e⋅s ? confirm_follow_selected_followers: Voulez-vous vraiment suivre les abonné⋅e⋅s sélectionné⋅e⋅s ?
@ -1790,6 +1793,12 @@ fr:
extra: Elle est maintenant prête à être téléchargée ! extra: Elle est maintenant prête à être téléchargée !
subject: Votre archive est prête à être téléchargée subject: Votre archive est prête à être téléchargée
title: Récupération de larchive title: Récupération de larchive
failed_2fa:
details: 'Voici les détails de la tentative de connexion :'
explanation: Quelqu'un a essayé de se connecter à votre compte mais a fourni un second facteur d'authentification invalide.
further_actions_html: Si ce n'était pas vous, nous vous recommandons %{action} immédiatement car il pourrait être compromis.
subject: Échec de l'authentification à double facteur
title: Échec de l'authentification à double facteur
suspicious_sign_in: suspicious_sign_in:
change_password: changer votre mot de passe change_password: changer votre mot de passe
details: 'Voici les détails de la connexion :' details: 'Voici les détails de la connexion :'
@ -1843,6 +1852,7 @@ fr:
go_to_sso_account_settings: Accédez aux paramètres du compte de votre fournisseur d'identité go_to_sso_account_settings: Accédez aux paramètres du compte de votre fournisseur d'identité
invalid_otp_token: Le code dauthentification à deux facteurs est invalide invalid_otp_token: Le code dauthentification à deux facteurs est invalide
otp_lost_help_html: Si vous perdez accès aux deux, vous pouvez contacter %{email} otp_lost_help_html: Si vous perdez accès aux deux, vous pouvez contacter %{email}
rate_limited: Trop de tentatives d'authentification, réessayez plus tard.
seamless_external_login: Vous êtes connecté via un service externe, donc les paramètres concernant le mot de passe et le courriel ne sont pas disponibles. seamless_external_login: Vous êtes connecté via un service externe, donc les paramètres concernant le mot de passe et le courriel ne sont pas disponibles.
signed_in_as: 'Connecté·e en tant que:' signed_in_as: 'Connecté·e en tant que:'
verification: verification:

View file

@ -1546,6 +1546,9 @@ gl:
errors: errors:
limit_reached: Acadouse o límite das diferentes reaccións limit_reached: Acadouse o límite das diferentes reaccións
unrecognized_emoji: non é unha emoticona recoñecida unrecognized_emoji: non é unha emoticona recoñecida
redirects:
prompt: Se confías nesta ligazón, preme nela para continuar.
title: Vas saír de %{instance}.
relationships: relationships:
activity: Actividade da conta activity: Actividade da conta
confirm_follow_selected_followers: Tes a certeza de querer seguir as seguidoras seleccionadas? confirm_follow_selected_followers: Tes a certeza de querer seguir as seguidoras seleccionadas?

View file

@ -1598,6 +1598,9 @@ he:
errors: errors:
limit_reached: גבול מספר התגובות השונות הושג limit_reached: גבול מספר התגובות השונות הושג
unrecognized_emoji: הוא לא אמוג'י מוכר unrecognized_emoji: הוא לא אמוג'י מוכר
redirects:
prompt: יש ללחוץ על הקישור, אם לדעתך ניתן לסמוך עליו.
title: יציאה מתוך %{instance}.
relationships: relationships:
activity: רמת פעילות activity: רמת פעילות
confirm_follow_selected_followers: האם את/ה בטוח/ה שברצונך לעקוב אחרי החשבונות שסומנו? confirm_follow_selected_followers: האם את/ה בטוח/ה שברצונך לעקוב אחרי החשבונות שסומנו?
@ -1856,7 +1859,7 @@ he:
title: הוצאת ארכיון title: הוצאת ארכיון
failed_2fa: failed_2fa:
details: 'הנה פרטי נסיון ההתחברות:' details: 'הנה פרטי נסיון ההתחברות:'
explanation: פולני אלמוני ניסה להתחבר לחשבונך אך האימות המשני נכשל. explanation: פלוני אלמוני ניסה להתחבר לחשבונך אך האימות המשני נכשל.
further_actions_html: אם הנסיון לא היה שלך, אנו ממליצים על %{action} באופן מיידי כדי שהחשבון לא יפול קורבן. further_actions_html: אם הנסיון לא היה שלך, אנו ממליצים על %{action} באופן מיידי כדי שהחשבון לא יפול קורבן.
subject: נכשל אימות בגורם שני subject: נכשל אימות בגורם שני
title: אימות בגורם שני נכשל title: אימות בגורם שני נכשל

View file

@ -1546,6 +1546,8 @@ hu:
errors: errors:
limit_reached: A különböző reakciók száma elérte a határértéket limit_reached: A különböző reakciók száma elérte a határértéket
unrecognized_emoji: nem ismert emodzsi unrecognized_emoji: nem ismert emodzsi
redirects:
prompt: Ha megbízunk ebben a hivatkozásban, kattintsunk rá a folytatáshoz.
relationships: relationships:
activity: Fiók aktivitás activity: Fiók aktivitás
confirm_follow_selected_followers: Biztos, hogy követni akarod a kiválasztott követőket? confirm_follow_selected_followers: Biztos, hogy követni akarod a kiválasztott követőket?

View file

@ -1550,6 +1550,9 @@ is:
errors: errors:
limit_reached: Hámarki mismunandi viðbragða náð limit_reached: Hámarki mismunandi viðbragða náð
unrecognized_emoji: er ekki þekkt tjáningartákn unrecognized_emoji: er ekki þekkt tjáningartákn
redirects:
prompt: Ef þú treystir þessum tengli, geturðu smellt á hann til að halda áfram.
title: Þú ert að yfirgefa %{instance}.
relationships: relationships:
activity: Virkni aðgangs activity: Virkni aðgangs
confirm_follow_selected_followers: Ertu viss um að þú viljir fylgjast með völdum fylgjendum? confirm_follow_selected_followers: Ertu viss um að þú viljir fylgjast með völdum fylgjendum?

View file

@ -1548,6 +1548,9 @@ it:
errors: errors:
limit_reached: Raggiunto il limite di reazioni diverse limit_reached: Raggiunto il limite di reazioni diverse
unrecognized_emoji: non è un emoji riconosciuto unrecognized_emoji: non è un emoji riconosciuto
redirects:
prompt: Se ti fidi di questo collegamento, fai clic su di esso per continuare.
title: Stai lasciando %{instance}.
relationships: relationships:
activity: Attività dell'account activity: Attività dell'account
confirm_follow_selected_followers: Sei sicuro di voler seguire i follower selezionati? confirm_follow_selected_followers: Sei sicuro di voler seguire i follower selezionati?

View file

@ -1758,6 +1758,12 @@ ja:
extra: ダウンロードの準備ができました! extra: ダウンロードの準備ができました!
subject: アーカイブの準備ができました subject: アーカイブの準備ができました
title: アーカイブの取り出し title: アーカイブの取り出し
failed_2fa:
details: '試行されたログインの詳細は以下のとおりです:'
explanation: アカウントへのログインが試行されましたが、二要素認証で不正な回答が送信されました。
further_actions_html: このログインに心当たりがない場合は、ただちに%{action}してください。
subject: 二要素認証に失敗しました
title: 二要素認証に失敗した記録があります
suspicious_sign_in: suspicious_sign_in:
change_password: パスワードを変更 change_password: パスワードを変更
details: 'ログインの詳細は以下のとおりです:' details: 'ログインの詳細は以下のとおりです:'

View file

@ -1522,6 +1522,9 @@ ko:
errors: errors:
limit_reached: 리액션 갯수 제한에 도달했습니다 limit_reached: 리액션 갯수 제한에 도달했습니다
unrecognized_emoji: 인식 되지 않은 에모지입니다 unrecognized_emoji: 인식 되지 않은 에모지입니다
redirects:
prompt: 이 링크를 믿을 수 있다면, 클릭해서 계속하세요.
title: "%{instance}를 떠나려고 합니다."
relationships: relationships:
activity: 계정 활동 activity: 계정 활동
confirm_follow_selected_followers: 정말로 선택된 팔로워들을 팔로우하시겠습니까? confirm_follow_selected_followers: 정말로 선택된 팔로워들을 팔로우하시겠습니까?
@ -1762,6 +1765,10 @@ ko:
title: 아카이브 테이크아웃 title: 아카이브 테이크아웃
failed_2fa: failed_2fa:
details: '로그인 시도에 대한 상세 정보입니다:' details: '로그인 시도에 대한 상세 정보입니다:'
explanation: 누군가가 내 계정에 로그인을 시도했지만 2차인증에 올바른 값을 입력하지 못했습니다.
further_actions_html: 만약 당신이 한 게 아니었다면 유출의 가능성이 있으니 가능한 빨리 %{action} 하시기 바랍니다.
subject: 2차 인증 실패
title: 2차 인증에 실패했습니다
suspicious_sign_in: suspicious_sign_in:
change_password: 암호 변경 change_password: 암호 변경
details: '로그인에 대한 상세 정보입니다:' details: '로그인에 대한 상세 정보입니다:'

View file

@ -1516,6 +1516,9 @@ lad:
errors: errors:
limit_reached: Limito de reaksyones desferentes alkansado limit_reached: Limito de reaksyones desferentes alkansado
unrecognized_emoji: no es un emoji konesido unrecognized_emoji: no es un emoji konesido
redirects:
prompt: Si konfiyas en este atadijo, klikalo para kontinuar.
title: Estas salyendo de %{instance}.
relationships: relationships:
activity: Aktivita del kuento activity: Aktivita del kuento
confirm_follow_selected_followers: Estas siguro ke keres segir a los suivantes eskojidos? confirm_follow_selected_followers: Estas siguro ke keres segir a los suivantes eskojidos?

View file

@ -478,6 +478,9 @@ lt:
other: Kita other: Kita
privacy: privacy:
hint_html: "<strong>Tikrink, kaip nori, kad tavo profilis ir įrašai būtų randami.</strong> Įjungus įvairias Mastodon funkcijas, jos gali padėti pasiekti platesnę auditoriją. Akimirką peržiūrėk šiuos nustatymus, kad įsitikintum, jog jie atitinka tavo naudojimo būdą." hint_html: "<strong>Tikrink, kaip nori, kad tavo profilis ir įrašai būtų randami.</strong> Įjungus įvairias Mastodon funkcijas, jos gali padėti pasiekti platesnę auditoriją. Akimirką peržiūrėk šiuos nustatymus, kad įsitikintum, jog jie atitinka tavo naudojimo būdą."
redirects:
prompt: Jei pasitiki šia nuoroda, spustelėk ją, kad tęstum.
title: Palieki %{instance}
remote_follow: remote_follow:
missing_resource: Jūsų paskyros nukreipimo URL nerasta missing_resource: Jūsų paskyros nukreipimo URL nerasta
scheduled_statuses: scheduled_statuses:

View file

@ -1546,6 +1546,9 @@ nl:
errors: errors:
limit_reached: Limiet van verschillende emoji-reacties bereikt limit_reached: Limiet van verschillende emoji-reacties bereikt
unrecognized_emoji: is geen bestaande emoji-reactie unrecognized_emoji: is geen bestaande emoji-reactie
redirects:
prompt: Als je deze link vertrouwt, klik er dan op om door te gaan.
title: Je verlaat %{instance}.
relationships: relationships:
activity: Accountactiviteit activity: Accountactiviteit
confirm_follow_selected_followers: Weet je zeker dat je de geselecteerde volgers wilt volgen? confirm_follow_selected_followers: Weet je zeker dat je de geselecteerde volgers wilt volgen?
@ -1792,7 +1795,8 @@ nl:
title: Archief ophalen title: Archief ophalen
failed_2fa: failed_2fa:
details: 'Hier zijn details van de aanmeldpoging:' details: 'Hier zijn details van de aanmeldpoging:'
explanation: Iemand heeft geprobeerd om in te loggen op uw account maar heeft een ongeldige tweede verificatiefactor opgegeven. explanation: Iemand heeft geprobeerd om in te loggen op jouw account maar heeft een ongeldige tweede verificatiefactor opgegeven.
further_actions_html: Als jij dit niet was, raden we je aan om onmiddellijk %{action} aangezien het in gevaar kan zijn.
subject: Tweede factor authenticatiefout subject: Tweede factor authenticatiefout
title: Tweestapsverificatie mislukt title: Tweestapsverificatie mislukt
suspicious_sign_in: suspicious_sign_in:

View file

@ -1546,6 +1546,9 @@ nn:
errors: errors:
limit_reached: Grensen for forskjellige reaksjoner nådd limit_reached: Grensen for forskjellige reaksjoner nådd
unrecognized_emoji: er ikke en gjenkjent emoji unrecognized_emoji: er ikke en gjenkjent emoji
redirects:
prompt: Hvis du stoler på denne lenken, så trykk på den for å fortsette.
title: Du forlater %{instance}.
relationships: relationships:
activity: Kontoaktivitet activity: Kontoaktivitet
confirm_follow_selected_followers: Er du sikker på at du ynskjer å fylgja dei valde fylgjarane? confirm_follow_selected_followers: Er du sikker på at du ynskjer å fylgja dei valde fylgjarane?

View file

@ -1546,6 +1546,9 @@
errors: errors:
limit_reached: Grensen for ulike reaksjoner nådd limit_reached: Grensen for ulike reaksjoner nådd
unrecognized_emoji: er ikke en gjenkjent emoji unrecognized_emoji: er ikke en gjenkjent emoji
redirects:
prompt: Hvis du stoler på denne lenken, så trykk på den for å fortsette.
title: Du forlater %{instance}.
relationships: relationships:
activity: Kontoaktivitet activity: Kontoaktivitet
confirm_follow_selected_followers: Er du sikker på at du vil følge valgte følgere? confirm_follow_selected_followers: Er du sikker på at du vil følge valgte følgere?

View file

@ -1598,6 +1598,9 @@ pl:
errors: errors:
limit_reached: Przekroczono limit różnych reakcji limit_reached: Przekroczono limit różnych reakcji
unrecognized_emoji: nie jest znanym emoji unrecognized_emoji: nie jest znanym emoji
redirects:
prompt: Kliknij ten link jeżeli mu ufasz.
title: Opuszczasz %{instance}.
relationships: relationships:
activity: Aktywność konta activity: Aktywność konta
confirm_follow_selected_followers: Czy na pewno chcesz obserwować wybranych obserwujących? confirm_follow_selected_followers: Czy na pewno chcesz obserwować wybranych obserwujących?

View file

@ -1546,6 +1546,9 @@ pt-BR:
errors: errors:
limit_reached: Limite de reações diferentes atingido limit_reached: Limite de reações diferentes atingido
unrecognized_emoji: não é um emoji reconhecido unrecognized_emoji: não é um emoji reconhecido
redirects:
prompt: Se você confia neste link, clique nele para continuar.
title: Você está saindo de %{instance}.
relationships: relationships:
activity: Atividade da conta activity: Atividade da conta
confirm_follow_selected_followers: Tem certeza que deseja seguir os seguidores selecionados? confirm_follow_selected_followers: Tem certeza que deseja seguir os seguidores selecionados?

View file

@ -1546,6 +1546,9 @@ pt-PT:
errors: errors:
limit_reached: Alcançado limite de reações diferentes limit_reached: Alcançado limite de reações diferentes
unrecognized_emoji: não é um emoji reconhecido unrecognized_emoji: não é um emoji reconhecido
redirects:
prompt: Se confia nesta hiperligação, clique nela para continuar.
title: Está a deixar %{instance}.
relationships: relationships:
activity: Atividade da conta activity: Atividade da conta
confirm_follow_selected_followers: Tem a certeza que deseja seguir os seguidores selecionados? confirm_follow_selected_followers: Tem a certeza que deseja seguir os seguidores selecionados?

View file

@ -1598,6 +1598,9 @@ ru:
errors: errors:
limit_reached: Достигнут лимит разных реакций limit_reached: Достигнут лимит разных реакций
unrecognized_emoji: не является распознанным эмодзи unrecognized_emoji: не является распознанным эмодзи
redirects:
prompt: Если вы доверяете этой ссылке, нажмите на нее, чтобы продолжить.
title: Вы покидаете %{instance}.
relationships: relationships:
activity: Активность учётной записи activity: Активность учётной записи
confirm_follow_selected_followers: Вы уверены, что хотите подписаться на выбранных подписчиков? confirm_follow_selected_followers: Вы уверены, что хотите подписаться на выбранных подписчиков?

View file

@ -1101,6 +1101,9 @@ sk:
errors: errors:
limit_reached: Maximálny počet rôznorodých reakcií bol dosiahnutý limit_reached: Maximálny počet rôznorodých reakcií bol dosiahnutý
unrecognized_emoji: je neznámy smajlík unrecognized_emoji: je neznámy smajlík
redirects:
prompt: Ak tomuto odkazu veríš, klikni naňho pre pokračovanie.
title: Opúšťaš %{instance}.
relationships: relationships:
activity: Aktivita účtu activity: Aktivita účtu
confirm_follow_selected_followers: Si si istý/á, že chceš nasledovať vybraných sledujúcich? confirm_follow_selected_followers: Si si istý/á, že chceš nasledovať vybraných sledujúcich?

View file

@ -1542,6 +1542,9 @@ sq:
errors: errors:
limit_reached: U mbërrit në kufirin e reagimeve të ndryshme limit_reached: U mbërrit në kufirin e reagimeve të ndryshme
unrecognized_emoji: sështë emotikon i pranuar unrecognized_emoji: sështë emotikon i pranuar
redirects:
prompt: Nëse e besoni këtë lidhje, klikoni që të vazhdohet.
title: Po e braktisni %{instance}.
relationships: relationships:
activity: Veprimtari llogarie activity: Veprimtari llogarie
confirm_follow_selected_followers: Jeni i sigurt se doni të ndiqet ndjekësit e përzgjedhur? confirm_follow_selected_followers: Jeni i sigurt se doni të ndiqet ndjekësit e përzgjedhur?

View file

@ -1572,6 +1572,9 @@ sr-Latn:
errors: errors:
limit_reached: Dostignuto je ograničenje različitih reakcija limit_reached: Dostignuto je ograničenje različitih reakcija
unrecognized_emoji: nije prepoznat emodži unrecognized_emoji: nije prepoznat emodži
redirects:
prompt: Ako verujete ovoj vezi, kliknite na nju za nastavak.
title: Napuštate %{instance}.
relationships: relationships:
activity: Aktivnost naloga activity: Aktivnost naloga
confirm_follow_selected_followers: Da li ste sigurni da želite da pratite izabrane pratioce? confirm_follow_selected_followers: Da li ste sigurni da želite da pratite izabrane pratioce?

View file

@ -1572,6 +1572,9 @@ sr:
errors: errors:
limit_reached: Достигнуто је ограничење различитих реакција limit_reached: Достигнуто је ограничење различитих реакција
unrecognized_emoji: није препознат емоџи unrecognized_emoji: није препознат емоџи
redirects:
prompt: Ако верујете овој вези, кликните на њу за наставак.
title: Напуштате %{instance}.
relationships: relationships:
activity: Активност налога activity: Активност налога
confirm_follow_selected_followers: Да ли сте сигурни да желите да пратите изабране пратиоце? confirm_follow_selected_followers: Да ли сте сигурни да желите да пратите изабране пратиоце?

View file

@ -1545,6 +1545,9 @@ sv:
errors: errors:
limit_reached: Gränsen för unika reaktioner uppnådd limit_reached: Gränsen för unika reaktioner uppnådd
unrecognized_emoji: är inte en igenkänd emoji unrecognized_emoji: är inte en igenkänd emoji
redirects:
prompt: Om du litar på denna länk, klicka på den för att fortsätta.
title: Du lämnar %{instance}.
relationships: relationships:
activity: Kontoaktivitet activity: Kontoaktivitet
confirm_follow_selected_followers: Är du säker på att du vill följa valda följare? confirm_follow_selected_followers: Är du säker på att du vill följa valda följare?

View file

@ -1546,6 +1546,9 @@ tr:
errors: errors:
limit_reached: Farklı reaksiyonların sınırına ulaşıldı limit_reached: Farklı reaksiyonların sınırına ulaşıldı
unrecognized_emoji: tanınan bir emoji değil unrecognized_emoji: tanınan bir emoji değil
redirects:
prompt: Eğer bu bağlantıya güveniyorsanız, tıklayıp devam edebilirsiniz.
title: "%{instance} sunucusundan ayrılıyorsunuz."
relationships: relationships:
activity: Hesap etkinliği activity: Hesap etkinliği
confirm_follow_selected_followers: Seçili takipçileri takip etmek istediğinizden emin misiniz? confirm_follow_selected_followers: Seçili takipçileri takip etmek istediğinizden emin misiniz?

View file

@ -1598,6 +1598,9 @@ uk:
errors: errors:
limit_reached: Досягнуто обмеження різних реакцій limit_reached: Досягнуто обмеження різних реакцій
unrecognized_emoji: не є розпізнаним емоджі unrecognized_emoji: не є розпізнаним емоджі
redirects:
prompt: Якщо ви довіряєте цьому посиланню, натисніть, щоб продовжити.
title: Ви покидаєте %{instance}.
relationships: relationships:
activity: Діяльність облікового запису activity: Діяльність облікового запису
confirm_follow_selected_followers: Ви справді бажаєте підписатися на обраних підписників? confirm_follow_selected_followers: Ви справді бажаєте підписатися на обраних підписників?

View file

@ -1520,6 +1520,9 @@ vi:
errors: errors:
limit_reached: Bạn không nên thao tác liên tục limit_reached: Bạn không nên thao tác liên tục
unrecognized_emoji: không phải là emoji unrecognized_emoji: không phải là emoji
redirects:
prompt: Nếu bạn tin tưởng, hãy nhấn tiếp tục.
title: Bạn đang thoát khỏi %{instance}.
relationships: relationships:
activity: Tương tác activity: Tương tác
confirm_follow_selected_followers: Bạn có chắc muốn theo dõi những người đã chọn? confirm_follow_selected_followers: Bạn có chắc muốn theo dõi những người đã chọn?

View file

@ -1520,6 +1520,9 @@ zh-CN:
errors: errors:
limit_reached: 互动种类的限制 limit_reached: 互动种类的限制
unrecognized_emoji: 不是一个可识别的表情 unrecognized_emoji: 不是一个可识别的表情
redirects:
prompt: 如果您信任此链接,请单击以继续跳转。
title: 您正在离开 %{instance} 。
relationships: relationships:
activity: 账号活动 activity: 账号活动
confirm_follow_selected_followers: 您确定想要关注所选的关注者吗? confirm_follow_selected_followers: 您确定想要关注所选的关注者吗?

View file

@ -1520,6 +1520,9 @@ zh-HK:
errors: errors:
limit_reached: 已達到可以給予反應極限 limit_reached: 已達到可以給予反應極限
unrecognized_emoji: 不能識別這個emoji unrecognized_emoji: 不能識別這個emoji
redirects:
prompt: 如果你信任此連結,點擊它繼續。
title: 你即將離開 %{instance}。
relationships: relationships:
activity: 帳戶活動 activity: 帳戶活動
confirm_follow_selected_followers: 你確定要追蹤選取的追蹤者嗎? confirm_follow_selected_followers: 你確定要追蹤選取的追蹤者嗎?

View file

@ -57,7 +57,7 @@ zh-TW:
destroyed_msg: 即將刪除 %{username} 的資料 destroyed_msg: 即將刪除 %{username} 的資料
disable: 停用 disable: 停用
disable_sign_in_token_auth: 停用電子郵件 token 驗證 disable_sign_in_token_auth: 停用電子郵件 token 驗證
disable_two_factor_authentication: 停用兩階段 disable_two_factor_authentication: 停用兩階段
disabled: 已停用 disabled: 已停用
display_name: 暱稱 display_name: 暱稱
domain: 站點 domain: 站點
@ -195,7 +195,7 @@ zh-TW:
destroy_status: 刪除狀態 destroy_status: 刪除狀態
destroy_unavailable_domain: 刪除無法存取的網域 destroy_unavailable_domain: 刪除無法存取的網域
destroy_user_role: 移除角色 destroy_user_role: 移除角色
disable_2fa_user: 停用兩階段 disable_2fa_user: 停用兩階段
disable_custom_emoji: 停用自訂顏文字 disable_custom_emoji: 停用自訂顏文字
disable_sign_in_token_auth_user: 停用使用者電子郵件 token 驗證 disable_sign_in_token_auth_user: 停用使用者電子郵件 token 驗證
disable_user: 停用帳號 disable_user: 停用帳號
@ -254,7 +254,7 @@ zh-TW:
destroy_status_html: "%{name} 已刪除 %{target} 的嘟文" destroy_status_html: "%{name} 已刪除 %{target} 的嘟文"
destroy_unavailable_domain_html: "%{name} 已恢復對網域 %{target} 的發送" destroy_unavailable_domain_html: "%{name} 已恢復對網域 %{target} 的發送"
destroy_user_role_html: "%{name} 已刪除 %{target} 角色" destroy_user_role_html: "%{name} 已刪除 %{target} 角色"
disable_2fa_user_html: "%{name} 已停用使用者 %{target} 的兩階段證 (2FA) " disable_2fa_user_html: "%{name} 已停用使用者 %{target} 的兩階段證 (2FA) "
disable_custom_emoji_html: "%{name} 已停用自訂表情符號 %{target}" disable_custom_emoji_html: "%{name} 已停用自訂表情符號 %{target}"
disable_sign_in_token_auth_user_html: "%{name} 已停用 %{target} 之使用者電子郵件 token 驗證" disable_sign_in_token_auth_user_html: "%{name} 已停用 %{target} 之使用者電子郵件 token 驗證"
disable_user_html: "%{name} 將使用者 %{target} 設定為禁止登入" disable_user_html: "%{name} 將使用者 %{target} 設定為禁止登入"
@ -418,7 +418,7 @@ zh-TW:
view: 顯示已封鎖網域 view: 顯示已封鎖網域
email_domain_blocks: email_domain_blocks:
add_new: 加入新項目 add_new: 加入新項目
allow_registrations_with_approval: 允許後可註冊 allow_registrations_with_approval: 審核後可註冊
attempts_over_week: attempts_over_week:
other: 上週共有 %{count} 次註冊嘗試 other: 上週共有 %{count} 次註冊嘗試
created_msg: 已成功將電子郵件網域加入黑名單 created_msg: 已成功將電子郵件網域加入黑名單
@ -505,7 +505,7 @@ zh-TW:
delivery_available: 可傳送 delivery_available: 可傳送
delivery_error_days: 遞送失敗天數 delivery_error_days: 遞送失敗天數
delivery_error_hint: 若 %{count} 日皆無法遞送 ,則會自動標記無法遞送。 delivery_error_hint: 若 %{count} 日皆無法遞送 ,則會自動標記無法遞送。
destroyed_msg: 來自 %{domain} 的資料現在正在佇列中等待刪除。 destroyed_msg: 來自 %{domain} 的資料目前正在佇列中等待刪除。
empty: 找不到網域 empty: 找不到網域
known_accounts: known_accounts:
other: "%{count} 個已知帳號" other: "%{count} 個已知帳號"
@ -759,7 +759,7 @@ zh-TW:
title: 註冊 title: 註冊
registrations_mode: registrations_mode:
modes: modes:
approved: 註冊需要 approved: 註冊需要
none: 沒有人可註冊 none: 沒有人可註冊
open: 任何人皆能註冊 open: 任何人皆能註冊
security: security:
@ -870,7 +870,7 @@ zh-TW:
links: links:
allow: 允許連結 allow: 允許連結
allow_provider: 允許發行者 allow_provider: 允許發行者
description_html: 這些連結是正在被您伺服器上看到該嘟文之帳號大量分享。這些連結可以幫助您的使用者探索現在世界上正在發生的事情。除非您核准該發行者,連結將不被公開展示。您也可以核准或駁回個別連結。 description_html: 這些連結是正在被您伺服器上看到該嘟文之帳號大量分享。這些連結可以幫助您的使用者探索目前世界上正在發生的事情。除非您核准該發行者,連結將不被公開展示。您也可以核准或駁回個別連結。
disallow: 不允許連結 disallow: 不允許連結
disallow_provider: 不允許發行者 disallow_provider: 不允許發行者
no_link_selected: 因未選取任何連結,所以什麼事都沒發生 no_link_selected: 因未選取任何連結,所以什麼事都沒發生
@ -1062,7 +1062,7 @@ zh-TW:
cas: CAS cas: CAS
saml: SAML saml: SAML
register: 註冊 register: 註冊
registration_closed: "%{instance} 現在不開放新成員" registration_closed: "%{instance} 目前不開放新成員"
resend_confirmation: 重新傳送確認連結 resend_confirmation: 重新傳送確認連結
reset_password: 重設密碼 reset_password: 重設密碼
rules: rules:
@ -1522,6 +1522,9 @@ zh-TW:
errors: errors:
limit_reached: 達到可回應之上限 limit_reached: 達到可回應之上限
unrecognized_emoji: 並非一個可識別的 emoji unrecognized_emoji: 並非一個可識別的 emoji
redirects:
prompt: 若您信任此連結,請點擊以繼續。
title: 您將要離開 %{instance} 。
relationships: relationships:
activity: 帳號動態 activity: 帳號動態
confirm_follow_selected_followers: 您確定要跟隨選取的跟隨者嗎? confirm_follow_selected_followers: 您確定要跟隨選取的跟隨者嗎?
@ -1627,7 +1630,7 @@ zh-TW:
relationships: 跟隨中與跟隨者 relationships: 跟隨中與跟隨者
statuses_cleanup: 自動嘟文刪除 statuses_cleanup: 自動嘟文刪除
strikes: 管理警告 strikes: 管理警告
two_factor_authentication: 兩階段 two_factor_authentication: 兩階段
webauthn_authentication: 安全金鑰 webauthn_authentication: 安全金鑰
statuses: statuses:
attached: attached:
@ -1733,11 +1736,11 @@ zh-TW:
disable: 停用兩階段驗證 disable: 停用兩階段驗證
disabled_success: 已成功啟用兩階段驗證 disabled_success: 已成功啟用兩階段驗證
edit: 編輯 edit: 編輯
enabled: 兩階段證已啟用 enabled: 兩階段證已啟用
enabled_success: 已成功啟用兩階段認證 enabled_success: 兩階段驗證已成功啟用
generate_recovery_codes: 產生備用驗證碼 generate_recovery_codes: 產生備用驗證碼
lost_recovery_codes: 讓您能於遺失手機時,使用備用驗證碼登入。若您已遺失備用驗證碼,可於此產生一批新的,舊有的備用驗證碼將會失效。 lost_recovery_codes: 讓您能於遺失手機時,使用備用驗證碼登入。若您已遺失備用驗證碼,可於此產生一批新的,舊有的備用驗證碼將會失效。
methods: 步驟方式 methods: 階段驗證
otp: 驗證應用程式 otp: 驗證應用程式
recovery_codes: 備份備用驗證碼 recovery_codes: 備份備用驗證碼
recovery_codes_regenerated: 成功產生新的備用驗證碼 recovery_codes_regenerated: 成功產生新的備用驗證碼
@ -1757,15 +1760,15 @@ zh-TW:
title: 申訴被駁回 title: 申訴被駁回
backup_ready: backup_ready:
explanation: 您要求完整備份您的 Mastodon 帳號。 explanation: 您要求完整備份您的 Mastodon 帳號。
extra: 準備好下載了! extra: 準備好可供下載了!
subject: 您的備份檔已可供下載 subject: 您的備份檔已可供下載
title: 檔案匯出 title: 檔案匯出
failed_2fa: failed_2fa:
details: 以下是該登入嘗試之詳細資訊: details: 以下是該登入嘗試之詳細資訊:
explanation: 有人嘗試登入您的帳號,但提供了無效的第二個驗證因子 explanation: 有人嘗試登入您的帳號,但提供了無效的兩階段驗證
further_actions_html: 若這並非您所為,我們建議您立刻 %{action},因為其可能已被入侵。 further_actions_html: 若這並非您所為,我們建議您立刻 %{action},因為其可能已被入侵。
subject: 第二因子驗證失敗 subject: 兩階段驗證失敗
title: 第二因子身份驗證失敗 title: 兩階段驗證失敗
suspicious_sign_in: suspicious_sign_in:
change_password: 變更密碼 change_password: 變更密碼
details: 以下是該登入之詳細資訊: details: 以下是該登入之詳細資訊:
@ -1817,9 +1820,9 @@ zh-TW:
users: users:
follow_limit_reached: 您無法跟隨多於 %{limit} 個人 follow_limit_reached: 您無法跟隨多於 %{limit} 個人
go_to_sso_account_settings: 前往您的身分提供商 (identity provider) 之帳號設定 go_to_sso_account_settings: 前往您的身分提供商 (identity provider) 之帳號設定
invalid_otp_token: 兩階段證碼不正確 invalid_otp_token: 兩階段證碼不正確
otp_lost_help_html: 如果您無法存取這兩者,您可以透過 %{email} 與我們聯繫 otp_lost_help_html: 如果您無法存取這兩者,您可以透過 %{email} 與我們聯繫
rate_limited: 身份驗證嘗試太多次,請稍後再試。 rate_limited: 過多次身份驗證嘗試,請稍後再試。
seamless_external_login: 由於您是由外部系統登入,所以不能設定密碼與電子郵件。 seamless_external_login: 由於您是由外部系統登入,所以不能設定密碼與電子郵件。
signed_in_as: 目前登入的帳號: signed_in_as: 目前登入的帳號:
verification: verification:

View file

@ -163,6 +163,11 @@ Rails.application.routes.draw do
end end
end end
namespace :redirect do
resources :accounts, only: :show
resources :statuses, only: :show
end
resources :media, only: [:show] do resources :media, only: [:show] do
get :player get :player
end end

View file

@ -72,6 +72,10 @@ module Mastodon::CLI
local? ? username : "#{username}@#{domain}" local? ? username : "#{username}@#{domain}"
end end
def db_table_exists?(table)
ActiveRecord::Base.connection.table_exists?(table)
end
# This is a duplicate of the Account::Merging concern because we need it # This is a duplicate of the Account::Merging concern because we need it
# to be independent from code version. # to be independent from code version.
def merge_with!(other_account) def merge_with!(other_account)
@ -88,12 +92,12 @@ module Mastodon::CLI
AccountModerationNote, AccountPin, AccountStat, ListAccount, AccountModerationNote, AccountPin, AccountStat, ListAccount,
PollVote, Mention PollVote, Mention
] ]
owned_classes << AccountDeletionRequest if ActiveRecord::Base.connection.table_exists?(:account_deletion_requests) owned_classes << AccountDeletionRequest if db_table_exists?(:account_deletion_requests)
owned_classes << AccountNote if ActiveRecord::Base.connection.table_exists?(:account_notes) owned_classes << AccountNote if db_table_exists?(:account_notes)
owned_classes << FollowRecommendationSuppression if ActiveRecord::Base.connection.table_exists?(:follow_recommendation_suppressions) owned_classes << FollowRecommendationSuppression if db_table_exists?(:follow_recommendation_suppressions)
owned_classes << AccountIdentityProof if ActiveRecord::Base.connection.table_exists?(:account_identity_proofs) owned_classes << AccountIdentityProof if db_table_exists?(:account_identity_proofs)
owned_classes << Appeal if ActiveRecord::Base.connection.table_exists?(:appeals) owned_classes << Appeal if db_table_exists?(:appeals)
owned_classes << BulkImport if ActiveRecord::Base.connection.table_exists?(:bulk_imports) owned_classes << BulkImport if db_table_exists?(:bulk_imports)
owned_classes.each do |klass| owned_classes.each do |klass|
klass.where(account_id: other_account.id).find_each do |record| klass.where(account_id: other_account.id).find_each do |record|
@ -104,7 +108,7 @@ module Mastodon::CLI
end end
target_classes = [Follow, FollowRequest, Block, Mute, AccountModerationNote, AccountPin] target_classes = [Follow, FollowRequest, Block, Mute, AccountModerationNote, AccountPin]
target_classes << AccountNote if ActiveRecord::Base.connection.table_exists?(:account_notes) target_classes << AccountNote if db_table_exists?(:account_notes)
target_classes.each do |klass| target_classes.each do |klass|
klass.where(target_account_id: other_account.id).find_each do |record| klass.where(target_account_id: other_account.id).find_each do |record|
@ -114,13 +118,13 @@ module Mastodon::CLI
end end
end end
if ActiveRecord::Base.connection.table_exists?(:canonical_email_blocks) if db_table_exists?(:canonical_email_blocks)
CanonicalEmailBlock.where(reference_account_id: other_account.id).find_each do |record| CanonicalEmailBlock.where(reference_account_id: other_account.id).find_each do |record|
record.update_attribute(:reference_account_id, id) record.update_attribute(:reference_account_id, id)
end end
end end
if ActiveRecord::Base.connection.table_exists?(:appeals) if db_table_exists?(:appeals)
Appeal.where(account_warning_id: other_account.id).find_each do |record| Appeal.where(account_warning_id: other_account.id).find_each do |record|
record.update_attribute(:account_warning_id, id) record.update_attribute(:account_warning_id, id)
end end
@ -234,16 +238,16 @@ module Mastodon::CLI
say 'Restoring index_accounts_on_username_and_domain_lower…' say 'Restoring index_accounts_on_username_and_domain_lower…'
if migrator_version < 2020_06_20_164023 if migrator_version < 2020_06_20_164023
ActiveRecord::Base.connection.add_index :accounts, 'lower (username), lower(domain)', name: 'index_accounts_on_username_and_domain_lower', unique: true database_connection.add_index :accounts, 'lower (username), lower(domain)', name: 'index_accounts_on_username_and_domain_lower', unique: true
else else
ActiveRecord::Base.connection.add_index :accounts, "lower (username), COALESCE(lower(domain), '')", name: 'index_accounts_on_username_and_domain_lower', unique: true database_connection.add_index :accounts, "lower (username), COALESCE(lower(domain), '')", name: 'index_accounts_on_username_and_domain_lower', unique: true
end end
say 'Reindexing textual indexes on accounts…' say 'Reindexing textual indexes on accounts…'
ActiveRecord::Base.connection.execute('REINDEX INDEX search_index;') database_connection.execute('REINDEX INDEX search_index;')
ActiveRecord::Base.connection.execute('REINDEX INDEX index_accounts_on_uri;') database_connection.execute('REINDEX INDEX index_accounts_on_uri;')
ActiveRecord::Base.connection.execute('REINDEX INDEX index_accounts_on_url;') database_connection.execute('REINDEX INDEX index_accounts_on_url;')
ActiveRecord::Base.connection.execute('REINDEX INDEX index_accounts_on_domain_and_id;') if migrator_version >= 2023_05_24_190515 database_connection.execute('REINDEX INDEX index_accounts_on_domain_and_id;') if migrator_version >= 2023_05_24_190515
end end
def deduplicate_users! def deduplicate_users!
@ -260,21 +264,21 @@ module Mastodon::CLI
deduplicate_users_process_password_token deduplicate_users_process_password_token
say 'Restoring users indexes…' say 'Restoring users indexes…'
ActiveRecord::Base.connection.add_index :users, ['confirmation_token'], name: 'index_users_on_confirmation_token', unique: true database_connection.add_index :users, ['confirmation_token'], name: 'index_users_on_confirmation_token', unique: true
ActiveRecord::Base.connection.add_index :users, ['email'], name: 'index_users_on_email', unique: true database_connection.add_index :users, ['email'], name: 'index_users_on_email', unique: true
ActiveRecord::Base.connection.add_index :users, ['remember_token'], name: 'index_users_on_remember_token', unique: true if migrator_version < 2022_01_18_183010 database_connection.add_index :users, ['remember_token'], name: 'index_users_on_remember_token', unique: true if migrator_version < 2022_01_18_183010
if migrator_version < 2022_03_10_060641 if migrator_version < 2022_03_10_060641
ActiveRecord::Base.connection.add_index :users, ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true database_connection.add_index :users, ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true
else else
ActiveRecord::Base.connection.add_index :users, ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true, where: 'reset_password_token IS NOT NULL', opclass: :text_pattern_ops database_connection.add_index :users, ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true, where: 'reset_password_token IS NOT NULL', opclass: :text_pattern_ops
end end
ActiveRecord::Base.connection.execute('REINDEX INDEX index_users_on_unconfirmed_email;') if migrator_version >= 2023_07_02_151753 database_connection.execute('REINDEX INDEX index_users_on_unconfirmed_email;') if migrator_version >= 2023_07_02_151753
end end
def deduplicate_users_process_email def deduplicate_users_process_email
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users GROUP BY email HAVING count(*) > 1").each do |row| database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users GROUP BY email HAVING count(*) > 1").each do |row|
users = User.where(id: row['ids'].split(',')).order(updated_at: :desc).includes(:account).to_a users = User.where(id: row['ids'].split(',')).order(updated_at: :desc).includes(:account).to_a
ref_user = users.shift ref_user = users.shift
say "Multiple users registered with e-mail address #{ref_user.email}.", :yellow say "Multiple users registered with e-mail address #{ref_user.email}.", :yellow
@ -288,7 +292,7 @@ module Mastodon::CLI
end end
def deduplicate_users_process_confirmation_token def deduplicate_users_process_confirmation_token
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE confirmation_token IS NOT NULL GROUP BY confirmation_token HAVING count(*) > 1").each do |row| database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE confirmation_token IS NOT NULL GROUP BY confirmation_token HAVING count(*) > 1").each do |row|
users = User.where(id: row['ids'].split(',')).order(created_at: :desc).includes(:account).to_a.drop(1) users = User.where(id: row['ids'].split(',')).order(created_at: :desc).includes(:account).to_a.drop(1)
say "Unsetting confirmation token for those accounts: #{users.map { |user| user.account.acct }.join(', ')}", :yellow say "Unsetting confirmation token for those accounts: #{users.map { |user| user.account.acct }.join(', ')}", :yellow
@ -300,7 +304,7 @@ module Mastodon::CLI
def deduplicate_users_process_remember_token def deduplicate_users_process_remember_token
if migrator_version < 2022_01_18_183010 if migrator_version < 2022_01_18_183010
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE remember_token IS NOT NULL GROUP BY remember_token HAVING count(*) > 1").each do |row| database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE remember_token IS NOT NULL GROUP BY remember_token HAVING count(*) > 1").each do |row|
users = User.where(id: row['ids'].split(',')).order(updated_at: :desc).to_a.drop(1) users = User.where(id: row['ids'].split(',')).order(updated_at: :desc).to_a.drop(1)
say "Unsetting remember token for those accounts: #{users.map { |user| user.account.acct }.join(', ')}", :yellow say "Unsetting remember token for those accounts: #{users.map { |user| user.account.acct }.join(', ')}", :yellow
@ -312,7 +316,7 @@ module Mastodon::CLI
end end
def deduplicate_users_process_password_token def deduplicate_users_process_password_token
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE reset_password_token IS NOT NULL GROUP BY reset_password_token HAVING count(*) > 1").each do |row| database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE reset_password_token IS NOT NULL GROUP BY reset_password_token HAVING count(*) > 1").each do |row|
users = User.where(id: row['ids'].split(',')).order(updated_at: :desc).includes(:account).to_a.drop(1) users = User.where(id: row['ids'].split(',')).order(updated_at: :desc).includes(:account).to_a.drop(1)
say "Unsetting password reset token for those accounts: #{users.map { |user| user.account.acct }.join(', ')}", :yellow say "Unsetting password reset token for those accounts: #{users.map { |user| user.account.acct }.join(', ')}", :yellow
@ -326,47 +330,47 @@ module Mastodon::CLI
remove_index_if_exists!(:account_domain_blocks, 'index_account_domain_blocks_on_account_id_and_domain') remove_index_if_exists!(:account_domain_blocks, 'index_account_domain_blocks_on_account_id_and_domain')
say 'Removing duplicate account domain blocks…' say 'Removing duplicate account domain blocks…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM account_domain_blocks GROUP BY account_id, domain HAVING count(*) > 1").each do |row| database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM account_domain_blocks GROUP BY account_id, domain HAVING count(*) > 1").each do |row|
AccountDomainBlock.where(id: row['ids'].split(',').drop(1)).delete_all AccountDomainBlock.where(id: row['ids'].split(',').drop(1)).delete_all
end end
say 'Restoring account domain blocks indexes…' say 'Restoring account domain blocks indexes…'
ActiveRecord::Base.connection.add_index :account_domain_blocks, %w(account_id domain), name: 'index_account_domain_blocks_on_account_id_and_domain', unique: true database_connection.add_index :account_domain_blocks, %w(account_id domain), name: 'index_account_domain_blocks_on_account_id_and_domain', unique: true
end end
def deduplicate_account_identity_proofs! def deduplicate_account_identity_proofs!
return unless ActiveRecord::Base.connection.table_exists?(:account_identity_proofs) return unless db_table_exists?(:account_identity_proofs)
remove_index_if_exists!(:account_identity_proofs, 'index_account_proofs_on_account_and_provider_and_username') remove_index_if_exists!(:account_identity_proofs, 'index_account_proofs_on_account_and_provider_and_username')
say 'Removing duplicate account identity proofs…' say 'Removing duplicate account identity proofs…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM account_identity_proofs GROUP BY account_id, provider, provider_username HAVING count(*) > 1").each do |row| database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM account_identity_proofs GROUP BY account_id, provider, provider_username HAVING count(*) > 1").each do |row|
AccountIdentityProof.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy) AccountIdentityProof.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy)
end end
say 'Restoring account identity proofs indexes…' say 'Restoring account identity proofs indexes…'
ActiveRecord::Base.connection.add_index :account_identity_proofs, %w(account_id provider provider_username), name: 'index_account_proofs_on_account_and_provider_and_username', unique: true database_connection.add_index :account_identity_proofs, %w(account_id provider provider_username), name: 'index_account_proofs_on_account_and_provider_and_username', unique: true
end end
def deduplicate_announcement_reactions! def deduplicate_announcement_reactions!
return unless ActiveRecord::Base.connection.table_exists?(:announcement_reactions) return unless db_table_exists?(:announcement_reactions)
remove_index_if_exists!(:announcement_reactions, 'index_announcement_reactions_on_account_id_and_announcement_id') remove_index_if_exists!(:announcement_reactions, 'index_announcement_reactions_on_account_id_and_announcement_id')
say 'Removing duplicate announcement reactions…' say 'Removing duplicate announcement reactions…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM announcement_reactions GROUP BY account_id, announcement_id, name HAVING count(*) > 1").each do |row| database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM announcement_reactions GROUP BY account_id, announcement_id, name HAVING count(*) > 1").each do |row|
AnnouncementReaction.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy) AnnouncementReaction.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy)
end end
say 'Restoring announcement_reactions indexes…' say 'Restoring announcement_reactions indexes…'
ActiveRecord::Base.connection.add_index :announcement_reactions, %w(account_id announcement_id name), name: 'index_announcement_reactions_on_account_id_and_announcement_id', unique: true database_connection.add_index :announcement_reactions, %w(account_id announcement_id name), name: 'index_announcement_reactions_on_account_id_and_announcement_id', unique: true
end end
def deduplicate_conversations! def deduplicate_conversations!
remove_index_if_exists!(:conversations, 'index_conversations_on_uri') remove_index_if_exists!(:conversations, 'index_conversations_on_uri')
say 'Deduplicating conversations…' say 'Deduplicating conversations…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM conversations WHERE uri IS NOT NULL GROUP BY uri HAVING count(*) > 1").each do |row| database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM conversations WHERE uri IS NOT NULL GROUP BY uri HAVING count(*) > 1").each do |row|
conversations = Conversation.where(id: row['ids'].split(',')).order(id: :desc).to_a conversations = Conversation.where(id: row['ids'].split(',')).order(id: :desc).to_a
ref_conversation = conversations.shift ref_conversation = conversations.shift
@ -379,9 +383,9 @@ module Mastodon::CLI
say 'Restoring conversations indexes…' say 'Restoring conversations indexes…'
if migrator_version < 2022_03_07_083603 if migrator_version < 2022_03_07_083603
ActiveRecord::Base.connection.add_index :conversations, ['uri'], name: 'index_conversations_on_uri', unique: true database_connection.add_index :conversations, ['uri'], name: 'index_conversations_on_uri', unique: true
else else
ActiveRecord::Base.connection.add_index :conversations, ['uri'], name: 'index_conversations_on_uri', unique: true, where: 'uri IS NOT NULL', opclass: :text_pattern_ops database_connection.add_index :conversations, ['uri'], name: 'index_conversations_on_uri', unique: true, where: 'uri IS NOT NULL', opclass: :text_pattern_ops
end end
end end
@ -389,7 +393,7 @@ module Mastodon::CLI
remove_index_if_exists!(:custom_emojis, 'index_custom_emojis_on_shortcode_and_domain') remove_index_if_exists!(:custom_emojis, 'index_custom_emojis_on_shortcode_and_domain')
say 'Deduplicating custom_emojis…' say 'Deduplicating custom_emojis…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM custom_emojis GROUP BY shortcode, domain HAVING count(*) > 1").each do |row| database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM custom_emojis GROUP BY shortcode, domain HAVING count(*) > 1").each do |row|
emojis = CustomEmoji.where(id: row['ids'].split(',')).order(id: :desc).to_a emojis = CustomEmoji.where(id: row['ids'].split(',')).order(id: :desc).to_a
ref_emoji = emojis.shift ref_emoji = emojis.shift
@ -401,14 +405,14 @@ module Mastodon::CLI
end end
say 'Restoring custom_emojis indexes…' say 'Restoring custom_emojis indexes…'
ActiveRecord::Base.connection.add_index :custom_emojis, %w(shortcode domain), name: 'index_custom_emojis_on_shortcode_and_domain', unique: true database_connection.add_index :custom_emojis, %w(shortcode domain), name: 'index_custom_emojis_on_shortcode_and_domain', unique: true
end end
def deduplicate_custom_emoji_categories! def deduplicate_custom_emoji_categories!
remove_index_if_exists!(:custom_emoji_categories, 'index_custom_emoji_categories_on_name') remove_index_if_exists!(:custom_emoji_categories, 'index_custom_emoji_categories_on_name')
say 'Deduplicating custom_emoji_categories…' say 'Deduplicating custom_emoji_categories…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM custom_emoji_categories GROUP BY name HAVING count(*) > 1").each do |row| database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM custom_emoji_categories GROUP BY name HAVING count(*) > 1").each do |row|
categories = CustomEmojiCategory.where(id: row['ids'].split(',')).order(id: :desc).to_a categories = CustomEmojiCategory.where(id: row['ids'].split(',')).order(id: :desc).to_a
ref_category = categories.shift ref_category = categories.shift
@ -420,26 +424,26 @@ module Mastodon::CLI
end end
say 'Restoring custom_emoji_categories indexes…' say 'Restoring custom_emoji_categories indexes…'
ActiveRecord::Base.connection.add_index :custom_emoji_categories, ['name'], name: 'index_custom_emoji_categories_on_name', unique: true database_connection.add_index :custom_emoji_categories, ['name'], name: 'index_custom_emoji_categories_on_name', unique: true
end end
def deduplicate_domain_allows! def deduplicate_domain_allows!
remove_index_if_exists!(:domain_allows, 'index_domain_allows_on_domain') remove_index_if_exists!(:domain_allows, 'index_domain_allows_on_domain')
say 'Deduplicating domain_allows…' say 'Deduplicating domain_allows…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM domain_allows GROUP BY domain HAVING count(*) > 1").each do |row| database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM domain_allows GROUP BY domain HAVING count(*) > 1").each do |row|
DomainAllow.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy) DomainAllow.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy)
end end
say 'Restoring domain_allows indexes…' say 'Restoring domain_allows indexes…'
ActiveRecord::Base.connection.add_index :domain_allows, ['domain'], name: 'index_domain_allows_on_domain', unique: true database_connection.add_index :domain_allows, ['domain'], name: 'index_domain_allows_on_domain', unique: true
end end
def deduplicate_domain_blocks! def deduplicate_domain_blocks!
remove_index_if_exists!(:domain_blocks, 'index_domain_blocks_on_domain') remove_index_if_exists!(:domain_blocks, 'index_domain_blocks_on_domain')
say 'Deduplicating domain_blocks…' say 'Deduplicating domain_blocks…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM domain_blocks GROUP BY domain HAVING count(*) > 1").each do |row| database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM domain_blocks GROUP BY domain HAVING count(*) > 1").each do |row|
domain_blocks = DomainBlock.where(id: row['ids'].split(',')).by_severity.reverse.to_a domain_blocks = DomainBlock.where(id: row['ids'].split(',')).by_severity.reverse.to_a
reject_media = domain_blocks.any?(&:reject_media?) reject_media = domain_blocks.any?(&:reject_media?)
@ -456,49 +460,49 @@ module Mastodon::CLI
end end
say 'Restoring domain_blocks indexes…' say 'Restoring domain_blocks indexes…'
ActiveRecord::Base.connection.add_index :domain_blocks, ['domain'], name: 'index_domain_blocks_on_domain', unique: true database_connection.add_index :domain_blocks, ['domain'], name: 'index_domain_blocks_on_domain', unique: true
end end
def deduplicate_unavailable_domains! def deduplicate_unavailable_domains!
return unless ActiveRecord::Base.connection.table_exists?(:unavailable_domains) return unless db_table_exists?(:unavailable_domains)
remove_index_if_exists!(:unavailable_domains, 'index_unavailable_domains_on_domain') remove_index_if_exists!(:unavailable_domains, 'index_unavailable_domains_on_domain')
say 'Deduplicating unavailable_domains…' say 'Deduplicating unavailable_domains…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM unavailable_domains GROUP BY domain HAVING count(*) > 1").each do |row| database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM unavailable_domains GROUP BY domain HAVING count(*) > 1").each do |row|
UnavailableDomain.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy) UnavailableDomain.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy)
end end
say 'Restoring unavailable_domains indexes…' say 'Restoring unavailable_domains indexes…'
ActiveRecord::Base.connection.add_index :unavailable_domains, ['domain'], name: 'index_unavailable_domains_on_domain', unique: true database_connection.add_index :unavailable_domains, ['domain'], name: 'index_unavailable_domains_on_domain', unique: true
end end
def deduplicate_email_domain_blocks! def deduplicate_email_domain_blocks!
remove_index_if_exists!(:email_domain_blocks, 'index_email_domain_blocks_on_domain') remove_index_if_exists!(:email_domain_blocks, 'index_email_domain_blocks_on_domain')
say 'Deduplicating email_domain_blocks…' say 'Deduplicating email_domain_blocks…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM email_domain_blocks GROUP BY domain HAVING count(*) > 1").each do |row| database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM email_domain_blocks GROUP BY domain HAVING count(*) > 1").each do |row|
domain_blocks = EmailDomainBlock.where(id: row['ids'].split(',')).order(EmailDomainBlock.arel_table[:parent_id].asc.nulls_first).to_a domain_blocks = EmailDomainBlock.where(id: row['ids'].split(',')).order(EmailDomainBlock.arel_table[:parent_id].asc.nulls_first).to_a
domain_blocks.drop(1).each(&:destroy) domain_blocks.drop(1).each(&:destroy)
end end
say 'Restoring email_domain_blocks indexes…' say 'Restoring email_domain_blocks indexes…'
ActiveRecord::Base.connection.add_index :email_domain_blocks, ['domain'], name: 'index_email_domain_blocks_on_domain', unique: true database_connection.add_index :email_domain_blocks, ['domain'], name: 'index_email_domain_blocks_on_domain', unique: true
end end
def deduplicate_media_attachments! def deduplicate_media_attachments!
remove_index_if_exists!(:media_attachments, 'index_media_attachments_on_shortcode') remove_index_if_exists!(:media_attachments, 'index_media_attachments_on_shortcode')
say 'Deduplicating media_attachments…' say 'Deduplicating media_attachments…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM media_attachments WHERE shortcode IS NOT NULL GROUP BY shortcode HAVING count(*) > 1").each do |row| database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM media_attachments WHERE shortcode IS NOT NULL GROUP BY shortcode HAVING count(*) > 1").each do |row|
MediaAttachment.where(id: row['ids'].split(',').drop(1)).update_all(shortcode: nil) MediaAttachment.where(id: row['ids'].split(',').drop(1)).update_all(shortcode: nil)
end end
say 'Restoring media_attachments indexes…' say 'Restoring media_attachments indexes…'
if migrator_version < 2022_03_10_060626 if migrator_version < 2022_03_10_060626
ActiveRecord::Base.connection.add_index :media_attachments, ['shortcode'], name: 'index_media_attachments_on_shortcode', unique: true database_connection.add_index :media_attachments, ['shortcode'], name: 'index_media_attachments_on_shortcode', unique: true
else else
ActiveRecord::Base.connection.add_index :media_attachments, ['shortcode'], name: 'index_media_attachments_on_shortcode', unique: true, where: 'shortcode IS NOT NULL', opclass: :text_pattern_ops database_connection.add_index :media_attachments, ['shortcode'], name: 'index_media_attachments_on_shortcode', unique: true, where: 'shortcode IS NOT NULL', opclass: :text_pattern_ops
end end
end end
@ -506,19 +510,19 @@ module Mastodon::CLI
remove_index_if_exists!(:preview_cards, 'index_preview_cards_on_url') remove_index_if_exists!(:preview_cards, 'index_preview_cards_on_url')
say 'Deduplicating preview_cards…' say 'Deduplicating preview_cards…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM preview_cards GROUP BY url HAVING count(*) > 1").each do |row| database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM preview_cards GROUP BY url HAVING count(*) > 1").each do |row|
PreviewCard.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy) PreviewCard.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy)
end end
say 'Restoring preview_cards indexes…' say 'Restoring preview_cards indexes…'
ActiveRecord::Base.connection.add_index :preview_cards, ['url'], name: 'index_preview_cards_on_url', unique: true database_connection.add_index :preview_cards, ['url'], name: 'index_preview_cards_on_url', unique: true
end end
def deduplicate_statuses! def deduplicate_statuses!
remove_index_if_exists!(:statuses, 'index_statuses_on_uri') remove_index_if_exists!(:statuses, 'index_statuses_on_uri')
say 'Deduplicating statuses…' say 'Deduplicating statuses…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM statuses WHERE uri IS NOT NULL GROUP BY uri HAVING count(*) > 1").each do |row| database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM statuses WHERE uri IS NOT NULL GROUP BY uri HAVING count(*) > 1").each do |row|
statuses = Status.where(id: row['ids'].split(',')).order(id: :asc).to_a statuses = Status.where(id: row['ids'].split(',')).order(id: :asc).to_a
ref_status = statuses.shift ref_status = statuses.shift
statuses.each do |status| statuses.each do |status|
@ -529,9 +533,9 @@ module Mastodon::CLI
say 'Restoring statuses indexes…' say 'Restoring statuses indexes…'
if migrator_version < 2022_03_10_060706 if migrator_version < 2022_03_10_060706
ActiveRecord::Base.connection.add_index :statuses, ['uri'], name: 'index_statuses_on_uri', unique: true database_connection.add_index :statuses, ['uri'], name: 'index_statuses_on_uri', unique: true
else else
ActiveRecord::Base.connection.add_index :statuses, ['uri'], name: 'index_statuses_on_uri', unique: true, where: 'uri IS NOT NULL', opclass: :text_pattern_ops database_connection.add_index :statuses, ['uri'], name: 'index_statuses_on_uri', unique: true, where: 'uri IS NOT NULL', opclass: :text_pattern_ops
end end
end end
@ -540,7 +544,7 @@ module Mastodon::CLI
remove_index_if_exists!(:tags, 'index_tags_on_name_lower_btree') remove_index_if_exists!(:tags, 'index_tags_on_name_lower_btree')
say 'Deduplicating tags…' say 'Deduplicating tags…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM tags GROUP BY lower((name)::text) HAVING count(*) > 1").each do |row| database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM tags GROUP BY lower((name)::text) HAVING count(*) > 1").each do |row|
tags = Tag.where(id: row['ids'].split(',')).order(Arel.sql('(usable::int + trendable::int + listable::int) desc')).to_a tags = Tag.where(id: row['ids'].split(',')).order(Arel.sql('(usable::int + trendable::int + listable::int) desc')).to_a
ref_tag = tags.shift ref_tag = tags.shift
tags.each do |tag| tags.each do |tag|
@ -551,38 +555,38 @@ module Mastodon::CLI
say 'Restoring tags indexes…' say 'Restoring tags indexes…'
if migrator_version < 2021_04_21_121431 if migrator_version < 2021_04_21_121431
ActiveRecord::Base.connection.add_index :tags, 'lower((name)::text)', name: 'index_tags_on_name_lower', unique: true database_connection.add_index :tags, 'lower((name)::text)', name: 'index_tags_on_name_lower', unique: true
else else
ActiveRecord::Base.connection.execute 'CREATE UNIQUE INDEX index_tags_on_name_lower_btree ON tags (lower(name) text_pattern_ops)' database_connection.execute 'CREATE UNIQUE INDEX index_tags_on_name_lower_btree ON tags (lower(name) text_pattern_ops)'
end end
end end
def deduplicate_webauthn_credentials! def deduplicate_webauthn_credentials!
return unless ActiveRecord::Base.connection.table_exists?(:webauthn_credentials) return unless db_table_exists?(:webauthn_credentials)
remove_index_if_exists!(:webauthn_credentials, 'index_webauthn_credentials_on_external_id') remove_index_if_exists!(:webauthn_credentials, 'index_webauthn_credentials_on_external_id')
say 'Deduplicating webauthn_credentials…' say 'Deduplicating webauthn_credentials…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM webauthn_credentials GROUP BY external_id HAVING count(*) > 1").each do |row| database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM webauthn_credentials GROUP BY external_id HAVING count(*) > 1").each do |row|
WebauthnCredential.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy) WebauthnCredential.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy)
end end
say 'Restoring webauthn_credentials indexes…' say 'Restoring webauthn_credentials indexes…'
ActiveRecord::Base.connection.add_index :webauthn_credentials, ['external_id'], name: 'index_webauthn_credentials_on_external_id', unique: true database_connection.add_index :webauthn_credentials, ['external_id'], name: 'index_webauthn_credentials_on_external_id', unique: true
end end
def deduplicate_webhooks! def deduplicate_webhooks!
return unless ActiveRecord::Base.connection.table_exists?(:webhooks) return unless db_table_exists?(:webhooks)
remove_index_if_exists!(:webhooks, 'index_webhooks_on_url') remove_index_if_exists!(:webhooks, 'index_webhooks_on_url')
say 'Deduplicating webhooks…' say 'Deduplicating webhooks…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM webhooks GROUP BY url HAVING count(*) > 1").each do |row| database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM webhooks GROUP BY url HAVING count(*) > 1").each do |row|
Webhook.where(id: row['ids'].split(',')).order(id: :desc).drop(1).each(&:destroy) Webhook.where(id: row['ids'].split(',')).order(id: :desc).drop(1).each(&:destroy)
end end
say 'Restoring webhooks indexes…' say 'Restoring webhooks indexes…'
ActiveRecord::Base.connection.add_index :webhooks, ['url'], name: 'index_webhooks_on_url', unique: true database_connection.add_index :webhooks, ['url'], name: 'index_webhooks_on_url', unique: true
end end
def deduplicate_software_updates! def deduplicate_software_updates!
@ -672,7 +676,7 @@ module Mastodon::CLI
def merge_statuses!(main_status, duplicate_status) def merge_statuses!(main_status, duplicate_status)
owned_classes = [Favourite, Mention, Poll] owned_classes = [Favourite, Mention, Poll]
owned_classes << Bookmark if ActiveRecord::Base.connection.table_exists?(:bookmarks) owned_classes << Bookmark if db_table_exists?(:bookmarks)
owned_classes.each do |klass| owned_classes.each do |klass|
klass.where(status_id: duplicate_status.id).find_each do |record| klass.where(status_id: duplicate_status.id).find_each do |record|
record.update_attribute(:status_id, main_status.id) record.update_attribute(:status_id, main_status.id)
@ -715,13 +719,21 @@ module Mastodon::CLI
end end
def find_duplicate_accounts def find_duplicate_accounts
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM accounts GROUP BY lower(username), COALESCE(lower(domain), '') HAVING count(*) > 1") database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM accounts GROUP BY lower(username), COALESCE(lower(domain), '') HAVING count(*) > 1")
end end
def remove_index_if_exists!(table, name) def remove_index_if_exists!(table, name)
ActiveRecord::Base.connection.remove_index(table, name: name) if ActiveRecord::Base.connection.index_name_exists?(table, name) database_connection.remove_index(table, name: name) if database_connection.index_name_exists?(table, name)
rescue ArgumentError, ActiveRecord::StatementInvalid rescue ArgumentError, ActiveRecord::StatementInvalid
nil nil
end end
def database_connection
ActiveRecord::Base.connection
end
def db_table_exists?(table)
database_connection.table_exists?(table)
end
end end
end end

View file

@ -2,6 +2,22 @@
namespace :tests do namespace :tests do
namespace :migrations do namespace :migrations do
desc 'Prepares all migrations and test data for consistency checks'
task prepare_database: :environment do
{
'2' => 2017_10_10_025614,
'2_4' => 2018_05_14_140000,
'2_4_3' => 2018_07_07_154237,
}.each do |release, version|
ActiveRecord::Tasks::DatabaseTasks
.migration_connection
.migration_context
.migrate(version)
Rake::Task["tests:migrations:populate_v#{release}"]
.invoke
end
end
desc 'Check that database state is consistent with a successful migration from populated data' desc 'Check that database state is consistent with a successful migration from populated data'
task check_database: :environment do task check_database: :environment do
unless Account.find_by(username: 'admin', domain: nil)&.hide_collections? == false unless Account.find_by(username: 'admin', domain: nil)&.hide_collections? == false
@ -88,6 +104,8 @@ namespace :tests do
puts 'Locale for fr-QC users not updated to fr-CA as expected' puts 'Locale for fr-QC users not updated to fr-CA as expected'
exit(1) exit(1)
end end
puts 'No errors found. Database state is consistent with a successful migration process.'
end end
desc 'Populate the database with test data for 2.4.3' desc 'Populate the database with test data for 2.4.3'

View file

@ -0,0 +1,32 @@
# frozen_string_literal: true
require 'rails_helper'
describe 'redirection confirmations' do
let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/users/foo', url: 'https://example.com/@foo') }
let(:status) { Fabricate(:status, account: account, uri: 'https://example.com/users/foo/statuses/1', url: 'https://example.com/@foo/1') }
context 'when a logged out user visits a local page for a remote account' do
it 'shows a confirmation page' do
visit "/@#{account.pretty_acct}"
# It explains about the redirect
expect(page).to have_content(I18n.t('redirects.title', instance: 'cb6e6126.ngrok.io'))
# It features an appropriate link
expect(page).to have_link(account.url, href: account.url)
end
end
context 'when a logged out user visits a local page for a remote status' do
it 'shows a confirmation page' do
visit "/@#{account.pretty_acct}/#{status.id}"
# It explains about the redirect
expect(page).to have_content(I18n.t('redirects.title', instance: 'cb6e6126.ngrok.io'))
# It features an appropriate link
expect(page).to have_link(status.url, href: status.url)
end
end
end

View file

@ -939,6 +939,49 @@ RSpec.describe ActivityPub::Activity::Create do
end end
end end
context 'when object URI uses bearcaps' do
subject { described_class.new(json, sender) }
let(:token) { 'foo' }
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: [ActivityPub::TagManager.instance.uri_for(sender), '#foo'].join,
type: 'Create',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: Addressable::URI.new(scheme: 'bear', query_values: { t: token, u: object_json[:id] }).to_s,
}.with_indifferent_access
end
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
to: 'https://www.w3.org/ns/activitystreams#Public',
}
end
before do
stub_request(:get, object_json[:id])
.with(headers: { Authorization: "Bearer #{token}" })
.to_return(body: Oj.dump(object_json), headers: { 'Content-Type': 'application/activity+json' })
subject.perform
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status).to have_attributes(
visibility: 'public',
text: 'Lorem ipsum'
)
end
end
context 'with an encrypted message' do context 'with an encrypted message' do
subject { described_class.new(json, sender, delivery: true, delivered_to_account_id: recipient.id) } subject { described_class.new(json, sender, delivery: true, delivered_to_account_id: recipient.id) }

View file

@ -296,16 +296,11 @@ RSpec.describe AccountStatusesCleanupPolicy do
let!(:old_status) { Fabricate(:status, created_at: 1.year.ago, account: account) } let!(:old_status) { Fabricate(:status, created_at: 1.year.ago, account: account) }
let!(:slightly_less_old_status) { Fabricate(:status, created_at: 6.months.ago, account: account) } let!(:slightly_less_old_status) { Fabricate(:status, created_at: 6.months.ago, account: account) }
it 'returns statuses including max_id' do it 'returns statuses included the max_id and older than the max_id but not newer than max_id' do
expect(subject).to include(old_status.id) expect(subject)
end .to include(old_status.id)
.and include(very_old_status.id)
it 'returns statuses including older than max_id' do .and not_include(slightly_less_old_status.id)
expect(subject).to include(very_old_status.id)
end
it 'does not return statuses newer than max_id' do
expect(subject).to_not include(slightly_less_old_status.id)
end end
end end
@ -315,16 +310,11 @@ RSpec.describe AccountStatusesCleanupPolicy do
let!(:old_status) { Fabricate(:status, created_at: 1.year.ago, account: account) } let!(:old_status) { Fabricate(:status, created_at: 1.year.ago, account: account) }
let!(:slightly_less_old_status) { Fabricate(:status, created_at: 6.months.ago, account: account) } let!(:slightly_less_old_status) { Fabricate(:status, created_at: 6.months.ago, account: account) }
it 'returns statuses including min_id' do it 'returns statuses including min_id and newer than min_id, but not older than min_id' do
expect(subject).to include(old_status.id) expect(subject)
end .to include(old_status.id)
.and include(slightly_less_old_status.id)
it 'returns statuses including newer than max_id' do .and not_include(very_old_status.id)
expect(subject).to include(slightly_less_old_status.id)
end
it 'does not return statuses older than min_id' do
expect(subject).to_not include(very_old_status.id)
end end
end end
@ -339,12 +329,10 @@ RSpec.describe AccountStatusesCleanupPolicy do
account_statuses_cleanup_policy.min_status_age = 2.years.seconds account_statuses_cleanup_policy.min_status_age = 2.years.seconds
end end
it 'does not return unrelated old status' do it 'does not return unrelated old status and does return oldest status' do
expect(subject.pluck(:id)).to_not include(unrelated_status.id) expect(subject.pluck(:id))
end .to not_include(unrelated_status.id)
.and eq [very_old_status.id]
it 'returns only oldest status for deletion' do
expect(subject.pluck(:id)).to eq [very_old_status.id]
end end
end end
@ -358,12 +346,10 @@ RSpec.describe AccountStatusesCleanupPolicy do
account_statuses_cleanup_policy.keep_self_bookmark = false account_statuses_cleanup_policy.keep_self_bookmark = false
end end
it 'does not return the old direct message for deletion' do it 'returns every old status except does not return the old direct message for deletion' do
expect(subject.pluck(:id)).to_not include(direct_message.id) expect(subject.pluck(:id))
end .to not_include(direct_message.id)
.and include(very_old_status.id, pinned_status.id, self_faved.id, self_bookmarked.id, status_with_poll.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id)
it 'returns every other old status for deletion' do
expect(subject.pluck(:id)).to include(very_old_status.id, pinned_status.id, self_faved.id, self_bookmarked.id, status_with_poll.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id)
end end
end end
@ -377,12 +363,10 @@ RSpec.describe AccountStatusesCleanupPolicy do
account_statuses_cleanup_policy.keep_self_bookmark = true account_statuses_cleanup_policy.keep_self_bookmark = true
end end
it 'does not return the old self-bookmarked message for deletion' do it 'returns every old status but does not return the old self-bookmarked message for deletion' do
expect(subject.pluck(:id)).to_not include(self_bookmarked.id) expect(subject.pluck(:id))
end .to not_include(self_bookmarked.id)
.and include(direct_message.id, very_old_status.id, pinned_status.id, self_faved.id, status_with_poll.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id)
it 'returns every other old status for deletion' do
expect(subject.pluck(:id)).to include(direct_message.id, very_old_status.id, pinned_status.id, self_faved.id, status_with_poll.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id)
end end
end end
@ -396,12 +380,10 @@ RSpec.describe AccountStatusesCleanupPolicy do
account_statuses_cleanup_policy.keep_self_bookmark = false account_statuses_cleanup_policy.keep_self_bookmark = false
end end
it 'does not return the old self-bookmarked message for deletion' do it 'returns every old status but does not return the old self-faved message for deletion' do
expect(subject.pluck(:id)).to_not include(self_faved.id) expect(subject.pluck(:id))
end .to not_include(self_faved.id)
.and include(direct_message.id, very_old_status.id, pinned_status.id, self_bookmarked.id, status_with_poll.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id)
it 'returns every other old status for deletion' do
expect(subject.pluck(:id)).to include(direct_message.id, very_old_status.id, pinned_status.id, self_bookmarked.id, status_with_poll.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id)
end end
end end
@ -415,12 +397,10 @@ RSpec.describe AccountStatusesCleanupPolicy do
account_statuses_cleanup_policy.keep_self_bookmark = false account_statuses_cleanup_policy.keep_self_bookmark = false
end end
it 'does not return the old message with media for deletion' do it 'returns every old status but does not return the old message with media for deletion' do
expect(subject.pluck(:id)).to_not include(status_with_media.id) expect(subject.pluck(:id))
end .to not_include(status_with_media.id)
.and include(direct_message.id, very_old_status.id, pinned_status.id, self_faved.id, self_bookmarked.id, status_with_poll.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id)
it 'returns every other old status for deletion' do
expect(subject.pluck(:id)).to include(direct_message.id, very_old_status.id, pinned_status.id, self_faved.id, self_bookmarked.id, status_with_poll.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id)
end end
end end
@ -434,12 +414,10 @@ RSpec.describe AccountStatusesCleanupPolicy do
account_statuses_cleanup_policy.keep_self_bookmark = false account_statuses_cleanup_policy.keep_self_bookmark = false
end end
it 'does not return the old poll message for deletion' do it 'returns every old status but does not return the old poll message for deletion' do
expect(subject.pluck(:id)).to_not include(status_with_poll.id) expect(subject.pluck(:id))
end .to not_include(status_with_poll.id)
.and include(direct_message.id, very_old_status.id, pinned_status.id, self_faved.id, self_bookmarked.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id)
it 'returns every other old status for deletion' do
expect(subject.pluck(:id)).to include(direct_message.id, very_old_status.id, pinned_status.id, self_faved.id, self_bookmarked.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id)
end end
end end
@ -453,12 +431,10 @@ RSpec.describe AccountStatusesCleanupPolicy do
account_statuses_cleanup_policy.keep_self_bookmark = false account_statuses_cleanup_policy.keep_self_bookmark = false
end end
it 'does not return the old pinned message for deletion' do it 'returns every old status but does not return the old pinned message for deletion' do
expect(subject.pluck(:id)).to_not include(pinned_status.id) expect(subject.pluck(:id))
end .to not_include(pinned_status.id)
.and include(direct_message.id, very_old_status.id, self_faved.id, self_bookmarked.id, status_with_poll.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id)
it 'returns every other old status for deletion' do
expect(subject.pluck(:id)).to include(direct_message.id, very_old_status.id, self_faved.id, self_bookmarked.id, status_with_poll.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id)
end end
end end
@ -472,16 +448,11 @@ RSpec.describe AccountStatusesCleanupPolicy do
account_statuses_cleanup_policy.keep_self_bookmark = false account_statuses_cleanup_policy.keep_self_bookmark = false
end end
it 'does not return the recent toot' do it 'returns every old status but does not return the recent or unrelated statuses' do
expect(subject.pluck(:id)).to_not include(recent_status.id) expect(subject.pluck(:id))
end .to not_include(recent_status.id)
.and not_include(unrelated_status.id)
it 'does not return the unrelated toot' do .and include(direct_message.id, very_old_status.id, pinned_status.id, self_faved.id, self_bookmarked.id, status_with_poll.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id)
expect(subject.pluck(:id)).to_not include(unrelated_status.id)
end
it 'returns every other old status for deletion' do
expect(subject.pluck(:id)).to include(direct_message.id, very_old_status.id, pinned_status.id, self_faved.id, self_bookmarked.id, status_with_poll.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id)
end end
end end
@ -495,12 +466,10 @@ RSpec.describe AccountStatusesCleanupPolicy do
account_statuses_cleanup_policy.keep_self_bookmark = true account_statuses_cleanup_policy.keep_self_bookmark = true
end end
it 'does not return unrelated old status' do it 'returns normal statuses and does not return unrelated old status' do
expect(subject.pluck(:id)).to_not include(unrelated_status.id) expect(subject.pluck(:id))
end .to not_include(unrelated_status.id)
.and contain_exactly(very_old_status.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id)
it 'returns only normal statuses for deletion' do
expect(subject.pluck(:id)).to contain_exactly(very_old_status.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id)
end end
end end
@ -509,20 +478,12 @@ RSpec.describe AccountStatusesCleanupPolicy do
account_statuses_cleanup_policy.min_reblogs = 5 account_statuses_cleanup_policy.min_reblogs = 5
end end
it 'does not return the recent toot' do it 'returns old not-reblogged statuses but does not return the recent, 5-times reblogged, or unrelated statuses' do
expect(subject.pluck(:id)).to_not include(recent_status.id) expect(subject.pluck(:id))
end .to not_include(recent_status.id)
.and not_include(reblogged_secondary.id)
it 'does not return the toot reblogged 5 times' do .and not_include(unrelated_status.id)
expect(subject.pluck(:id)).to_not include(reblogged_secondary.id) .and include(very_old_status.id, faved_primary.id, faved_secondary.id, reblogged_primary.id)
end
it 'does not return the unrelated toot' do
expect(subject.pluck(:id)).to_not include(unrelated_status.id)
end
it 'returns old statuses not reblogged as much' do
expect(subject.pluck(:id)).to include(very_old_status.id, faved_primary.id, faved_secondary.id, reblogged_primary.id)
end end
end end
@ -531,20 +492,12 @@ RSpec.describe AccountStatusesCleanupPolicy do
account_statuses_cleanup_policy.min_favs = 5 account_statuses_cleanup_policy.min_favs = 5
end end
it 'does not return the recent toot' do it 'returns old not-faved statuses but does not return the recent, 5-times faved, or unrelated statuses' do
expect(subject.pluck(:id)).to_not include(recent_status.id) expect(subject.pluck(:id))
end .to not_include(recent_status.id)
.and not_include(faved_secondary.id)
it 'does not return the toot faved 5 times' do .and not_include(unrelated_status.id)
expect(subject.pluck(:id)).to_not include(faved_secondary.id) .and include(very_old_status.id, faved_primary.id, reblogged_primary.id, reblogged_secondary.id)
end
it 'does not return the unrelated toot' do
expect(subject.pluck(:id)).to_not include(unrelated_status.id)
end
it 'returns old statuses not faved as much' do
expect(subject.pluck(:id)).to include(very_old_status.id, faved_primary.id, reblogged_primary.id, reblogged_secondary.id)
end end
end end
end end

View file

@ -0,0 +1,35 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe CustomFilterKeyword do
describe '#to_regex' do
context 'when whole_word is true' do
it 'builds a regex with boundaries and the keyword' do
keyword = described_class.new(whole_word: true, keyword: 'test')
expect(keyword.to_regex).to eq(/(?mix:\b#{Regexp.escape(keyword.keyword)}\b)/)
end
it 'builds a regex with starting boundary and the keyword when end with non-word' do
keyword = described_class.new(whole_word: true, keyword: 'test#')
expect(keyword.to_regex).to eq(/(?mix:\btest\#)/)
end
it 'builds a regex with end boundary and the keyword when start with non-word' do
keyword = described_class.new(whole_word: true, keyword: '#test')
expect(keyword.to_regex).to eq(/(?mix:\#test\b)/)
end
end
context 'when whole_word is false' do
it 'builds a regex with the keyword' do
keyword = described_class.new(whole_word: false, keyword: 'test')
expect(keyword.to_regex).to eq(/test/i)
end
end
end
end

View file

@ -265,7 +265,7 @@ RSpec.describe ActivityPub::ProcessCollectionService, type: :service do
anything anything
) )
expect(Status.where(uri: 'https://example.com/users/bob/fake-status').exists?).to be false expect(Status.exists?(uri: 'https://example.com/users/bob/fake-status')).to be false
end end
end end
end end

View file

@ -4684,13 +4684,13 @@ __metadata:
linkType: hard linkType: hard
"axios@npm:^1.4.0": "axios@npm:^1.4.0":
version: 1.6.5 version: 1.6.6
resolution: "axios@npm:1.6.5" resolution: "axios@npm:1.6.6"
dependencies: dependencies:
follow-redirects: "npm:^1.15.4" follow-redirects: "npm:^1.15.4"
form-data: "npm:^4.0.0" form-data: "npm:^4.0.0"
proxy-from-env: "npm:^1.1.0" proxy-from-env: "npm:^1.1.0"
checksum: aeb9acf87590d8aa67946072ced38e01ca71f5dfe043782c0ccea667e5dd5c45830c08afac9be3d7c894f09684b8ab2a458f497d197b73621233bcf202d9d468 checksum: 974f54cfade94fd4c0191309122a112c8d233089cecb0070cd8e0904e9bd9c364ac3a6fd0f981c978508077249788950427c565f54b7b2110e5c3426006ff343
languageName: node languageName: node
linkType: hard linkType: hard
@ -6912,9 +6912,9 @@ __metadata:
linkType: hard linkType: hard
"dotenv@npm:^16.0.3": "dotenv@npm:^16.0.3":
version: 16.3.2 version: 16.4.1
resolution: "dotenv@npm:16.3.2" resolution: "dotenv@npm:16.4.1"
checksum: a87d62cef0810b670cb477db1a24a42a093b6b428c9e65c185ce1d6368ad7175234b13547718ba08da18df43faae4f814180cc0366e11be1ded2277abc4dd22e checksum: ef3d95f48f38146df0881a4b58447ae437d2da3f6d645074b84de4e64ef64ba75fc357c5ed66b3c2b813b5369fdeb6a4777d6ade2d50e54eed6aa06dddc98bc4
languageName: node languageName: node
linkType: hard linkType: hard