diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 1ae40d416..0dddc3e29 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -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, diff --git a/app/controllers/api/web/embeds_controller.rb b/app/controllers/api/web/embeds_controller.rb index 58f6345e6..63c3f2d90 100644 --- a/app/controllers/api/web/embeds_controller.rb +++ b/app/controllers/api/web/embeds_controller.rb @@ -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 diff --git a/app/javascript/flavours/glitch/components/router.tsx b/app/javascript/flavours/glitch/components/router.tsx new file mode 100644 index 000000000..018f66e15 --- /dev/null +++ b/app/javascript/flavours/glitch/components/router.tsx @@ -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 'flavours/glitch/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 = ({ children }) => { + return {children}; +}; diff --git a/app/javascript/flavours/glitch/components/status_action_bar.jsx b/app/javascript/flavours/glitch/components/status_action_bar.jsx index 7652eb11d..16a8e4430 100644 --- a/app/javascript/flavours/glitch/components/status_action_bar.jsx +++ b/app/javascript/flavours/glitch/components/status_action_bar.jsx @@ -199,9 +199,8 @@ class StatusActionBar extends ImmutablePureComponent { render () { const { status, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props; - const { permissions } = this.context.identity; + const { permissions, signedIn } = this.context.identity; - const anonymousAccess = !me; const mutingConversation = status.get('muted'); const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility')); @@ -225,54 +224,56 @@ 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 }); } - menu.push(null); - - if (writtenByMe && pinnableStatus) { - menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); - menu.push(null); - } - - if (writtenByMe || withDismiss) { - menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); - menu.push(null); - } - - if (writtenByMe) { - menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); - menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick, dangerous: true }); - menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick, dangerous: true }); - } else { - menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); - menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick }); + if (signedIn) { menu.push(null); - if (!this.props.onFilter) { - menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick, dangerous: true }); + if (writtenByMe && pinnableStatus) { + menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); menu.push(null); } - menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick, dangerous: true }); - menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick, dangerous: true }); - menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport, dangerous: true }); + if (writtenByMe || withDismiss) { + menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); + menu.push(null); + } - if (((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && (accountAdminLink || statusAdminLink)) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) { + if (writtenByMe) { + menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick, dangerous: true }); + menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick, dangerous: true }); + } else { + menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); + menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick }); menu.push(null); - if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) { - if (accountAdminLink !== undefined) { - menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: accountAdminLink(status.getIn(['account', 'id'])) }); + + if (!this.props.onFilter) { + menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick, dangerous: true }); + menu.push(null); + } + + menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick, dangerous: true }); + menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick, dangerous: true }); + menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport, dangerous: true }); + + if (((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && (accountAdminLink || statusAdminLink)) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) { + menu.push(null); + if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) { + if (accountAdminLink !== undefined) { + menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: accountAdminLink(status.getIn(['account', 'id'])) }); + } + if (statusAdminLink !== undefined) { + menu.push({ text: intl.formatMessage(messages.admin_status), href: statusAdminLink(status.getIn(['account', 'id']), status.get('id')) }); + } } - if (statusAdminLink !== undefined) { - menu.push({ text: intl.formatMessage(messages.admin_status), href: statusAdminLink(status.getIn(['account', 'id']), status.get('id')) }); + if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) { + const domain = status.getIn(['account', 'acct']).split('@')[1]; + menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` }); } } - if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) { - const domain = status.getIn(['account', 'acct']).split('@')[1]; - menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` }); - } } } @@ -313,14 +314,13 @@ class StatusActionBar extends ImmutablePureComponent { /> - + {filterButton}
- + - + diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index 5d9398909..1f4b982ad 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -177,7 +177,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ dispatch(openModal({ modalType: 'EMBED', modalProps: { - url: status.get('url'), + id: status.get('id'), onError: error => dispatch(showAlertForError(error)), }, })); diff --git a/app/javascript/flavours/glitch/features/getting_started/index.jsx b/app/javascript/flavours/glitch/features/getting_started/index.jsx index 1b3aec552..9a8ee3f09 100644 --- a/app/javascript/flavours/glitch/features/getting_started/index.jsx +++ b/app/javascript/flavours/glitch/features/getting_started/index.jsx @@ -192,7 +192,7 @@ class GettingStarted extends ImmutablePureComponent { )}
- + {(multiColumn && showTrends) && } diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.jsx b/app/javascript/flavours/glitch/features/local_settings/page/index.jsx index 929391927..dab63c4ce 100644 --- a/app/javascript/flavours/glitch/features/local_settings/page/index.jsx +++ b/app/javascript/flavours/glitch/features/local_settings/page/index.jsx @@ -17,12 +17,6 @@ import LocalSettingsPageItem from './item'; // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * const messages = defineMessages({ - layout_auto: { id: 'layout.auto', defaultMessage: 'Auto' }, - layout_auto_hint: { id: 'layout.hint.auto', defaultMessage: 'Automatically chose layout based on “Enable advanced web interface” setting and screen size.' }, - layout_desktop: { id: 'layout.desktop', defaultMessage: 'Desktop' }, - layout_desktop_hint: { id: 'layout.hint.desktop', defaultMessage: 'Use multiple-column layout regardless of the “Enable advanced web interface” setting or screen size.' }, - layout_mobile: { id: 'layout.single', defaultMessage: 'Mobile' }, - layout_mobile_hint: { id: 'layout.hint.single', defaultMessage: 'Use single-column layout regardless of the “Enable advanced web interface” setting or screen size.' }, side_arm_none: { id: 'settings.side_arm.none', defaultMessage: 'None' }, side_arm_keep: { id: 'settings.side_arm_reply_mode.keep', defaultMessage: 'Keep its set privacy' }, side_arm_copy: { id: 'settings.side_arm_reply_mode.copy', defaultMessage: 'Copy privacy setting of the toot being replied to' }, @@ -166,19 +160,6 @@ class LocalSettingsPage extends PureComponent {

- - - ({ dispatch(openModal({ modalType: 'EMBED', modalProps: { - url: status.get('url'), + id: status.get('id'), onError: error => dispatch(showAlertForError(error)), }, })); diff --git a/app/javascript/flavours/glitch/features/status/index.jsx b/app/javascript/flavours/glitch/features/status/index.jsx index 0c2536f2a..a65cf31c7 100644 --- a/app/javascript/flavours/glitch/features/status/index.jsx +++ b/app/javascript/flavours/glitch/features/status/index.jsx @@ -500,7 +500,7 @@ class Status extends ImmutablePureComponent { handleEmbed = (status) => { this.props.dispatch(openModal({ modalType: 'EMBED', - modalProps: { url: status.get('url') }, + modalProps: { id: status.get('id') }, })); }; diff --git a/app/javascript/flavours/glitch/features/ui/components/embed_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/embed_modal.jsx index dffa36142..52659b611 100644 --- a/app/javascript/flavours/glitch/features/ui/components/embed_modal.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/embed_modal.jsx @@ -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; diff --git a/app/javascript/flavours/glitch/features/ui/components/link_footer.jsx b/app/javascript/flavours/glitch/features/ui/components/link_footer.jsx index 1738116c7..0ef37bb23 100644 --- a/app/javascript/flavours/glitch/features/ui/components/link_footer.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/link_footer.jsx @@ -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 {

{domain}: {' '} - + {statusPageUrl && ( <> {DividingCircle} @@ -84,7 +86,7 @@ class LinkFooter extends PureComponent { )} {DividingCircle} - +

diff --git a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx index 4c8c3e4a5..68e456e86 100644 --- a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx @@ -5,6 +5,7 @@ import { defineMessages, injectIntl } from 'react-intl'; import NavigationPortal from 'flavours/glitch/components/navigation_portal'; import { timelinePreview, trendsEnabled } from 'flavours/glitch/initial_state'; +import { transientSingleColumn } from 'flavours/glitch/is_mobile'; import { preferencesLink } from 'flavours/glitch/utils/backend_links'; import ColumnLink from './column_link'; @@ -27,6 +28,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' }, app_settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, }); @@ -52,6 +54,15 @@ class NavigationPanel extends Component { return (

+ {transientSingleColumn && ( + <> + + {intl.formatMessage(messages.advancedInterface)} + +
+ + )} + {signedIn && ( <> diff --git a/app/javascript/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx index b8dd49023..cbd2637af 100644 --- a/app/javascript/flavours/glitch/features/ui/index.jsx +++ b/app/javascript/flavours/glitch/features/ui/index.jsx @@ -79,7 +79,6 @@ const mapStateToProps = state => ({ hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0, hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0, canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4, - layout_local_setting: state.getIn(['local_settings', 'layout']), isWide: state.getIn(['local_settings', 'stretch']), dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null, unreadNotifications: state.getIn(['notifications', 'unread']), @@ -134,11 +133,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 { @@ -152,9 +151,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); } } @@ -165,16 +164,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 = ; } else { - redirect = ; + redirect = ; } } else if (singleUserMode && owner && initialState?.accounts[owner]) { redirect = ; @@ -185,10 +185,13 @@ class SwitchingColumnsArea extends PureComponent { } return ( - + {redirect} + {singleColumn ? : null} + {singleColumn && pathName.startsWith('/deck/') ? : null} + @@ -256,7 +259,6 @@ class UI extends Component { static propTypes = { dispatch: PropTypes.func.isRequired, children: PropTypes.node, - layout_local_setting: PropTypes.string, isWide: PropTypes.bool, systemFontUi: PropTypes.bool, isComposing: PropTypes.bool, @@ -381,7 +383,7 @@ class UI extends Component { }); handleResize = () => { - const layout = layoutFromWindow(this.props.layout_local_setting); + const layout = layoutFromWindow(); if (layout !== this.props.layout) { this.handleLayoutChange.cancel(); @@ -445,19 +447,6 @@ class UI extends Component { } } - UNSAFE_componentWillReceiveProps (nextProps) { - if (nextProps.layout_local_setting !== this.props.layout_local_setting) { - const layout = layoutFromWindow(nextProps.layout_local_setting); - - if (layout !== this.props.layout) { - this.handleLayoutChange.cancel(); - this.props.dispatch(changeLayout(layout)); - } else { - this.handleLayoutChange(); - } - } - } - componentDidUpdate (prevProps) { if (this.props.unreadNotifications !== prevProps.unreadNotifications || this.props.showFaviconBadge !== prevProps.showFaviconBadge) { @@ -667,7 +656,7 @@ class UI extends Component {
- + {children} diff --git a/app/javascript/flavours/glitch/features/ui/util/react_router_helpers.jsx b/app/javascript/flavours/glitch/features/ui/util/react_router_helpers.jsx index 94847cd79..d0d034cbd 100644 --- a/app/javascript/flavours/glitch/features/ui/util/react_router_helpers.jsx +++ b/app/javascript/flavours/glitch/features/ui/util/react_router_helpers.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import * as React from 'react'; +import { Component, PureComponent, cloneElement, Children } from 'react'; import { Switch, Route } from 'react-router-dom'; @@ -10,14 +10,22 @@ import ColumnLoading from 'flavours/glitch/features/ui/components/column_loading import BundleContainer from 'flavours/glitch/features/ui/containers/bundle_container'; // Small wrapper to pass multiColumn to the route components -export class WrappedSwitch extends React.PureComponent { +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 ( - - {React.Children.map(children, child => React.cloneElement(child, { multiColumn }))} + + {Children.map(children, child => child ? cloneElement(child, { multiColumn }) : null)} ); } @@ -32,7 +40,7 @@ WrappedSwitch.propTypes = { // Small Wraper to extract the params from the route and pass // them to the rendered component, together with the content to // be rendered inside (the children) -export class WrappedRoute extends React.Component { +export class WrappedRoute extends Component { static propTypes = { component: PropTypes.func.isRequired, diff --git a/app/javascript/flavours/glitch/initial_state.js b/app/javascript/flavours/glitch/initial_state.js index 15ae7a904..f07c9fb71 100644 --- a/app/javascript/flavours/glitch/initial_state.js +++ b/app/javascript/flavours/glitch/initial_state.js @@ -88,6 +88,13 @@ * @property {string} default_content_type */ +/** @type {string} */ +const initialPath = document.querySelector("head meta[name=initialPath]")?.getAttribute("content") ?? ''; +/** @type {boolean} */ +export const hasMultiColumnPath = initialPath === '/' + || initialPath === '/getting-started' + || initialPath.startsWith('/deck'); + /** * @typedef InitialState * @property {Record} accounts diff --git a/app/javascript/flavours/glitch/is_mobile.ts b/app/javascript/flavours/glitch/is_mobile.ts index d69beb240..7f339e287 100644 --- a/app/javascript/flavours/glitch/is_mobile.ts +++ b/app/javascript/flavours/glitch/is_mobile.ts @@ -1,30 +1,21 @@ import { supportsPassiveEvents } from 'detect-passive-events'; -import { forceSingleColumn } from 'flavours/glitch/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 = (layout_local_setting: string): LayoutType => { - switch (layout_local_setting) { - case 'multiple': - return 'multi-column'; - case 'single': - if (isMobile(window.innerWidth)) { - return 'mobile'; - } else { - return 'single-column'; - } - default: - if (isMobile(window.innerWidth)) { - return 'mobile'; - } else if (forceSingleColumn) { - return 'single-column'; - } else { - return 'multi-column'; - } +export const layoutFromWindow = (): LayoutType => { + if (isMobile(window.innerWidth)) { + return 'mobile'; + } else if (!forceSingleColumn && !transientSingleColumn) { + return 'multi-column'; + } else { + return 'single-column'; } }; diff --git a/app/javascript/flavours/glitch/locales/en.json b/app/javascript/flavours/glitch/locales/en.json index 555375053..2a93f94aa 100644 --- a/app/javascript/flavours/glitch/locales/en.json +++ b/app/javascript/flavours/glitch/locales/en.json @@ -64,12 +64,6 @@ "keyboard_shortcuts.bookmark": "to bookmark", "keyboard_shortcuts.secondary_toot": "to send toot using secondary privacy setting", "keyboard_shortcuts.toggle_collapse": "to collapse/uncollapse toots", - "layout.auto": "Auto", - "layout.desktop": "Desktop", - "layout.hint.auto": "Automatically chose layout based on “Enable advanced web interface” setting and screen size.", - "layout.hint.desktop": "Use multiple-column layout regardless of the “Enable advanced web interface” setting or screen size.", - "layout.hint.single": "Use single-column layout regardless of the “Enable advanced web interface” setting or screen size.", - "layout.single": "Mobile", "media_gallery.sensitive": "Sensitive", "moved_to_warning": "This account is marked as moved to {moved_to_link}, and may thus not accept new follows.", "navigation_bar.app_settings": "App settings", @@ -145,7 +139,6 @@ "settings.image_backgrounds_media_hint": "If the post has any media attachment, use the first one as a background", "settings.image_backgrounds_users": "Give collapsed toots an image background", "settings.inline_preview_cards": "Inline preview cards for external links", - "settings.layout": "Layout:", "settings.layout_opts": "Layout options", "settings.media": "Media", "settings.media_fullwidth": "Full-width media previews", diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js index 21e9dcec2..712f115d0 100644 --- a/app/javascript/flavours/glitch/reducers/local_settings.js +++ b/app/javascript/flavours/glitch/reducers/local_settings.js @@ -6,7 +6,6 @@ import { LOCAL_SETTING_CHANGE, LOCAL_SETTING_DELETE } from 'flavours/glitch/acti import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; const initialState = ImmutableMap({ - layout : 'auto', stretch : true, side_arm : 'none', side_arm_reply_mode : 'keep', diff --git a/app/javascript/mastodon/components/router.tsx b/app/javascript/mastodon/components/router.tsx new file mode 100644 index 000000000..c82711790 --- /dev/null +++ b/app/javascript/mastodon/components/router.tsx @@ -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 = ({ children }) => { + return {children}; +}; diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index ab9dac27e..b713c98c6 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -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 }); } diff --git a/app/javascript/mastodon/containers/mastodon.jsx b/app/javascript/mastodon/containers/mastodon.jsx index 4538db050..59efc8057 100644 --- a/app/javascript/mastodon/containers/mastodon.jsx +++ b/app/javascript/mastodon/containers/mastodon.jsx @@ -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 { - + - + diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx index 6167b404f..8b3d8b46b 100644 --- a/app/javascript/mastodon/containers/status_container.jsx +++ b/app/javascript/mastodon/containers/status_container.jsx @@ -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)), }, })); diff --git a/app/javascript/mastodon/features/getting_started/index.jsx b/app/javascript/mastodon/features/getting_started/index.jsx index e31ca79af..85fd6f120 100644 --- a/app/javascript/mastodon/features/getting_started/index.jsx +++ b/app/javascript/mastodon/features/getting_started/index.jsx @@ -142,7 +142,7 @@ class GettingStarted extends ImmutablePureComponent { {!multiColumn &&
} - +
{(multiColumn && showTrends) && } diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx index d3295913c..0bacf2965 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.jsx +++ b/app/javascript/mastodon/features/status/components/action_bar.jsx @@ -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 }); } diff --git a/app/javascript/mastodon/features/status/containers/detailed_status_container.js b/app/javascript/mastodon/features/status/containers/detailed_status_container.js index e76790a9f..57d5f61c8 100644 --- a/app/javascript/mastodon/features/status/containers/detailed_status_container.js +++ b/app/javascript/mastodon/features/status/containers/detailed_status_container.js @@ -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)), }, })); diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index e5faba083..e5f91071e 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -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') }, })); }; diff --git a/app/javascript/mastodon/features/ui/components/embed_modal.jsx b/app/javascript/mastodon/features/ui/components/embed_modal.jsx index b08d53ab1..bacbad347 100644 --- a/app/javascript/mastodon/features/ui/components/embed_modal.jsx +++ b/app/javascript/mastodon/features/ui/components/embed_modal.jsx @@ -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; diff --git a/app/javascript/mastodon/features/ui/components/link_footer.jsx b/app/javascript/mastodon/features/ui/components/link_footer.jsx index b02517440..7aaa887ac 100644 --- a/app/javascript/mastodon/features/ui/components/link_footer.jsx +++ b/app/javascript/mastodon/features/ui/components/link_footer.jsx @@ -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 {

{domain}: {' '} - + {statusPageUrl && ( <> {DividingCircle} @@ -84,7 +86,7 @@ class LinkFooter extends PureComponent { )} {DividingCircle} - +

diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx index dc406fa55..ab5c78246 100644 --- a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx +++ b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx @@ -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 {

+ + {transientSingleColumn && ( + + {intl.formatMessage(messages.advancedInterface)} + + )}
diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index b38acfc14..ae81a354b 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -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 = ; } else { - redirect = ; + redirect = ; } } else if (singleUserMode && owner && initialState?.accounts[owner]) { redirect = ; @@ -177,10 +178,13 @@ class SwitchingColumnsArea extends PureComponent { } return ( - + {redirect} + {singleColumn ? : null} + {singleColumn && pathName.startsWith('/deck/') ? : null} + @@ -573,7 +577,7 @@ class UI extends PureComponent {
- + {children} diff --git a/app/javascript/mastodon/features/ui/util/react_router_helpers.jsx b/app/javascript/mastodon/features/ui/util/react_router_helpers.jsx index 66cfee970..992772685 100644 --- a/app/javascript/mastodon/features/ui/util/react_router_helpers.jsx +++ b/app/javascript/mastodon/features/ui/util/react_router_helpers.jsx @@ -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 ( - - {Children.map(children, child => cloneElement(child, { multiColumn }))} + + {Children.map(children, child => child ? cloneElement(child, { multiColumn }) : null)} ); } diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index edac55c1b..f838c75bc 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -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 diff --git a/app/javascript/mastodon/is_mobile.ts b/app/javascript/mastodon/is_mobile.ts index 36cde2133..7f339e287 100644 --- a/app/javascript/mastodon/is_mobile.ts +++ b/app/javascript/mastodon/is_mobile.ts @@ -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'; } }; diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index edecaf60f..8c85cb7be 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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", diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index 75b7890d2..13eb9762e 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -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", diff --git a/app/models/status.rb b/app/models/status.rb index a52098b60..c669fa512 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -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| diff --git a/app/views/shared/_web_app.html.haml b/app/views/shared/_web_app.html.haml index b9a0ce1fc..67f3ff9b4 100644 --- a/app/views/shared/_web_app.html.haml +++ b/app/views/shared/_web_app.html.haml @@ -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 } diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 27d8ff8da..9b9330a75 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,28 +1,5 @@ { "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, @@ -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" } diff --git a/config/routes.rb b/config/routes.rb index 2f15c4fc0..e4a82ebd4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -31,6 +31,7 @@ Rails.application.routes.draw do /mutes /followed_tags /statuses/(*any) + /deck/(*any) ).freeze root 'home#index' diff --git a/config/routes/api.rb b/config/routes/api.rb index 040ea7124..a9a974334 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -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 diff --git a/package.json b/package.json index 0c48b8a9d..bc488dd9e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/spec/controllers/api/web/embeds_controller_spec.rb b/spec/controllers/api/web/embeds_controller_spec.rb deleted file mode 100644 index 8c4e1a8f2..000000000 --- a/spec/controllers/api/web/embeds_controller_spec.rb +++ /dev/null @@ -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 diff --git a/spec/requests/api/web/embeds_spec.rb b/spec/requests/api/web/embeds_spec.rb new file mode 100644 index 000000000..6314f43aa --- /dev/null +++ b/spec/requests/api/web/embeds_spec.rb @@ -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