diff --git a/.github/renovate.json5 b/.github/renovate.json5 new file mode 100644 index 000000000..1ae40d416 --- /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 51bde39bc..4d3c2ce5a 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 2ddbca781..56d817123 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 547035ab3..1f0cfd1e7 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 7dfc0e058..8712d8bd8 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 b489ce968..d19a0470d 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 de54fe9ae..0395c8639 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 d77451ee6..295e9610b 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 6a8035210..131a62a57 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 32e21d23c..3306105f9 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 212b2cfe7..a91fd819a 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 310153929..50266fb8a 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 f284745ea..07cb1d41f 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 2cb7bd0e5..4964cf856 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 1ed3fd18a..01d9ba8ce 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 ea863f5d1..0f3b85388 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 = - ); - } - -} diff --git a/app/javascript/mastodon/components/load_more.tsx b/app/javascript/mastodon/components/load_more.tsx new file mode 100644 index 000000000..8b5746ad3 --- /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 9a0c4c8a7..53a84ecb5 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 936a66080..8b3c20f82 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 e9e5934f4..a206bcc3b 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 27de4740c..653a25866 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 b329cae79..b11ac478a 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 d4854f186..635b6f411 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 6b053a9dc..dc1f72022 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 a6e817c73..fb9e55c57 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 2ce94d9d8..bc90ce592 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 09282de7c..ef0964b19 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 def4058e6..aba5bf6ce 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 7de25f8fd..d6dda1b3c 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 14a61de88..3d081fdd9 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 dc0f5492c..378c6754d 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 f2088ffb3..e213348fb 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 779883e03..2d4b5f56b 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 c1f7189bf..9da3d53e3 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 1b38b40c5..8c71c6614 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 71191f1a2..6837c14c8 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 4b80a0c8c..c395c4634 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 000000000..969065f73 --- /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 4ab746c8f..780db75a1 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 11f229602..e6378b802 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 dfed69285..f0aeec0b3 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