Merge commit '1483a3ddfe74e4fb81d87447a1781943eab86c60' into glitch-soc/merge-upstream

Conflicts:
- `config/initializers/simple_form.rb`:
  Upstream added a new simple_form component, where we had an extra one.
  Kept both components.
shrike
Claire 2023-06-10 16:22:14 +02:00
commit d8b0a732aa
68 changed files with 837 additions and 159 deletions

View File

@ -81,6 +81,15 @@ module.exports = {
{ property: 'substring', message: 'Use .slice instead of .substring.' }, { property: 'substring', message: 'Use .slice instead of .substring.' },
{ property: 'substr', message: 'Use .slice instead of .substr.' }, { property: 'substr', message: 'Use .slice instead of .substr.' },
], ],
'no-restricted-syntax': [
'error',
{
// eslint-disable-next-line no-restricted-syntax
selector: 'Literal[value=/•/], JSXText[value=/•/]',
// eslint-disable-next-line no-restricted-syntax
message: "Use '·' (middle dot) instead of '•' (bullet)",
},
],
'no-self-assign': 'off', 'no-self-assign': 'off',
'no-unused-expressions': 'error', 'no-unused-expressions': 'error',
'no-unused-vars': 'off', 'no-unused-vars': 'off',

View File

@ -4,6 +4,11 @@ exclude:
- 'vendor/**/*' - 'vendor/**/*'
- lib/templates/haml/scaffold/_form.html.haml - lib/templates/haml/scaffold/_form.html.haml
require:
- ./lib/linter/haml_middle_dot.rb
linters: linters:
AltText: AltText:
enabled: true enabled: true
MiddleDot:
enabled: true

View File

@ -11,6 +11,7 @@ require:
- rubocop-rspec - rubocop-rspec
- rubocop-performance - rubocop-performance
- rubocop-capybara - rubocop-capybara
- ./lib/linter/rubocop_middle_dot
AllCops: AllCops:
TargetRubyVersion: 3.0 # Set to minimum supported version of CI TargetRubyVersion: 3.0 # Set to minimum supported version of CI
@ -205,3 +206,6 @@ Style/TrailingCommaInArrayLiteral:
# https://docs.rubocop.org/rubocop/cops_style.html#styletrailingcommainhashliteral # https://docs.rubocop.org/rubocop/cops_style.html#styletrailingcommainhashliteral
Style/TrailingCommaInHashLiteral: Style/TrailingCommaInHashLiteral:
EnforcedStyleForMultiline: 'comma' EnforcedStyleForMultiline: 'comma'
Style/MiddleDot:
Enabled: true

View File

@ -20,7 +20,7 @@ gem 'dotenv-rails', '~> 2.8'
gem 'aws-sdk-s3', '~> 1.123', require: false gem 'aws-sdk-s3', '~> 1.123', require: false
gem 'fog-core', '<= 2.4.0' gem 'fog-core', '<= 2.4.0'
gem 'fog-openstack', '~> 0.3', require: false gem 'fog-openstack', '~> 0.3', require: false
gem 'kt-paperclip', '~> 7.1', github: 'kreeti/kt-paperclip', ref: '11abf222dc31bff71160a1d138b445214f434b2b' gem 'kt-paperclip', '~> 7.2'
gem 'blurhash', '~> 0.1' gem 'blurhash', '~> 0.1'
gem 'active_model_serializers', '~> 0.10' gem 'active_model_serializers', '~> 0.10'
@ -60,7 +60,6 @@ gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0' gem 'link_header', '~> 0.0'
gem 'mime-types', '~> 3.4.1', require: 'mime/types/columnar' gem 'mime-types', '~> 3.4.1', require: 'mime/types/columnar'
gem 'nokogiri', '~> 1.15' gem 'nokogiri', '~> 1.15'
gem 'nsa', '~> 0.2'
gem 'oj', '~> 3.14' gem 'oj', '~> 3.14'
gem 'ox', '~> 2.14' gem 'ox', '~> 2.14'
gem 'parslet' gem 'parslet'

View File

@ -7,18 +7,6 @@ GIT
hkdf (~> 0.2) hkdf (~> 0.2)
jwt (~> 2.0) jwt (~> 2.0)
GIT
remote: https://github.com/kreeti/kt-paperclip.git
revision: 11abf222dc31bff71160a1d138b445214f434b2b
ref: 11abf222dc31bff71160a1d138b445214f434b2b
specs:
kt-paperclip (7.1.1)
activemodel (>= 4.2.0)
activesupport (>= 4.2.0)
marcel (~> 1.0.1)
mime-types
terrapin (~> 0.6.0)
GIT GIT
remote: https://github.com/mastodon/rails-settings-cached.git remote: https://github.com/mastodon/rails-settings-cached.git
revision: 86328ef0bd04ce21cc0504ff5e334591e8c2ccab revision: 86328ef0bd04ce21cc0504ff5e334591e8c2ccab
@ -380,6 +368,12 @@ GEM
activerecord activerecord
kaminari-core (= 1.2.2) kaminari-core (= 1.2.2)
kaminari-core (1.2.2) kaminari-core (1.2.2)
kt-paperclip (7.2.0)
activemodel (>= 4.2.0)
activesupport (>= 4.2.0)
marcel (~> 1.0.1)
mime-types
terrapin (~> 0.6.0)
launchy (2.5.2) launchy (2.5.2)
addressable (~> 2.8) addressable (~> 2.8)
letter_opener (1.8.1) letter_opener (1.8.1)
@ -442,11 +436,6 @@ GEM
nokogiri (1.15.2) nokogiri (1.15.2)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nsa (0.2.8)
activesupport (>= 4.2, < 7)
concurrent-ruby (~> 1.0, >= 1.0.2)
sidekiq (>= 3.5)
statsd-ruby (~> 1.4, >= 1.4.0)
oj (3.14.3) oj (3.14.3)
omniauth (1.9.2) omniauth (1.9.2)
hashie (>= 3.4.6) hashie (>= 3.4.6)
@ -682,7 +671,6 @@ GEM
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
stackprof (0.2.25) stackprof (0.2.25)
statsd-ruby (1.5.0)
stoplight (3.0.1) stoplight (3.0.1)
redlock (~> 1.0) redlock (~> 1.0)
strong_migrations (0.8.0) strong_migrations (0.8.0)
@ -819,7 +807,7 @@ DEPENDENCIES
json-ld-preloaded (~> 3.2) json-ld-preloaded (~> 3.2)
json-schema (~> 4.0) json-schema (~> 4.0)
kaminari (~> 1.2) kaminari (~> 1.2)
kt-paperclip (~> 7.1)! kt-paperclip (~> 7.2)
letter_opener (~> 1.8) letter_opener (~> 1.8)
letter_opener_web (~> 2.0) letter_opener_web (~> 2.0)
link_header (~> 0.0) link_header (~> 0.0)
@ -831,7 +819,6 @@ DEPENDENCIES
net-http (~> 0.3.2) net-http (~> 0.3.2)
net-ldap (~> 0.18) net-ldap (~> 0.18)
nokogiri (~> 1.15) nokogiri (~> 1.15)
nsa (~> 0.2)
oj (~> 3.14) oj (~> 3.14)
omniauth (~> 1.9) omniauth (~> 1.9)
omniauth-cas (~> 2.0) omniauth-cas (~> 2.0)

View File

@ -42,6 +42,6 @@ class Api::V1::ListsController < Api::BaseController
end end
def list_params def list_params
params.permit(:title, :replies_policy) params.permit(:title, :replies_policy, :exclusive)
end end
end end

View File

@ -11,15 +11,15 @@ class BackupsController < ApplicationController
def download def download
case Paperclip::Attachment.default_options[:storage] case Paperclip::Attachment.default_options[:storage]
when :s3 when :s3
redirect_to @backup.dump.expiring_url(10) redirect_to @backup.dump.expiring_url(10), allow_other_host: true
when :fog when :fog
if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present? if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present?
redirect_to @backup.dump.expiring_url(Time.now.utc + 10) redirect_to @backup.dump.expiring_url(Time.now.utc + 10), allow_other_host: true
else else
redirect_to full_asset_url(@backup.dump.url) redirect_to full_asset_url(@backup.dump.url), allow_other_host: true
end end
when :filesystem when :filesystem
redirect_to full_asset_url(@backup.dump.url) redirect_to full_asset_url(@backup.dump.url), allow_other_host: true
end end
end end

View File

@ -138,7 +138,7 @@ export function normalizePollOptionTranslation(translation, poll) {
export function normalizeAnnouncement(announcement) { export function normalizeAnnouncement(announcement) {
const normalAnnouncement = { ...announcement }; const normalAnnouncement = { ...announcement };
const emojiMap = makeEmojiMap.emojis(normalAnnouncement); const emojiMap = makeEmojiMap(normalAnnouncement.emojis);
normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap); normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);

View File

@ -151,10 +151,10 @@ export const createListFail = error => ({
error, error,
}); });
export const updateList = (id, title, shouldReset, replies_policy) => (dispatch, getState) => { export const updateList = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch, getState) => {
dispatch(updateListRequest(id)); dispatch(updateListRequest(id));
api(getState).put(`/api/v1/lists/${id}`, { title, replies_policy }).then(({ data }) => { api(getState).put(`/api/v1/lists/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => {
dispatch(updateListSuccess(data)); dispatch(updateListSuccess(data));
if (shouldReset) { if (shouldReset) {

View File

@ -67,7 +67,7 @@ class Explore extends PureComponent {
<Search /> <Search />
</div> </div>
<div className='scrollable scrollable--flex'> <div className='scrollable scrollable--flex' data-nosnippet>
{isSearching ? ( {isSearching ? (
<SearchResults /> <SearchResults />
) : ( ) : (

View File

@ -8,6 +8,8 @@ import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import Toggle from 'react-toggle';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { fetchList, deleteList, updateList } from 'mastodon/actions/lists'; import { fetchList, deleteList, updateList } from 'mastodon/actions/lists';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
@ -145,7 +147,13 @@ class ListTimeline extends PureComponent {
handleRepliesPolicyChange = ({ target }) => { handleRepliesPolicyChange = ({ target }) => {
const { dispatch } = this.props; const { dispatch } = this.props;
const { id } = this.props.params; const { id } = this.props.params;
dispatch(updateList(id, undefined, false, target.value)); dispatch(updateList(id, undefined, false, undefined, target.value));
};
onExclusiveToggle = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateList(id, undefined, false, target.checked, undefined));
}; };
render () { render () {
@ -154,6 +162,7 @@ class ListTimeline extends PureComponent {
const pinned = !!columnId; const pinned = !!columnId;
const title = list ? list.get('title') : id; const title = list ? list.get('title') : id;
const replies_policy = list ? list.get('replies_policy') : undefined; const replies_policy = list ? list.get('replies_policy') : undefined;
const isExclusive = list ? list.get('exclusive') : undefined;
if (typeof list === 'undefined') { if (typeof list === 'undefined') {
return ( return (
@ -191,6 +200,13 @@ class ListTimeline extends PureComponent {
</button> </button>
</div> </div>
<div className='setting-toggle'>
<Toggle id={`list-${id}-exclusive`} defaultChecked={isExclusive} onChange={this.onExclusiveToggle} />
<label htmlFor={`list-${id}-exclusive`} className='setting-toggle__label'>
<FormattedMessage id='lists.exclusive' defaultMessage='Hide these posts from home' />
</label>
</div>
{ replies_policy !== undefined && ( { replies_policy !== undefined && (
<div role='group' aria-labelledby={`list-${id}-replies-policy`}> <div role='group' aria-labelledby={`list-${id}-replies-policy`}>
<span id={`list-${id}-replies-policy`} className='column-settings__section'> <span id={`list-${id}-replies-policy`} className='column-settings__section'>

View File

@ -121,7 +121,7 @@ class Onboarding extends ImmutablePureComponent {
<div className='onboarding__steps'> <div className='onboarding__steps'>
<Step onClick={this.handleProfileClick} href='/settings/profile' completed={(!account.get('avatar').endsWith('missing.png')) || (account.get('display_name').length > 0 && account.get('note').length > 0)} icon='address-book-o' label={<FormattedMessage id='onboarding.steps.setup_profile.title' defaultMessage='Customize your profile' />} description={<FormattedMessage id='onboarding.steps.setup_profile.body' defaultMessage='Others are more likely to interact with you with a filled out profile.' />} /> <Step onClick={this.handleProfileClick} href='/settings/profile' completed={(!account.get('avatar').endsWith('missing.png')) || (account.get('display_name').length > 0 && account.get('note').length > 0)} icon='address-book-o' label={<FormattedMessage id='onboarding.steps.setup_profile.title' defaultMessage='Customize your profile' />} description={<FormattedMessage id='onboarding.steps.setup_profile.body' defaultMessage='Others are more likely to interact with you with a filled out profile.' />} />
<Step onClick={this.handleFollowClick} completed={(account.get('following_count') * 1) >= 7} icon='user-plus' label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Follow {count, plural, one {one person} other {# people}}' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own feed. Let's fill it with interesting people." />} /> <Step onClick={this.handleFollowClick} completed={(account.get('following_count') * 1) >= 7} icon='user-plus' label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Find at least {count, plural, one {one person} other {# people}} to follow' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own home feed. Let's fill it with interesting people." />} />
<Step onClick={this.handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' label={<FormattedMessage id='onboarding.steps.publish_status.title' defaultMessage='Make your first post' />} description={<FormattedMessage id='onboarding.steps.publish_status.body' defaultMessage='Say hello to the world.' />} /> <Step onClick={this.handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' label={<FormattedMessage id='onboarding.steps.publish_status.title' defaultMessage='Make your first post' />} description={<FormattedMessage id='onboarding.steps.publish_status.body' defaultMessage='Say hello to the world.' />} />
<Step onClick={this.handleShareClick} completed={shareClicked} icon='copy' label={<FormattedMessage id='onboarding.steps.share_profile.title' defaultMessage='Share your profile' />} description={<FormattedMessage id='onboarding.steps.share_profile.body' defaultMessage='Let your friends know how to find you on Mastodon!' />} /> <Step onClick={this.handleShareClick} completed={shareClicked} icon='copy' label={<FormattedMessage id='onboarding.steps.share_profile.title' defaultMessage='Share your profile' />} description={<FormattedMessage id='onboarding.steps.share_profile.body' defaultMessage='Let your friends know how to find you on Mastodon!' />} />
</div> </div>

View File

@ -217,7 +217,7 @@ class DetailedStatus extends ImmutablePureComponent {
} else if (this.context.router) { } else if (this.context.router) {
reblogLink = ( reblogLink = (
<> <>
· {' · '}
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`} className='detailed-status__link'> <Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`} className='detailed-status__link'>
<Icon id={reblogIcon} /> <Icon id={reblogIcon} />
<span className='detailed-status__reblogs'> <span className='detailed-status__reblogs'>
@ -229,7 +229,7 @@ class DetailedStatus extends ImmutablePureComponent {
} else { } else {
reblogLink = ( reblogLink = (
<> <>
· {' · '}
<a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}> <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
<Icon id={reblogIcon} /> <Icon id={reblogIcon} />
<span className='detailed-status__reblogs'> <span className='detailed-status__reblogs'>
@ -263,7 +263,7 @@ class DetailedStatus extends ImmutablePureComponent {
if (status.get('edited_at')) { if (status.get('edited_at')) {
edited = ( edited = (
<> <>
· {' · '}
<EditedTimestamp statusId={status.get('id')} timestamp={status.get('edited_at')} /> <EditedTimestamp statusId={status.get('id')} timestamp={status.get('edited_at')} />
</> </>
); );

View File

@ -356,6 +356,7 @@
"lists.delete": "Delete list", "lists.delete": "Delete list",
"lists.edit": "Edit list", "lists.edit": "Edit list",
"lists.edit.submit": "Change title", "lists.edit.submit": "Change title",
"lists.exclusive": "Hide these posts from home",
"lists.new.create": "Add list", "lists.new.create": "Add list",
"lists.new.title_placeholder": "New list title", "lists.new.title_placeholder": "New list title",
"lists.replies_policy.followed": "Any followed user", "lists.replies_policy.followed": "Any followed user",
@ -460,8 +461,8 @@
"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?",
"onboarding.start.title": "You've made it!", "onboarding.start.title": "You've made it!",
"onboarding.steps.follow_people.body": "You curate your own feed. Lets fill it with interesting people.", "onboarding.steps.follow_people.body": "You curate your own home feed. Let's fill it with interesting people.",
"onboarding.steps.follow_people.title": "Follow {count, plural, one {one person} other {# people}}", "onboarding.steps.follow_people.title": "Find at least {count, plural, one {one person} other {# people}} to follow",
"onboarding.steps.publish_status.body": "Say hello to the world.", "onboarding.steps.publish_status.body": "Say hello to the world.",
"onboarding.steps.publish_status.title": "Make your first post", "onboarding.steps.publish_status.title": "Make your first post",
"onboarding.steps.setup_profile.body": "Others are more likely to interact with you with a filled out profile.", "onboarding.steps.setup_profile.body": "Others are more likely to interact with you with a filled out profile.",

View File

@ -48,6 +48,7 @@ export const IntlProvider: React.FC<
locale={locale} locale={locale}
messages={messages} messages={messages}
onError={onProviderError} onError={onProviderError}
textComponent='span'
{...props} {...props}
> >
{children} {children}

View File

@ -25,6 +25,7 @@ const initialState = ImmutableMap({
isSubmitting: false, isSubmitting: false,
isChanged: false, isChanged: false,
title: '', title: '',
isExclusive: false,
accounts: ImmutableMap({ accounts: ImmutableMap({
items: ImmutableList(), items: ImmutableList(),
@ -46,6 +47,7 @@ export default function listEditorReducer(state = initialState, action) {
return state.withMutations(map => { return state.withMutations(map => {
map.set('listId', action.list.get('id')); map.set('listId', action.list.get('id'));
map.set('title', action.list.get('title')); map.set('title', action.list.get('title'));
map.set('isExclusive', action.list.get('is_exclusive'));
map.set('isSubmitting', false); map.set('isSubmitting', false);
}); });
case LIST_EDITOR_TITLE_CHANGE: case LIST_EDITOR_TITLE_CHANGE:

View File

@ -3,11 +3,8 @@
display: block; display: block;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); overflow: hidden;
border-radius: 4px;
@media screen and (max-width: $no-gap-breakpoint) {
box-shadow: none;
}
&:hover, &:hover,
&:active, &:active,
@ -22,7 +19,6 @@
height: 130px; height: 130px;
position: relative; position: relative;
background: darken($ui-base-color, 12%); background: darken($ui-base-color, 12%);
border-radius: 4px 4px 0 0;
img { img {
display: block; display: block;
@ -30,7 +26,6 @@
height: 100%; height: 100%;
margin: 0; margin: 0;
object-fit: cover; object-fit: cover;
border-radius: 4px 4px 0 0;
} }
@media screen and (width <= 600px) { @media screen and (width <= 600px) {
@ -45,11 +40,6 @@
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
background: lighten($ui-base-color, 4%); background: lighten($ui-base-color, 4%);
border-radius: 0 0 4px 4px;
@media screen and (max-width: $no-gap-breakpoint) {
border-radius: 0;
}
.avatar { .avatar {
flex: 0 0 auto; flex: 0 0 auto;

View File

@ -137,6 +137,10 @@ code {
color: $secondary-text-color; color: $secondary-text-color;
margin-bottom: 30px; margin-bottom: 30px;
&.invited-by {
margin-bottom: 15px;
}
a { a {
color: $highlight-text-color; color: $highlight-text-color;
} }

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Admin::Metrics::Dimension::InstanceAccountsDimension < Admin::Metrics::Dimension::BaseDimension class Admin::Metrics::Dimension::InstanceAccountsDimension < Admin::Metrics::Dimension::BaseDimension
include Admin::Metrics::Dimension::QueryHelper
include LanguagesHelper include LanguagesHelper
def self.with_params? def self.with_params?
@ -14,19 +15,23 @@ class Admin::Metrics::Dimension::InstanceAccountsDimension < Admin::Metrics::Dim
protected protected
def perform_query def perform_query
sql = <<-SQL.squish dimension_data_rows.map { |row| { key: row['username'], human_key: row['username'], value: row['value'].to_s } }
end
def sql_array
[sql_query_string, { domain: params[:domain], limit: @limit }]
end
def sql_query_string
<<~SQL.squish
SELECT accounts.username, count(follows.*) AS value SELECT accounts.username, count(follows.*) AS value
FROM accounts FROM accounts
LEFT JOIN follows ON follows.target_account_id = accounts.id LEFT JOIN follows ON follows.target_account_id = accounts.id
WHERE accounts.domain = $1 WHERE accounts.domain = :domain
GROUP BY accounts.id, follows.target_account_id GROUP BY accounts.id, follows.target_account_id
ORDER BY value DESC ORDER BY value DESC
LIMIT $2 LIMIT :limit
SQL SQL
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:domain]], [nil, @limit]])
rows.map { |row| { key: row['username'], human_key: row['username'], value: row['value'].to_s } }
end end
def params def params

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Admin::Metrics::Dimension::InstanceLanguagesDimension < Admin::Metrics::Dimension::BaseDimension class Admin::Metrics::Dimension::InstanceLanguagesDimension < Admin::Metrics::Dimension::BaseDimension
include Admin::Metrics::Dimension::QueryHelper
include LanguagesHelper include LanguagesHelper
def self.with_params? def self.with_params?
@ -14,21 +15,33 @@ class Admin::Metrics::Dimension::InstanceLanguagesDimension < Admin::Metrics::Di
protected protected
def perform_query def perform_query
sql = <<-SQL.squish dimension_data_rows.map { |row| { key: row['language'], human_key: standard_locale_name(row['language']), value: row['value'].to_s } }
end
def sql_array
[sql_query_string, { domain: params[:domain], earliest_status_id: earliest_status_id, latest_status_id: latest_status_id, limit: @limit }]
end
def sql_query_string
<<~SQL.squish
SELECT COALESCE(statuses.language, 'und') AS language, count(*) AS value SELECT COALESCE(statuses.language, 'und') AS language, count(*) AS value
FROM statuses FROM statuses
INNER JOIN accounts ON accounts.id = statuses.account_id INNER JOIN accounts ON accounts.id = statuses.account_id
WHERE accounts.domain = $1 WHERE accounts.domain = :domain
AND statuses.id BETWEEN $2 AND $3 AND statuses.id BETWEEN :earliest_status_id AND :latest_status_id
AND statuses.reblog_of_id IS NULL AND statuses.reblog_of_id IS NULL
GROUP BY COALESCE(statuses.language, 'und') GROUP BY COALESCE(statuses.language, 'und')
ORDER BY count(*) DESC ORDER BY count(*) DESC
LIMIT $4 LIMIT :limit
SQL SQL
end
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:domain]], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @limit]]) def earliest_status_id
Mastodon::Snowflake.id_at(@start_at, with_random: false)
end
rows.map { |row| { key: row['language'], human_key: standard_locale_name(row['language']), value: row['value'].to_s } } def latest_status_id
Mastodon::Snowflake.id_at(@end_at, with_random: false)
end end
def params def params

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Admin::Metrics::Dimension::LanguagesDimension < Admin::Metrics::Dimension::BaseDimension class Admin::Metrics::Dimension::LanguagesDimension < Admin::Metrics::Dimension::BaseDimension
include Admin::Metrics::Dimension::QueryHelper
include LanguagesHelper include LanguagesHelper
def key def key
@ -10,18 +11,22 @@ class Admin::Metrics::Dimension::LanguagesDimension < Admin::Metrics::Dimension:
protected protected
def perform_query def perform_query
sql = <<-SQL.squish dimension_data_rows.map { |row| { key: row['locale'], human_key: standard_locale_name(row['locale']), value: row['value'].to_s } }
end
def sql_array
[sql_query_string, { start_at: @start_at, end_at: @end_at, limit: @limit }]
end
def sql_query_string
<<~SQL.squish
SELECT locale, count(*) AS value SELECT locale, count(*) AS value
FROM users FROM users
WHERE current_sign_in_at BETWEEN $1 AND $2 WHERE current_sign_in_at BETWEEN :start_at AND :end_at
AND locale IS NOT NULL AND locale IS NOT NULL
GROUP BY locale GROUP BY locale
ORDER BY count(*) DESC ORDER BY count(*) DESC
LIMIT $3 LIMIT :limit
SQL SQL
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @limit]])
rows.map { |row| { key: row['locale'], human_key: standard_locale_name(row['locale']), value: row['value'].to_s } }
end end
end end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
module Admin::Metrics::Dimension::QueryHelper
protected
def dimension_data_rows
ActiveRecord::Base.connection.select_all(sanitized_sql_string)
end
def sanitized_sql_string
ActiveRecord::Base.sanitize_sql_array(sql_array)
end
end

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class Admin::Metrics::Dimension::ServersDimension < Admin::Metrics::Dimension::BaseDimension class Admin::Metrics::Dimension::ServersDimension < Admin::Metrics::Dimension::BaseDimension
include Admin::Metrics::Dimension::QueryHelper
def key def key
'servers' 'servers'
end end
@ -8,18 +10,30 @@ class Admin::Metrics::Dimension::ServersDimension < Admin::Metrics::Dimension::B
protected protected
def perform_query def perform_query
sql = <<-SQL.squish dimension_data_rows.map { |row| { key: row['domain'] || Rails.configuration.x.local_domain, human_key: row['domain'] || Rails.configuration.x.local_domain, value: row['value'].to_s } }
end
def sql_array
[sql_query_string, { earliest_status_id: earliest_status_id, latest_status_id: latest_status_id, limit: @limit }]
end
def sql_query_string
<<~SQL.squish
SELECT accounts.domain, count(*) AS value SELECT accounts.domain, count(*) AS value
FROM statuses FROM statuses
INNER JOIN accounts ON accounts.id = statuses.account_id INNER JOIN accounts ON accounts.id = statuses.account_id
WHERE statuses.id BETWEEN $1 AND $2 WHERE statuses.id BETWEEN :earliest_status_id AND :latest_status_id
GROUP BY accounts.domain GROUP BY accounts.domain
ORDER BY count(*) DESC ORDER BY count(*) DESC
LIMIT $3 LIMIT :limit
SQL SQL
end
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, Mastodon::Snowflake.id_at(@start_at)], [nil, Mastodon::Snowflake.id_at(@end_at)], [nil, @limit]]) def earliest_status_id
Mastodon::Snowflake.id_at(@start_at)
end
rows.map { |row| { key: row['domain'] || Rails.configuration.x.local_domain, human_key: row['domain'] || Rails.configuration.x.local_domain, value: row['value'].to_s } } def latest_status_id
Mastodon::Snowflake.id_at(@end_at)
end end
end end

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class Admin::Metrics::Dimension::SourcesDimension < Admin::Metrics::Dimension::BaseDimension class Admin::Metrics::Dimension::SourcesDimension < Admin::Metrics::Dimension::BaseDimension
include Admin::Metrics::Dimension::QueryHelper
def key def key
'sources' 'sources'
end end
@ -8,18 +10,22 @@ class Admin::Metrics::Dimension::SourcesDimension < Admin::Metrics::Dimension::B
protected protected
def perform_query def perform_query
sql = <<-SQL.squish dimension_data_rows.map { |row| { key: row['name'] || 'web', human_key: row['name'] || I18n.t('admin.dashboard.website'), value: row['value'].to_s } }
end
def sql_array
[sql_query_string, { start_at: @start_at, end_at: @end_at, limit: @limit }]
end
def sql_query_string
<<~SQL.squish
SELECT oauth_applications.name, count(*) AS value SELECT oauth_applications.name, count(*) AS value
FROM users FROM users
LEFT JOIN oauth_applications ON oauth_applications.id = users.created_by_application_id LEFT JOIN oauth_applications ON oauth_applications.id = users.created_by_application_id
WHERE users.created_at BETWEEN $1 AND $2 WHERE users.created_at BETWEEN :start_at AND :end_at
GROUP BY oauth_applications.name GROUP BY oauth_applications.name
ORDER BY count(*) DESC ORDER BY count(*) DESC
LIMIT $3 LIMIT :limit
SQL SQL
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @limit]])
rows.map { |row| { key: row['name'] || 'web', human_key: row['name'] || I18n.t('admin.dashboard.website'), value: row['value'].to_s } }
end end
end end

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Admin::Metrics::Dimension::TagLanguagesDimension < Admin::Metrics::Dimension::BaseDimension class Admin::Metrics::Dimension::TagLanguagesDimension < Admin::Metrics::Dimension::BaseDimension
include Admin::Metrics::Dimension::QueryHelper
include LanguagesHelper include LanguagesHelper
def self.with_params? def self.with_params?
@ -14,20 +15,36 @@ class Admin::Metrics::Dimension::TagLanguagesDimension < Admin::Metrics::Dimensi
protected protected
def perform_query def perform_query
sql = <<-SQL.squish dimension_data_rows.map { |row| { key: row['language'], human_key: standard_locale_name(row['language']), value: row['value'].to_s } }
end
def sql_array
[sql_query_string, { tag_id: tag_id, earliest_status_id: earliest_status_id, latest_status_id: latest_status_id, limit: @limit }]
end
def sql_query_string
<<~SQL.squish
SELECT COALESCE(statuses.language, 'und') AS language, count(*) AS value SELECT COALESCE(statuses.language, 'und') AS language, count(*) AS value
FROM statuses FROM statuses
INNER JOIN statuses_tags ON statuses_tags.status_id = statuses.id INNER JOIN statuses_tags ON statuses_tags.status_id = statuses.id
WHERE statuses_tags.tag_id = $1 WHERE statuses_tags.tag_id = :tag_id
AND statuses.id BETWEEN $2 AND $3 AND statuses.id BETWEEN :earliest_status_id AND :latest_status_id
GROUP BY COALESCE(statuses.language, 'und') GROUP BY COALESCE(statuses.language, 'und')
ORDER BY count(*) DESC ORDER BY count(*) DESC
LIMIT $4 LIMIT :limit
SQL SQL
end
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:id]], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @limit]]) def tag_id
params[:id]
end
rows.map { |row| { key: row['language'], human_key: standard_locale_name(row['language']), value: row['value'].to_s } } def earliest_status_id
Mastodon::Snowflake.id_at(@start_at, with_random: false)
end
def latest_status_id
Mastodon::Snowflake.id_at(@end_at, with_random: false)
end end
def params def params

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class Admin::Metrics::Dimension::TagServersDimension < Admin::Metrics::Dimension::BaseDimension class Admin::Metrics::Dimension::TagServersDimension < Admin::Metrics::Dimension::BaseDimension
include Admin::Metrics::Dimension::QueryHelper
def self.with_params? def self.with_params?
true true
end end
@ -12,21 +14,37 @@ class Admin::Metrics::Dimension::TagServersDimension < Admin::Metrics::Dimension
protected protected
def perform_query def perform_query
sql = <<-SQL.squish dimension_data_rows.map { |row| { key: row['domain'] || Rails.configuration.x.local_domain, human_key: row['domain'] || Rails.configuration.x.local_domain, value: row['value'].to_s } }
end
def sql_array
[sql_query_string, { tag_id: tag_id, earliest_status_id: earliest_status_id, latest_status_id: latest_status_id, limit: @limit }]
end
def sql_query_string
<<-SQL.squish
SELECT accounts.domain, count(*) AS value SELECT accounts.domain, count(*) AS value
FROM statuses FROM statuses
INNER JOIN accounts ON accounts.id = statuses.account_id INNER JOIN accounts ON accounts.id = statuses.account_id
INNER JOIN statuses_tags ON statuses_tags.status_id = statuses.id INNER JOIN statuses_tags ON statuses_tags.status_id = statuses.id
WHERE statuses_tags.tag_id = $1 WHERE statuses_tags.tag_id = :tag_id
AND statuses.id BETWEEN $2 AND $3 AND statuses.id BETWEEN :earliest_status_id AND :latest_status_id
GROUP BY accounts.domain GROUP BY accounts.domain
ORDER BY count(*) DESC ORDER BY count(*) DESC
LIMIT $4 LIMIT :limit
SQL SQL
end
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:id]], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @limit]]) def tag_id
params[:id]
end
rows.map { |row| { key: row['domain'] || Rails.configuration.x.local_domain, human_key: row['domain'] || Rails.configuration.x.local_domain, value: row['value'].to_s } } def earliest_status_id
Mastodon::Snowflake.id_at(@start_at, with_random: false)
end
def latest_status_id
Mastodon::Snowflake.id_at(@end_at, with_random: false)
end end
def params def params

View File

@ -40,9 +40,9 @@ class FeedManager
def filter?(timeline_type, status, receiver) def filter?(timeline_type, status, receiver)
case timeline_type case timeline_type
when :home when :home
filter_from_home?(status, receiver.id, build_crutches(receiver.id, [status])) filter_from_home?(status, receiver.id, build_crutches(receiver.id, [status]), :home)
when :list when :list
filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status])) filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status]), :list)
when :mentions when :mentions
filter_from_mentions?(status, receiver.id) filter_from_mentions?(status, receiver.id)
when :direct when :direct
@ -401,10 +401,11 @@ class FeedManager
# @param [Integer] receiver_id # @param [Integer] receiver_id
# @param [Hash] crutches # @param [Hash] crutches
# @return [Boolean] # @return [Boolean]
def filter_from_home?(status, receiver_id, crutches) def filter_from_home?(status, receiver_id, crutches, timeline_type = :home)
return false if receiver_id == status.account_id return false if receiver_id == status.account_id
return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?) return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
return true if crutches[:languages][status.account_id].present? && status.language.present? && !crutches[:languages][status.account_id].include?(status.language) return true if timeline_type != :list && crutches[:exclusive_list_users][status.account_id].present?
return true if crutches[:languages][status.account_id].present? && status.language.present? && !crutches[:languages][status.account_id].include?(status.language)
check_for_blocks = crutches[:active_mentions][status.id] || [] check_for_blocks = crutches[:active_mentions][status.id] || []
check_for_blocks.push(status.account_id) check_for_blocks.push(status.account_id)
@ -603,13 +604,16 @@ class FeedManager
arr arr
end end
crutches[:following] = Follow.where(account_id: receiver_id, target_account_id: statuses.filter_map(&:in_reply_to_account_id)).pluck(:target_account_id).index_with(true) lists = List.where(account_id: receiver_id, exclusive: true)
crutches[:languages] = Follow.where(account_id: receiver_id, target_account_id: statuses.map(&:account_id)).pluck(:target_account_id, :languages).to_h
crutches[:hiding_reblogs] = Follow.where(account_id: receiver_id, target_account_id: statuses.filter_map { |s| s.account_id if s.reblog? }, show_reblogs: false).pluck(:target_account_id).index_with(true) crutches[:following] = Follow.where(account_id: receiver_id, target_account_id: statuses.filter_map(&:in_reply_to_account_id)).pluck(:target_account_id).index_with(true)
crutches[:blocking] = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).index_with(true) crutches[:languages] = Follow.where(account_id: receiver_id, target_account_id: statuses.map(&:account_id)).pluck(:target_account_id, :languages).to_h
crutches[:muting] = Mute.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).index_with(true) crutches[:hiding_reblogs] = Follow.where(account_id: receiver_id, target_account_id: statuses.filter_map { |s| s.account_id if s.reblog? }, show_reblogs: false).pluck(:target_account_id).index_with(true)
crutches[:domain_blocking] = AccountDomainBlock.where(account_id: receiver_id, domain: statuses.flat_map { |s| [s.account.domain, s.reblog&.account&.domain] }.compact).pluck(:domain).index_with(true) crutches[:blocking] = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).index_with(true)
crutches[:blocked_by] = Block.where(target_account_id: receiver_id, account_id: statuses.map { |s| [s.account_id, s.reblog&.account_id] }.flatten.compact).pluck(:account_id).index_with(true) crutches[:muting] = Mute.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).index_with(true)
crutches[:domain_blocking] = AccountDomainBlock.where(account_id: receiver_id, domain: statuses.flat_map { |s| [s.account.domain, s.reblog&.account&.domain] }.compact).pluck(:domain).index_with(true)
crutches[:blocked_by] = Block.where(target_account_id: receiver_id, account_id: statuses.map { |s| [s.account_id, s.reblog&.account_id] }.flatten.compact).pluck(:account_id).index_with(true)
crutches[:exclusive_list_users] = ListAccount.where(list: lists, account_id: statuses.map(&:account_id)).pluck(:account_id).index_with(true)
crutches crutches
end end

View File

@ -10,6 +10,7 @@
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# replies_policy :integer default("list"), not null # replies_policy :integer default("list"), not null
# exclusive :boolean default(FALSE)
# #
class List < ApplicationRecord class List < ApplicationRecord

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class REST::ListSerializer < ActiveModel::Serializer class REST::ListSerializer < ActiveModel::Serializer
attributes :id, :title, :replies_policy attributes :id, :title, :replies_policy, :exclusive
def id def id
object.id.to_s object.id.to_s

View File

@ -9,6 +9,6 @@
- if email_domain_block.parent.present? - if email_domain_block.parent.present?
= t('admin.email_domain_blocks.resolved_through_html', domain: content_tag(:samp, email_domain_block.parent.domain)) = t('admin.email_domain_blocks.resolved_through_html', domain: content_tag(:samp, email_domain_block.parent.domain))
·
= t('admin.email_domain_blocks.attempts_over_week', count: email_domain_block.history.reduce(0) { |sum, day| sum + day.accounts }) = t('admin.email_domain_blocks.attempts_over_week', count: email_domain_block.history.reduce(0) { |sum, day| sum + day.accounts })

View File

@ -17,11 +17,11 @@
%br/ %br/
= f.object.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' ') = f.object.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' · ')
- if f.object.public_comment.present? - if f.object.public_comment.present?
·
= f.object.public_comment = f.object.public_comment
- if existing_relationships - if existing_relationships
·
= fa_icon 'warning fw' = fa_icon 'warning fw'
= t('admin.export_domain_blocks.import.existing_relationships_warning') = t('admin.export_domain_blocks.import.existing_relationships_warning')

View File

@ -6,7 +6,7 @@
%small %small
- if instance.domain_block - if instance.domain_block
= instance.domain_block.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' ') = instance.domain_block.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' · ')
- elsif instance.domain_allow - elsif instance.domain_allow
= t('admin.accounts.whitelisted') = t('admin.accounts.whitelisted')
- else - else

View File

@ -55,7 +55,7 @@
%td= @instance.domain_block.public_comment %td= @instance.domain_block.public_comment
%tr %tr
%th= t('admin.instances.content_policies.policy') %th= t('admin.instances.content_policies.policy')
%td= @instance.domain_block.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' ') %td= @instance.domain_block.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' · ')
= link_to t('admin.domain_blocks.edit'), edit_admin_domain_block_path(@instance.domain_block), class: 'button' = link_to t('admin.domain_blocks.edit'), edit_admin_domain_block_path(@instance.domain_block), class: 'button'
= link_to t('admin.domain_blocks.undo'), admin_domain_block_path(@instance.domain_block), class: 'button', data: { confirm: t('admin.accounts.are_you_sure'), method: :delete } = link_to t('admin.domain_blocks.undo'), admin_domain_block_path(@instance.domain_block), class: 'button', data: { confirm: t('admin.accounts.are_you_sure'), method: :delete }

View File

@ -5,7 +5,7 @@
.pending-account__header .pending-account__header
%samp= link_to "#{ip_block.ip}/#{ip_block.ip.prefix}", admin_accounts_path(ip: "#{ip_block.ip}/#{ip_block.ip.prefix}") %samp= link_to "#{ip_block.ip}/#{ip_block.ip.prefix}", admin_accounts_path(ip: "#{ip_block.ip}/#{ip_block.ip.prefix}")
- if ip_block.comment.present? - if ip_block.comment.present?
·
= ip_block.comment = ip_block.comment
%br/ %br/
= t("simple_form.labels.ip_block.severities.#{ip_block.severity}") = t("simple_form.labels.ip_block.severities.#{ip_block.severity}")

View File

@ -24,7 +24,7 @@
= t('admin.roles.everyone_full_description_html') = t('admin.roles.everyone_full_description_html')
- else - else
= link_to t('admin.roles.assigned_users', count: role.users.count), admin_accounts_path(role_ids: role.id) = link_to t('admin.roles.assigned_users', count: role.users.count), admin_accounts_path(role_ids: role.id)
·
%abbr{ title: role.permissions_as_keys.map { |privilege| I18n.t("admin.roles.privileges.#{privilege}") }.join(', ') }= t('admin.roles.permissions_count', count: role.permissions_as_keys.size) %abbr{ title: role.permissions_as_keys.map { |privilege| I18n.t("admin.roles.privileges.#{privilege}") }.join(', ') }= t('admin.roles.permissions_count', count: role.permissions_as_keys.size)
%div %div
= table_link_to 'pencil', t('admin.accounts.edit'), edit_admin_role_path(role) if can?(:update, role) = table_link_to 'pencil', t('admin.accounts.edit'), edit_admin_role_path(role) if can?(:update, role)

View File

@ -12,7 +12,7 @@
.fields-group .fields-group
= f.input :media_cache_retention_period, wrapper: :with_block_label, input_html: { pattern: '[0-9]+' } = f.input :media_cache_retention_period, wrapper: :with_block_label, input_html: { pattern: '[0-9]+' }
= f.input :content_cache_retention_period, wrapper: :with_block_label, input_html: { pattern: '[0-9]+' } = f.input :content_cache_retention_period, wrapper: :with_block_label, input_html: { pattern: '[0-9]+' }, hint: false, warning_hint: t('simple_form.hints.form_admin_settings.content_cache_retention_period')
= f.input :backups_retention_period, wrapper: :with_block_label, input_html: { pattern: '[0-9]+' } = f.input :backups_retention_period, wrapper: :with_block_label, input_html: { pattern: '[0-9]+' }
.actions .actions

View File

@ -10,21 +10,21 @@
- if preview_card.provider_name.present? - if preview_card.provider_name.present?
= preview_card.provider_name = preview_card.provider_name
·
- if preview_card.language.present? - if preview_card.language.present?
= standard_locale_name(preview_card.language) = standard_locale_name(preview_card.language)
·
= t('admin.trends.links.shared_by_over_week', count: preview_card.history.reduce(0) { |sum, day| sum + day.accounts }) = t('admin.trends.links.shared_by_over_week', count: preview_card.history.reduce(0) { |sum, day| sum + day.accounts })
- if preview_card.trend.allowed? - if preview_card.trend.allowed?
·
%abbr{ title: t('admin.trends.tags.current_score', score: preview_card.trend.score) }= t('admin.trends.tags.trending_rank', rank: preview_card.trend.rank) %abbr{ title: t('admin.trends.tags.current_score', score: preview_card.trend.score) }= t('admin.trends.tags.trending_rank', rank: preview_card.trend.rank)
- if preview_card.decaying? - if preview_card.decaying?
·
= t('admin.trends.tags.peaked_on_and_decaying', date: l(preview_card.max_score_at.to_date, format: :short)) = t('admin.trends.tags.peaked_on_and_decaying', date: l(preview_card.max_score_at.to_date, format: :short))
- elsif preview_card.requires_review? - elsif preview_card.requires_review?
·
= t('admin.trends.pending_review') = t('admin.trends.pending_review')

View File

@ -17,17 +17,17 @@
= t('admin.trends.statuses.shared_by', count: status.reblogs_count + status.favourites_count, friendly_count: friendly_number_to_human(status.reblogs_count + status.favourites_count)) = t('admin.trends.statuses.shared_by', count: status.reblogs_count + status.favourites_count, friendly_count: friendly_number_to_human(status.reblogs_count + status.favourites_count))
- if status.account.domain.present? - if status.account.domain.present?
·
= status.account.domain = status.account.domain
- if status.language.present? - if status.language.present?
·
= standard_locale_name(status.language) = standard_locale_name(status.language)
- if status.trendable? && !status.account.discoverable? - if status.trendable? && !status.account.discoverable?
·
= t('admin.trends.statuses.not_discoverable') = t('admin.trends.statuses.not_discoverable')
- if status.trend.allowed? - if status.trend.allowed?
·
%abbr{ title: t('admin.trends.tags.current_score', score: status.trend.score) }= t('admin.trends.tags.trending_rank', rank: status.trend.rank) %abbr{ title: t('admin.trends.tags.current_score', score: status.trend.score) }= t('admin.trends.tags.trending_rank', rank: status.trend.rank)
- elsif status.requires_review? - elsif status.requires_review?
·
= t('admin.trends.pending_review') = t('admin.trends.pending_review')

View File

@ -13,12 +13,12 @@
= t('admin.trends.tags.used_by_over_week', count: tag.history.reduce(0) { |sum, day| sum + day.accounts }) = t('admin.trends.tags.used_by_over_week', count: tag.history.reduce(0) { |sum, day| sum + day.accounts })
- if tag.trendable? && (rank = Trends.tags.rank(tag.id)) - if tag.trendable? && (rank = Trends.tags.rank(tag.id))
·
%abbr{ title: t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id)) }= t('admin.trends.tags.trending_rank', rank: rank + 1) %abbr{ title: t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id)) }= t('admin.trends.tags.trending_rank', rank: rank + 1)
- if tag.decaying? - if tag.decaying?
·
= t('admin.trends.tags.peaked_on_and_decaying', date: l(tag.max_score_at.to_date, format: :short)) = t('admin.trends.tags.peaked_on_and_decaying', date: l(tag.max_score_at.to_date, format: :short))
- elsif tag.requires_review? - elsif tag.requires_review?
·
= t('admin.trends.pending_review') = t('admin.trends.pending_review')

View File

@ -10,7 +10,7 @@
- else - else
%span.negative-hint= t('admin.webhooks.disabled') %span.negative-hint= t('admin.webhooks.disabled')
·
%abbr{ title: webhook.events.join(', ') }= t('admin.webhooks.enabled_events', count: webhook.events.size) %abbr{ title: webhook.events.join(', ') }= t('admin.webhooks.enabled_events', count: webhook.events.size)

View File

@ -1,8 +1,8 @@
<%= raw t('admin_mailer.new_trends.new_trending_links.title') %> <%= raw t('admin_mailer.new_trends.new_trending_links.title') %>
<% @links.each do |link| %> <% @links.each do |link| %>
- <%= link.title %> <%= link.url %> - <%= link.title %> · <%= link.url %>
<%= standard_locale_name(link.language) %> <%= raw t('admin.trends.links.usage_comparison', today: link.history.get(Time.now.utc).accounts, yesterday: link.history.get(Time.now.utc - 1.day).accounts) %> <%= t('admin.trends.tags.current_score', score: link.trend.score.round(2)) %> <%= standard_locale_name(link.language) %> · <%= raw t('admin.trends.links.usage_comparison', today: link.history.get(Time.now.utc).accounts, yesterday: link.history.get(Time.now.utc - 1.day).accounts) %> · <%= t('admin.trends.tags.current_score', score: link.trend.score.round(2)) %>
<% end %> <% end %>
<%= raw t('application_mailer.view')%> <%= admin_trends_links_url %> <%= raw t('application_mailer.view')%> <%= admin_trends_links_url %>

View File

@ -2,7 +2,7 @@
<% @statuses.each do |status| %> <% @statuses.each do |status| %>
- <%= ActivityPub::TagManager.instance.url_for(status) %> - <%= ActivityPub::TagManager.instance.url_for(status) %>
<%= standard_locale_name(status.language) %> <%= raw t('admin.trends.tags.current_score', score: status.trend.score.round(2)) %> <%= standard_locale_name(status.language) %> · <%= raw t('admin.trends.tags.current_score', score: status.trend.score.round(2)) %>
<% end %> <% end %>
<%= raw t('application_mailer.view')%> <%= admin_trends_statuses_url %> <%= raw t('application_mailer.view')%> <%= admin_trends_statuses_url %>

View File

@ -2,7 +2,7 @@
<% @tags.each do |tag| %> <% @tags.each do |tag| %>
- #<%= tag.display_name %> - #<%= tag.display_name %>
<%= raw t('admin.trends.tags.usage_comparison', today: tag.history.get(Time.now.utc).accounts, yesterday: tag.history.get(Time.now.utc - 1.day).accounts) %> <%= t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id).round(2)) %> <%= raw t('admin.trends.tags.usage_comparison', today: tag.history.get(Time.now.utc).accounts, yesterday: tag.history.get(Time.now.utc - 1.day).accounts) %> · <%= t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id).round(2)) %>
<% end %> <% end %>
<% if @lowest_trending_tag %> <% if @lowest_trending_tag %>

View File

@ -1,9 +1,11 @@
- account_url = local_assigns[:admin] ? admin_account_path(account.id) : ActivityPub::TagManager.instance.url_for(account) - account_url = local_assigns[:admin] ? admin_account_path(account.id) : ActivityPub::TagManager.instance.url_for(account)
- compact ||= false
.card.h-card .card.h-card
= link_to account_url, target: '_blank', rel: 'noopener noreferrer' do = link_to account_url, target: '_blank', rel: 'noopener noreferrer' do
.card__img - unless compact
= image_tag account.header.url, alt: '' .card__img
= image_tag account.header.url, alt: ''
.card__bar .card__bar
.avatar .avatar
= image_tag account.avatar.url, alt: '', width: 48, height: 48, class: 'u-photo' = image_tag account.avatar.url, alt: '', width: 48, height: 48, class: 'u-photo'

View File

@ -7,8 +7,14 @@
.simple_form .simple_form
= render 'auth/shared/progress', stage: 'rules' = render 'auth/shared/progress', stage: 'rules'
%h1.title= t('auth.rules.title') - if @invite.present? && @invite.autofollow?
%p.lead= t('auth.rules.preamble', domain: site_hostname) %h1.title= t('auth.rules.title_invited')
%p.lead.invited-by= t('auth.rules.invited_by', domain: site_hostname)
= render 'application/card', account: @invite.user.account, compact: true
%p.lead= t('auth.rules.preamble_invited', domain: site_hostname)
- else
%h1.title= t('auth.rules.title')
%p.lead= t('auth.rules.preamble', domain: site_hostname)
%ol.rules-list %ol.rules-list
- @rules.each do |rule| - @rules.each do |rule|

View File

@ -23,7 +23,7 @@
- else - else
= t('doorkeeper.authorized_applications.index.never_used') = t('doorkeeper.authorized_applications.index.never_used')
·
= t('doorkeeper.authorized_applications.index.authorized_at', date: l(application.created_at.to_date)) = t('doorkeeper.authorized_applications.index.authorized_at', date: l(application.created_at.to_date))

View File

@ -19,6 +19,14 @@ module RecommendedComponent
end end
end end
module WarningHintComponent
def warning_hint(_wrapper_options = nil)
@warning_hint ||= begin
options[:warning_hint].to_s.html_safe if options[:warning_hint].present?
end
end
end
module GlitchOnlyComponent module GlitchOnlyComponent
def glitch_only(_wrapper_options = nil) def glitch_only(_wrapper_options = nil)
return unless options[:glitch_only] return unless options[:glitch_only]
@ -30,6 +38,7 @@ end
SimpleForm.include_component(AppendComponent) SimpleForm.include_component(AppendComponent)
SimpleForm.include_component(RecommendedComponent) SimpleForm.include_component(RecommendedComponent)
SimpleForm.include_component(WarningHintComponent)
SimpleForm.include_component(GlitchOnlyComponent) SimpleForm.include_component(GlitchOnlyComponent)
SimpleForm.setup do |config| SimpleForm.setup do |config|
@ -112,6 +121,7 @@ SimpleForm.setup do |config|
b.use :html5 b.use :html5
b.use :label b.use :label
b.use :hint, wrap_with: { tag: :span, class: :hint } b.use :hint, wrap_with: { tag: :span, class: :hint }
b.use :warning_hint, wrap_with: { tag: :span, class: [:hint, 'warning-hint'] }
b.use :input, wrap_with: { tag: :div, class: :label_input } b.use :input, wrap_with: { tag: :div, class: :label_input }
b.use :error, wrap_with: { tag: :span, class: :error } b.use :error, wrap_with: { tag: :span, class: :error }
end end

View File

@ -1,15 +0,0 @@
# frozen_string_literal: true
if ENV['STATSD_ADDR'].present?
host, port = ENV['STATSD_ADDR'].split(':')
$statsd = ::Statsd.new(host, port)
$statsd.namespace = ENV.fetch('STATSD_NAMESPACE') { ['Mastodon', Rails.env].join('.') }
::NSA.inform_statsd($statsd) do |informant|
informant.collect(:action_controller, :web)
informant.collect(:active_record, :db)
informant.collect(:active_support_cache, :cache)
informant.collect(:sidekiq, :sidekiq)
end
end

View File

@ -1031,8 +1031,11 @@ en:
rules: rules:
accept: Accept accept: Accept
back: Back back: Back
invited_by: 'You can join %{domain} thanks to the invitation you have received from:'
preamble: These are set and enforced by the %{domain} moderators. preamble: These are set and enforced by the %{domain} moderators.
preamble_invited: Before you proceed, please consider the ground rules set by the moderators of %{domain}.
title: Some ground rules. title: Some ground rules.
title_invited: You've been invited.
security: Security security: Security
set_new_password: Set new password set_new_password: Set new password
setup: setup:

View File

@ -78,7 +78,7 @@ en:
backups_retention_period: Keep generated user archives for the specified number of days. backups_retention_period: Keep generated user archives for the specified number of days.
bootstrap_timeline_accounts: These accounts will be pinned to the top of new users' follow recommendations. bootstrap_timeline_accounts: These accounts will be pinned to the top of new users' follow recommendations.
closed_registrations_message: Displayed when sign-ups are closed closed_registrations_message: Displayed when sign-ups are closed
content_cache_retention_period: Posts from other servers will be deleted after the specified number of days when set to a positive value. This may be irreversible. content_cache_retention_period: All posts and boosts from other servers will be deleted after the specified number of days. Some posts may not be recoverable. All related bookmarks, favourites and boosts will also be lost and impossible to undo.
custom_css: You can apply custom styles on the web version of Mastodon. custom_css: You can apply custom styles on the web version of Mastodon.
mascot: Overrides the illustration in the advanced web interface. mascot: Overrides the illustration in the advanced web interface.
media_cache_retention_period: Downloaded media files will be deleted after the specified number of days when set to a positive value, and re-downloaded on demand. media_cache_retention_period: Downloaded media files will be deleted after the specified number of days when set to a positive value, and re-downloaded on demand.

View File

@ -1,3 +0,0 @@
console.error("The localisation functionality has been refactored, please see the Localisation section in the development documentation (https://docs.joinmastodon.org/dev/code/#localizations)");
process.exit(1);

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddExclusiveToLists < ActiveRecord::Migration[6.1]
def change
add_column :lists, :exclusive, :boolean, null: false, default: false
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2023_05_31_154811) do ActiveRecord::Schema.define(version: 2023_06_05_085710) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -567,6 +567,7 @@ ActiveRecord::Schema.define(version: 2023_05_31_154811) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "replies_policy", default: 0, null: false t.integer "replies_policy", default: 0, null: false
t.boolean "exclusive", default: false
t.index ["account_id"], name: "index_lists_on_account_id" t.index ["account_id"], name: "index_lists_on_account_id"
end end

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
module HamlLint
# Bans the usage of “•” (bullet) in HTML/HAML in favor of “·” (middle dot) in anything that will end up as a text node. (including string literals in Ruby code)
class Linter::MiddleDot < Linter
include LinterRegistry
# rubocop:disable Style/MiddleDot
BULLET = '•'
# rubocop:enable Style/MiddleDot
MIDDLE_DOT = '·'
MESSAGE = "Use '#{MIDDLE_DOT}' (middle dot) instead of '#{BULLET}' (bullet)".freeze
def visit_plain(node)
return unless node.text.include?(BULLET)
record_lint(node, MESSAGE)
end
def visit_script(node)
return unless node.script.include?(BULLET)
record_lint(node, MESSAGE)
end
end
end

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
module RuboCop
module Cop
module Style
# Bans the usage of “•” (bullet) in HTML/HAML in favor of “·” (middle dot) in string literals
class MiddleDot < Base
extend AutoCorrector
extend Util
# rubocop:disable Style/MiddleDot
BULLET = '•'
# rubocop:enable Style/MiddleDot
MIDDLE_DOT = '·'
MESSAGE = "Use '#{MIDDLE_DOT}' (middle dot) instead of '#{BULLET}' (bullet)".freeze
def on_str(node)
# Constants like __FILE__ are handled as strings,
# but don't respond to begin.
return unless node.loc.respond_to?(:begin) && node.loc.begin
return unless node.value.include?(BULLET)
add_offense(node, message: MESSAGE) do |corrector|
corrector.replace(node, node.source.gsub(BULLET, MIDDLE_DOT))
end
end
end
end
end
end

View File

@ -21,7 +21,6 @@
"lint:sass": "stylelint \"**/*.{css,scss}\" && prettier --check \"**/*.{css,scss}\"", "lint:sass": "stylelint \"**/*.{css,scss}\" && prettier --check \"**/*.{css,scss}\"",
"lint:yml": "prettier --check \"**/*.{yaml,yml}\"", "lint:yml": "prettier --check \"**/*.{yaml,yml}\"",
"lint": "yarn lint:js && yarn lint:json && yarn lint:sass && yarn lint:yml", "lint": "yarn lint:js && yarn lint:json && yarn lint:sass && yarn lint:yml",
"manage:translations": "node ./config/webpack/translationRunner.js",
"postversion": "git push --tags", "postversion": "git push --tags",
"prepare": "husky install", "prepare": "husky install",
"start": "node ./streaming/index.js", "start": "node ./streaming/index.js",

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
require 'rails_helper'
describe Admin::Metrics::Dimension::InstanceAccountsDimension do
subject(:dimension) { described_class.new(start_at, end_at, limit, params) }
let(:start_at) { 2.days.ago }
let(:end_at) { Time.now.utc }
let(:limit) { 10 }
let(:params) { ActionController::Parameters.new }
describe '#data' do
it 'runs data query without error' do
expect { dimension.data }.to_not raise_error
end
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
require 'rails_helper'
describe Admin::Metrics::Dimension::InstanceLanguagesDimension do
subject(:dimension) { described_class.new(start_at, end_at, limit, params) }
let(:start_at) { 2.days.ago }
let(:end_at) { Time.now.utc }
let(:limit) { 10 }
let(:params) { ActionController::Parameters.new }
describe '#data' do
it 'runs data query without error' do
expect { dimension.data }.to_not raise_error
end
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
require 'rails_helper'
describe Admin::Metrics::Dimension::LanguagesDimension do
subject(:dimension) { described_class.new(start_at, end_at, limit, params) }
let(:start_at) { 2.days.ago }
let(:end_at) { Time.now.utc }
let(:limit) { 10 }
let(:params) { ActionController::Parameters.new }
describe '#data' do
it 'runs data query without error' do
expect { dimension.data }.to_not raise_error
end
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
require 'rails_helper'
describe Admin::Metrics::Dimension::ServersDimension do
subject(:dimension) { described_class.new(start_at, end_at, limit, params) }
let(:start_at) { 2.days.ago }
let(:end_at) { Time.now.utc }
let(:limit) { 10 }
let(:params) { ActionController::Parameters.new }
describe '#data' do
it 'runs data query without error' do
expect { dimension.data }.to_not raise_error
end
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
require 'rails_helper'
describe Admin::Metrics::Dimension::SoftwareVersionsDimension do
subject(:dimension) { described_class.new(start_at, end_at, limit, params) }
let(:start_at) { 2.days.ago }
let(:end_at) { Time.now.utc }
let(:limit) { 10 }
let(:params) { ActionController::Parameters.new }
describe '#data' do
it 'runs data query without error' do
expect { dimension.data }.to_not raise_error
end
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
require 'rails_helper'
describe Admin::Metrics::Dimension::SourcesDimension do
subject(:dimension) { described_class.new(start_at, end_at, limit, params) }
let(:start_at) { 2.days.ago }
let(:end_at) { Time.now.utc }
let(:limit) { 10 }
let(:params) { ActionController::Parameters.new }
describe '#data' do
it 'runs data query without error' do
expect { dimension.data }.to_not raise_error
end
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
require 'rails_helper'
describe Admin::Metrics::Dimension::SpaceUsageDimension do
subject(:dimension) { described_class.new(start_at, end_at, limit, params) }
let(:start_at) { 2.days.ago }
let(:end_at) { Time.now.utc }
let(:limit) { 10 }
let(:params) { ActionController::Parameters.new }
describe '#data' do
it 'runs data query without error' do
expect { dimension.data }.to_not raise_error
end
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
require 'rails_helper'
describe Admin::Metrics::Dimension::TagLanguagesDimension do
subject(:dimension) { described_class.new(start_at, end_at, limit, params) }
let(:start_at) { 2.days.ago }
let(:end_at) { Time.now.utc }
let(:limit) { 10 }
let(:params) { ActionController::Parameters.new }
describe '#data' do
it 'runs data query without error' do
expect { dimension.data }.to_not raise_error
end
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
require 'rails_helper'
describe Admin::Metrics::Dimension::TagServersDimension do
subject(:dimension) { described_class.new(start_at, end_at, limit, params) }
let(:start_at) { 2.days.ago }
let(:end_at) { Time.now.utc }
let(:limit) { 10 }
let(:params) { ActionController::Parameters.new }
describe '#data' do
it 'runs data query without error' do
expect { dimension.data }.to_not raise_error
end
end
end

View File

@ -26,6 +26,7 @@ RSpec.describe FeedManager do
let(:alice) { Fabricate(:account, username: 'alice') } let(:alice) { Fabricate(:account, username: 'alice') }
let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') } let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') }
let(:jeff) { Fabricate(:account, username: 'jeff') } let(:jeff) { Fabricate(:account, username: 'jeff') }
let(:list) { Fabricate(:list, account: alice) }
context 'with home feed' do context 'with home feed' do
it 'returns false for followee\'s status' do it 'returns false for followee\'s status' do
@ -160,6 +161,42 @@ RSpec.describe FeedManager do
status = Fabricate(:status, text: 'Hallo Welt', account: bob, language: 'de') status = Fabricate(:status, text: 'Hallo Welt', account: bob, language: 'de')
expect(FeedManager.instance.filter?(:home, status, alice)).to be false expect(FeedManager.instance.filter?(:home, status, alice)).to be false
end end
it 'returns true for post from followee on exclusive list' do
list.exclusive = true
alice.follow!(bob)
list.accounts << bob
allow(List).to receive(:where).and_return(list)
status = Fabricate(:status, text: 'I post a lot', account: bob)
expect(FeedManager.instance.filter?(:home, status, alice)).to be true
end
it 'returns true for reblog from followee on exclusive list' do
list.exclusive = true
alice.follow!(jeff)
list.accounts << jeff
allow(List).to receive(:where).and_return(list)
status = Fabricate(:status, text: 'I post a lot', account: bob)
reblog = Fabricate(:status, reblog: status, account: jeff)
expect(FeedManager.instance.filter?(:home, reblog, alice)).to be true
end
it 'returns false for post from followee on non-exclusive list' do
list.exclusive = false
alice.follow!(bob)
list.accounts << bob
status = Fabricate(:status, text: 'I post a lot', account: bob)
expect(FeedManager.instance.filter?(:home, status, alice)).to be false
end
it 'returns false for reblog from followee on non-exclusive list' do
list.exclusive = false
alice.follow!(jeff)
list.accounts << jeff
status = Fabricate(:status, text: 'I post a lot', account: bob)
reblog = Fabricate(:status, reblog: status, account: jeff)
expect(FeedManager.instance.filter?(:home, reblog, alice)).to be false
end
end end
context 'with mentions feed' do context 'with mentions feed' do

View File

@ -998,4 +998,254 @@ describe Mastodon::CLI::Accounts do
end end
end end
end end
describe '#merge' do
shared_examples 'an account not found' do |acct|
it 'exits with an error message indicating that there is no such account' do
expect { cli.invoke(:merge, arguments) }.to output(
a_string_including("No such account (#{acct})")
).to_stdout
.and raise_error(SystemExit)
end
end
context 'when "from_account" is not found' do
let(:to_account) { Fabricate(:account, domain: 'example.com') }
let(:arguments) { ['non_existent_username@domain.com', "#{to_account.username}@#{to_account.domain}"] }
it_behaves_like 'an account not found', 'non_existent_username@domain.com'
end
context 'when "from_account" is a local account' do
let(:from_account) { Fabricate(:account, domain: nil, username: 'bob') }
let(:to_account) { Fabricate(:account, domain: 'example.com') }
let(:arguments) { [from_account.username, "#{to_account.username}@#{to_account.domain}"] }
it_behaves_like 'an account not found', 'bob'
end
context 'when "to_account" is not found' do
let(:from_account) { Fabricate(:account, domain: 'example.com') }
let(:arguments) { ["#{from_account.username}@#{from_account.domain}", 'non_existent_username'] }
it_behaves_like 'an account not found', 'non_existent_username'
end
context 'when "to_account" is local' do
let(:from_account) { Fabricate(:account, domain: 'example.com') }
let(:to_account) { Fabricate(:account, domain: nil, username: 'bob') }
let(:arguments) do
["#{from_account.username}@#{from_account.domain}", "#{to_account.username}@#{to_account.domain}"]
end
it_behaves_like 'an account not found', 'bob@'
end
context 'when "from_account" and "to_account" public keys do not match' do
let(:from_account) { instance_double(Account, username: 'bob', domain: 'example1.com', local?: false, public_key: 'from_account') }
let(:to_account) { instance_double(Account, username: 'bob', domain: 'example2.com', local?: false, public_key: 'to_account') }
let(:arguments) do
["#{from_account.username}@#{from_account.domain}", "#{to_account.username}@#{to_account.domain}"]
end
before do
allow(Account).to receive(:find_remote).with(from_account.username, from_account.domain).and_return(from_account)
allow(Account).to receive(:find_remote).with(to_account.username, to_account.domain).and_return(to_account)
end
it 'exits with an error message indicating that the accounts do not have the same pub key' do
expect { cli.invoke(:merge, arguments) }.to output(
a_string_including("Accounts don't have the same public key, might not be duplicates!\nOverride with --force")
).to_stdout
.and raise_error(SystemExit)
end
context 'with --force option' do
let(:options) { { force: true } }
before do
allow(to_account).to receive(:merge_with!)
allow(from_account).to receive(:destroy)
end
it 'merges "from_account" into "to_account"' do
cli.invoke(:merge, arguments, options)
expect(to_account).to have_received(:merge_with!).with(from_account).once
end
it 'deletes "from_account"' do
cli.invoke(:merge, arguments, options)
expect(from_account).to have_received(:destroy).once
end
end
end
context 'when "from_account" and "to_account" public keys match' do
let(:from_account) { instance_double(Account, username: 'bob', domain: 'example1.com', local?: false, public_key: 'pub_key') }
let(:to_account) { instance_double(Account, username: 'bob', domain: 'example2.com', local?: false, public_key: 'pub_key') }
let(:arguments) do
["#{from_account.username}@#{from_account.domain}", "#{to_account.username}@#{to_account.domain}"]
end
before do
allow(Account).to receive(:find_remote).with(from_account.username, from_account.domain).and_return(from_account)
allow(Account).to receive(:find_remote).with(to_account.username, to_account.domain).and_return(to_account)
allow(to_account).to receive(:merge_with!)
allow(from_account).to receive(:destroy)
end
it 'merges "from_account" into "to_account"' do
cli.invoke(:merge, arguments)
expect(to_account).to have_received(:merge_with!).with(from_account).once
end
it 'deletes "from_account"' do
cli.invoke(:merge, arguments)
expect(from_account).to have_received(:destroy)
end
end
end
describe '#cull' do
let(:delete_account_service) { instance_double(DeleteAccountService, call: nil) }
let!(:tom) { Fabricate(:account, updated_at: 30.days.ago, username: 'tom', uri: 'https://example.com/users/tom', domain: 'example.com') }
let!(:bob) { Fabricate(:account, updated_at: 30.days.ago, last_webfingered_at: nil, username: 'bob', uri: 'https://example.org/users/bob', domain: 'example.org') }
let!(:gon) { Fabricate(:account, updated_at: 15.days.ago, last_webfingered_at: 15.days.ago, username: 'gon', uri: 'https://example.net/users/gon', domain: 'example.net') }
let!(:ana) { Fabricate(:account, username: 'ana', uri: 'https://example.com/users/ana', domain: 'example.com') }
let!(:tales) { Fabricate(:account, updated_at: 10.days.ago, last_webfingered_at: nil, username: 'tales', uri: 'https://example.net/users/tales', domain: 'example.net') }
before do
allow(DeleteAccountService).to receive(:new).and_return(delete_account_service)
end
context 'when no domain is specified' do
let(:scope) { Account.remote.where(protocol: :activitypub).partitioned }
before do
allow(cli).to receive(:parallelize_with_progress).and_yield(tom)
.and_yield(bob)
.and_yield(gon)
.and_yield(ana)
.and_yield(tales)
.and_return([5, 3])
stub_request(:head, 'https://example.org/users/bob').to_return(status: 404)
stub_request(:head, 'https://example.net/users/gon').to_return(status: 410)
stub_request(:head, 'https://example.net/users/tales').to_return(status: 200)
end
it 'deletes all inactive remote accounts that longer exist in the origin server' do
cli.cull
expect(cli).to have_received(:parallelize_with_progress).with(scope).once
expect(delete_account_service).to have_received(:call).with(bob, reserve_username: false).once
expect(delete_account_service).to have_received(:call).with(gon, reserve_username: false).once
end
it 'does not delete any active remote account that still exists in the origin server' do
cli.cull
expect(cli).to have_received(:parallelize_with_progress).with(scope).once
expect(delete_account_service).to_not have_received(:call).with(tom, reserve_username: false)
expect(delete_account_service).to_not have_received(:call).with(ana, reserve_username: false)
expect(delete_account_service).to_not have_received(:call).with(tales, reserve_username: false)
end
it 'touches inactive remote accounts that have not been deleted' do
allow(tales).to receive(:touch)
cli.cull
expect(tales).to have_received(:touch).once
end
it 'displays the summary correctly' do
expect { cli.cull }.to output(
a_string_including('Visited 5 accounts, removed 3')
).to_stdout
end
end
context 'when a domain is specified' do
let(:domain) { 'example.net' }
let(:scope) { Account.remote.where(protocol: :activitypub, domain: domain).partitioned }
before do
allow(cli).to receive(:parallelize_with_progress).and_yield(gon)
.and_yield(tales)
.and_return([2, 2])
stub_request(:head, 'https://example.net/users/gon').to_return(status: 410)
stub_request(:head, 'https://example.net/users/tales').to_return(status: 404)
end
it 'deletes inactive remote accounts that longer exist in the specified domain' do
cli.cull(domain)
expect(cli).to have_received(:parallelize_with_progress).with(scope).once
expect(delete_account_service).to have_received(:call).with(gon, reserve_username: false).once
expect(delete_account_service).to have_received(:call).with(tales, reserve_username: false).once
end
it 'displays the summary correctly' do
expect { cli.cull }.to output(
a_string_including('Visited 2 accounts, removed 2')
).to_stdout
end
end
context 'when a domain is unavailable' do
shared_examples 'an unavailable domain' do
before do
allow(cli).to receive(:parallelize_with_progress).and_yield(tales).and_return([1, 0])
end
it 'skips accounts from the unavailable domain' do
cli.cull
expect(delete_account_service).to_not have_received(:call).with(tales, reserve_username: false)
end
it 'displays the summary correctly' do
expect { cli.cull }.to output(
a_string_including("Visited 1 accounts, removed 0\nThe following domains were not available during the check:\n example.net")
).to_stdout
end
end
context 'when a connection timeout occurs' do
before do
stub_request(:head, 'https://example.net/users/tales').to_timeout
end
it_behaves_like 'an unavailable domain'
end
context 'when a connection error occurs' do
before do
stub_request(:head, 'https://example.net/users/tales').to_raise(HTTP::ConnectionError)
end
it_behaves_like 'an unavailable domain'
end
context 'when an ssl error occurs' do
before do
stub_request(:head, 'https://example.net/users/tales').to_raise(OpenSSL::SSL::SSLError)
end
it_behaves_like 'an unavailable domain'
end
context 'when a private network address error occurs' do
before do
stub_request(:head, 'https://example.net/users/tales').to_raise(Mastodon::PrivateNetworkAddressError)
end
it_behaves_like 'an unavailable domain'
end
end
end
end end

View File

@ -4,9 +4,57 @@ require 'rails_helper'
require 'mastodon/cli/canonical_email_blocks' require 'mastodon/cli/canonical_email_blocks'
describe Mastodon::CLI::CanonicalEmailBlocks do describe Mastodon::CLI::CanonicalEmailBlocks do
let(:cli) { described_class.new }
describe '.exit_on_failure?' do describe '.exit_on_failure?' do
it 'returns true' do it 'returns true' do
expect(described_class.exit_on_failure?).to be true expect(described_class.exit_on_failure?).to be true
end end
end end
describe '#find' do
let(:arguments) { ['user@example.com'] }
context 'when a block is present' do
before { Fabricate(:canonical_email_block, email: 'user@example.com') }
it 'announces the presence of the block' do
expect { cli.invoke(:find, arguments) }.to output(
a_string_including('user@example.com is blocked')
).to_stdout
end
end
context 'when a block is not present' do
it 'announces the absence of the block' do
expect { cli.invoke(:find, arguments) }.to output(
a_string_including('user@example.com is not blocked')
).to_stdout
end
end
end
describe '#remove' do
let(:arguments) { ['user@example.com'] }
context 'when a block is present' do
before { Fabricate(:canonical_email_block, email: 'user@example.com') }
it 'removes the block' do
expect { cli.invoke(:remove, arguments) }.to output(
a_string_including('Unblocked user@example.com')
).to_stdout
expect(CanonicalEmailBlock.matching_email('user@example.com')).to be_empty
end
end
context 'when a block is not present' do
it 'announces the absence of the block' do
expect { cli.invoke(:remove, arguments) }.to output(
a_string_including('user@example.com is not blocked')
).to_stdout
end
end
end
end end