diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 04ac9560c..f991036ad 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 446abe7b6..5c3439b19 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -322,7 +322,6 @@ RSpec/LetSetup: - 'spec/controllers/api/v1/admin/accounts_controller_spec.rb' - 'spec/controllers/api/v1/filters_controller_spec.rb' - 'spec/controllers/api/v1/followed_tags_controller_spec.rb' - - 'spec/controllers/api/v1/tags_controller_spec.rb' - 'spec/controllers/api/v2/admin/accounts_controller_spec.rb' - 'spec/controllers/api/v2/filters/keywords_controller_spec.rb' - 'spec/controllers/api/v2/filters/statuses_controller_spec.rb' @@ -445,45 +444,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: @@ -764,7 +724,6 @@ Rails/WhereExists: - 'app/workers/move_worker.rb' - 'db/migrate/20190529143559_preserve_old_layout_for_existing_users.rb' - 'lib/tasks/tests.rake' - - 'spec/controllers/api/v1/tags_controller_spec.rb' - 'spec/models/account_spec.rb' - 'spec/services/activitypub/process_collection_service_spec.rb' - 'spec/services/purge_domain_service_spec.rb' diff --git a/Gemfile.lock b/Gemfile.lock index bdbeb7922..c3eb9d4d7 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) @@ -106,7 +106,7 @@ GEM aws-sdk-kms (1.67.0) aws-sdk-core (~> 3, >= 3.174.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.125.0) + aws-sdk-s3 (1.126.0) aws-sdk-core (~> 3, >= 3.174.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.4) @@ -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) @@ -511,20 +511,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 +539,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) diff --git a/app/controllers/admin/webhooks_controller.rb b/app/controllers/admin/webhooks_controller.rb index 01d9ba8ce..f1aad7c4b 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,10 +40,12 @@ module Admin def update authorize @webhook, :update? + @webhook.current_account = current_account + if @webhook.update(resource_params) redirect_to admin_webhook_path(@webhook) else - render :show + render :edit end end diff --git a/app/controllers/api/v1/conversations_controller.rb b/app/controllers/api/v1/conversations_controller.rb index 63644f85e..b3ca2f790 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/statuses/histories_controller.rb b/app/controllers/api/v1/statuses/histories_controller.rb index dff2425d0..2913472b0 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/admin/accounts_controller.rb b/app/controllers/api/v2/admin/accounts_controller.rb index 0c451f778..65cf0c4db 100644 --- a/app/controllers/api/v2/admin/accounts_controller.rb +++ b/app/controllers/api/v2/admin/accounts_controller.rb @@ -18,6 +18,14 @@ class Api::V2::Admin::AccountsController < Api::V1::Admin::AccountsController private + def next_path + api_v2_admin_accounts_url(pagination_params(max_id: pagination_max_id)) if records_continue? + end + + def prev_path + api_v2_admin_accounts_url(pagination_params(min_id: pagination_since_id)) unless @accounts.empty? + end + def filtered_accounts AccountFilter.new(translated_filter_params).results end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index ae89cec78..889ca7f40 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -24,13 +24,4 @@ module SettingsHelper safe_join([image_tag(account.avatar.url, width: 15, height: 15, alt: display_name(account), class: 'avatar'), content_tag(:span, account.acct, class: 'username')], ' ') end end - - def picture_hint(hint, picture) - if picture.original_filename.nil? - hint - else - link = link_to t('generic.delete'), settings_profile_picture_path(picture.name.to_s), data: { method: :delete } - safe_join([hint, link], '
'.html_safe) - end - end end diff --git a/app/javascript/flavours/glitch/components/autosuggest_hashtag.jsx b/app/javascript/flavours/glitch/components/autosuggest_hashtag.jsx deleted file mode 100644 index 37f7e20f0..000000000 --- a/app/javascript/flavours/glitch/components/autosuggest_hashtag.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { FormattedMessage } from 'react-intl'; - -import ShortNumber from 'flavours/glitch/components/short_number'; - -export default class AutosuggestHashtag extends PureComponent { - - static propTypes = { - tag: PropTypes.shape({ - name: PropTypes.string.isRequired, - url: PropTypes.string, - history: PropTypes.array, - }).isRequired, - }; - - render() { - const { tag } = this.props; - const weeklyUses = tag.history && ( - total + day.uses * 1, 0)} - /> - ); - - return ( -
-
- #{tag.name} -
- {tag.history !== undefined && ( -
- -
- )} -
- ); - } - -} diff --git a/app/javascript/flavours/glitch/components/autosuggest_hashtag.tsx b/app/javascript/flavours/glitch/components/autosuggest_hashtag.tsx new file mode 100644 index 000000000..932370884 --- /dev/null +++ b/app/javascript/flavours/glitch/components/autosuggest_hashtag.tsx @@ -0,0 +1,42 @@ +import { FormattedMessage } from 'react-intl'; + +import ShortNumber from 'flavours/glitch/components/short_number'; + +interface Props { + tag: { + name: string; + url?: string; + history?: Array<{ + uses: number; + accounts: string; + day: string; + }>; + following?: boolean; + type: 'hashtag'; + }; +} + +export const AutosuggestHashtag: React.FC = ({ tag }) => { + const weeklyUses = tag.history && ( + total + day.uses * 1, 0)} + /> + ); + + return ( +
+
+ #{tag.name} +
+ {tag.history !== undefined && ( +
+ +
+ )} +
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/autosuggest_input.jsx b/app/javascript/flavours/glitch/components/autosuggest_input.jsx index d3b7c48ab..f0833c8c6 100644 --- a/app/javascript/flavours/glitch/components/autosuggest_input.jsx +++ b/app/javascript/flavours/glitch/components/autosuggest_input.jsx @@ -8,9 +8,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container'; import AutosuggestEmoji from './autosuggest_emoji'; -import AutosuggestHashtag from './autosuggest_hashtag'; - - +import { AutosuggestHashtag } from './autosuggest_hashtag'; const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => { let word; diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx b/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx index 86f10651d..25ca3fefa 100644 --- a/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx +++ b/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx @@ -10,7 +10,7 @@ import Textarea from 'react-textarea-autosize'; import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container'; import AutosuggestEmoji from './autosuggest_emoji'; -import AutosuggestHashtag from './autosuggest_hashtag'; +import { AutosuggestHashtag } from './autosuggest_hashtag'; const textAtCursorMatchesToken = (str, caretPosition) => { let word; diff --git a/app/javascript/flavours/glitch/features/bookmarked_statuses/index.jsx b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.jsx index fe8b883de..c674c8254 100644 --- a/app/javascript/flavours/glitch/features/bookmarked_statuses/index.jsx +++ b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.jsx @@ -15,13 +15,14 @@ import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/col import ColumnHeader from 'flavours/glitch/components/column_header'; import StatusList from 'flavours/glitch/components/status_list'; import Column from 'flavours/glitch/features/ui/components/column'; +import { getStatusList } from 'flavours/glitch/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/flavours/glitch/features/community_timeline/index.jsx b/app/javascript/flavours/glitch/features/community_timeline/index.jsx index 127e7cf18..ca11adb46 100644 --- a/app/javascript/flavours/glitch/features/community_timeline/index.jsx +++ b/app/javascript/flavours/glitch/features/community_timeline/index.jsx @@ -142,11 +142,8 @@ class CommunityTimeline extends PureComponent { - - - - } trackScroll={!pinned} scrollKey={`community_timeline-${columnId}`} timelineId={`community${onlyMedia ? ':media' : ''}`} diff --git a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx index 20e499805..01cd3d5dd 100644 --- a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx @@ -392,7 +392,7 @@ class EmojiPickerDropdown extends PureComponent { {button || 🙂} diff --git a/app/javascript/flavours/glitch/features/explore/links.jsx b/app/javascript/flavours/glitch/features/explore/links.jsx index ca7d94862..88976c4ea 100644 --- a/app/javascript/flavours/glitch/features/explore/links.jsx +++ b/app/javascript/flavours/glitch/features/explore/links.jsx @@ -35,7 +35,7 @@ class Links extends PureComponent { const banner = ( - + ); diff --git a/app/javascript/flavours/glitch/features/explore/statuses.jsx b/app/javascript/flavours/glitch/features/explore/statuses.jsx index 212980c28..ce484ef77 100644 --- a/app/javascript/flavours/glitch/features/explore/statuses.jsx +++ b/app/javascript/flavours/glitch/features/explore/statuses.jsx @@ -11,9 +11,10 @@ import { debounce } from 'lodash'; import { fetchTrendingStatuses, expandTrendingStatuses } from 'flavours/glitch/actions/trends'; import DismissableBanner from 'flavours/glitch/components/dismissable_banner'; import StatusList from 'flavours/glitch/components/status_list'; +import { getStatusList } from 'flavours/glitch/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/flavours/glitch/features/favourited_statuses/index.jsx b/app/javascript/flavours/glitch/features/favourited_statuses/index.jsx index 08152063c..ed11f2b8c 100644 --- a/app/javascript/flavours/glitch/features/favourited_statuses/index.jsx +++ b/app/javascript/flavours/glitch/features/favourited_statuses/index.jsx @@ -15,13 +15,14 @@ import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'flavours/glit import ColumnHeader from 'flavours/glitch/components/column_header'; import StatusList from 'flavours/glitch/components/status_list'; import Column from 'flavours/glitch/features/ui/components/column'; +import { getStatusList } from 'flavours/glitch/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/flavours/glitch/features/home_timeline/components/explore_prompt.jsx b/app/javascript/flavours/glitch/features/home_timeline/components/explore_prompt.jsx new file mode 100644 index 000000000..3eb28c59a --- /dev/null +++ b/app/javascript/flavours/glitch/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 DismissableBanner from 'flavours/glitch/components/dismissable_banner'; +import background from 'mastodon/../images/friends-cropped.png'; + + +export const ExplorePrompt = () => ( + + + +

+

+ +
+
+ + +
+
+
+); diff --git a/app/javascript/flavours/glitch/features/home_timeline/index.jsx b/app/javascript/flavours/glitch/features/home_timeline/index.jsx index 791c31055..9a110f06e 100644 --- a/app/javascript/flavours/glitch/features/home_timeline/index.jsx +++ b/app/javascript/flavours/glitch/features/home_timeline/index.jsx @@ -5,35 +5,66 @@ 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 'flavours/glitch/actions/announcements'; -import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; -import { expandHomeTimeline } from 'flavours/glitch/actions/timelines'; -import Column from 'flavours/glitch/components/column'; -import ColumnHeader from 'flavours/glitch/components/column_header'; import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge'; import { NotSignedInIndicator } from 'flavours/glitch/components/not_signed_in_indicator'; import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container'; -import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; +import { me } from 'flavours/glitch/initial_state'; +import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; +import { expandHomeTimeline } from '../../actions/timelines'; +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({ title: { id: 'column.home', defaultMessage: 'Home' }, show_announcements: { id: 'home.show_announcements', defaultMessage: 'Show announcements' }, 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.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), regex: state.getIn(['settings', 'home', 'regex', 'body']), }); @@ -53,6 +84,7 @@ class HomeTimeline extends PureComponent { hasAnnouncements: PropTypes.bool, unreadAnnouncements: PropTypes.number, showAnnouncements: PropTypes.bool, + tooSlow: PropTypes.bool, regex: PropTypes.string, }; @@ -123,11 +155,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 = ( @@ -142,6 +174,10 @@ class HomeTimeline extends PureComponent { ); } + if (tooSlow) { + banner = ; + } + return ( }} />} + emptyMessage={} bindToDocument={!multiColumn} regex={this.props.regex} /> diff --git a/app/javascript/flavours/glitch/features/pinned_statuses/index.jsx b/app/javascript/flavours/glitch/features/pinned_statuses/index.jsx index add05bdff..bbb95e552 100644 --- a/app/javascript/flavours/glitch/features/pinned_statuses/index.jsx +++ b/app/javascript/flavours/glitch/features/pinned_statuses/index.jsx @@ -8,17 +8,19 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; -import { fetchPinnedStatuses } from 'flavours/glitch/actions/pin_statuses'; -import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim'; -import StatusList from 'flavours/glitch/components/status_list'; -import Column from 'flavours/glitch/features/ui/components/column'; +import { getStatusList } from 'flavours/glitch/selectors'; + +import { fetchPinnedStatuses } from '../../actions/pin_statuses'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import StatusList from '../../components/status_list'; +import Column from '../ui/components/column'; const messages = defineMessages({ heading: { id: 'column.pins', defaultMessage: 'Pinned post' }, }); 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/flavours/glitch/features/public_timeline/index.jsx b/app/javascript/flavours/glitch/features/public_timeline/index.jsx index 5bbbea066..4e4b350f8 100644 --- a/app/javascript/flavours/glitch/features/public_timeline/index.jsx +++ b/app/javascript/flavours/glitch/features/public_timeline/index.jsx @@ -146,11 +146,8 @@ class PublicTimeline extends PureComponent { - - - - } timelineId={`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`} onLoadMore={this.handleLoadMore} trackScroll={!pinned} diff --git a/app/javascript/flavours/glitch/features/ui/components/header.jsx b/app/javascript/flavours/glitch/features/ui/components/header.jsx index bbef3a5fb..f2b89f3bd 100644 --- a/app/javascript/flavours/glitch/features/ui/components/header.jsx +++ b/app/javascript/flavours/glitch/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 'flavours/glitch/actions/modal'; +import { fetchServer } from 'flavours/glitch/actions/server'; import { Avatar } from 'flavours/glitch/components/avatar'; +import { Icon } from 'flavours/glitch/components/icon'; import { WordmarkLogo, SymbolLogo } from 'flavours/glitch/components/logo'; import Permalink from 'flavours/glitch/components/permalink'; import { registrationsOpen, me } from 'flavours/glitch/initial_state'; @@ -21,6 +23,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', }); @@ -29,6 +35,9 @@ const mapDispatchToProps = (dispatch) => ({ openClosedRegistrationsModal() { dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' })); }, + dispatchServer() { + dispatch(fetchServer()); + } }); class Header extends PureComponent { @@ -41,18 +50,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' && } ); @@ -75,6 +92,7 @@ class Header extends PureComponent { content = ( <> + {location.pathname !== '/search' && } {signupButton} @@ -97,4 +115,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/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js index 14e889de2..ad0eebf84 100644 --- a/app/javascript/flavours/glitch/selectors/index.js +++ b/app/javascript/flavours/glitch/selectors/index.js @@ -148,3 +148,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/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss index 9dc6a43fc..f3a590607 100644 --- a/app/javascript/flavours/glitch/styles/components/columns.scss +++ b/app/javascript/flavours/glitch/styles/components/columns.scss @@ -966,26 +966,79 @@ $ui-header-height: 55px; } .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/flavours/glitch/styles/components/misc.scss b/app/javascript/flavours/glitch/styles/components/misc.scss index 53620eeb3..208204021 100644 --- a/app/javascript/flavours/glitch/styles/components/misc.scss +++ b/app/javascript/flavours/glitch/styles/components/misc.scss @@ -108,12 +108,13 @@ text-transform: none; background: transparent; padding: 6px 17px; - border: 1px solid $ui-primary-color; + border: 1px solid lighten($ui-base-color, 12%); &:active, &:focus, &:hover { - border-color: lighten($ui-primary-color, 4%); + background: lighten($ui-base-color, 4%); + border-color: lighten($ui-base-color, 16%); color: lighten($darker-text-color, 4%); text-decoration: none; } diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index 0eb65d1ee..46feab18b 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -468,8 +468,8 @@ &.status-direct > .status__content::after { background: linear-gradient( - rgba(lighten($ui-base-color, 8%), 0), - rgba(lighten($ui-base-color, 8%), 1) + rgba(mix($ui-base-color, $ui-highlight-color, 95%), 0), + rgba(mix($ui-base-color, $ui-highlight-color, 95%), 1) ); } diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss index 2294f2d7c..cfcdd742e 100644 --- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss +++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss @@ -653,11 +653,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/images/friends-cropped.png b/app/javascript/images/friends-cropped.png new file mode 100755 index 000000000..b13e16a58 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 a372ada40..7cc8516fd 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -129,13 +129,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/components/account.jsx b/app/javascript/mastodon/components/account.jsx index 0f3b85388..dd5aff1d8 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'; @@ -49,6 +49,7 @@ class Account extends ImmutablePureComponent { actionTitle: PropTypes.string, defaultAction: PropTypes.string, onActionClick: PropTypes.func, + withBio: PropTypes.bool, }; static defaultProps = { @@ -80,7 +81,7 @@ class Account extends ImmutablePureComponent { }; render () { - const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction, size, minimal } = this.props; + const { account, intl, hidden, withBio, onActionClick, actionIcon, actionTitle, defaultAction, size, minimal } = this.props; if (!account) { return ; @@ -171,6 +172,15 @@ class Account extends ImmutablePureComponent { )} + + {withBio && (account.get('note').length > 0 ? ( +
+ ) : ( +
+ ))}
); } diff --git a/app/javascript/mastodon/components/autosuggest_hashtag.jsx b/app/javascript/mastodon/components/autosuggest_hashtag.jsx deleted file mode 100644 index b509f48df..000000000 --- a/app/javascript/mastodon/components/autosuggest_hashtag.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { FormattedMessage } from 'react-intl'; - -import ShortNumber from 'mastodon/components/short_number'; - -export default class AutosuggestHashtag extends PureComponent { - - static propTypes = { - tag: PropTypes.shape({ - name: PropTypes.string.isRequired, - url: PropTypes.string, - history: PropTypes.array, - }).isRequired, - }; - - render() { - const { tag } = this.props; - const weeklyUses = tag.history && ( - total + day.uses * 1, 0)} - /> - ); - - return ( -
-
- #{tag.name} -
- {tag.history !== undefined && ( -
- -
- )} -
- ); - } - -} diff --git a/app/javascript/mastodon/components/autosuggest_hashtag.tsx b/app/javascript/mastodon/components/autosuggest_hashtag.tsx new file mode 100644 index 000000000..c6798054d --- /dev/null +++ b/app/javascript/mastodon/components/autosuggest_hashtag.tsx @@ -0,0 +1,42 @@ +import { FormattedMessage } from 'react-intl'; + +import ShortNumber from 'mastodon/components/short_number'; + +interface Props { + tag: { + name: string; + url?: string; + history?: Array<{ + uses: number; + accounts: string; + day: string; + }>; + following?: boolean; + type: 'hashtag'; + }; +} + +export const AutosuggestHashtag: React.FC = ({ tag }) => { + const weeklyUses = tag.history && ( + total + day.uses * 1, 0)} + /> + ); + + return ( +
+
+ #{tag.name} +
+ {tag.history !== undefined && ( +
+ +
+ )} +
+ ); +}; diff --git a/app/javascript/mastodon/components/autosuggest_input.jsx b/app/javascript/mastodon/components/autosuggest_input.jsx index 890f94928..06cbb5d75 100644 --- a/app/javascript/mastodon/components/autosuggest_input.jsx +++ b/app/javascript/mastodon/components/autosuggest_input.jsx @@ -8,7 +8,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; import AutosuggestEmoji from './autosuggest_emoji'; -import AutosuggestHashtag from './autosuggest_hashtag'; +import { AutosuggestHashtag } from './autosuggest_hashtag'; const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => { let word; diff --git a/app/javascript/mastodon/components/autosuggest_textarea.jsx b/app/javascript/mastodon/components/autosuggest_textarea.jsx index 463d2e94c..230e4f657 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.jsx +++ b/app/javascript/mastodon/components/autosuggest_textarea.jsx @@ -10,7 +10,7 @@ import Textarea from 'react-textarea-autosize'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; import AutosuggestEmoji from './autosuggest_emoji'; -import AutosuggestHashtag from './autosuggest_hashtag'; +import { AutosuggestHashtag } from './autosuggest_hashtag'; const textAtCursorMatchesToken = (str, caretPosition) => { let word; diff --git a/app/javascript/mastodon/components/verified_badge.tsx b/app/javascript/mastodon/components/verified_badge.tsx index 6b421ba42..9a6adcfa8 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/bookmarked_statuses/index.jsx b/app/javascript/mastodon/features/bookmarked_statuses/index.jsx index 795b859ce..936dee12e 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 a18da2f64..7e3b9babe 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/emoji_picker_dropdown.jsx b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx index 6f23322ca..d3254f0ef 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx @@ -390,7 +390,7 @@ class EmojiPickerDropdown extends PureComponent { {button || 🙂} diff --git a/app/javascript/mastodon/features/explore/links.jsx b/app/javascript/mastodon/features/explore/links.jsx index df91337fd..49c667f02 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 abacf333d..eb2fe777a 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 4902ddc28..abce7ac05 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/home_timeline/components/explore_prompt.jsx b/app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx new file mode 100644 index 000000000..5ce47de83 --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx @@ -0,0 +1,26 @@ +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 c9fe07875..41e5aa344 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.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 0f478f26a..379f43304 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 8b4ad0b08..472a87f5e 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 f1447b771..3627f76cd 100644 --- a/app/javascript/mastodon/features/onboarding/index.jsx +++ b/app/javascript/mastodon/features/onboarding/index.jsx @@ -19,6 +19,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'; @@ -122,21 +123,22 @@ 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 687179302..c5b185a24 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 a93e82cfa..f09d5471e 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 01b02d402..d77b76a63 100644 --- a/app/javascript/mastodon/features/public_timeline/index.jsx +++ b/app/javascript/mastodon/features/public_timeline/index.jsx @@ -142,11 +142,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 bb6747c00..bdd1c7305 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' && } ); @@ -74,6 +91,7 @@ class Header extends PureComponent { content = ( <> + {location.pathname !== '/search' && } {signupButton} @@ -96,4 +114,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/locales/en.json b/app/javascript/mastodon/locales/en.json index 8a9d3d121..f42dc4f66 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -52,6 +52,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", @@ -146,7 +147,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}}", @@ -197,9 +198,9 @@ "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.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 this and other servers of the decentralized network that this server knows about.", "embed.instructions": "Embed this post on your website by copying the code below.", "embed.preview": "Here is what it will look like:", @@ -232,8 +233,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.", @@ -292,9 +292,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.", @@ -451,28 +455,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!", diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index b67734316..f92e7fe48 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 7498477ca..91828d408 100644 --- a/app/javascript/styles/mastodon-light/diff.scss +++ b/app/javascript/styles/mastodon-light/diff.scss @@ -653,11 +653,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/components.scss b/app/javascript/styles/mastodon/components.scss index 44527b687..c3707d1f8 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -133,12 +133,13 @@ color: $darker-text-color; background: transparent; padding: 6px 17px; - border: 1px solid $ui-primary-color; + border: 1px solid lighten($ui-base-color, 12%); &:active, &:focus, &:hover { - border-color: lighten($ui-primary-color, 4%); + background: lighten($ui-base-color, 4%); + border-color: lighten($ui-base-color, 16%); color: lighten($darker-text-color, 4%); text-decoration: none; } @@ -1533,12 +1534,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; + } + } } } @@ -2636,13 +2662,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; @@ -2704,6 +2732,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; @@ -2716,14 +2745,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; @@ -2747,16 +2776,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; } @@ -3120,7 +3166,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; @@ -3128,6 +3174,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; @@ -3140,7 +3187,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; @@ -3175,7 +3222,7 @@ $ui-header-height: 55px; padding: 15px; position: absolute; inset-inline-end: 0; - top: -48px; + top: -50px; } .react-toggle { @@ -3856,7 +3903,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; @@ -3911,7 +3959,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; @@ -3919,16 +3967,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%); } } @@ -3942,6 +3989,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; @@ -3961,13 +4009,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; } @@ -4384,17 +4432,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 { @@ -4454,6 +4498,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; } @@ -6138,6 +6183,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; @@ -6815,24 +6861,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 { @@ -7347,7 +7375,6 @@ noscript { .account__header { overflow: hidden; - background: lighten($ui-base-color, 4%); &.inactive { opacity: 0.5; @@ -7369,6 +7396,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; @@ -7382,7 +7410,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; @@ -7391,7 +7419,7 @@ noscript { .account__avatar { background: darken($ui-base-color, 8%); - border: 2px solid lighten($ui-base-color, 4%); + border: 2px solid $ui-base-color; } } } @@ -8718,27 +8746,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/lib/attachment_batch.rb b/app/lib/attachment_batch.rb new file mode 100644 index 000000000..1f87b9433 --- /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/request_pool.rb b/app/lib/request_pool.rb index 6be172286..86c825498 100644 --- a/app/lib/request_pool.rb +++ b/app/lib/request_pool.rb @@ -28,8 +28,9 @@ class RequestPool end MAX_IDLE_TIME = 30 - WAIT_TIMEOUT = 5 MAX_POOL_SIZE = ENV.fetch('MAX_REQUEST_POOL_SIZE', 512).to_i + REAPER_FREQUENCY = 30 + WAIT_TIMEOUT = 5 class Connection attr_reader :site, :last_used_at, :created_at, :in_use, :dead, :fresh @@ -98,7 +99,7 @@ class RequestPool def initialize @pool = ConnectionPool::SharedConnectionPool.new(size: MAX_POOL_SIZE, timeout: WAIT_TIMEOUT) { |site| Connection.new(site) } - @reaper = Reaper.new(self, 30) + @reaper = Reaper.new(self, REAPER_FREQUENCY) @reaper.run end diff --git a/app/lib/text_formatter.rb b/app/lib/text_formatter.rb index 243e89289..0404cbace 100644 --- a/app/lib/text_formatter.rb +++ b/app/lib/text_formatter.rb @@ -79,7 +79,7 @@ class TextFormatter cutoff = url[prefix.length..-1].length > 30 <<~HTML.squish - #{h(display_url)} + #{h(display_url)} HTML rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError h(entity[:url]) @@ -122,7 +122,7 @@ class TextFormatter display_username = same_username_hits&.positive? || with_domains? ? account.pretty_acct : account.username <<~HTML.squish - @#{h(display_username)} + @#{h(display_username)} HTML end diff --git a/app/lib/vacuum/media_attachments_vacuum.rb b/app/lib/vacuum/media_attachments_vacuum.rb index 7c0a85a9d..7b21c84bb 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_conversation.rb b/app/models/account_conversation.rb index 32fe79ccf..25a75d8a6 100644 --- a/app/models/account_conversation.rb +++ b/app/models/account_conversation.rb @@ -32,14 +32,8 @@ class AccountConversation < ApplicationRecord end def participant_accounts - @participant_accounts ||= begin - if participant_account_ids.empty? - [account] - else - participants = Account.where(id: participant_account_ids).to_a - participants.empty? ? [account] : participants - end - end + @participant_accounts ||= Account.where(id: participant_account_ids).to_a + @participant_accounts.presence || [account] end class << self diff --git a/app/models/user_settings.rb b/app/models/user_settings.rb index 43afe32ad..70b375e94 100644 --- a/app/models/user_settings.rb +++ b/app/models/user_settings.rb @@ -15,7 +15,7 @@ class UserSettings setting :show_application, default: true setting :default_language, default: nil setting :default_sensitive, default: false - setting :default_privacy, default: nil + setting :default_privacy, default: nil, in: %w(public unlisted private) setting :default_content_type, default: 'text/plain' setting :hide_followers_count, default: false setting :visible_reactions, default: 6 @@ -80,7 +80,10 @@ class UserSettings raise KeyError, "Undefined setting: #{key}" unless self.class.definition_for?(key) - typecast_value = self.class.definition_for(key).type_cast(value) + setting_definition = self.class.definition_for(key) + typecast_value = setting_definition.type_cast(value) + + raise ArgumentError, "Invalid value for setting #{key}: #{typecast_value}" if setting_definition.in.present? && setting_definition.in.exclude?(typecast_value) if typecast_value.nil? @original_hash.delete(key) diff --git a/app/models/webhook.rb b/app/models/webhook.rb index c46fce743..14f33c5fc 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 a2199a333..577e891b6 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/clear_domain_media_service.rb b/app/services/clear_domain_media_service.rb index 9e70ebe51..7bf2d62fb 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/remove_status_service.rb b/app/services/remove_status_service.rb index e1c1c35fc..64ace295b 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -12,6 +12,7 @@ class RemoveStatusService < BaseService # @option [Boolean] :immediate # @option [Boolean] :preserve # @option [Boolean] :original_removed + # @option [Boolean] :skip_streaming def call(status, **options) @payload = Oj.dump(event: :delete, payload: status.id.to_s) @status = status @@ -53,6 +54,9 @@ class RemoveStatusService < BaseService private + # The following FeedManager calls all do not result in redis publishes for + # streaming, as the `:update` option is false + def remove_from_self FeedManager.instance.unpush_from_home(@account, @status) FeedManager.instance.unpush_from_direct(@account, @status) if @status.direct_visibility? @@ -77,6 +81,8 @@ class RemoveStatusService < BaseService # followers. Here we send a delete to actively mentioned accounts # that may not follow the account + return if skip_streaming? + @status.active_mentions.find_each do |mention| redis.publish("timeline:#{mention.account_id}", @payload) end @@ -105,7 +111,7 @@ class RemoveStatusService < BaseService # without us being able to do all the fancy stuff @status.reblogs.rewhere(deleted_at: [nil, @status.deleted_at]).includes(:account).reorder(nil).find_each do |reblog| - RemoveStatusService.new.call(reblog, original_removed: true) + RemoveStatusService.new.call(reblog, original_removed: true, skip_streaming: skip_streaming?) end end @@ -116,6 +122,8 @@ class RemoveStatusService < BaseService return unless @status.public_visibility? + return if skip_streaming? + @status.tags.map(&:name).each do |hashtag| redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload) redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @payload) if @status.local? @@ -125,6 +133,8 @@ class RemoveStatusService < BaseService def remove_from_public return unless @status.public_visibility? + return if skip_streaming? + redis.publish('timeline:public', @payload) redis.publish(@status.local? ? 'timeline:public:local' : 'timeline:public:remote', @payload) end @@ -132,6 +142,8 @@ class RemoveStatusService < BaseService def remove_from_media return unless @status.public_visibility? + return if skip_streaming? + redis.publish('timeline:public:media', @payload) redis.publish(@status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', @payload) end @@ -151,4 +163,8 @@ class RemoveStatusService < BaseService def permanently? @options[:immediate] || !(@options[:preserve] || @status.reported?) end + + def skip_streaming? + !!@options[:skip_streaming] + end end diff --git a/app/views/admin/domain_blocks/confirm_suspension.html.haml b/app/views/admin/domain_blocks/confirm_suspension.html.haml index e291d4ce2..e0e55e70f 100644 --- a/app/views/admin/domain_blocks/confirm_suspension.html.haml +++ b/app/views/admin/domain_blocks/confirm_suspension.html.haml @@ -1,7 +1,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 8d019ff43..c870e943f 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/app/workers/scheduler/user_cleanup_scheduler.rb b/app/workers/scheduler/user_cleanup_scheduler.rb index 45cfbc62e..4aee7935a 100644 --- a/app/workers/scheduler/user_cleanup_scheduler.rb +++ b/app/workers/scheduler/user_cleanup_scheduler.rb @@ -24,7 +24,7 @@ class Scheduler::UserCleanupScheduler def clean_discarded_statuses! Status.unscoped.discarded.where('deleted_at <= ?', 30.days.ago).find_in_batches do |statuses| RemovalWorker.push_bulk(statuses) do |status| - [status.id, { 'immediate' => true }] + [status.id, { 'immediate' => true, 'skip_streaming' => true }] end end end diff --git a/config/boot.rb b/config/boot.rb index 4e379e7db..3a1d1d6d2 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 8aee15659..a53c7c6e9 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/api.rb b/config/routes/api.rb index b54ab1416..88dd08a2e 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -87,6 +87,7 @@ namespace :api, format: false do resources :conversations, only: [:index, :destroy] do member do post :read + post :unread end end diff --git a/lib/sanitize_ext/sanitize_config.rb b/lib/sanitize_ext/sanitize_config.rb index 43218361f..4e775d1b4 100644 --- a/lib/sanitize_ext/sanitize_config.rb +++ b/lib/sanitize_ext/sanitize_config.rb @@ -55,6 +55,11 @@ class Sanitize end end + TRANSLATE_TRANSFORMER = lambda do |env| + node = env[:node] + node.remove_attribute('translate') unless node['translate'] == 'no' + end + UNSUPPORTED_HREF_TRANSFORMER = lambda do |env| return unless env[:node_name] == 'a' @@ -82,9 +87,10 @@ class Sanitize elements: %w(p br span a abbr del pre blockquote code b strong u sub sup i em h1 h2 h3 h4 h5 ul ol li), attributes: { - 'a' => %w(href rel class title), + 'a' => %w(href rel class title translate), 'abbr' => %w(title), - 'span' => %w(class), + 'span' => %w(class translate), + 'blockquote' => %w(cite), 'ol' => %w(start reversed), 'li' => %w(value), @@ -105,6 +111,7 @@ class Sanitize transformers: [ CLASS_WHITELIST_TRANSFORMER, IMG_TAG_TRANSFORMER, + TRANSLATE_TRANSFORMER, UNSUPPORTED_HREF_TRANSFORMER, ] ) @@ -160,7 +167,7 @@ class Sanitize MASTODON_OUTGOING ||= freeze_config MASTODON_STRICT.merge( attributes: merge( MASTODON_STRICT[:attributes], - 'a' => %w(href rel class title target) + 'a' => %w(href rel class title target translate) ), add_attributes: {}, @@ -168,6 +175,7 @@ class Sanitize transformers: [ CLASS_WHITELIST_TRANSFORMER, IMG_TAG_TRANSFORMER, + TRANSLATE_TRANSFORMER, UNSUPPORTED_HREF_TRANSFORMER, LINK_REL_TRANSFORMER, LINK_TARGET_TRANSFORMER, diff --git a/spec/controllers/admin/change_emails_controller_spec.rb b/spec/controllers/admin/change_emails_controller_spec.rb index 503862a7b..dd8a764b6 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 181616a66..955916078 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 99b19298c..3c3f23f52 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 6b0453476..6f82f322b 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 d499aa64c..fb7fb2957 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 701855f92..1f3951516 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 5e45c7408..0ccfbbcc6 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 @@ -82,7 +82,7 @@ describe Admin::WebhooksController do end.to_not change(webhook, :url) expect(response).to have_http_status(:success) - expect(response).to render_template(:show) + expect(response).to render_template(:edit) end end diff --git a/spec/controllers/api/v1/admin/account_actions_controller_spec.rb b/spec/controllers/api/v1/admin/account_actions_controller_spec.rb deleted file mode 100644 index 523350e12..000000000 --- a/spec/controllers/api/v1/admin/account_actions_controller_spec.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Api::V1::Admin::AccountActionsController do - render_views - - let(:role) { UserRole.find_by(name: 'Moderator') } - let(:user) { Fabricate(:user, role: role) } - let(:scopes) { 'admin:read admin:write' } - let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } - let(:account) { Fabricate(:account) } - - before do - allow(controller).to receive(:doorkeeper_token) { token } - end - - describe 'POST #create' do - context 'with type of disable' do - before do - post :create, params: { account_id: account.id, type: 'disable' } - end - - it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', '' - - it 'returns http success' do - expect(response).to have_http_status(200) - end - - it 'performs action against account' do - expect(account.reload.user_disabled?).to be true - end - - it 'logs action' do - log_item = Admin::ActionLog.last - - expect(log_item).to_not be_nil - expect(log_item.action).to eq :disable - expect(log_item.account_id).to eq user.account_id - expect(log_item.target_id).to eq account.user.id - end - end - - context 'with no type' do - before do - post :create, params: { account_id: account.id } - end - - it 'returns http unprocessable entity' do - expect(response).to have_http_status(422) - end - end - end -end diff --git a/spec/controllers/api/v1/conversations_controller_spec.rb b/spec/controllers/api/v1/conversations_controller_spec.rb index f8a598563..28d7c7f3a 100644 --- a/spec/controllers/api/v1/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/conversations_controller_spec.rb @@ -18,6 +18,7 @@ RSpec.describe Api::V1::ConversationsController do before do PostStatusService.new.call(other.account, text: 'Hey @alice', visibility: 'direct') + PostStatusService.new.call(user.account, text: 'Hey, nobody here', visibility: 'direct') end it 'returns http success' do @@ -33,7 +34,8 @@ RSpec.describe Api::V1::ConversationsController do it 'returns conversations' do get :index json = body_as_json - expect(json.size).to eq 1 + expect(json.size).to eq 2 + expect(json[0][:accounts].size).to eq 1 end context 'with since_id' do @@ -41,7 +43,7 @@ RSpec.describe Api::V1::ConversationsController do it 'returns conversations' do get :index, params: { since_id: Mastodon::Snowflake.id_at(1.hour.ago, with_random: false) } json = body_as_json - expect(json.size).to eq 1 + expect(json.size).to eq 2 end end diff --git a/spec/controllers/api/v1/notifications_controller_spec.rb b/spec/controllers/api/v1/notifications_controller_spec.rb index 28b8e656a..6615848b8 100644 --- a/spec/controllers/api/v1/notifications_controller_spec.rb +++ b/spec/controllers/api/v1/notifications_controller_spec.rb @@ -67,24 +67,13 @@ RSpec.describe Api::V1::NotificationsController do get :index end - it 'returns http success' do + it 'returns expected notification types', :aggregate_failures do expect(response).to have_http_status(200) - end - it 'includes reblog' do - expect(body_as_json.pluck(:type)).to include 'reblog' - end - - it 'includes mention' do - expect(body_as_json.pluck(:type)).to include 'mention' - end - - it 'includes favourite' do - expect(body_as_json.pluck(:type)).to include 'favourite' - end - - it 'includes follow' do - expect(body_as_json.pluck(:type)).to include 'follow' + expect(body_json_types).to include 'reblog' + expect(body_json_types).to include 'mention' + expect(body_json_types).to include 'favourite' + expect(body_json_types).to include 'follow' end end @@ -93,12 +82,14 @@ RSpec.describe Api::V1::NotificationsController do get :index, params: { account_id: third.account.id } end - it 'returns http success' do + it 'returns only notifications from specified user', :aggregate_failures do expect(response).to have_http_status(200) + + expect(body_json_account_ids.uniq).to eq [third.account.id.to_s] end - it 'returns only notifications from specified user' do - expect(body_as_json.map { |x| x[:account][:id] }.uniq).to eq [third.account.id.to_s] + def body_json_account_ids + body_as_json.map { |x| x[:account][:id] } end end @@ -107,27 +98,23 @@ RSpec.describe Api::V1::NotificationsController do get :index, params: { account_id: 'foo' } end - it 'returns http success' do + it 'returns nothing', :aggregate_failures do expect(response).to have_http_status(200) - end - it 'returns nothing' do expect(body_as_json.size).to eq 0 end end - describe 'with excluded_types param' do + describe 'with exclude_types param' do before do get :index, params: { exclude_types: %w(mention) } end - it 'returns http success' do + it 'returns everything but excluded type', :aggregate_failures do expect(response).to have_http_status(200) - end - it 'returns everything but excluded type' do expect(body_as_json.size).to_not eq 0 - expect(body_as_json.pluck(:type).uniq).to_not include 'mention' + expect(body_json_types.uniq).to_not include 'mention' end end @@ -136,13 +123,15 @@ RSpec.describe Api::V1::NotificationsController do get :index, params: { types: %w(mention) } end - it 'returns http success' do + it 'returns only requested type', :aggregate_failures do expect(response).to have_http_status(200) - end - it 'returns only requested type' do - expect(body_as_json.pluck(:type).uniq).to eq ['mention'] + expect(body_json_types.uniq).to eq ['mention'] end end + + def body_json_types + body_as_json.pluck(:type) + end end end diff --git a/spec/controllers/api/v1/reports_controller_spec.rb b/spec/controllers/api/v1/reports_controller_spec.rb index 0eb9ce170..01b7e4a71 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 00677f1d2..99384c8ed 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/statuses/reactions_controller_spec.rb b/spec/controllers/api/v1/statuses/reactions_controller_spec.rb new file mode 100644 index 000000000..5ca296b2f --- /dev/null +++ b/spec/controllers/api/v1/statuses/reactions_controller_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Api::V1::Statuses::ReactionsController do + render_views + + let(:user) { Fabricate(:user) } + let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write:favourites', application: app) } + + context 'with an oauth token' do + before do + allow(controller).to receive(:doorkeeper_token) { token } + end + + describe 'POST #create' do + let(:status) { Fabricate(:status, account: user.account) } + + before do + post :create, params: { status_id: status.id, id: '👍' } + end + + context 'with public status' do + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'updates the reacted attribute' do + expect(user.account.reacted?(status, '👍')).to be true + end + + it 'returns json with updated attributes' do + hash_body = body_as_json + + expect(hash_body[:id]).to eq status.id.to_s + expect(hash_body[:reactions]).to_not be_empty + expect(hash_body[:reactions][0][:count]).to be 1 + expect(hash_body[:reactions][0][:me]).to be true + expect(hash_body[:reactions][0][:name]).to eq '👍' + end + end + + context 'with private status of not-followed account' do + let(:status) { Fabricate(:status, visibility: :private) } + + it 'returns http not found' do + expect(response).to have_http_status(404) + end + end + end + + describe 'POST #destroy' do + context 'with public status' do + let(:status) { Fabricate(:status, account: user.account) } + + before do + ReactService.new.call(user.account, status, '👍') + post :destroy, params: { status_id: status.id, id: '👍' } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'updates the reacted attribute' do + expect(user.account.reacted?(status, '👍')).to be false + end + + it 'returns json with updated attributes' do + hash_body = body_as_json + + expect(hash_body[:id]).to eq status.id.to_s + expect(hash_body[:reactions]).to be_empty + end + end + + context 'with public status when blocked by its author' do + let(:status) { Fabricate(:status) } + + before do + ReactService.new.call(user.account, status, '👍') + status.account.block!(user.account) + post :destroy, params: { status_id: status.id, id: '👍' } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'updates the reacted attribute' do + expect(user.account.reacted?(status, '👍')).to be false + end + + it 'returns json with updated attributes' do + hash_body = body_as_json + + expect(hash_body[:id]).to eq status.id.to_s + expect(hash_body[:reactions]).to be_empty + end + end + + context 'with private status that was not reacted to' do + let(:status) { Fabricate(:status, visibility: :private) } + + before do + post :destroy, params: { status_id: status.id, id: '👍' } + end + + it 'returns http not found' do + expect(response).to have_http_status(404) + end + end + 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 c61ce0ec0..000000000 --- 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/v1/tags_controller_spec.rb b/spec/controllers/api/v1/tags_controller_spec.rb deleted file mode 100644 index e914f5992..000000000 --- a/spec/controllers/api/v1/tags_controller_spec.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Api::V1::TagsController do - render_views - - let(:user) { Fabricate(:user) } - let(:scopes) { 'write:follows' } - let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } - - before { allow(controller).to receive(:doorkeeper_token) { token } } - - describe 'GET #show' do - before do - get :show, params: { id: name } - end - - context 'with existing tag' do - let!(:tag) { Fabricate(:tag) } - let(:name) { tag.name } - - it 'returns http success' do - expect(response).to have_http_status(:success) - end - end - - context 'with non-existing tag' do - let(:name) { 'hoge' } - - it 'returns http success' do - expect(response).to have_http_status(:success) - end - end - end - - describe 'POST #follow' do - let!(:unrelated_tag) { Fabricate(:tag) } - - before do - TagFollow.create!(account: user.account, tag: unrelated_tag) - - post :follow, params: { id: name } - end - - context 'with existing tag' do - let!(:tag) { Fabricate(:tag) } - let(:name) { tag.name } - - it 'returns http success' do - expect(response).to have_http_status(:success) - end - - it 'creates follow' do - expect(TagFollow.where(tag: tag, account: user.account).exists?).to be true - end - end - - context 'with non-existing tag' do - let(:name) { 'hoge' } - - it 'returns http success' do - expect(response).to have_http_status(:success) - end - - it 'creates follow' do - expect(TagFollow.where(tag: Tag.find_by!(name: name), account: user.account).exists?).to be true - end - end - end - - describe 'POST #unfollow' do - let!(:tag) { Fabricate(:tag, name: 'foo') } - let!(:tag_follow) { Fabricate(:tag_follow, account: user.account, tag: tag) } - - before do - post :unfollow, params: { id: tag.name } - end - - it 'returns http success' do - expect(response).to have_http_status(:success) - end - - it 'removes the follow' do - expect(TagFollow.where(tag: tag, account: user.account).exists?).to be false - end - end -end diff --git a/spec/controllers/api/v2/admin/accounts_controller_spec.rb b/spec/controllers/api/v2/admin/accounts_controller_spec.rb index a775be170..635f64591 100644 --- a/spec/controllers/api/v2/admin/accounts_controller_spec.rb +++ b/spec/controllers/api/v2/admin/accounts_controller_spec.rb @@ -55,5 +55,13 @@ RSpec.describe Api::V2::Admin::AccountsController do end end end + + context 'with limit param' do + let(:params) { { limit: 1 } } + + it 'sets the correct pagination headers' do + expect(response.headers['Link'].find_link(%w(rel next)).href).to eq api_v2_admin_accounts_url(limit: 1, max_id: admin_account.id) + 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 b0c48a5ae..8c4e1a8f2 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 5b7d5d5cd..c727a7633 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 e52103941..098c25ba3 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 d0e1cd390..a0f9c7b91 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 1885814cd..bd98929c0 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 3cf60a48a..c77d604eb 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 105da7e1b..b7824ca60 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 9c45e465e..ec6df0171 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/emoji_react_spec.rb b/spec/lib/activitypub/activity/emoji_react_spec.rb new file mode 100644 index 000000000..1759d4c4d --- /dev/null +++ b/spec/lib/activitypub/activity/emoji_react_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::EmojiReact do + let(:sender) { Fabricate(:account) } + let(:remote_sender) { Fabricate(:account, domain: 'example.com') } + let(:recipient) { Fabricate(:account) } + let(:status) { Fabricate(:status, account: recipient) } + let(:custom_emoji) { Fabricate(:custom_emoji) } + let(:remote_custom_emoji) { Fabricate(:custom_emoji, domain: 'example.com') } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'EmojiReact', + content: '👍', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + }.with_indifferent_access + end + + let(:json_custom_emoji) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'EmojiReact', + content: ":#{custom_emoji.shortcode}:", + tag: ['Emoji', custom_emoji], + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + }.with_indifferent_access + end + + let(:json_remote_custom_emoji) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'EmojiReact', + content: ":#{remote_custom_emoji.shortcode}:", + tag: ['Emoji', remote_custom_emoji], + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + }.with_indifferent_access + end + + describe '#perform' do + context 'with an emoji' do + subject { described_class.new(json, sender) } + + before do + subject.perform + end + + it 'creates a reaction from sender to status' do + expect(sender.reacted?(status, '👍')).to be true + end + end + + context 'with a custom emoji' do + subject { described_class.new(json_custom_emoji, sender) } + + before do + subject.perform + end + + it 'creates a reaction from sender to status' do + expect(sender.reacted?(status, custom_emoji.shortcode)).to be true + end + end + + context 'with a remote custom emoji' do + subject { described_class.new(json_remote_custom_emoji, remote_sender) } + + before do + subject.perform + end + + it 'creates a reaction from sender to status' do + expect(remote_sender.reacted?(status, remote_custom_emoji.shortcode)).to be true + end + end + end +end diff --git a/spec/lib/activitypub/activity/move_spec.rb b/spec/lib/activitypub/activity/move_spec.rb index 8bd23aa7b..f3973c70c 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/activitypub/activity/undo_spec.rb b/spec/lib/activitypub/activity/undo_spec.rb index 58e71fc4e..e8eb78024 100644 --- a/spec/lib/activitypub/activity/undo_spec.rb +++ b/spec/lib/activitypub/activity/undo_spec.rb @@ -176,5 +176,52 @@ RSpec.describe ActivityPub::Activity::Undo do expect(sender.favourited?(status)).to be false end end + + context 'with EmojiReact' do + let(:status) { Fabricate(:status) } + + let(:object_json) do + { + id: 'bar', + type: 'EmojiReact', + content: '👍', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + } + end + + before do + Fabricate(:status_reaction, account: sender, status: status) + end + + it 'deletes reaction from sender to status' do + subject.perform + expect(sender.reacted?(status, '👍')).to be false + end + end + + context 'with EmojiReact containing custom emoji' do + let(:status) { Fabricate(:status) } + let(:custom_emoji) { Fabricate(:custom_emoji) } + + let(:object_json) do + { + id: 'bar', + type: 'EmojiReact', + content: ":#{custom_emoji.shortcode}:", + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + } + end + + before do + Fabricate(:status_reaction, account: sender, status: status, custom_emoji: custom_emoji) + end + + it 'deletes reaction from sender to status' do + subject.perform + expect(sender.reacted?(status, custom_emoji.shortcode)).to be false + end + end end end diff --git a/spec/lib/request_pool_spec.rb b/spec/lib/request_pool_spec.rb index 395268fe4..f179e6ca9 100644 --- a/spec/lib/request_pool_spec.rb +++ b/spec/lib/request_pool_spec.rb @@ -48,16 +48,25 @@ describe RequestPool do expect(subject.size).to be > 1 end - it 'closes idle connections' do - stub_request(:get, 'http://example.com/').to_return(status: 200, body: 'Hello!') - - subject.with('http://example.com') do |http_client| - http_client.get('/').flush + context 'with an idle connection' do + before do + stub_const('RequestPool::MAX_IDLE_TIME', 1) # Lower idle time limit to 1 seconds + stub_const('RequestPool::REAPER_FREQUENCY', 0.1) # Run reaper every 0.1 seconds + stub_request(:get, 'http://example.com/').to_return(status: 200, body: 'Hello!') end - expect(subject.size).to eq 1 - sleep RequestPool::MAX_IDLE_TIME + 30 + 1 - expect(subject.size).to eq 0 + it 'closes the connections' do + subject.with('http://example.com') do |http_client| + http_client.get('/').flush + end + + expect { reaper_observes_idle_timeout }.to change(subject, :size).from(1).to(0) + end + + def reaper_observes_idle_timeout + # One full idle period and 2 reaper cycles more + sleep RequestPool::MAX_IDLE_TIME + (RequestPool::REAPER_FREQUENCY * 2) + end end end end diff --git a/spec/lib/request_spec.rb b/spec/lib/request_spec.rb index e88631e47..f0861376b 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/sanitize_config_spec.rb b/spec/lib/sanitize_config_spec.rb index 586a43d59..cc9916bfd 100644 --- a/spec/lib/sanitize_config_spec.rb +++ b/spec/lib/sanitize_config_spec.rb @@ -36,6 +36,14 @@ describe Sanitize::Config do expect(Sanitize.fragment('Test', subject)).to eq 'Test' end + it 'keeps a with translate="no"' do + expect(Sanitize.fragment('Test', subject)).to eq 'Test' + end + + it 'removes "translate" attribute with invalid value' do + expect(Sanitize.fragment('Test', subject)).to eq 'Test' + end + it 'removes a with unparsable href' do expect(Sanitize.fragment('Test', subject)).to eq 'Test' end diff --git a/spec/lib/suspicious_sign_in_detector_spec.rb b/spec/lib/suspicious_sign_in_detector_spec.rb index c61b1ef1e..9e64aff08 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/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 702aa1c35..3c42a2bb7 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -142,4 +142,59 @@ describe UserMailer do expect(mail.body.encoded).to include I18n.t('user_mailer.appeal_rejected.title') end end + + describe 'two_factor_enabled' do + let(:mail) { described_class.two_factor_enabled(receiver) } + + it 'renders two_factor_enabled mail' do + expect(mail.subject).to eq I18n.t('devise.mailer.two_factor_enabled.subject') + expect(mail.body.encoded).to include I18n.t('devise.mailer.two_factor_enabled.explanation') + end + end + + describe 'two_factor_disabled' do + let(:mail) { described_class.two_factor_disabled(receiver) } + + it 'renders two_factor_disabled mail' do + expect(mail.subject).to eq I18n.t('devise.mailer.two_factor_disabled.subject') + expect(mail.body.encoded).to include I18n.t('devise.mailer.two_factor_disabled.explanation') + end + end + + describe 'webauthn_enabled' do + let(:mail) { described_class.webauthn_enabled(receiver) } + + it 'renders webauthn_enabled mail' do + expect(mail.subject).to eq I18n.t('devise.mailer.webauthn_enabled.subject') + expect(mail.body.encoded).to include I18n.t('devise.mailer.webauthn_enabled.explanation') + end + end + + describe 'webauthn_disabled' do + let(:mail) { described_class.webauthn_disabled(receiver) } + + it 'renders webauthn_disabled mail' do + expect(mail.subject).to eq I18n.t('devise.mailer.webauthn_disabled.subject') + expect(mail.body.encoded).to include I18n.t('devise.mailer.webauthn_disabled.explanation') + end + end + + describe 'two_factor_recovery_codes_changed' do + let(:mail) { described_class.two_factor_recovery_codes_changed(receiver) } + + it 'renders two_factor_recovery_codes_changed mail' do + expect(mail.subject).to eq I18n.t('devise.mailer.two_factor_recovery_codes_changed.subject') + expect(mail.body.encoded).to include I18n.t('devise.mailer.two_factor_recovery_codes_changed.explanation') + end + end + + describe 'webauthn_credential_added' do + let(:credential) { Fabricate.build(:webauthn_credential) } + let(:mail) { described_class.webauthn_credential_added(receiver, credential) } + + it 'renders webauthn_credential_added mail' do + expect(mail.subject).to eq I18n.t('devise.mailer.webauthn_credential.added.subject') + expect(mail.body.encoded).to include I18n.t('devise.mailer.webauthn_credential.added.explanation') + end + end end diff --git a/spec/models/account/field_spec.rb b/spec/models/account/field_spec.rb index 5715a5379..22593bb21 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 d76edddd5..f4544740b 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/concerns/account_interactions_spec.rb b/spec/models/concerns/account_interactions_spec.rb index 41aedd18b..7342e5df1 100644 --- a/spec/models/concerns/account_interactions_spec.rb +++ b/spec/models/concerns/account_interactions_spec.rb @@ -524,6 +524,28 @@ describe AccountInteractions do end end + describe '#reacted?' do + subject { account.reacted?(status, '👍') } + + let(:status) { Fabricate(:status, account: account, status_reactions: reactions) } + + context 'when reacted' do + let(:reactions) { [Fabricate(:status_reaction, account: account)] } + + it 'returns true' do + expect(subject).to be true + end + end + + context 'when not reacted' do + let(:reactions) { [] } + + it 'returns false' do + expect(subject).to be false + end + end + end + describe '#reblogged?' do subject { account.reblogged?(status) } diff --git a/spec/models/notification_spec.rb b/spec/models/notification_spec.rb index d6e228202..d06c2619b 100644 --- a/spec/models/notification_spec.rb +++ b/spec/models/notification_spec.rb @@ -9,6 +9,7 @@ RSpec.describe Notification do let(:reblog) { Fabricate(:status, reblog: status) } let(:favourite) { Fabricate(:favourite, status: status) } let(:mention) { Fabricate(:mention, status: status) } + let(:reaction) { Fabricate(:status_reaction, status: status) } context 'when Activity is reblog' do let(:activity) { reblog } @@ -34,6 +35,14 @@ RSpec.describe Notification do expect(notification.target_status).to eq status end end + + context 'when Activity is react' do + let(:activity) { reaction } + + it 'returns status' do + expect(notification.target_status).to eq status + end + end end describe '#type' do @@ -56,6 +65,11 @@ RSpec.describe Notification do notification = described_class.new(activity: Follow.new) expect(notification.type).to eq :follow end + + it 'returns :reaction for a Reaction' do + notification = Notification.new(activity: StatusReaction.new) + expect(notification.type).to eq :reaction + end end describe '.preload_cache_collection_target_statuses' do @@ -86,6 +100,7 @@ RSpec.describe Notification do let(:follow_request) { Fabricate(:follow_request) } let(:favourite) { Fabricate(:favourite) } let(:poll) { Fabricate(:poll) } + let(:reaction) { Fabricate(:status_reaction) } let(:notifications) do [ @@ -96,6 +111,7 @@ RSpec.describe Notification do Fabricate(:notification, type: :follow_request, activity: follow_request), Fabricate(:notification, type: :favourite, activity: favourite), Fabricate(:notification, type: :poll, activity: poll), + Fabricate(:notification, type: :reaction, activity: reaction), ] end @@ -138,6 +154,12 @@ RSpec.describe Notification do expect(subject[6].association(:poll)).to be_loaded expect(subject[6].poll.association(:status)).to be_loaded end + + it 'preloads reaction' do + expect(subject[7].type).to eq :reaction + expect(subject[7].association(:status_reaction)).to be_loaded + expect(subject[7].reaction.association(:status)).to be_loaded + end end context 'with a cached status' do @@ -180,7 +202,15 @@ RSpec.describe Notification do expect(subject[6].target_status.association(:account)).to be_loaded expect(subject[6].target_status).to eq poll.status end + + it 'replaces reaction' do + # reaction + expect(subject[7].type).to eq :reaction + expect(subject[7].target_status.association(:account)).to be_loaded + expect(subject[7].target_status).to eq reaction.status + end end + # rubocop:enable RSpec/MultipleExpectations end end end diff --git a/spec/models/session_activation_spec.rb b/spec/models/session_activation_spec.rb index 052a06e5c..75842e25b 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 bba585cec..5ed5c5d76 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/models/status_reaction_spec.rb b/spec/models/status_reaction_spec.rb index ccfa9ee8d..d9ca45301 100644 --- a/spec/models/status_reaction_spec.rb +++ b/spec/models/status_reaction_spec.rb @@ -1,3 +1,45 @@ # frozen_string_literal: true require 'rails_helper' + +RSpec.describe StatusReaction do + let(:account) { Fabricate(:account) } + let(:status) { Fabricate(:status) } + + describe 'validations' do + let(:custom_emoji) { Fabricate(:custom_emoji) } + + it 'is invalid without name' do + expect(described_class.new(name: '', account: account, status: status)).to_not be_valid + expect(described_class.new(name: '', account: account, status: status, custom_emoji: custom_emoji)).to_not be_valid + end + + it 'is valid with name' do + expect(described_class.new(name: '👍', account: account, status: status)).to be_valid + expect(described_class.new(name: custom_emoji.shortcode, account: account, status: status, custom_emoji: custom_emoji)).to be_valid + end + end + + describe 'before validations' do + let(:custom_emoji) { Fabricate(:custom_emoji) } + let(:disabled_custom_emoji) { Fabricate(:custom_emoji, disabled: true) } + + context 'when given a custom emoji' do + it 'sets custom_emoji' do + expect(described_class.create(name: custom_emoji.shortcode, account: account, status: status, custom_emoji: custom_emoji).custom_emoji).to eq custom_emoji + end + end + + context 'when not given a custom emoji' do + it 'sets custom_emoji' do + expect(described_class.create(name: custom_emoji.shortcode, account: account, status: status, custom_emoji: nil).custom_emoji).to eq custom_emoji + end + end + + context 'when given a disabled custom emoji' do + it 'sets custom_emoji to nil' do + expect(described_class.create(name: disabled_custom_emoji.shortcode, account: account, status: status, custom_emoji: disabled_custom_emoji).custom_emoji).to be_nil + end + end + end +end diff --git a/spec/models/user_settings_spec.rb b/spec/models/user_settings_spec.rb index f0e4272fd..653597c90 100644 --- a/spec/models/user_settings_spec.rb +++ b/spec/models/user_settings_spec.rb @@ -49,6 +49,16 @@ RSpec.describe UserSettings do expect(subject[:always_send_emails]).to be true end end + + context 'when the setting has a closed set of values' do + it 'updates the attribute when given a valid value' do + expect { subject[:'web.display_media'] = :show_all }.to change { subject[:'web.display_media'] }.from('default').to('show_all') + end + + it 'raises an error when given an invalid value' do + expect { subject[:'web.display_media'] = 'invalid value' }.to raise_error ArgumentError + end + end end describe '#update' do diff --git a/spec/policies/status_policy_spec.rb b/spec/policies/status_policy_spec.rb index 725bd0bbb..308f3cf4e 100644 --- a/spec/policies/status_policy_spec.rb +++ b/spec/policies/status_policy_spec.rb @@ -151,6 +151,24 @@ RSpec.describe StatusPolicy, type: :model do end end + context 'with the permission of react?' do + permissions :react? do + it 'grants access when viewer is not blocked' do + react = Fabricate(:react) + status.account = react.target_account + + expect(subject).to permit(react.account, status) + end + + it 'denies when viewer is blocked' do + block = Fabricate(:block) + status.account = block.target_account + + expect(subject).to_not permit(block.account, status) + end + end + end + context 'with the permission of update?' do permissions :update? do it 'grants access if owner' do diff --git a/spec/policies/webhook_policy_spec.rb b/spec/policies/webhook_policy_spec.rb index 1eac8932d..909311461 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/admin/account_actions_spec.rb b/spec/requests/api/v1/admin/account_actions_spec.rb new file mode 100644 index 000000000..9295d262d --- /dev/null +++ b/spec/requests/api/v1/admin/account_actions_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Account actions' do + let(:role) { UserRole.find_by(name: 'Admin') } + let(:user) { Fabricate(:user, role: role) } + let(:scopes) { 'admin:write admin:write:accounts' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + let(:mailer) { instance_double(ActionMailer::MessageDelivery, deliver_later!: nil) } + + before do + allow(UserMailer).to receive(:warning).with(target_account.user, anything).and_return(mailer) + end + + shared_examples 'a successful notification delivery' do + it 'notifies the user about the action taken' do + subject + + expect(UserMailer).to have_received(:warning).with(target_account.user, anything).once + expect(mailer).to have_received(:deliver_later!).once + end + end + + shared_examples 'a successful logged action' do |action_type, target_type| + it 'logs action' do + subject + + log_item = Admin::ActionLog.last + + expect(log_item).to be_present + expect(log_item.action).to eq(action_type) + expect(log_item.account_id).to eq(user.account_id) + expect(log_item.target_id).to eq(target_type == :user ? target_account.user.id : target_account.id) + end + end + + describe 'POST /api/v1/admin/accounts/:id/action' do + subject do + post "/api/v1/admin/accounts/#{target_account.id}/action", headers: headers, params: params + end + + let(:target_account) { Fabricate(:account) } + + context 'with type of disable' do + let(:params) { { type: 'disable' } } + + it_behaves_like 'forbidden for wrong scope', 'admin:read admin:read:accounts' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'a successful notification delivery' + it_behaves_like 'a successful logged action', :disable, :user + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'disables the target account' do + expect { subject }.to change { target_account.reload.user_disabled? }.from(false).to(true) + end + end + + context 'with type of sensitive' do + let(:params) { { type: 'sensitive' } } + + it_behaves_like 'forbidden for wrong scope', 'admin:read admin:read:accounts' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'a successful notification delivery' + it_behaves_like 'a successful logged action', :sensitive, :account + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'marks the target account as sensitive' do + expect { subject }.to change { target_account.reload.sensitized? }.from(false).to(true) + end + end + + context 'with type of silence' do + let(:params) { { type: 'silence' } } + + it_behaves_like 'forbidden for wrong scope', 'admin:read admin:read:accounts' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'a successful notification delivery' + it_behaves_like 'a successful logged action', :silence, :account + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'marks the target account as silenced' do + expect { subject }.to change { target_account.reload.silenced? }.from(false).to(true) + end + end + + context 'with type of suspend' do + let(:params) { { type: 'suspend' } } + + it_behaves_like 'forbidden for wrong scope', 'admin:read admin:read:accounts' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'a successful notification delivery' + it_behaves_like 'a successful logged action', :suspend, :account + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'marks the target account as suspended' do + expect { subject }.to change { target_account.reload.suspended? }.from(false).to(true) + end + end + + context 'with type of none' do + let(:params) { { type: 'none' } } + + it_behaves_like 'a successful notification delivery' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + end + + context 'with no type' do + let(:params) { {} } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'with invalid type' do + let(:params) { { type: 'invalid' } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + 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 000000000..42b7f8662 --- /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/requests/api/v1/tags_spec.rb b/spec/requests/api/v1/tags_spec.rb new file mode 100644 index 000000000..300ddf805 --- /dev/null +++ b/spec/requests/api/v1/tags_spec.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Tags' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'write:follows' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/tags/:id' do + subject do + get "/api/v1/tags/#{name}" + end + + context 'when the tag exists' do + let!(:tag) { Fabricate(:tag) } + let(:name) { tag.name } + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns the tag' do + subject + + expect(body_as_json[:name]).to eq(name) + end + end + + context 'when the tag does not exist' do + let(:name) { 'hoge' } + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + end + + context 'when the tag name is invalid' do + let(:name) { 'tag-name' } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/tags/:id/follow' do + subject do + post "/api/v1/tags/#{name}/follow", headers: headers + end + + let!(:tag) { Fabricate(:tag) } + let(:name) { tag.name } + + it_behaves_like 'forbidden for wrong scope', 'read read:follows' + + context 'when the tag exists' do + it 'returns http success' do + subject + + expect(response).to have_http_status(:success) + end + + it 'creates follow' do + subject + + expect(TagFollow.where(tag: tag, account: user.account)).to exist + end + end + + context 'when the tag does not exist' do + let(:name) { 'hoge' } + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'creates a new tag with the specified name' do + subject + + expect(Tag.where(name: name)).to exist + end + + it 'creates follow' do + subject + + expect(TagFollow.where(tag: Tag.find_by(name: name), account: user.account)).to exist + end + end + + context 'when the tag name is invalid' do + let(:name) { 'tag-name' } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + + context 'when the Authorization header is missing' do + let(:headers) { {} } + let(:name) { 'unauthorized' } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + end + + describe 'POST #unfollow' do + subject do + post "/api/v1/tags/#{name}/unfollow", headers: headers + end + + let(:name) { tag.name } + let!(:tag) { Fabricate(:tag, name: 'foo') } + + before do + Fabricate(:tag_follow, account: user.account, tag: tag) + end + + it_behaves_like 'forbidden for wrong scope', 'read read:follows' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'removes the follow' do + subject + + expect(TagFollow.where(tag: tag, account: user.account)).to_not exist + end + + context 'when the tag name is invalid' do + let(:name) { 'tag-name' } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + + context 'when the Authorization header is missing' do + let(:headers) { {} } + let(:name) { 'unauthorized' } + + 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 98264e6e1..1cd036f48 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 73e0b42ad..806ba1832 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 5a15ba741..721a0337f 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 09dfb0a0b..281b642ea 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 da7e42351..0f1068471 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 32ba4409c..1904ac8dc 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 76ef5391f..d201292e1 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/react_service_spec.rb b/spec/services/react_service_spec.rb new file mode 100644 index 000000000..de72e5684 --- /dev/null +++ b/spec/services/react_service_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ReactService, type: :service do + subject { described_class.new } + + let(:sender) { Fabricate(:account, username: 'alice') } + + describe 'local' do + let(:bob) { Fabricate(:account) } + let(:status) { Fabricate(:status, account: bob) } + + before do + subject.call(sender, status, '👍') + end + + it 'creates a reaction' do + expect(status.reactions.first).to_not be_nil + end + end + + describe 'remote ActivityPub' do + let(:bob) { Fabricate(:account, protocol: :activitypub, username: 'bob', domain: 'example.com', inbox_url: 'http://example.com/inbox') } + let(:status) { Fabricate(:status, account: bob) } + + before do + stub_request(:post, 'http://example.com/inbox').to_return(status: 200, body: '', headers: {}) + subject.call(sender, status, '👍') + end + + it 'creates a reaction' do + expect(status.reactions.first).to_not be_nil + end + + it 'sends a react activity' do + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once + end + end +end diff --git a/spec/services/resolve_url_service_spec.rb b/spec/services/resolve_url_service_spec.rb index 8d2af7417..ad5bebb4e 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) diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb index 00f693dfa..1283a23bf 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,7 +64,7 @@ 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) diff --git a/spec/services/unreact_service_spec.rb b/spec/services/unreact_service_spec.rb new file mode 100644 index 000000000..45dd3fb06 --- /dev/null +++ b/spec/services/unreact_service_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe UnreactService, type: :service do + subject { described_class.new } + + let(:sender) { Fabricate(:account, username: 'alice') } + + describe 'local' do + let(:bob) { Fabricate(:account) } + let(:status) { Fabricate(:status, account: bob) } + + before do + sender.status_reactions.find_or_create_by!(status: status, name: '👍') + subject.call(sender, status, '👍') + end + + it 'removes a reaction' do + expect(status.reactions.first).to be_nil + end + end + + describe 'remote ActivityPub' do + let(:bob) { Fabricate(:account, protocol: :activitypub, username: 'bob', domain: 'example.com', inbox_url: 'http://example.com/inbox') } + let(:status) { Fabricate(:status, account: bob) } + + before do + sender.status_reactions.find_or_create_by!(status: status, name: '👍') + stub_request(:post, 'http://example.com/inbox').to_return(status: 200, body: '', headers: {}) + subject.call(sender, status, '👍') + end + + it 'removes a reaction' do + expect(status.reactions.first).to be_nil + end + + it 'sends an undo activity' do + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once + end + end +end diff --git a/spec/services/unsuspend_account_service_spec.rb b/spec/services/unsuspend_account_service_spec.rb index e02ae41b9..7ef2630ae 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 a642405ae..3d3d50f65 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 e98db3879..7144d2891 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 d9703d81b..876d73c18 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 7b9055a27..86b6511d6 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 e45d221d7..66fccad3e 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 069a47161..95feb043d 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 7e06b9bd9..8535ddd75 100644 --- a/spec/validators/status_length_validator_spec.rb +++ b/spec/validators/status_length_validator_spec.rb @@ -5,27 +5,27 @@ 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 MAX_CHARS characters' do chars = StatusLengthValidator::MAX_CHARS + 1 - status = double(spoiler_text: 'a' * chars, text: '', errors: double(add: nil), local?: true, reblog?: false) + status = instance_double(Status, spoiler_text: 'a' * chars, 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 MAX_CHARS characters' do chars = StatusLengthValidator::MAX_CHARS + 1 - status = double(spoiler_text: '', text: 'a' * chars, errors: double(add: nil), local?: true, reblog?: false) + status = instance_double(Status, spoiler_text: '', text: 'a' * chars, errors: activemodel_errors, local?: true, reblog?: false) subject.validate(status) expect(status.errors).to have_received(:add) end @@ -33,7 +33,7 @@ describe StatusLengthValidator do it 'adds an error when text and content warning are over MAX_CHARS characters total' do chars1 = 20 chars2 = StatusLengthValidator::MAX_CHARS + 1 - chars1 - status = double(spoiler_text: 'a' * chars1, text: 'b' * chars2, errors: double(add: nil), local?: true, reblog?: false) + status = instance_double(Status, spoiler_text: 'a' * chars1, text: 'b' * chars2, errors: activemodel_errors, local?: true, reblog?: false) subject.validate(status) expect(status.errors).to have_received(:add) end @@ -41,7 +41,7 @@ describe StatusLengthValidator do it 'counts URLs as 23 characters flat' do chars = StatusLengthValidator::MAX_CHARS - 1 - 23 text = ('a' * chars) + " 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) @@ -49,7 +49,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) @@ -57,7 +57,7 @@ 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 @@ -66,7 +66,7 @@ describe StatusLengthValidator do username = '@alice' chars = StatusLengthValidator::MAX_CHARS - 1 - username.length text = ('a' * chars) + " #{username}@#{'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) @@ -74,10 +74,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 00b89d702..e8f8a4543 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/status_reaction_validator_spec.rb b/spec/validators/status_reaction_validator_spec.rb new file mode 100644 index 000000000..27dabaa80 --- /dev/null +++ b/spec/validators/status_reaction_validator_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe StatusReactionValidator do + let(:status) { Fabricate(:status) } + let(:account) { Fabricate(:account) } + let(:remote_account) { Fabricate(:account, domain: 'example.com') } + + describe '#validate' do + it 'adds error when not a valid unicode emoji' do + reaction = status.reactions.build(name: 'F', account: account) + subject.validate(reaction) + expect(reaction.errors).to_not be_empty + end + + it 'does not add error when non-unicode emoji is a custom emoji' do + custom_emoji = Fabricate(:custom_emoji) + reaction = status.reactions.build(name: custom_emoji.shortcode, custom_emoji: custom_emoji, account: account) + subject.validate(reaction) + expect(reaction.errors).to be_empty + end + + it 'adds error when local account already reacted' do + status.reactions.create!(name: '👍', account: account) + + reaction = status.reactions.build(name: '😘', account: account) + subject.validate(reaction) + expect(reaction.errors).to_not be_empty + end + + it 'does not add error when remote account already reacted' do + status.reactions.create!(name: '👍', account: remote_account) + + reaction = status.reactions.build(name: '😘', account: remote_account) + subject.validate(reaction) + expect(reaction.errors).to be_empty + end + end +end diff --git a/spec/validators/unique_username_validator_spec.rb b/spec/validators/unique_username_validator_spec.rb index 6867cbc6c..0d172c840 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 85bd7dcb6..6f353eeaf 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 a56ccd8e0..f2220e32b 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 370743dfe..06f5132d9 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 6b57f16a9..66d1cf489 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 b67c58b23..861fd71a7 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 8b98443fa..33c3ca009 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 f21d1fe18..21f8f87b2 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 16f7d73e0..97c73c599 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 ac7bd506b..7577f6e89 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 0977bba1e..2e50d4a50 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 1dac15385..5718d4db4 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 147a76be5..37b0a04c4 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/spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb b/spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb index 5565636d5..4d9185093 100644 --- a/spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb +++ b/spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb @@ -75,6 +75,12 @@ describe Scheduler::AccountsStatusesCleanupScheduler do end describe '#perform' do + around do |example| + Timeout.timeout(30) do + example.run + end + end + before do # Policies for the accounts Fabricate(:account_statuses_cleanup_policy, account: account_alice) diff --git a/spec/workers/unreact_worker_spec.rb b/spec/workers/unreact_worker_spec.rb new file mode 100644 index 000000000..ed4fc5ecf --- /dev/null +++ b/spec/workers/unreact_worker_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe UnreactWorker do + let(:worker) { described_class.new } + + describe 'perform' do + it 'runs without error for missing record' do + account_id = nil + status_id = nil + emoji = nil + expect { worker.perform(account_id, status_id, emoji) }.to_not raise_error + end + end +end diff --git a/yarn.lock b/yarn.lock index f2d02f0c8..3149f2ff3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4724,9 +4724,9 @@ domutils@^3.0.1: domhandler "^5.0.3" dotenv@^16.0.3: - version "16.1.4" - resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.1.4.tgz#67ac1a10cd9c25f5ba604e4e08bc77c0ebe0ca8c" - integrity sha512-m55RtE8AsPeJBpOIFKihEmqUcoVncQIwo7x9U8ZwLEZw9ZpXboz2c+rvog+jUaJvVrZ5kBOeYQBX5+8Aa/OZQw== + version "16.3.1" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" + integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ== duplexer@^0.1.2: version "0.1.2" @@ -5841,9 +5841,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" @@ -8042,9 +8042,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" @@ -8769,15 +8769,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" @@ -8789,10 +8794,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" @@ -8824,19 +8829,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" @@ -9582,9 +9587,9 @@ react-redux-loading-bar@^5.0.4: react-lifecycles-compat "^3.0.4" 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" @@ -9702,9 +9707,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" @@ -10171,9 +10176,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" @@ -10747,6 +10752,7 @@ string-length@^4.0.1: strip-ansi "^6.0.0" "string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + name string-width-cjs version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -12246,6 +12252,7 @@ workbox-window@7.0.0, workbox-window@^7.0.0: workbox-core "7.0.0" "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: + name wrap-ansi-cjs version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==