diff --git a/.github/renovate.json5 b/.github/renovate.json5
new file mode 100644
index 0000000000..1ae40d4161
--- /dev/null
+++ b/.github/renovate.json5
@@ -0,0 +1,114 @@
+{
+ $schema: 'https://docs.renovatebot.com/renovate-schema.json',
+ extends: [
+ 'config:base',
+ ':dependencyDashboard',
+ ':labels(dependencies)',
+ ':maintainLockFilesMonthly', // update non-direct dependencies monthly
+ ':prConcurrentLimit10', // only 10 open PRs at the same time
+ ],
+ stabilityDays: 3, // Wait 3 days after the package has been published before upgrading it
+ // packageRules order is important, they are applied from top to bottom and are merged,
+ // so for example grouping rules needs to be at the bottom
+ packageRules: [
+ {
+ // Ignore major version bumps for these node packages
+ matchManagers: ['npm'],
+ matchPackageNames: [
+ '@rails/ujs', // Needs to match the major Rails version
+ 'tesseract.js', // Requires code changes
+ 'react-hotkeys', // Requires code changes
+
+ // Requires Webpacker upgrade or replacement
+ '@types/webpack',
+ 'babel-loader',
+ 'compression-webpack-plugin',
+ 'css-loader',
+ 'imports-loader',
+ 'mini-css-extract-plugin',
+ 'postcss-loader',
+ 'sass-loader',
+ 'terser-webpack-plugin',
+ 'webpack',
+ 'webpack-assets-manifest',
+ 'webpack-bundle-analyzer',
+ 'webpack-dev-server',
+ 'webpack-cli',
+
+ // react-router: Requires manual upgrade
+ 'history',
+ 'react-router-dom',
+ ],
+ matchUpdateTypes: ['major'],
+ enabled: false,
+ },
+ {
+ // Ignore major version bumps for these Ruby packages
+ matchManagers: ['bundler'],
+ matchPackageNames: [
+ 'sprockets', // Requires manual upgrade https://github.com/rails/sprockets/blob/master/UPGRADING.md#guide-to-upgrading-from-sprockets-3x-to-4x
+ 'strong_migrations', // Requires manual upgrade
+ 'sidekiq', // Requires manual upgrade
+ 'sidekiq-unique-jobs', // Requires manual upgrades and sync with Sidekiq version
+ 'redis', // Requires manual upgrade and sync with Sidekiq version
+ 'fog-openstack', // TODO: was ignored in https://github.com/mastodon/mastodon/pull/13964
+
+ // Needs major Rails version bump
+ 'rack',
+ 'rails',
+ 'rails-i18n',
+ ],
+ matchUpdateTypes: ['major'],
+ enabled: false,
+ },
+ {
+ // Update Github Actions and Docker images weekly
+ matchManagers: ['github-actions', 'dockerfile', 'docker-compose'],
+ extends: ['schedule:weekly'],
+ },
+ {
+ // Ignore major & minor bumps for the ruby image, this needs to be synced with .ruby-version
+ matchManagers: ['dockerfile'],
+ matchPackageNames: ['moritzheiber/ruby-jemalloc'],
+ matchUpdateTypes: ['minor', 'major'],
+ enabled: false,
+ },
+ {
+ // Ignore major bump for the node image, this needs to be synced with .nvmrc
+ matchManagers: ['dockerfile'],
+ matchPackageNames: ['node'],
+ matchUpdateTypes: ['major'],
+ enabled: false,
+ },
+ {
+ // Ignore major postgres bumps in the docker-compose file, as those break dev environments
+ matchManagers: ['docker-compose'],
+ matchPackageNames: ['postgres'],
+ matchUpdateTypes: ['major'],
+ enabled: false,
+ },
+ {
+ // Update devDependencies every week, with one grouped PR
+ matchDepTypes: 'devDependencies',
+ matchUpdateTypes: ['patch', 'minor'],
+ excludePackageNames: [
+ 'typescript', // Typescript has many changes in minor versions, needs to be checked every time
+ ],
+ groupName: 'devDependencies (non-major)',
+ extends: ['schedule:weekly'],
+ },
+ {
+ // Update @types/* packages every week, with one grouped PR
+ matchPackagePrefixes: '@types/',
+ matchUpdateTypes: ['patch', 'minor'],
+ groupName: 'DefinitelyTyped types (non-major)',
+ extends: ['schedule:weekly'],
+ addLabels: ['typescript'],
+ },
+ // Add labels depending on package manager
+ { matchManagers: ['npm', 'nvm'], addLabels: ['javascript'] },
+ { matchManagers: ['bundler', 'ruby-version'], addLabels: ['ruby'] },
+ { matchManagers: ['docker-compose', 'dockerfile'], addLabels: ['docker'] },
+ { matchManagers: ['github-actions'], addLabels: ['github_actions'] },
+ ],
+}
diff --git a/.github/workflows/lint-css.yml b/.github/workflows/lint-css.yml
index 51bde39bc1..4d3c2ce5af 100644
--- a/.github/workflows/lint-css.yml
+++ b/.github/workflows/lint-css.yml
@@ -3,6 +3,7 @@ on:
push:
branches-ignore:
- 'dependabot/**'
+ - 'renovate/**'
paths:
- 'package.json'
- 'yarn.lock'
diff --git a/.github/workflows/lint-haml.yml b/.github/workflows/lint-haml.yml
index 2ddbca7818..56d817123a 100644
--- a/.github/workflows/lint-haml.yml
+++ b/.github/workflows/lint-haml.yml
@@ -3,6 +3,7 @@ on:
push:
branches-ignore:
- 'dependabot/**'
+ - 'renovate/**'
paths:
- '.github/workflows/haml-lint-problem-matcher.json'
- '.github/workflows/lint-haml.yml'
diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml
index 547035ab3f..1f0cfd1e70 100644
--- a/.github/workflows/lint-js.yml
+++ b/.github/workflows/lint-js.yml
@@ -3,6 +3,7 @@ on:
push:
branches-ignore:
- 'dependabot/**'
+ - 'renovate/**'
paths:
- 'package.json'
- 'yarn.lock'
diff --git a/.github/workflows/lint-json.yml b/.github/workflows/lint-json.yml
index 7dfc0e0588..8712d8bd80 100644
--- a/.github/workflows/lint-json.yml
+++ b/.github/workflows/lint-json.yml
@@ -3,6 +3,7 @@ on:
push:
branches-ignore:
- 'dependabot/**'
+ - 'renovate/**'
paths:
- 'package.json'
- 'yarn.lock'
diff --git a/.github/workflows/lint-md.yml b/.github/workflows/lint-md.yml
index b489ce9684..d19a0470db 100644
--- a/.github/workflows/lint-md.yml
+++ b/.github/workflows/lint-md.yml
@@ -3,6 +3,7 @@ on:
push:
branches-ignore:
- 'dependabot/**'
+ - 'renovate/**'
paths:
- '.github/workflows/lint-md.yml'
- '.nvmrc'
diff --git a/.github/workflows/lint-ruby.yml b/.github/workflows/lint-ruby.yml
index de54fe9ae5..0395c8639f 100644
--- a/.github/workflows/lint-ruby.yml
+++ b/.github/workflows/lint-ruby.yml
@@ -3,6 +3,7 @@ on:
push:
branches-ignore:
- 'dependabot/**'
+ - 'renovate/**'
paths:
- 'Gemfile*'
- '.rubocop*.yml'
diff --git a/.github/workflows/lint-yml.yml b/.github/workflows/lint-yml.yml
index d77451ee62..295e9610b3 100644
--- a/.github/workflows/lint-yml.yml
+++ b/.github/workflows/lint-yml.yml
@@ -3,6 +3,7 @@ on:
push:
branches-ignore:
- 'dependabot/**'
+ - 'renovate/**'
paths:
- 'package.json'
- 'yarn.lock'
diff --git a/.github/workflows/rebase-needed.yml b/.github/workflows/rebase-needed.yml
index 6a8035210c..131a62a576 100644
--- a/.github/workflows/rebase-needed.yml
+++ b/.github/workflows/rebase-needed.yml
@@ -4,10 +4,12 @@ on:
push:
branches-ignore:
- 'dependabot/**'
+ - 'renovate/**'
- 'l10n_main'
pull_request_target:
branches-ignore:
- 'dependabot/**'
+ - 'renovate/**'
- 'l10n_main'
types: [synchronize]
diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml
index 32e21d23ce..3306105f9e 100644
--- a/.github/workflows/test-js.yml
+++ b/.github/workflows/test-js.yml
@@ -3,6 +3,7 @@ on:
push:
branches-ignore:
- 'dependabot/**'
+ - 'renovate/**'
paths:
- 'package.json'
- 'yarn.lock'
diff --git a/.github/workflows/test-migrations-one-step.yml b/.github/workflows/test-migrations-one-step.yml
index 212b2cfe73..a91fd819a2 100644
--- a/.github/workflows/test-migrations-one-step.yml
+++ b/.github/workflows/test-migrations-one-step.yml
@@ -3,6 +3,7 @@ on:
push:
branches-ignore:
- 'dependabot/**'
+ - 'renovate/**'
pull_request:
jobs:
diff --git a/.github/workflows/test-migrations-two-step.yml b/.github/workflows/test-migrations-two-step.yml
index 310153929d..50266fb8a0 100644
--- a/.github/workflows/test-migrations-two-step.yml
+++ b/.github/workflows/test-migrations-two-step.yml
@@ -3,6 +3,7 @@ on:
push:
branches-ignore:
- 'dependabot/**'
+ - 'renovate/**'
pull_request:
jobs:
diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml
index f284745ea4..07cb1d41f8 100644
--- a/.github/workflows/test-ruby.yml
+++ b/.github/workflows/test-ruby.yml
@@ -4,6 +4,7 @@ on:
push:
branches-ignore:
- 'dependabot/**'
+ - 'renovate/**'
pull_request:
env:
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index 2cb7bd0e5c..4964cf856c 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -239,79 +239,6 @@ RSpec/AnyInstance:
- 'spec/workers/activitypub/delivery_worker_spec.rb'
- 'spec/workers/web/push_notification_worker_spec.rb'
-# This cop supports unsafe autocorrection (--autocorrect-all).
-# Configuration parameters: SkipBlocks, EnforcedStyle.
-# SupportedStyles: described_class, explicit
-RSpec/DescribedClass:
- Exclude:
- - 'spec/controllers/concerns/cache_concern_spec.rb'
- - 'spec/controllers/concerns/challengable_concern_spec.rb'
- - 'spec/lib/entity_cache_spec.rb'
- - 'spec/lib/extractor_spec.rb'
- - 'spec/lib/feed_manager_spec.rb'
- - 'spec/lib/hash_object_spec.rb'
- - 'spec/lib/ostatus/tag_manager_spec.rb'
- - 'spec/lib/request_spec.rb'
- - 'spec/lib/tag_manager_spec.rb'
- - 'spec/lib/webfinger_resource_spec.rb'
- - 'spec/mailers/notification_mailer_spec.rb'
- - 'spec/mailers/user_mailer_spec.rb'
- - 'spec/models/account_conversation_spec.rb'
- - 'spec/models/account_domain_block_spec.rb'
- - 'spec/models/account_migration_spec.rb'
- - 'spec/models/account_spec.rb'
- - 'spec/models/block_spec.rb'
- - 'spec/models/domain_block_spec.rb'
- - 'spec/models/email_domain_block_spec.rb'
- - 'spec/models/export_spec.rb'
- - 'spec/models/favourite_spec.rb'
- - 'spec/models/follow_spec.rb'
- - 'spec/models/identity_spec.rb'
- - 'spec/models/import_spec.rb'
- - 'spec/models/media_attachment_spec.rb'
- - 'spec/models/notification_spec.rb'
- - 'spec/models/relationship_filter_spec.rb'
- - 'spec/models/report_filter_spec.rb'
- - 'spec/models/session_activation_spec.rb'
- - 'spec/models/setting_spec.rb'
- - 'spec/models/site_upload_spec.rb'
- - 'spec/models/status_pin_spec.rb'
- - 'spec/models/status_spec.rb'
- - 'spec/models/user_spec.rb'
- - 'spec/policies/account_moderation_note_policy_spec.rb'
- - 'spec/presenters/account_relationships_presenter_spec.rb'
- - 'spec/presenters/status_relationships_presenter_spec.rb'
- - 'spec/serializers/activitypub/note_serializer_spec.rb'
- - 'spec/serializers/activitypub/update_poll_serializer_spec.rb'
- - 'spec/serializers/rest/account_serializer_spec.rb'
- - 'spec/services/activitypub/fetch_remote_account_service_spec.rb'
- - 'spec/services/activitypub/fetch_remote_actor_service_spec.rb'
- - 'spec/services/activitypub/fetch_remote_key_service_spec.rb'
- - 'spec/services/after_block_domain_from_account_service_spec.rb'
- - 'spec/services/authorize_follow_service_spec.rb'
- - 'spec/services/batched_remove_status_service_spec.rb'
- - 'spec/services/block_domain_service_spec.rb'
- - 'spec/services/block_service_spec.rb'
- - 'spec/services/bootstrap_timeline_service_spec.rb'
- - 'spec/services/clear_domain_media_service_spec.rb'
- - 'spec/services/favourite_service_spec.rb'
- - 'spec/services/follow_service_spec.rb'
- - 'spec/services/import_service_spec.rb'
- - 'spec/services/post_status_service_spec.rb'
- - 'spec/services/precompute_feed_service_spec.rb'
- - 'spec/services/process_mentions_service_spec.rb'
- - 'spec/services/purge_domain_service_spec.rb'
- - 'spec/services/reblog_service_spec.rb'
- - 'spec/services/reject_follow_service_spec.rb'
- - 'spec/services/remove_from_followers_service_spec.rb'
- - 'spec/services/remove_status_service_spec.rb'
- - 'spec/services/unallow_domain_service_spec.rb'
- - 'spec/services/unblock_service_spec.rb'
- - 'spec/services/unfollow_service_spec.rb'
- - 'spec/services/unmute_service_spec.rb'
- - 'spec/services/update_account_service_spec.rb'
- - 'spec/validators/note_length_validator_spec.rb'
-
# This cop supports unsafe autocorrection (--autocorrect-all).
RSpec/EmptyExampleGroup:
Exclude:
@@ -468,30 +395,6 @@ RSpec/MessageSpies:
- 'spec/spec_helper.rb'
- 'spec/validators/status_length_validator_spec.rb'
-RSpec/MissingExampleGroupArgument:
- Exclude:
- - 'spec/controllers/accounts_controller_spec.rb'
- - 'spec/controllers/activitypub/collections_controller_spec.rb'
- - 'spec/controllers/admin/statuses_controller_spec.rb'
- - 'spec/controllers/admin/users/roles_controller_spec.rb'
- - 'spec/controllers/api/v1/accounts_controller_spec.rb'
- - 'spec/controllers/api/v1/admin/account_actions_controller_spec.rb'
- - 'spec/controllers/api/v1/admin/domain_allows_controller_spec.rb'
- - 'spec/controllers/api/v1/statuses_controller_spec.rb'
- - 'spec/controllers/auth/registrations_controller_spec.rb'
- - 'spec/features/log_in_spec.rb'
- - 'spec/lib/activitypub/activity/undo_spec.rb'
- - 'spec/lib/status_reach_finder_spec.rb'
- - 'spec/models/account_spec.rb'
- - 'spec/models/email_domain_block_spec.rb'
- - 'spec/models/trends/statuses_spec.rb'
- - 'spec/models/trends/tags_spec.rb'
- - 'spec/models/user_role_spec.rb'
- - 'spec/models/user_spec.rb'
- - 'spec/services/fetch_link_card_service_spec.rb'
- - 'spec/services/notify_service_spec.rb'
- - 'spec/services/process_mentions_service_spec.rb'
-
RSpec/MultipleExpectations:
Max: 19
@@ -1336,11 +1239,6 @@ Style/GlobalStdStream:
- 'config/environments/development.rb'
- 'config/environments/production.rb'
-# Configuration parameters: AllowedVariables.
-Style/GlobalVars:
- Exclude:
- - 'config/initializers/statsd.rb'
-
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: MinBodyLength, AllowConsecutiveConditionals.
Style/GuardClause:
@@ -1474,7 +1372,6 @@ Style/RedundantConstantBase:
Exclude:
- 'config/environments/production.rb'
- 'config/initializers/sidekiq.rb'
- - 'config/initializers/statsd.rb'
- 'config/locales/sr-Latn.rb'
- 'config/locales/sr.rb'
@@ -1488,52 +1385,6 @@ Style/RedundantFetchBlock:
- 'config/initializers/paperclip.rb'
- 'config/puma.rb'
-# This cop supports safe autocorrection (--autocorrect).
-Style/RedundantRegexpCharacterClass:
- Exclude:
- - 'app/lib/link_details_extractor.rb'
- - 'app/lib/tag_manager.rb'
- - 'app/models/domain_allow.rb'
- - 'app/models/domain_block.rb'
- - 'app/services/fetch_oembed_service.rb'
- - 'config/initializers/rack_attack.rb'
- - 'lib/tasks/emojis.rake'
- - 'lib/tasks/mastodon.rake'
-
-# This cop supports safe autocorrection (--autocorrect).
-Style/RedundantRegexpEscape:
- Exclude:
- - 'app/lib/webfinger_resource.rb'
- - 'app/models/account.rb'
- - 'app/models/tag.rb'
- - 'app/services/fetch_link_card_service.rb'
- - 'config/initializers/twitter_regex.rb'
- - 'lib/paperclip/color_extractor.rb'
- - 'lib/tasks/mastodon.rake'
-
-# This cop supports safe autocorrection (--autocorrect).
-# Configuration parameters: EnforcedStyle, AllowInnerSlashes.
-# SupportedStyles: slashes, percent_r, mixed
-Style/RegexpLiteral:
- Exclude:
- - 'app/lib/link_details_extractor.rb'
- - 'app/lib/plain_text_formatter.rb'
- - 'app/lib/tag_manager.rb'
- - 'app/lib/text_formatter.rb'
- - 'app/models/account.rb'
- - 'app/models/domain_allow.rb'
- - 'app/models/domain_block.rb'
- - 'app/models/site_upload.rb'
- - 'app/models/tag.rb'
- - 'app/services/backup_service.rb'
- - 'app/services/fetch_oembed_service.rb'
- - 'app/services/search_service.rb'
- - 'config/initializers/rack_attack.rb'
- - 'config/initializers/twitter_regex.rb'
- - 'config/routes.rb'
- - 'lib/mastodon/premailer_webpack_strategy.rb'
- - 'lib/tasks/mastodon.rake'
-
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods, MaxChainLength.
# AllowedMethods: present?, blank?, presence, try, try!
diff --git a/app/controllers/admin/webhooks_controller.rb b/app/controllers/admin/webhooks_controller.rb
index 1ed3fd18ab..01d9ba8ce2 100644
--- a/app/controllers/admin/webhooks_controller.rb
+++ b/app/controllers/admin/webhooks_controller.rb
@@ -71,7 +71,7 @@ module Admin
end
def resource_params
- params.require(:webhook).permit(:url, events: [])
+ params.require(:webhook).permit(:url, :template, events: [])
end
end
end
diff --git a/app/javascript/mastodon/components/account.jsx b/app/javascript/mastodon/components/account.jsx
index ea863f5d18..0f3b85388c 100644
--- a/app/javascript/mastodon/components/account.jsx
+++ b/app/javascript/mastodon/components/account.jsx
@@ -16,6 +16,7 @@ import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { me } from '../initial_state';
import { Avatar } from './avatar';
+import Button from './button';
import { DisplayName } from './display_name';
import { IconButton } from './icon_button';
import { RelativeTimestamp } from './relative_timestamp';
@@ -23,13 +24,13 @@ import { RelativeTimestamp } from './relative_timestamp';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
- requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
- unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
- unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
- mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' },
- unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' },
- mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
- block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+ cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
+ unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
+ unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
+ mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' },
+ unmute_notifications: { id: 'account.unmute_notifications_short', defaultMessage: 'Unmute notifications' },
+ mute: { id: 'account.mute_short', defaultMessage: 'Mute' },
+ block: { id: 'account.block_short', defaultMessage: 'Block' },
});
class Account extends ImmutablePureComponent {
@@ -96,39 +97,39 @@ class Account extends ImmutablePureComponent {
let buttons;
- if (actionIcon) {
- if (onActionClick) {
- buttons = ;
- }
- } else if (account.get('id') !== me && account.get('relationship', null) !== null) {
+ if (actionIcon && onActionClick) {
+ buttons = ;
+ } else if (!actionIcon && account.get('id') !== me && account.get('relationship', null) !== null) {
const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']);
const muting = account.getIn(['relationship', 'muting']);
if (requested) {
- buttons = ;
+ buttons = ;
} else if (blocking) {
- buttons = ;
+ buttons = ;
} else if (muting) {
let hidingNotificationsButton;
+
if (account.getIn(['relationship', 'muting_notifications'])) {
- hidingNotificationsButton = ;
+ hidingNotificationsButton = ;
} else {
- hidingNotificationsButton = ;
+ hidingNotificationsButton = ;
}
+
buttons = (
<>
-
+
{hidingNotificationsButton}
>
);
} else if (defaultAction === 'mute') {
- buttons = ;
+ buttons = ;
} else if (defaultAction === 'block') {
- buttons = ;
+ buttons = ;
} else if (!account.get('moved') || following) {
- buttons = ;
+ buttons = ;
}
}
diff --git a/app/javascript/mastodon/components/dropdown_menu.jsx b/app/javascript/mastodon/components/dropdown_menu.jsx
index 4cadf907e7..3cfa0ee125 100644
--- a/app/javascript/mastodon/components/dropdown_menu.jsx
+++ b/app/javascript/mastodon/components/dropdown_menu.jsx
@@ -121,10 +121,10 @@ class DropdownMenu extends PureComponent {
return
;
}
- const { text, href = '#', target = '_blank', method } = option;
+ const { text, href = '#', target = '_blank', method, dangerous } = option;
return (
-
+
{text}
diff --git a/app/javascript/mastodon/components/load_more.jsx b/app/javascript/mastodon/components/load_more.jsx
deleted file mode 100644
index 6b7ecdea0a..0000000000
--- a/app/javascript/mastodon/components/load_more.jsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
-
-import { FormattedMessage } from 'react-intl';
-
-export default class LoadMore extends PureComponent {
-
- static propTypes = {
- onClick: PropTypes.func,
- disabled: PropTypes.bool,
- visible: PropTypes.bool,
- };
-
- static defaultProps = {
- visible: true,
- };
-
- render() {
- const { disabled, visible } = this.props;
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/load_more.tsx b/app/javascript/mastodon/components/load_more.tsx
new file mode 100644
index 0000000000..8b5746ad30
--- /dev/null
+++ b/app/javascript/mastodon/components/load_more.tsx
@@ -0,0 +1,24 @@
+import { FormattedMessage } from 'react-intl';
+
+interface Props {
+ onClick: (event: React.MouseEvent) => void;
+ disabled?: boolean;
+ visible?: boolean;
+}
+export const LoadMore: React.FC = ({
+ onClick,
+ disabled,
+ visible = true,
+}) => {
+ return (
+
+ );
+};
diff --git a/app/javascript/mastodon/components/scrollable_list.jsx b/app/javascript/mastodon/components/scrollable_list.jsx
index 9a0c4c8a7e..53a84ecb53 100644
--- a/app/javascript/mastodon/components/scrollable_list.jsx
+++ b/app/javascript/mastodon/components/scrollable_list.jsx
@@ -15,7 +15,7 @@ import IntersectionObserverArticleContainer from '../containers/intersection_obs
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
-import LoadMore from './load_more';
+import { LoadMore } from './load_more';
import LoadPending from './load_pending';
import LoadingIndicator from './loading_indicator';
diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx
index 936a66080e..8b3c20f824 100644
--- a/app/javascript/mastodon/components/status_action_bar.jsx
+++ b/app/javascript/mastodon/components/status_action_bar.jsx
@@ -280,8 +280,8 @@ class StatusActionBar extends ImmutablePureComponent {
if (writtenByMe) {
menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
- menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
- menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
+ menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick, dangerous: true });
+ menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick, dangerous: true });
} else {
menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.handleMentionClick });
menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick });
@@ -290,22 +290,22 @@ class StatusActionBar extends ImmutablePureComponent {
if (relationship && relationship.get('muting')) {
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick });
} else {
- menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.handleMuteClick });
+ menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.handleMuteClick, dangerous: true });
}
if (relationship && relationship.get('blocking')) {
menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.handleBlockClick });
} else {
- menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick });
+ menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick, dangerous: true });
}
if (!this.props.onFilter) {
menu.push(null);
- menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick });
+ menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick, dangerous: true });
menu.push(null);
}
- menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.handleReport });
+ menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.handleReport, dangerous: true });
if (account.get('acct') !== account.get('username')) {
const domain = account.get('acct').split('@')[1];
@@ -315,7 +315,7 @@ class StatusActionBar extends ImmutablePureComponent {
if (relationship && relationship.get('domain_blocking')) {
menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.handleUnblockDomain });
} else {
- menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain });
+ menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain, dangerous: true });
}
}
diff --git a/app/javascript/mastodon/features/account/components/header.jsx b/app/javascript/mastodon/features/account/components/header.jsx
index e9e5934f46..a206bcc3ba 100644
--- a/app/javascript/mastodon/features/account/components/header.jsx
+++ b/app/javascript/mastodon/features/account/components/header.jsx
@@ -332,16 +332,16 @@ class Header extends ImmutablePureComponent {
if (account.getIn(['relationship', 'muting'])) {
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute });
} else {
- menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute });
+ menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute, dangerous: true });
}
if (account.getIn(['relationship', 'blocking'])) {
menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock });
} else {
- menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock });
+ menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock, dangerous: true });
}
- menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport });
+ menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport, dangerous: true });
}
if (signedIn && isRemote) {
@@ -350,7 +350,7 @@ class Header extends ImmutablePureComponent {
if (account.getIn(['relationship', 'domain_blocking'])) {
menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain: remoteDomain }), action: this.props.onUnblockDomain });
} else {
- menu.push({ text: intl.formatMessage(messages.blockDomain, { domain: remoteDomain }), action: this.props.onBlockDomain });
+ menu.push({ text: intl.formatMessage(messages.blockDomain, { domain: remoteDomain }), action: this.props.onBlockDomain, dangerous: true });
}
}
diff --git a/app/javascript/mastodon/features/account_gallery/index.jsx b/app/javascript/mastodon/features/account_gallery/index.jsx
index 27de4740ca..653a258667 100644
--- a/app/javascript/mastodon/features/account_gallery/index.jsx
+++ b/app/javascript/mastodon/features/account_gallery/index.jsx
@@ -9,7 +9,7 @@ import { connect } from 'react-redux';
import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
import ColumnBackButton from 'mastodon/components/column_back_button';
-import LoadMore from 'mastodon/components/load_more';
+import { LoadMore } from 'mastodon/components/load_more';
import LoadingIndicator from 'mastodon/components/loading_indicator';
import ScrollContainer from 'mastodon/containers/scroll_container';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
diff --git a/app/javascript/mastodon/features/compose/components/search_results.jsx b/app/javascript/mastodon/features/compose/components/search_results.jsx
index b329cae791..b11ac478a4 100644
--- a/app/javascript/mastodon/features/compose/components/search_results.jsx
+++ b/app/javascript/mastodon/features/compose/components/search_results.jsx
@@ -6,7 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Icon } from 'mastodon/components/icon';
-import LoadMore from 'mastodon/components/load_more';
+import { LoadMore } from 'mastodon/components/load_more';
import { ImmutableHashtag as Hashtag } from '../../../components/hashtag';
import AccountContainer from '../../../containers/account_container';
diff --git a/app/javascript/mastodon/features/directory/index.jsx b/app/javascript/mastodon/features/directory/index.jsx
index d4854f1869..635b6f4113 100644
--- a/app/javascript/mastodon/features/directory/index.jsx
+++ b/app/javascript/mastodon/features/directory/index.jsx
@@ -13,7 +13,7 @@ import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'mastodo
import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
-import LoadMore from 'mastodon/components/load_more';
+import { LoadMore } from 'mastodon/components/load_more';
import LoadingIndicator from 'mastodon/components/loading_indicator';
import { RadioButton } from 'mastodon/components/radio_button';
import ScrollContainer from 'mastodon/containers/scroll_container';
diff --git a/app/javascript/mastodon/features/explore/results.jsx b/app/javascript/mastodon/features/explore/results.jsx
index 6b053a9dc1..dc1f720220 100644
--- a/app/javascript/mastodon/features/explore/results.jsx
+++ b/app/javascript/mastodon/features/explore/results.jsx
@@ -11,7 +11,7 @@ import { connect } from 'react-redux';
import { expandSearch } from 'mastodon/actions/search';
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
-import LoadMore from 'mastodon/components/load_more';
+import { LoadMore } from 'mastodon/components/load_more';
import LoadingIndicator from 'mastodon/components/loading_indicator';
import Account from 'mastodon/containers/account_container';
import Status from 'mastodon/containers/status_container';
diff --git a/app/javascript/mastodon/features/report/category.jsx b/app/javascript/mastodon/features/report/category.jsx
index a6e817c73d..fb9e55c579 100644
--- a/app/javascript/mastodon/features/report/category.jsx
+++ b/app/javascript/mastodon/features/report/category.jsx
@@ -16,6 +16,8 @@ const messages = defineMessages({
dislike_description: { id: 'report.reasons.dislike_description', defaultMessage: 'It is not something you want to see' },
spam: { id: 'report.reasons.spam', defaultMessage: 'It\'s spam' },
spam_description: { id: 'report.reasons.spam_description', defaultMessage: 'Malicious links, fake engagement, or repetitive replies' },
+ legal: { id: 'report.reasons.legal', defaultMessage: 'It\'s illegal' },
+ legal_description: { id: 'report.reasons.legal_description', defaultMessage: 'You believe it violates the law of your or the server\'s country' },
violation: { id: 'report.reasons.violation', defaultMessage: 'It violates server rules' },
violation_description: { id: 'report.reasons.violation_description', defaultMessage: 'You are aware that it breaks specific rules' },
other: { id: 'report.reasons.other', defaultMessage: 'It\'s something else' },
@@ -69,11 +71,13 @@ class Category extends PureComponent {
const options = rules.size > 0 ? [
'dislike',
'spam',
+ 'legal',
'violation',
'other',
] : [
'dislike',
'spam',
+ 'legal',
'other',
];
diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx
index 2ce94d9d84..bc90ce592c 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.jsx
+++ b/app/javascript/mastodon/features/status/components/action_bar.jsx
@@ -219,8 +219,8 @@ class ActionBar extends PureComponent {
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
- menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
- menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
+ menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick, dangerous: true });
+ menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick, dangerous: true });
} else {
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push(null);
@@ -228,16 +228,16 @@ class ActionBar extends PureComponent {
if (relationship && relationship.get('muting')) {
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick });
} else {
- menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.handleMuteClick });
+ menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.handleMuteClick, dangerous: true });
}
if (relationship && relationship.get('blocking')) {
menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.handleBlockClick });
} else {
- menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick });
+ menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick, dangerous: true });
}
- menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
+ menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport, dangerous: true });
if (account.get('acct') !== account.get('username')) {
const domain = account.get('acct').split('@')[1];
@@ -247,7 +247,7 @@ class ActionBar extends PureComponent {
if (relationship && relationship.get('domain_blocking')) {
menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.handleUnblockDomain });
} else {
- menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain });
+ menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain, dangerous: true });
}
}
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 09282de7c8..ef0964b192 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -17,9 +17,10 @@
"account.badges.group": "Group",
"account.block": "Block @{name}",
"account.block_domain": "Block domain {domain}",
+ "account.block_short": "Block",
"account.blocked": "Blocked",
"account.browse_more_on_origin_server": "Browse more on the original profile",
- "account.cancel_follow_request": "Withdraw follow request",
+ "account.cancel_follow_request": "Cancel follow",
"account.direct": "Privately mention @{name}",
"account.disable_notifications": "Stop notifying me when @{name} posts",
"account.domain_blocked": "Domain blocked",
@@ -48,7 +49,8 @@
"account.mention": "Mention @{name}",
"account.moved_to": "{name} has indicated that their new account is now:",
"account.mute": "Mute @{name}",
- "account.mute_notifications": "Mute notifications from @{name}",
+ "account.mute_notifications_short": "Mute notifications",
+ "account.mute_short": "Mute",
"account.muted": "Muted",
"account.open_original_page": "Open original page",
"account.posts": "Posts",
@@ -65,7 +67,7 @@
"account.unendorse": "Don't feature on profile",
"account.unfollow": "Unfollow",
"account.unmute": "Unmute @{name}",
- "account.unmute_notifications": "Unmute notifications from @{name}",
+ "account.unmute_notifications_short": "Unmute notifications",
"account.unmute_short": "Unmute",
"account_note.placeholder": "Click to add note",
"admin.dashboard.daily_retention": "User retention rate by day after sign-up",
@@ -530,6 +532,8 @@
"report.placeholder": "Additional comments",
"report.reasons.dislike": "I don't like it",
"report.reasons.dislike_description": "It is not something you want to see",
+ "report.reasons.legal": "It's illegal",
+ "report.reasons.legal_description": "You believe it violates the law of your or the server's country",
"report.reasons.other": "It's something else",
"report.reasons.other_description": "The issue does not fit into other categories",
"report.reasons.spam": "It's spam",
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index def4058e62..aba5bf6ce0 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1526,6 +1526,7 @@ body > [data-popper-placement] {
.account__wrapper {
display: flex;
gap: 10px;
+ align-items: center;
}
.account__avatar {
@@ -1594,108 +1595,10 @@ a .account__avatar {
}
.account__relationship {
- height: 18px;
- padding: 10px;
white-space: nowrap;
-}
-
-.account__disclaimer {
- padding: 10px;
- border-top: 1px solid lighten($ui-base-color, 8%);
- color: $dark-text-color;
-
- strong {
- font-weight: 500;
-
- @each $lang in $cjk-langs {
- &:lang(#{$lang}) {
- font-weight: 700;
- }
- }
- }
-
- a {
- font-weight: 500;
- color: inherit;
- text-decoration: underline;
-
- &:hover,
- &:focus,
- &:active {
- text-decoration: none;
- }
- }
-}
-
-.account__action-bar {
- border-top: 1px solid lighten($ui-base-color, 8%);
- border-bottom: 1px solid lighten($ui-base-color, 8%);
- line-height: 36px;
- overflow: hidden;
- flex: 0 0 auto;
display: flex;
-}
-
-.account__action-bar-dropdown {
- padding: 10px;
-
- .icon-button {
- vertical-align: middle;
- }
-
- .dropdown--active {
- .dropdown__content.dropdown__right {
- inset-inline-start: 6px;
- inset-inline-end: initial;
- }
-
- &::after {
- bottom: initial;
- margin-inline-start: 11px;
- margin-top: -7px;
- inset-inline-end: initial;
- }
- }
-}
-
-.account__action-bar-links {
- display: flex;
- flex: 1 1 auto;
- line-height: 18px;
- text-align: center;
-}
-
-.account__action-bar__tab {
- text-decoration: none;
- overflow: hidden;
- flex: 0 1 100%;
- border-inline-end: 1px solid lighten($ui-base-color, 8%);
- padding: 10px 0;
- border-bottom: 4px solid transparent;
-
- &.active {
- border-bottom: 4px solid $ui-highlight-color;
- }
-
- & > span {
- display: block;
- text-transform: uppercase;
- font-size: 11px;
- color: $darker-text-color;
- }
-
- strong {
- display: block;
- font-size: 15px;
- font-weight: 500;
- color: $primary-text-color;
-
- @each $lang in $cjk-langs {
- &:lang(#{$lang}) {
- font-weight: 700;
- }
- }
- }
+ align-items: center;
+ gap: 4px;
}
.account-authorize {
@@ -2049,36 +1952,18 @@ a.account__display-name {
}
.dropdown-animation {
- animation: dropdown 300ms cubic-bezier(0.1, 0.7, 0.1, 1);
+ animation: dropdown 150ms cubic-bezier(0.1, 0.7, 0.1, 1);
@keyframes dropdown {
from {
opacity: 0;
- transform: scaleX(0.85) scaleY(0.75);
}
to {
opacity: 1;
- transform: scaleX(1) scaleY(1);
}
}
- &.top {
- transform-origin: bottom;
- }
-
- &.right {
- transform-origin: left;
- }
-
- &.bottom {
- transform-origin: top;
- }
-
- &.left {
- transform-origin: right;
- }
-
.reduce-motion & {
animation: none;
}
@@ -2094,16 +1979,17 @@ a.account__display-name {
}
.dropdown-menu__separator {
- border-bottom: 1px solid darken($ui-secondary-color, 8%);
- margin: 5px 7px 6px;
+ border-bottom: 1px solid var(--dropdown-border-color);
+ margin: 5px 0;
height: 0;
}
.dropdown-menu {
- background: $ui-secondary-color;
- padding: 4px 0;
+ background: var(--dropdown-background-color);
+ border: 1px solid var(--dropdown-border-color);
+ padding: 4px;
border-radius: 4px;
- box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+ box-shadow: var(--dropdown-shadow);
z-index: 9999;
&__text-button {
@@ -2124,12 +2010,13 @@ a.account__display-name {
&__container {
&__header {
- border-bottom: 1px solid darken($ui-secondary-color, 8%);
- padding: 4px 14px;
- padding-bottom: 8px;
+ border-bottom: 1px solid var(--dropdown-border-color);
+ padding: 10px 14px;
+ padding-bottom: 14px;
+ margin-bottom: 4px;
font-size: 13px;
line-height: 18px;
- color: $inverted-text-color;
+ color: $darker-text-color;
}
&__list {
@@ -2166,103 +2053,43 @@ a.account__display-name {
}
}
-.dropdown-menu__arrow {
- position: absolute;
-
- &::before {
- content: '';
- display: block;
- width: 14px;
- height: 5px;
- background-color: $ui-secondary-color;
- mask-image: url("data:image/svg+xml;utf8,");
- }
-
- &.top {
- bottom: -5px;
-
- &::before {
- transform: rotate(180deg);
- }
- }
-
- &.right {
- inset-inline-start: -9px;
-
- &::before {
- transform: rotate(-90deg);
- }
- }
-
- &.bottom {
- top: -5px;
- }
-
- &.left {
- inset-inline-end: -9px;
-
- &::before {
- transform: rotate(90deg);
- }
- }
-}
-
.dropdown-menu__item {
font-size: 13px;
line-height: 18px;
+ font-weight: 500;
display: block;
- color: $inverted-text-color;
+
+ &--dangerous {
+ color: $error-value-color;
+ }
a,
button {
- font-family: inherit;
- font-size: inherit;
- line-height: inherit;
+ font: inherit;
display: block;
width: 100%;
- padding: 4px 14px;
+ padding: 10px 14px;
border: 0;
margin: 0;
+ background: transparent;
box-sizing: border-box;
text-decoration: none;
- background: $ui-secondary-color;
color: inherit;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: inherit;
+ border-radius: 4px;
&:focus,
&:hover,
&:active {
- background: $ui-highlight-color;
- color: $secondary-text-color;
+ background: var(--dropdown-border-color);
outline: 0;
}
}
}
-.dropdown-menu__item--text {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- padding: 4px 14px;
-}
-
-.dropdown-menu__item.edited-timestamp__history__item {
- border-bottom: 1px solid darken($ui-secondary-color, 8%);
-
- &:last-child {
- border-bottom: 0;
- }
-
- &.dropdown-menu__item--text,
- a,
- button {
- padding: 8px 14px;
- }
-}
-
.inline-account {
display: inline-flex;
align-items: center;
@@ -2278,62 +2105,6 @@ a.account__display-name {
}
}
-.dropdown--active .dropdown__content {
- display: block;
- line-height: 18px;
- max-width: 311px;
- inset-inline-end: 0;
- text-align: start;
- z-index: 9999;
-
- & > ul {
- list-style: none;
- background: $ui-secondary-color;
- padding: 4px 0;
- border-radius: 4px;
- box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);
- min-width: 140px;
- position: relative;
- }
-
- &.dropdown__right {
- inset-inline-end: 0;
- }
-
- &.dropdown__left {
- & > ul {
- inset-inline-start: -98px;
- }
- }
-
- & > ul > li > a {
- font-size: 13px;
- line-height: 18px;
- display: block;
- padding: 4px 14px;
- box-sizing: border-box;
- text-decoration: none;
- background: $ui-secondary-color;
- color: $inverted-text-color;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-
- &:focus {
- outline: 0;
- }
-
- &:hover {
- background: $ui-highlight-color;
- color: $secondary-text-color;
- }
- }
-}
-
-.dropdown__icon {
- vertical-align: middle;
-}
-
.columns-area {
display: flex;
flex: 1 1 auto;
@@ -3111,10 +2882,10 @@ $ui-header-height: 55px;
.compose-form__highlightable {
display: flex;
flex-direction: column;
- overflow: hidden;
flex: 0 1 auto;
border-radius: 4px;
transition: box-shadow 300ms linear;
+ min-height: 0;
&.active {
transition: none;
@@ -3156,7 +2927,6 @@ $ui-header-height: 55px;
.compose-form {
flex: 1;
- overflow-y: hidden;
display: flex;
flex-direction: column;
min-height: 310px;
diff --git a/app/javascript/styles/mastodon/variables.scss b/app/javascript/styles/mastodon/variables.scss
index 7de25f8fd4..d6dda1b3c7 100644
--- a/app/javascript/styles/mastodon/variables.scss
+++ b/app/javascript/styles/mastodon/variables.scss
@@ -61,3 +61,10 @@ $no-gap-breakpoint: 1175px;
$font-sans-serif: 'mastodon-font-sans-serif' !default;
$font-display: 'mastodon-font-display' !default;
$font-monospace: 'mastodon-font-monospace' !default;
+
+:root {
+ --dropdown-border-color: #{lighten($ui-base-color, 12%)};
+ --dropdown-background-color: #{lighten($ui-base-color, 4%)};
+ --dropdown-shadow: 0 20px 25px -5px #{rgba($base-shadow-color, 0.25)},
+ 0 8px 10px -6px #{rgba($base-shadow-color, 0.25)};
+}
diff --git a/app/lib/admin/metrics/measure/instance_accounts_measure.rb b/app/lib/admin/metrics/measure/instance_accounts_measure.rb
index 14a61de88c..3d081fdd90 100644
--- a/app/lib/admin/metrics/measure/instance_accounts_measure.rb
+++ b/app/lib/admin/metrics/measure/instance_accounts_measure.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Admin::Metrics::Measure::InstanceAccountsMeasure < Admin::Metrics::Measure::BaseMeasure
+ include Admin::Metrics::Measure::QueryHelper
+
def self.with_params?
true
end
@@ -25,33 +27,25 @@ class Admin::Metrics::Measure::InstanceAccountsMeasure < Admin::Metrics::Measure
nil
end
- def perform_data_query
- account_matching_sql = begin
- if params[:include_subdomains]
- "accounts.domain IN (SELECT domain FROM instances WHERE reverse('.' || domain) LIKE reverse('.' || $3::text))"
- else
- 'accounts.domain = $3::text'
- end
- end
+ def sql_array
+ [sql_query_string, { start_at: @start_at, end_at: @end_at, domain: params[:domain] }]
+ end
- sql = <<-SQL.squish
+ def sql_query_string
+ <<~SQL.squish
SELECT axis.*, (
WITH new_accounts AS (
SELECT accounts.id
FROM accounts
WHERE date_trunc('day', accounts.created_at)::date = axis.period
- AND #{account_matching_sql}
+ AND #{account_domain_sql(params[:include_subdomains])}
)
SELECT count(*) FROM new_accounts
) AS value
FROM (
- SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period
+ SELECT generate_series(date_trunc('day', :start_at::timestamp)::date, date_trunc('day', :end_at::timestamp)::date, interval '1 day') AS period
) AS axis
SQL
-
- rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, params[:domain]]])
-
- rows.map { |row| { date: row['period'], value: row['value'].to_s } }
end
def time_period
diff --git a/app/lib/admin/metrics/measure/instance_followers_measure.rb b/app/lib/admin/metrics/measure/instance_followers_measure.rb
index dc0f5492c9..378c6754d9 100644
--- a/app/lib/admin/metrics/measure/instance_followers_measure.rb
+++ b/app/lib/admin/metrics/measure/instance_followers_measure.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Admin::Metrics::Measure::InstanceFollowersMeasure < Admin::Metrics::Measure::BaseMeasure
+ include Admin::Metrics::Measure::QueryHelper
+
def self.with_params?
true
end
@@ -25,34 +27,26 @@ class Admin::Metrics::Measure::InstanceFollowersMeasure < Admin::Metrics::Measur
nil
end
- def perform_data_query
- account_matching_sql = begin
- if params[:include_subdomains]
- "accounts.domain IN (SELECT domain FROM instances WHERE reverse('.' || domain) LIKE reverse('.' || $3::text))"
- else
- 'accounts.domain = $3::text'
- end
- end
+ def sql_array
+ [sql_query_string, { start_at: @start_at, end_at: @end_at, domain: params[:domain] }]
+ end
- sql = <<-SQL.squish
+ def sql_query_string
+ <<~SQL.squish
SELECT axis.*, (
WITH new_followers AS (
SELECT follows.id
FROM follows
INNER JOIN accounts ON follows.account_id = accounts.id
WHERE date_trunc('day', follows.created_at)::date = axis.period
- AND #{account_matching_sql}
+ AND #{account_domain_sql(params[:include_subdomains])}
)
SELECT count(*) FROM new_followers
) AS value
FROM (
- SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period
+ SELECT generate_series(date_trunc('day', :start_at::timestamp)::date, date_trunc('day', :end_at::timestamp)::date, interval '1 day') AS period
) AS axis
SQL
-
- rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, params[:domain]]])
-
- rows.map { |row| { date: row['period'], value: row['value'].to_s } }
end
def time_period
diff --git a/app/lib/admin/metrics/measure/instance_follows_measure.rb b/app/lib/admin/metrics/measure/instance_follows_measure.rb
index f2088ffb30..e213348fbc 100644
--- a/app/lib/admin/metrics/measure/instance_follows_measure.rb
+++ b/app/lib/admin/metrics/measure/instance_follows_measure.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Admin::Metrics::Measure::InstanceFollowsMeasure < Admin::Metrics::Measure::BaseMeasure
+ include Admin::Metrics::Measure::QueryHelper
+
def self.with_params?
true
end
@@ -25,34 +27,26 @@ class Admin::Metrics::Measure::InstanceFollowsMeasure < Admin::Metrics::Measure:
nil
end
- def perform_data_query
- account_matching_sql = begin
- if params[:include_subdomains]
- "accounts.domain IN (SELECT domain FROM instances WHERE reverse('.' || domain) LIKE reverse('.' || $3::text))"
- else
- 'accounts.domain = $3::text'
- end
- end
+ def sql_array
+ [sql_query_string, { start_at: @start_at, end_at: @end_at, domain: params[:domain] }]
+ end
- sql = <<-SQL.squish
+ def sql_query_string
+ <<~SQL.squish
SELECT axis.*, (
WITH new_follows AS (
SELECT follows.id
FROM follows
INNER JOIN accounts ON follows.target_account_id = accounts.id
WHERE date_trunc('day', follows.created_at)::date = axis.period
- AND #{account_matching_sql}
+ AND #{account_domain_sql(params[:include_subdomains])}
)
SELECT count(*) FROM new_follows
) AS value
FROM (
- SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period
+ SELECT generate_series(date_trunc('day', :start_at::timestamp)::date, date_trunc('day', :end_at::timestamp)::date, interval '1 day') AS period
) AS axis
SQL
-
- rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, params[:domain]]])
-
- rows.map { |row| { date: row['period'], value: row['value'].to_s } }
end
def time_period
diff --git a/app/lib/admin/metrics/measure/instance_media_attachments_measure.rb b/app/lib/admin/metrics/measure/instance_media_attachments_measure.rb
index 779883e031..2d4b5f56b0 100644
--- a/app/lib/admin/metrics/measure/instance_media_attachments_measure.rb
+++ b/app/lib/admin/metrics/measure/instance_media_attachments_measure.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class Admin::Metrics::Measure::InstanceMediaAttachmentsMeasure < Admin::Metrics::Measure::BaseMeasure
+ include Admin::Metrics::Measure::QueryHelper
include ActionView::Helpers::NumberHelper
def self.with_params?
@@ -35,34 +36,26 @@ class Admin::Metrics::Measure::InstanceMediaAttachmentsMeasure < Admin::Metrics:
nil
end
- def perform_data_query
- account_matching_sql = begin
- if params[:include_subdomains]
- "accounts.domain IN (SELECT domain FROM instances WHERE reverse('.' || domain) LIKE reverse('.' || $3::text))"
- else
- 'accounts.domain = $3::text'
- end
- end
+ def sql_array
+ [sql_query_string, { start_at: @start_at, end_at: @end_at, domain: params[:domain] }]
+ end
- sql = <<-SQL.squish
+ def sql_query_string
+ <<~SQL.squish
SELECT axis.*, (
WITH new_media_attachments AS (
SELECT COALESCE(media_attachments.file_file_size, 0) + COALESCE(media_attachments.thumbnail_file_size, 0) AS size
FROM media_attachments
INNER JOIN accounts ON accounts.id = media_attachments.account_id
WHERE date_trunc('day', media_attachments.created_at)::date = axis.period
- AND #{account_matching_sql}
+ AND #{account_domain_sql(params[:include_subdomains])}
)
SELECT SUM(size) FROM new_media_attachments
) AS value
FROM (
- SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period
+ SELECT generate_series(date_trunc('day', :start_at::timestamp)::date, date_trunc('day', :end_at::timestamp)::date, interval '1 day') AS period
) AS axis
SQL
-
- rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, params[:domain]]])
-
- rows.map { |row| { date: row['period'], value: row['value'].to_s } }
end
def time_period
diff --git a/app/lib/admin/metrics/measure/instance_reports_measure.rb b/app/lib/admin/metrics/measure/instance_reports_measure.rb
index c1f7189bfe..9da3d53e34 100644
--- a/app/lib/admin/metrics/measure/instance_reports_measure.rb
+++ b/app/lib/admin/metrics/measure/instance_reports_measure.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Admin::Metrics::Measure::InstanceReportsMeasure < Admin::Metrics::Measure::BaseMeasure
+ include Admin::Metrics::Measure::QueryHelper
+
def self.with_params?
true
end
@@ -25,34 +27,26 @@ class Admin::Metrics::Measure::InstanceReportsMeasure < Admin::Metrics::Measure:
nil
end
- def perform_data_query
- account_matching_sql = begin
- if params[:include_subdomains]
- "accounts.domain IN (SELECT domain FROM instances WHERE reverse('.' || domain) LIKE reverse('.' || $3::text))"
- else
- 'accounts.domain = $3::text'
- end
- end
+ def sql_array
+ [sql_query_string, { start_at: @start_at, end_at: @end_at, domain: params[:domain] }]
+ end
- sql = <<-SQL.squish
+ def sql_query_string
+ <<~SQL.squish
SELECT axis.*, (
WITH new_reports AS (
SELECT reports.id
FROM reports
INNER JOIN accounts ON accounts.id = reports.target_account_id
WHERE date_trunc('day', reports.created_at)::date = axis.period
- AND #{account_matching_sql}
+ AND #{account_domain_sql(params[:include_subdomains])}
)
SELECT count(*) FROM new_reports
) AS value
FROM (
- SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period
+ SELECT generate_series(date_trunc('day', :start_at::timestamp)::date, date_trunc('day', :end_at::timestamp)::date, interval '1 day') AS period
) AS axis
SQL
-
- rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, params[:domain]]])
-
- rows.map { |row| { date: row['period'], value: row['value'].to_s } }
end
def time_period
diff --git a/app/lib/admin/metrics/measure/instance_statuses_measure.rb b/app/lib/admin/metrics/measure/instance_statuses_measure.rb
index 1b38b40c55..8c71c66145 100644
--- a/app/lib/admin/metrics/measure/instance_statuses_measure.rb
+++ b/app/lib/admin/metrics/measure/instance_statuses_measure.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Admin::Metrics::Measure::InstanceStatusesMeasure < Admin::Metrics::Measure::BaseMeasure
+ include Admin::Metrics::Measure::QueryHelper
+
def self.with_params?
true
end
@@ -25,35 +27,35 @@ class Admin::Metrics::Measure::InstanceStatusesMeasure < Admin::Metrics::Measure
nil
end
- def perform_data_query
- account_matching_sql = begin
- if params[:include_subdomains]
- "accounts.domain IN (SELECT domain FROM instances WHERE reverse('.' || domain) LIKE reverse('.' || $5::text))"
- else
- 'accounts.domain = $5::text'
- end
- end
+ def sql_array
+ [sql_query_string, { start_at: @start_at, end_at: @end_at, domain: params[:domain], earliest_status_id: earliest_status_id, latest_status_id: latest_status_id }]
+ end
- sql = <<-SQL.squish
+ def sql_query_string
+ <<~SQL.squish
SELECT axis.*, (
WITH new_statuses AS (
SELECT statuses.id
FROM statuses
INNER JOIN accounts ON accounts.id = statuses.account_id
- WHERE statuses.id BETWEEN $3 AND $4
- AND #{account_matching_sql}
+ WHERE statuses.id BETWEEN :earliest_status_id AND :latest_status_id
+ AND #{account_domain_sql(params[:include_subdomains])}
AND date_trunc('day', statuses.created_at)::date = axis.period
)
SELECT count(*) FROM new_statuses
) AS value
FROM (
- SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period
+ SELECT generate_series(date_trunc('day', :start_at::timestamp)::date, date_trunc('day', :end_at::timestamp)::date, interval '1 day') AS period
) AS axis
SQL
+ end
- rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, params[:domain]]])
+ def earliest_status_id
+ Mastodon::Snowflake.id_at(@start_at, with_random: false)
+ end
- rows.map { |row| { date: row['period'], value: row['value'].to_s } }
+ def latest_status_id
+ Mastodon::Snowflake.id_at(@end_at, with_random: false)
end
def time_period
diff --git a/app/lib/admin/metrics/measure/new_users_measure.rb b/app/lib/admin/metrics/measure/new_users_measure.rb
index 71191f1a22..6837c14c82 100644
--- a/app/lib/admin/metrics/measure/new_users_measure.rb
+++ b/app/lib/admin/metrics/measure/new_users_measure.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Admin::Metrics::Measure::NewUsersMeasure < Admin::Metrics::Measure::BaseMeasure
+ include Admin::Metrics::Measure::QueryHelper
+
def key
'new_users'
end
@@ -15,8 +17,12 @@ class Admin::Metrics::Measure::NewUsersMeasure < Admin::Metrics::Measure::BaseMe
User.where(created_at: previous_time_period).count
end
- def perform_data_query
- sql = <<-SQL.squish
+ def sql_array
+ [sql_query_string, { start_at: @start_at, end_at: @end_at }]
+ end
+
+ def sql_query_string
+ <<~SQL.squish
SELECT axis.*, (
WITH new_users AS (
SELECT users.id
@@ -26,12 +32,8 @@ class Admin::Metrics::Measure::NewUsersMeasure < Admin::Metrics::Measure::BaseMe
SELECT count(*) FROM new_users
) AS value
FROM (
- SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period
+ SELECT generate_series(date_trunc('day', :start_at::timestamp)::date, date_trunc('day', :end_at::timestamp)::date, interval '1 day') AS period
) AS axis
SQL
-
- rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at]])
-
- rows.map { |row| { date: row['period'], value: row['value'].to_s } }
end
end
diff --git a/app/lib/admin/metrics/measure/opened_reports_measure.rb b/app/lib/admin/metrics/measure/opened_reports_measure.rb
index 4b80a0c8c3..c395c46341 100644
--- a/app/lib/admin/metrics/measure/opened_reports_measure.rb
+++ b/app/lib/admin/metrics/measure/opened_reports_measure.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Admin::Metrics::Measure::OpenedReportsMeasure < Admin::Metrics::Measure::BaseMeasure
+ include Admin::Metrics::Measure::QueryHelper
+
def key
'opened_reports'
end
@@ -15,8 +17,12 @@ class Admin::Metrics::Measure::OpenedReportsMeasure < Admin::Metrics::Measure::B
Report.where(created_at: previous_time_period).count
end
- def perform_data_query
- sql = <<-SQL.squish
+ def sql_array
+ [sql_query_string, { start_at: @start_at, end_at: @end_at }]
+ end
+
+ def sql_query_string
+ <<~SQL.squish
SELECT axis.*, (
WITH new_reports AS (
SELECT reports.id
@@ -26,12 +32,8 @@ class Admin::Metrics::Measure::OpenedReportsMeasure < Admin::Metrics::Measure::B
SELECT count(*) FROM new_reports
) AS value
FROM (
- SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period
+ SELECT generate_series(date_trunc('day', :start_at::timestamp)::date, date_trunc('day', :end_at::timestamp)::date, interval '1 day') AS period
) AS axis
SQL
-
- rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at]])
-
- rows.map { |row| { date: row['period'], value: row['value'].to_s } }
end
end
diff --git a/app/lib/admin/metrics/measure/query_helper.rb b/app/lib/admin/metrics/measure/query_helper.rb
new file mode 100644
index 0000000000..969065f73f
--- /dev/null
+++ b/app/lib/admin/metrics/measure/query_helper.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Admin::Metrics::Measure::QueryHelper
+ protected
+
+ def perform_data_query
+ measurement_data_rows.map { |row| { date: row['period'], value: row['value'].to_s } }
+ end
+
+ def measurement_data_rows
+ ActiveRecord::Base.connection.select_all(sanitized_sql_string)
+ end
+
+ def sanitized_sql_string
+ ActiveRecord::Base.sanitize_sql_array(sql_array)
+ end
+
+ def account_domain_sql(include_subdomains)
+ if include_subdomains
+ "accounts.domain IN (SELECT domain FROM instances WHERE reverse('.' || domain) LIKE reverse('.' || :domain::text))"
+ else
+ 'accounts.domain = :domain::text'
+ end
+ end
+end
diff --git a/app/lib/admin/metrics/measure/resolved_reports_measure.rb b/app/lib/admin/metrics/measure/resolved_reports_measure.rb
index 4ab746c8fa..780db75a10 100644
--- a/app/lib/admin/metrics/measure/resolved_reports_measure.rb
+++ b/app/lib/admin/metrics/measure/resolved_reports_measure.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Admin::Metrics::Measure::ResolvedReportsMeasure < Admin::Metrics::Measure::BaseMeasure
+ include Admin::Metrics::Measure::QueryHelper
+
def key
'resolved_reports'
end
@@ -15,8 +17,12 @@ class Admin::Metrics::Measure::ResolvedReportsMeasure < Admin::Metrics::Measure:
Report.resolved.where(action_taken_at: previous_time_period).count
end
- def perform_data_query
- sql = <<-SQL.squish
+ def sql_array
+ [sql_query_string, { start_at: @start_at, end_at: @end_at }]
+ end
+
+ def sql_query_string
+ <<~SQL.squish
SELECT axis.*, (
WITH resolved_reports AS (
SELECT reports.id
@@ -26,12 +32,8 @@ class Admin::Metrics::Measure::ResolvedReportsMeasure < Admin::Metrics::Measure:
SELECT count(*) FROM resolved_reports
) AS value
FROM (
- SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period
+ SELECT generate_series(date_trunc('day', :start_at::timestamp)::date, date_trunc('day', :end_at::timestamp)::date, interval '1 day') AS period
) AS axis
SQL
-
- rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at]])
-
- rows.map { |row| { date: row['period'], value: row['value'].to_s } }
end
end
diff --git a/app/lib/admin/metrics/measure/tag_servers_measure.rb b/app/lib/admin/metrics/measure/tag_servers_measure.rb
index 11f229602e..e6378b8021 100644
--- a/app/lib/admin/metrics/measure/tag_servers_measure.rb
+++ b/app/lib/admin/metrics/measure/tag_servers_measure.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Admin::Metrics::Measure::TagServersMeasure < Admin::Metrics::Measure::BaseMeasure
+ include Admin::Metrics::Measure::QueryHelper
+
def self.with_params?
true
end
@@ -19,25 +21,33 @@ class Admin::Metrics::Measure::TagServersMeasure < Admin::Metrics::Measure::Base
tag.statuses.where('statuses.id BETWEEN ? AND ?', Mastodon::Snowflake.id_at(@start_at - length_of_period, with_random: false), Mastodon::Snowflake.id_at(@end_at - length_of_period, with_random: false)).joins(:account).count('distinct accounts.domain')
end
- def perform_data_query
- sql = <<-SQL.squish
+ def sql_array
+ [sql_query_string, { start_at: @start_at, end_at: @end_at, tag_id: tag.id, earliest_status_id: earliest_status_id, latest_status_id: latest_status_id }]
+ end
+
+ def sql_query_string
+ <<~SQL.squish
SELECT axis.*, (
SELECT count(distinct accounts.domain) AS value
FROM statuses
INNER JOIN statuses_tags ON statuses.id = statuses_tags.status_id
INNER JOIN accounts ON statuses.account_id = accounts.id
- WHERE statuses_tags.tag_id = $1
- AND statuses.id BETWEEN $2 AND $3
+ WHERE statuses_tags.tag_id = :tag_id
+ AND statuses.id BETWEEN :earliest_status_id AND :latest_status_id
AND date_trunc('day', statuses.created_at)::date = axis.day
)
FROM (
- SELECT generate_series(date_trunc('day', $4::timestamp)::date, date_trunc('day', $5::timestamp)::date, ('1 day')::interval) AS day
+ SELECT generate_series(date_trunc('day', :start_at::timestamp)::date, date_trunc('day', :end_at::timestamp)::date, ('1 day')::interval) AS day
) as axis
SQL
+ end
- rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:id].to_i], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @start_at], [nil, @end_at]])
+ def earliest_status_id
+ Mastodon::Snowflake.id_at(@start_at, with_random: false)
+ end
- rows.map { |row| { date: row['day'], value: row['value'].to_s } }
+ def latest_status_id
+ Mastodon::Snowflake.id_at(@end_at, with_random: false)
end
def tag
diff --git a/app/lib/link_details_extractor.rb b/app/lib/link_details_extractor.rb
index dfed69285f..f0aeec0b3e 100644
--- a/app/lib/link_details_extractor.rb
+++ b/app/lib/link_details_extractor.rb
@@ -7,15 +7,15 @@ class LinkDetailsExtractor
# Some publications wrap their JSON-LD data in their