diff --git a/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-resolve-redirect.php b/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-resolve-redirect.php new file mode 100644 index 0000000000000..442a2efa158c6 --- /dev/null +++ b/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-resolve-redirect.php @@ -0,0 +1,94 @@ +namespace = 'wpcom/v2'; + $this->rest_base = 'resolve-redirect'; + // This endpoint *does not* need to connect directly to Jetpack sites. + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + /** + * Register the route. + */ + public function register_routes() { + // GET /sites//resolve-redirect/ - Follow 301/302 redirects on a URL, and return the final destination. + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P.+)', + array( + 'args' => array( + 'url' => array( + 'description' => __( 'The URL to check for redirects.', 'jetpack' ), + 'type' => 'string', + 'required' => 'true', + 'validate_callback' => 'wp_http_validate_url', + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'follow_redirect' ), + 'permission_callback' => 'is_user_logged_in', + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Follows 301/302 redirect for the passed URL, and returns the final destination. + * + * @param WP_REST_Request $request The REST API request data. + * @return WP_REST_Response The REST API response. + */ + public function follow_redirect( $request ) { + $response = wp_safe_remote_get( $request['url'] ); + if ( is_wp_error( $response ) ) { + return rest_ensure_response( '' ); + } + + $history = $response['http_response']->get_response_object()->history; + if ( ! $history ) { + return response_ensure_response( $request['url'] ); + } + + $location = $history[0]->headers->getValues( 'location' ); + if ( ! $location ) { + return response_ensure_response( $request['url'] ); + } + + return rest_ensure_response( $location[0] ); + } + + /** + * Retrieves the comment's schema, conforming to JSON Schema. + * + * @return array + */ + public function get_item_schema() { + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'resolve-redirect', + 'type' => 'string', + 'description' => __( 'The final destination of the URL being checked for redirects.', 'jetpack' ), + ); + + return $schema; + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Resolve_Redirect' ); diff --git a/bin/phpcs-whitelist.js b/bin/phpcs-whitelist.js index 936139a220d8e..03cd49286d43e 100644 --- a/bin/phpcs-whitelist.js +++ b/bin/phpcs-whitelist.js @@ -12,6 +12,7 @@ module.exports = [ '_inc/lib/class.jetpack-password-checker.php', '_inc/lib/components.php', '_inc/lib/core-api/class.jetpack-core-api-site-endpoints.php', + '_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-resolve-redirect.php', '_inc/lib/core-api/wpcom-endpoints/memberships.php', '_inc/lib/debugger/', '_inc/lib/plans.php', diff --git a/extensions/blocks/pinterest/edit.js b/extensions/blocks/pinterest/edit.js new file mode 100644 index 0000000000000..35251f6293eb1 --- /dev/null +++ b/extensions/blocks/pinterest/edit.js @@ -0,0 +1,217 @@ +/** + * External dependencies + */ +import { invoke } from 'lodash'; +import { __, _x } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; +import { Placeholder, SandBox, Button, IconButton, Spinner, Toolbar } from '@wordpress/components'; +import { BlockControls, BlockIcon } from '@wordpress/editor'; +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { fallback, pinType } from './utils'; +import { icon } from '.'; + +const PINIT_URL_REGEX = /^\s*https?:\/\/pin\.it\//i; + +class PinterestEdit extends Component { + constructor() { + super( ...arguments ); + + this.state = { + editedUrl: this.props.attributes.url || '', + editingUrl: false, + // If this is a pin.it URL, we're going to need to find where it redirects to. + resolvingRedirect: PINIT_URL_REGEX.test( this.props.attributes.url ), + // The interactive-related magic comes from Core's EmbedPreview component, + // which currently isn't exported in a way we can use. + interactive: false, + }; + } + + componentDidMount() { + const { resolvingRedirect } = this.state; + + // Check if we need to resolve a pin.it URL immediately. + if ( resolvingRedirect ) { + this.resolveRedirect(); + } + } + + componentDidUpdate( prevProps, prevState ) { + // Check if a pin.it URL has been entered, so we need to resolve it. + if ( ! prevState.resolvingRedirect && this.state.resolvingRedirect ) { + this.resolveRedirect(); + } + } + + componentWillUnmount() { + invoke( this.fetchRequest, [ 'abort' ] ); + } + + resolveRedirect = () => { + const { url } = this.props.attributes; + + this.fetchRequest = apiFetch( { + path: `/wpcom/v2/resolve-redirect/${ url }`, + } ); + + this.fetchRequest.then( + resolvedUrl => { + // resolve + this.fetchRequest = null; + this.props.setAttributes( { url: resolvedUrl } ); + this.setState( { + resolvingRedirect: false, + editedUrl: resolvedUrl, + } ); + }, + xhr => { + // reject + if ( xhr.statusText === 'abort' ) { + return; + } + this.fetchRequest = null; + this.setState( { + resolvingRedirect: false, + editingUrl: true, + } ); + } + ); + }; + + static getDerivedStateFromProps( nextProps, state ) { + if ( ! nextProps.isSelected && state.interactive ) { + // We only want to change this when the block is not selected, because changing it when + // the block becomes selected makes the overlap disappear too early. Hiding the overlay + // happens on mouseup when the overlay is clicked. + return { interactive: false }; + } + + return null; + } + + hideOverlay = () => { + // This is called onMouseUp on the overlay. We can't respond to the `isSelected` prop + // changing, because that happens on mouse down, and the overlay immediately disappears, + // and the mouse event can end up in the preview content. We can't use onClick on + // the overlay to hide it either, because then the editor misses the mouseup event, and + // thinks we're multi-selecting blocks. + this.setState( { interactive: true } ); + }; + + setUrl = event => { + if ( event ) { + event.preventDefault(); + } + + const { editedUrl: url } = this.state; + + this.props.setAttributes( { url } ); + this.setState( { editingUrl: false } ); + + if ( PINIT_URL_REGEX.test( url ) ) { + // Setting the `resolvingRedirect` state here, then waiting for `componentDidUpdate()` to + // be called before actually resolving it ensures that the `editedUrl` state has also been + // updated before resolveRedirect() is called. + this.setState( { resolvingRedirect: true } ); + } + }; + + /** + * Render a preview of the Pinterest embed. + * + * @returns {object} The UI displayed when user edits this block. + */ + render() { + const { attributes, className } = this.props; + const { url } = attributes; + const { editedUrl, interactive, editingUrl, resolvingRedirect } = this.state; + + if ( resolvingRedirect ) { + return ( +
+ +

{ __( 'Embedding…' ) }

+
+ ); + } + + const type = pinType( url ); + const html = ``; + + const cannotEmbed = url && ! type; + + const controls = ( + + + this.setState( { editingUrl: true } ) } + /> + + + ); + + if ( editingUrl || ! url || cannotEmbed ) { + return ( +
+ { controls } + }> +
+ this.setState( { editedUrl: event.target.value } ) } + /> + + { cannotEmbed && ( +

+ { __( 'Sorry, this content could not be embedded.', 'jetpack' ) } +
+ +

+ ) } +
+
+
+ ); + } + + // Disabled because the overlay div doesn't actually have a role or functionality + // as far as the user is concerned. We're just catching the first click so that + // the block can be selected without interacting with the embed preview that the overlay covers. + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( +
+ { controls } +
+ + { ! interactive && ( +
+ ) } +
+
+ ); + } +} + +export default PinterestEdit; diff --git a/extensions/blocks/pinterest/editor.js b/extensions/blocks/pinterest/editor.js new file mode 100644 index 0000000000000..d05f403942058 --- /dev/null +++ b/extensions/blocks/pinterest/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import registerJetpackBlock from '../../shared/register-jetpack-block'; +import { name, settings } from '.'; + +registerJetpackBlock( name, settings ); diff --git a/extensions/blocks/pinterest/index.js b/extensions/blocks/pinterest/index.js new file mode 100644 index 0000000000000..56c5ac9655c4c --- /dev/null +++ b/extensions/blocks/pinterest/index.js @@ -0,0 +1,81 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { G, Path, Rect, SVG } from '@wordpress/components'; +import { createBlock } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import { pinType } from './utils'; + +export const URL_REGEX = /^\s*https?:\/\/(?:www\.)?(?:[a-z]{2}\.)?(?:pinterest\.[a-z.]+|pin\.it)\/([^/]+)(\/[^/]+)?/i; + +export const name = 'pinterest'; +export const title = __( 'Pinterest', 'jetpack' ); + +export const icon = ( + + + + + + +); + +export const settings = { + title, + + description: __( 'Embed a Pinterest pin, board, or user.', 'jetpack' ), + + icon, + + category: 'jetpack', + + supports: { + align: false, + html: false, + }, + + attributes: { + url: { + type: 'string', + }, + }, + + edit, + + save: ( { attributes } ) => { + const { url } = attributes; + + const type = pinType( url ); + + if ( ! type ) { + return null; + } + + return ; + }, + + transforms: { + from: [ + { + type: 'raw', + isMatch: node => node.nodeName === 'P' && URL_REGEX.test( node.textContent ), + transform: node => { + return createBlock( 'jetpack/pinterest', { + url: node.textContent.trim(), + } ); + }, + }, + ], + }, + + example: { + attributes: { + url: 'https://pinterest.com/anapinskywalker/', + }, + }, +}; diff --git a/extensions/blocks/pinterest/pinterest.php b/extensions/blocks/pinterest/pinterest.php new file mode 100644 index 0000000000000..a3b6e61681ff7 --- /dev/null +++ b/extensions/blocks/pinterest/pinterest.php @@ -0,0 +1,26 @@ + 'jetpack_pinterest_block_load_assets' ) +); + +/** + * Pinterest block registration/dependency declaration. + * + * @param array $attr Array containing the Pinterest block attributes. + * @param string $content String containing the Pinterest block content. + * + * @return string + */ +function jetpack_pinterest_block_load_assets( $attr, $content ) { + wp_enqueue_script( 'pinterest-pinit', 'https://assets.pinterest.com/js/pinit.js', array(), JETPACK__VERSION, true ); + return $content; +} diff --git a/extensions/blocks/pinterest/utils.js b/extensions/blocks/pinterest/utils.js new file mode 100644 index 0000000000000..b963b3bacc76c --- /dev/null +++ b/extensions/blocks/pinterest/utils.js @@ -0,0 +1,55 @@ +/** + * External dependencies + */ +import { getPath } from '@wordpress/url'; +import { renderToString } from '@wordpress/element'; +import { createBlock } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { URL_REGEX } from '.'; + +/** + * Determines the Pinterest embed type from the URL. + * + * @param {string} url The URL to check. + * @returns {string} The pin type. Empty string if it isn't a valid Pinterest URL. + */ +export function pinType( url ) { + if ( ! URL_REGEX.test( url ) ) { + return ''; + } + + const path = getPath( url ); + + if ( ! path ) { + return ''; + } + + if ( path.startsWith( 'pin/' ) ) { + return 'embedPin'; + } + + if ( path.match( /^([^/]+)\/?$/ ) ) { + return 'embedUser'; + } + + if ( path.match( /^([^/]+)\/([^/]+)\/?$/ ) ) { + return 'embedBoard'; + } + + return ''; +} + +/** + * Fallback behaviour for unembeddable URLs. + * Creates a paragraph block containing a link to the URL, and calls `onReplace`. + * + * @param {string} url The URL that could not be embedded. + * @param {Function} onReplace Function to call with the created fallback block. + */ +export function fallback( url, onReplace ) { + const link = { url }; + onReplace( createBlock( 'core/paragraph', { content: renderToString( link ) } ) ); +} diff --git a/extensions/index.json b/extensions/index.json index 41306bdf5d88a..0f75add47f544 100644 --- a/extensions/index.json +++ b/extensions/index.json @@ -21,5 +21,5 @@ "videopress", "wordads" ], - "beta": [ "seo" ] + "beta": [ "pinterest", "seo" ] }