diff --git a/core-blocks/embed/index.js b/core-blocks/embed/index.js index e30a87125121a0..8e5f72ff38869c 100644 --- a/core-blocks/embed/index.js +++ b/core-blocks/embed/index.js @@ -3,19 +3,18 @@ */ import { parse } from 'url'; import { includes, kebabCase, toLower } from 'lodash'; -import memoize from 'memize'; import classnames from 'classnames'; /** * WordPress dependencies */ import { __, sprintf } from '@wordpress/i18n'; -import { Component, Fragment, renderToString } from '@wordpress/element'; +import { compose } from '@wordpress/compose'; +import { Component, renderToString } from '@wordpress/element'; import { Button, Placeholder, Spinner, SandBox, IconButton, Toolbar } from '@wordpress/components'; import { createBlock } from '@wordpress/blocks'; import { RichText, BlockControls } from '@wordpress/editor'; -import apiFetch from '@wordpress/api-fetch'; -import { addQueryArgs } from '@wordpress/url'; +import { withSelect } from '@wordpress/data'; /** * Internal dependencies @@ -26,11 +25,6 @@ import './editor.scss'; // These embeds do not work in sandboxes const HOSTS_NO_PREVIEWS = [ 'facebook.com' ]; -// Caches the embed API calls, so if blocks get transformed, or deleted and added again, we don't spam the API. -const wpEmbedAPI = memoize( ( url ) => - apiFetch( { path: addQueryArgs( '/oembed/1.0/proxy', { url } ) } ) -); - const matchesPatterns = ( url, patterns = [] ) => { return patterns.some( ( pattern ) => { return url.match( pattern ); @@ -46,6 +40,229 @@ const findBlock = ( url ) => { return 'core/embed'; }; +export function getEmbedEdit( title, icon ) { + return class extends Component { + constructor() { + super( ...arguments ); + + this.switchBackToURLInput = this.switchBackToURLInput.bind( this ); + this.setUrl = this.setUrl.bind( this ); + this.maybeSwitchBlock = this.maybeSwitchBlock.bind( this ); + this.setAttributesFromPreview = this.setAttributesFromPreview.bind( this ); + + this.state = { + editingURL: false, + url: this.props.attributes.url, + }; + + this.maybeSwitchBlock(); + } + + componentWillUnmount() { + // can't abort the fetch promise, so let it know we will unmount + this.unmounting = true; + } + + componentDidUpdate( prevProps ) { + const hasPreview = undefined !== this.props.preview; + const hadPreview = undefined !== prevProps.preview; + // We had a preview, and the URL was edited, and the new URL already has a preview fetched. + const switchedPreview = this.props.preview && this.props.attributes.url !== prevProps.attributes.url; + const switchedURL = this.props.attributes.url !== prevProps.attributes.url; + + if ( switchedURL && this.maybeSwitchBlock() ) { + return; + } + + if ( ( hasPreview && ! hadPreview ) || switchedPreview ) { + if ( this.props.previewIsFallback ) { + this.setState( { editingURL: true } ); + return; + } + this.setAttributesFromPreview(); + } + } + + getPhotoHtml( photo ) { + // 100% width for the preview so it fits nicely into the document, some "thumbnails" are + // acually the full size photo. + const photoPreview =

{

; + return renderToString( photoPreview ); + } + + setUrl( event ) { + if ( event ) { + event.preventDefault(); + } + const { url } = this.state; + const { setAttributes } = this.props; + this.setState( { editingURL: false } ); + setAttributes( { url } ); + } + + /*** + * Maybe switches to a different embed block type, based on the URL + * and the HTML in the preview. + * + * @return {boolean} Whether the block was switched. + */ + maybeSwitchBlock() { + const { preview } = this.props; + const { url } = this.props.attributes; + + if ( ! url ) { + return false; + } + + const matchingBlock = findBlock( url ); + + // WordPress blocks can work on multiple sites, and so don't have patterns, + // so if we're in a WordPress block, assume the user has chosen it for a WordPress URL. + if ( 'core-embed/wordpress' !== this.props.name && 'core/embed' !== matchingBlock ) { + // At this point, we have discovered a more suitable block for this url, so transform it. + if ( this.props.name !== matchingBlock ) { + this.props.onReplace( createBlock( matchingBlock, { url } ) ); + return true; + } + } + + if ( preview ) { + const { html } = preview; + + // This indicates it's a WordPress embed, there aren't a set of URL patterns we can use to match WordPress URLs. + if ( includes( html, 'class="wp-embedded-content" data-secret' ) ) { + // If this is not the WordPress embed block, transform it into one. + if ( this.props.name !== 'core-embed/wordpress' ) { + this.props.onReplace( createBlock( 'core-embed/wordpress', { url } ) ); + return true; + } + } + } + + return false; + } + + /*** + * Sets block attributes based on the preview data. + */ + setAttributesFromPreview() { + const { setAttributes, preview } = this.props; + + // Some plugins only return HTML with no type info, so default this to 'rich'. + let { type = 'rich' } = preview; + // If we got a provider name from the API, use it for the slug, otherwise we use the title, + // because not all embed code gives us a provider name. + const { html, provider_name: providerName } = preview; + const providerNameSlug = kebabCase( toLower( '' !== providerName ? providerName : title ) ); + + if ( includes( html, 'class="wp-embedded-content" data-secret' ) ) { + type = 'wp-embed'; + } + + if ( html || 'photo' === type ) { + setAttributes( { type, providerNameSlug } ); + } + } + + switchBackToURLInput() { + this.setState( { editingURL: true } ); + } + + render() { + const { url, editingURL } = this.state; + const { caption, type } = this.props.attributes; + const { fetching, setAttributes, isSelected, className, preview, previewIsFallback } = this.props; + const controls = ( + + + { ( preview && ! previewIsFallback && ) } + + + ); + + if ( fetching ) { + return ( +
+ +

{ __( 'Embedding…' ) }

+
+ ); + } + + if ( ! preview || previewIsFallback || editingURL ) { + // translators: %s: type of embed e.g: "YouTube", "Twitter", etc. "Embed" is used when no specific type exists + const label = sprintf( __( '%s URL' ), title ); + + return ( + +
+ this.setState( { url: event.target.value } ) } /> + + { previewIsFallback &&

{ __( 'Sorry, we could not embed that content.' ) }

} +
+
+ ); + } + + const html = 'photo' === type ? this.getPhotoHtml( preview ) : preview.html; + const parsedUrl = parse( url ); + const cannotPreview = includes( HOSTS_NO_PREVIEWS, parsedUrl.host.replace( /^www\./, '' ) ); + // translators: %s: host providing embed content e.g: www.youtube.com + const iframeTitle = sprintf( __( 'Embedded content from %s' ), parsedUrl.host ); + const embedWrapper = 'wp-embed' === type ? ( +
+ ) : ( +
+ +
+ ); + + return ( +
+ { controls } + { ( cannotPreview ) ? ( + +

{ url }

+

{ __( 'Previews for this are unavailable in the editor, sorry!' ) }

+
+ ) : embedWrapper } + { ( caption && caption.length > 0 ) || isSelected ? ( + setAttributes( { caption: value } ) } + inlineToolbar + /> + ) : null } +
+ ); + } + }; +} + function getEmbedBlockSettings( { title, description, icon, category = 'embed', transforms, keywords = [] } ) { // translators: %s: Name of service (e.g. VideoPress, YouTube) const blockDescription = description || sprintf( __( 'Add a block that displays content pulled from other sites, like Twitter, Instagram or YouTube.' ), title ); @@ -79,199 +296,21 @@ function getEmbedBlockSettings( { title, description, icon, category = 'embed', transforms, - edit: class extends Component { - constructor() { - super( ...arguments ); - - this.doServerSideRender = this.doServerSideRender.bind( this ); - this.switchBackToURLInput = this.switchBackToURLInput.bind( this ); - - this.state = { - html: '', - type: '', - error: false, - fetching: false, - providerName: '', + edit: compose( + withSelect( ( select, ownProps ) => { + const { url } = ownProps.attributes; + const core = select( 'core' ); + const { getEmbedPreview, isPreviewEmbedFallback, isRequestingEmbedPreview } = core; + const preview = getEmbedPreview( url ); + const previewIsFallback = isPreviewEmbedFallback( url ); + const fetching = undefined !== url && isRequestingEmbedPreview( url ); + return { + preview, + previewIsFallback, + fetching, }; - } - - componentDidMount() { - this.doServerSideRender(); - } - - componentWillUnmount() { - // can't abort the fetch promise, so let it know we will unmount - this.unmounting = true; - } - - getPhotoHtml( photo ) { - // 100% width for the preview so it fits nicely into the document, some "thumbnails" are - // acually the full size photo. - const photoPreview =

{

; - return renderToString( photoPreview ); - } - - doServerSideRender( event ) { - if ( event ) { - event.preventDefault(); - } - const { url } = this.props.attributes; - const { setAttributes } = this.props; - - if ( undefined === url ) { - return; - } - - const matchingBlock = findBlock( url ); - - // WordPress blocks can work on multiple sites, and so don't have patterns, - // so if we're in a WordPress block, assume the user has chosen it for a WordPress URL. - if ( 'core-embed/wordpress' !== this.props.name && 'core/embed' !== matchingBlock ) { - // At this point, we have discovered a more suitable block for this url, so transform it. - if ( this.props.name !== matchingBlock ) { - this.props.onReplace( createBlock( matchingBlock, { url } ) ); - return; - } - } - - this.setState( { error: false, fetching: true } ); - wpEmbedAPI( url ) - .then( - ( obj ) => { - if ( this.unmounting ) { - return; - } - // Some plugins only return HTML with no type info, so default this to 'rich'. - let { type = 'rich' } = obj; - // If we got a provider name from the API, use it for the slug, otherwise we use the title, - // because not all embed code gives us a provider name. - const { html, provider_name: providerName } = obj; - const providerNameSlug = kebabCase( toLower( '' !== providerName ? providerName : title ) ); - // This indicates it's a WordPress embed, there aren't a set of URL patterns we can use to match WordPress URLs. - if ( includes( html, 'class="wp-embedded-content" data-secret' ) ) { - type = 'wp-embed'; - // If this is not the WordPress embed block, transform it into one. - if ( this.props.name !== 'core-embed/wordpress' ) { - this.props.onReplace( createBlock( 'core-embed/wordpress', { url } ) ); - return; - } - } - if ( html ) { - this.setState( { html, type, providerNameSlug } ); - setAttributes( { type, providerNameSlug } ); - } else if ( 'photo' === type ) { - this.setState( { html: this.getPhotoHtml( obj ), type, providerNameSlug } ); - setAttributes( { type, providerNameSlug } ); - } else { - // No html, no custom type that we support, so show the error state. - this.setState( { error: true } ); - } - this.setState( { fetching: false } ); - }, - () => { - this.setState( { fetching: false, error: true } ); - } - ); - } - - switchBackToURLInput() { - this.setState( { html: undefined } ); - } - - render() { - const { html, type, error, fetching } = this.state; - const { url, caption } = this.props.attributes; - const { setAttributes, isSelected, className } = this.props; - const controls = ( - - - { ( html && ) } - - - ); - - if ( fetching ) { - return ( -
- -

{ __( 'Embedding…' ) }

-
- ); - } - - if ( ! html ) { - // translators: %s: type of embed e.g: "YouTube", "Twitter", etc. "Embed" is used when no specific type exists - const label = sprintf( __( '%s URL' ), title ); - - return ( - -
- setAttributes( { url: event.target.value } ) } /> - - { error &&

{ __( 'Sorry, we could not embed that content.' ) }

} -
-
- ); - } - - const parsedUrl = parse( url ); - const cannotPreview = includes( HOSTS_NO_PREVIEWS, parsedUrl.host.replace( /^www\./, '' ) ); - // translators: %s: host providing embed content e.g: www.youtube.com - const iframeTitle = sprintf( __( 'Embedded content from %s' ), parsedUrl.host ); - const embedWrapper = 'wp-embed' === type ? ( -
- ) : ( -
- -
- ); - - return ( - - { controls } -
- { ( cannotPreview ) ? ( - -

{ url }

-

{ __( 'Previews for this are unavailable in the editor, sorry!' ) }

-
- ) : embedWrapper } - { ( caption && caption.length > 0 ) || isSelected ? ( - setAttributes( { caption: value } ) } - inlineToolbar - /> - ) : null } -
-
- ); - } - }, + } ) + )( getEmbedEdit( title, icon ) ), save( { attributes } ) { const { url, caption, type, providerNameSlug } = attributes; diff --git a/core-blocks/embed/test/index.js b/core-blocks/embed/test/index.js index 0b8a1985b06aa4..2ca6d72649755e 100644 --- a/core-blocks/embed/test/index.js +++ b/core-blocks/embed/test/index.js @@ -1,12 +1,17 @@ +/** + * External dependencies + */ +import { render } from 'enzyme'; + /** * Internal dependencies */ -import { name, settings } from '../'; -import { blockEditRender } from '../../test/helpers'; +import { getEmbedEdit } from '../'; describe( 'core/embed', () => { test( 'block edit matches snapshot', () => { - const wrapper = blockEditRender( name, settings ); + const EmbedEdit = getEmbedEdit( 'Embed', 'embed-generic' ); + const wrapper = render( ); expect( wrapper ).toMatchSnapshot(); } ); diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 3353f48865940f..06afd03354f971 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -96,3 +96,20 @@ export function receiveThemeSupportsFromIndex( index ) { themeSupports: index.theme_supports, }; } + +/** + * Returns an action object used in signalling that the preview data for + * a given URl has been received. + * + * @param {string} url URL to preview the embed for. + * @param {Mixed} preview Preview data. + * + * @return {Object} Action object. + */ +export function receiveEmbedPreview( url, preview ) { + return { + type: 'RECEIVE_EMBED_PREVIEW', + url, + preview, + }; +} diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index cc02fe1d470195..9a8d6a37d5cf08 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -197,10 +197,31 @@ export const entities = ( state = {}, action ) => { }; }; +/** + * Reducer managing embed preview data. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function embedPreviews( state = {}, action ) { + switch ( action.type ) { + case 'RECEIVE_EMBED_PREVIEW': + const { url, preview } = action; + return { + ...state, + [ url ]: preview, + }; + } + return state; +} + export default combineReducers( { terms, users, taxonomies, themeSupports, entities, + embedPreviews, } ); diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index d1594daf60c60b..7d939de0fac4a6 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -17,6 +17,7 @@ import { receiveUserQuery, receiveEntityRecords, receiveThemeSupportsFromIndex, + receiveEmbedPreview, } from './actions'; import { getKindEntities } from './entities'; @@ -84,3 +85,19 @@ export async function* getThemeSupports() { const index = await apiFetch( { path: '/' } ); yield receiveThemeSupportsFromIndex( index ); } + +/** + * Requests a preview from the from the Embed API. + * + * @param {Object} state State tree + * @param {string} url URL to get the preview for. + */ +export async function* getEmbedPreview( state, url ) { + try { + const embedProxyResponse = await apiFetch( { path: addQueryArgs( '/oembed/1.0/proxy', { url } ) } ); + yield receiveEmbedPreview( url, embedProxyResponse ); + } catch ( error ) { + // Embed API 404s if the URL cannot be embedded, so we have to catch the error from the apiRequest here. + yield receiveEmbedPreview( url, false ); + } +} diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js index d01ea3d3b9c7e5..1e0fd66f63f11a 100644 --- a/packages/core-data/src/selectors.js +++ b/packages/core-data/src/selectors.js @@ -25,7 +25,7 @@ import { getQueriedItems } from './queried-data'; * @return {boolean} Whether resolution is in progress. */ function isResolving( selectorName, ...args ) { - return select( 'core/data' ).isResolving( REDUCER_KEY, selectorName, ...args ); + return select( 'core/data' ).isResolving( REDUCER_KEY, selectorName, args ); } /** @@ -76,6 +76,19 @@ export function isRequestingCategories() { return isResolving( 'getCategories' ); } +/** + * Returns true if a request is in progress for embed preview data, or false + * otherwise. + * + * @param {Object} state Data state. + * @param {string} url URL the preview would be for. + * + * @return {boolean} Whether a request is in progress for an embed preview. + */ +export function isRequestingEmbedPreview( state, url ) { + return isResolving( 'getEmbedPreview', url ); +} + /** * Returns all available authors. * @@ -171,3 +184,36 @@ export function getEntityRecords( state, kind, name, query ) { export function getThemeSupports( state ) { return state.themeSupports; } + +/** + * Returns the embed preview for the given URL. + * + * @param {Object} state Data state. + * @param {string} url Embedded URL. + * + * @return {*} Undefined if the preview has not been fetched, otherwise, the preview fetched from the embed preview API. + */ +export function getEmbedPreview( state, url ) { + return state.embedPreviews[ url ]; +} + +/** + * Determines if the returned preview is an oEmbed link fallback. + * + * WordPress can be configured to return a simple link to a URL if it is not embeddable. + * We need to be able to determine if a URL is embeddable or not, based on what we + * get back from the oEmbed preview API. + * + * @param {Object} state Data state. + * @param {string} url Embedded URL. + * + * @return {booleans} Is the preview for the URL an oEmbed link fallback. + */ +export function isPreviewEmbedFallback( state, url ) { + const preview = state.embedPreviews[ url ]; + const oEmbedLinkCheck = '' + url + ''; + if ( ! preview ) { + return false; + } + return preview.html === oEmbedLinkCheck; +} diff --git a/packages/core-data/src/test/reducer.js b/packages/core-data/src/test/reducer.js index f25462746f7ee2..90f9b21f1a8cf2 100644 --- a/packages/core-data/src/test/reducer.js +++ b/packages/core-data/src/test/reducer.js @@ -7,7 +7,7 @@ import { filter } from 'lodash'; /** * Internal dependencies */ -import { terms, entities } from '../reducer'; +import { terms, entities, embedPreviews } from '../reducer'; describe( 'terms()', () => { it( 'returns an empty object by default', () => { @@ -96,3 +96,24 @@ describe( 'entities', () => { ] ); } ); } ); + +describe( 'embedPreviews()', () => { + it( 'returns an empty object by default', () => { + const state = embedPreviews( undefined, {} ); + + expect( state ).toEqual( {} ); + } ); + + it( 'returns with received preview', () => { + const originalState = deepFreeze( {} ); + const state = embedPreviews( originalState, { + type: 'RECEIVE_EMBED_PREVIEW', + url: 'http://twitter.com/notnownikki', + preview: { data: 42 }, + } ); + + expect( state ).toEqual( { + 'http://twitter.com/notnownikki': { data: 42 }, + } ); + } ); +} ); diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index ee5d242255d224..581830a04f3f29 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -3,11 +3,16 @@ */ import apiFetch from '@wordpress/api-fetch'; +/** + * External dependencies + */ +import { addQueryArgs } from '@wordpress/url'; + /** * Internal dependencies */ -import { getCategories, getEntityRecord, getEntityRecords } from '../resolvers'; -import { receiveTerms, receiveEntityRecords, addEntities } from '../actions'; +import { getCategories, getEntityRecord, getEntityRecords, getEmbedPreview } from '../resolvers'; +import { receiveTerms, receiveEntityRecords, addEntities, receiveEmbedPreview } from '../actions'; jest.mock( '@wordpress/api-fetch', () => jest.fn() ); @@ -105,3 +110,31 @@ describe( 'getEntityRecords', () => { expect( received ).toEqual( receiveEntityRecords( 'root', 'postType', Object.values( POST_TYPES ), {} ) ); } ); } ); + +describe( 'getEmbedPreview', () => { + const SUCCESSFUL_EMBED_RESPONSE = { data: '

some html

' }; + const UNEMBEDDABLE_RESPONSE = false; + const EMBEDDABLE_URL = 'http://twitter.com/notnownikki'; + const UNEMBEDDABLE_URL = 'http://example.com/'; + + beforeAll( () => { + apiFetch.mockImplementation( ( options ) => { + if ( options.path === addQueryArgs( '/oembed/1.0/proxy', { url: EMBEDDABLE_URL } ) ) { + return Promise.resolve( SUCCESSFUL_EMBED_RESPONSE ); + } + throw 404; + } ); + } ); + + it( 'yields with fetched embed preview', async () => { + const fulfillment = getEmbedPreview( {}, EMBEDDABLE_URL ); + const received = ( await fulfillment.next() ).value; + expect( received ).toEqual( receiveEmbedPreview( EMBEDDABLE_URL, SUCCESSFUL_EMBED_RESPONSE ) ); + } ); + + it( 'yields false if the URL cannot be embedded', async () => { + const fulfillment = getEmbedPreview( {}, UNEMBEDDABLE_URL ); + const received = ( await fulfillment.next() ).value; + expect( received ).toEqual( receiveEmbedPreview( UNEMBEDDABLE_URL, UNEMBEDDABLE_RESPONSE ) ); + } ); +} ); diff --git a/packages/core-data/src/test/selectors.js b/packages/core-data/src/test/selectors.js index a2080f3a4c1bd1..d06fdc1fc9c3a2 100644 --- a/packages/core-data/src/test/selectors.js +++ b/packages/core-data/src/test/selectors.js @@ -6,7 +6,14 @@ import deepFreeze from 'deep-freeze'; /** * Internal dependencies */ -import { getTerms, isRequestingCategories, getEntityRecord, getEntityRecords } from '../selectors'; +import { + getTerms, + isRequestingCategories, + getEntityRecord, + getEntityRecords, + getEmbedPreview, + isPreviewEmbedFallback, +} from '../selectors'; import { select } from '@wordpress/data'; jest.mock( '@wordpress/data', () => { @@ -144,3 +151,29 @@ describe( 'getEntityRecords', () => { } ); } ); +describe( 'getEmbedPreview()', () => { + it( 'returns preview stored for url', () => { + let state = deepFreeze( { + embedPreviews: {}, + } ); + expect( getEmbedPreview( state, 'http://example.com/' ) ).toBe( undefined ); + + state = deepFreeze( { + embedPreviews: { + 'http://example.com/': { data: 42 }, + }, + } ); + expect( getEmbedPreview( state, 'http://example.com/' ) ).toEqual( { data: 42 } ); + } ); +} ); + +describe( 'isPreviewEmbedFallback()', () => { + it( 'returns true if the preview html is just a single link', () => { + const state = deepFreeze( { + embedPreviews: { + 'http://example.com/': { html: 'http://example.com/' }, + }, + } ); + expect( isPreviewEmbedFallback( state, 'http://example.com/' ) ).toEqual( true ); + } ); +} );