~cytrogen/masto-fe

d9adda1a991dde52949489208c948b0502af8a3f — Claire 2 years ago a004718 + 71db616
Merge commit '71db616fed817893d0efa363f0e7dbfcf23866a0' into glitch-soc/merge-upstream
M .github/renovate.json5 => .github/renovate.json5 +0 -7
@@ 15,7 15,6 @@
      // Ignore major version bumps for these node packages
      matchManagers: ['npm'],
      matchPackageNames: [
        '@rails/ujs', // Needs to match the major Rails version
        'tesseract.js', // Requires code changes
        'react-hotkeys', // Requires code changes



@@ 51,12 50,6 @@
        'sidekiq', // Requires manual upgrade
        'sidekiq-unique-jobs', // Requires manual upgrades and sync with Sidekiq version
        'redis', // Requires manual upgrade and sync with Sidekiq version
        'fog-openstack', // TODO: was ignored in https://github.com/mastodon/mastodon/pull/13964

        // Needs major Rails version bump
        'rack',
        'rails',
        'rails-i18n',
      ],
      matchUpdateTypes: ['major'],
      enabled: false,

M app/controllers/api/web/embeds_controller.rb => app/controllers/api/web/embeds_controller.rb +24 -13
@@ 1,25 1,36 @@
# frozen_string_literal: true

class Api::Web::EmbedsController < Api::Web::BaseController
  before_action :require_user!
  include Authorization

  def create
    status = StatusFinder.new(params[:url]).status
  before_action :set_status

    return not_found if status.hidden?
  def show
    return not_found if @status.hidden?

    render json: status, serializer: OEmbedSerializer, width: 400
  rescue ActiveRecord::RecordNotFound
    oembed = FetchOEmbedService.new.call(params[:url])
    if @status.local?
      render json: @status, serializer: OEmbedSerializer, width: 400
    else
      return not_found unless user_signed_in?

    return not_found if oembed.nil?
      url = ActivityPub::TagManager.instance.url_for(@status)
      oembed = FetchOEmbedService.new.call(url)
      return not_found if oembed.nil?

    begin
      oembed[:html] = Sanitize.fragment(oembed[:html], Sanitize::Config::MASTODON_OEMBED)
    rescue ArgumentError
      return not_found
      begin
        oembed[:html] = Sanitize.fragment(oembed[:html], Sanitize::Config::MASTODON_OEMBED)
      rescue ArgumentError
        return not_found
      end

      render json: oembed
    end
  end

    render json: oembed
  def set_status
    @status = Status.find(params[:id])
    authorize @status, :show?
  rescue Mastodon::NotPermittedError
    not_found
  end
end

A app/javascript/mastodon/components/router.tsx => app/javascript/mastodon/components/router.tsx +23 -0
@@ 0,0 1,23 @@
import type { PropsWithChildren } from 'react';
import React from 'react';

import type { History } from 'history';
import { createBrowserHistory } from 'history';
import { Router as OriginalRouter } from 'react-router';

import { layoutFromWindow } from 'mastodon/is_mobile';

const browserHistory = createBrowserHistory();
const originalPush = browserHistory.push.bind(browserHistory);

browserHistory.push = (path: string, state: History.LocationState) => {
  if (layoutFromWindow() === 'multi-column' && !path.startsWith('/deck')) {
    originalPush(`/deck${path}`, state);
  } else {
    originalPush(path, state);
  }
};

export const Router: React.FC<PropsWithChildren> = ({ children }) => {
  return <OriginalRouter history={browserHistory}>{children}</OriginalRouter>;
};

M app/javascript/mastodon/components/status_action_bar.jsx => app/javascript/mastodon/components/status_action_bar.jsx +1 -1
@@ 258,7 258,7 @@ class StatusActionBar extends ImmutablePureComponent {
      menu.push({ text: intl.formatMessage(messages.share), action: this.handleShareClick });
    }

    if (publicStatus) {
    if (publicStatus && (signedIn || !isRemote)) {
      menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
    }


M app/javascript/mastodon/containers/mastodon.jsx => app/javascript/mastodon/containers/mastodon.jsx +4 -3
@@ 2,7 2,7 @@ import PropTypes from 'prop-types';
import { PureComponent } from 'react';

import { Helmet } from 'react-helmet';
import { BrowserRouter, Route } from 'react-router-dom';
import { Route } from 'react-router-dom';

import { Provider as ReduxProvider } from 'react-redux';



@@ 12,6 12,7 @@ import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
import { hydrateStore } from 'mastodon/actions/store';
import { connectUserStream } from 'mastodon/actions/streaming';
import ErrorBoundary from 'mastodon/components/error_boundary';
import { Router } from 'mastodon/components/router';
import UI from 'mastodon/features/ui';
import initialState, { title as siteTitle } from 'mastodon/initial_state';
import { IntlProvider } from 'mastodon/locales';


@@ 75,11 76,11 @@ export default class Mastodon extends PureComponent {
      <IntlProvider>
        <ReduxProvider store={store}>
          <ErrorBoundary>
            <BrowserRouter>
            <Router>
              <ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
                <Route path='/' component={UI} />
              </ScrollContext>
            </BrowserRouter>
            </Router>

            <Helmet defaultTitle={title} titleTemplate={`%s - ${title}`} />
          </ErrorBoundary>

M app/javascript/mastodon/containers/status_container.jsx => app/javascript/mastodon/containers/status_container.jsx +1 -1
@@ 139,7 139,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
    dispatch(openModal({
      modalType: 'EMBED',
      modalProps: {
        url: status.get('url'),
        id: status.get('id'),
        onError: error => dispatch(showAlertForError(error)),
      },
    }));

M app/javascript/mastodon/features/getting_started/index.jsx => app/javascript/mastodon/features/getting_started/index.jsx +1 -1
@@ 142,7 142,7 @@ class GettingStarted extends ImmutablePureComponent {

          {!multiColumn && <div className='flex-spacer' />}

          <LinkFooter />
          <LinkFooter multiColumn />
        </div>

        {(multiColumn && showTrends) && <TrendsContainer />}

M app/javascript/mastodon/features/status/components/action_bar.jsx => app/javascript/mastodon/features/status/components/action_bar.jsx +1 -1
@@ 205,7 205,7 @@ class ActionBar extends PureComponent {
      menu.push({ text: intl.formatMessage(messages.share), action: this.handleShare });
    }

    if (publicStatus) {
    if (publicStatus && (signedIn || !isRemote)) {
      menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
    }


M app/javascript/mastodon/features/status/containers/detailed_status_container.js => app/javascript/mastodon/features/status/containers/detailed_status_container.js +1 -1
@@ 110,7 110,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
    dispatch(openModal({
      modalType: 'EMBED',
      modalProps: {
        url: status.get('url'),
        id: status.get('id'),
        onError: error => dispatch(showAlertForError(error)),
      },
    }));

M app/javascript/mastodon/features/status/index.jsx => app/javascript/mastodon/features/status/index.jsx +1 -1
@@ 449,7 449,7 @@ class Status extends ImmutablePureComponent {
  handleEmbed = (status) => {
    this.props.dispatch(openModal({
      modalType: 'EMBED',
      modalProps: { url: status.get('url') },
      modalProps: { id: status.get('id') },
    }));
  };


M app/javascript/mastodon/features/ui/components/embed_modal.jsx => app/javascript/mastodon/features/ui/components/embed_modal.jsx +3 -3
@@ 14,7 14,7 @@ const messages = defineMessages({
class EmbedModal extends ImmutablePureComponent {

  static propTypes = {
    url: PropTypes.string.isRequired,
    id: PropTypes.string.isRequired,
    onClose: PropTypes.func.isRequired,
    onError: PropTypes.func.isRequired,
    intl: PropTypes.object.isRequired,


@@ 26,11 26,11 @@ class EmbedModal extends ImmutablePureComponent {
  };

  componentDidMount () {
    const { url } = this.props;
    const { id } = this.props;

    this.setState({ loading: true });

    api().post('/api/web/embed', { url }).then(res => {
    api().get(`/api/web/embeds/${id}`).then(res => {
      this.setState({ loading: false, oembed: res.data });

      const iframeDocument = this.iframe.contentWindow.document;

M app/javascript/mastodon/features/ui/components/link_footer.jsx => app/javascript/mastodon/features/ui/components/link_footer.jsx +4 -2
@@ 38,6 38,7 @@ class LinkFooter extends PureComponent {
  };

  static propTypes = {
    multiColumn: PropTypes.bool,
    onLogout: PropTypes.func.isRequired,
    intl: PropTypes.object.isRequired,
  };


@@ 53,6 54,7 @@ class LinkFooter extends PureComponent {

  render () {
    const { signedIn, permissions } = this.context.identity;
    const { multiColumn } = this.props;

    const canInvite = signedIn && ((permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS);
    const canProfileDirectory = profileDirectory;


@@ 64,7 66,7 @@ class LinkFooter extends PureComponent {
        <p>
          <strong>{domain}</strong>:
          {' '}
          <Link to='/about'><FormattedMessage id='footer.about' defaultMessage='About' /></Link>
          <Link to='/about' target={multiColumn ? '_blank' : undefined}><FormattedMessage id='footer.about' defaultMessage='About' /></Link>
          {statusPageUrl && (
            <>
              {DividingCircle}


@@ 84,7 86,7 @@ class LinkFooter extends PureComponent {
            </>
          )}
          {DividingCircle}
          <Link to='/privacy-policy'><FormattedMessage id='footer.privacy_policy' defaultMessage='Privacy policy' /></Link>
          <Link to='/privacy-policy' target={multiColumn ? '_blank' : undefined}><FormattedMessage id='footer.privacy_policy' defaultMessage='Privacy policy' /></Link>
        </p>

        <p>

M app/javascript/mastodon/features/ui/components/navigation_panel.jsx => app/javascript/mastodon/features/ui/components/navigation_panel.jsx +8 -0
@@ 8,6 8,7 @@ import { Link } from 'react-router-dom';
import { WordmarkLogo } from 'mastodon/components/logo';
import NavigationPortal from 'mastodon/components/navigation_portal';
import { timelinePreview, trendsEnabled } from 'mastodon/initial_state';
import { transientSingleColumn } from 'mastodon/is_mobile';

import ColumnLink from './column_link';
import DisabledAccountBanner from './disabled_account_banner';


@@ 29,6 30,7 @@ const messages = defineMessages({
  followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' },
  about: { id: 'navigation_bar.about', defaultMessage: 'About' },
  search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
  advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface' },
});

class NavigationPanel extends Component {


@@ 54,6 56,12 @@ class NavigationPanel extends Component {
      <div className='navigation-panel'>
        <div className='navigation-panel__logo'>
          <Link to='/' className='column-link column-link--logo'><WordmarkLogo /></Link>

          {transientSingleColumn && (
            <a href={`/deck${location.pathname}`} className='button button--block'>
              {intl.formatMessage(messages.advancedInterface)}
            </a>
          )}
          <hr />
        </div>


M app/javascript/mastodon/features/ui/index.jsx => app/javascript/mastodon/features/ui/index.jsx +14 -10
@@ 126,11 126,11 @@ class SwitchingColumnsArea extends PureComponent {
  static propTypes = {
    children: PropTypes.node,
    location: PropTypes.object,
    mobile: PropTypes.bool,
    singleColumn: PropTypes.bool,
  };

  UNSAFE_componentWillMount () {
    if (this.props.mobile) {
    if (this.props.singleColumn) {
      document.body.classList.toggle('layout-single-column', true);
      document.body.classList.toggle('layout-multiple-columns', false);
    } else {


@@ 144,9 144,9 @@ class SwitchingColumnsArea extends PureComponent {
      this.node.handleChildrenContentChange();
    }

    if (prevProps.mobile !== this.props.mobile) {
      document.body.classList.toggle('layout-single-column', this.props.mobile);
      document.body.classList.toggle('layout-multiple-columns', !this.props.mobile);
    if (prevProps.singleColumn !== this.props.singleColumn) {
      document.body.classList.toggle('layout-single-column', this.props.singleColumn);
      document.body.classList.toggle('layout-multiple-columns', !this.props.singleColumn);
    }
  }



@@ 157,16 157,17 @@ class SwitchingColumnsArea extends PureComponent {
  };

  render () {
    const { children, mobile } = this.props;
    const { children, singleColumn } = this.props;
    const { signedIn } = this.context.identity;
    const pathName = this.props.location.pathname;

    let redirect;

    if (signedIn) {
      if (mobile) {
      if (singleColumn) {
        redirect = <Redirect from='/' to='/home' exact />;
      } else {
        redirect = <Redirect from='/' to='/getting-started' exact />;
        redirect = <Redirect from='/' to='/deck/getting-started' exact />;
      }
    } else if (singleUserMode && owner && initialState?.accounts[owner]) {
      redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />;


@@ 177,10 178,13 @@ class SwitchingColumnsArea extends PureComponent {
    }

    return (
      <ColumnsAreaContainer ref={this.setRef} singleColumn={mobile}>
      <ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn}>
        <WrappedSwitch>
          {redirect}

          {singleColumn ? <Redirect from='/deck' to='/home' exact /> : null}
          {singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={pathName.slice(5)} /> : null}

          <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
          <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
          <WrappedRoute path='/about' component={About} content={children} />


@@ 573,7 577,7 @@ class UI extends PureComponent {
        <div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef} style={{ pointerEvents: dropdownMenuIsOpen ? 'none' : null }}>
          <Header />

          <SwitchingColumnsArea location={location} mobile={layout === 'mobile' || layout === 'single-column'}>
          <SwitchingColumnsArea location={location} singleColumn={layout === 'mobile' || layout === 'single-column'}>
            {children}
          </SwitchingColumnsArea>


M app/javascript/mastodon/features/ui/util/react_router_helpers.jsx => app/javascript/mastodon/features/ui/util/react_router_helpers.jsx +10 -2
@@ 11,13 11,21 @@ import BundleContainer from '../containers/bundle_container';

// Small wrapper to pass multiColumn to the route components
export class WrappedSwitch extends PureComponent {
  static contextTypes = {
    router: PropTypes.object,
  };

  render () {
    const { multiColumn, children } = this.props;
    const { location } = this.context.router.route;

    const decklessLocation = multiColumn && location.pathname.startsWith('/deck')
      ? {...location, pathname: location.pathname.slice(5)}
      : location;

    return (
      <Switch>
        {Children.map(children, child => cloneElement(child, { multiColumn }))}
      <Switch location={decklessLocation}>
        {Children.map(children, child => child ? cloneElement(child, { multiColumn }) : null)}
      </Switch>
    );
  }

M app/javascript/mastodon/initial_state.js => app/javascript/mastodon/initial_state.js +7 -0
@@ 95,6 95,13 @@ const element = document.getElementById('initial-state');
/** @type {InitialState | undefined} */
const initialState = element?.textContent && JSON.parse(element.textContent);

/** @type {string} */
const initialPath = document.querySelector("head meta[name=initialPath]")?.getAttribute("content") ?? '';
/** @type {boolean} */
export const hasMultiColumnPath = initialPath === '/'
  || initialPath === '/getting-started'
  || initialPath.startsWith('/deck');

/**
 * @template {keyof InitialStateMeta} K
 * @param {K} prop

M app/javascript/mastodon/is_mobile.ts => app/javascript/mastodon/is_mobile.ts +6 -4
@@ 1,19 1,21 @@
import { supportsPassiveEvents } from 'detect-passive-events';

import { forceSingleColumn } from './initial_state';
import { forceSingleColumn, hasMultiColumnPath } from './initial_state';

const LAYOUT_BREAKPOINT = 630;

export const isMobile = (width: number) => width <= LAYOUT_BREAKPOINT;

export const transientSingleColumn = !forceSingleColumn && !hasMultiColumnPath;

export type LayoutType = 'mobile' | 'single-column' | 'multi-column';
export const layoutFromWindow = (): LayoutType => {
  if (isMobile(window.innerWidth)) {
    return 'mobile';
  } else if (forceSingleColumn) {
    return 'single-column';
  } else {
  } else if (!forceSingleColumn && !transientSingleColumn) {
    return 'multi-column';
  } else {
    return 'single-column';
  }
};


M app/javascript/mastodon/locales/en.json => app/javascript/mastodon/locales/en.json +1 -0
@@ 385,6 385,7 @@
  "mute_modal.hide_notifications": "Hide notifications from this user?",
  "mute_modal.indefinite": "Indefinite",
  "navigation_bar.about": "About",
  "navigation_bar.advanced_interface": "Open in advanced web interface",
  "navigation_bar.blocks": "Blocked users",
  "navigation_bar.bookmarks": "Bookmarks",
  "navigation_bar.community_timeline": "Local timeline",

M app/javascript/mastodon/locales/fr.json => app/javascript/mastodon/locales/fr.json +1 -0
@@ 368,6 368,7 @@
  "mute_modal.hide_notifications": "Masquer les notifications de cette personne ?",
  "mute_modal.indefinite": "Indéfinie",
  "navigation_bar.about": "À propos",
  "navigation_bar.advanced_interface": "Ouvrir dans l’interface avancée",
  "navigation_bar.blocks": "Comptes bloqués",
  "navigation_bar.bookmarks": "Marque-pages",
  "navigation_bar.community_timeline": "Fil public local",

M app/models/status.rb => app/models/status.rb +3 -1
@@ 106,7 106,9 @@ class Status < ApplicationRecord
  scope :not_domain_blocked_by_account, ->(account) { account.excluded_from_timeline_domains.blank? ? left_outer_joins(:account) : left_outer_joins(:account).where('accounts.domain IS NULL OR accounts.domain NOT IN (?)', account.excluded_from_timeline_domains) }
  scope :tagged_with_all, lambda { |tag_ids|
    Array(tag_ids).map(&:to_i).reduce(self) do |result, id|
      result.joins("INNER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}")
      result.where(<<~SQL.squish, tag_id: id)
        EXISTS(SELECT 1 FROM statuses_tags WHERE statuses_tags.status_id = statuses.id AND statuses_tags.tag_id = :tag_id)
      SQL
    end
  }
  scope :tagged_with_none, lambda { |tag_ids|

M app/views/shared/_web_app.html.haml => app/views/shared/_web_app.html.haml +1 -0
@@ 3,6 3,7 @@
    = preload_pack_asset 'features/compose.js', crossorigin: 'anonymous'
    = preload_pack_asset 'features/home_timeline.js', crossorigin: 'anonymous'
    = preload_pack_asset 'features/notifications.js', crossorigin: 'anonymous'
    %meta{ name: 'initialPath', content: request.path }

  %meta{ name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key }


M config/brakeman.ignore => config/brakeman.ignore +1 -24
@@ 1,29 1,6 @@
{
  "ignored_warnings": [
    {
      "warning_type": "SQL Injection",
      "warning_code": 0,
      "fingerprint": "19df3740b8d02a9fe0eb52c939b4b87d3a2a591162a6adfa8d64e9c26aeebe6d",
      "check_name": "SQL",
      "message": "Possible SQL injection",
      "file": "app/models/status.rb",
      "line": 106,
      "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
      "code": "result.joins(\"INNER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}\")",
      "render_path": null,
      "location": {
        "type": "method",
        "class": "Status",
        "method": null
      },
      "user_input": "id",
      "confidence": "Weak",
      "cwe_id": [
        89
      ],
      "note": ""
    },
    {
      "warning_type": "Cross-Site Scripting",
      "warning_code": 2,
      "fingerprint": "71cf98c8235b5cfa9946b5db8fdc1a2f3a862566abb34e4542be6f3acae78233",


@@ 206,6 183,6 @@
      "note": ""
    }
  ],
  "updated": "2023-07-11 16:08:58 +0200",
  "updated": "2023-07-12 11:20:51 -0400",
  "brakeman_version": "6.0.0"
}

M config/routes.rb => config/routes.rb +1 -0
@@ 31,6 31,7 @@ Rails.application.routes.draw do
    /mutes
    /followed_tags
    /statuses/(*any)
    /deck/(*any)
  ).freeze

  root 'home#index'

M config/routes/api.rb => config/routes/api.rb +1 -1
@@ 298,7 298,7 @@ namespace :api, format: false do

  namespace :web do
    resource :settings, only: [:update]
    resource :embed, only: [:create]
    resources :embeds, only: [:show]
    resources :push_subscriptions, only: [:create] do
      member do
        put :update

M package.json => package.json +1 -0
@@ 109,6 109,7 @@
    "react-overlays": "^5.2.1",
    "react-redux": "^8.0.4",
    "react-redux-loading-bar": "^5.0.4",
    "react-router": "^4.3.1",
    "react-router-dom": "^4.1.1",
    "react-router-scroll-4": "^1.0.0-beta.1",
    "react-select": "^5.7.3",

D spec/controllers/api/web/embeds_controller_spec.rb => spec/controllers/api/web/embeds_controller_spec.rb +0 -54
@@ 1,54 0,0 @@
# frozen_string_literal: true

require 'rails_helper'

describe Api::Web::EmbedsController do
  render_views

  let(:user) { Fabricate(:user) }

  before { sign_in user }

  describe 'POST #create' do
    subject(:body) { JSON.parse(response.body, symbolize_names: true) }

    let(:response) { post :create, params: { url: url } }

    context 'when successfully finds status' do
      let(:status) { Fabricate(:status) }
      let(:url) { "http://#{Rails.configuration.x.web_domain}/@#{status.account.username}/#{status.id}" }

      it 'returns a right response' do
        expect(response).to have_http_status 200
        expect(body[:author_name]).to eq status.account.username
      end
    end

    context 'when fails to find status' do
      let(:url) { 'https://host.test/oembed.html' }
      let(:service_instance) { instance_double(FetchOEmbedService) }

      before do
        allow(FetchOEmbedService).to receive(:new) { service_instance }
        allow(service_instance).to receive(:call) { call_result }
      end

      context 'when successfully fetching oembed' do
        let(:call_result) { { result: :ok } }

        it 'returns a right response' do
          expect(response).to have_http_status 200
          expect(body[:result]).to eq 'ok'
        end
      end

      context 'when fails to fetch oembed' do
        let(:call_result) { nil }

        it 'returns a right response' do
          expect(response).to have_http_status 404
        end
      end
    end
  end
end

A spec/requests/api/web/embeds_spec.rb => spec/requests/api/web/embeds_spec.rb +161 -0
@@ 0,0 1,161 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe '/api/web/embed' do
  subject { get "/api/web/embeds/#{id}", headers: headers }

  context 'when accessed anonymously' do
    let(:headers) { {} }

    context 'when the requested status is local' do
      let(:id) { status.id }

      context 'when the requested status is public' do
        let(:status) { Fabricate(:status, visibility: :public) }

        it 'returns JSON with an html attribute' do
          subject

          expect(response).to have_http_status(200)
          expect(body_as_json[:html]).to be_present
        end
      end

      context 'when the requested status is private' do
        let(:status) { Fabricate(:status, visibility: :private) }

        it 'returns http not found' do
          subject

          expect(response).to have_http_status(404)
        end
      end
    end

    context 'when the requested status is remote' do
      let(:remote_account) { Fabricate(:account, domain: 'example.com') }
      let(:status)         { Fabricate(:status, visibility: :public, account: remote_account, url: 'https://example.com/statuses/1') }
      let(:id)             { status.id }

      it 'returns http not found' do
        subject

        expect(response).to have_http_status(404)
      end
    end

    context 'when the requested status does not exist' do
      let(:id) { -1 }

      it 'returns http not found' do
        subject

        expect(response).to have_http_status(404)
      end
    end
  end

  context 'with an API token' do
    let(:user)    { Fabricate(:user) }
    let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read') }
    let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }

    context 'when the requested status is local' do
      let(:id) { status.id }

      context 'when the requested status is public' do
        let(:status) { Fabricate(:status, visibility: :public) }

        it 'returns JSON with an html attribute' do
          subject

          expect(response).to have_http_status(200)
          expect(body_as_json[:html]).to be_present
        end

        context 'when the requesting user is blocked' do
          before do
            status.account.block!(user.account)
          end

          it 'returns http not found' do
            subject

            expect(response).to have_http_status(404)
          end
        end
      end

      context 'when the requested status is private' do
        let(:status) { Fabricate(:status, visibility: :private) }

        before do
          user.account.follow!(status.account)
        end

        it 'returns http not found' do
          subject

          expect(response).to have_http_status(404)
        end
      end
    end

    context 'when the requested status is remote' do
      let(:remote_account) { Fabricate(:account, domain: 'example.com') }
      let(:status)         { Fabricate(:status, visibility: :public, account: remote_account, url: 'https://example.com/statuses/1') }
      let(:id)             { status.id }

      let(:service_instance) { instance_double(FetchOEmbedService) }

      before do
        allow(FetchOEmbedService).to receive(:new) { service_instance }
        allow(service_instance).to receive(:call) { call_result }
      end

      context 'when the requesting user is blocked' do
        before do
          status.account.block!(user.account)
        end

        it 'returns http not found' do
          subject

          expect(response).to have_http_status(404)
        end
      end

      context 'when successfully fetching OEmbed' do
        let(:call_result) { { html: 'ok' } }

        it 'returns JSON with an html attribute' do
          subject

          expect(response).to have_http_status(200)
          expect(body_as_json[:html]).to be_present
        end
      end

      context 'when failing to fetch OEmbed' do
        let(:call_result) { nil }

        it 'returns http not found' do
          subject

          expect(response).to have_http_status(404)
        end
      end
    end

    context 'when the requested status does not exist' do
      let(:id) { -1 }

      it 'returns http not found' do
        subject

        expect(response).to have_http_status(404)
      end
    end
  end
end