diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 04ac9560ca..f991036add 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,5 +1,5 @@ # For details, see https://github.com/devcontainers/images/tree/main/src/ruby -FROM mcr.microsoft.com/devcontainers/ruby:0-3.2-bullseye +FROM mcr.microsoft.com/devcontainers/ruby:1-3.2-bullseye # Install Rails # RUN gem install rails webdrivers diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 3e21a2035c..0bf6624788 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -437,45 +437,6 @@ RSpec/SubjectStub: - 'spec/services/unallow_domain_service_spec.rb' - 'spec/validators/blacklisted_email_validator_spec.rb' -# Configuration parameters: IgnoreNameless, IgnoreSymbolicNames. -RSpec/VerifiedDoubles: - Exclude: - - 'spec/controllers/admin/change_emails_controller_spec.rb' - - 'spec/controllers/admin/confirmations_controller_spec.rb' - - 'spec/controllers/admin/disputes/appeals_controller_spec.rb' - - 'spec/controllers/admin/domain_allows_controller_spec.rb' - - 'spec/controllers/admin/domain_blocks_controller_spec.rb' - - 'spec/controllers/api/v1/reports_controller_spec.rb' - - 'spec/controllers/api/web/embeds_controller_spec.rb' - - 'spec/controllers/auth/sessions_controller_spec.rb' - - 'spec/controllers/disputes/appeals_controller_spec.rb' - - 'spec/helpers/statuses_helper_spec.rb' - - 'spec/lib/suspicious_sign_in_detector_spec.rb' - - 'spec/models/account/field_spec.rb' - - 'spec/models/session_activation_spec.rb' - - 'spec/models/setting_spec.rb' - - 'spec/services/account_search_service_spec.rb' - - 'spec/services/post_status_service_spec.rb' - - 'spec/services/search_service_spec.rb' - - 'spec/validators/blacklisted_email_validator_spec.rb' - - 'spec/validators/disallowed_hashtags_validator_spec.rb' - - 'spec/validators/email_mx_validator_spec.rb' - - 'spec/validators/follow_limit_validator_spec.rb' - - 'spec/validators/note_length_validator_spec.rb' - - 'spec/validators/poll_validator_spec.rb' - - 'spec/validators/status_length_validator_spec.rb' - - 'spec/validators/status_pin_validator_spec.rb' - - 'spec/validators/unique_username_validator_spec.rb' - - 'spec/validators/unreserved_username_validator_spec.rb' - - 'spec/validators/url_validator_spec.rb' - - 'spec/views/statuses/show.html.haml_spec.rb' - - 'spec/workers/activitypub/processing_worker_spec.rb' - - 'spec/workers/admin/domain_purge_worker_spec.rb' - - 'spec/workers/domain_block_worker_spec.rb' - - 'spec/workers/domain_clear_media_worker_spec.rb' - - 'spec/workers/feed_insert_worker_spec.rb' - - 'spec/workers/regeneration_worker_spec.rb' - # This cop supports unsafe autocorrection (--autocorrect-all). Rails/ApplicationController: Exclude: diff --git a/Gemfile b/Gemfile index ad164af1e4..3feb3f9548 100644 --- a/Gemfile +++ b/Gemfile @@ -3,8 +3,6 @@ source 'https://rubygems.org' ruby '>= 3.0.0' -gem 'pkg-config', '~> 1.5' - gem 'puma', '~> 6.3' gem 'rails', '~> 6.1.7' gem 'sprockets', '~> 3.7.2' diff --git a/Gemfile.lock b/Gemfile.lock index 5f3678fe58..b2d75e9d4a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -18,40 +18,40 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (6.1.7.3) - actionpack (= 6.1.7.3) - activesupport (= 6.1.7.3) + actioncable (6.1.7.4) + actionpack (= 6.1.7.4) + activesupport (= 6.1.7.4) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.7.3) - actionpack (= 6.1.7.3) - activejob (= 6.1.7.3) - activerecord (= 6.1.7.3) - activestorage (= 6.1.7.3) - activesupport (= 6.1.7.3) + actionmailbox (6.1.7.4) + actionpack (= 6.1.7.4) + activejob (= 6.1.7.4) + activerecord (= 6.1.7.4) + activestorage (= 6.1.7.4) + activesupport (= 6.1.7.4) mail (>= 2.7.1) - actionmailer (6.1.7.3) - actionpack (= 6.1.7.3) - actionview (= 6.1.7.3) - activejob (= 6.1.7.3) - activesupport (= 6.1.7.3) + actionmailer (6.1.7.4) + actionpack (= 6.1.7.4) + actionview (= 6.1.7.4) + activejob (= 6.1.7.4) + activesupport (= 6.1.7.4) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.1.7.3) - actionview (= 6.1.7.3) - activesupport (= 6.1.7.3) + actionpack (6.1.7.4) + actionview (= 6.1.7.4) + activesupport (= 6.1.7.4) rack (~> 2.0, >= 2.0.9) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.7.3) - actionpack (= 6.1.7.3) - activerecord (= 6.1.7.3) - activestorage (= 6.1.7.3) - activesupport (= 6.1.7.3) + actiontext (6.1.7.4) + actionpack (= 6.1.7.4) + activerecord (= 6.1.7.4) + activestorage (= 6.1.7.4) + activesupport (= 6.1.7.4) nokogiri (>= 1.8.5) - actionview (6.1.7.3) - activesupport (= 6.1.7.3) + actionview (6.1.7.4) + activesupport (= 6.1.7.4) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -61,22 +61,22 @@ GEM activemodel (>= 4.1, < 7.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (6.1.7.3) - activesupport (= 6.1.7.3) + activejob (6.1.7.4) + activesupport (= 6.1.7.4) globalid (>= 0.3.6) - activemodel (6.1.7.3) - activesupport (= 6.1.7.3) - activerecord (6.1.7.3) - activemodel (= 6.1.7.3) - activesupport (= 6.1.7.3) - activestorage (6.1.7.3) - actionpack (= 6.1.7.3) - activejob (= 6.1.7.3) - activerecord (= 6.1.7.3) - activesupport (= 6.1.7.3) + activemodel (6.1.7.4) + activesupport (= 6.1.7.4) + activerecord (6.1.7.4) + activemodel (= 6.1.7.4) + activesupport (= 6.1.7.4) + activestorage (6.1.7.4) + actionpack (= 6.1.7.4) + activejob (= 6.1.7.4) + activerecord (= 6.1.7.4) + activesupport (= 6.1.7.4) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (6.1.7.3) + activesupport (6.1.7.4) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -412,7 +412,7 @@ GEM mime-types-data (3.2023.0218.1) mini_mime (1.1.2) mini_portile2 (2.8.2) - minitest (5.18.0) + minitest (5.18.1) msgpack (1.7.1) multi_json (1.15.0) multipart-post (2.3.0) @@ -478,7 +478,6 @@ GEM pg (1.5.3) pghero (3.3.3) activerecord (>= 6) - pkg-config (1.5.1) posix-spawn (0.3.15) premailer (1.21.0) addressable @@ -511,20 +510,20 @@ GEM rack rack-test (2.1.0) rack (>= 1.3) - rails (6.1.7.3) - actioncable (= 6.1.7.3) - actionmailbox (= 6.1.7.3) - actionmailer (= 6.1.7.3) - actionpack (= 6.1.7.3) - actiontext (= 6.1.7.3) - actionview (= 6.1.7.3) - activejob (= 6.1.7.3) - activemodel (= 6.1.7.3) - activerecord (= 6.1.7.3) - activestorage (= 6.1.7.3) - activesupport (= 6.1.7.3) + rails (6.1.7.4) + actioncable (= 6.1.7.4) + actionmailbox (= 6.1.7.4) + actionmailer (= 6.1.7.4) + actionpack (= 6.1.7.4) + actiontext (= 6.1.7.4) + actionview (= 6.1.7.4) + activejob (= 6.1.7.4) + activemodel (= 6.1.7.4) + activerecord (= 6.1.7.4) + activestorage (= 6.1.7.4) + activesupport (= 6.1.7.4) bundler (>= 1.15.0) - railties (= 6.1.7.3) + railties (= 6.1.7.4) sprockets-rails (>= 2.0.0) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) @@ -539,9 +538,9 @@ GEM rails-i18n (6.0.0) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 7) - railties (6.1.7.3) - actionpack (= 6.1.7.3) - activesupport (= 6.1.7.3) + railties (6.1.7.4) + actionpack (= 6.1.7.4) + activesupport (= 6.1.7.4) method_source rake (>= 12.2) thor (~> 1.0) @@ -717,7 +716,7 @@ GEM unf_ext unf_ext (0.0.8.2) unicode-display_width (2.4.2) - uri (0.12.1) + uri (0.12.2) validate_email (0.1.6) activemodel (>= 3.0) mail (>= 2.2.5) @@ -833,7 +832,6 @@ DEPENDENCIES parslet pg (~> 1.5) pghero - pkg-config (~> 1.5) posix-spawn premailer-rails private_address_check (~> 0.5) diff --git a/app/chewy/accounts_index.rb b/app/chewy/accounts_index.rb index 5889aa0e64..b80fdfed9f 100644 --- a/app/chewy/accounts_index.rb +++ b/app/chewy/accounts_index.rb @@ -2,8 +2,37 @@ class AccountsIndex < Chewy::Index settings index: { refresh_interval: '30s' }, analysis: { + filter: { + english_stop: { + type: 'stop', + stopwords: '_english_', + }, + + english_stemmer: { + type: 'stemmer', + language: 'english', + }, + + english_possessive_stemmer: { + type: 'stemmer', + language: 'possessive_english', + }, + }, + analyzer: { - content: { + natural: { + tokenizer: 'uax_url_email', + filter: %w( + english_possessive_stemmer + lowercase + asciifolding + cjk_width + english_stop + english_stemmer + ), + }, + + verbatim: { tokenizer: 'whitespace', filter: %w(lowercase asciifolding cjk_width), }, @@ -26,18 +55,13 @@ class AccountsIndex < Chewy::Index index_scope ::Account.searchable.includes(:account_stat) root date_detection: false do - field :id, type: 'long' - - field :display_name, type: 'text', analyzer: 'content' do - field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content' - end - - field :acct, type: 'text', analyzer: 'content', value: ->(account) { [account.username, account.domain].compact.join('@') } do - field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content' - end - - field :following_count, type: 'long', value: ->(account) { account.public_following_count } - field :followers_count, type: 'long', value: ->(account) { account.public_followers_count } - field :last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at } + field(:id, type: 'long') + field(:following_count, type: 'long', value: ->(account) { account.public_following_count }) + field(:followers_count, type: 'long', value: ->(account) { account.public_followers_count }) + field(:properties, type: 'keyword', value: ->(account) { account.searchable_properties }) + field(:last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at }) + field(:display_name, type: 'text', analyzer: 'verbatim') { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' } + field(:username, type: 'text', analyzer: 'verbatim', value: ->(account) { [account.username, account.domain].compact.join('@') }) { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' } + field(:text, type: 'text', value: ->(account) { account.searchable_text }) { field :stemmed, type: 'text', analyzer: 'natural' } end end diff --git a/app/controllers/admin/webhooks_controller.rb b/app/controllers/admin/webhooks_controller.rb index e087476658..f1aad7c4b5 100644 --- a/app/controllers/admin/webhooks_controller.rb +++ b/app/controllers/admin/webhooks_controller.rb @@ -28,6 +28,7 @@ module Admin authorize :webhook, :create? @webhook = Webhook.new(resource_params) + @webhook.current_account = current_account if @webhook.save redirect_to admin_webhook_path(@webhook) @@ -39,6 +40,8 @@ module Admin def update authorize @webhook, :update? + @webhook.current_account = current_account + if @webhook.update(resource_params) redirect_to admin_webhook_path(@webhook) else diff --git a/app/controllers/api/v1/conversations_controller.rb b/app/controllers/api/v1/conversations_controller.rb index 63644f85e2..b3ca2f7903 100644 --- a/app/controllers/api/v1/conversations_controller.rb +++ b/app/controllers/api/v1/conversations_controller.rb @@ -19,6 +19,11 @@ class Api::V1::ConversationsController < Api::BaseController render json: @conversation, serializer: REST::ConversationSerializer end + def unread + @conversation.update!(unread: true) + render json: @conversation, serializer: REST::ConversationSerializer + end + def destroy @conversation.destroy! render_empty diff --git a/app/controllers/api/v1/directories_controller.rb b/app/controllers/api/v1/directories_controller.rb index c0585e8599..1109435507 100644 --- a/app/controllers/api/v1/directories_controller.rb +++ b/app/controllers/api/v1/directories_controller.rb @@ -21,11 +21,35 @@ class Api::V1::DirectoriesController < Api::BaseController def accounts_scope Account.discoverable.tap do |scope| - scope.merge!(Account.local) if truthy_param?(:local) - scope.merge!(Account.by_recent_status) if params[:order].blank? || params[:order] == 'active' - scope.merge!(Account.order(id: :desc)) if params[:order] == 'new' - scope.merge!(Account.not_excluded_by_account(current_account)) if current_account - scope.merge!(Account.not_domain_blocked_by_account(current_account)) if current_account && !truthy_param?(:local) + scope.merge!(account_order_scope) + scope.merge!(local_account_scope) if local_accounts? + scope.merge!(account_exclusion_scope) if current_account + scope.merge!(account_domain_block_scope) if current_account && !local_accounts? end end + + def local_accounts? + truthy_param?(:local) + end + + def account_order_scope + case params[:order] + when 'new' + Account.order(id: :desc) + when 'active', nil + Account.by_recent_status + end + end + + def local_account_scope + Account.local + end + + def account_exclusion_scope + Account.not_excluded_by_account(current_account) + end + + def account_domain_block_scope + Account.not_domain_blocked_by_account(current_account) + end end diff --git a/app/controllers/api/v1/emails/confirmations_controller.rb b/app/controllers/api/v1/emails/confirmations_controller.rb index 29ff897b91..16e91b4497 100644 --- a/app/controllers/api/v1/emails/confirmations_controller.rb +++ b/app/controllers/api/v1/emails/confirmations_controller.rb @@ -5,6 +5,7 @@ class Api::V1::Emails::ConfirmationsController < Api::BaseController before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :check before_action :require_user_owned_by_application!, except: :check before_action :require_user_not_confirmed!, except: :check + before_action :require_authenticated_user!, only: :check def create current_user.update!(email: params[:email]) if params.key?(:email) diff --git a/app/controllers/api/v1/statuses/histories_controller.rb b/app/controllers/api/v1/statuses/histories_controller.rb index dff2425d06..2913472b04 100644 --- a/app/controllers/api/v1/statuses/histories_controller.rb +++ b/app/controllers/api/v1/statuses/histories_controller.rb @@ -8,11 +8,15 @@ class Api::V1::Statuses::HistoriesController < Api::BaseController def show cache_if_unauthenticated! - render json: @status.edits.includes(:account, status: [:account]), each_serializer: REST::StatusEditSerializer + render json: status_edits, each_serializer: REST::StatusEditSerializer end private + def status_edits + @status.edits.includes(:account, status: [:account]).to_a.presence || [@status.build_snapshot(at_time: @status.edited_at || @status.created_at)] + end + def set_status @status = Status.find(params[:status_id]) authorize @status, :show? diff --git a/app/controllers/api/v2/search_controller.rb b/app/controllers/api/v2/search_controller.rb index c9a8050ead..3b450b2420 100644 --- a/app/controllers/api/v2/search_controller.rb +++ b/app/controllers/api/v2/search_controller.rb @@ -34,11 +34,11 @@ class Api::V2::SearchController < Api::BaseController params[:q], current_account, limit_param(RESULTS_LIMIT), - search_params.merge(resolve: truthy_param?(:resolve), exclude_unreviewed: truthy_param?(:exclude_unreviewed)) + search_params.merge(resolve: truthy_param?(:resolve), exclude_unreviewed: truthy_param?(:exclude_unreviewed), following: truthy_param?(:following)) ) end def search_params - params.permit(:type, :offset, :min_id, :max_id, :account_id, :searchability) + params.permit(:type, :offset, :min_id, :max_id, :account_id, :following, :searchability) end end diff --git a/app/javascript/images/friends-cropped.png b/app/javascript/images/friends-cropped.png new file mode 100755 index 0000000000..b13e16a580 Binary files /dev/null and b/app/javascript/images/friends-cropped.png differ diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 2443a28533..ab87ec2ab9 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -132,13 +132,13 @@ export function resetCompose() { }; } -export const focusCompose = (routerHistory, defaultText) => dispatch => { +export const focusCompose = (routerHistory, defaultText) => (dispatch, getState) => { dispatch({ type: COMPOSE_FOCUS, defaultText, }); - ensureComposeIsVisible(routerHistory); + ensureComposeIsVisible(getState, routerHistory); }; export function mentionCompose(account, routerHistory) { diff --git a/app/javascript/mastodon/actions/server.js b/app/javascript/mastodon/actions/server.js index bd784906d4..65f3efc3a7 100644 --- a/app/javascript/mastodon/actions/server.js +++ b/app/javascript/mastodon/actions/server.js @@ -19,6 +19,10 @@ export const SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS = 'SERVER_DOMAIN_BLOCKS_FETCH_SU export const SERVER_DOMAIN_BLOCKS_FETCH_FAIL = 'SERVER_DOMAIN_BLOCKS_FETCH_FAIL'; export const fetchServer = () => (dispatch, getState) => { + if (getState().getIn(['server', 'server', 'isLoading'])) { + return; + } + dispatch(fetchServerRequest()); api(getState) @@ -66,6 +70,10 @@ const fetchServerTranslationLanguagesFail = error => ({ }); export const fetchExtendedDescription = () => (dispatch, getState) => { + if (getState().getIn(['server', 'extendedDescription', 'isLoading'])) { + return; + } + dispatch(fetchExtendedDescriptionRequest()); api(getState) @@ -89,6 +97,10 @@ const fetchExtendedDescriptionFail = error => ({ }); export const fetchDomainBlocks = () => (dispatch, getState) => { + if (getState().getIn(['server', 'domainBlocks', 'isLoading'])) { + return; + } + dispatch(fetchDomainBlocksRequest()); api(getState) diff --git a/app/javascript/mastodon/components/account.jsx b/app/javascript/mastodon/components/account.jsx index e223800a81..600168f16a 100644 --- a/app/javascript/mastodon/components/account.jsx +++ b/app/javascript/mastodon/components/account.jsx @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import classNames from 'classnames'; import { Link } from 'react-router-dom'; @@ -51,6 +51,7 @@ class Account extends ImmutablePureComponent { defaultAction: PropTypes.string, onActionClick: PropTypes.func, children: PropTypes.object, + withBio: PropTypes.bool, }; static defaultProps = { @@ -82,7 +83,7 @@ class Account extends ImmutablePureComponent { }; render () { - const { account, intl, hidden, hideButtons, onActionClick, actionIcon, actionTitle, defaultAction, size, minimal, children } = this.props; + const { account, intl, hidden, hideButtons, withBio, onActionClick, actionIcon, actionTitle, defaultAction, size, minimal, children } = this.props; if (!account) { return ; @@ -178,6 +179,15 @@ class Account extends ImmutablePureComponent { )} + + {withBio && (account.get('note').length > 0 ? ( +
+ ) : ( +
+ ))}
); } diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index 3b3a191d6c..688a456319 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -104,7 +104,7 @@ class StatusContent extends PureComponent { if (mention) { link.addEventListener('click', this.onMentionClick.bind(this, mention), false); - link.setAttribute('title', mention.get('acct')); + link.setAttribute('title', `@${mention.get('acct')}`); link.setAttribute('href', `/@${mention.get('acct')}`); } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false); diff --git a/app/javascript/mastodon/components/verified_badge.tsx b/app/javascript/mastodon/components/verified_badge.tsx index 6b421ba42c..9a6adcfa86 100644 --- a/app/javascript/mastodon/components/verified_badge.tsx +++ b/app/javascript/mastodon/components/verified_badge.tsx @@ -1,11 +1,27 @@ import { Icon } from './icon'; +const domParser = new DOMParser(); + +const stripRelMe = (html: string) => { + const document = domParser.parseFromString(html, 'text/html').documentElement; + + document.querySelectorAll('a[rel]').forEach((link) => { + link.rel = link.rel + .split(' ') + .filter((x: string) => x !== 'me') + .join(' '); + }); + + const body = document.querySelector('body'); + return body ? { __html: body.innerHTML } : undefined; +}; + interface Props { link: string; } export const VerifiedBadge: React.FC = ({ link }) => ( - + ); diff --git a/app/javascript/mastodon/features/about/index.jsx b/app/javascript/mastodon/features/about/index.jsx index 5d260c22d0..0f2541c7e5 100644 --- a/app/javascript/mastodon/features/about/index.jsx +++ b/app/javascript/mastodon/features/about/index.jsx @@ -168,7 +168,7 @@ class About extends PureComponent {
- {!isLoading && (server.get('rules').isEmpty() ? ( + {!isLoading && (server.get('rules', []).isEmpty() ? (

) : (
    diff --git a/app/javascript/mastodon/features/bookmarked_statuses/index.jsx b/app/javascript/mastodon/features/bookmarked_statuses/index.jsx index 795b859ce4..936dee12e3 100644 --- a/app/javascript/mastodon/features/bookmarked_statuses/index.jsx +++ b/app/javascript/mastodon/features/bookmarked_statuses/index.jsx @@ -15,13 +15,14 @@ import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; import ColumnHeader from 'mastodon/components/column_header'; import StatusList from 'mastodon/components/status_list'; import Column from 'mastodon/features/ui/components/column'; +import { getStatusList } from 'mastodon/selectors'; const messages = defineMessages({ heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, }); const mapStateToProps = state => ({ - statusIds: state.getIn(['status_lists', 'bookmarks', 'items']), + statusIds: getStatusList(state, 'bookmarks'), isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true), hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']), }); diff --git a/app/javascript/mastodon/features/community_timeline/index.jsx b/app/javascript/mastodon/features/community_timeline/index.jsx index a18da2f642..7e3b9babe9 100644 --- a/app/javascript/mastodon/features/community_timeline/index.jsx +++ b/app/javascript/mastodon/features/community_timeline/index.jsx @@ -140,11 +140,8 @@ class CommunityTimeline extends PureComponent { - - - - } trackScroll={!pinned} scrollKey={`community_timeline-${columnId}`} timelineId={`community${onlyMedia ? ':media' : ''}`} diff --git a/app/javascript/mastodon/features/compose/components/action_bar.jsx b/app/javascript/mastodon/features/compose/components/action_bar.jsx index 3c4d33683f..ed0625cb18 100644 --- a/app/javascript/mastodon/features/compose/components/action_bar.jsx +++ b/app/javascript/mastodon/features/compose/components/action_bar.jsx @@ -66,7 +66,7 @@ class ActionBar extends PureComponent { return (
    - +
    ); diff --git a/app/javascript/mastodon/features/explore/links.jsx b/app/javascript/mastodon/features/explore/links.jsx index df91337fdd..49c667f027 100644 --- a/app/javascript/mastodon/features/explore/links.jsx +++ b/app/javascript/mastodon/features/explore/links.jsx @@ -35,7 +35,7 @@ class Links extends PureComponent { const banner = ( - + ); diff --git a/app/javascript/mastodon/features/explore/statuses.jsx b/app/javascript/mastodon/features/explore/statuses.jsx index abacf333dd..eb2fe777a6 100644 --- a/app/javascript/mastodon/features/explore/statuses.jsx +++ b/app/javascript/mastodon/features/explore/statuses.jsx @@ -11,9 +11,10 @@ import { debounce } from 'lodash'; import { fetchTrendingStatuses, expandTrendingStatuses } from 'mastodon/actions/trends'; import DismissableBanner from 'mastodon/components/dismissable_banner'; import StatusList from 'mastodon/components/status_list'; +import { getStatusList } from 'mastodon/selectors'; const mapStateToProps = state => ({ - statusIds: state.getIn(['status_lists', 'trending', 'items']), + statusIds: getStatusList(state, 'trending'), isLoading: state.getIn(['status_lists', 'trending', 'isLoading'], true), hasMore: !!state.getIn(['status_lists', 'trending', 'next']), }); @@ -46,7 +47,7 @@ class Statuses extends PureComponent { return ( <> - + - + ); diff --git a/app/javascript/mastodon/features/favourited_statuses/index.jsx b/app/javascript/mastodon/features/favourited_statuses/index.jsx index 4902ddc28b..abce7ac053 100644 --- a/app/javascript/mastodon/features/favourited_statuses/index.jsx +++ b/app/javascript/mastodon/features/favourited_statuses/index.jsx @@ -15,13 +15,14 @@ import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'mastodon/acti import ColumnHeader from 'mastodon/components/column_header'; import StatusList from 'mastodon/components/status_list'; import Column from 'mastodon/features/ui/components/column'; +import { getStatusList } from 'mastodon/selectors'; const messages = defineMessages({ heading: { id: 'column.favourites', defaultMessage: 'Favourites' }, }); const mapStateToProps = state => ({ - statusIds: state.getIn(['status_lists', 'favourites', 'items']), + statusIds: getStatusList(state, 'favourites'), isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true), hasMore: !!state.getIn(['status_lists', 'favourites', 'next']), }); diff --git a/app/javascript/mastodon/features/firehose/index.jsx b/app/javascript/mastodon/features/firehose/index.jsx new file mode 100644 index 0000000000..9ba4fd5b2b --- /dev/null +++ b/app/javascript/mastodon/features/firehose/index.jsx @@ -0,0 +1,211 @@ +import PropTypes from 'prop-types'; +import { useRef, useCallback, useEffect } from 'react'; + +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; +import { NavLink } from 'react-router-dom'; + +import { addColumn } from 'mastodon/actions/columns'; +import { changeSetting } from 'mastodon/actions/settings'; +import { connectPublicStream, connectCommunityStream } from 'mastodon/actions/streaming'; +import { expandPublicTimeline, expandCommunityTimeline } from 'mastodon/actions/timelines'; +import DismissableBanner from 'mastodon/components/dismissable_banner'; +import initialState, { domain } from 'mastodon/initial_state'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +import Column from '../../components/column'; +import ColumnHeader from '../../components/column_header'; +import SettingToggle from '../notifications/components/setting_toggle'; +import StatusListContainer from '../ui/containers/status_list_container'; + +const messages = defineMessages({ + title: { id: 'column.firehose', defaultMessage: 'Live feeds' }, +}); + +// TODO: use a proper React context later on +const useIdentity = () => ({ + signedIn: !!initialState.meta.me, + accountId: initialState.meta.me, + disabledAccountId: initialState.meta.disabled_account_id, + accessToken: initialState.meta.access_token, + permissions: initialState.role ? initialState.role.permissions : 0, +}); + +const ColumnSettings = () => { + const dispatch = useAppDispatch(); + const settings = useAppSelector((state) => state.getIn(['settings', 'firehose'])); + const onChange = useCallback( + (key, checked) => dispatch(changeSetting(['firehose', ...key], checked)), + [dispatch], + ); + + return ( +
    +
    + } + /> +
    +
    + ); +}; + +const Firehose = ({ feedType, multiColumn }) => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + const { signedIn } = useIdentity(); + const columnRef = useRef(null); + + const onlyMedia = useAppSelector((state) => state.getIn(['settings', 'firehose', 'onlyMedia'], false)); + const hasUnread = useAppSelector((state) => state.getIn(['timelines', `${feedType}${onlyMedia ? ':media' : ''}`, 'unread'], 0) > 0); + + const handlePin = useCallback( + () => { + switch(feedType) { + case 'community': + dispatch(addColumn('COMMUNITY', { other: { onlyMedia } })); + break; + case 'public': + dispatch(addColumn('PUBLIC', { other: { onlyMedia } })); + break; + case 'public:remote': + dispatch(addColumn('REMOTE', { other: { onlyMedia, onlyRemote: true } })); + break; + } + }, + [dispatch, onlyMedia, feedType], + ); + + const handleLoadMore = useCallback( + (maxId) => { + switch(feedType) { + case 'community': + dispatch(expandCommunityTimeline({ maxId, onlyMedia })); + break; + case 'public': + dispatch(expandPublicTimeline({ maxId, onlyMedia })); + break; + case 'public:remote': + dispatch(expandPublicTimeline({ maxId, onlyMedia, onlyRemote: true })); + break; + } + }, + [dispatch, onlyMedia, feedType], + ); + + const handleHeaderClick = useCallback(() => columnRef.current?.scrollTop(), []); + + useEffect(() => { + let disconnect; + + switch(feedType) { + case 'community': + dispatch(expandCommunityTimeline({ onlyMedia })); + if (signedIn) { + disconnect = dispatch(connectCommunityStream({ onlyMedia })); + } + break; + case 'public': + dispatch(expandPublicTimeline({ onlyMedia })); + if (signedIn) { + disconnect = dispatch(connectPublicStream({ onlyMedia })); + } + break; + case 'public:remote': + dispatch(expandPublicTimeline({ onlyMedia, onlyRemote: true })); + if (signedIn) { + disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote: true })); + } + break; + } + + return () => disconnect?.(); + }, [dispatch, signedIn, feedType, onlyMedia]); + + const prependBanner = feedType === 'community' ? ( + + + + ) : ( + + + + ); + + const emptyMessage = feedType === 'community' ? ( + + ) : ( + + ); + + return ( + + + + + +
    +
    + + + + + + + + + + + +
    + + +
    + + + {intl.formatMessage(messages.title)} + + +
    + ); +} + +Firehose.propTypes = { + multiColumn: PropTypes.bool, + feedType: PropTypes.string, +}; + +export default Firehose; diff --git a/app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx b/app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx new file mode 100644 index 0000000000..a6993c6418 --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import background from 'mastodon/../images/friends-cropped.png'; +import DismissableBanner from 'mastodon/components/dismissable_banner'; + + +export const ExplorePrompt = () => ( + + + +

    +

    + +
    +
    + + +
    +
    +
    +); diff --git a/app/javascript/mastodon/features/home_timeline/index.jsx b/app/javascript/mastodon/features/home_timeline/index.jsx index c9fe078755..ae98aec0a6 100644 --- a/app/javascript/mastodon/features/home_timeline/index.jsx +++ b/app/javascript/mastodon/features/home_timeline/index.jsx @@ -5,14 +5,16 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import classNames from 'classnames'; import { Helmet } from 'react-helmet'; -import { Link } from 'react-router-dom'; +import { List as ImmutableList } from 'immutable'; import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements'; import { IconWithBadge } from 'mastodon/components/icon_with_badge'; import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator'; import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container'; +import { me } from 'mastodon/initial_state'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { expandHomeTimeline } from '../../actions/timelines'; @@ -20,6 +22,7 @@ import Column from '../../components/column'; import ColumnHeader from '../../components/column_header'; import StatusListContainer from '../ui/containers/status_list_container'; +import { ExplorePrompt } from './components/explore_prompt'; import ColumnSettingsContainer from './containers/column_settings_container'; const messages = defineMessages({ @@ -28,12 +31,40 @@ const messages = defineMessages({ hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' }, }); +const getHomeFeedSpeed = createSelector([ + state => state.getIn(['timelines', 'home', 'items'], ImmutableList()), + state => state.getIn(['timelines', 'home', 'pendingItems'], ImmutableList()), + state => state.get('statuses'), +], (statusIds, pendingStatusIds, statusMap) => { + const recentStatusIds = pendingStatusIds.size > 0 ? pendingStatusIds : statusIds; + const statuses = recentStatusIds.filter(id => id !== null).map(id => statusMap.get(id)).filter(status => status?.get('account') !== me).take(20); + const oldest = new Date(statuses.getIn([statuses.size - 1, 'created_at'], 0)); + const newest = new Date(statuses.getIn([0, 'created_at'], 0)); + const averageGap = (newest - oldest) / (1000 * (statuses.size + 1)); // Average gap between posts on first page in seconds + + return { + gap: averageGap, + newest, + }; +}); + +const homeTooSlow = createSelector([ + state => state.getIn(['timelines', 'home', 'isLoading']), + state => state.getIn(['timelines', 'home', 'isPartial']), + getHomeFeedSpeed, +], (isLoading, isPartial, speed) => + !isLoading && !isPartial // Only if the home feed has finished loading + && (speed.gap > (30 * 60) // If the average gap between posts is more than 20 minutes + || (Date.now() - speed.newest) > (1000 * 3600)) // If the most recent post is from over an hour ago +); + const mapStateToProps = state => ({ hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, isPartial: state.getIn(['timelines', 'home', 'isPartial']), hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(), unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')), showAnnouncements: state.getIn(['announcements', 'show']), + tooSlow: homeTooSlow(state), }); class HomeTimeline extends PureComponent { @@ -52,6 +83,7 @@ class HomeTimeline extends PureComponent { hasAnnouncements: PropTypes.bool, unreadAnnouncements: PropTypes.number, showAnnouncements: PropTypes.bool, + tooSlow: PropTypes.bool, }; handlePin = () => { @@ -121,11 +153,11 @@ class HomeTimeline extends PureComponent { }; render () { - const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props; + const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props; const pinned = !!columnId; const { signedIn } = this.context.identity; - let announcementsButton = null; + let announcementsButton, banner; if (hasAnnouncements) { announcementsButton = ( @@ -141,6 +173,10 @@ class HomeTimeline extends PureComponent { ); } + if (tooSlow) { + banner = ; + } + return ( }} />} + emptyMessage={} bindToDocument={!multiColumn} /> ) : } diff --git a/app/javascript/mastodon/features/onboarding/components/step.jsx b/app/javascript/mastodon/features/onboarding/components/step.jsx index 0f478f26a3..379f433040 100644 --- a/app/javascript/mastodon/features/onboarding/components/step.jsx +++ b/app/javascript/mastodon/features/onboarding/components/step.jsx @@ -3,6 +3,8 @@ import PropTypes from 'prop-types'; import { Check } from 'mastodon/components/check'; import { Icon } from 'mastodon/components/icon'; +import ArrowSmallRight from './arrow_small_right'; + const Step = ({ label, description, icon, completed, onClick, href }) => { const content = ( <> @@ -15,11 +17,9 @@ const Step = ({ label, description, icon, completed, onClick, href }) => {

    {description}

    - {completed && ( -
    - -
    - )} +
    + {completed ? : } +
    ); diff --git a/app/javascript/mastodon/features/onboarding/follows.jsx b/app/javascript/mastodon/features/onboarding/follows.jsx index 8b4ad0b087..472a87f5ec 100644 --- a/app/javascript/mastodon/features/onboarding/follows.jsx +++ b/app/javascript/mastodon/features/onboarding/follows.jsx @@ -12,20 +12,11 @@ import Column from 'mastodon/components/column'; import ColumnBackButton from 'mastodon/components/column_back_button'; import { EmptyAccount } from 'mastodon/components/empty_account'; import Account from 'mastodon/containers/account_container'; -import { me } from 'mastodon/initial_state'; -import { makeGetAccount } from 'mastodon/selectors'; -import ProgressIndicator from './components/progress_indicator'; - -const mapStateToProps = () => { - const getAccount = makeGetAccount(); - - return state => ({ - account: getAccount(state, me), - suggestions: state.getIn(['suggestions', 'items']), - isLoading: state.getIn(['suggestions', 'isLoading']), - }); -}; +const mapStateToProps = state => ({ + suggestions: state.getIn(['suggestions', 'items']), + isLoading: state.getIn(['suggestions', 'isLoading']), +}); class Follows extends PureComponent { @@ -33,7 +24,6 @@ class Follows extends PureComponent { onBack: PropTypes.func, dispatch: PropTypes.func.isRequired, suggestions: ImmutablePropTypes.list, - account: ImmutablePropTypes.map, isLoading: PropTypes.bool, multiColumn: PropTypes.bool, }; @@ -49,7 +39,7 @@ class Follows extends PureComponent { } render () { - const { onBack, isLoading, suggestions, account, multiColumn } = this.props; + const { onBack, isLoading, suggestions, multiColumn } = this.props; let loadedContent; @@ -58,7 +48,7 @@ class Follows extends PureComponent { } else if (suggestions.isEmpty()) { loadedContent =
    ; } else { - loadedContent = suggestions.map(suggestion => ); + loadedContent = suggestions.map(suggestion => ); } return ( @@ -71,8 +61,6 @@ class Follows extends PureComponent {

    - -
    {loadedContent}
    diff --git a/app/javascript/mastodon/features/onboarding/index.jsx b/app/javascript/mastodon/features/onboarding/index.jsx index 7c8b38f23e..08ca4669e6 100644 --- a/app/javascript/mastodon/features/onboarding/index.jsx +++ b/app/javascript/mastodon/features/onboarding/index.jsx @@ -18,6 +18,7 @@ import { closeOnboarding } from 'mastodon/actions/onboarding'; import Column from 'mastodon/features/ui/components/column'; import { me } from 'mastodon/initial_state'; import { makeGetAccount } from 'mastodon/selectors'; +import { assetHost } from 'mastodon/utils/config'; import ArrowSmallRight from './components/arrow_small_right'; import Step from './components/step'; @@ -121,30 +122,26 @@ class Onboarding extends ImmutablePureComponent {
    0 && account.get('note').length > 0)} icon='address-book-o' label={} description={} /> = 7} icon='user-plus' label={} description={} /> - = 1} icon='pencil-square-o' label={} description={} /> + = 1} icon='pencil-square-o' label={} description={ }} />} /> } description={} />
    -

    +

    + - - + + -
    - -
    - -
    diff --git a/app/javascript/mastodon/features/onboarding/share.jsx b/app/javascript/mastodon/features/onboarding/share.jsx index 6871793026..c5b185a244 100644 --- a/app/javascript/mastodon/features/onboarding/share.jsx +++ b/app/javascript/mastodon/features/onboarding/share.jsx @@ -177,13 +177,13 @@ class Share extends PureComponent {
    + - + -
    diff --git a/app/javascript/mastodon/features/pinned_statuses/index.jsx b/app/javascript/mastodon/features/pinned_statuses/index.jsx index a93e82cfae..f09d5471e3 100644 --- a/app/javascript/mastodon/features/pinned_statuses/index.jsx +++ b/app/javascript/mastodon/features/pinned_statuses/index.jsx @@ -8,6 +8,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; +import { getStatusList } from 'mastodon/selectors'; + import { fetchPinnedStatuses } from '../../actions/pin_statuses'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import StatusList from '../../components/status_list'; @@ -18,7 +20,7 @@ const messages = defineMessages({ }); const mapStateToProps = state => ({ - statusIds: state.getIn(['status_lists', 'pins', 'items']), + statusIds: getStatusList(state, 'pins'), hasMore: !!state.getIn(['status_lists', 'pins', 'next']), }); diff --git a/app/javascript/mastodon/features/public_timeline/index.jsx b/app/javascript/mastodon/features/public_timeline/index.jsx index 01b02d4024..352baa8336 100644 --- a/app/javascript/mastodon/features/public_timeline/index.jsx +++ b/app/javascript/mastodon/features/public_timeline/index.jsx @@ -8,6 +8,7 @@ import { Helmet } from 'react-helmet'; import { connect } from 'react-redux'; import DismissableBanner from 'mastodon/components/dismissable_banner'; +import { domain } from 'mastodon/initial_state'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { connectPublicStream } from '../../actions/streaming'; @@ -142,11 +143,8 @@ class PublicTimeline extends PureComponent {
    - - - - } timelineId={`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`} onLoadMore={this.handleLoadMore} trackScroll={!pinned} diff --git a/app/javascript/mastodon/features/ui/components/header.jsx b/app/javascript/mastodon/features/ui/components/header.jsx index bb6747c00c..3d249e8d4f 100644 --- a/app/javascript/mastodon/features/ui/components/header.jsx +++ b/app/javascript/mastodon/features/ui/components/header.jsx @@ -1,14 +1,16 @@ import PropTypes from 'prop-types'; import { PureComponent } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import { Link, withRouter } from 'react-router-dom'; import { connect } from 'react-redux'; import { openModal } from 'mastodon/actions/modal'; +import { fetchServer } from 'mastodon/actions/server'; import { Avatar } from 'mastodon/components/avatar'; +import { Icon } from 'mastodon/components/icon'; import { WordmarkLogo, SymbolLogo } from 'mastodon/components/logo'; import { registrationsOpen, me } from 'mastodon/initial_state'; @@ -20,6 +22,10 @@ const Account = connect(state => ({ )); +const messages = defineMessages({ + search: { id: 'navigation_bar.search', defaultMessage: 'Search' }, +}); + const mapStateToProps = (state) => ({ signupUrl: state.getIn(['server', 'server', 'registrations', 'url'], null) || '/auth/sign_up', }); @@ -28,6 +34,9 @@ const mapDispatchToProps = (dispatch) => ({ openClosedRegistrationsModal() { dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' })); }, + dispatchServer() { + dispatch(fetchServer()); + } }); class Header extends PureComponent { @@ -40,18 +49,26 @@ class Header extends PureComponent { openClosedRegistrationsModal: PropTypes.func, location: PropTypes.object, signupUrl: PropTypes.string.isRequired, + dispatchServer: PropTypes.func, + intl: PropTypes.object.isRequired, }; + componentDidMount () { + const { dispatchServer } = this.props; + dispatchServer(); + } + render () { const { signedIn } = this.context.identity; - const { location, openClosedRegistrationsModal, signupUrl } = this.props; + const { location, openClosedRegistrationsModal, signupUrl, intl } = this.props; let content; if (signedIn) { content = ( <> - {location.pathname !== '/publish' && } + {location.pathname !== '/search' && } + {location.pathname !== '/publish' && } ); @@ -96,4 +113,4 @@ class Header extends PureComponent { } -export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Header)); +export default injectIntl(withRouter(connect(mapStateToProps, mapDispatchToProps)(Header))); diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx index 639d8a92d4..b0f86003f5 100644 --- a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx +++ b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx @@ -20,8 +20,7 @@ const messages = defineMessages({ home: { id: 'tabs_bar.home', defaultMessage: 'Home' }, notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, explore: { id: 'explore.title', defaultMessage: 'Explore' }, - local: { id: 'tabs_bar.local_timeline', defaultMessage: 'Local' }, - federated: { id: 'tabs_bar.federated_timeline', defaultMessage: 'Federated' }, + firehose: { id: 'column.firehose', defaultMessage: 'Live feeds' }, direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, @@ -43,6 +42,10 @@ class NavigationPanel extends Component { intl: PropTypes.object.isRequired, }; + isFirehoseActive = (match, location) => { + return match || location.pathname.startsWith('/public'); + }; + render () { const { intl } = this.props; const { signedIn, disabledAccountId } = this.context.identity; @@ -70,10 +73,7 @@ class NavigationPanel extends Component { {!signedIn && explorer} {(signedIn || timelinePreview) && ( - <> - - - + )} {signedIn && ( diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index abdfa184e4..2dd86b45ae 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -36,8 +36,7 @@ import { Status, GettingStarted, KeyboardShortcuts, - PublicTimeline, - CommunityTimeline, + Firehose, AccountTimeline, AccountGallery, HomeTimeline, @@ -192,8 +191,11 @@ class SwitchingColumnsArea extends PureComponent { - - + + + + + diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index a10991026a..873d91ee56 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -22,6 +22,10 @@ export function CommunityTimeline () { return import(/* webpackChunkName: "features/community_timeline" */'../../community_timeline'); } +export function Firehose () { + return import(/* webpackChunkName: "features/firehose" */'../../firehose'); +} + export function HashtagTimeline () { return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline'); } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 54a343abe6..056e2373f6 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -54,6 +54,7 @@ "account.mute_notifications_short": "Mute notifications", "account.mute_short": "Mute", "account.muted": "Muted", + "account.no_bio": "No description provided.", "account.open_original_page": "Open original page", "account.posts": "Posts", "account.posts_with_replies": "Posts and replies", @@ -115,6 +116,7 @@ "column.directory": "Browse profiles", "column.domain_blocks": "Blocked domains", "column.favourites": "Favourites", + "column.firehose": "Live feeds", "column.follow_requests": "Follow requests", "column.home": "Home", "column.lists": "Lists", @@ -152,7 +154,7 @@ "compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices", "compose_form.poll.switch_to_single": "Change poll to allow for a single choice", "compose_form.publish": "Publish", - "compose_form.publish_form": "Publish", + "compose_form.publish_form": "New post", "compose_form.publish_loud": "{publish}!", "compose_form.save_changes": "Save changes", "compose_form.sensitive.hide": "{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}", @@ -203,10 +205,10 @@ "disabled_account_banner.text": "Your account {disabledAccount} is currently disabled.", "dismissable_banner.community_timeline": "These are the most recent public posts from people whose accounts are hosted by {domain}.", "dismissable_banner.dismiss": "Dismiss", - "dismissable_banner.explore_links": "These news stories are being talked about by people on this and other servers of the decentralized network right now.", - "dismissable_banner.explore_statuses": "These posts from this and other servers in the decentralized network are gaining traction on this server right now.", - "dismissable_banner.explore_tags": "These hashtags are gaining traction among people on this and other servers of the decentralized network right now.", - "dismissable_banner.public_timeline": "These are the most recent public posts from people on this and other servers of the decentralized network that this server knows about.", + "dismissable_banner.explore_links": "These are news stories being shared the most on the social web today. Newer news stories posted by more different people are ranked higher.", + "dismissable_banner.explore_statuses": "These are posts from across the social web that are gaining traction today. Newer posts with more boosts and favourites are ranked higher.", + "dismissable_banner.explore_tags": "These are hashtags that are gaining traction on the social web today. Hashtags that are used by more different people are ranked higher.", + "dismissable_banner.public_timeline": "These are the most recent public posts from people on the social web that people on {domain} follow.", "embed.instructions": "Embed this post on your website by copying the code below.", "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Activity", @@ -238,8 +240,7 @@ "empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.", "empty_column.followed_tags": "You have not followed any hashtags yet. When you do, they will show up here.", "empty_column.hashtag": "There is nothing in this hashtag yet.", - "empty_column.home": "Your home timeline is empty! Follow more people to fill it up. {suggestions}", - "empty_column.home.suggestions": "See some suggestions", + "empty_column.home": "Your home timeline is empty! Follow more people to fill it up.", "empty_column.list": "There is nothing in this list yet. When members of this list publish new posts, they will appear here.", "empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.", "empty_column.mutes": "You haven't muted any users yet.", @@ -273,6 +274,9 @@ "filter_modal.select_filter.subtitle": "Use an existing category or create a new one", "filter_modal.select_filter.title": "Filter this post", "filter_modal.title.status": "Filter a post", + "firehose.all": "All", + "firehose.local": "This server", + "firehose.remote": "Other servers", "follow_request.authorize": "Authorize", "follow_request.reject": "Reject", "follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.", @@ -298,9 +302,13 @@ "hashtag.column_settings.tag_toggle": "Include additional tags for this column", "hashtag.follow": "Follow hashtag", "hashtag.unfollow": "Unfollow hashtag", + "home.actions.go_to_explore": "See what's trending", + "home.actions.go_to_suggestions": "Find people to follow", "home.column_settings.basic": "Basic", "home.column_settings.show_reblogs": "Show boosts", "home.column_settings.show_replies": "Show replies", + "home.explore_prompt.body": "Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. It's looking pretty quiet right now, so how about:", + "home.explore_prompt.title": "This is your home base within Mastodon.", "home.hide_announcements": "Hide announcements", "home.show_announcements": "Show announcements", "interaction_modal.description.favourite": "With an account on Mastodon, you can favourite this post to let the author know you appreciate it and save it for later.", @@ -459,28 +467,27 @@ "notifications_permission_banner.title": "Never miss a thing", "onboarding.action.back": "Take me back", "onboarding.actions.back": "Take me back", - "onboarding.actions.close": "Don't show this screen again", - "onboarding.actions.go_to_explore": "See what's trending", - "onboarding.actions.go_to_home": "Go to your home feed", + "onboarding.actions.go_to_explore": "Take me to trending", + "onboarding.actions.go_to_home": "Take me to my home feed", "onboarding.compose.template": "Hello #Mastodon!", "onboarding.follows.empty": "Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.", - "onboarding.follows.lead": "You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!", - "onboarding.follows.title": "Popular on Mastodon", + "onboarding.follows.lead": "Your home feed is the primary way to experience Mastodon. The more people you follow, the more active and interesting it will be. To get you started, here are some suggestions:", + "onboarding.follows.title": "Personalize your home feed", "onboarding.share.lead": "Let people know how they can find you on Mastodon!", "onboarding.share.message": "I'm {username} on #Mastodon! Come follow me at {url}", "onboarding.share.next_steps": "Possible next steps:", "onboarding.share.title": "Share your profile", - "onboarding.start.lead": "Your new Mastodon account is ready to go. Here's how you can make the most of it:", - "onboarding.start.skip": "Want to skip right ahead?", + "onboarding.start.lead": "You're now part of Mastodon, a unique, decentralized social media platform where you—not an algorithm—curate your own experience. Let's get you started on this new social frontier:", + "onboarding.start.skip": "Don't need help getting started?", "onboarding.start.title": "You've made it!", - "onboarding.steps.follow_people.body": "You curate your own home feed. Let's fill it with interesting people.", - "onboarding.steps.follow_people.title": "Find at least {count, plural, one {one person} other {# people}} to follow", - "onboarding.steps.publish_status.body": "Say hello to the world.", + "onboarding.steps.follow_people.body": "Following interesting people is what Mastodon is all about.", + "onboarding.steps.follow_people.title": "Personalize your home feed", + "onboarding.steps.publish_status.body": "Say hello to the world with text, photos, videos, or polls {emoji}", "onboarding.steps.publish_status.title": "Make your first post", - "onboarding.steps.setup_profile.body": "Others are more likely to interact with you with a filled out profile.", - "onboarding.steps.setup_profile.title": "Customize your profile", - "onboarding.steps.share_profile.body": "Let your friends know how to find you on Mastodon!", - "onboarding.steps.share_profile.title": "Share your profile", + "onboarding.steps.setup_profile.body": "Boost your interactions by having a comprehensive profile.", + "onboarding.steps.setup_profile.title": "Personalize your profile", + "onboarding.steps.share_profile.body": "Let your friends know how to find you on Mastodon", + "onboarding.steps.share_profile.title": "Share your Mastodon profile", "onboarding.tips.2fa": "Did you know? You can secure your account by setting up two-factor authentication in your account settings. It works with any TOTP app of your choice, no phone number necessary!", "onboarding.tips.accounts_from_other_servers": "Did you know? Since Mastodon is decentralized, some profiles you come across will be hosted on servers other than yours. And yet you can interact with them seamlessly! Their server is in the second half of their username!", "onboarding.tips.migration": "Did you know? If you feel like {domain} is not a great server choice for you in the future, you can move to another Mastodon server without losing your followers. You can even host your own server!", @@ -672,9 +679,7 @@ "subscribed_languages.target": "Change subscribed languages for {target}", "suggestions.dismiss": "Dismiss suggestion", "suggestions.header": "You might be interested in…", - "tabs_bar.federated_timeline": "Federated", "tabs_bar.home": "Home", - "tabs_bar.local_timeline": "Local", "tabs_bar.notifications": "Notifications", "time_remaining.days": "{number, plural, one {# day} other {# days}} left", "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index e9019611f5..4a1cc95b57 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -1,3 +1,5 @@ +import { Record as ImmutableRecord } from 'immutable'; + import { loadingBarReducer } from 'react-redux-loading-bar'; import { combineReducers } from 'redux-immutable'; @@ -96,6 +98,22 @@ const reducers = { reaction_deck, }; -const rootReducer = combineReducers(reducers); +// We want the root state to be an ImmutableRecord, which is an object with a defined list of keys, +// so it is properly typed and keys can be accessed using `state.` syntax. +// This will allow an easy conversion to a plain object once we no longer call `get` or `getIn` on the root state + +// By default with `combineReducers` it is a Collection, so we provide our own implementation to get a Record +const initialRootState = Object.fromEntries( + Object.entries(reducers).map(([name, reducer]) => [ + name, + reducer(undefined, { + // empty action + }), + ]) +); + +const RootStateRecord = ImmutableRecord(initialRootState, 'RootState'); + +const rootReducer = combineReducers(reducers, RootStateRecord); export { rootReducer }; diff --git a/app/javascript/mastodon/reducers/server.js b/app/javascript/mastodon/reducers/server.js index 486314c338..2bbf0f9a30 100644 --- a/app/javascript/mastodon/reducers/server.js +++ b/app/javascript/mastodon/reducers/server.js @@ -17,15 +17,15 @@ import { const initialState = ImmutableMap({ server: ImmutableMap({ - isLoading: true, + isLoading: false, }), extendedDescription: ImmutableMap({ - isLoading: true, + isLoading: false, }), domainBlocks: ImmutableMap({ - isLoading: true, + isLoading: false, isAvailable: true, items: ImmutableList(), }), diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index 3641c00a45..07d1bda0f4 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -79,6 +79,10 @@ const initialState = ImmutableMap({ }), }), + firehose: ImmutableMap({ + onlyMedia: false, + }), + community: ImmutableMap({ regex: ImmutableMap({ body: '', diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index b67734316b..f92e7fe48d 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -137,3 +137,7 @@ export const getAccountHidden = createSelector([ ], (hidden, followingOrRequested, isSelf) => { return hidden && !(isSelf || followingOrRequested); }); + +export const getStatusList = createSelector([ + (state, type) => state.getIn(['status_lists', type, 'items']), +], (items) => items.toList()); diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss index 4dd5e63612..cdf56ca066 100644 --- a/app/javascript/styles/mastodon-light/diff.scss +++ b/app/javascript/styles/mastodon-light/diff.scss @@ -5,19 +5,6 @@ html { scrollbar-color: $ui-base-color rgba($ui-base-color, 0.25); } -// Change the colors of button texts -.button { - color: $white; - - &.button-alternative-2 { - color: $white; - } - - &.button-tertiary { - color: $highlight-text-color; - } -} - .simple_form .button.button-tertiary { color: $highlight-text-color; } @@ -445,26 +432,6 @@ html { color: $white; } -.button.button-tertiary { - &:hover, - &:focus, - &:active { - color: $white; - } -} - -.button.button-secondary { - border-color: $darker-text-color; - color: $darker-text-color; - - &:hover, - &:focus, - &:active { - border-color: darken($darker-text-color, 8%); - color: darken($darker-text-color, 8%); - } -} - .flash-message.warning { color: lighten($gold-star, 16%); } @@ -662,11 +629,6 @@ html { border: 1px solid lighten($ui-base-color, 8%); } -.dismissable-banner { - border-left: 1px solid lighten($ui-base-color, 8%); - border-right: 1px solid lighten($ui-base-color, 8%); -} - .status__content, .reply-indicator__content { a { diff --git a/app/javascript/styles/mastodon-light/variables.scss b/app/javascript/styles/mastodon-light/variables.scss index cae065878c..250e200fc6 100644 --- a/app/javascript/styles/mastodon-light/variables.scss +++ b/app/javascript/styles/mastodon-light/variables.scss @@ -7,6 +7,12 @@ $classic-primary-color: #9baec8; $classic-secondary-color: #d9e1e8; $classic-highlight-color: #6364ff; +$blurple-600: #563acc; // Iris +$blurple-500: #6364ff; // Brand purple +$blurple-300: #858afa; // Faded Blue +$grey-600: #4e4c5a; // Trout +$grey-100: #dadaf3; // Topaz + // Differences $success-green: lighten(#3c754d, 8%); @@ -19,6 +25,13 @@ $ui-primary-color: #9bcbed; $ui-secondary-color: $classic-base-color !default; $ui-highlight-color: $classic-highlight-color !default; +$ui-button-secondary-color: $grey-600 !default; +$ui-button-secondary-border-color: $grey-600 !default; +$ui-button-secondary-focus-color: $white !default; + +$ui-button-tertiary-color: $blurple-500 !default; +$ui-button-tertiary-border-color: $blurple-500 !default; + $primary-text-color: $black !default; $darker-text-color: $classic-base-color !default; $highlight-text-color: darken($ui-highlight-color, 8%) !default; diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index dec8a65846..41f86eb12d 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -128,7 +128,6 @@ $content-width: 840px; } &.selected { - background: darken($ui-base-color, 2%); border-radius: 4px 0 0; } } @@ -146,13 +145,9 @@ $content-width: 840px; .simple-navigation-active-leaf a { color: $primary-text-color; - background-color: darken($ui-highlight-color, 2%); + background-color: $ui-highlight-color; border-bottom: 0; border-radius: 0; - - &:hover { - background-color: $ui-highlight-color; - } } } @@ -246,12 +241,6 @@ $content-width: 840px; font-weight: 700; color: $primary-text-color; background: $ui-highlight-color; - - &:hover, - &:focus, - &:active { - background: lighten($ui-highlight-color, 4%); - } } } } diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index f35e512c18..5b20f38608 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -47,11 +47,11 @@ } .button { - background-color: darken($ui-highlight-color, 2%); + background-color: $ui-button-background-color; border: 10px none; border-radius: 4px; box-sizing: border-box; - color: $primary-text-color; + color: $ui-button-color; cursor: pointer; display: inline-block; font-family: inherit; @@ -71,14 +71,14 @@ &:active, &:focus, &:hover { - background-color: $ui-highlight-color; + background-color: $ui-button-focus-background-color; } &--destructive { &:active, &:focus, &:hover { - background-color: $error-red; + background-color: $ui-button-destructive-focus-background-color; transition: none; } } @@ -108,38 +108,18 @@ outline: 0 !important; } - &.button-alternative { - color: $inverted-text-color; - background: $ui-primary-color; - - &:active, - &:focus, - &:hover { - background-color: lighten($ui-primary-color, 4%); - } - } - - &.button-alternative-2 { - background: $ui-base-lighter-color; - - &:active, - &:focus, - &:hover { - background-color: lighten($ui-base-lighter-color, 4%); - } - } - &.button-secondary { - color: $darker-text-color; + color: $ui-button-secondary-color; background: transparent; padding: 6px 17px; - border: 1px solid $ui-primary-color; + border: 1px solid $ui-button-secondary-border-color; &:active, &:focus, &:hover { - border-color: lighten($ui-primary-color, 4%); - color: lighten($darker-text-color, 4%); + border-color: $ui-button-secondary-focus-background-color; + color: $ui-button-secondary-focus-color; + background-color: $ui-button-secondary-focus-background-color; text-decoration: none; } @@ -151,14 +131,14 @@ &.button-tertiary { background: transparent; padding: 6px 17px; - color: $highlight-text-color; - border: 1px solid $highlight-text-color; + color: $ui-button-tertiary-color; + border: 1px solid $ui-button-tertiary-border-color; &:active, &:focus, &:hover { - background: $ui-highlight-color; - color: $primary-text-color; + background-color: $ui-button-tertiary-focus-background-color; + color: $ui-button-tertiary-focus-color; border: 0; padding: 7px 18px; } @@ -1569,12 +1549,37 @@ body > [data-popper-placement] { } &__note { + font-size: 14px; + font-weight: 400; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; - -webkit-line-clamp: 2; + -webkit-line-clamp: 1; -webkit-box-orient: vertical; - color: $ui-secondary-color; + margin-top: 10px; + color: $darker-text-color; + + &--missing { + color: $dark-text-color; + } + + p { + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + } + + a { + color: inherit; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } } } @@ -2672,13 +2677,15 @@ $ui-header-height: 55px; .onboarding__link { display: flex; align-items: center; + justify-content: space-between; gap: 10px; color: $highlight-text-color; background: lighten($ui-base-color, 4%); border-radius: 8px; - padding: 10px; + padding: 10px 15px; box-sizing: border-box; - font-size: 17px; + font-size: 14px; + font-weight: 500; height: 56px; text-decoration: none; @@ -2740,6 +2747,7 @@ $ui-header-height: 55px; align-items: center; gap: 10px; padding: 10px; + padding-inline-end: 15px; margin-bottom: 2px; text-decoration: none; text-align: start; @@ -2752,14 +2760,14 @@ $ui-header-height: 55px; &__icon { flex: 0 0 auto; - background: $ui-base-color; border-radius: 50%; display: none; align-items: center; justify-content: center; width: 36px; height: 36px; - color: $dark-text-color; + color: $highlight-text-color; + font-size: 1.2rem; @media screen and (width >= 600px) { display: flex; @@ -2783,16 +2791,33 @@ $ui-header-height: 55px; } } + &__go { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 21px; + height: 21px; + color: $highlight-text-color; + font-size: 17px; + + svg { + height: 1.5em; + width: auto; + } + } + &__description { flex: 1 1 auto; - line-height: 18px; + line-height: 20px; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; h6 { - color: $primary-text-color; - font-weight: 700; + color: $highlight-text-color; + font-weight: 500; + font-size: 14px; overflow: hidden; text-overflow: ellipsis; } @@ -3156,7 +3181,7 @@ $ui-header-height: 55px; .column-back-button { box-sizing: border-box; width: 100%; - background: lighten($ui-base-color, 4%); + background: $ui-base-color; border-radius: 4px 4px 0 0; color: $highlight-text-color; cursor: pointer; @@ -3164,6 +3189,7 @@ $ui-header-height: 55px; font-size: 16px; line-height: inherit; border: 0; + border-bottom: 1px solid lighten($ui-base-color, 8%); text-align: unset; padding: 15px; margin: 0; @@ -3176,7 +3202,7 @@ $ui-header-height: 55px; } .column-header__back-button { - background: lighten($ui-base-color, 4%); + background: $ui-base-color; border: 0; font-family: inherit; color: $highlight-text-color; @@ -3211,7 +3237,7 @@ $ui-header-height: 55px; padding: 15px; position: absolute; inset-inline-end: 0; - top: -48px; + top: -50px; } .react-toggle { @@ -3892,7 +3918,8 @@ a.status-card.compact:hover { .column-header { display: flex; font-size: 16px; - background: lighten($ui-base-color, 4%); + background: $ui-base-color; + border-bottom: 1px solid lighten($ui-base-color, 8%); border-radius: 4px 4px 0 0; flex: 0 0 auto; cursor: pointer; @@ -3947,7 +3974,7 @@ a.status-card.compact:hover { } .column-header__button { - background: lighten($ui-base-color, 4%); + background: $ui-base-color; border: 0; color: $darker-text-color; cursor: pointer; @@ -3955,16 +3982,15 @@ a.status-card.compact:hover { padding: 0 15px; &:hover { - color: lighten($darker-text-color, 7%); + color: lighten($darker-text-color, 4%); } &.active { color: $primary-text-color; - background: lighten($ui-base-color, 8%); + background: lighten($ui-base-color, 4%); &:hover { color: $primary-text-color; - background: lighten($ui-base-color, 8%); } } @@ -3978,6 +4004,7 @@ a.status-card.compact:hover { max-height: 70vh; overflow: hidden; overflow-y: auto; + border-bottom: 1px solid lighten($ui-base-color, 8%); color: $darker-text-color; transition: max-height 150ms ease-in-out, opacity 300ms linear; opacity: 1; @@ -3997,13 +4024,13 @@ a.status-card.compact:hover { height: 0; background: transparent; border: 0; - border-top: 1px solid lighten($ui-base-color, 12%); + border-top: 1px solid lighten($ui-base-color, 8%); margin: 10px 0; } } .column-header__collapsible-inner { - background: lighten($ui-base-color, 8%); + background: $ui-base-color; padding: 15px; } @@ -4428,17 +4455,13 @@ a.status-card.compact:hover { color: $primary-text-color; margin-bottom: 4px; display: block; - background-color: $base-overlay-background; - text-transform: uppercase; + background-color: rgba($black, 0.45); + backdrop-filter: blur(10px) saturate(180%) contrast(75%) brightness(70%); font-size: 11px; - font-weight: 500; - padding: 4px; + text-transform: uppercase; + font-weight: 700; + padding: 2px 6px; border-radius: 4px; - opacity: 0.7; - - &:hover { - opacity: 1; - } } .setting-toggle { @@ -4498,6 +4521,7 @@ a.status-card.compact:hover { .follow_requests-unlocked_explanation { background: darken($ui-base-color, 4%); + border-bottom: 1px solid lighten($ui-base-color, 8%); contain: initial; flex-grow: 0; } @@ -5852,15 +5876,15 @@ a.status-card.compact:hover { } .button.button-secondary { - border-color: $inverted-text-color; - color: $inverted-text-color; + border-color: $ui-button-secondary-border-color; + color: $ui-button-secondary-color; flex: 0 0 auto; &:hover, &:focus, &:active { - border-color: lighten($inverted-text-color, 15%); - color: lighten($inverted-text-color, 15%); + border-color: $ui-button-secondary-focus-background-color; + color: $ui-button-secondary-focus-color; } } @@ -6203,6 +6227,7 @@ a.status-card.compact:hover { display: block; color: $white; background: rgba($black, 0.65); + backdrop-filter: blur(10px) saturate(180%) contrast(75%) brightness(70%); padding: 2px 6px; border-radius: 4px; font-size: 11px; @@ -6896,24 +6921,6 @@ a.status-card.compact:hover { } } } - - &.directory__section-headline { - background: darken($ui-base-color, 2%); - border-bottom-color: transparent; - - a, - button { - &.active { - &::before { - display: none; - } - - &::after { - border-color: transparent transparent darken($ui-base-color, 7%); - } - } - } - } } .filter-form { @@ -7466,7 +7473,6 @@ noscript { .account__header { overflow: hidden; - background: lighten($ui-base-color, 4%); &.inactive { opacity: 0.5; @@ -7488,6 +7494,7 @@ noscript { height: 145px; position: relative; background: darken($ui-base-color, 4%); + border-bottom: 1px solid lighten($ui-base-color, 8%); img { object-fit: cover; @@ -7501,7 +7508,7 @@ noscript { &__bar { position: relative; padding: 0 20px; - border-bottom: 1px solid lighten($ui-base-color, 12%); + border-bottom: 1px solid lighten($ui-base-color, 8%); .avatar { display: block; @@ -7510,7 +7517,7 @@ noscript { .account__avatar { background: darken($ui-base-color, 8%); - border: 2px solid lighten($ui-base-color, 4%); + border: 2px solid $ui-base-color; } } } @@ -8838,27 +8845,80 @@ noscript { } .dismissable-banner { - background: $ui-base-color; - border-bottom: 1px solid lighten($ui-base-color, 8%); - display: flex; - align-items: center; - gap: 30px; + position: relative; + margin: 10px; + margin-bottom: 5px; + border-radius: 8px; + border: 1px solid $highlight-text-color; + background: rgba($highlight-text-color, 0.15); + padding-inline-end: 45px; + overflow: hidden; + + &__background-image { + width: 125%; + position: absolute; + bottom: -25%; + inset-inline-end: -25%; + z-index: -1; + opacity: 0.15; + mix-blend-mode: luminosity; + } &__message { flex: 1 1 auto; - padding: 20px 15px; - cursor: default; - font-size: 14px; - line-height: 18px; + padding: 15px; + font-size: 15px; + line-height: 22px; + font-weight: 500; color: $primary-text-color; + + p { + margin-bottom: 15px; + + &:last-child { + margin-bottom: 0; + } + } + + h1 { + color: $highlight-text-color; + font-size: 22px; + line-height: 33px; + font-weight: 700; + margin-bottom: 15px; + } + + &__actions { + display: flex; + flex-wrap: wrap; + gap: 4px; + + &__wrapper { + display: flex; + margin-top: 30px; + } + + .button { + display: block; + flex-grow: 1; + } + } + + .button-tertiary { + background: rgba($ui-base-color, 0.15); + backdrop-filter: blur(8px); + } } &__action { - padding: 15px; - flex: 0 0 auto; - display: flex; - align-items: center; - justify-content: center; + position: absolute; + inset-inline-end: 0; + top: 0; + padding: 10px; + + .icon-button { + color: $highlight-text-color; + } } } diff --git a/app/javascript/styles/mastodon/dashboard.scss b/app/javascript/styles/mastodon/dashboard.scss index bc34c6ec0a..36a7f44253 100644 --- a/app/javascript/styles/mastodon/dashboard.scss +++ b/app/javascript/styles/mastodon/dashboard.scss @@ -81,7 +81,7 @@ display: flex; align-items: baseline; border-radius: 4px; - background: darken($ui-highlight-color, 2%); + background: $ui-button-background-color; color: $primary-text-color; transition: all 100ms ease-in; font-size: 14px; @@ -94,7 +94,7 @@ &:active, &:focus, &:hover { - background-color: $ui-highlight-color; + background-color: $ui-button-focus-background-color; transition: all 200ms ease-out; } diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 81a656a602..f69b699a0a 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -511,8 +511,8 @@ code { width: 100%; border: 0; border-radius: 4px; - background: darken($ui-highlight-color, 2%); - color: $primary-text-color; + background: $ui-button-background-color; + color: $ui-button-color; font-size: 18px; line-height: inherit; height: auto; @@ -534,7 +534,7 @@ code { &:active, &:focus, &:hover { - background-color: $ui-highlight-color; + background-color: $ui-button-focus-background-color; } &:disabled:hover { @@ -542,15 +542,12 @@ code { } &.negative { - background: $error-value-color; - - &:hover { - background-color: lighten($error-value-color, 5%); - } + background: $ui-button-destructive-background-color; + &:hover, &:active, &:focus { - background-color: darken($error-value-color, 5%); + background-color: $ui-button-destructive-focus-background-color; } } } diff --git a/app/javascript/styles/mastodon/variables.scss b/app/javascript/styles/mastodon/variables.scss index 73d3aa2a94..ea3c905320 100644 --- a/app/javascript/styles/mastodon/variables.scss +++ b/app/javascript/styles/mastodon/variables.scss @@ -1,8 +1,16 @@ // Commonly used web colors $black: #000000; // Black $white: #ffffff; // White +$red-600: #b7253d !default; // Deep Carmine +$red-500: #df405a !default; // Cerise +$blurple-600: #563acc; // Iris +$blurple-500: #6364ff; // Brand purple +$blurple-300: #858afa; // Faded Blue +$grey-600: #4e4c5a; // Trout +$grey-100: #dadaf3; // Topaz + $success-green: #79bd9a !default; // Padua -$error-red: #df405a !default; // Cerise +$error-red: $red-500 !default; // Cerise $warning-red: #ff5050 !default; // Sunset Orange $gold-star: #ca8f04 !default; // Dark Goldenrod $kmyblue: #29a5f7 !default; @@ -32,6 +40,22 @@ $ui-base-lighter-color: lighten( $ui-primary-color: $classic-primary-color !default; // Lighter $ui-secondary-color: $classic-secondary-color !default; // Lightest $ui-highlight-color: $classic-highlight-color !default; +$ui-button-color: $white !default; +$ui-button-background-color: $blurple-500 !default; +$ui-button-focus-background-color: $blurple-600 !default; + +$ui-button-secondary-color: $grey-100 !default; +$ui-button-secondary-border-color: $grey-100 !default; +$ui-button-secondary-focus-background-color: $grey-600 !default; +$ui-button-secondary-focus-color: $white !default; + +$ui-button-tertiary-color: $blurple-300 !default; +$ui-button-tertiary-border-color: $blurple-300 !default; +$ui-button-tertiary-focus-background-color: $blurple-600 !default; +$ui-button-tertiary-focus-color: $white !default; + +$ui-button-destructive-background-color: $red-500 !default; +$ui-button-destructive-focus-background-color: $red-600 !default; // Variables for texts $primary-text-color: $white !default; @@ -40,6 +64,7 @@ $dark-text-color: $ui-base-lighter-color !default; $secondary-text-color: $ui-secondary-color !default; $highlight-text-color: lighten($ui-highlight-color, 8%) !default; $action-button-color: $ui-base-lighter-color !default; +$action-button-focus-color: lighten($ui-base-lighter-color, 4%) !default; $passive-text-color: $gold-star !default; $active-passive-text-color: $success-green !default; diff --git a/app/lib/attachment_batch.rb b/app/lib/attachment_batch.rb new file mode 100644 index 0000000000..1f87b94336 --- /dev/null +++ b/app/lib/attachment_batch.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +class AttachmentBatch + # Maximum amount of objects you can delete in an S3 API call. It's + # important to remember that this does not correspond to the number + # of records in the batch, since records can have multiple attachments + LIMIT = 1_000 + + # Attributes generated and maintained by Paperclip (not all of them + # are always used on every class, however) + NULLABLE_ATTRIBUTES = %w( + file_name + content_type + file_size + fingerprint + created_at + updated_at + ).freeze + + # Styles that are always present even when not explicitly defined + BASE_STYLES = %i(original).freeze + + attr_reader :klass, :records, :storage_mode + + def initialize(klass, records) + @klass = klass + @records = records + @storage_mode = Paperclip::Attachment.default_options[:storage] + @attachment_names = klass.attachment_definitions.keys + end + + def delete + remove_files + batch.delete_all + end + + def clear + remove_files + batch.update_all(nullified_attributes) # rubocop:disable Rails/SkipsModelValidations + end + + private + + def batch + klass.where(id: records.map(&:id)) + end + + def remove_files + keys = [] + + logger.debug { "Preparing to delete attachments for #{records.size} records" } + + records.each do |record| + @attachment_names.each do |attachment_name| + attachment = record.public_send(attachment_name) + styles = BASE_STYLES | attachment.styles.keys + + next if attachment.blank? + + styles.each do |style| + case @storage_mode + when :s3 + logger.debug { "Adding #{attachment.path(style)} to batch for deletion" } + keys << attachment.style_name_as_path(style) + when :filesystem + logger.debug { "Deleting #{attachment.path(style)}" } + path = attachment.path(style) + FileUtils.remove_file(path, true) + + begin + FileUtils.rmdir(File.dirname(path), parents: true) + rescue Errno::EEXIST, Errno::ENOTEMPTY, Errno::ENOENT, Errno::EINVAL, Errno::ENOTDIR, Errno::EACCES + # Ignore failure to delete a directory, with the same ignored errors + # as Paperclip + end + when :fog + logger.debug { "Deleting #{attachment.path(style)}" } + attachment.directory.files.new(key: attachment.path(style)).destroy + end + end + end + end + + return unless storage_mode == :s3 + + # We can batch deletes over S3, but there is a limit of how many + # objects can be processed at once, so we have to potentially + # separate them into multiple calls. + + keys.each_slice(LIMIT) do |keys_slice| + logger.debug { "Deleting #{keys_slice.size} objects" } + + bucket.delete_objects(delete: { + objects: keys_slice.map { |key| { key: key } }, + quiet: true, + }) + end + end + + def bucket + @bucket ||= records.first.public_send(@attachment_names.first).s3_bucket + end + + def nullified_attributes + @attachment_names.flat_map { |attachment_name| NULLABLE_ATTRIBUTES.map { |attribute| "#{attachment_name}_#{attribute}" } & klass.column_names }.index_with(nil) + end + + def logger + Rails.logger + end +end diff --git a/app/lib/vacuum/media_attachments_vacuum.rb b/app/lib/vacuum/media_attachments_vacuum.rb index 7c0a85a9d9..7b21c84bbc 100644 --- a/app/lib/vacuum/media_attachments_vacuum.rb +++ b/app/lib/vacuum/media_attachments_vacuum.rb @@ -15,15 +15,15 @@ class Vacuum::MediaAttachmentsVacuum private def vacuum_cached_files! - media_attachments_past_retention_period.find_each do |media_attachment| - media_attachment.file.destroy - media_attachment.thumbnail.destroy - media_attachment.save + media_attachments_past_retention_period.find_in_batches do |media_attachments| + AttachmentBatch.new(MediaAttachment, media_attachments).clear end end def vacuum_orphaned_records! - orphaned_media_attachments.in_batches.destroy_all + orphaned_media_attachments.find_in_batches do |media_attachments| + AttachmentBatch.new(MediaAttachment, media_attachments).delete + end end def media_attachments_past_retention_period diff --git a/app/models/account.rb b/app/models/account.rb index 2e166fee98..41830211e8 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -117,7 +117,7 @@ class Account < ApplicationRecord scope :matches_username, ->(value) { where('lower((username)::text) ~ lower(?)', value.to_s) } scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches_regexp(value.to_s)) } scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } - scope :without_unapproved, -> { left_outer_joins(:user).remote.or(left_outer_joins(:user).merge(User.approved.confirmed)) } + scope :without_unapproved, -> { left_outer_joins(:user).merge(User.approved.confirmed).or(remote) } scope :searchable, -> { without_unapproved.without_suspended.where(moved_to_account_id: nil) } scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).left_outer_joins(:account_stat) } scope :followable_by, ->(account) { joins(arel_table.join(Follow.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(Follow.arel_table[:target_account_id]).and(Follow.arel_table[:account_id].eq(account.id))).join_sources).where(Follow.arel_table[:id].eq(nil)).joins(arel_table.join(FollowRequest.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(FollowRequest.arel_table[:target_account_id]).and(FollowRequest.arel_table[:account_id].eq(account.id))).join_sources).where(FollowRequest.arel_table[:id].eq(nil)) } diff --git a/app/models/concerns/account_search.rb b/app/models/concerns/account_search.rb index 67d77793fe..46cf68e1a3 100644 --- a/app/models/concerns/account_search.rb +++ b/app/models/concerns/account_search.rb @@ -106,6 +106,17 @@ module AccountSearch LIMIT :limit OFFSET :offset SQL + def searchable_text + PlainTextFormatter.new(note, local?).to_s if discoverable? + end + + def searchable_properties + [].tap do |properties| + properties << 'bot' if bot? + properties << 'verified' if fields.any?(&:verified?) + end + end + class_methods do def search_for(terms, limit: 10, offset: 0) tsquery = generate_query_for_search(terms) diff --git a/app/models/webhook.rb b/app/models/webhook.rb index c46fce743e..14f33c5fc4 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -24,6 +24,8 @@ class Webhook < ApplicationRecord status.updated ).freeze + attr_writer :current_account + scope :enabled, -> { where(enabled: true) } validates :url, presence: true, url: true @@ -31,6 +33,7 @@ class Webhook < ApplicationRecord validates :events, presence: true validate :validate_events + validate :validate_permissions validate :validate_template before_validation :strip_events @@ -48,12 +51,31 @@ class Webhook < ApplicationRecord update!(enabled: false) end + def required_permissions + events.map { |event| Webhook.permission_for_event(event) } + end + + def self.permission_for_event(event) + case event + when 'account.approved', 'account.created', 'account.updated' + :manage_users + when 'report.created' + :manage_reports + when 'status.created', 'status.updated' + :view_devops + end + end + private def validate_events errors.add(:events, :invalid) if events.any? { |e| EVENTS.exclude?(e) } end + def validate_permissions + errors.add(:events, :invalid_permissions) if defined?(@current_account) && required_permissions.any? { |permission| !@current_account.user_role.can?(permission) } + end + def validate_template return if template.blank? diff --git a/app/policies/webhook_policy.rb b/app/policies/webhook_policy.rb index a2199a333f..577e891b66 100644 --- a/app/policies/webhook_policy.rb +++ b/app/policies/webhook_policy.rb @@ -14,7 +14,7 @@ class WebhookPolicy < ApplicationPolicy end def update? - role.can?(:manage_webhooks) + role.can?(:manage_webhooks) && record.required_permissions.all? { |permission| role.can?(permission) } end def enable? @@ -30,6 +30,6 @@ class WebhookPolicy < ApplicationPolicy end def destroy? - role.can?(:manage_webhooks) + role.can?(:manage_webhooks) && record.required_permissions.all? { |permission| role.can?(permission) } end end diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb index dfc3a45f8f..3c9e73c124 100644 --- a/app/services/account_search_service.rb +++ b/app/services/account_search_service.rb @@ -9,12 +9,11 @@ class AccountSearchService < BaseService MIN_QUERY_LENGTH = 5 def call(query, account = nil, options = {}) - @acct_hint = query&.start_with?('@') - @query = query&.strip&.gsub(/\A@/, '') - @limit = options[:limit].to_i - @offset = options[:offset].to_i - @options = options - @account = account + @query = query&.strip&.gsub(/\A@/, '') + @limit = options[:limit].to_i + @offset = options[:offset].to_i + @options = options + @account = account search_service_results.compact.uniq end @@ -72,8 +71,8 @@ class AccountSearchService < BaseService end def from_elasticsearch - must_clauses = [{ multi_match: { query: terms_for_query, fields: likely_acct? ? %w(acct.edge_ngram acct) : %w(acct.edge_ngram acct display_name.edge_ngram display_name), type: 'most_fields', operator: 'and' } }] - should_clauses = [] + must_clauses = must_clause + should_clauses = should_clause if account return [] if options[:following] && following_ids.empty? @@ -88,7 +87,7 @@ class AccountSearchService < BaseService query = { bool: { must: must_clauses, should: should_clauses } } functions = [reputation_score_function, followers_score_function, time_distance_function] - records = AccountsIndex.query(function_score: { query: query, functions: functions, boost_mode: 'multiply', score_mode: 'avg' }) + records = AccountsIndex.query(function_score: { query: query, functions: functions }) .limit(limit_for_non_exact_results) .offset(offset) .objects @@ -133,6 +132,36 @@ class AccountSearchService < BaseService } end + def must_clause + fields = %w(username username.* display_name display_name.*) + fields << 'text' << 'text.*' if options[:use_searchable_text] + + [ + { + multi_match: { + query: terms_for_query, + fields: fields, + type: 'best_fields', + operator: 'or', + }, + }, + ] + end + + def should_clause + [ + { + multi_match: { + query: terms_for_query, + fields: %w(username username.* display_name display_name.*), + type: 'best_fields', + operator: 'and', + boost: 10, + }, + }, + ] + end + def following_ids @following_ids ||= account.active_relationships.pluck(:target_account_id) + [account.id] end @@ -182,8 +211,4 @@ class AccountSearchService < BaseService def username_complete? query.include?('@') && "@#{query}".match?(MENTION_ONLY_RE) end - - def likely_acct? - @acct_hint || username_complete? - end end diff --git a/app/services/clear_domain_media_service.rb b/app/services/clear_domain_media_service.rb index 9e70ebe51c..7bf2d62fb0 100644 --- a/app/services/clear_domain_media_service.rb +++ b/app/services/clear_domain_media_service.rb @@ -10,14 +10,6 @@ class ClearDomainMediaService < BaseService private - def invalidate_association_caches!(status_ids) - # Normally, associated models of a status are immutable (except for accounts) - # so they are aggressively cached. After updating the media attachments to no - # longer point to a local file, we need to clear the cache to make those - # changes appear in the API and UI - Rails.cache.delete_multi(status_ids.map { |id| "statuses/#{id}" }) - end - def clear_media! clear_account_images! clear_account_attachments! @@ -25,31 +17,21 @@ class ClearDomainMediaService < BaseService end def clear_account_images! - blocked_domain_accounts.reorder(nil).find_each do |account| - account.avatar.destroy if account.avatar&.exists? - account.header.destroy if account.header&.exists? - account.save + blocked_domain_accounts.reorder(nil).find_in_batches do |accounts| + AttachmentBatch.new(Account, accounts).clear end end def clear_account_attachments! media_from_blocked_domain.reorder(nil).find_in_batches do |attachments| - affected_status_ids = [] - - attachments.each do |attachment| - affected_status_ids << attachment.status_id if attachment.status_id.present? - - attachment.file.destroy if attachment.file&.exists? - attachment.type = :unknown - attachment.save - end - - invalidate_association_caches!(affected_status_ids) unless affected_status_ids.empty? + AttachmentBatch.new(MediaAttachment, attachments).clear end end def clear_emojos! - emojis_from_blocked_domains.destroy_all + emojis_from_blocked_domains.find_in_batches do |custom_emojis| + AttachmentBatch.new(CustomEmoji, custom_emojis).delete + end end def blocked_domain diff --git a/app/services/resolve_url_service.rb b/app/services/resolve_url_service.rb index d8e795f3b0..d6e528654f 100644 --- a/app/services/resolve_url_service.rb +++ b/app/services/resolve_url_service.rb @@ -89,13 +89,28 @@ class ResolveURLService < BaseService def process_local_url recognized_params = Rails.application.routes.recognize_path(@url) - return unless recognized_params[:action] == 'show' + case recognized_params[:controller] + when 'statuses' + return unless recognized_params[:action] == 'show' - if recognized_params[:controller] == 'statuses' status = Status.find_by(id: recognized_params[:id]) check_local_status(status) - elsif recognized_params[:controller] == 'accounts' + when 'accounts' + return unless recognized_params[:action] == 'show' + Account.find_local(recognized_params[:username]) + when 'home' + return unless recognized_params[:action] == 'index' && recognized_params[:username_with_domain].present? + + if recognized_params[:any]&.match?(/\A[0-9]+\Z/) + status = Status.find_by(id: recognized_params[:any]) + check_local_status(status) + elsif recognized_params[:any].blank? + username, domain = recognized_params[:username_with_domain].gsub(/\A@/, '').split('@') + return unless username.present? && domain.present? + + Account.find_remote(username, domain) + end end end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index ae65520b7a..f8a96ddb7b 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -5,12 +5,12 @@ class SearchService < BaseService @query = query&.strip pull_query_commands - @account = account - @options = options - @limit = limit.to_i - @offset = options[:type].blank? ? 0 : options[:offset].to_i - @resolve = options[:resolve] || false - @searchability = options[:searchability] || 'public' + @account = account + @options = options + @limit = limit.to_i + @offset = options[:type].blank? ? 0 : options[:offset].to_i + @resolve = options[:resolve] || false + @following = options[:following] || false default_results.tap do |results| next if @query.blank? || @limit.zero? @@ -36,7 +36,9 @@ class SearchService < BaseService @account, limit: @limit, resolve: @resolve, - offset: @offset + offset: @offset, + use_searchable_text: true, + following: @following ) end diff --git a/app/views/admin/domain_blocks/confirm_suspension.html.haml b/app/views/admin/domain_blocks/confirm_suspension.html.haml index fa9272c77b..1d28ba1579 100644 --- a/app/views/admin/domain_blocks/confirm_suspension.html.haml +++ b/app/views/admin/domain_blocks/confirm_suspension.html.haml @@ -4,7 +4,7 @@ - content_for :page_title do = t('.title', domain: Addressable::IDNA.to_unicode(@domain_block.domain)) -= simple_form_for @domain_block, url: admin_domain_blocks_path(@domain_block) do |f| += simple_form_for @domain_block, url: admin_domain_blocks_path, method: :post do |f| %p.hint= t('.preamble_html', domain: Addressable::IDNA.to_unicode(@domain_block.domain)) %ul.hint diff --git a/app/views/admin/webhooks/_form.html.haml b/app/views/admin/webhooks/_form.html.haml index 8d019ff43b..c870e943f4 100644 --- a/app/views/admin/webhooks/_form.html.haml +++ b/app/views/admin/webhooks/_form.html.haml @@ -5,7 +5,7 @@ = f.input :url, wrapper: :with_block_label, input_html: { placeholder: 'https://' } .fields-group - = f.input :events, collection: Webhook::EVENTS, wrapper: :with_block_label, include_blank: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' + = f.input :events, collection: Webhook::EVENTS, wrapper: :with_block_label, include_blank: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', disabled: Webhook::EVENTS.filter { |event| !current_user.role.can?(Webhook.permission_for_event(event)) } .fields-group = f.input :template, wrapper: :with_block_label, input_html: { placeholder: '{ "content": "Hello {{object.username}}" }' } diff --git a/config/boot.rb b/config/boot.rb index 4e379e7db5..3a1d1d6d24 100644 --- a/config/boot.rb +++ b/config/boot.rb @@ -6,12 +6,4 @@ end ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) require 'bundler/setup' # Set up gems listed in the Gemfile. -require 'bootsnap' # Speed up boot time by caching expensive operations. - -Bootsnap.setup( - cache_dir: File.expand_path('../tmp/cache', __dir__), - development_mode: ENV.fetch('RAILS_ENV', 'development') == 'development', - load_path_cache: true, - compile_cache_iseq: false, - compile_cache_yaml: false -) +require 'bootsnap/setup' # Speed up boot time by caching expensive operations. diff --git a/config/locales/activerecord.en.yml b/config/locales/activerecord.en.yml index 8aee15659f..a53c7c6e9e 100644 --- a/config/locales/activerecord.en.yml +++ b/config/locales/activerecord.en.yml @@ -53,3 +53,7 @@ en: position: elevated: cannot be higher than your current role own_role: cannot be changed with your current role + webhook: + attributes: + events: + invalid_permissions: cannot include events you don't have the rights to diff --git a/config/routes.rb b/config/routes.rb index 4a0eb12b4d..a16257070e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,6 +12,7 @@ Rails.application.routes.draw do /home /public /public/local + /public/remote /conversations /lists/(*any) /notifications @@ -105,8 +106,6 @@ Rails.application.routes.draw do resources :followers, only: [:index], controller: :follower_accounts resources :following, only: [:index], controller: :following_accounts - resource :follow, only: [:create], controller: :account_follow - resource :unfollow, only: [:create], controller: :account_unfollow resource :outbox, only: [:show], module: :activitypub resource :inbox, only: [:create], module: :activitypub @@ -167,7 +166,7 @@ Rails.application.routes.draw do get '/backups/:id/download', to: 'backups#download', as: :download_backup, format: false resource :authorize_interaction, only: [:show, :create] - resource :share, only: [:show, :create] + resource :share, only: [:show] draw(:admin) diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 7aec7be550..3f8d689057 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -3,7 +3,7 @@ namespace :admin do get '/dashboard', to: 'dashboard#index' - resources :domain_allows, only: [:new, :create, :show, :destroy] + resources :domain_allows, only: [:new, :create, :destroy] resources :domain_blocks, only: [:new, :create, :destroy, :update, :edit] do collection do post :batch @@ -31,7 +31,7 @@ namespace :admin do end resources :action_logs, only: [:index] - resources :warning_presets, except: [:new] + resources :warning_presets, except: [:new, :show] resources :media_attachments, only: [:index] resources :announcements, except: [:show] do @@ -76,7 +76,7 @@ namespace :admin do end end - resources :rules + resources :rules, only: [:index, :create, :edit, :update, :destroy] resources :webhooks do member do diff --git a/config/routes/api.rb b/config/routes/api.rb index 57543be8fb..a4b54687cc 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -89,6 +89,7 @@ namespace :api, format: false do resources :conversations, only: [:index, :destroy] do member do post :read + post :unread end end diff --git a/crowdin.yml b/crowdin.yml index 7cb74c4010..5cd4a744aa 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -1,6 +1,5 @@ +skip_untranslated_strings: 1 commit_message: '[ci skip]' -skip_untranslated_strings: true - files: - source: /app/javascript/mastodon/locales/en.json translation: /app/javascript/mastodon/locales/%two_letters_code%.json diff --git a/db/migrate/20230630145300_add_index_backups_on_user_id.rb b/db/migrate/20230630145300_add_index_backups_on_user_id.rb new file mode 100644 index 0000000000..c3d2f17707 --- /dev/null +++ b/db/migrate/20230630145300_add_index_backups_on_user_id.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddIndexBackupsOnUserId < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + def change + add_index :backups, :user_id, algorithm: :concurrently + end +end diff --git a/db/migrate/20230702131023_add_superapp_index_to_applications.rb b/db/migrate/20230702131023_add_superapp_index_to_applications.rb new file mode 100644 index 0000000000..f301127a3e --- /dev/null +++ b/db/migrate/20230702131023_add_superapp_index_to_applications.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddSuperappIndexToApplications < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + def change + add_index :oauth_applications, :superapp, where: 'superapp = true', algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index 9169d163db..df556f55ac 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,7 +12,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2023_06_05_085711) do +ActiveRecord::Schema.define(version: 2023_07_02_151753) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -346,6 +346,7 @@ ActiveRecord::Schema.define(version: 2023_06_05_085711) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.bigint "dump_file_size" + t.index ["user_id"], name: "index_backups_on_user_id" end create_table "blocks", force: :cascade do |t| @@ -804,6 +805,7 @@ ActiveRecord::Schema.define(version: 2023_06_05_085711) do t.bigint "owner_id" t.boolean "confidential", default: true, null: false t.index ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type" + t.index ["superapp"], name: "index_oauth_applications_on_superapp", where: "(superapp = true)" t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true end @@ -1218,6 +1220,7 @@ ActiveRecord::Schema.define(version: 2023_06_05_085711) do t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, opclass: :text_pattern_ops, where: "(reset_password_token IS NOT NULL)" t.index ["role_id"], name: "index_users_on_role_id", where: "(role_id IS NOT NULL)" + t.index ["unconfirmed_email"], name: "index_users_on_unconfirmed_email", where: "(unconfirmed_email IS NOT NULL)" end create_table "web_push_subscriptions", force: :cascade do |t| diff --git a/spec/controllers/admin/change_emails_controller_spec.rb b/spec/controllers/admin/change_emails_controller_spec.rb index 503862a7b9..dd8a764b64 100644 --- a/spec/controllers/admin/change_emails_controller_spec.rb +++ b/spec/controllers/admin/change_emails_controller_spec.rb @@ -23,7 +23,8 @@ RSpec.describe Admin::ChangeEmailsController do describe 'GET #update' do before do - allow(UserMailer).to receive(:confirmation_instructions).and_return(double('email', deliver_later: nil)) + allow(UserMailer).to receive(:confirmation_instructions) + .and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil)) end it 'returns http success' do diff --git a/spec/controllers/admin/confirmations_controller_spec.rb b/spec/controllers/admin/confirmations_controller_spec.rb index 181616a66e..9559160786 100644 --- a/spec/controllers/admin/confirmations_controller_spec.rb +++ b/spec/controllers/admin/confirmations_controller_spec.rb @@ -38,7 +38,7 @@ RSpec.describe Admin::ConfirmationsController do let!(:user) { Fabricate(:user, confirmed_at: confirmed_at) } before do - allow(UserMailer).to receive(:confirmation_instructions) { double(:email, deliver_later: nil) } + allow(UserMailer).to receive(:confirmation_instructions) { instance_double(ActionMailer::MessageDelivery, deliver_later: nil) } end context 'when email is not confirmed' do diff --git a/spec/controllers/admin/disputes/appeals_controller_spec.rb b/spec/controllers/admin/disputes/appeals_controller_spec.rb index 99b19298c6..3c3f23f529 100644 --- a/spec/controllers/admin/disputes/appeals_controller_spec.rb +++ b/spec/controllers/admin/disputes/appeals_controller_spec.rb @@ -19,7 +19,8 @@ RSpec.describe Admin::Disputes::AppealsController do let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } before do - allow(UserMailer).to receive(:appeal_approved).and_return(double('email', deliver_later: nil)) + allow(UserMailer).to receive(:appeal_approved) + .and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil)) post :approve, params: { id: appeal.id } end @@ -40,7 +41,8 @@ RSpec.describe Admin::Disputes::AppealsController do let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } before do - allow(UserMailer).to receive(:appeal_rejected).and_return(double('email', deliver_later: nil)) + allow(UserMailer).to receive(:appeal_rejected) + .and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil)) post :reject, params: { id: appeal.id } end diff --git a/spec/controllers/admin/domain_allows_controller_spec.rb b/spec/controllers/admin/domain_allows_controller_spec.rb index 6b0453476a..6f82f322b5 100644 --- a/spec/controllers/admin/domain_allows_controller_spec.rb +++ b/spec/controllers/admin/domain_allows_controller_spec.rb @@ -37,7 +37,7 @@ RSpec.describe Admin::DomainAllowsController do describe 'DELETE #destroy' do it 'disallows the domain' do - service = double(call: true) + service = instance_double(UnallowDomainService, call: true) allow(UnallowDomainService).to receive(:new).and_return(service) domain_allow = Fabricate(:domain_allow) delete :destroy, params: { id: domain_allow.id } diff --git a/spec/controllers/admin/domain_blocks_controller_spec.rb b/spec/controllers/admin/domain_blocks_controller_spec.rb index d499aa64ce..fb7fb2957f 100644 --- a/spec/controllers/admin/domain_blocks_controller_spec.rb +++ b/spec/controllers/admin/domain_blocks_controller_spec.rb @@ -213,7 +213,7 @@ RSpec.describe Admin::DomainBlocksController do describe 'DELETE #destroy' do it 'unblocks the domain' do - service = double(call: true) + service = instance_double(UnblockDomainService, call: true) allow(UnblockDomainService).to receive(:new).and_return(service) domain_block = Fabricate(:domain_block) delete :destroy, params: { id: domain_block.id } diff --git a/spec/controllers/admin/reports/actions_controller_spec.rb b/spec/controllers/admin/reports/actions_controller_spec.rb index 701855f92e..1f3951516d 100644 --- a/spec/controllers/admin/reports/actions_controller_spec.rb +++ b/spec/controllers/admin/reports/actions_controller_spec.rb @@ -62,17 +62,10 @@ describe Admin::Reports::ActionsController do end shared_examples 'common behavior' do - it 'closes the report' do - expect { subject }.to change { report.reload.action_taken? }.from(false).to(true) - end + it 'closes the report and redirects' do + expect { subject }.to mark_report_action_taken.and create_target_account_strike - it 'creates a strike with the expected text' do - expect { subject }.to change { report.target_account.strikes.count }.by(1) expect(report.target_account.strikes.last.text).to eq text - end - - it 'redirects' do - subject expect(response).to redirect_to(admin_reports_path) end @@ -81,20 +74,21 @@ describe Admin::Reports::ActionsController do { report_id: report.id } end - it 'closes the report' do - expect { subject }.to change { report.reload.action_taken? }.from(false).to(true) - end + it 'closes the report and redirects' do + expect { subject }.to mark_report_action_taken.and create_target_account_strike - it 'creates a strike with the expected text' do - expect { subject }.to change { report.target_account.strikes.count }.by(1) expect(report.target_account.strikes.last.text).to eq '' - end - - it 'redirects' do - subject expect(response).to redirect_to(admin_reports_path) end end + + def mark_report_action_taken + change { report.reload.action_taken? }.from(false).to(true) + end + + def create_target_account_strike + change { report.target_account.strikes.count }.by(1) + end end shared_examples 'all action types' do diff --git a/spec/controllers/admin/webhooks_controller_spec.rb b/spec/controllers/admin/webhooks_controller_spec.rb index 074956c555..0ccfbbcc6e 100644 --- a/spec/controllers/admin/webhooks_controller_spec.rb +++ b/spec/controllers/admin/webhooks_controller_spec.rb @@ -48,7 +48,7 @@ describe Admin::WebhooksController do end context 'with an existing record' do - let!(:webhook) { Fabricate :webhook } + let!(:webhook) { Fabricate(:webhook, events: ['account.created', 'report.created']) } describe 'GET #show' do it 'returns http success and renders view' do diff --git a/spec/controllers/api/v1/directories_controller_spec.rb b/spec/controllers/api/v1/directories_controller_spec.rb index b18aedc4d1..5e21802e7a 100644 --- a/spec/controllers/api/v1/directories_controller_spec.rb +++ b/spec/controllers/api/v1/directories_controller_spec.rb @@ -5,19 +5,124 @@ require 'rails_helper' describe Api::V1::DirectoriesController do render_views - let(:user) { Fabricate(:user) } + let(:user) { Fabricate(:user, confirmed_at: nil) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:follows') } - let(:account) { Fabricate(:account) } before do allow(controller).to receive(:doorkeeper_token) { token } end describe 'GET #show' do - it 'returns http success' do - get :show + context 'with no params' do + before do + _local_unconfirmed_account = Fabricate( + :account, + domain: nil, + user: Fabricate(:user, confirmed_at: nil, approved: true), + username: 'local_unconfirmed' + ) - expect(response).to have_http_status(200) + local_unapproved_account = Fabricate( + :account, + domain: nil, + user: Fabricate(:user, confirmed_at: 10.days.ago), + username: 'local_unapproved' + ) + local_unapproved_account.user.update(approved: false) + + _local_undiscoverable_account = Fabricate( + :account, + domain: nil, + user: Fabricate(:user, confirmed_at: 10.days.ago, approved: true), + discoverable: false, + username: 'local_undiscoverable' + ) + + excluded_from_timeline_account = Fabricate( + :account, + domain: 'host.example', + discoverable: true, + username: 'remote_excluded_from_timeline' + ) + Fabricate(:block, account: user.account, target_account: excluded_from_timeline_account) + + _domain_blocked_account = Fabricate( + :account, + domain: 'test.example', + discoverable: true, + username: 'remote_domain_blocked' + ) + Fabricate(:account_domain_block, account: user.account, domain: 'test.example') + end + + it 'returns only the local discoverable account' do + local_discoverable_account = Fabricate( + :account, + domain: nil, + user: Fabricate(:user, confirmed_at: 10.days.ago, approved: true), + discoverable: true, + username: 'local_discoverable' + ) + + eligible_remote_account = Fabricate( + :account, + domain: 'host.example', + discoverable: true, + username: 'eligible_remote' + ) + + get :show + + expect(response).to have_http_status(200) + expect(body_as_json.size).to eq(2) + expect(body_as_json.first[:id]).to include(eligible_remote_account.id.to_s) + expect(body_as_json.second[:id]).to include(local_discoverable_account.id.to_s) + end + end + + context 'when asking for local accounts only' do + it 'returns only the local accounts' do + user = Fabricate(:user, confirmed_at: 10.days.ago, approved: true) + local_account = Fabricate(:account, domain: nil, user: user) + remote_account = Fabricate(:account, domain: 'host.example') + + get :show, params: { local: '1' } + + expect(response).to have_http_status(200) + expect(body_as_json.size).to eq(1) + expect(body_as_json.first[:id]).to include(local_account.id.to_s) + expect(response.body).to_not include(remote_account.id.to_s) + end + end + + context 'when ordered by active' do + it 'returns accounts in order of most recent status activity' do + status_old = Fabricate(:status) + travel_to 10.seconds.from_now + status_new = Fabricate(:status) + + get :show, params: { order: 'active' } + + expect(response).to have_http_status(200) + expect(body_as_json.size).to eq(2) + expect(body_as_json.first[:id]).to include(status_new.account.id.to_s) + expect(body_as_json.second[:id]).to include(status_old.account.id.to_s) + end + end + + context 'when ordered by new' do + it 'returns accounts in order of creation' do + account_old = Fabricate(:account) + travel_to 10.seconds.from_now + account_new = Fabricate(:account) + + get :show, params: { order: 'new' } + + expect(response).to have_http_status(200) + expect(body_as_json.size).to eq(2) + expect(body_as_json.first[:id]).to include(account_new.id.to_s) + expect(body_as_json.second[:id]).to include(account_old.id.to_s) + end end end end diff --git a/spec/controllers/api/v1/emails/confirmations_controller_spec.rb b/spec/controllers/api/v1/emails/confirmations_controller_spec.rb index 219b5075df..80d6c8799d 100644 --- a/spec/controllers/api/v1/emails/confirmations_controller_spec.rb +++ b/spec/controllers/api/v1/emails/confirmations_controller_spec.rb @@ -130,5 +130,13 @@ RSpec.describe Api::V1::Emails::ConfirmationsController do end end end + + context 'without an oauth token and an authentication cookie' do + it 'returns http unauthorized' do + get :check + + expect(response).to have_http_status(401) + end + end end end diff --git a/spec/controllers/api/v1/reports_controller_spec.rb b/spec/controllers/api/v1/reports_controller_spec.rb index 0eb9ce1709..01b7e4a71c 100644 --- a/spec/controllers/api/v1/reports_controller_spec.rb +++ b/spec/controllers/api/v1/reports_controller_spec.rb @@ -23,7 +23,8 @@ RSpec.describe Api::V1::ReportsController do let(:rule_ids) { nil } before do - allow(AdminMailer).to receive(:new_report).and_return(double('email', deliver_later: nil)) + allow(AdminMailer).to receive(:new_report) + .and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil)) post :create, params: { status_ids: [status.id], account_id: target_account.id, comment: 'reasons', category: category, rule_ids: rule_ids, forward: forward } end diff --git a/spec/controllers/api/v1/statuses/histories_controller_spec.rb b/spec/controllers/api/v1/statuses/histories_controller_spec.rb index 00677f1d2c..99384c8ed5 100644 --- a/spec/controllers/api/v1/statuses/histories_controller_spec.rb +++ b/spec/controllers/api/v1/statuses/histories_controller_spec.rb @@ -23,6 +23,7 @@ describe Api::V1::Statuses::HistoriesController do it 'returns http success' do expect(response).to have_http_status(200) + expect(body_as_json.size).to_not be 0 end end end diff --git a/spec/controllers/api/v1/suggestions_controller_spec.rb b/spec/controllers/api/v1/suggestions_controller_spec.rb deleted file mode 100644 index c61ce0ec05..0000000000 --- a/spec/controllers/api/v1/suggestions_controller_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Api::V1::SuggestionsController do - render_views - - let(:user) { Fabricate(:user) } - let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read write') } - - before do - allow(controller).to receive(:doorkeeper_token) { token } - end - - describe 'GET #index' do - let(:bob) { Fabricate(:account) } - let(:jeff) { Fabricate(:account) } - - before do - PotentialFriendshipTracker.record(user.account_id, bob.id, :reblog) - PotentialFriendshipTracker.record(user.account_id, jeff.id, :favourite) - - get :index - end - - it 'returns http success' do - expect(response).to have_http_status(200) - end - - it 'returns accounts' do - json = body_as_json - - expect(json.size).to be >= 1 - expect(json.pluck(:id)).to include(*[bob, jeff].map { |i| i.id.to_s }) - end - end -end diff --git a/spec/controllers/api/v2/search_controller_spec.rb b/spec/controllers/api/v2/search_controller_spec.rb index bfabe8cc17..a3b92fc37a 100644 --- a/spec/controllers/api/v2/search_controller_spec.rb +++ b/spec/controllers/api/v2/search_controller_spec.rb @@ -14,13 +14,40 @@ RSpec.describe Api::V2::SearchController do end describe 'GET #index' do - before do - get :index, params: { q: 'test' } - end + let!(:bob) { Fabricate(:account, username: 'bob_test') } + let!(:ana) { Fabricate(:account, username: 'ana_test') } + let!(:tom) { Fabricate(:account, username: 'tom_test') } + let(:params) { { q: 'test' } } it 'returns http success' do + get :index, params: params + expect(response).to have_http_status(200) end + + context 'when searching accounts' do + let(:params) { { q: 'test', type: 'accounts' } } + + it 'returns all matching accounts' do + get :index, params: params + + expect(body_as_json[:accounts].pluck(:id)).to contain_exactly(bob.id.to_s, ana.id.to_s, tom.id.to_s) + end + + context 'with following=true' do + let(:params) { { q: 'test', type: 'accounts', following: 'true' } } + + before do + user.account.follow!(ana) + end + + it 'returns only the followed accounts' do + get :index, params: params + + expect(body_as_json[:accounts].pluck(:id)).to contain_exactly(ana.id.to_s) + end + end + end end end diff --git a/spec/controllers/api/web/embeds_controller_spec.rb b/spec/controllers/api/web/embeds_controller_spec.rb index b0c48a5aed..8c4e1a8f26 100644 --- a/spec/controllers/api/web/embeds_controller_spec.rb +++ b/spec/controllers/api/web/embeds_controller_spec.rb @@ -26,7 +26,7 @@ describe Api::Web::EmbedsController do context 'when fails to find status' do let(:url) { 'https://host.test/oembed.html' } - let(:service_instance) { double('fetch_oembed_service') } + let(:service_instance) { instance_double(FetchOEmbedService) } before do allow(FetchOEmbedService).to receive(:new) { service_instance } diff --git a/spec/controllers/auth/sessions_controller_spec.rb b/spec/controllers/auth/sessions_controller_spec.rb index 5b7d5d5cd4..c727a76333 100644 --- a/spec/controllers/auth/sessions_controller_spec.rb +++ b/spec/controllers/auth/sessions_controller_spec.rb @@ -127,7 +127,8 @@ RSpec.describe Auth::SessionsController do before do allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return(current_ip) - allow(UserMailer).to receive(:suspicious_sign_in).and_return(double('email', deliver_later!: nil)) + allow(UserMailer).to receive(:suspicious_sign_in) + .and_return(instance_double(ActionMailer::MessageDelivery, deliver_later!: nil)) user.update(current_sign_in_at: 1.month.ago) post :create, params: { user: { email: user.email, password: user.password } } end diff --git a/spec/controllers/authorize_interactions_controller_spec.rb b/spec/controllers/authorize_interactions_controller_spec.rb index e521039410..098c25ba32 100644 --- a/spec/controllers/authorize_interactions_controller_spec.rb +++ b/spec/controllers/authorize_interactions_controller_spec.rb @@ -28,7 +28,7 @@ describe AuthorizeInteractionsController do end it 'renders error when account cant be found' do - service = double + service = instance_double(ResolveAccountService) allow(ResolveAccountService).to receive(:new).and_return(service) allow(service).to receive(:call).with('missing@hostname').and_return(nil) @@ -40,7 +40,7 @@ describe AuthorizeInteractionsController do it 'sets resource from url' do account = Fabricate(:account) - service = double + service = instance_double(ResolveURLService) allow(ResolveURLService).to receive(:new).and_return(service) allow(service).to receive(:call).with('http://example.com').and_return(account) @@ -52,7 +52,7 @@ describe AuthorizeInteractionsController do it 'sets resource from acct uri' do account = Fabricate(:account) - service = double + service = instance_double(ResolveAccountService) allow(ResolveAccountService).to receive(:new).and_return(service) allow(service).to receive(:call).with('found@hostname').and_return(account) @@ -82,7 +82,7 @@ describe AuthorizeInteractionsController do end it 'shows error when account not found' do - service = double + service = instance_double(ResolveAccountService) allow(ResolveAccountService).to receive(:new).and_return(service) allow(service).to receive(:call).with('user@hostname').and_return(nil) @@ -94,7 +94,7 @@ describe AuthorizeInteractionsController do it 'follows account when found' do target_account = Fabricate(:account) - service = double + service = instance_double(ResolveAccountService) allow(ResolveAccountService).to receive(:new).and_return(service) allow(service).to receive(:call).with('user@hostname').and_return(target_account) diff --git a/spec/controllers/disputes/appeals_controller_spec.rb b/spec/controllers/disputes/appeals_controller_spec.rb index d0e1cd3908..a0f9c7b910 100644 --- a/spec/controllers/disputes/appeals_controller_spec.rb +++ b/spec/controllers/disputes/appeals_controller_spec.rb @@ -14,7 +14,8 @@ RSpec.describe Disputes::AppealsController do let(:strike) { Fabricate(:account_warning, target_account: current_user.account) } before do - allow(AdminMailer).to receive(:new_appeal).and_return(double('email', deliver_later: nil)) + allow(AdminMailer).to receive(:new_appeal) + .and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil)) post :create, params: { strike_id: strike.id, appeal: { text: 'Foo' } } end diff --git a/spec/controllers/statuses_controller_spec.rb b/spec/controllers/statuses_controller_spec.rb index 1885814cda..bd98929c02 100644 --- a/spec/controllers/statuses_controller_spec.rb +++ b/spec/controllers/statuses_controller_spec.rb @@ -75,23 +75,11 @@ describe StatusesController do context 'with HTML' do let(:format) { 'html' } - it 'returns http success' do + it 'renders status successfully', :aggregate_failures do expect(response).to have_http_status(200) - end - - it 'returns Link header' do expect(response.headers['Link'].to_s).to include 'activity+json' - end - - it 'returns Vary header' do expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' - end - - it 'returns public Cache-Control header' do expect(response.headers['Cache-Control']).to include 'public' - end - - it 'renders status' do expect(response).to render_template(:show) expect(response.body).to include status.text end @@ -100,25 +88,13 @@ describe StatusesController do context 'with JSON' do let(:format) { 'json' } - it 'returns http success' do - expect(response).to have_http_status(200) - end - - it 'returns Link header' do - expect(response.headers['Link'].to_s).to include 'activity+json' - end - - it 'returns Vary header' do - expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' - end - it_behaves_like 'cacheable response' - it 'returns Content-Type header' do + it 'renders ActivityPub Note object successfully', :aggregate_failures do + expect(response).to have_http_status(200) + expect(response.headers['Link'].to_s).to include 'activity+json' + expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' expect(response.headers['Content-Type']).to include 'application/activity+json' - end - - it 'renders ActivityPub Note object' do json = body_as_json expect(json[:content]).to include status.text end @@ -199,23 +175,11 @@ describe StatusesController do context 'with HTML' do let(:format) { 'html' } - it 'returns http success' do + it 'renders status successfully', :aggregate_failures do expect(response).to have_http_status(200) - end - - it 'returns Link header' do expect(response.headers['Link'].to_s).to include 'activity+json' - end - - it 'returns Vary header' do expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' - end - - it 'returns private Cache-Control header' do expect(response.headers['Cache-Control']).to include 'private' - end - - it 'renders status' do expect(response).to render_template(:show) expect(response.body).to include status.text end @@ -224,27 +188,12 @@ describe StatusesController do context 'with JSON' do let(:format) { 'json' } - it 'returns http success' do + it 'renders ActivityPub Note object successfully', :aggregate_failures do expect(response).to have_http_status(200) - end - - it 'returns Link header' do expect(response.headers['Link'].to_s).to include 'activity+json' - end - - it 'returns Vary header' do expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' - end - - it 'returns private Cache-Control header' do expect(response.headers['Cache-Control']).to include 'private' - end - - it 'returns Content-Type header' do expect(response.headers['Content-Type']).to include 'application/activity+json' - end - - it 'renders ActivityPub Note object' do json = body_as_json expect(json[:content]).to include status.text end @@ -263,23 +212,11 @@ describe StatusesController do context 'with HTML' do let(:format) { 'html' } - it 'returns http success' do + it 'renders status successfully', :aggregate_failures do expect(response).to have_http_status(200) - end - - it 'returns Link header' do expect(response.headers['Link'].to_s).to include 'activity+json' - end - - it 'returns Vary header' do expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' - end - - it 'returns private Cache-Control header' do expect(response.headers['Cache-Control']).to include 'private' - end - - it 'renders status' do expect(response).to render_template(:show) expect(response.body).to include status.text end @@ -288,27 +225,12 @@ describe StatusesController do context 'with JSON' do let(:format) { 'json' } - it 'returns http success' do + it 'renders ActivityPub Note object successfully', :aggregate_failures do expect(response).to have_http_status(200) - end - - it 'returns Link header' do expect(response.headers['Link'].to_s).to include 'activity+json' - end - - it 'returns Vary header' do expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' - end - - it 'returns private Cache-Control header' do expect(response.headers['Cache-Control']).to include 'private' - end - - it 'returns Content-Type header' do expect(response.headers['Content-Type']).to include 'application/activity+json' - end - - it 'renders ActivityPub Note object' do json = body_as_json expect(json[:content]).to include status.text end @@ -350,23 +272,11 @@ describe StatusesController do context 'with HTML' do let(:format) { 'html' } - it 'returns http success' do + it 'renders status successfully', :aggregate_failures do expect(response).to have_http_status(200) - end - - it 'returns Link header' do expect(response.headers['Link'].to_s).to include 'activity+json' - end - - it 'returns Vary header' do expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' - end - - it 'returns private Cache-Control header' do expect(response.headers['Cache-Control']).to include 'private' - end - - it 'renders status' do expect(response).to render_template(:show) expect(response.body).to include status.text end @@ -375,27 +285,12 @@ describe StatusesController do context 'with JSON' do let(:format) { 'json' } - it 'returns http success' do + it 'renders ActivityPub Note object successfully' do expect(response).to have_http_status(200) - end - - it 'returns Link header' do expect(response.headers['Link'].to_s).to include 'activity+json' - end - - it 'returns Vary header' do expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' - end - - it 'returns private Cache-Control header' do expect(response.headers['Cache-Control']).to include 'private' - end - - it 'returns Content-Type header' do expect(response.headers['Content-Type']).to include 'application/activity+json' - end - - it 'renders ActivityPub Note object' do json = body_as_json expect(json[:content]).to include status.text end @@ -463,23 +358,11 @@ describe StatusesController do context 'with HTML' do let(:format) { 'html' } - it 'returns http success' do + it 'renders status successfully', :aggregate_failures do expect(response).to have_http_status(200) - end - - it 'returns Link header' do expect(response.headers['Link'].to_s).to include 'activity+json' - end - - it 'returns Vary header' do expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' - end - - it 'returns private Cache-Control header' do expect(response.headers['Cache-Control']).to include 'private' - end - - it 'renders status' do expect(response).to render_template(:show) expect(response.body).to include status.text end @@ -488,25 +371,13 @@ describe StatusesController do context 'with JSON' do let(:format) { 'json' } - it 'returns http success' do - expect(response).to have_http_status(200) - end - - it 'returns Link header' do - expect(response.headers['Link'].to_s).to include 'activity+json' - end - - it 'returns Vary header' do - expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' - end - it_behaves_like 'cacheable response' - it 'returns Content-Type header' do + it 'renders ActivityPub Note object successfully', :aggregate_failures do + expect(response).to have_http_status(200) + expect(response.headers['Link'].to_s).to include 'activity+json' + expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' expect(response.headers['Content-Type']).to include 'application/activity+json' - end - - it 'renders ActivityPub Note object' do json = body_as_json expect(json[:content]).to include status.text end @@ -525,23 +396,11 @@ describe StatusesController do context 'with HTML' do let(:format) { 'html' } - it 'returns http success' do + it 'renders status successfully', :aggregate_failures do expect(response).to have_http_status(200) - end - - it 'returns Link header' do expect(response.headers['Link'].to_s).to include 'activity+json' - end - - it 'returns Vary header' do expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' - end - - it 'returns private Cache-Control header' do expect(response.headers['Cache-Control']).to include 'private' - end - - it 'renders status' do expect(response).to render_template(:show) expect(response.body).to include status.text end @@ -550,27 +409,12 @@ describe StatusesController do context 'with JSON' do let(:format) { 'json' } - it 'returns http success' do + it 'renders ActivityPub Note object successfully' do expect(response).to have_http_status(200) - end - - it 'returns Link header' do expect(response.headers['Link'].to_s).to include 'activity+json' - end - - it 'returns Vary header' do expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' - end - - it 'returns private Cache-Control header' do expect(response.headers['Cache-Control']).to include 'private' - end - - it 'returns Content-Type header' do expect(response.headers['Content-Type']).to include 'application/activity+json' - end - - it 'renders ActivityPub Note object' do json = body_as_json expect(json[:content]).to include status.text end @@ -612,23 +456,11 @@ describe StatusesController do context 'with HTML' do let(:format) { 'html' } - it 'returns http success' do + it 'renders status successfully', :aggregate_failures do expect(response).to have_http_status(200) - end - - it 'returns Link header' do expect(response.headers['Link'].to_s).to include 'activity+json' - end - - it 'returns Vary header' do expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' - end - - it 'returns private Cache-Control header' do expect(response.headers['Cache-Control']).to include 'private' - end - - it 'renders status' do expect(response).to render_template(:show) expect(response.body).to include status.text end @@ -637,27 +469,12 @@ describe StatusesController do context 'with JSON' do let(:format) { 'json' } - it 'returns http success' do + it 'renders ActivityPub Note object', :aggregate_failures do expect(response).to have_http_status(200) - end - - it 'returns Link header' do expect(response.headers['Link'].to_s).to include 'activity+json' - end - - it 'returns Vary header' do expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' - end - - it 'returns private Cache-Control header' do expect(response.headers['Cache-Control']).to include 'private' - end - - it 'returns Content-Type header' do expect(response.headers['Content-Type']).to include 'application/activity+json' - end - - it 'renders ActivityPub Note object' do json = body_as_json expect(json[:content]).to include status.text end @@ -933,23 +750,11 @@ describe StatusesController do get :embed, params: { account_username: status.account.username, id: status.id } end - it 'returns http success' do + it 'renders status successfully', :aggregate_failures do expect(response).to have_http_status(200) - end - - it 'returns Link header' do expect(response.headers['Link'].to_s).to include 'activity+json' - end - - it 'returns Vary header' do expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' - end - - it 'returns public Cache-Control header' do expect(response.headers['Cache-Control']).to include 'public' - end - - it 'renders status' do expect(response).to render_template(:embed) expect(response.body).to include status.text end diff --git a/spec/features/admin/domain_blocks_spec.rb b/spec/features/admin/domain_blocks_spec.rb index 3cf60a48ae..c77d604ebd 100644 --- a/spec/features/admin/domain_blocks_spec.rb +++ b/spec/features/admin/domain_blocks_spec.rb @@ -53,7 +53,7 @@ describe 'blocking domains through the moderation interface' do # Confirming updates the block click_on I18n.t('admin.domain_blocks.confirm_suspension.confirm') - expect(domain_block.reload.severity).to eq 'silence' + expect(domain_block.reload.severity).to eq 'suspend' end end @@ -72,7 +72,7 @@ describe 'blocking domains through the moderation interface' do # Confirming updates the block click_on I18n.t('admin.domain_blocks.confirm_suspension.confirm') - expect(domain_block.reload.severity).to eq 'silence' + expect(domain_block.reload.severity).to eq 'suspend' end end end diff --git a/spec/helpers/statuses_helper_spec.rb b/spec/helpers/statuses_helper_spec.rb index 105da7e1b1..b7824ca604 100644 --- a/spec/helpers/statuses_helper_spec.rb +++ b/spec/helpers/statuses_helper_spec.rb @@ -117,42 +117,42 @@ describe StatusesHelper do describe '#style_classes' do it do - status = double(reblog?: false) + status = instance_double(Status, reblog?: false) classes = helper.style_classes(status, false, false, false) expect(classes).to eq 'entry' end it do - status = double(reblog?: true) + status = instance_double(Status, reblog?: true) classes = helper.style_classes(status, false, false, false) expect(classes).to eq 'entry entry-reblog' end it do - status = double(reblog?: false) + status = instance_double(Status, reblog?: false) classes = helper.style_classes(status, true, false, false) expect(classes).to eq 'entry entry-predecessor' end it do - status = double(reblog?: false) + status = instance_double(Status, reblog?: false) classes = helper.style_classes(status, false, true, false) expect(classes).to eq 'entry entry-successor' end it do - status = double(reblog?: false) + status = instance_double(Status, reblog?: false) classes = helper.style_classes(status, false, false, true) expect(classes).to eq 'entry entry-center' end it do - status = double(reblog?: true) + status = instance_double(Status, reblog?: true) classes = helper.style_classes(status, true, true, true) expect(classes).to eq 'entry entry-predecessor entry-reblog entry-successor entry-center' @@ -161,35 +161,35 @@ describe StatusesHelper do describe '#microformats_classes' do it do - status = double(reblog?: false) + status = instance_double(Status, reblog?: false) classes = helper.microformats_classes(status, false, false) expect(classes).to eq '' end it do - status = double(reblog?: false) + status = instance_double(Status, reblog?: false) classes = helper.microformats_classes(status, true, false) expect(classes).to eq 'p-in-reply-to' end it do - status = double(reblog?: false) + status = instance_double(Status, reblog?: false) classes = helper.microformats_classes(status, false, true) expect(classes).to eq 'p-comment' end it do - status = double(reblog?: true) + status = instance_double(Status, reblog?: true) classes = helper.microformats_classes(status, true, false) expect(classes).to eq 'p-in-reply-to p-repost-of' end it do - status = double(reblog?: true) + status = instance_double(Status, reblog?: true) classes = helper.microformats_classes(status, true, true) expect(classes).to eq 'p-in-reply-to p-repost-of p-comment' @@ -198,42 +198,42 @@ describe StatusesHelper do describe '#microformats_h_class' do it do - status = double(reblog?: false) + status = instance_double(Status, reblog?: false) css_class = helper.microformats_h_class(status, false, false, false) expect(css_class).to eq 'h-entry' end it do - status = double(reblog?: true) + status = instance_double(Status, reblog?: true) css_class = helper.microformats_h_class(status, false, false, false) expect(css_class).to eq 'h-cite' end it do - status = double(reblog?: false) + status = instance_double(Status, reblog?: false) css_class = helper.microformats_h_class(status, true, false, false) expect(css_class).to eq 'h-cite' end it do - status = double(reblog?: false) + status = instance_double(Status, reblog?: false) css_class = helper.microformats_h_class(status, false, true, false) expect(css_class).to eq 'h-cite' end it do - status = double(reblog?: false) + status = instance_double(Status, reblog?: false) css_class = helper.microformats_h_class(status, false, false, true) expect(css_class).to eq '' end it do - status = double(reblog?: true) + status = instance_double(Status, reblog?: true) css_class = helper.microformats_h_class(status, true, true, true) expect(css_class).to eq 'h-cite' diff --git a/spec/lib/activitypub/activity/add_spec.rb b/spec/lib/activitypub/activity/add_spec.rb index 9c45e465e4..ec6df01716 100644 --- a/spec/lib/activitypub/activity/add_spec.rb +++ b/spec/lib/activitypub/activity/add_spec.rb @@ -26,7 +26,7 @@ RSpec.describe ActivityPub::Activity::Add do end context 'when status was not known before' do - let(:service_stub) { double } + let(:service_stub) { instance_double(ActivityPub::FetchRemoteStatusService) } let(:json) do { diff --git a/spec/lib/activitypub/activity/move_spec.rb b/spec/lib/activitypub/activity/move_spec.rb index 8bd23aa7bf..f3973c70ce 100644 --- a/spec/lib/activitypub/activity/move_spec.rb +++ b/spec/lib/activitypub/activity/move_spec.rb @@ -26,7 +26,7 @@ RSpec.describe ActivityPub::Activity::Move do stub_request(:post, old_account.inbox_url).to_return(status: 200) stub_request(:post, new_account.inbox_url).to_return(status: 200) - service_stub = double + service_stub = instance_double(ActivityPub::FetchRemoteAccountService) allow(ActivityPub::FetchRemoteAccountService).to receive(:new).and_return(service_stub) allow(service_stub).to receive(:call).and_return(returned_account) end diff --git a/spec/lib/request_spec.rb b/spec/lib/request_spec.rb index e88631e475..f0861376b9 100644 --- a/spec/lib/request_spec.rb +++ b/spec/lib/request_spec.rb @@ -48,7 +48,7 @@ describe Request do end it 'executes a HTTP request when the first address is private' do - resolver = double + resolver = instance_double(Resolv::DNS) allow(resolver).to receive(:getaddresses).with('example.com').and_return(%w(0.0.0.0 2001:4860:4860::8844)) allow(resolver).to receive(:timeouts=).and_return(nil) @@ -83,7 +83,7 @@ describe Request do end it 'raises Mastodon::ValidationError' do - resolver = double + resolver = instance_double(Resolv::DNS) allow(resolver).to receive(:getaddresses).with('example.com').and_return(%w(0.0.0.0 2001:db8::face)) allow(resolver).to receive(:timeouts=).and_return(nil) diff --git a/spec/lib/suspicious_sign_in_detector_spec.rb b/spec/lib/suspicious_sign_in_detector_spec.rb index c61b1ef1e6..9e64aff08a 100644 --- a/spec/lib/suspicious_sign_in_detector_spec.rb +++ b/spec/lib/suspicious_sign_in_detector_spec.rb @@ -7,7 +7,7 @@ RSpec.describe SuspiciousSignInDetector do subject { described_class.new(user).suspicious?(request) } let(:user) { Fabricate(:user, current_sign_in_at: 1.day.ago) } - let(:request) { double(remote_ip: remote_ip) } + let(:request) { instance_double(ActionDispatch::Request, remote_ip: remote_ip) } let(:remote_ip) { nil } context 'when user has 2FA enabled' do diff --git a/spec/models/account/field_spec.rb b/spec/models/account/field_spec.rb index 5715a53791..22593bb218 100644 --- a/spec/models/account/field_spec.rb +++ b/spec/models/account/field_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Account::Field do describe '#verified?' do subject { described_class.new(account, 'name' => 'Foo', 'value' => 'Bar', 'verified_at' => verified_at) } - let(:account) { double('Account', local?: true) } + let(:account) { instance_double(Account, local?: true) } context 'when verified_at is set' do let(:verified_at) { Time.now.utc.iso8601 } @@ -28,7 +28,7 @@ RSpec.describe Account::Field do describe '#mark_verified!' do subject { described_class.new(account, original_hash) } - let(:account) { double('Account', local?: true) } + let(:account) { instance_double(Account, local?: true) } let(:original_hash) { { 'name' => 'Foo', 'value' => 'Bar' } } before do @@ -47,7 +47,7 @@ RSpec.describe Account::Field do describe '#verifiable?' do subject { described_class.new(account, 'name' => 'Foo', 'value' => value) } - let(:account) { double('Account', local?: local) } + let(:account) { instance_double(Account, local?: local) } context 'with local accounts' do let(:local) { true } diff --git a/spec/models/account_migration_spec.rb b/spec/models/account_migration_spec.rb index d76edddd51..f4544740b1 100644 --- a/spec/models/account_migration_spec.rb +++ b/spec/models/account_migration_spec.rb @@ -15,7 +15,7 @@ RSpec.describe AccountMigration do before do target_account.aliases.create!(acct: source_account.acct) - service_double = double + service_double = instance_double(ResolveAccountService) allow(ResolveAccountService).to receive(:new).and_return(service_double) allow(service_double).to receive(:call).with(target_acct, anything).and_return(target_account) end @@ -29,7 +29,7 @@ RSpec.describe AccountMigration do let(:target_acct) { 'target@remote' } before do - service_double = double + service_double = instance_double(ResolveAccountService) allow(ResolveAccountService).to receive(:new).and_return(service_double) allow(service_double).to receive(:call).with(target_acct, anything).and_return(nil) end diff --git a/spec/models/session_activation_spec.rb b/spec/models/session_activation_spec.rb index 052a06e5ca..75842e25ba 100644 --- a/spec/models/session_activation_spec.rb +++ b/spec/models/session_activation_spec.rb @@ -16,7 +16,7 @@ RSpec.describe SessionActivation do allow(session_activation).to receive(:detection).and_return(detection) end - let(:detection) { double(id: 1) } + let(:detection) { instance_double(Browser::Chrome, id: 1) } let(:session_activation) { Fabricate(:session_activation) } it 'returns detection.id' do @@ -30,7 +30,7 @@ RSpec.describe SessionActivation do end let(:session_activation) { Fabricate(:session_activation) } - let(:detection) { double(platform: double(id: 1)) } + let(:detection) { instance_double(Browser::Chrome, platform: instance_double(Browser::Platform, id: 1)) } it 'returns detection.platform.id' do expect(session_activation.platform).to be 1 diff --git a/spec/models/setting_spec.rb b/spec/models/setting_spec.rb index bba585cec6..5ed5c5d766 100644 --- a/spec/models/setting_spec.rb +++ b/spec/models/setting_spec.rb @@ -62,7 +62,7 @@ RSpec.describe Setting do context 'when RailsSettings::Settings.object returns truthy' do let(:object) { db_val } - let(:db_val) { double(value: 'db_val') } + let(:db_val) { instance_double(described_class, value: 'db_val') } context 'when default_value is a Hash' do let(:default_value) { { default_value: 'default_value' } } diff --git a/spec/policies/webhook_policy_spec.rb b/spec/policies/webhook_policy_spec.rb index 1eac8932d4..909311461a 100644 --- a/spec/policies/webhook_policy_spec.rb +++ b/spec/policies/webhook_policy_spec.rb @@ -8,16 +8,32 @@ describe WebhookPolicy do let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } - permissions :index?, :create?, :show?, :update?, :enable?, :disable?, :rotate_secret?, :destroy? do + permissions :index?, :create? do context 'with an admin' do it 'permits' do - expect(policy).to permit(admin, Tag) + expect(policy).to permit(admin, Webhook) end end context 'with a non-admin' do it 'denies' do - expect(policy).to_not permit(john, Tag) + expect(policy).to_not permit(john, Webhook) + end + end + end + + permissions :show?, :update?, :enable?, :disable?, :rotate_secret?, :destroy? do + let(:webhook) { Fabricate(:webhook, events: ['account.created', 'report.created']) } + + context 'with an admin' do + it 'permits' do + expect(policy).to permit(admin, webhook) + end + end + + context 'with a non-admin' do + it 'denies' do + expect(policy).to_not permit(john, webhook) end end end diff --git a/spec/requests/api/v1/suggestions_spec.rb b/spec/requests/api/v1/suggestions_spec.rb new file mode 100644 index 0000000000..42b7f86629 --- /dev/null +++ b/spec/requests/api/v1/suggestions_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Suggestions' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/suggestions' do + subject do + get '/api/v1/suggestions', headers: headers, params: params + end + + let(:bob) { Fabricate(:account) } + let(:jeff) { Fabricate(:account) } + let(:params) { {} } + + before do + PotentialFriendshipTracker.record(user.account_id, bob.id, :reblog) + PotentialFriendshipTracker.record(user.account_id, jeff.id, :favourite) + end + + it_behaves_like 'forbidden for wrong scope', 'write' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns accounts' do + subject + + body = body_as_json + + expect(body.size).to eq 2 + expect(body.pluck(:id)).to match_array([bob, jeff].map { |i| i.id.to_s }) + end + + context 'with limit param' do + let(:params) { { limit: 1 } } + + it 'returns only the requested number of accounts' do + subject + + expect(body_as_json.size).to eq 1 + end + end + + context 'without an authorization header' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + end + + describe 'DELETE /api/v1/suggestions/:id' do + subject do + delete "/api/v1/suggestions/#{jeff.id}", headers: headers + end + + let(:suggestions_source) { instance_double(AccountSuggestions::PastInteractionsSource, remove: nil) } + let(:bob) { Fabricate(:account) } + let(:jeff) { Fabricate(:account) } + + before do + PotentialFriendshipTracker.record(user.account_id, bob.id, :reblog) + PotentialFriendshipTracker.record(user.account_id, jeff.id, :favourite) + allow(AccountSuggestions::PastInteractionsSource).to receive(:new).and_return(suggestions_source) + end + + it_behaves_like 'forbidden for wrong scope', 'write' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'removes the specified suggestion' do + subject + + expect(suggestions_source).to have_received(:remove).with(user.account, jeff.id.to_s).once + expect(suggestions_source).to_not have_received(:remove).with(user.account, bob.id.to_s) + end + + context 'without an authorization header' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + end +end diff --git a/spec/services/account_search_service_spec.rb b/spec/services/account_search_service_spec.rb index 98264e6e13..1cd036f484 100644 --- a/spec/services/account_search_service_spec.rb +++ b/spec/services/account_search_service_spec.rb @@ -53,7 +53,7 @@ describe AccountSearchService, type: :service do context 'when there is a domain but no exact match' do it 'follows the remote account when resolve is true' do - service = double(call: nil) + service = instance_double(ResolveAccountService, call: nil) allow(ResolveAccountService).to receive(:new).and_return(service) results = subject.call('newuser@remote.com', nil, limit: 10, resolve: true) @@ -61,7 +61,7 @@ describe AccountSearchService, type: :service do end it 'does not follow the remote account when resolve is false' do - service = double(call: nil) + service = instance_double(ResolveAccountService, call: nil) allow(ResolveAccountService).to receive(:new).and_return(service) results = subject.call('newuser@remote.com', nil, limit: 10, resolve: false) diff --git a/spec/services/backup_service_spec.rb b/spec/services/backup_service_spec.rb index 73e0b42adb..806ba18323 100644 --- a/spec/services/backup_service_spec.rb +++ b/spec/services/backup_service_spec.rb @@ -30,7 +30,7 @@ RSpec.describe BackupService, type: :service do it 'stores them as expected' do service_call - json = Oj.load(read_zip_file(backup, 'actor.json')) + json = export_json(:actor) avatar_path = json.dig('icon', 'url') header_path = json.dig('image', 'url') @@ -42,47 +42,60 @@ RSpec.describe BackupService, type: :service do end end - it 'marks the backup as processed' do - expect { service_call }.to change(backup, :processed).from(false).to(true) + it 'marks the backup as processed and exports files' do + expect { service_call }.to process_backup + + expect_outbox_export + expect_likes_export + expect_bookmarks_export end - it 'exports outbox.json as expected' do - service_call + def process_backup + change(backup, :processed).from(false).to(true) + end - json = Oj.load(read_zip_file(backup, 'outbox.json')) - expect(json['@context']).to_not be_nil - expect(json['type']).to eq 'OrderedCollection' - expect(json['totalItems']).to eq 2 - expect(json['orderedItems'][0]['@context']).to be_nil - expect(json['orderedItems'][0]).to include({ + def expect_outbox_export + json = export_json(:outbox) + + aggregate_failures do + expect(json['@context']).to_not be_nil + expect(json['type']).to eq 'OrderedCollection' + expect(json['totalItems']).to eq 2 + expect(json['orderedItems'][0]['@context']).to be_nil + expect(json['orderedItems'][0]).to include_create_item(status) + expect(json['orderedItems'][1]).to include_create_item(private_status) + end + end + + def expect_likes_export + json = export_json(:likes) + + aggregate_failures do + expect(json['type']).to eq 'OrderedCollection' + expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(favourite.status)] + end + end + + def expect_bookmarks_export + json = export_json(:bookmarks) + + aggregate_failures do + expect(json['type']).to eq 'OrderedCollection' + expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(bookmark.status)] + end + end + + def export_json(type) + Oj.load(read_zip_file(backup, "#{type}.json")) + end + + def include_create_item(status) + include({ 'type' => 'Create', 'object' => include({ 'id' => ActivityPub::TagManager.instance.uri_for(status), - 'content' => '

    Hello

    ', + 'content' => "

    #{status.text}

    ", }), }) - expect(json['orderedItems'][1]).to include({ - 'type' => 'Create', - 'object' => include({ - 'id' => ActivityPub::TagManager.instance.uri_for(private_status), - 'content' => '

    secret

    ', - }), - }) - end - - it 'exports likes.json as expected' do - service_call - - json = Oj.load(read_zip_file(backup, 'likes.json')) - expect(json['type']).to eq 'OrderedCollection' - expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(favourite.status)] - end - - it 'exports bookmarks.json as expected' do - service_call - - json = Oj.load(read_zip_file(backup, 'bookmarks.json')) - expect(json['type']).to eq 'OrderedCollection' - expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(bookmark.status)] end end diff --git a/spec/services/bootstrap_timeline_service_spec.rb b/spec/services/bootstrap_timeline_service_spec.rb index 5a15ba7418..721a0337fd 100644 --- a/spec/services/bootstrap_timeline_service_spec.rb +++ b/spec/services/bootstrap_timeline_service_spec.rb @@ -6,7 +6,7 @@ RSpec.describe BootstrapTimelineService, type: :service do subject { described_class.new } context 'when the new user has registered from an invite' do - let(:service) { double } + let(:service) { instance_double(FollowService) } let(:autofollow) { false } let(:inviter) { Fabricate(:user, confirmed_at: 2.days.ago) } let(:invite) { Fabricate(:invite, user: inviter, max_uses: nil, expires_at: 1.hour.from_now, autofollow: autofollow) } diff --git a/spec/services/bulk_import_service_spec.rb b/spec/services/bulk_import_service_spec.rb index 09dfb0a0b6..281b642ea4 100644 --- a/spec/services/bulk_import_service_spec.rb +++ b/spec/services/bulk_import_service_spec.rb @@ -47,7 +47,7 @@ RSpec.describe BulkImportService do it 'requests to follow all the listed users once the workers have run' do subject.call(import) - resolve_account_service_double = double + resolve_account_service_double = instance_double(ResolveAccountService) allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double) allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) } allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) } @@ -95,7 +95,7 @@ RSpec.describe BulkImportService do it 'requests to follow all the expected users once the workers have run' do subject.call(import) - resolve_account_service_double = double + resolve_account_service_double = instance_double(ResolveAccountService) allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double) allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) } allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) } @@ -133,7 +133,7 @@ RSpec.describe BulkImportService do it 'blocks all the listed users once the workers have run' do subject.call(import) - resolve_account_service_double = double + resolve_account_service_double = instance_double(ResolveAccountService) allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double) allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) } allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) } @@ -177,7 +177,7 @@ RSpec.describe BulkImportService do it 'requests to follow all the expected users once the workers have run' do subject.call(import) - resolve_account_service_double = double + resolve_account_service_double = instance_double(ResolveAccountService) allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double) allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) } allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) } @@ -215,7 +215,7 @@ RSpec.describe BulkImportService do it 'mutes all the listed users once the workers have run' do subject.call(import) - resolve_account_service_double = double + resolve_account_service_double = instance_double(ResolveAccountService) allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double) allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) } allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) } @@ -263,7 +263,7 @@ RSpec.describe BulkImportService do it 'requests to follow all the expected users once the workers have run' do subject.call(import) - resolve_account_service_double = double + resolve_account_service_double = instance_double(ResolveAccountService) allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double) allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) } allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) } @@ -360,7 +360,7 @@ RSpec.describe BulkImportService do it 'updates the bookmarks as expected once the workers have run' do subject.call(import) - service_double = double + service_double = instance_double(ActivityPub::FetchRemoteStatusService) allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service_double) allow(service_double).to receive(:call).with('https://domain.unknown/foo') { Fabricate(:status, uri: 'https://domain.unknown/foo') } allow(service_double).to receive(:call).with('https://domain.unknown/private') { Fabricate(:status, uri: 'https://domain.unknown/private', visibility: :direct) } @@ -403,7 +403,7 @@ RSpec.describe BulkImportService do it 'updates the bookmarks as expected once the workers have run' do subject.call(import) - service_double = double + service_double = instance_double(ActivityPub::FetchRemoteStatusService) allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service_double) allow(service_double).to receive(:call).with('https://domain.unknown/foo') { Fabricate(:status, uri: 'https://domain.unknown/foo') } allow(service_double).to receive(:call).with('https://domain.unknown/private') { Fabricate(:status, uri: 'https://domain.unknown/private', visibility: :direct) } diff --git a/spec/services/fetch_resource_service_spec.rb b/spec/services/fetch_resource_service_spec.rb index da7e423517..0f1068471f 100644 --- a/spec/services/fetch_resource_service_spec.rb +++ b/spec/services/fetch_resource_service_spec.rb @@ -24,7 +24,7 @@ RSpec.describe FetchResourceService, type: :service do context 'when OpenSSL::SSL::SSLError is raised' do before do - request = double + request = instance_double(Request) allow(Request).to receive(:new).and_return(request) allow(request).to receive(:add_headers) allow(request).to receive(:on_behalf_of) @@ -36,7 +36,7 @@ RSpec.describe FetchResourceService, type: :service do context 'when HTTP::ConnectionError is raised' do before do - request = double + request = instance_double(Request) allow(Request).to receive(:new).and_return(request) allow(request).to receive(:add_headers) allow(request).to receive(:on_behalf_of) diff --git a/spec/services/import_service_spec.rb b/spec/services/import_service_spec.rb index 32ba4409c3..1904ac8dc9 100644 --- a/spec/services/import_service_spec.rb +++ b/spec/services/import_service_spec.rb @@ -219,7 +219,7 @@ RSpec.describe ImportService, type: :service do end before do - service = double + service = instance_double(ActivityPub::FetchRemoteStatusService) allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service) allow(service).to receive(:call).with('https://unknown-remote.com/users/bar/statuses/1') do Fabricate(:status, uri: 'https://unknown-remote.com/users/bar/statuses/1') diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index 76ef5391f0..d201292e17 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -132,7 +132,7 @@ RSpec.describe PostStatusService, type: :service do end it 'processes mentions' do - mention_service = double(:process_mentions_service) + mention_service = instance_double(ProcessMentionsService) allow(mention_service).to receive(:call) allow(ProcessMentionsService).to receive(:new).and_return(mention_service) account = Fabricate(:account) @@ -163,7 +163,7 @@ RSpec.describe PostStatusService, type: :service do end it 'processes hashtags' do - hashtags_service = double(:process_hashtags_service) + hashtags_service = instance_double(ProcessHashtagsService) allow(hashtags_service).to receive(:call) allow(ProcessHashtagsService).to receive(:new).and_return(hashtags_service) account = Fabricate(:account) diff --git a/spec/services/resolve_url_service_spec.rb b/spec/services/resolve_url_service_spec.rb index 8d2af74173..99761b6c73 100644 --- a/spec/services/resolve_url_service_spec.rb +++ b/spec/services/resolve_url_service_spec.rb @@ -9,7 +9,7 @@ describe ResolveURLService, type: :service do it 'returns nil when there is no resource url' do url = 'http://example.com/missing-resource' known_account = Fabricate(:account, uri: url) - service = double + service = instance_double(FetchResourceService) allow(FetchResourceService).to receive(:new).and_return service allow(service).to receive(:response_code).and_return(404) @@ -21,7 +21,7 @@ describe ResolveURLService, type: :service do it 'returns known account on temporary error' do url = 'http://example.com/missing-resource' known_account = Fabricate(:account, uri: url) - service = double + service = instance_double(FetchResourceService) allow(FetchResourceService).to receive(:new).and_return service allow(service).to receive(:response_code).and_return(500) @@ -145,5 +145,35 @@ describe ResolveURLService, type: :service do expect(subject.call(url, on_behalf_of: account)).to eq(status) end end + + context 'when searching for a local link of a remote private status' do + let(:account) { Fabricate(:account) } + let(:poster) { Fabricate(:account, username: 'foo', domain: 'example.com') } + let(:url) { 'https://example.com/@foo/42' } + let(:uri) { 'https://example.com/users/foo/statuses/42' } + let!(:status) { Fabricate(:status, url: url, uri: uri, account: poster, visibility: :private) } + let(:search_url) { "https://#{Rails.configuration.x.local_domain}/@foo@example.com/#{status.id}" } + + before do + stub_request(:get, url).to_return(status: 404) if url.present? + stub_request(:get, uri).to_return(status: 404) + end + + context 'when the account follows the poster' do + before do + account.follow!(poster) + end + + it 'returns the status' do + expect(subject.call(search_url, on_behalf_of: account)).to eq(status) + end + end + + context 'when the account does not follow the poster' do + it 'does not return the status' do + expect(subject.call(search_url, on_behalf_of: account)).to be_nil + end + end + end end end diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb index 00f693dfab..497ec74474 100644 --- a/spec/services/search_service_spec.rb +++ b/spec/services/search_service_spec.rb @@ -25,7 +25,7 @@ describe SearchService, type: :service do context 'when it does not find anything' do it 'returns the empty results' do - service = double(call: nil) + service = instance_double(ResolveURLService, call: nil) allow(ResolveURLService).to receive(:new).and_return(service) results = subject.call(@query, nil, 10, resolve: true) @@ -37,7 +37,7 @@ describe SearchService, type: :service do context 'when it finds an account' do it 'includes the account in the results' do account = Account.new - service = double(call: account) + service = instance_double(ResolveURLService, call: account) allow(ResolveURLService).to receive(:new).and_return(service) results = subject.call(@query, nil, 10, resolve: true) @@ -49,7 +49,7 @@ describe SearchService, type: :service do context 'when it finds a status' do it 'includes the status in the results' do status = Status.new - service = double(call: status) + service = instance_double(ResolveURLService, call: status) allow(ResolveURLService).to receive(:new).and_return(service) results = subject.call(@query, nil, 10, resolve: true) @@ -64,11 +64,11 @@ describe SearchService, type: :service do it 'includes the account in the results' do query = 'username' account = Account.new - service = double(call: [account]) + service = instance_double(AccountSearchService, call: [account]) allow(AccountSearchService).to receive(:new).and_return(service) results = subject.call(query, nil, 10) - expect(service).to have_received(:call).with(query, nil, limit: 10, offset: 0, resolve: false) + expect(service).to have_received(:call).with(query, nil, limit: 10, offset: 0, resolve: false, use_searchable_text: true, following: false) expect(results).to eq empty_results.merge(accounts: [account]) end end diff --git a/spec/services/unsuspend_account_service_spec.rb b/spec/services/unsuspend_account_service_spec.rb index e02ae41b99..7ef2630aeb 100644 --- a/spec/services/unsuspend_account_service_spec.rb +++ b/spec/services/unsuspend_account_service_spec.rb @@ -63,7 +63,7 @@ RSpec.describe UnsuspendAccountService, type: :service do describe 'unsuspending a remote account' do include_examples 'with common context' do let!(:account) { Fabricate(:account, domain: 'bob.com', uri: 'https://bob.com', inbox_url: 'https://bob.com/inbox', protocol: :activitypub) } - let!(:resolve_account_service) { double } + let!(:resolve_account_service) { instance_double(ResolveAccountService) } before do allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service) diff --git a/spec/validators/blacklisted_email_validator_spec.rb b/spec/validators/blacklisted_email_validator_spec.rb index a642405ae6..3d3d50f659 100644 --- a/spec/validators/blacklisted_email_validator_spec.rb +++ b/spec/validators/blacklisted_email_validator_spec.rb @@ -6,8 +6,8 @@ RSpec.describe BlacklistedEmailValidator, type: :validator do describe '#validate' do subject { described_class.new.validate(user); errors } - let(:user) { double(email: 'info@mail.com', sign_up_ip: '1.2.3.4', errors: errors) } - let(:errors) { double(add: nil) } + let(:user) { instance_double(User, email: 'info@mail.com', sign_up_ip: '1.2.3.4', errors: errors) } + let(:errors) { instance_double(ActiveModel::Errors, add: nil) } before do allow(user).to receive(:valid_invitation?).and_return(false) diff --git a/spec/validators/disallowed_hashtags_validator_spec.rb b/spec/validators/disallowed_hashtags_validator_spec.rb index e98db38792..7144d28918 100644 --- a/spec/validators/disallowed_hashtags_validator_spec.rb +++ b/spec/validators/disallowed_hashtags_validator_spec.rb @@ -11,8 +11,8 @@ RSpec.describe DisallowedHashtagsValidator, type: :validator do described_class.new.validate(status) end - let(:status) { double(errors: errors, local?: local, reblog?: reblog, text: disallowed_tags.map { |x| "##{x}" }.join(' ')) } - let(:errors) { double(add: nil) } + let(:status) { instance_double(Status, errors: errors, local?: local, reblog?: reblog, text: disallowed_tags.map { |x| "##{x}" }.join(' ')) } + let(:errors) { instance_double(ActiveModel::Errors, add: nil) } context 'with a remote reblog' do let(:local) { false } diff --git a/spec/validators/email_mx_validator_spec.rb b/spec/validators/email_mx_validator_spec.rb index d9703d81b1..876d73c184 100644 --- a/spec/validators/email_mx_validator_spec.rb +++ b/spec/validators/email_mx_validator_spec.rb @@ -4,7 +4,7 @@ require 'rails_helper' describe EmailMxValidator do describe '#validate' do - let(:user) { double(email: 'foo@example.com', sign_up_ip: '1.2.3.4', errors: double(add: nil)) } + let(:user) { instance_double(User, email: 'foo@example.com', sign_up_ip: '1.2.3.4', errors: instance_double(ActiveModel::Errors, add: nil)) } context 'with an e-mail domain that is explicitly allowed' do around do |block| @@ -15,7 +15,7 @@ describe EmailMxValidator do end it 'does not add errors if there are no DNS records' do - resolver = double + resolver = instance_double(Resolv::DNS) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) @@ -29,7 +29,7 @@ describe EmailMxValidator do end it 'adds no error if there are DNS records for the e-mail domain' do - resolver = double + resolver = instance_double(Resolv::DNS) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([Resolv::DNS::Resource::IN::A.new('192.0.2.42')]) @@ -46,19 +46,19 @@ describe EmailMxValidator do allow(TagManager).to receive(:instance).and_return(double) allow(double).to receive(:normalize_domain).with('example.com').and_raise(Addressable::URI::InvalidURIError) - user = double(email: 'foo@example.com', errors: double(add: nil)) + user = instance_double(User, email: 'foo@example.com', errors: instance_double(ActiveModel::Errors, add: nil)) subject.validate(user) expect(user.errors).to have_received(:add) end it 'adds an error if the domain email portion is blank' do - user = double(email: 'foo@', errors: double(add: nil)) + user = instance_double(User, email: 'foo@', errors: instance_double(ActiveModel::Errors, add: nil)) subject.validate(user) expect(user.errors).to have_received(:add) end it 'adds an error if the email domain name contains empty labels' do - resolver = double + resolver = instance_double(Resolv::DNS) allow(resolver).to receive(:getresources).with('example..com', Resolv::DNS::Resource::IN::MX).and_return([]) allow(resolver).to receive(:getresources).with('example..com', Resolv::DNS::Resource::IN::A).and_return([Resolv::DNS::Resource::IN::A.new('192.0.2.42')]) @@ -66,13 +66,13 @@ describe EmailMxValidator do allow(resolver).to receive(:timeouts=).and_return(nil) allow(Resolv::DNS).to receive(:open).and_yield(resolver) - user = double(email: 'foo@example..com', sign_up_ip: '1.2.3.4', errors: double(add: nil)) + user = instance_double(User, email: 'foo@example..com', sign_up_ip: '1.2.3.4', errors: instance_double(ActiveModel::Errors, add: nil)) subject.validate(user) expect(user.errors).to have_received(:add) end it 'adds an error if there are no DNS records for the e-mail domain' do - resolver = double + resolver = instance_double(Resolv::DNS) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) @@ -85,9 +85,11 @@ describe EmailMxValidator do end it 'adds an error if a MX record does not lead to an IP' do - resolver = double + resolver = instance_double(Resolv::DNS) - allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')]) + allow(resolver).to receive(:getresources) + .with('example.com', Resolv::DNS::Resource::IN::MX) + .and_return([instance_double(Resolv::DNS::Resource::MX, exchange: 'mail.example.com')]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([]) allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([]) @@ -101,13 +103,15 @@ describe EmailMxValidator do it 'adds an error if the MX record is blacklisted' do EmailDomainBlock.create!(domain: 'mail.example.com') - resolver = double + resolver = instance_double(Resolv::DNS) - allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')]) + allow(resolver).to receive(:getresources) + .with('example.com', Resolv::DNS::Resource::IN::MX) + .and_return([instance_double(Resolv::DNS::Resource::MX, exchange: 'mail.example.com')]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([]) - allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([double(address: '2.3.4.5')]) - allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::AAAA).and_return([double(address: 'fd00::2')]) + allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([instance_double(Resolv::DNS::Resource::IN::A, address: '2.3.4.5')]) + allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::AAAA).and_return([instance_double(Resolv::DNS::Resource::IN::A, address: 'fd00::2')]) allow(resolver).to receive(:timeouts=).and_return(nil) allow(Resolv::DNS).to receive(:open).and_yield(resolver) diff --git a/spec/validators/follow_limit_validator_spec.rb b/spec/validators/follow_limit_validator_spec.rb index 7b9055a27f..86b6511d65 100644 --- a/spec/validators/follow_limit_validator_spec.rb +++ b/spec/validators/follow_limit_validator_spec.rb @@ -12,9 +12,9 @@ RSpec.describe FollowLimitValidator, type: :validator do described_class.new.validate(follow) end - let(:follow) { double(account: account, errors: errors) } - let(:errors) { double(add: nil) } - let(:account) { double(nil?: _nil, local?: local, following_count: 0, followers_count: 0) } + let(:follow) { instance_double(Follow, account: account, errors: errors) } + let(:errors) { instance_double(ActiveModel::Errors, add: nil) } + let(:account) { instance_double(Account, nil?: _nil, local?: local, following_count: 0, followers_count: 0) } let(:_nil) { true } let(:local) { false } diff --git a/spec/validators/note_length_validator_spec.rb b/spec/validators/note_length_validator_spec.rb index e45d221d76..66fccad3ec 100644 --- a/spec/validators/note_length_validator_spec.rb +++ b/spec/validators/note_length_validator_spec.rb @@ -8,7 +8,7 @@ describe NoteLengthValidator do describe '#validate' do it 'adds an error when text is over 500 characters' do text = 'a' * 520 - account = double(note: text, errors: double(add: nil)) + account = instance_double(Account, note: text, errors: activemodel_errors) subject.validate_each(account, 'note', text) expect(account.errors).to have_received(:add) @@ -16,7 +16,7 @@ describe NoteLengthValidator do it 'counts URLs as 23 characters flat' do text = ('a' * 476) + " http://#{'b' * 30}.com/example" - account = double(note: text, errors: double(add: nil)) + account = instance_double(Account, note: text, errors: activemodel_errors) subject.validate_each(account, 'note', text) expect(account.errors).to_not have_received(:add) @@ -24,10 +24,16 @@ describe NoteLengthValidator do it 'does not count non-autolinkable URLs as 23 characters flat' do text = ('a' * 476) + "http://#{'b' * 30}.com/example" - account = double(note: text, errors: double(add: nil)) + account = instance_double(Account, note: text, errors: activemodel_errors) subject.validate_each(account, 'note', text) expect(account.errors).to have_received(:add) end + + private + + def activemodel_errors + instance_double(ActiveModel::Errors, add: nil) + end end end diff --git a/spec/validators/poll_validator_spec.rb b/spec/validators/poll_validator_spec.rb index 069a471619..95feb043db 100644 --- a/spec/validators/poll_validator_spec.rb +++ b/spec/validators/poll_validator_spec.rb @@ -9,8 +9,8 @@ RSpec.describe PollValidator, type: :validator do end let(:validator) { described_class.new } - let(:poll) { double(options: options, expires_at: expires_at, errors: errors) } - let(:errors) { double(add: nil) } + let(:poll) { instance_double(Poll, options: options, expires_at: expires_at, errors: errors) } + let(:errors) { instance_double(ActiveModel::Errors, add: nil) } let(:options) { %w(foo bar) } let(:expires_at) { 1.day.from_now } diff --git a/spec/validators/status_length_validator_spec.rb b/spec/validators/status_length_validator_spec.rb index e132b5618a..98ea15e03b 100644 --- a/spec/validators/status_length_validator_spec.rb +++ b/spec/validators/status_length_validator_spec.rb @@ -5,38 +5,38 @@ require 'rails_helper' describe StatusLengthValidator do describe '#validate' do it 'does not add errors onto remote statuses' do - status = double(local?: false) + status = instance_double(Status, local?: false) subject.validate(status) expect(status).to_not receive(:errors) end it 'does not add errors onto local reblogs' do - status = double(local?: false, reblog?: true) + status = instance_double(Status, local?: false, reblog?: true) subject.validate(status) expect(status).to_not receive(:errors) end it 'adds an error when content warning is over 500 characters' do - status = double(spoiler_text: 'a' * 520, text: '', errors: double(add: nil), local?: true, reblog?: false) + status = instance_double(Status, spoiler_text: 'a' * 520, text: '', errors: activemodel_errors, local?: true, reblog?: false) subject.validate(status) expect(status.errors).to have_received(:add) end it 'adds an error when text is over 500 characters' do - status = double(spoiler_text: '', text: 'a' * 520, errors: double(add: nil), local?: true, reblog?: false) + status = instance_double(Status, spoiler_text: '', text: 'a' * 520, errors: activemodel_errors, local?: true, reblog?: false) subject.validate(status) expect(status.errors).to have_received(:add) end it 'adds an error when text and content warning are over 500 characters total' do - status = double(spoiler_text: 'a' * 250, text: 'b' * 251, errors: double(add: nil), local?: true, reblog?: false) + status = instance_double(Status, spoiler_text: 'a' * 250, text: 'b' * 251, errors: activemodel_errors, local?: true, reblog?: false) subject.validate(status) expect(status.errors).to have_received(:add) end it 'counts URLs as 23 characters flat' do text = ('a' * 476) + " http://#{'b' * 30}.com/example" - status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false) + status = instance_double(Status, spoiler_text: '', text: text, errors: activemodel_errors, local?: true, reblog?: false) subject.validate(status) expect(status.errors).to_not have_received(:add) @@ -44,7 +44,7 @@ describe StatusLengthValidator do it 'does not count non-autolinkable URLs as 23 characters flat' do text = ('a' * 476) + "http://#{'b' * 30}.com/example" - status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false) + status = instance_double(Status, spoiler_text: '', text: text, errors: activemodel_errors, local?: true, reblog?: false) subject.validate(status) expect(status.errors).to have_received(:add) @@ -52,14 +52,14 @@ describe StatusLengthValidator do it 'does not count overly long URLs as 23 characters flat' do text = "http://example.com/valid?#{'#foo?' * 1000}" - status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false) + status = instance_double(Status, spoiler_text: '', text: text, errors: activemodel_errors, local?: true, reblog?: false) subject.validate(status) expect(status.errors).to have_received(:add) end it 'counts only the front part of remote usernames' do text = ('a' * 475) + " @alice@#{'b' * 30}.com" - status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false) + status = instance_double(Status, spoiler_text: '', text: text, errors: activemodel_errors, local?: true, reblog?: false) subject.validate(status) expect(status.errors).to_not have_received(:add) @@ -67,10 +67,16 @@ describe StatusLengthValidator do it 'does count both parts of remote usernames for overly long domains' do text = "@alice@#{'b' * 500}.com" - status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false) + status = instance_double(Status, spoiler_text: '', text: text, errors: activemodel_errors, local?: true, reblog?: false) subject.validate(status) expect(status.errors).to have_received(:add) end end + + private + + def activemodel_errors + instance_double(ActiveModel::Errors, add: nil) + end end diff --git a/spec/validators/status_pin_validator_spec.rb b/spec/validators/status_pin_validator_spec.rb index 00b89d702f..e8f8a45434 100644 --- a/spec/validators/status_pin_validator_spec.rb +++ b/spec/validators/status_pin_validator_spec.rb @@ -8,11 +8,11 @@ RSpec.describe StatusPinValidator, type: :validator do subject.validate(pin) end - let(:pin) { double(account: account, errors: errors, status: status, account_id: pin_account_id) } - let(:status) { double(reblog?: reblog, account_id: status_account_id, visibility: visibility, direct_visibility?: visibility == 'direct') } - let(:account) { double(status_pins: status_pins, local?: local) } - let(:status_pins) { double(count: count) } - let(:errors) { double(add: nil) } + let(:pin) { instance_double(StatusPin, account: account, errors: errors, status: status, account_id: pin_account_id) } + let(:status) { instance_double(Status, reblog?: reblog, account_id: status_account_id, visibility: visibility, direct_visibility?: visibility == 'direct') } + let(:account) { instance_double(Account, status_pins: status_pins, local?: local) } + let(:status_pins) { instance_double(Array, count: count) } + let(:errors) { instance_double(ActiveModel::Errors, add: nil) } let(:pin_account_id) { 1 } let(:status_account_id) { 1 } let(:visibility) { 'public' } diff --git a/spec/validators/unique_username_validator_spec.rb b/spec/validators/unique_username_validator_spec.rb index 6867cbc6ce..0d172c8408 100644 --- a/spec/validators/unique_username_validator_spec.rb +++ b/spec/validators/unique_username_validator_spec.rb @@ -6,7 +6,7 @@ describe UniqueUsernameValidator do describe '#validate' do context 'when local account' do it 'does not add errors if username is nil' do - account = double(username: nil, domain: nil, persisted?: false, errors: double(add: nil)) + account = instance_double(Account, username: nil, domain: nil, persisted?: false, errors: activemodel_errors) subject.validate(account) expect(account.errors).to_not have_received(:add) end @@ -18,14 +18,14 @@ describe UniqueUsernameValidator do it 'adds an error when the username is already used with ignoring cases' do Fabricate(:account, username: 'ABCdef') - account = double(username: 'abcDEF', domain: nil, persisted?: false, errors: double(add: nil)) + account = instance_double(Account, username: 'abcDEF', domain: nil, persisted?: false, errors: activemodel_errors) subject.validate(account) expect(account.errors).to have_received(:add) end it 'does not add errors when same username remote account exists' do Fabricate(:account, username: 'abcdef', domain: 'example.com') - account = double(username: 'abcdef', domain: nil, persisted?: false, errors: double(add: nil)) + account = instance_double(Account, username: 'abcdef', domain: nil, persisted?: false, errors: activemodel_errors) subject.validate(account) expect(account.errors).to_not have_received(:add) end @@ -34,7 +34,7 @@ describe UniqueUsernameValidator do context 'when remote account' do it 'does not add errors if username is nil' do - account = double(username: nil, domain: 'example.com', persisted?: false, errors: double(add: nil)) + account = instance_double(Account, username: nil, domain: 'example.com', persisted?: false, errors: activemodel_errors) subject.validate(account) expect(account.errors).to_not have_received(:add) end @@ -46,23 +46,29 @@ describe UniqueUsernameValidator do it 'adds an error when the username is already used with ignoring cases' do Fabricate(:account, username: 'ABCdef', domain: 'example.com') - account = double(username: 'abcDEF', domain: 'example.com', persisted?: false, errors: double(add: nil)) + account = instance_double(Account, username: 'abcDEF', domain: 'example.com', persisted?: false, errors: activemodel_errors) subject.validate(account) expect(account.errors).to have_received(:add) end it 'adds an error when the domain is already used with ignoring cases' do Fabricate(:account, username: 'ABCdef', domain: 'example.com') - account = double(username: 'ABCdef', domain: 'EXAMPLE.COM', persisted?: false, errors: double(add: nil)) + account = instance_double(Account, username: 'ABCdef', domain: 'EXAMPLE.COM', persisted?: false, errors: activemodel_errors) subject.validate(account) expect(account.errors).to have_received(:add) end it 'does not add errors when account with the same username and another domain exists' do Fabricate(:account, username: 'abcdef', domain: 'example.com') - account = double(username: 'abcdef', domain: 'example2.com', persisted?: false, errors: double(add: nil)) + account = instance_double(Account, username: 'abcdef', domain: 'example2.com', persisted?: false, errors: activemodel_errors) subject.validate(account) expect(account.errors).to_not have_received(:add) end end + + private + + def activemodel_errors + instance_double(ActiveModel::Errors, add: nil) + end end diff --git a/spec/validators/unreserved_username_validator_spec.rb b/spec/validators/unreserved_username_validator_spec.rb index 85bd7dcb6a..6f353eeafd 100644 --- a/spec/validators/unreserved_username_validator_spec.rb +++ b/spec/validators/unreserved_username_validator_spec.rb @@ -10,8 +10,8 @@ RSpec.describe UnreservedUsernameValidator, type: :validator do end let(:validator) { described_class.new } - let(:account) { double(username: username, errors: errors) } - let(:errors) { double(add: nil) } + let(:account) { instance_double(Account, username: username, errors: errors) } + let(:errors) { instance_double(ActiveModel::Errors, add: nil) } context 'when @username is blank?' do let(:username) { nil } diff --git a/spec/validators/url_validator_spec.rb b/spec/validators/url_validator_spec.rb index a56ccd8e08..f2220e32b0 100644 --- a/spec/validators/url_validator_spec.rb +++ b/spec/validators/url_validator_spec.rb @@ -10,8 +10,8 @@ RSpec.describe URLValidator, type: :validator do end let(:validator) { described_class.new(attributes: [attribute]) } - let(:record) { double(errors: errors) } - let(:errors) { double(add: nil) } + let(:record) { instance_double(Webhook, errors: errors) } + let(:errors) { instance_double(ActiveModel::Errors, add: nil) } let(:value) { '' } let(:attribute) { :foo } diff --git a/spec/views/statuses/show.html.haml_spec.rb b/spec/views/statuses/show.html.haml_spec.rb index 370743dfec..06f5132d9f 100644 --- a/spec/views/statuses/show.html.haml_spec.rb +++ b/spec/views/statuses/show.html.haml_spec.rb @@ -4,7 +4,7 @@ require 'rails_helper' describe 'statuses/show.html.haml', without_verify_partial_doubles: true do before do - double(api_oembed_url: '') + allow(view).to receive(:api_oembed_url).and_return('') allow(view).to receive(:show_landing_strip?).and_return(true) allow(view).to receive(:site_title).and_return('example site') allow(view).to receive(:site_hostname).and_return('example.com') diff --git a/spec/workers/activitypub/processing_worker_spec.rb b/spec/workers/activitypub/processing_worker_spec.rb index 6b57f16a92..66d1cf4890 100644 --- a/spec/workers/activitypub/processing_worker_spec.rb +++ b/spec/workers/activitypub/processing_worker_spec.rb @@ -9,7 +9,8 @@ describe ActivityPub::ProcessingWorker do describe '#perform' do it 'delegates to ActivityPub::ProcessCollectionService' do - allow(ActivityPub::ProcessCollectionService).to receive(:new).and_return(double(:service, call: nil)) + allow(ActivityPub::ProcessCollectionService).to receive(:new) + .and_return(instance_double(ActivityPub::ProcessCollectionService, call: nil)) subject.perform(account.id, '') expect(ActivityPub::ProcessCollectionService).to have_received(:new) end diff --git a/spec/workers/admin/domain_purge_worker_spec.rb b/spec/workers/admin/domain_purge_worker_spec.rb index b67c58b234..861fd71a7f 100644 --- a/spec/workers/admin/domain_purge_worker_spec.rb +++ b/spec/workers/admin/domain_purge_worker_spec.rb @@ -7,7 +7,7 @@ describe Admin::DomainPurgeWorker do describe 'perform' do it 'calls domain purge service for relevant domain block' do - service = double(call: nil) + service = instance_double(PurgeDomainService, call: nil) allow(PurgeDomainService).to receive(:new).and_return(service) result = subject.perform('example.com') diff --git a/spec/workers/domain_block_worker_spec.rb b/spec/workers/domain_block_worker_spec.rb index 8b98443fa7..33c3ca009a 100644 --- a/spec/workers/domain_block_worker_spec.rb +++ b/spec/workers/domain_block_worker_spec.rb @@ -9,7 +9,7 @@ describe DomainBlockWorker do let(:domain_block) { Fabricate(:domain_block) } it 'calls domain block service for relevant domain block' do - service = double(call: nil) + service = instance_double(BlockDomainService, call: nil) allow(BlockDomainService).to receive(:new).and_return(service) result = subject.perform(domain_block.id) diff --git a/spec/workers/domain_clear_media_worker_spec.rb b/spec/workers/domain_clear_media_worker_spec.rb index f21d1fe189..21f8f87b2f 100644 --- a/spec/workers/domain_clear_media_worker_spec.rb +++ b/spec/workers/domain_clear_media_worker_spec.rb @@ -9,7 +9,7 @@ describe DomainClearMediaWorker do let(:domain_block) { Fabricate(:domain_block, severity: :silence, reject_media: true) } it 'calls domain clear media service for relevant domain block' do - service = double(call: nil) + service = instance_double(ClearDomainMediaService, call: nil) allow(ClearDomainMediaService).to receive(:new).and_return(service) result = subject.perform(domain_block.id) diff --git a/spec/workers/feed_insert_worker_spec.rb b/spec/workers/feed_insert_worker_spec.rb index 16f7d73e02..97c73c5999 100644 --- a/spec/workers/feed_insert_worker_spec.rb +++ b/spec/workers/feed_insert_worker_spec.rb @@ -11,7 +11,7 @@ describe FeedInsertWorker do context 'when there are no records' do it 'skips push with missing status' do - instance = double(push_to_home: nil) + instance = instance_double(FeedManager, push_to_home: nil) allow(FeedManager).to receive(:instance).and_return(instance) result = subject.perform(nil, follower.id) @@ -20,7 +20,7 @@ describe FeedInsertWorker do end it 'skips push with missing account' do - instance = double(push_to_home: nil) + instance = instance_double(FeedManager, push_to_home: nil) allow(FeedManager).to receive(:instance).and_return(instance) result = subject.perform(status.id, nil) @@ -31,7 +31,7 @@ describe FeedInsertWorker do context 'when there are real records' do it 'skips the push when there is a filter' do - instance = double(push_to_home: nil, filter?: true) + instance = instance_double(FeedManager, push_to_home: nil, filter?: true) allow(FeedManager).to receive(:instance).and_return(instance) result = subject.perform(status.id, follower.id) @@ -40,7 +40,7 @@ describe FeedInsertWorker do end it 'pushes the status onto the home timeline without filter' do - instance = double(push_to_home: nil, filter?: false) + instance = instance_double(FeedManager, push_to_home: nil, filter?: false) allow(FeedManager).to receive(:instance).and_return(instance) result = subject.perform(status.id, follower.id) diff --git a/spec/workers/move_worker_spec.rb b/spec/workers/move_worker_spec.rb index ac7bd506b6..7577f6e896 100644 --- a/spec/workers/move_worker_spec.rb +++ b/spec/workers/move_worker_spec.rb @@ -15,7 +15,7 @@ describe MoveWorker do let!(:account_note) { Fabricate(:account_note, account: local_user.account, target_account: source_account, comment: comment) } let(:list) { Fabricate(:list, account: local_follower) } - let(:block_service) { double } + let(:block_service) { instance_double(BlockService) } before do stub_request(:post, 'https://example.org/a/inbox').to_return(status: 200) diff --git a/spec/workers/publish_scheduled_announcement_worker_spec.rb b/spec/workers/publish_scheduled_announcement_worker_spec.rb index 0977bba1ee..2e50d4a50d 100644 --- a/spec/workers/publish_scheduled_announcement_worker_spec.rb +++ b/spec/workers/publish_scheduled_announcement_worker_spec.rb @@ -12,7 +12,7 @@ describe PublishScheduledAnnouncementWorker do describe 'perform' do before do - service = double + service = instance_double(FetchRemoteStatusService) allow(FetchRemoteStatusService).to receive(:new).and_return(service) allow(service).to receive(:call).with('https://domain.com/users/foo/12345') { remote_status.reload } diff --git a/spec/workers/refollow_worker_spec.rb b/spec/workers/refollow_worker_spec.rb index 1dac15385b..5718d4db49 100644 --- a/spec/workers/refollow_worker_spec.rb +++ b/spec/workers/refollow_worker_spec.rb @@ -10,7 +10,7 @@ describe RefollowWorker do let(:bob) { Fabricate(:account, domain: nil, username: 'bob') } describe 'perform' do - let(:service) { double } + let(:service) { instance_double(FollowService) } before do allow(FollowService).to receive(:new).and_return(service) diff --git a/spec/workers/regeneration_worker_spec.rb b/spec/workers/regeneration_worker_spec.rb index 147a76be50..37b0a04c49 100644 --- a/spec/workers/regeneration_worker_spec.rb +++ b/spec/workers/regeneration_worker_spec.rb @@ -9,7 +9,7 @@ describe RegenerationWorker do let(:account) { Fabricate(:account) } it 'calls the precompute feed service for the account' do - service = double(call: nil) + service = instance_double(PrecomputeFeedService, call: nil) allow(PrecomputeFeedService).to receive(:new).and_return(service) result = subject.perform(account.id) diff --git a/yarn.lock b/yarn.lock index 728123f278..22731c47e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5843,9 +5843,9 @@ glob-parent@^6.0.2: is-glob "^4.0.3" glob@^10.2.5, glob@^10.2.6: - version "10.2.7" - resolved "https://registry.yarnpkg.com/glob/-/glob-10.2.7.tgz#9dd2828cd5bc7bd861e7738d91e7113dda41d7d8" - integrity sha512-jTKehsravOJo8IJxUGfZILnkvVJM/MOfHRs8QcXolVef2zNI9Tqyy5+SeuOAZd3upViEZQLyFpQhYiHLrMUNmA== + version "10.3.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.0.tgz#763d02a894f3cdfc521b10bbbbc8e0309e750cce" + integrity sha512-AQ1/SB9HH0yCx1jXAT4vmCbTOPe5RQ+kCurjbel5xSCGhebumUv+GJZfa1rEqor3XIViqwSEmlkZCQD43RWrBg== dependencies: foreground-child "^3.1.0" jackspeak "^2.0.3" @@ -8049,9 +8049,9 @@ minimatch@^5.0.1: brace-expansion "^2.0.1" minimatch@^9.0.1: - version "9.0.1" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253" - integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w== + version "9.0.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.2.tgz#397e387fff22f6795844d00badc903a3d5de7057" + integrity sha512-PZOT9g5v2ojiTL7r1xF6plNHLtOeTpSlDI007As2NlA2aYBMfVom17yqa6QzhmDP8QOhn7LjHTg7DFCVSSa6yg== dependencies: brace-expansion "^2.0.1" @@ -8776,15 +8776,20 @@ performance-now@^2.1.0: resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== -pg-cloudflare@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.1.0.tgz#833d70870d610d14bf9df7afb40e1cba310c17a0" - integrity sha512-tGM8/s6frwuAIyRcJ6nWcIvd3+3NmUKIs6OjviIm1HPPFEt5MzQDOTBQyhPWg/m0kCl95M6gA1JaIXtS8KovOA== +pg-cloudflare@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz#e6d5833015b170e23ae819e8c5d7eaedb472ca98" + integrity sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q== pg-connection-string@^2.6.0: - version "2.6.0" - resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.0.tgz#12a36cc4627df19c25cc1b9b736cc39ee1f73ae8" - integrity sha512-x14ibktcwlHKoHxx9X3uTVW9zIGR41ZB6QNhHb21OPNdCCO3NaRnpJuwKIQSR4u+Yqjx4HCvy7Hh7VSy1U4dGg== + version "2.6.1" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.1.tgz#78c23c21a35dd116f48e12e23c0965e8d9e2cbfb" + integrity sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg== + +pg-connection-string@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.1.tgz#78c23c21a35dd116f48e12e23c0965e8d9e2cbfb" + integrity sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg== pg-int8@1.0.1: version "1.0.1" @@ -8796,10 +8801,10 @@ pg-numeric@1.0.2: resolved "https://registry.yarnpkg.com/pg-numeric/-/pg-numeric-1.0.2.tgz#816d9a44026086ae8ae74839acd6a09b0636aa3a" integrity sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw== -pg-pool@^3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.6.0.tgz#3190df3e4747a0d23e5e9e8045bcd99bda0a712e" - integrity sha512-clFRf2ksqd+F497kWFyM21tMjeikn60oGDmqMT8UBrynEwVEX/5R5xd2sdvdo1cZCFlguORNpVuqxIj+aK4cfQ== +pg-pool@^3.6.1: + version "3.6.1" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.6.1.tgz#5a902eda79a8d7e3c928b77abf776b3cb7d351f7" + integrity sha512-jizsIzhkIitxCGfPRzJn1ZdcosIt3pz9Sh3V01fm1vZnbnCMgmGl5wvGGdNN2EL9Rmb0EcFoCkixH4Pu+sP9Og== pg-protocol@*, pg-protocol@^1.6.0: version "1.6.0" @@ -8831,19 +8836,19 @@ pg-types@^4.0.1: postgres-range "^1.1.1" pg@^8.5.0: - version "8.11.0" - resolved "https://registry.yarnpkg.com/pg/-/pg-8.11.0.tgz#a37e534e94b57a7ed811e926f23a7c56385f55d9" - integrity sha512-meLUVPn2TWgJyLmy7el3fQQVwft4gU5NGyvV0XbD41iU9Jbg8lCH4zexhIkihDzVHJStlt6r088G6/fWeNjhXA== + version "8.11.1" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.11.1.tgz#297e0eb240306b1e9e4f55af8a3bae76ae4810b1" + integrity sha512-utdq2obft07MxaDg0zBJI+l/M3mBRfIpEN3iSemsz0G5F2/VXx+XzqF4oxrbIZXQxt2AZzIUzyVg/YM6xOP/WQ== dependencies: buffer-writer "2.0.0" packet-reader "1.0.0" - pg-connection-string "^2.6.0" - pg-pool "^3.6.0" + pg-connection-string "^2.6.1" + pg-pool "^3.6.1" pg-protocol "^1.6.0" pg-types "^2.1.0" pgpass "1.x" optionalDependencies: - pg-cloudflare "^1.1.0" + pg-cloudflare "^1.1.1" pgpass@1.x: version "1.0.5" @@ -9619,9 +9624,9 @@ react-redux@^7.2.0: react-is "^17.0.2" react-redux@^8.0.4: - version "8.1.0" - resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-8.1.0.tgz#4e147339f00bbaac7196bc42bc99e6fc412846e7" - integrity sha512-CtHZzAOxi7GQvTph4dVLWwZHAWUjV2kMEQtk50OrN8z3gKxpWg3Tz7JfDw32N3Rpd7fh02z73cF6yZkK467gbQ== + version "8.1.1" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-8.1.1.tgz#8e740f3fd864a4cd0de5ba9cdc8ad39cc9e7c81a" + integrity sha512-5W0QaKtEhj+3bC0Nj0NkqkhIv8gLADH/2kYFMTHxCVqQILiWzLv6MaLuV5wJU3BQEdHKzTfcvPN0WMS6SC1oyA== dependencies: "@babel/runtime" "^7.12.1" "@types/hoist-non-react-statics" "^3.3.1" @@ -9739,9 +9744,9 @@ react-test-renderer@^18.2.0: scheduler "^0.23.0" react-textarea-autosize@*, react-textarea-autosize@^8.4.1: - version "8.4.1" - resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.4.1.tgz#bcfc5462727014b808b14ee916c01e275e8a8335" - integrity sha512-aD2C+qK6QypknC+lCMzteOdIjoMbNlgSFmJjCV+DrfTPwp59i/it9mMNf2HDzvRjQgKAyBDPyLJhcrzElf2U4Q== + version "8.5.0" + resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.5.0.tgz#bb0f7faf9849850f1c20b6e7fac0309d4b92f87b" + integrity sha512-cp488su3U9RygmHmGpJp0KEt0i/+57KCK33XVPH+50swVRBhIZYh0fGduz2YLKXwl9vSKBZ9HUXcg9PQXUXqIw== dependencies: "@babel/runtime" "^7.20.13" use-composed-ref "^1.3.0" @@ -10208,9 +10213,9 @@ sass-loader@^10.2.0: semver "^7.3.2" sass@^1.62.1: - version "1.63.4" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.63.4.tgz#caf60643321044c61f6a0fe638a07abbd31cfb5d" - integrity sha512-Sx/+weUmK+oiIlI+9sdD0wZHsqpbgQg8wSwSnGBjwb5GwqFhYNwwnI+UWZtLjKvKyFlKkatRK235qQ3mokyPoQ== + version "1.63.6" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.63.6.tgz#481610e612902e0c31c46b46cf2dad66943283ea" + integrity sha512-MJuxGMHzaOW7ipp+1KdELtqKbfAWbH7OLIdoSMnVe3EXPMTmxTmlaZDCTsgIpPCs3w99lLo9/zDKkOrJuT5byw== dependencies: chokidar ">=3.0.0 <4.0.0" immutable "^4.0.0" @@ -10883,7 +10888,6 @@ stringz@^2.1.0: char-regex "^1.0.2" "strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: - name strip-ansi-cjs version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==