summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/jetpack/extensions')
-rw-r--r--plugins/jetpack/extensions/README.md225
-rw-r--r--plugins/jetpack/extensions/blocks/business-hours/business-hours.php147
-rw-r--r--plugins/jetpack/extensions/blocks/business-hours/components/day-edit.js200
-rw-r--r--plugins/jetpack/extensions/blocks/business-hours/components/day-preview.js53
-rw-r--r--plugins/jetpack/extensions/blocks/business-hours/edit.js104
-rw-r--r--plugins/jetpack/extensions/blocks/business-hours/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/business-hours/editor.scss128
-rw-r--r--plugins/jetpack/extensions/blocks/business-hours/index.js101
-rw-r--r--plugins/jetpack/extensions/blocks/contact-form/components/jetpack-contact-form.js266
-rw-r--r--plugins/jetpack/extensions/blocks/contact-form/components/jetpack-field-checkbox.js62
-rw-r--r--plugins/jetpack/extensions/blocks/contact-form/components/jetpack-field-label.js35
-rw-r--r--plugins/jetpack/extensions/blocks/contact-form/components/jetpack-field-multiple.js122
-rw-r--r--plugins/jetpack/extensions/blocks/contact-form/components/jetpack-field-textarea.js59
-rw-r--r--plugins/jetpack/extensions/blocks/contact-form/components/jetpack-field.js62
-rw-r--r--plugins/jetpack/extensions/blocks/contact-form/components/jetpack-option.js80
-rw-r--r--plugins/jetpack/extensions/blocks/contact-form/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/contact-form/editor.scss696
-rw-r--r--plugins/jetpack/extensions/blocks/contact-form/index.js462
-rw-r--r--plugins/jetpack/extensions/blocks/contact-info/address/edit.js125
-rw-r--r--plugins/jetpack/extensions/blocks/contact-info/address/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/contact-info/address/index.js71
-rw-r--r--plugins/jetpack/extensions/blocks/contact-info/address/save.js85
-rw-r--r--plugins/jetpack/extensions/blocks/contact-info/class-jetpack-contact-info-block.php114
-rw-r--r--plugins/jetpack/extensions/blocks/contact-info/contact-info.php40
-rw-r--r--plugins/jetpack/extensions/blocks/contact-info/edit.js51
-rw-r--r--plugins/jetpack/extensions/blocks/contact-info/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/contact-info/editor.scss18
-rw-r--r--plugins/jetpack/extensions/blocks/contact-info/email/edit.js15
-rw-r--r--plugins/jetpack/extensions/blocks/contact-info/email/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/contact-info/email/index.js42
-rw-r--r--plugins/jetpack/extensions/blocks/contact-info/email/save.js36
-rw-r--r--plugins/jetpack/extensions/blocks/contact-info/index.js57
-rw-r--r--plugins/jetpack/extensions/blocks/contact-info/phone/edit.js19
-rw-r--r--plugins/jetpack/extensions/blocks/contact-info/phone/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/contact-info/phone/index.js42
-rw-r--r--plugins/jetpack/extensions/blocks/contact-info/phone/save.js47
-rw-r--r--plugins/jetpack/extensions/blocks/contact-info/style.scss3
-rw-r--r--plugins/jetpack/extensions/blocks/contact-info/view.js5
-rw-r--r--plugins/jetpack/extensions/blocks/gif/edit.js217
-rw-r--r--plugins/jetpack/extensions/blocks/gif/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/gif/editor.scss86
-rw-r--r--plugins/jetpack/extensions/blocks/gif/gif.php67
-rw-r--r--plugins/jetpack/extensions/blocks/gif/index.js61
-rw-r--r--plugins/jetpack/extensions/blocks/gif/style.scss40
-rw-r--r--plugins/jetpack/extensions/blocks/gif/view.js4
-rw-r--r--plugins/jetpack/extensions/blocks/likes/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/likes/index.js8
-rw-r--r--plugins/jetpack/extensions/blocks/likes/likes-checkbox.js45
-rw-r--r--plugins/jetpack/extensions/blocks/mailchimp/edit.js235
-rw-r--r--plugins/jetpack/extensions/blocks/mailchimp/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/mailchimp/editor.scss29
-rw-r--r--plugins/jetpack/extensions/blocks/mailchimp/index.js72
-rw-r--r--plugins/jetpack/extensions/blocks/mailchimp/mailchimp.php137
-rw-r--r--plugins/jetpack/extensions/blocks/mailchimp/view.js91
-rw-r--r--plugins/jetpack/extensions/blocks/mailchimp/view.scss34
-rw-r--r--plugins/jetpack/extensions/blocks/map/add-point/index.js42
-rw-r--r--plugins/jetpack/extensions/blocks/map/add-point/oval.svg19
-rw-r--r--plugins/jetpack/extensions/blocks/map/add-point/style.scss46
-rw-r--r--plugins/jetpack/extensions/blocks/map/component.js332
-rw-r--r--plugins/jetpack/extensions/blocks/map/edit.js283
-rw-r--r--plugins/jetpack/extensions/blocks/map/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/map/editor.scss28
-rw-r--r--plugins/jetpack/extensions/blocks/map/index.js28
-rw-r--r--plugins/jetpack/extensions/blocks/map/info-window/index.js52
-rw-r--r--plugins/jetpack/extensions/blocks/map/location-search/index.js108
-rw-r--r--plugins/jetpack/extensions/blocks/map/locations/index.js81
-rw-r--r--plugins/jetpack/extensions/blocks/map/locations/style.scss27
-rw-r--r--plugins/jetpack/extensions/blocks/map/lookup/index.js230
-rw-r--r--plugins/jetpack/extensions/blocks/map/map-marker/index.js65
-rw-r--r--plugins/jetpack/extensions/blocks/map/map-marker/style.scss6
-rw-r--r--plugins/jetpack/extensions/blocks/map/map-theme-picker/index.js50
-rw-r--r--plugins/jetpack/extensions/blocks/map/map-theme-picker/map-theme_black_and_white.jpgbin0 -> 85291 bytes
-rw-r--r--plugins/jetpack/extensions/blocks/map/map-theme-picker/map-theme_default.jpgbin0 -> 113462 bytes
-rw-r--r--plugins/jetpack/extensions/blocks/map/map-theme-picker/map-theme_satellite.jpgbin0 -> 160272 bytes
-rw-r--r--plugins/jetpack/extensions/blocks/map/map-theme-picker/map-theme_terrain.jpgbin0 -> 108702 bytes
-rw-r--r--plugins/jetpack/extensions/blocks/map/map-theme-picker/style.scss36
-rw-r--r--plugins/jetpack/extensions/blocks/map/map.php31
-rw-r--r--plugins/jetpack/extensions/blocks/map/mapbox-map-formatter/index.js22
-rw-r--r--plugins/jetpack/extensions/blocks/map/save.js38
-rw-r--r--plugins/jetpack/extensions/blocks/map/settings.js104
-rw-r--r--plugins/jetpack/extensions/blocks/map/style.scss22
-rw-r--r--plugins/jetpack/extensions/blocks/map/view.js33
-rw-r--r--plugins/jetpack/extensions/blocks/markdown/edit.js127
-rw-r--r--plugins/jetpack/extensions/blocks/markdown/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/markdown/editor.scss144
-rw-r--r--plugins/jetpack/extensions/blocks/markdown/index.js70
-rw-r--r--plugins/jetpack/extensions/blocks/markdown/markdown.php20
-rw-r--r--plugins/jetpack/extensions/blocks/markdown/renderer.js28
-rw-r--r--plugins/jetpack/extensions/blocks/markdown/save.js8
-rw-r--r--plugins/jetpack/extensions/blocks/markdown/test/__snapshots__/markdown-renderer.js.snap63
-rw-r--r--plugins/jetpack/extensions/blocks/markdown/test/fixtures/source.js63
-rw-r--r--plugins/jetpack/extensions/blocks/markdown/test/markdown-renderer.js17
-rw-r--r--plugins/jetpack/extensions/blocks/membership-button/edit.jsx392
-rw-r--r--plugins/jetpack/extensions/blocks/membership-button/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/membership-button/editor.scss38
-rw-r--r--plugins/jetpack/extensions/blocks/membership-button/index.js69
-rw-r--r--plugins/jetpack/extensions/blocks/membership-button/membership-button.php19
-rw-r--r--plugins/jetpack/extensions/blocks/membership-button/view.js79
-rw-r--r--plugins/jetpack/extensions/blocks/membership-button/view.scss49
-rw-r--r--plugins/jetpack/extensions/blocks/publicize/connection-verify.js112
-rw-r--r--plugins/jetpack/extensions/blocks/publicize/connection.js100
-rw-r--r--plugins/jetpack/extensions/blocks/publicize/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/publicize/editor.scss100
-rw-r--r--plugins/jetpack/extensions/blocks/publicize/form-unwrapped.js118
-rw-r--r--plugins/jetpack/extensions/blocks/publicize/form.js72
-rw-r--r--plugins/jetpack/extensions/blocks/publicize/index.js50
-rw-r--r--plugins/jetpack/extensions/blocks/publicize/panel.js51
-rw-r--r--plugins/jetpack/extensions/blocks/publicize/service-icon.js64
-rw-r--r--plugins/jetpack/extensions/blocks/publicize/settings-button.js73
-rw-r--r--plugins/jetpack/extensions/blocks/publicize/store/actions.js41
-rw-r--r--plugins/jetpack/extensions/blocks/publicize/store/controls.js19
-rw-r--r--plugins/jetpack/extensions/blocks/publicize/store/effects.js32
-rw-r--r--plugins/jetpack/extensions/blocks/publicize/store/index.js24
-rw-r--r--plugins/jetpack/extensions/blocks/publicize/store/middlewares.js40
-rw-r--r--plugins/jetpack/extensions/blocks/publicize/store/reducer.js18
-rw-r--r--plugins/jetpack/extensions/blocks/publicize/store/selectors.js24
-rw-r--r--plugins/jetpack/extensions/blocks/related-posts/edit.js252
-rw-r--r--plugins/jetpack/extensions/blocks/related-posts/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/related-posts/index.js75
-rw-r--r--plugins/jetpack/extensions/blocks/related-posts/style.scss85
-rw-r--r--plugins/jetpack/extensions/blocks/repeat-visitor/components/edit.js114
-rw-r--r--plugins/jetpack/extensions/blocks/repeat-visitor/components/save.js12
-rw-r--r--plugins/jetpack/extensions/blocks/repeat-visitor/constants.js5
-rw-r--r--plugins/jetpack/extensions/blocks/repeat-visitor/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/repeat-visitor/editor.scss55
-rw-r--r--plugins/jetpack/extensions/blocks/repeat-visitor/index.js46
-rw-r--r--plugins/jetpack/extensions/blocks/repeat-visitor/repeat-visitor.php41
-rw-r--r--plugins/jetpack/extensions/blocks/repeat-visitor/view.js35
-rw-r--r--plugins/jetpack/extensions/blocks/seo/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/seo/editor.scss15
-rw-r--r--plugins/jetpack/extensions/blocks/seo/index.js39
-rw-r--r--plugins/jetpack/extensions/blocks/seo/panel.js54
-rw-r--r--plugins/jetpack/extensions/blocks/sharing/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/sharing/index.js8
-rw-r--r--plugins/jetpack/extensions/blocks/sharing/sharing-checkbox.js45
-rw-r--r--plugins/jetpack/extensions/blocks/shortlinks/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/shortlinks/index.js45
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/constants.js39
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/edit.js579
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/editor.scss63
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/featured-media.js69
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/help-message.js25
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/help-message.scss23
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/index.js131
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/paypal-button-2x.pngbin0 -> 8186 bytes
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/paypal-button.pngbin0 -> 7496 bytes
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/product-placeholder.js68
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/product-placeholder.scss93
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/save.js9
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/utils.js29
-rw-r--r--plugins/jetpack/extensions/blocks/slideshow/create-swiper.js48
-rw-r--r--plugins/jetpack/extensions/blocks/slideshow/edit.js249
-rw-r--r--plugins/jetpack/extensions/blocks/slideshow/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/slideshow/editor.scss44
-rw-r--r--plugins/jetpack/extensions/blocks/slideshow/index.js93
-rw-r--r--plugins/jetpack/extensions/blocks/slideshow/save.js15
-rw-r--r--plugins/jetpack/extensions/blocks/slideshow/slideshow.js232
-rw-r--r--plugins/jetpack/extensions/blocks/slideshow/slideshow.php28
-rw-r--r--plugins/jetpack/extensions/blocks/slideshow/style.scss165
-rw-r--r--plugins/jetpack/extensions/blocks/slideshow/swiper-callbacks.js95
-rw-r--r--plugins/jetpack/extensions/blocks/slideshow/transforms.js78
-rw-r--r--plugins/jetpack/extensions/blocks/slideshow/view.js70
-rw-r--r--plugins/jetpack/extensions/blocks/subscriptions/edit.js81
-rw-r--r--plugins/jetpack/extensions/blocks/subscriptions/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/subscriptions/index.js85
-rw-r--r--plugins/jetpack/extensions/blocks/subscriptions/save.js17
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/constants.js28
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/css-gram.scss86
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/constants.js27
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/image.js51
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/index.js81
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/column.js3
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/gallery.js7
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/index.js144
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/index.js104
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/ratios.js280
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/resize.js107
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/row.js8
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/square.js33
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/save.js31
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/edit.js289
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/editor.scss148
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/filter-toolbar.js136
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/gallery-image/edit.js147
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/gallery-image/save.js46
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/index.js212
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/column.js3
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/gallery.js7
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/index.js160
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/index.js104
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/ratios.js280
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/resize.js107
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/index.js.snap98
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/ratios.js.snap30
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/fixtures/ratios.js16
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/index.js21
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/ratios.js11
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/row.js8
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/square.js33
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/layout/test/fixtures/image-sets.js103
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/save.js30
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/tiled-gallery.php171
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/variables.scss2
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/view.js64
-rw-r--r--plugins/jetpack/extensions/blocks/tiled-gallery/view.scss115
-rw-r--r--plugins/jetpack/extensions/blocks/videopress/edit.js185
-rw-r--r--plugins/jetpack/extensions/blocks/videopress/editor.js119
-rw-r--r--plugins/jetpack/extensions/blocks/videopress/index.js9
-rw-r--r--plugins/jetpack/extensions/blocks/videopress/loading.js13
-rw-r--r--plugins/jetpack/extensions/blocks/videopress/save.js37
-rw-r--r--plugins/jetpack/extensions/blocks/wordads/constants.js61
-rw-r--r--plugins/jetpack/extensions/blocks/wordads/edit.js56
-rw-r--r--plugins/jetpack/extensions/blocks/wordads/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/wordads/editor.scss52
-rw-r--r--plugins/jetpack/extensions/blocks/wordads/format-picker.js59
-rw-r--r--plugins/jetpack/extensions/blocks/wordads/index.js66
-rw-r--r--plugins/jetpack/extensions/blocks/wordads/wordads.php121
-rw-r--r--plugins/jetpack/extensions/editor.js5
-rw-r--r--plugins/jetpack/extensions/index.json24
-rw-r--r--plugins/jetpack/extensions/shared/styles/gutenberg-colors.scss93
-rw-r--r--plugins/jetpack/extensions/shared/styles/gutenberg-variables.scss22
-rw-r--r--plugins/jetpack/extensions/shared/styles/jetpack-variables.scss1
-rw-r--r--plugins/jetpack/extensions/view.js4
225 files changed, 16278 insertions, 0 deletions
diff --git a/plugins/jetpack/extensions/README.md b/plugins/jetpack/extensions/README.md
new file mode 100644
index 00000000..147e57b6
--- /dev/null
+++ b/plugins/jetpack/extensions/README.md
@@ -0,0 +1,225 @@
+# Jetpack Block Editor Extensions
+
+This directory contains the source code for extensions in the block editor,
+also known as Gutenberg, [that was introduced in WordPress 5.0](https://wordpress.org/news/2018/12/bebo/).
+
+## Extension Type
+
+We define different types of block editor extensions:
+
+- Blocks are available in the editor itself.
+- Plugins are available in the Jetpack sidebar that appears on the right side of the block editor.
+
+## Extension Structure
+
+Extensions in the `extensions/blocks` folder loosely follow this structure:
+
+```
+.
+└── block-or-plugin-name/
+ ├── block-or-plugin-name.php ← PHP file where the block and its assets are registered.
+ ├── editor.js ← script loaded only in the editor
+ ├── editor.scss ← styles loaded only in the editor
+ ├── view.js ← script loaded in the editor and theme
+ └── view.scss ← styles loaded in the editor and theme
+```
+
+If your block depends on another block, place them all in extensions folder:
+
+```
+.
+├── block-name/
+└── sub-blockname/
+```
+
+## Developing block editor extensions in Jetpack
+
+### High-level overview of the development flow
+
+1. Use the [Jetpack Docker environment](https://github.com/Automattic/jetpack/tree/master/docker#readme).
+1. Start a new branch.
+1. Add your new extension's source files to the extensions/blocks directory.
+And add your extensions' slug to the beta array in `extensions/index.json`. You can use Jetpack-CLI command to scaffold the block (see below).
+By keeping your extension in the beta array, it's safe to do small PRs and merge frequently.
+1. Or modify existing extensions in the same folder.
+1. Run `yarn build-extensions [--watch]` to compile your changes.
+1. Now test your changes in your Docker environment's wp-admin.
+1. Open a PR, and a WordPress.com diff will be automatically generated with your changes.
+1. Test the WordPress.com diff
+1. Once the code works well in both environments and has been approved by a Jetpack crew member, you can merge your branch!
+1. When your block is ready to be shipped, move your extensions' slug from beta to production array in `extensions/index.json`
+
+### Beta Extensions
+
+Generally, all new extensions should start out as a beta.
+
+- Before you develop, remember to add your extension's slug to the beta array in `extensions/index.json`.
+- In the `wp-config.php` for your Docker environment (`docker/wordpress/wp-config.php`) or in your custom mu-plugins file (`docker/mu-plugins/yourfile.php`), enable beta extensions with the following snippet: `define( 'JETPACK_BETA_BLOCKS', true );`
+- In the WordPress.com environment, a12s will be able to see beta extensions with no further configuration
+- Once you've successfully beta tested your new extension, you can open new PR to make your extension live!
+- Simply move the extension's slug out of the beta array and into the production array in `extensions/index.json`.
+
+### Testing
+
+Run `yarn test-extensions [--watch]` to run tests written in [Jest](https://jestjs.io/en/).
+
+Note that adding [Jest snapshot tests](https://jestjs.io/docs/en/snapshot-testing) for block's `save` methods is problematic because many core packages relying on `window` that is not present when testing with Jest. See [prior exploration](https://github.com/Automattic/wp-calypso/pull/30727).
+
+## Scaffolding blocks with WP-CLI
+
+We have a command in WP-CLI that allows to scaffold Jetpack blocks. Its syntax is as follows:
+
+`wp jetpack scaffold <type> <title> [--slug] [--description] [--keywords]`
+
+**Currently the only `type` is `block`.**
+
+### Options
+
+- **title**: Block name, also used to create the slug. This parameter is required. If it's something like _Logo gallery_, the slug will be `logo-gallery`. It's also used to generate the class name when an external edit component is requested. Following this example, it would be `LogoGalleryEdit`.
+- **--slug**: Specific slug to identify the block that overrides the one generated base don the title.
+- **--description**: Allows to provide a text description of the block.
+- **--keywords**: Provide up to three keywords separated by a comma so users when they search for a block in the editor.
+
+### Files
+
+All files will be created in a directory under `extensions/blocks/` named after the block title or a specific given slug. For a hypothetical **Jukebox** block, it will create the following files
+
+- `extensions/blocks/jukebox/`
+- `extensions/blocks/jukebox/jukebox.php`
+- `extensions/blocks/jukebox/index.js`
+- `extensions/blocks/jukebox/editor.js`
+- `extensions/blocks/jukebox/editor.scss`
+- `extensions/blocks/jukebox/edit.js`
+
+Additionally, the slug of the new block will be added to the `beta` array in the file `extensions/index.json`.
+Since it's added to the beta array, you need to load the beta blocks as explained above to be able to test this block.
+
+### Examples
+
+`wp jetpack scaffold block "Cool Block"`
+
+`wp jetpack scaffold block "Amazing Rock" --slug="good-music" --description="Rock the best music on your site"`
+
+`wp jetpack scaffold block "Jukebox" --keywords="music, audio, media"`
+
+### Can I use Jurassic Ninja to test blocks?
+
+Yes! Just like any other changes in Jetpack, also blocks work in Jurassic Ninja.
+
+Simply add branch name to the URL: jurassic.ninja/create/?jetpack-beta&branch=master or use other ninjastic features.
+
+### How do I merge extensions to Jetpack
+
+- Jetpack is released once a month, so be sure your team is aware of [upcoming code freezes](https://github.com/Automattic/Jetpack/milestones).
+- Make sure you and your team have tested your PR in both the Jetpack environment, and the WordPress.com environment.
+- Additionally, your PR will require approval from a Jetpack crew member.
+- Once merged, your extension will appear in the next release.
+
+### How do I merge extensions to WordPress.com?
+
+- Merge to Jetpack master first.
+- Now, merge the auto-generated diff on WordPress.com.
+- There's no need to wait on release schedules, in fact it is best if you merge your WordPress.com diff immediately after you've merged to Jetpack master.
+
+### What if I need to manually create a WordPress.com diff?
+
+You can build extensions from the Jetpack folder to your local sandbox folder and sync the whole sandbox like you always do:
+
+```bash
+yarn clean-extensions
+yarn build-extensions \
+ --output-path /PATH_TO_YOUR_SANDBOX/wp-content/mu-plugins/jetpack/_inc/blocks/ \
+ --watch
+```
+
+Alternatively, if you don’t need to touch PHP files, you can build extensions in the Jetpack folder without --output-path and use rsync to push files directly to your sandbox:
+
+```bash
+rsync -az --delete _inc/blocks/ \
+ YOUR_WPCOM_SANDBOX:/BLOCKS_PATH_IN_YOUR_SANDBOX/
+```
+
+To test extensions for a Simple site in Calypso, sandbox the simple site URL (`example.wordpress.com`). Calypso loads Gutenberg from simple sites’ wp-admin in an iframe.
+
+## Good to know when developing Gutenberg extensions
+
+## The Build
+
+- Compiled extensions are output to `_inc/blocks`
+- You can view the various build commands in `package.json`
+- You can see the build configuration in `webpack.config.extensions.js`
+
+If you need to modify the build process, bear in mind that config files are also
+synced to WordPress.com via Fusion. Consult with a Jetpack crew member to ensure
+you test the new build in both environments.
+
+## Debugging
+
+Setting these might be useful for debugging with block editor:
+
+```php
+define( 'SCRIPT_DEBUG', true );
+define( 'GUTENBERG_DEVELOPMENT_MODE', true );
+```
+
+You could modify `SCRIPT_DEBUG` from `docker/wordpress/wp-config.php` in your Docker environment and add `GUTENBERG_DEVELOPMENT_MODE` there as well, or in your custom mu-plugins file (`docker/mu-plugins/yourfile.php`).
+
+[G Debugger](https://wordpress.org/plugins/g-debugger/) plugin might come handy, too.
+
+### Don't worry about dependencies
+
+The build takes care of core dependencies for both editor and view scripts. React, Lodash and `@wordpress/*` [dependencies](https://github.com/WordPress/gutenberg/blob/master/docs/contributors/scripts.md) are externalized and automatically enqueued in PHP for your extension.
+
+Extensions _always_ get [Gutenberg's polyfill scripts](https://github.com/WordPress/gutenberg/blob/master/docs/contributors/scripts.md#polyfill-scripts) enqueued so you can safely use methods not supported by older browsers such as IE11.
+
+### Jetpack plugin sidebar
+
+Jetpack adds its own [plugin sidebar](https://wordpress.org/gutenberg/handbook/designers-developers/developers/tutorials/plugin-sidebar-0/plugin-sidebar-1-up-and-running/) to the block editor. You can find it by choosing "Jetpack" from block the editor's ellipsis menu or by pressing the Jetpack icon in the "pinned plugins" toolbar.
+
+The sidebar itself is always registered in the editor and populated using the [Slot Fill](https://github.com/WordPress/gutenberg/tree/master/packages/components/src/slot-fill#readme) mechanism.
+
+Use the `JetpackPluginSidebar` component to render from anywhere in your plugin's code:
+
+```jsx
+import JetpackPluginSidebar from '../../shared/jetpack-plugin-sidebar';
+
+<JetpackPluginSidebar>
+ <PanelBody title={ __( 'My sidebar section', 'jetpack' ) }>
+ <p>Jetpack is Bestpack!</p>
+ </PanelBody>
+</JetpackPluginSidebar>
+```
+
+The sidebar won't show up at all if nothing is being rendered in the sidebar's "slot".
+
+Remember to be mindful of what post types you want to enable your sidebar section for: e.g. posts, pages, custom post types, and re-usable block post type (`/wp-admin/edit.php?post_type=wp_block`).
+
+See [Publicize](blocks/publicize/index.js) and [Shortlinks](blocks/shortlinks/index.js) for examples how to limit functionality only to some specific post types or posts. The [Likes](blocks/likes/likes-checkbox.js) & [Sharing](blocks/sharing/sharing-checkbox.js) extensions are a great example of how to [output](shared/jetpack-likes-and-sharing-panel.js) content from several extensions to one sidebar section using "slots".
+
+### i18n
+
+As of 04/2019, `wp.i18n` [doesn't support React elements in strings](https://github.com/WordPress/gutenberg/issues/9846). You will have to structure your copy so that links and other HTML can be translated separately.
+
+Not possible:
+
+```js
+__( 'Still confused? Check out <a>documentation</a> for more!' )
+```
+
+Possible:
+
+```jsx
+{ __( 'Still confused?' ) } <a>{ __( 'Check out documentation for more!' ) }</a>
+```
+
+### Colors
+
+To stay consistent with Gutenberg, your extensions should follow [Gutenberg styles and visuals](https://wordpress.org/gutenberg/handbook/designers-developers/designers/block-design/).
+
+Use Gutenberg color variables where possible by importing them in your stylesheet from `extensions/shared/styles/gutenberg-colors.scss`.
+
+The build pipeline also supports [Muriel colors](https://github.com/Automattic/color-studio) via SASS variables (`$muriel-pink-300`) and CSS custom properties (`var( --muriel-pink-300 )`) without specifically importing them first. Prefer CSS custom properties if possible.
+
+### Icons
+
+Please use outline versions of [Material icons](https://material.io/tools/icons/?style=outline) to stay in line with Muriel guidelines. Don't rely on icons used in WordPress core to avoid visual mixing up with core blocks.
diff --git a/plugins/jetpack/extensions/blocks/business-hours/business-hours.php b/plugins/jetpack/extensions/blocks/business-hours/business-hours.php
new file mode 100644
index 00000000..e8a5e261
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/business-hours/business-hours.php
@@ -0,0 +1,147 @@
+<?php
+/**
+ * Business Hours Block.
+ *
+ * @since 7.1.0
+ *
+ * @package Jetpack
+ */
+
+jetpack_register_block(
+ 'jetpack/business-hours',
+ array( 'render_callback' => 'jetpack_business_hours_render' )
+);
+
+/**
+ * Get's default days / hours to render a business hour block with no data provided.
+ *
+ * @return array
+ */
+function jetpack_business_hours_get_default_days() {
+ return array(
+ array(
+ 'name' => 'Sun',
+ 'hours' => array(),
+ ),
+ array(
+ 'name' => 'Mon',
+ 'hours' => array(
+ array(
+ 'opening' => '09:00',
+ 'closing' => '17:00',
+ ),
+ ),
+ ),
+ array(
+ 'name' => 'Tue',
+ 'hours' => array(
+ array(
+ 'opening' => '09:00',
+ 'closing' => '17:00',
+ ),
+ ),
+ ),
+ array(
+ 'name' => 'Wed',
+ 'hours' => array(
+ array(
+ 'opening' => '09:00',
+ 'closing' => '17:00',
+ ),
+ ),
+ ),
+ array(
+ 'name' => 'Thu',
+ 'hours' => array(
+ array(
+ 'opening' => '09:00',
+ 'closing' => '17:00',
+ ),
+ ),
+ ),
+ array(
+ 'name' => 'Fri',
+ 'hours' => array(
+ array(
+ 'opening' => '09:00',
+ 'closing' => '17:00',
+ ),
+ ),
+ ),
+ array(
+ 'name' => 'Sat',
+ 'hours' => array(),
+ ),
+ );
+}
+
+/**
+ * Dynamic rendering of the block.
+ *
+ * @param array $attributes Array containing the business hours block attributes.
+ *
+ * @return string
+ */
+function jetpack_business_hours_render( $attributes ) {
+ global $wp_locale;
+
+ if ( empty( $attributes['days'] ) || ! is_array( $attributes['days'] ) ) {
+ $attributes['days'] = jetpack_business_hours_get_default_days();
+ }
+
+ $start_of_week = (int) get_option( 'start_of_week', 0 );
+ $time_format = get_option( 'time_format' );
+ $content = sprintf(
+ '<dl class="jetpack-business-hours %s">',
+ ! empty( $attributes['className'] ) ? esc_attr( $attributes['className'] ) : ''
+ );
+
+ $days = array( 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' );
+
+ if ( $start_of_week ) {
+ $chunk1 = array_slice( $attributes['days'], 0, $start_of_week );
+ $chunk2 = array_slice( $attributes['days'], $start_of_week );
+ $attributes['days'] = array_merge( $chunk2, $chunk1 );
+ }
+
+ foreach ( $attributes['days'] as $day ) {
+ $content .= '<dt class="' . esc_attr( $day['name'] ) . '">' .
+ ucfirst( $wp_locale->get_weekday( array_search( $day['name'], $days, true ) ) ) .
+ '</dt>';
+ $content .= '<dd class="' . esc_attr( $day['name'] ) . '">';
+ $days_hours = '';
+
+ foreach ( $day['hours'] as $hour ) {
+ $opening = strtotime( $hour['opening'] );
+ $closing = strtotime( $hour['closing'] );
+ if ( ! $opening || ! $closing ) {
+ continue;
+ }
+ $days_hours .= sprintf(
+ /* Translators: Business opening hours info. */
+ _x( 'From %1$s to %2$s', 'from business opening hour to closing hour', 'jetpack' ),
+ date( $time_format, $opening ),
+ date( $time_format, $closing )
+ );
+ $days_hours .= '<br />';
+ }
+
+ if ( empty( $days_hours ) ) {
+ $days_hours = esc_html__( 'Closed', 'jetpack' );
+ }
+ $content .= $days_hours;
+ $content .= '</dd>';
+ }
+
+ $content .= '</dl>';
+
+ /**
+ * Allows folks to filter the HTML content for the Business Hours block
+ *
+ * @since 7.1.0
+ *
+ * @param string $content The default HTML content set by `jetpack_business_hours_render`
+ * @param array $attributes Attributes generated in the block editor for the Business Hours block
+ */
+ return apply_filters( 'jetpack_business_hours_content', $content, $attributes );
+}
diff --git a/plugins/jetpack/extensions/blocks/business-hours/components/day-edit.js b/plugins/jetpack/extensions/blocks/business-hours/components/day-edit.js
new file mode 100644
index 00000000..bab6958a
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/business-hours/components/day-edit.js
@@ -0,0 +1,200 @@
+/**
+ * External dependencies
+ */
+import classNames from 'classnames';
+import { __ } from '@wordpress/i18n';
+import { Component, Fragment } from '@wordpress/element';
+import { IconButton, TextControl, ToggleControl } from '@wordpress/components';
+import { isEmpty } from 'lodash';
+
+const defaultOpen = '09:00';
+const defaultClose = '17:00';
+
+class DayEdit extends Component {
+ renderInterval = ( interval, intervalIndex ) => {
+ const { day } = this.props;
+ const { opening, closing } = interval;
+ return (
+ <Fragment key={ intervalIndex }>
+ <div className="business-hours__row">
+ <div className={ classNames( day.name, 'business-hours__day' ) }>
+ { intervalIndex === 0 && this.renderDayToggle() }
+ </div>
+ <div className={ classNames( day.name, 'business-hours__hours' ) }>
+ <TextControl
+ type="time"
+ label={ __( 'Opening', 'jetpack' ) }
+ value={ opening }
+ className="business-hours__open"
+ placeholder={ defaultOpen }
+ onChange={ value => {
+ this.setHour( value, 'opening', intervalIndex );
+ } }
+ />
+ <TextControl
+ type="time"
+ label={ __( 'Closing', 'jetpack' ) }
+ value={ closing }
+ className="business-hours__close"
+ placeholder={ defaultClose }
+ onChange={ value => {
+ this.setHour( value, 'closing', intervalIndex );
+ } }
+ />
+ </div>
+ <div className="business-hours__remove">
+ { day.hours.length > 1 && (
+ <IconButton
+ isSmall
+ isLink
+ icon="trash"
+ onClick={ () => {
+ this.removeInterval( intervalIndex );
+ } }
+ />
+ ) }
+ </div>
+ </div>
+ { intervalIndex === day.hours.length - 1 && (
+ <div className="business-hours__row business-hours-row__add">
+ <div className={ classNames( day.name, 'business-hours__day' ) }>&nbsp;</div>
+ <div className={ classNames( day.name, 'business-hours__hours' ) }>
+ <IconButton
+ isLink
+ label={ __( 'Add Hours', 'jetpack' ) }
+ onClick={ this.addInterval }
+ >
+ { __( 'Add Hours', 'jetpack' ) }
+ </IconButton>
+ </div>
+ <div className="business-hours__remove">&nbsp;</div>
+ </div>
+ ) }
+ </Fragment>
+ );
+ };
+
+ setHour = ( hourValue, hourType, hourIndex ) => {
+ const { day, attributes, setAttributes } = this.props;
+ const { days } = attributes;
+ setAttributes( {
+ days: days.map( value => {
+ if ( value.name === day.name ) {
+ return {
+ ...value,
+ hours: value.hours.map( ( hour, index ) => {
+ if ( index === hourIndex ) {
+ return {
+ ...hour,
+ [ hourType ]: hourValue,
+ };
+ }
+ return hour;
+ } ),
+ };
+ }
+ return value;
+ } ),
+ } );
+ };
+
+ toggleClosed = nextValue => {
+ const { day, attributes, setAttributes } = this.props;
+ const { days } = attributes;
+
+ setAttributes( {
+ days: days.map( value => {
+ if ( value.name === day.name ) {
+ const hours = nextValue
+ ? [
+ {
+ opening: defaultOpen,
+ closing: defaultClose,
+ },
+ ]
+ : [];
+ return {
+ ...value,
+ hours,
+ };
+ }
+ return value;
+ } ),
+ } );
+ };
+
+ addInterval = () => {
+ const { day, attributes, setAttributes } = this.props;
+ const { days } = attributes;
+ day.hours.push( { opening: '', closing: '' } );
+ setAttributes( {
+ days: days.map( value => {
+ if ( value.name === day.name ) {
+ return {
+ ...value,
+ hours: day.hours,
+ };
+ }
+ return value;
+ } ),
+ } );
+ };
+
+ removeInterval = hourIndex => {
+ const { day, attributes, setAttributes } = this.props;
+ const { days } = attributes;
+
+ setAttributes( {
+ days: days.map( value => {
+ if ( day.name === value.name ) {
+ return {
+ ...value,
+ hours: value.hours.filter( ( hour, index ) => {
+ return hourIndex !== index;
+ } ),
+ };
+ }
+ return value;
+ } ),
+ } );
+ };
+
+ isClosed() {
+ const { day } = this.props;
+ return isEmpty( day.hours );
+ }
+
+ renderDayToggle() {
+ const { day, localization } = this.props;
+ return (
+ <Fragment>
+ <span className="business-hours__day-name">{ localization.days[ day.name ] }</span>
+ <ToggleControl
+ label={ this.isClosed() ? __( 'Closed', 'jetpack' ) : __( 'Open', 'jetpack' ) }
+ checked={ ! this.isClosed() }
+ onChange={ this.toggleClosed }
+ />
+ </Fragment>
+ );
+ }
+
+ renderClosed() {
+ const { day } = this.props;
+ return (
+ <div className="business-hours__row business-hours-row__closed">
+ <div className={ classNames( day.name, 'business-hours__day' ) }>
+ { this.renderDayToggle() }
+ </div>
+ <div className={ classNames( day.name, 'closed', 'business-hours__hours' ) }>&nbsp;</div>
+ <div className="business-hours__remove">&nbsp;</div>
+ </div>
+ );
+ }
+
+ render() {
+ const { day } = this.props;
+ return this.isClosed() ? this.renderClosed() : day.hours.map( this.renderInterval );
+ }
+}
+
+export default DayEdit;
diff --git a/plugins/jetpack/extensions/blocks/business-hours/components/day-preview.js b/plugins/jetpack/extensions/blocks/business-hours/components/day-preview.js
new file mode 100644
index 00000000..be0dd24b
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/business-hours/components/day-preview.js
@@ -0,0 +1,53 @@
+/**
+ * External dependencies
+ */
+import { _x, sprintf } from '@wordpress/i18n';
+import { Component, Fragment } from '@wordpress/element';
+import { date } from '@wordpress/date';
+import { isEmpty } from 'lodash';
+
+class DayPreview extends Component {
+ formatTime( time ) {
+ const { timeFormat } = this.props;
+ const [ hours, minutes ] = time.split( ':' );
+ const _date = new Date();
+ if ( ! hours || ! minutes ) {
+ return false;
+ }
+ _date.setHours( hours );
+ _date.setMinutes( minutes );
+ return date( timeFormat, _date );
+ }
+
+ renderInterval = ( interval, key ) => {
+ return (
+ <dd key={ key }>
+ { sprintf(
+ _x( 'From %s to %s', 'from business opening hour to closing hour', 'jetpack' ),
+ this.formatTime( interval.opening ),
+ this.formatTime( interval.closing )
+ ) }
+ </dd>
+ );
+ };
+
+ render() {
+ const { day, localization } = this.props;
+ const hours = day.hours.filter(
+ // remove any malformed or empty intervals
+ interval => this.formatTime( interval.opening ) && this.formatTime( interval.closing )
+ );
+ return (
+ <Fragment>
+ <dt className={ day.name }>{ localization.days[ day.name ] }</dt>
+ { isEmpty( hours ) ? (
+ <dd>{ _x( 'Closed', 'business is closed on a full day', 'jetpack' ) }</dd>
+ ) : (
+ hours.map( this.renderInterval )
+ ) }
+ </Fragment>
+ );
+ }
+}
+
+export default DayPreview;
diff --git a/plugins/jetpack/extensions/blocks/business-hours/edit.js b/plugins/jetpack/extensions/blocks/business-hours/edit.js
new file mode 100644
index 00000000..7649fd45
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/business-hours/edit.js
@@ -0,0 +1,104 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+import classNames from 'classnames';
+import { __ } from '@wordpress/i18n';
+import { __experimentalGetSettings } from '@wordpress/date';
+import { BlockIcon } from '@wordpress/editor';
+import { Component } from '@wordpress/element';
+import { Placeholder } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import DayEdit from './components/day-edit';
+import DayPreview from './components/day-preview';
+import { icon } from '.';
+
+const defaultLocalization = {
+ days: {
+ Sun: __( 'Sunday', 'jetpack' ),
+ Mon: __( 'Monday', 'jetpack' ),
+ Tue: __( 'Tuesday', 'jetpack' ),
+ Wed: __( 'Wednesday', 'jetpack' ),
+ Thu: __( 'Thursday', 'jetpack' ),
+ Fri: __( 'Friday', 'jetpack' ),
+ Sat: __( 'Saturday', 'jetpack' ),
+ },
+ startOfWeek: 0,
+};
+
+class BusinessHours extends Component {
+ state = {
+ localization: defaultLocalization,
+ hasFetched: false,
+ };
+
+ componentDidMount() {
+ this.apiFetch();
+ }
+
+ apiFetch() {
+ this.setState( { data: defaultLocalization }, () => {
+ apiFetch( { path: '/wpcom/v2/business-hours/localized-week' } ).then(
+ data => {
+ this.setState( { localization: data, hasFetched: true } );
+ },
+ () => {
+ this.setState( { localization: defaultLocalization, hasFetched: true } );
+ }
+ );
+ } );
+ }
+
+ render() {
+ const { attributes, className, isSelected } = this.props;
+ const { days } = attributes;
+ const { localization, hasFetched } = this.state;
+ const { startOfWeek } = localization;
+ const localizedWeek = days.concat( days.slice( 0, startOfWeek ) ).slice( startOfWeek );
+
+ if ( ! hasFetched ) {
+ return (
+ <Placeholder
+ icon={ <BlockIcon icon={ icon } /> }
+ label={ __( 'Loading business hours', 'jetpack' ) }
+ />
+ );
+ }
+
+ if ( ! isSelected ) {
+ const settings = __experimentalGetSettings();
+ const {
+ formats: { time },
+ } = settings;
+ return (
+ <dl className={ classNames( className, 'jetpack-business-hours' ) }>
+ { localizedWeek.map( ( day, key ) => {
+ return (
+ <DayPreview
+ key={ key }
+ day={ day }
+ localization={ localization }
+ timeFormat={ time }
+ />
+ );
+ } ) }
+ </dl>
+ );
+ }
+
+ return (
+ <div className={ classNames( className, 'is-edit' ) }>
+ { localizedWeek.map( ( day, key ) => {
+ return (
+ <DayEdit key={ key } day={ day } localization={ localization } { ...this.props } />
+ );
+ } ) }
+ </div>
+ );
+ }
+}
+
+export default BusinessHours;
diff --git a/plugins/jetpack/extensions/blocks/business-hours/editor.js b/plugins/jetpack/extensions/blocks/business-hours/editor.js
new file mode 100644
index 00000000..d05f4039
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/business-hours/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/plugins/jetpack/extensions/blocks/business-hours/editor.scss b/plugins/jetpack/extensions/blocks/business-hours/editor.scss
new file mode 100644
index 00000000..18073276
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/business-hours/editor.scss
@@ -0,0 +1,128 @@
+@import '../../shared/styles/gutenberg-variables.scss';
+
+.wp-block-jetpack-business-hours {
+ overflow: hidden;
+
+ .business-hours__row {
+ display: flex;
+
+ &.business-hours-row__add,
+ &.business-hours-row__closed {
+ margin-bottom: 20px;
+ }
+
+ .business-hours__day {
+ width: 44%;
+ display: flex;
+ align-items: baseline;
+
+ .business-hours__day-name {
+ width: 60%;
+ font-weight: bold;
+ overflow-x: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .components-form-toggle {
+ margin-right: 4px;
+ }
+ }
+
+ .business-hours__hours {
+ width: 44%;
+ margin: 0;
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+
+ .components-base-control {
+ display: inline-block;
+ margin-bottom: 0;
+ width: 48%;
+
+ &.business-hours__open {
+ margin-right: 4%;
+ }
+
+ .components-base-control__label {
+ margin-bottom: 0;
+ }
+ }
+ }
+ }
+
+ .business-hours__remove {
+ align-self: flex-end;
+ margin-bottom: 8px;
+ text-align: center;
+ width: 10%;
+ }
+
+ .business-hours-row__add button:hover {
+ box-shadow: none !important;
+ }
+
+ .business-hours__remove button {
+ display: block;
+ margin: 0 auto;
+ }
+
+ .business-hours-row__add .components-button.is-default:hover,
+ .business-hours__remove .components-button.is-default:hover,
+ .business-hours-row__add .components-button.is-default:focus,
+ .business-hours__remove .components-button.is-default:focus,
+ .business-hours-row__add .components-button.is-default:active,
+ .business-hours__remove .components-button.is-default:active {
+ background: none;
+ box-shadow: none;
+ }
+}
+
+/**
+ * We consider the editor area to be small when the business hours block is:
+ * - within a column block
+ * - in a screen < xlarge size with the sidebar open
+ * - in a screen < small size
+ * In these cases we'll apply small screen styles.
+ */
+@mixin editor-area-is-small {
+ @media ( max-width: $break-xlarge ) {
+ .is-sidebar-opened {
+ @content;
+ }
+ }
+ @media ( max-width: $break-small ) {
+ @content;
+ }
+
+ .wp-block-columns {
+ @content;
+ }
+}
+
+@include editor-area-is-small() {
+ .wp-block-jetpack-business-hours {
+ .business-hours__row {
+ flex-wrap: wrap;
+
+ &.business-hours-row__add {
+ .business-hours__day,
+ .business-hours__remove {
+ display: none;
+ }
+ }
+
+ .business-hours__day {
+ width: 100%;
+ }
+
+ .business-hours__hours {
+ width: 78%;
+ }
+ .business-hours__remove {
+ width: 18%;
+ }
+ }
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/business-hours/index.js b/plugins/jetpack/extensions/blocks/business-hours/index.js
new file mode 100644
index 00000000..ee307afc
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/business-hours/index.js
@@ -0,0 +1,101 @@
+/**
+ * External dependencies
+ */
+import { __, _x } from '@wordpress/i18n';
+import { Path } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import './editor.scss';
+import BusinessHours from './edit';
+import renderMaterialIcon from '../../shared/render-material-icon';
+
+/**
+ * Block Registrations:
+ */
+
+export const name = 'business-hours';
+
+export const icon = renderMaterialIcon(
+ <Path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" />
+);
+
+export const settings = {
+ title: __( 'Business Hours', 'jetpack' ),
+ description: __( 'Display opening hours for your business.', 'jetpack' ),
+ icon,
+ category: 'jetpack',
+ supports: {
+ html: true,
+ },
+ keywords: [
+ _x( 'opening hours', 'block search term', 'jetpack' ),
+ _x( 'closing time', 'block search term', 'jetpack' ),
+ _x( 'schedule', 'block search term', 'jetpack' ),
+ ],
+ attributes: {
+ days: {
+ type: 'array',
+ default: [
+ {
+ name: 'Sun',
+ hours: [], // Closed by default
+ },
+ {
+ name: 'Mon',
+ hours: [
+ {
+ opening: '09:00',
+ closing: '17:00',
+ },
+ ],
+ },
+ {
+ name: 'Tue',
+ hours: [
+ {
+ opening: '09:00',
+ closing: '17:00',
+ },
+ ],
+ },
+ {
+ name: 'Wed',
+ hours: [
+ {
+ opening: '09:00',
+ closing: '17:00',
+ },
+ ],
+ },
+ {
+ name: 'Thu',
+ hours: [
+ {
+ opening: '09:00',
+ closing: '17:00',
+ },
+ ],
+ },
+ {
+ name: 'Fri',
+ hours: [
+ {
+ opening: '09:00',
+ closing: '17:00',
+ },
+ ],
+ },
+ {
+ name: 'Sat',
+ hours: [], // Closed by default
+ },
+ ],
+ },
+ },
+
+ edit: props => <BusinessHours { ...props } />,
+
+ save: () => null,
+};
diff --git a/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-contact-form.js b/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-contact-form.js
new file mode 100644
index 00000000..952a3934
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-contact-form.js
@@ -0,0 +1,266 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+import emailValidator from 'email-validator';
+import { __, sprintf } from '@wordpress/i18n';
+import { Button, PanelBody, Path, Placeholder, TextControl } from '@wordpress/components';
+import { Component, Fragment } from '@wordpress/element';
+import { compose, withInstanceId } from '@wordpress/compose';
+import { InnerBlocks, InspectorControls } from '@wordpress/editor';
+
+/**
+ * Internal dependencies
+ */
+import HelpMessage from '../../../shared/help-message';
+import renderMaterialIcon from '../../../shared/render-material-icon';
+import SubmitButton from '../../../shared/submit-button';
+
+const ALLOWED_BLOCKS = [
+ 'jetpack/markdown',
+ 'core/paragraph',
+ 'core/image',
+ 'core/heading',
+ 'core/gallery',
+ 'core/list',
+ 'core/quote',
+ 'core/shortcode',
+ 'core/audio',
+ 'core/code',
+ 'core/cover',
+ 'core/file',
+ 'core/html',
+ 'core/separator',
+ 'core/spacer',
+ 'core/subhead',
+ 'core/table',
+ 'core/verse',
+ 'core/video',
+];
+
+class JetpackContactForm extends Component {
+ constructor( ...args ) {
+ super( ...args );
+ this.onChangeSubject = this.onChangeSubject.bind( this );
+ this.onBlurTo = this.onBlurTo.bind( this );
+ this.onChangeTo = this.onChangeTo.bind( this );
+ this.onChangeSubmit = this.onChangeSubmit.bind( this );
+ this.onFormSettingsSet = this.onFormSettingsSet.bind( this );
+ this.getToValidationError = this.getToValidationError.bind( this );
+ this.renderToAndSubjectFields = this.renderToAndSubjectFields.bind( this );
+ this.preventEnterSubmittion = this.preventEnterSubmittion.bind( this );
+ this.hasEmailError = this.hasEmailError.bind( this );
+
+ const to = args[ 0 ].attributes.to ? args[ 0 ].attributes.to : '';
+ const error = to
+ .split( ',' )
+ .map( this.getToValidationError )
+ .filter( Boolean );
+
+ this.state = {
+ toError: error && error.length ? error : null,
+ };
+ }
+
+ getIntroMessage() {
+ return __(
+ 'You’ll receive an email notification each time someone fills out the form. Where should it go, and what should the subject line be?',
+ 'jetpack'
+ );
+ }
+
+ getEmailHelpMessage() {
+ return __( 'You can enter multiple email addresses separated by commas.', 'jetpack' );
+ }
+
+ onChangeSubject( subject ) {
+ this.props.setAttributes( { subject } );
+ }
+
+ getToValidationError( email ) {
+ email = email.trim();
+ if ( email.length === 0 ) {
+ return false; // ignore the empty emails
+ }
+ if ( ! emailValidator.validate( email ) ) {
+ return { email };
+ }
+ return false;
+ }
+
+ onBlurTo( event ) {
+ const error = event.target.value
+ .split( ',' )
+ .map( this.getToValidationError )
+ .filter( Boolean );
+ if ( error && error.length ) {
+ this.setState( { toError: error } );
+ return;
+ }
+ }
+
+ onChangeTo( to ) {
+ const emails = to.trim();
+ if ( emails.length === 0 ) {
+ this.setState( { toError: null } );
+ this.props.setAttributes( { to } );
+ return;
+ }
+
+ this.setState( { toError: null } );
+ this.props.setAttributes( { to } );
+ }
+
+ onChangeSubmit( submitButtonText ) {
+ this.props.setAttributes( { submitButtonText } );
+ }
+
+ onFormSettingsSet( event ) {
+ event.preventDefault();
+ if ( this.state.toError ) {
+ // don't submit the form if there are errors.
+ return;
+ }
+ this.props.setAttributes( { hasFormSettingsSet: 'yes' } );
+ }
+
+ getfieldEmailError( errors ) {
+ if ( errors ) {
+ if ( errors.length === 1 ) {
+ if ( errors[ 0 ] && errors[ 0 ].email ) {
+ return sprintf( __( '%s is not a valid email address.', 'jetpack' ), errors[ 0 ].email );
+ }
+ return errors[ 0 ];
+ }
+
+ if ( errors.length === 2 ) {
+ return sprintf(
+ __( '%s and %s are not a valid email address.', 'jetpack' ),
+ errors[ 0 ].email,
+ errors[ 1 ].email
+ );
+ }
+ const inValidEmails = errors.map( error => error.email );
+ return sprintf(
+ __( '%s are not a valid email address.', 'jetpack' ),
+ inValidEmails.join( ', ' )
+ );
+ }
+ return null;
+ }
+
+ preventEnterSubmittion( event ) {
+ if ( event.key === 'Enter' ) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }
+
+ renderToAndSubjectFields() {
+ const fieldEmailError = this.state.toError;
+ const { instanceId, attributes } = this.props;
+ const { subject, to } = attributes;
+ return (
+ <Fragment>
+ <TextControl
+ aria-describedby={ `contact-form-${ instanceId }-email-${
+ this.hasEmailError() ? 'error' : 'help'
+ }` }
+ label={ __( 'Email address', 'jetpack' ) }
+ placeholder={ __( 'name@example.com', 'jetpack' ) }
+ onKeyDown={ this.preventEnterSubmittion }
+ value={ to }
+ onBlur={ this.onBlurTo }
+ onChange={ this.onChangeTo }
+ />
+ <HelpMessage isError id={ `contact-form-${ instanceId }-email-error` }>
+ { this.getfieldEmailError( fieldEmailError ) }
+ </HelpMessage>
+ <HelpMessage id={ `contact-form-${ instanceId }-email-help` }>
+ { this.getEmailHelpMessage() }
+ </HelpMessage>
+
+ <TextControl
+ label={ __( 'Email subject line', 'jetpack' ) }
+ value={ subject }
+ placeholder={ __( "Let's work together", 'jetpack' ) }
+ onChange={ this.onChangeSubject }
+ />
+ </Fragment>
+ );
+ }
+
+ hasEmailError() {
+ const fieldEmailError = this.state.toError;
+ return fieldEmailError && fieldEmailError.length > 0;
+ }
+
+ render() {
+ const { className, attributes } = this.props;
+ const { hasFormSettingsSet } = attributes;
+ const formClassnames = classnames( className, 'jetpack-contact-form', {
+ 'has-intro': ! hasFormSettingsSet,
+ } );
+
+ return (
+ <Fragment>
+ <InspectorControls>
+ <PanelBody title={ __( 'Email feedback settings', 'jetpack' ) }>
+ { this.renderToAndSubjectFields() }
+ </PanelBody>
+ </InspectorControls>
+ <div className={ formClassnames }>
+ { ! hasFormSettingsSet && (
+ <Placeholder
+ label={ __( 'Form', 'jetpack' ) }
+ icon={ renderMaterialIcon(
+ <Path d="M13 7.5h5v2h-5zm0 7h5v2h-5zM19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM11 6H6v5h5V6zm-1 4H7V7h3v3zm1 3H6v5h5v-5zm-1 4H7v-3h3v3z" />
+ ) }
+ >
+ <form onSubmit={ this.onFormSettingsSet }>
+ <p className="jetpack-contact-form__intro-message">{ this.getIntroMessage() }</p>
+ { this.renderToAndSubjectFields() }
+ <p className="jetpack-contact-form__intro-message">
+ { __(
+ '(If you leave these blank, notifications will go to the author with the post or page title as the subject line.)',
+ 'jetpack'
+ ) }
+ </p>
+ <div className="jetpack-contact-form__create">
+ <Button isPrimary type="submit" disabled={ this.hasEmailError() }>
+ { __( 'Add form', 'jetpack' ) }
+ </Button>
+ </div>
+ </form>
+ </Placeholder>
+ ) }
+ { hasFormSettingsSet && (
+ <InnerBlocks
+ allowedBlocks={ ALLOWED_BLOCKS }
+ templateLock={ false }
+ template={ [
+ [
+ 'jetpack/field-name',
+ {
+ required: true,
+ },
+ ],
+ [
+ 'jetpack/field-email',
+ {
+ required: true,
+ },
+ ],
+ [ 'jetpack/field-url', {} ],
+ [ 'jetpack/field-textarea', {} ],
+ ] }
+ />
+ ) }
+ { hasFormSettingsSet && <SubmitButton { ...this.props } /> }
+ </div>
+ </Fragment>
+ );
+ }
+}
+
+export default compose( [ withInstanceId ] )( JetpackContactForm );
diff --git a/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-field-checkbox.js b/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-field-checkbox.js
new file mode 100644
index 00000000..05e49dc4
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-field-checkbox.js
@@ -0,0 +1,62 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { BaseControl, PanelBody, TextControl, ToggleControl } from '@wordpress/components';
+import { Fragment } from '@wordpress/element';
+import { InspectorControls } from '@wordpress/editor';
+import { withInstanceId } from '@wordpress/compose';
+
+/**
+ * Internal dependencies
+ */
+import JetpackFieldLabel from './jetpack-field-label';
+
+const JetpackFieldCheckbox = ( {
+ instanceId,
+ required,
+ label,
+ setAttributes,
+ isSelected,
+ defaultValue,
+ id,
+} ) => {
+ return (
+ <BaseControl
+ id={ `jetpack-field-checkbox-${ instanceId }` }
+ className="jetpack-field jetpack-field-checkbox"
+ label={
+ <Fragment>
+ <input
+ className="jetpack-field-checkbox__checkbox"
+ type="checkbox"
+ disabled
+ checked={ defaultValue }
+ />
+ <JetpackFieldLabel
+ required={ required }
+ label={ label }
+ setAttributes={ setAttributes }
+ isSelected={ isSelected }
+ />
+ <InspectorControls>
+ <PanelBody title={ __( 'Field Settings', 'jetpack' ) }>
+ <ToggleControl
+ label={ __( 'Default Checked State', 'jetpack' ) }
+ checked={ defaultValue }
+ onChange={ value => setAttributes( { defaultValue: value } ) }
+ />
+ <TextControl
+ label={ __( 'ID', 'jetpack' ) }
+ value={ id }
+ onChange={ value => setAttributes( { id: value } ) }
+ />
+ </PanelBody>
+ </InspectorControls>
+ </Fragment>
+ }
+ />
+ );
+};
+
+export default withInstanceId( JetpackFieldCheckbox );
diff --git a/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-field-label.js b/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-field-label.js
new file mode 100644
index 00000000..0ee3d7ba
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-field-label.js
@@ -0,0 +1,35 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { PlainText } from '@wordpress/editor';
+import { ToggleControl } from '@wordpress/components';
+
+const JetpackFieldLabel = ( { setAttributes, label, resetFocus, isSelected, required } ) => {
+ return (
+ <div className="jetpack-field-label">
+ <PlainText
+ value={ label }
+ className="jetpack-field-label__input"
+ onChange={ value => {
+ resetFocus && resetFocus();
+ setAttributes( { label: value } );
+ } }
+ placeholder={ __( 'Write label…', 'jetpack' ) }
+ />
+ { isSelected && (
+ <ToggleControl
+ label={ __( 'Required', 'jetpack' ) }
+ className="jetpack-field-label__required"
+ checked={ required }
+ onChange={ value => setAttributes( { required: value } ) }
+ />
+ ) }
+ { ! isSelected && required && (
+ <span className="required">{ __( '(required)', 'jetpack' ) }</span>
+ ) }
+ </div>
+ );
+};
+
+export default JetpackFieldLabel;
diff --git a/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-field-multiple.js b/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-field-multiple.js
new file mode 100644
index 00000000..292bdeaf
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-field-multiple.js
@@ -0,0 +1,122 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { BaseControl, IconButton, PanelBody, TextControl } from '@wordpress/components';
+import { Component, Fragment } from '@wordpress/element';
+import { InspectorControls } from '@wordpress/editor';
+import { withInstanceId } from '@wordpress/compose';
+
+/**
+ * Internal dependencies
+ */
+import JetpackFieldLabel from './jetpack-field-label';
+import JetpackOption from './jetpack-option';
+
+class JetpackFieldMultiple extends Component {
+ constructor( ...args ) {
+ super( ...args );
+ this.onChangeOption = this.onChangeOption.bind( this );
+ this.addNewOption = this.addNewOption.bind( this );
+ this.state = { inFocus: null };
+ }
+
+ onChangeOption( key = null, option = null ) {
+ const newOptions = this.props.options.slice( 0 );
+ if ( null === option ) {
+ // Remove a key
+ newOptions.splice( key, 1 );
+ if ( key > 0 ) {
+ this.setState( { inFocus: key - 1 } );
+ }
+ } else {
+ // update a key
+ newOptions.splice( key, 1, option );
+ this.setState( { inFocus: key } ); // set the focus.
+ }
+ this.props.setAttributes( { options: newOptions } );
+ }
+
+ addNewOption( key = null ) {
+ const newOptions = this.props.options.slice( 0 );
+ let inFocus = 0;
+ if ( 'object' === typeof key ) {
+ newOptions.push( '' );
+ inFocus = newOptions.length - 1;
+ } else {
+ newOptions.splice( key + 1, 0, '' );
+ inFocus = key + 1;
+ }
+
+ this.setState( { inFocus: inFocus } );
+ this.props.setAttributes( { options: newOptions } );
+ }
+
+ render() {
+ const { type, instanceId, required, label, setAttributes, isSelected, id } = this.props;
+ let { options } = this.props;
+ let { inFocus } = this.state;
+ if ( ! options.length ) {
+ options = [ '' ];
+ inFocus = 0;
+ }
+
+ return (
+ <Fragment>
+ <BaseControl
+ id={ `jetpack-field-multiple-${ instanceId }` }
+ className="jetpack-field jetpack-field-multiple"
+ label={
+ <JetpackFieldLabel
+ required={ required }
+ label={ label }
+ setAttributes={ setAttributes }
+ isSelected={ isSelected }
+ resetFocus={ () => this.setState( { inFocus: null } ) }
+ />
+ }
+ >
+ <ol
+ className="jetpack-field-multiple__list"
+ id={ `jetpack-field-multiple-${ instanceId }` }
+ >
+ { options.map( ( option, index ) => (
+ <JetpackOption
+ type={ type }
+ key={ index }
+ option={ option }
+ index={ index }
+ onChangeOption={ this.onChangeOption }
+ onAddOption={ this.addNewOption }
+ isInFocus={ index === inFocus && isSelected }
+ isSelected={ isSelected }
+ />
+ ) ) }
+ </ol>
+ { isSelected && (
+ <IconButton
+ className="jetpack-field-multiple__add-option"
+ icon="insert"
+ label={ __( 'Insert option', 'jetpack' ) }
+ onClick={ this.addNewOption }
+ >
+ { __( 'Add option', 'jetpack' ) }
+ </IconButton>
+ ) }
+ </BaseControl>
+
+ <InspectorControls>
+ <PanelBody title={ __( 'Field Settings', 'jetpack' ) }>
+ <TextControl
+ label={ __( 'ID', 'jetpack' ) }
+ value={ id }
+ onChange={ value => setAttributes( { id: value } ) }
+ />
+ </PanelBody>
+ </InspectorControls>
+ </Fragment>
+ );
+ }
+}
+
+export default withInstanceId( JetpackFieldMultiple );
diff --git a/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-field-textarea.js b/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-field-textarea.js
new file mode 100644
index 00000000..e2025941
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-field-textarea.js
@@ -0,0 +1,59 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Fragment } from '@wordpress/element';
+import { InspectorControls } from '@wordpress/editor';
+import { PanelBody, TextareaControl, TextControl } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import JetpackFieldLabel from './jetpack-field-label';
+
+function JetpackFieldTextarea( {
+ required,
+ label,
+ setAttributes,
+ isSelected,
+ defaultValue,
+ placeholder,
+ id,
+} ) {
+ return (
+ <Fragment>
+ <div className="jetpack-field">
+ <TextareaControl
+ label={
+ <JetpackFieldLabel
+ required={ required }
+ label={ label }
+ setAttributes={ setAttributes }
+ isSelected={ isSelected }
+ />
+ }
+ placeholder={ placeholder }
+ value={ placeholder }
+ onChange={ value => setAttributes( { placeholder: value } ) }
+ title={ __( 'Set the placeholder text', 'jetpack' ) }
+ />
+ </div>
+ <InspectorControls>
+ <PanelBody title={ __( 'Field Settings', 'jetpack' ) }>
+ <TextControl
+ label={ __( 'Default Value', 'jetpack' ) }
+ value={ defaultValue }
+ onChange={ value => setAttributes( { defaultValue: value } ) }
+ />
+ <TextControl
+ label={ __( 'ID', 'jetpack' ) }
+ value={ id }
+ onChange={ value => setAttributes( { id: value } ) }
+ />
+ </PanelBody>
+ </InspectorControls>
+ </Fragment>
+ );
+}
+
+export default JetpackFieldTextarea;
diff --git a/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-field.js b/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-field.js
new file mode 100644
index 00000000..6a8269ff
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-field.js
@@ -0,0 +1,62 @@
+/**
+ * External dependencies
+ */
+import classNames from 'classnames';
+import { __ } from '@wordpress/i18n';
+import { Fragment } from '@wordpress/element';
+import { InspectorControls } from '@wordpress/editor';
+import { PanelBody, TextControl } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import JetpackFieldLabel from './jetpack-field-label';
+
+function JetpackField( {
+ isSelected,
+ type,
+ required,
+ label,
+ setAttributes,
+ defaultValue,
+ placeholder,
+ id,
+} ) {
+ return (
+ <Fragment>
+ <div className={ classNames( 'jetpack-field', { 'is-selected': isSelected } ) }>
+ <TextControl
+ type={ type }
+ label={
+ <JetpackFieldLabel
+ required={ required }
+ label={ label }
+ setAttributes={ setAttributes }
+ isSelected={ isSelected }
+ />
+ }
+ placeholder={ placeholder }
+ value={ placeholder }
+ onChange={ value => setAttributes( { placeholder: value } ) }
+ title={ __( 'Set the placeholder text', 'jetpack' ) }
+ />
+ </div>
+ <InspectorControls>
+ <PanelBody title={ __( 'Field Settings', 'jetpack' ) }>
+ <TextControl
+ label={ __( 'Default Value', 'jetpack' ) }
+ value={ defaultValue }
+ onChange={ value => setAttributes( { defaultValue: value } ) }
+ />
+ <TextControl
+ label={ __( 'ID', 'jetpack' ) }
+ value={ id }
+ onChange={ value => setAttributes( { id: value } ) }
+ />
+ </PanelBody>
+ </InspectorControls>
+ </Fragment>
+ );
+}
+
+export default JetpackField;
diff --git a/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-option.js b/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-option.js
new file mode 100644
index 00000000..8cd2792d
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-form/components/jetpack-option.js
@@ -0,0 +1,80 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { IconButton } from '@wordpress/components';
+import { Component, createRef } from '@wordpress/element';
+
+class JetpackOption extends Component {
+ constructor( ...args ) {
+ super( ...args );
+ this.onChangeOption = this.onChangeOption.bind( this );
+ this.onKeyPress = this.onKeyPress.bind( this );
+ this.onDeleteOption = this.onDeleteOption.bind( this );
+ this.textInput = createRef();
+ }
+
+ componentDidMount() {
+ if ( this.props.isInFocus ) {
+ this.textInput.current.focus();
+ }
+ }
+
+ componentDidUpdate() {
+ if ( this.props.isInFocus ) {
+ this.textInput.current.focus();
+ }
+ }
+
+ onChangeOption( event ) {
+ this.props.onChangeOption( this.props.index, event.target.value );
+ }
+
+ onKeyPress( event ) {
+ if ( event.key === 'Enter' ) {
+ this.props.onAddOption( this.props.index );
+ event.preventDefault();
+ return;
+ }
+
+ if ( event.key === 'Backspace' && event.target.value === '' ) {
+ this.props.onChangeOption( this.props.index );
+ event.preventDefault();
+ return;
+ }
+ }
+
+ onDeleteOption() {
+ this.props.onChangeOption( this.props.index );
+ }
+
+ render() {
+ const { isSelected, option, type } = this.props;
+ return (
+ <li className="jetpack-option">
+ { type && type !== 'select' && (
+ <input className="jetpack-option__type" type={ type } disabled />
+ ) }
+ <input
+ type="text"
+ className="jetpack-option__input"
+ value={ option }
+ placeholder={ __( 'Write option…', 'jetpack' ) }
+ onChange={ this.onChangeOption }
+ onKeyDown={ this.onKeyPress }
+ ref={ this.textInput }
+ />
+ { isSelected && (
+ <IconButton
+ className="jetpack-option__remove"
+ icon="trash"
+ label={ __( 'Remove option', 'jetpack' ) }
+ onClick={ this.onDeleteOption }
+ />
+ ) }
+ </li>
+ );
+ }
+}
+
+export default JetpackOption;
diff --git a/plugins/jetpack/extensions/blocks/contact-form/editor.js b/plugins/jetpack/extensions/blocks/contact-form/editor.js
new file mode 100644
index 00000000..9ffc068d
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-form/editor.js
@@ -0,0 +1,7 @@
+/**
+ * Internal dependencies
+ */
+import registerJetpackBlock from '../../shared/register-jetpack-block';
+import { childBlocks, name, settings } from '.';
+
+registerJetpackBlock( name, settings, childBlocks );
diff --git a/plugins/jetpack/extensions/blocks/contact-form/editor.scss b/plugins/jetpack/extensions/blocks/contact-form/editor.scss
new file mode 100644
index 00000000..13053f07
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-form/editor.scss
@@ -0,0 +1,696 @@
+@import '../../shared/styles/gutenberg-colors.scss';
+@import '../../shared/styles/gutenberg-variables.scss';
+
+.jetpack-contact-form .components-placeholder {
+ padding: 24px;
+
+ input[type='text'] {
+ width: 100%;
+ outline-width: 0;
+ outline-style: none;
+ line-height: 16px;
+ }
+
+ .components-placeholder__label svg {
+ margin-right: 1ch;
+ }
+
+ .help-message,
+ .components-placeholder__fieldset {
+ text-align: left;
+ }
+
+ .help-message {
+ width: 100%;
+ margin: -18px 0 28px;
+ }
+
+ .components-base-control {
+ margin-bottom: 16px;
+ width: 100%;
+ }
+}
+
+.jetpack-contact-form__intro-message {
+ margin: 0 0 16px;
+}
+
+.jetpack-contact-form__create {
+ width: 100%;
+}
+
+.jetpack-field-label {
+ display: flex;
+ flex-direction: row;
+
+ .components-base-control {
+ margin-top:-1px;
+ margin-bottom: -3px;
+
+ &.jetpack-field-label__required {
+ .components-form-toggle {
+ margin: 2px 8px 0 0;
+ }
+
+ .components-toggle-control__label {
+ word-break: normal;
+ }
+ }
+ }
+
+ .required {
+ color: var( --color-error );
+ word-break: normal;
+ }
+
+ .components-toggle-control .components-base-control__field {
+ margin-bottom: 0;
+ }
+}
+
+.jetpack-field-label__input {
+ flex-grow: 1;
+ min-height: unset;
+ padding: 0;
+}
+
+// Duplicated to elevate specificity in order to overwrite core styles
+.jetpack-field-label__input.jetpack-field-label__input.jetpack-field-label__input {
+ border-color: $white;
+ border-radius: 0;
+ font-weight: 600;
+ margin: 0;
+ margin-bottom: 2px;
+ padding: 0;
+ width: auto;
+
+ &:focus {
+ border-color: $white;
+ box-shadow: none;
+ }
+}
+
+input.components-text-control__input {
+ line-height: 16px;
+}
+
+.jetpack-field {
+ // done to increase elevate specificity in order to overwrite calypso styles
+ .components-text-control__input.components-text-control__input {
+ width: 100%;
+ }
+ .components-text-control__input,
+ .components-textarea-control__input {
+ color: #72777c;
+ padding: 10px 8px;
+ }
+}
+
+.jetpack-field-checkbox__checkbox.jetpack-field-checkbox__checkbox.jetpack-field-checkbox__checkbox {
+ float: left;
+}
+
+// Duplicated to elevate specificity in order to overwrite core styles
+.jetpack-field-multiple__list.jetpack-field-multiple__list {
+ list-style-type: none;
+ margin: 0;
+
+ &:empty {
+ display: none;
+ }
+
+ // TODO: make this a class, @enej
+ [data-type='jetpack/field-select'] & {
+ border: 1px solid $dark-gray-150;
+ border-radius: 4px;
+ padding: 4px;
+ }
+}
+
+.jetpack-option {
+ display: flex;
+ align-items: center;
+ margin: 0;
+}
+
+.jetpack-option__type.jetpack-option__type {
+ margin-top: 0;
+}
+
+// Duplicated to elevate specificity in order to overwrite core styles
+.jetpack-option__input.jetpack-option__input.jetpack-option__input {
+ border-color: $white;
+ border-radius: 0;
+ flex-grow: 1;
+
+ &:hover {
+ border-color: #357cb5;
+ }
+
+ &:focus {
+ border-color: #e3e5e8;
+ box-shadow: none;
+ }
+}
+// Duplicated to elevate specificity in order to overwrite calypso styles
+.jetpack-option__remove.jetpack-option__remove {
+ padding: 6px;
+ vertical-align: bottom;
+}
+
+.jetpack-field-multiple__add-option {
+ margin-left: -6px;
+ padding: 4px;
+ padding-right: 8px;
+
+ svg {
+ margin-right: 12px;
+ }
+}
+
+.jetpack-field-checkbox .components-base-control__label {
+ display: flex;
+ align-items: center;
+
+ .jetpack-field-label {
+ flex-grow:1;
+ }
+
+ .jetpack-field-label__input {
+ font-size: 13px;
+ font-weight: 400;
+ padding-left: 10px;
+ }
+}
+
+/* ==========================================================================
+** Shortcode Classic Block Styles
+** ======================================================================= */
+
+@media ( min-width: 481px ) {
+ .jetpack-contact-form-shortcode-preview {
+ padding: 24px;
+ }
+}
+
+.jetpack-contact-form-shortcode-preview {
+ font-family: $default-font;
+ font-size: 16px;
+ line-height: 1.4em;
+ display: block;
+ position: relative;
+ margin: 0 auto;
+ padding: 16px;
+ box-sizing: border-box;
+ background: $white;
+ box-shadow: 0 0 0 1px rgba( 200, 215, 225, 0.5 ), 0 1px 2px #e9eff3;
+
+ &::after {
+ content: '.';
+ display: block;
+ height: 0;
+ clear: both;
+ visibility: hidden;
+ }
+
+ > div {
+ margin-top: 24px;
+ }
+
+ > div:first-child {
+ margin-top: 0;
+ }
+ /* ==========================================================================
+ ** Labels
+ ** ======================================================================= */
+
+ label {
+ display: block;
+ font-size: 14px;
+ font-weight: 600;
+ margin-bottom: 5px;
+ }
+
+
+ /* ==========================================================================
+ ** Text Inputs
+ ** ======================================================================= */
+
+ input[type='text'],
+ input[type='tel'],
+ input[type='email'],
+ input[type='url'] {
+ border-radius: 0;
+ appearance: none;
+ box-sizing: border-box;
+ margin: 0;
+ padding: 7px 14px;
+ width: 100%;
+ color: #2e4453;
+ font-size: 16px;
+ line-height: 1.5;
+ border: 1px solid #c8d7e1;
+ background-color: $white;
+ transition: all 0.15s ease-in-out;
+ box-shadow: none;
+ }
+
+ input[type='text']::placeholder,
+ input[type='tel']::placeholder,
+ input[type='email']::placeholder,
+ input[type='url']::placeholder {
+ color: #87a6bc;
+ }
+
+ input[type='text']:hover,
+ input[type='tel']:hover,
+ input[type='email']:hover,
+ input[type='url']:hover {
+ border-color: #a8bece;
+ }
+
+ input[type='text']:focus,
+ input[type='tel']:focus,
+ input[type='email']:focus,
+ input[type='url']:focus {
+ border-color: #0087be;
+ outline: none;
+ box-shadow: 0 0 0 2px #78dcfa;
+ }
+
+ input[type='text']:focus::-ms-clear,
+ input[type='tel']:focus::-ms-clear,
+ input[type='email']:focus::-ms-clear,
+ input[type='url']:focus::-ms-clear {
+ display: none;
+ }
+
+ input[type='text']:disabled,
+ input[type='tel']:disabled,
+ input[type='email']:disabled,
+ input[type='url']:disabled {
+ background: #f3f6f8;
+ border-color: #e9eff3;
+ color: #a8bece;
+ -webkit-text-fill-color: #a8bece;
+ }
+
+ input[type='text']:disabled:hover,
+ input[type='tel']:disabled:hover,
+ input[type='email']:disabled:hover,
+ input[type='url']:disabled:hover {
+ cursor: default;
+ }
+
+ input[type='text']:disabled::placeholder,
+ input[type='tel']:disabled::placeholder,
+ input[type='email']:disabled::placeholder,
+ input[type='url']:disabled::placeholder {
+ color: #a8bece;
+ }
+
+
+ /* ==========================================================================
+ ** Textareas
+ ** ======================================================================= */
+
+ textarea {
+ border-radius: 0;
+ appearance: none;
+ box-sizing: border-box;
+ margin: 0;
+ padding: 7px 14px;
+ height: 92px;
+ width: 100%;
+ color: #2e4453;
+ font-size: 16px;
+ line-height: 1.5;
+ border: 1px solid #c8d7e1;
+ background-color: $white;
+ transition: all 0.15s ease-in-out;
+ box-shadow: none;
+ }
+
+ textarea::placeholder {
+ color: #87a6bc;
+ }
+
+ textarea:hover {
+ border-color: #a8bece;
+ }
+
+ textarea:focus {
+ border-color: #0087be;
+ outline: none;
+ box-shadow: 0 0 0 2px #78dcfa;
+ }
+
+ textarea:focus::-ms-clear {
+ display: none;
+ }
+
+ textarea:disabled {
+ background: #f3f6f8;
+ border-color: #e9eff3;
+ color: #a8bece;
+ -webkit-text-fill-color: #a8bece;
+ }
+
+ textarea:disabled:hover {
+ cursor: default;
+ }
+
+ textarea:disabled::placeholder {
+ color: #a8bece;
+ }
+
+
+ /* ==========================================================================
+ ** Checkboxes
+ ** ======================================================================= */
+
+ input[type='checkbox'] {
+ -webkit-appearance: none;
+ display: inline-block;
+ box-sizing: border-box;
+ margin: 2px 0 0;
+ padding: 7px 14px;
+ width: 16px;
+ height: 16px;
+ float: left;
+ outline: 0;
+ padding: 0;
+ box-shadow: none;
+ background-color: $white;
+ border: 1px solid #c8d7e1;
+ color: #2e4453;
+ font-size: 16px;
+ line-height: 0;
+ text-align: center;
+ vertical-align: middle;
+ appearance: none;
+ transition: all 0.15s ease-in-out;
+ clear: none;
+ cursor: pointer;
+ }
+
+ input[type='checkbox']:checked::before {
+ content: '\f147';
+ font-family: Dashicons;
+ margin: -3px 0 0 -4px;
+ float: left;
+ display: inline-block;
+ vertical-align: middle;
+ width: 16px;
+ font-size: 20px;
+ line-height: 1;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ speak: none;
+ color: #00aadc;
+ }
+
+ input[type='checkbox']:disabled:checked::before {
+ color: #a8bece;
+ }
+
+ input[type='checkbox']:hover {
+ border-color: #a8bece;
+ }
+
+ input[type='checkbox']:focus {
+ border-color: #0087be;
+ outline: none;
+ box-shadow: 0 0 0 2px #78dcfa;
+ }
+
+ input[type='checkbox']:disabled {
+ background: #f3f6f8;
+ border-color: #e9eff3;
+ color: #a8bece;
+ opacity: 1;
+ }
+
+ input[type='checkbox']:disabled:hover {
+ cursor: default;
+ }
+
+ input[type='checkbox'] + span {
+ display: block;
+ font-weight: normal;
+ margin-left: 24px;
+ }
+
+
+ /* ==========================================================================
+ ** Radio buttons
+ ** ======================================================================== */
+
+ input[type=radio] {
+ color: #2e4453;
+ font-size: 16px;
+ border: 1px solid #c8d7e1;
+ background-color: $white;
+ transition: all 0.15s ease-in-out;
+ box-sizing: border-box;
+ -webkit-appearance: none;
+ clear: none;
+ cursor: pointer;
+ display: inline-block;
+ line-height: 0;
+ height: 16px;
+ margin: 2px 4px 0 0;
+ float: left;
+ outline: 0;
+ padding: 0;
+ text-align: center;
+ vertical-align: middle;
+ width: 16px;
+ min-width: 16px;
+ appearance: none;
+ border-radius: 50%;
+ line-height: 10px;
+ }
+
+ input[type='radio']:hover {
+ border-color: #a8bece;
+ }
+
+ input[type='radio']:focus {
+ border-color: #0087be;
+ outline: none;
+ box-shadow: 0 0 0 2px #78dcfa;
+ }
+
+ input[type='radio']:focus::-ms-clear {
+ display: none;
+ }
+
+ input[type='radio']:checked::before {
+ float: left;
+ display: inline-block;
+ content: '\2022';
+ margin: 3px;
+ width: 8px;
+ height: 8px;
+ text-indent: -9999px;
+ background: #00aadc;
+ vertical-align: middle;
+ border-radius: 50%;
+ animation: grow 0.2s ease-in-out;
+ }
+
+ input[type='radio']:disabled {
+ background: #f3f6f8;
+ border-color: #e9eff3;
+ color: #a8bece;
+ opacity: 1;
+ -webkit-text-fill-color: #a8bece;
+ }
+
+ input[type='radio']:disabled:hover {
+ cursor: default;
+ }
+
+ input[type='radio']:disabled::placeholder {
+ color: #a8bece;
+ }
+
+ input[type='radio']:disabled:checked::before {
+ background: #e9eff3;
+ }
+
+ input[type='radio'] + span {
+ display: block;
+ font-weight: normal;
+ margin-left: 24px;
+ }
+
+ @keyframes grow {
+ 0% {
+ transform: scale( 0.3 );
+ }
+
+ 60% {
+ transform: scale( 1.15 );
+ }
+
+ 100% {
+ transform: scale( 1 );
+ }
+ }
+
+ @keyframes grow {
+ 0% {
+ transform: scale( 0.3 );
+ }
+
+ 60% {
+ transform: scale( 1.15 );
+ }
+
+ 100% {
+ transform: scale( 1 );
+ }
+ }
+
+
+ /* ==========================================================================
+ ** Selects
+ ** ======================================================================== */
+
+ select {
+ background: $white url( data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyB3aWR0aD0iMjBweCIgaGVpZ2h0PSIyMHB4IiB2aWV3Qm94PSIwIDAgMjAgMjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sbnM6c2tldGNoPSJodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2gvbnMiPiAgICAgICAgPHRpdGxlPmFycm93LWRvd248L3RpdGxlPiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4gICAgPGRlZnM+PC9kZWZzPiAgICA8ZyBpZD0iUGFnZS0xIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBza2V0Y2g6dHlwZT0iTVNQYWdlIj4gICAgICAgIDxnIGlkPSJhcnJvdy1kb3duIiBza2V0Y2g6dHlwZT0iTVNBcnRib2FyZEdyb3VwIiBmaWxsPSIjQzhEN0UxIj4gICAgICAgICAgICA8cGF0aCBkPSJNMTUuNSw2IEwxNyw3LjUgTDEwLjI1LDE0LjI1IEwzLjUsNy41IEw1LDYgTDEwLjI1LDExLjI1IEwxNS41LDYgWiIgaWQ9IkRvd24tQXJyb3ciIHNrZXRjaDp0eXBlPSJNU1NoYXBlR3JvdXAiPjwvcGF0aD4gICAgICAgIDwvZz4gICAgPC9nPjwvc3ZnPg== ) no-repeat right 10px center;
+ border-color: #c8d7e1;
+ border-style: solid;
+ border-radius: 4px;
+ border-width: 1px 1px 2px;
+ color: #2e4453;
+ cursor: pointer;
+ display: inline-block;
+ margin: 0;
+ outline: 0;
+ overflow: hidden;
+ font-size: 14px;
+ line-height: 21px;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ text-decoration: none;
+ vertical-align: top;
+ white-space: nowrap;
+ box-sizing: border-box;
+ padding: 2px 32px 2px 14px; // Aligns the text to the 8px baseline grid and adds padding on right to allow for the arrow.
+ appearance: none;
+ font-family: sans-serif;
+ }
+
+ select:hover {
+ background-image: url( data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyB3aWR0aD0iMjBweCIgaGVpZ2h0PSIyMHB4IiB2aWV3Qm94PSIwIDAgMjAgMjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sbnM6c2tldGNoPSJodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2gvbnMiPiAgICAgICAgPHRpdGxlPmFycm93LWRvd248L3RpdGxlPiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4gICAgPGRlZnM+PC9kZWZzPiAgICA8ZyBpZD0iUGFnZS0xIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBza2V0Y2g6dHlwZT0iTVNQYWdlIj4gICAgICAgIDxnIGlkPSJhcnJvdy1kb3duIiBza2V0Y2g6dHlwZT0iTVNBcnRib2FyZEdyb3VwIiBmaWxsPSIjYThiZWNlIj4gICAgICAgICAgICA8cGF0aCBkPSJNMTUuNSw2IEwxNyw3LjUgTDEwLjI1LDE0LjI1IEwzLjUsNy41IEw1LDYgTDEwLjI1LDExLjI1IEwxNS41LDYgWiIgaWQ9IkRvd24tQXJyb3ciIHNrZXRjaDp0eXBlPSJNU1NoYXBlR3JvdXAiPjwvcGF0aD4gICAgICAgIDwvZz4gICAgPC9nPjwvc3ZnPg== );
+ }
+
+ select:focus {
+ background-image: url( data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyB3aWR0aD0iMjBweCIgaGVpZ2h0PSIyMHB4IiB2aWV3Qm94PSIwIDAgMjAgMjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sbnM6c2tldGNoPSJodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2gvbnMiPiA8dGl0bGU+YXJyb3ctZG93bjwvdGl0bGU+IDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPiA8ZGVmcz48L2RlZnM+IDxnIGlkPSJQYWdlLTEiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHNrZXRjaDp0eXBlPSJNU1BhZ2UiPiA8ZyBpZD0iYXJyb3ctZG93biIgc2tldGNoOnR5cGU9Ik1TQXJ0Ym9hcmRHcm91cCIgZmlsbD0iIzJlNDQ1MyI+IDxwYXRoIGQ9Ik0xNS41LDYgTDE3LDcuNSBMMTAuMjUsMTQuMjUgTDMuNSw3LjUgTDUsNiBMMTAuMjUsMTEuMjUgTDE1LjUsNiBaIiBpZD0iRG93bi1BcnJvdyIgc2tldGNoOnR5cGU9Ik1TU2hhcGVHcm91cCI+PC9wYXRoPiA8L2c+IDwvZz48L3N2Zz4= );
+ border-color: #00aadc;
+ box-shadow: 0 0 0 2px #78dcfa;
+ outline: 0;
+ -moz-outline:none;
+ -moz-user-focus:ignore;
+ }
+
+ select:disabled,
+ select:hover:disabled {
+ background: url( data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyB3aWR0aD0iMjBweCIgaGVpZ2h0PSIyMHB4IiB2aWV3Qm94PSIwIDAgMjAgMjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sbnM6c2tldGNoPSJodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2gvbnMiPiAgICAgICAgPHRpdGxlPmFycm93LWRvd248L3RpdGxlPiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4gICAgPGRlZnM+PC9kZWZzPiAgICA8ZyBpZD0iUGFnZS0xIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBza2V0Y2g6dHlwZT0iTVNQYWdlIj4gICAgICAgIDxnIGlkPSJhcnJvdy1kb3duIiBza2V0Y2g6dHlwZT0iTVNBcnRib2FyZEdyb3VwIiBmaWxsPSIjZTllZmYzIj4gICAgICAgICAgICA8cGF0aCBkPSJNMTUuNSw2IEwxNyw3LjUgTDEwLjI1LDE0LjI1IEwzLjUsNy41IEw1LDYgTDEwLjI1LDExLjI1IEwxNS41LDYgWiIgaWQ9IkRvd24tQXJyb3ciIHNrZXRjaDp0eXBlPSJNU1NoYXBlR3JvdXAiPjwvcGF0aD4gICAgICAgIDwvZz4gICAgPC9nPjwvc3ZnPg== ) no-repeat right 10px center;;
+ }
+
+ select.is-compact {
+ min-width: 0;
+ padding: 0 20px 2px 6px;
+ margin: 0 4px;
+ background-position: right 5px center;
+ background-size: 12px 12px;
+ }
+
+ /* Make it display:block when it follows a label */
+ label select,
+ label + select {
+ display: block;
+ min-width: 200px;
+ }
+
+ label select.is-compact,
+ label + select.is-compact {
+ display: inline-block;
+ min-width: 0;
+ }
+
+ /* IE: Remove the default arrow */
+ select::-ms-expand {
+ display: none;
+ }
+
+ /* IE: Remove default background and color styles on focus */
+ select::-ms-value {
+ background: none;
+ color: #2e4453;
+ }
+
+ /* Firefox: Remove the focus outline, see http://stackoverflow.com/questions/3773430/remove-outline-from-select-box-in-ff/18853002#18853002 */
+ select:-moz-focusring {
+ color: transparent;
+ text-shadow: 0 0 0 #2e4453;
+ }
+
+
+ /* ==========================================================================
+ ** Buttons
+ ** ======================================================================== */
+
+ input[type='submit'] {
+ padding: 0;
+ font-size: 14px;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ vertical-align: baseline;
+ background: $white;
+ border-color: #c8d7e1;
+ border-style: solid;
+ border-width: 1px 1px 2px;
+ color: #2e4453;
+ cursor: pointer;
+ display: inline-block;
+ margin: 24px 0 0;
+ outline: 0;
+ overflow: hidden;
+ font-weight: 500;
+ text-overflow: ellipsis;
+ text-decoration: none;
+ vertical-align: top;
+ box-sizing: border-box;
+ font-size: 14px;
+ line-height: 21px;
+ border-radius: 4px;
+ padding: 7px 14px 9px;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ }
+
+ input[type='submit']:hover {
+ border-color: #a8bece;
+ color: #2e4453;
+ }
+
+ input[type='submit']:active {
+ border-width: 2px 1px 1px;
+ }
+
+ input[type='submit']:visited {
+ color: #2e4453;
+ }
+
+ input[type='submit']:focus {
+ border-color: #00aadc;
+ box-shadow: 0 0 0 2px #78dcfa;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/contact-form/index.js b/plugins/jetpack/extensions/blocks/contact-form/index.js
new file mode 100644
index 00000000..e2a90c89
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-form/index.js
@@ -0,0 +1,462 @@
+/**
+ * External dependencies
+ */
+import { __, _x } from '@wordpress/i18n';
+import { getBlockType, createBlock } from '@wordpress/blocks';
+import { Path, Circle } from '@wordpress/components';
+import { Fragment } from '@wordpress/element';
+import { InnerBlocks } from '@wordpress/editor';
+
+/**
+ * Internal dependencies
+ */
+import './editor.scss';
+import JetpackContactForm from './components/jetpack-contact-form';
+import JetpackField from './components/jetpack-field';
+import JetpackFieldTextarea from './components/jetpack-field-textarea';
+import JetpackFieldCheckbox from './components/jetpack-field-checkbox';
+import JetpackFieldMultiple from './components/jetpack-field-multiple';
+import renderMaterialIcon from '../../shared/render-material-icon';
+
+export const name = 'contact-form';
+
+export const settings = {
+ title: __( 'Form', 'jetpack' ),
+ description: __( 'A simple way to get feedback from folks visiting your site.', 'jetpack' ),
+ icon: renderMaterialIcon(
+ <Path d="M13 7.5h5v2h-5zm0 7h5v2h-5zM19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM11 6H6v5h5V6zm-1 4H7V7h3v3zm1 3H6v5h5v-5zm-1 4H7v-3h3v3z" />
+ ),
+ keywords: [
+ _x( 'email', 'block search term', 'jetpack' ),
+ _x( 'feedback', 'block search term', 'jetpack' ),
+ _x( 'contact', 'block search term', 'jetpack' ),
+ ],
+ category: 'jetpack',
+ supports: {
+ reusable: false,
+ html: false,
+ },
+ attributes: {
+ subject: {
+ type: 'string',
+ default: '',
+ },
+ to: {
+ type: 'string',
+ default: '',
+ },
+ submitButtonText: {
+ type: 'string',
+ default: __( 'Submit', 'jetpack' ),
+ },
+ customBackgroundButtonColor: { type: 'string' },
+ customTextButtonColor: { type: 'string' },
+ submitButtonClasses: { type: 'string' },
+ hasFormSettingsSet: {
+ type: 'string',
+ default: null,
+ },
+
+ // Deprecated
+ has_form_settings_set: {
+ type: 'string',
+ default: null,
+ },
+ submit_button_text: {
+ type: 'string',
+ default: __( 'Submit', 'jetpack' ),
+ },
+ },
+
+ edit: JetpackContactForm,
+ save: InnerBlocks.Content,
+ deprecated: [
+ {
+ attributes: {
+ subject: {
+ type: 'string',
+ default: '',
+ },
+ to: {
+ type: 'string',
+ default: '',
+ },
+ submit_button_text: {
+ type: 'string',
+ default: __( 'Submit', 'jetpack' ),
+ },
+ has_form_settings_set: {
+ type: 'string',
+ default: null,
+ },
+ },
+ migrate: attr => {
+ return {
+ submitButtonText: attr.submit_button_text,
+ hasFormSettingsSet: attr.has_form_settings_set,
+ to: attr.to,
+ subject: attr.subject,
+ };
+ },
+
+ isEligible: attr => {
+ // when the deprecated, snake_case values are default, no need to migrate
+ if ( ! attr.has_form_settings_set && attr.submit_button_text === 'Submit' ) {
+ return false;
+ }
+ return true;
+ },
+
+ save: InnerBlocks.Content,
+ },
+ ],
+};
+
+const FieldDefaults = {
+ category: 'jetpack',
+ parent: [ 'jetpack/contact-form' ],
+ supports: {
+ reusable: false,
+ html: false,
+ },
+ attributes: {
+ label: {
+ type: 'string',
+ default: null,
+ },
+ required: {
+ type: 'boolean',
+ default: false,
+ },
+ options: {
+ type: 'array',
+ default: [],
+ },
+ defaultValue: {
+ type: 'string',
+ default: '',
+ },
+ placeholder: {
+ type: 'string',
+ default: '',
+ },
+ id: {
+ type: 'string',
+ default: '',
+ },
+ },
+ transforms: {
+ to: [
+ {
+ type: 'block',
+ blocks: [ 'jetpack/field-text' ],
+ isMatch: ( { options } ) => ! options.length,
+ transform: attributes => createBlock( 'jetpack/field-text', attributes ),
+ },
+ {
+ type: 'block',
+ blocks: [ 'jetpack/field-name' ],
+ isMatch: ( { options } ) => ! options.length,
+ transform: attributes => createBlock( 'jetpack/field-name', attributes ),
+ },
+ {
+ type: 'block',
+ blocks: [ 'jetpack/field-email' ],
+ isMatch: ( { options } ) => ! options.length,
+ transform: attributes => createBlock( 'jetpack/field-email', attributes ),
+ },
+ {
+ type: 'block',
+ blocks: [ 'jetpack/field-url' ],
+ isMatch: ( { options } ) => ! options.length,
+ transform: attributes => createBlock( 'jetpack/field-url', attributes ),
+ },
+ {
+ type: 'block',
+ blocks: [ 'jetpack/field-date' ],
+ isMatch: ( { options } ) => ! options.length,
+ transform: attributes => createBlock( 'jetpack/field-date', attributes ),
+ },
+ {
+ type: 'block',
+ blocks: [ 'jetpack/field-telephone' ],
+ isMatch: ( { options } ) => ! options.length,
+ transform: attributes => createBlock( 'jetpack/field-telephone', attributes ),
+ },
+ {
+ type: 'block',
+ blocks: [ 'jetpack/field-textarea' ],
+ isMatch: ( { options } ) => ! options.length,
+ transform: attributes => createBlock( 'jetpack/field-textarea', attributes ),
+ },
+ /* // not yet ready for prime time.
+ {
+ type: 'block',
+ blocks: [ 'jetpack/field-checkbox' ],
+ isMatch: ( { options } ) => 1 === options.length,
+ transform: ( attributes )=>createBlock( 'jetpack/field-checkbox', attributes )
+ },
+ */
+ {
+ type: 'block',
+ blocks: [ 'jetpack/field-checkbox-multiple' ],
+ isMatch: ( { options } ) => 1 <= options.length,
+ transform: attributes => createBlock( 'jetpack/field-checkbox-multiple', attributes ),
+ },
+ {
+ type: 'block',
+ blocks: [ 'jetpack/field-radio' ],
+ isMatch: ( { options } ) => 1 <= options.length,
+ transform: attributes => createBlock( 'jetpack/field-radio', attributes ),
+ },
+ {
+ type: 'block',
+ blocks: [ 'jetpack/field-select' ],
+ isMatch: ( { options } ) => 1 <= options.length,
+ transform: attributes => createBlock( 'jetpack/field-select', attributes ),
+ },
+ ],
+ },
+ save: () => null,
+};
+
+const getFieldLabel = ( { attributes, name: blockName } ) => {
+ return null === attributes.label ? getBlockType( blockName ).title : attributes.label;
+};
+
+const editField = type => props => (
+ <JetpackField
+ type={ type }
+ label={ getFieldLabel( props ) }
+ required={ props.attributes.required }
+ setAttributes={ props.setAttributes }
+ isSelected={ props.isSelected }
+ defaultValue={ props.attributes.defaultValue }
+ placeholder={ props.attributes.placeholder }
+ id={ props.attributes.id }
+ />
+);
+
+const editMultiField = type => props => (
+ <JetpackFieldMultiple
+ label={ getFieldLabel( props ) }
+ required={ props.attributes.required }
+ options={ props.attributes.options }
+ setAttributes={ props.setAttributes }
+ type={ type }
+ isSelected={ props.isSelected }
+ id={ props.attributes.id }
+ />
+);
+
+export const childBlocks = [
+ {
+ name: 'field-text',
+ settings: {
+ ...FieldDefaults,
+ title: __( 'Text', 'jetpack' ),
+ description: __( 'When you need just a small amount of text, add a text input.', 'jetpack' ),
+ icon: renderMaterialIcon( <Path d="M4 9h16v2H4V9zm0 4h10v2H4v-2z" /> ),
+ edit: editField( 'text' ),
+ },
+ },
+ {
+ name: 'field-name',
+ settings: {
+ ...FieldDefaults,
+ title: __( 'Name', 'jetpack' ),
+ description: __(
+ 'Introductions are important. Add an input for folks to add their name.',
+ 'jetpack'
+ ),
+ icon: renderMaterialIcon(
+ <Path d="M12 6c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2m0 10c2.7 0 5.8 1.29 6 2H6c.23-.72 3.31-2 6-2m0-12C9.79 4 8 5.79 8 8s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 10c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
+ ),
+ edit: editField( 'text' ),
+ },
+ },
+ {
+ name: 'field-email',
+ settings: {
+ ...FieldDefaults,
+ title: __( 'Email', 'jetpack' ),
+ keywords: [ __( 'e-mail', 'jetpack' ), __( 'mail', 'jetpack' ), 'email' ],
+ description: __( 'Want to reply to folks? Add an email address input.', 'jetpack' ),
+ icon: renderMaterialIcon(
+ <Path d="M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6zm-2 0l-8 5-8-5h16zm0 12H4V8l8 5 8-5v10z" />
+ ),
+ edit: editField( 'email' ),
+ },
+ },
+
+ {
+ name: 'field-url',
+ settings: {
+ ...FieldDefaults,
+ title: __( 'Website', 'jetpack' ),
+ keywords: [ 'url', __( 'internet page', 'jetpack' ), 'link' ],
+ description: __( 'Add an address input for a website.', 'jetpack' ),
+ icon: renderMaterialIcon(
+ <Path d="M20 18c1.1 0 1.99-.9 1.99-2L22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2h-4zM4 6h16v10H4V6z" />
+ ),
+ edit: editField( 'url' ),
+ },
+ },
+
+ {
+ name: 'field-date',
+ settings: {
+ ...FieldDefaults,
+ title: __( 'Date Picker', 'jetpack' ),
+ keywords: [
+ __( 'Calendar', 'jetpack' ),
+ __( 'day month year', 'block search term', 'jetpack' ),
+ ],
+ description: __( 'The best way to set a date. Add a date picker.', 'jetpack' ),
+ icon: renderMaterialIcon(
+ <Path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V9h14v10zm0-12H5V5h14v2zM7 11h5v5H7z" />
+ ),
+ edit: editField( 'text' ),
+ },
+ },
+ {
+ name: 'field-telephone',
+ settings: {
+ ...FieldDefaults,
+ title: __( 'Telephone', 'jetpack' ),
+ keywords: [
+ __( 'Phone', 'jetpack' ),
+ __( 'Cellular phone', 'jetpack' ),
+ __( 'Mobile', 'jetpack' ),
+ ],
+ description: __( 'Add a phone number input.', 'jetpack' ),
+ icon: renderMaterialIcon(
+ <Path d="M6.54 5c.06.89.21 1.76.45 2.59l-1.2 1.2c-.41-1.2-.67-2.47-.76-3.79h1.51m9.86 12.02c.85.24 1.72.39 2.6.45v1.49c-1.32-.09-2.59-.35-3.8-.75l1.2-1.19M7.5 3H4c-.55 0-1 .45-1 1 0 9.39 7.61 17 17 17 .55 0 1-.45 1-1v-3.49c0-.55-.45-1-1-1-1.24 0-2.45-.2-3.57-.57-.1-.04-.21-.05-.31-.05-.26 0-.51.1-.71.29l-2.2 2.2c-2.83-1.45-5.15-3.76-6.59-6.59l2.2-2.2c.28-.28.36-.67.25-1.02C8.7 6.45 8.5 5.25 8.5 4c0-.55-.45-1-1-1z" />
+ ),
+ edit: editField( 'tel' ),
+ },
+ },
+ {
+ name: 'field-textarea',
+ settings: {
+ ...FieldDefaults,
+ title: __( 'Message', 'jetpack' ),
+ keywords: [ __( 'Textarea', 'jetpack' ), 'textarea', __( 'Multiline text', 'jetpack' ) ],
+ description: __(
+ 'Let folks speak their mind. This text box is great for longer responses.',
+ 'jetpack'
+ ),
+ icon: renderMaterialIcon( <Path d="M21 11.01L3 11v2h18zM3 16h12v2H3zM21 6H3v2.01L21 8z" /> ),
+ edit: props => (
+ <JetpackFieldTextarea
+ label={ getFieldLabel( props ) }
+ required={ props.attributes.required }
+ setAttributes={ props.setAttributes }
+ isSelected={ props.isSelected }
+ defaultValue={ props.attributes.defaultValue }
+ placeholder={ props.attributes.placeholder }
+ id={ props.attributes.id }
+ />
+ ),
+ },
+ },
+ {
+ name: 'field-checkbox',
+ settings: {
+ ...FieldDefaults,
+ title: __( 'Checkbox', 'jetpack' ),
+ keywords: [ __( 'Confirm', 'jetpack' ), __( 'Accept', 'jetpack' ) ],
+ description: __( 'Add a single checkbox.', 'jetpack' ),
+ icon: renderMaterialIcon(
+ <Path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM17.99 9l-1.41-1.42-6.59 6.59-2.58-2.57-1.42 1.41 4 3.99z" />
+ ),
+ edit: props => (
+ <JetpackFieldCheckbox
+ label={ props.attributes.label } // label intentinally left blank
+ required={ props.attributes.required }
+ setAttributes={ props.setAttributes }
+ isSelected={ props.isSelected }
+ defaultValue={ props.attributes.defaultValue }
+ id={ props.attributes.id }
+ />
+ ),
+ attributes: {
+ ...FieldDefaults.attributes,
+ label: {
+ type: 'string',
+ default: '',
+ },
+ },
+ },
+ },
+ {
+ name: 'field-checkbox-multiple',
+ settings: {
+ ...FieldDefaults,
+ title: __( 'Checkbox Group', 'jetpack' ),
+ keywords: [ __( 'Choose Multiple', 'jetpack' ), __( 'Option', 'jetpack' ) ],
+ description: __( 'People love options. Add several checkbox items.', 'jetpack' ),
+ icon: renderMaterialIcon(
+ <Path d="M18 7l-1.41-1.41-6.34 6.34 1.41 1.41L18 7zm4.24-1.41L11.66 16.17 7.48 12l-1.41 1.41L11.66 19l12-12-1.42-1.41zM.41 13.41L6 19l1.41-1.41L1.83 12 .41 13.41z" />
+ ),
+ edit: editMultiField( 'checkbox' ),
+ attributes: {
+ ...FieldDefaults.attributes,
+ label: {
+ type: 'string',
+ default: 'Choose several',
+ },
+ },
+ },
+ },
+ {
+ name: 'field-radio',
+ settings: {
+ ...FieldDefaults,
+ title: __( 'Radio', 'jetpack' ),
+ keywords: [ __( 'Choose', 'jetpack' ), __( 'Select', 'jetpack' ), __( 'Option', 'jetpack' ) ],
+ description: __(
+ 'Inspired by radios, only one radio item can be selected at a time. Add several radio button items.',
+ 'jetpack'
+ ),
+ icon: renderMaterialIcon(
+ <Fragment>
+ <Path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" />
+ <Circle cx="12" cy="12" r="5" />
+ </Fragment>
+ ),
+ edit: editMultiField( 'radio' ),
+ attributes: {
+ ...FieldDefaults.attributes,
+ label: {
+ type: 'string',
+ default: 'Choose one',
+ },
+ },
+ },
+ },
+ {
+ name: 'field-select',
+ settings: {
+ ...FieldDefaults,
+ title: __( 'Select', 'jetpack' ),
+ keywords: [
+ __( 'Choose', 'jetpack' ),
+ __( 'Dropdown', 'jetpack' ),
+ __( 'Option', 'jetpack' ),
+ ],
+ description: __( 'Compact, but powerful. Add a select box with several items.', 'jetpack' ),
+ icon: renderMaterialIcon(
+ <Path d="M3 17h18v2H3zm16-5v1H5v-1h14m2-2H3v5h18v-5zM3 6h18v2H3z" />
+ ),
+ edit: editMultiField( 'select' ),
+ attributes: {
+ ...FieldDefaults.attributes,
+ label: {
+ type: 'string',
+ default: 'Select one',
+ },
+ },
+ },
+ },
+];
diff --git a/plugins/jetpack/extensions/blocks/contact-info/address/edit.js b/plugins/jetpack/extensions/blocks/contact-info/address/edit.js
new file mode 100644
index 00000000..4618f686
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-info/address/edit.js
@@ -0,0 +1,125 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+import { __ } from '@wordpress/i18n';
+import { Component, Fragment } from '@wordpress/element';
+import { PlainText } from '@wordpress/editor';
+import { ToggleControl } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import save from './save';
+
+class AddressEdit extends Component {
+ constructor( ...args ) {
+ super( ...args );
+
+ this.preventEnterKey = this.preventEnterKey.bind( this );
+ }
+
+ preventEnterKey( event ) {
+ if ( event.key === 'Enter' ) {
+ event.preventDefault();
+ return;
+ }
+ }
+
+ render() {
+ const {
+ attributes: {
+ address,
+ addressLine2,
+ addressLine3,
+ city,
+ region,
+ postal,
+ country,
+ linkToGoogleMaps,
+ },
+ isSelected,
+ setAttributes,
+ } = this.props;
+
+ const hasContent = [ address, addressLine2, addressLine3, city, region, postal, country ].some(
+ value => value !== ''
+ );
+ const classNames = classnames( {
+ 'jetpack-address-block': true,
+ 'is-selected': isSelected,
+ } );
+
+ const externalLink = (
+ <ToggleControl
+ label={ __( 'Link address to Google Maps', 'jetpack' ) }
+ checked={ linkToGoogleMaps }
+ onChange={ newlinkToGoogleMaps =>
+ setAttributes( { linkToGoogleMaps: newlinkToGoogleMaps } )
+ }
+ />
+ );
+
+ return (
+ <div className={ classNames }>
+ { ! isSelected && hasContent && save( this.props ) }
+ { ( isSelected || ! hasContent ) && (
+ <Fragment>
+ <PlainText
+ value={ address }
+ placeholder={ __( 'Street Address', 'jetpack' ) }
+ aria-label={ __( 'Street Address', 'jetpack' ) }
+ onChange={ newAddress => setAttributes( { address: newAddress } ) }
+ onKeyDown={ this.preventEnterKey }
+ />
+ <PlainText
+ value={ addressLine2 }
+ placeholder={ __( 'Address Line 2', 'jetpack' ) }
+ aria-label={ __( 'Address Line 2', 'jetpack' ) }
+ onChange={ newAddressLine2 => setAttributes( { addressLine2: newAddressLine2 } ) }
+ onKeyDown={ this.preventEnterKey }
+ />
+ <PlainText
+ value={ addressLine3 }
+ placeholder={ __( 'Address Line 3', 'jetpack' ) }
+ aria-label={ __( 'Address Line 3', 'jetpack' ) }
+ onChange={ newAddressLine3 => setAttributes( { addressLine3: newAddressLine3 } ) }
+ onKeyDown={ this.preventEnterKey }
+ />
+ <PlainText
+ value={ city }
+ placeholder={ __( 'City', 'jetpack' ) }
+ aria-label={ __( 'City', 'jetpack' ) }
+ onChange={ newCity => setAttributes( { city: newCity } ) }
+ onKeyDown={ this.preventEnterKey }
+ />
+ <PlainText
+ value={ region }
+ placeholder={ __( 'State/Province/Region', 'jetpack' ) }
+ aria-label={ __( 'State/Province/Region', 'jetpack' ) }
+ onChange={ newRegion => setAttributes( { region: newRegion } ) }
+ onKeyDown={ this.preventEnterKey }
+ />
+ <PlainText
+ value={ postal }
+ placeholder={ __( 'Postal/Zip Code', 'jetpack' ) }
+ aria-label={ __( 'Postal/Zip Code', 'jetpack' ) }
+ onChange={ newPostal => setAttributes( { postal: newPostal } ) }
+ onKeyDown={ this.preventEnterKey }
+ />
+ <PlainText
+ value={ country }
+ placeholder={ __( 'Country', 'jetpack' ) }
+ aria-label={ __( 'Country', 'jetpack' ) }
+ onChange={ newCountry => setAttributes( { country: newCountry } ) }
+ onKeyDown={ this.preventEnterKey }
+ />
+ { externalLink }
+ </Fragment>
+ ) }
+ </div>
+ );
+ }
+}
+
+export default AddressEdit;
diff --git a/plugins/jetpack/extensions/blocks/contact-info/address/editor.js b/plugins/jetpack/extensions/blocks/contact-info/address/editor.js
new file mode 100644
index 00000000..403fddb8
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-info/address/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/plugins/jetpack/extensions/blocks/contact-info/address/index.js b/plugins/jetpack/extensions/blocks/contact-info/address/index.js
new file mode 100644
index 00000000..f94259f4
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-info/address/index.js
@@ -0,0 +1,71 @@
+/**
+ * External dependencies
+ */
+import { __, _x } from '@wordpress/i18n';
+import { Fragment } from '@wordpress/element';
+import { Path, Circle } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import edit from './edit';
+import save from './save';
+import renderMaterialIcon from '../../../shared/render-material-icon';
+
+const attributes = {
+ address: {
+ type: 'string',
+ default: '',
+ },
+ addressLine2: {
+ type: 'string',
+ default: '',
+ },
+ addressLine3: {
+ type: 'string',
+ default: '',
+ },
+ city: {
+ type: 'string',
+ default: '',
+ },
+ region: {
+ type: 'string',
+ default: '',
+ },
+ postal: {
+ type: 'string',
+ default: '',
+ },
+ country: {
+ type: 'string',
+ default: '',
+ },
+ linkToGoogleMaps: {
+ type: 'boolean',
+ default: false,
+ },
+};
+
+export const name = 'address';
+
+export const settings = {
+ title: __( 'Address', 'jetpack' ),
+ description: __( 'Lets you add a physical address with Schema markup.', 'jetpack' ),
+ keywords: [
+ _x( 'location', 'block search term', 'jetpack' ),
+ _x( 'direction', 'block search term', 'jetpack' ),
+ _x( 'place', 'block search term', 'jetpack' ),
+ ],
+ icon: renderMaterialIcon(
+ <Fragment>
+ <Path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zM7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 2.88-2.88 7.19-5 9.88C9.92 16.21 7 11.85 7 9z" />
+ <Circle cx="12" cy="9" r="2.5" />
+ </Fragment>
+ ),
+ category: 'jetpack',
+ attributes,
+ parent: [ 'jetpack/contact-info' ],
+ edit,
+ save,
+};
diff --git a/plugins/jetpack/extensions/blocks/contact-info/address/save.js b/plugins/jetpack/extensions/blocks/contact-info/address/save.js
new file mode 100644
index 00000000..fd1ba8bb
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-info/address/save.js
@@ -0,0 +1,85 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Fragment } from '@wordpress/element';
+
+const hasAddress = ( { address, addressLine2, addressLine3, city, region, postal, country } ) => {
+ return [ address, addressLine2, addressLine3, city, region, postal, country ].some(
+ value => value !== ''
+ );
+};
+
+const Address = ( {
+ attributes: { address, addressLine2, addressLine3, city, region, postal, country },
+} ) => (
+ <Fragment>
+ { address && (
+ <div className="jetpack-address__address jetpack-address__address1">{ address }</div>
+ ) }
+ { addressLine2 && (
+ <div className="jetpack-address__address jetpack-address__address2">{ addressLine2 }</div>
+ ) }
+ { addressLine3 && (
+ <div className="jetpack-address__address jetpack-address__address3">{ addressLine3 }</div>
+ ) }
+ { city && ! ( region || postal ) && <div className="jetpack-address__city">{ city }</div> }
+ { city && ( region || postal ) && (
+ <div>
+ { [
+ <span className="jetpack-address__city">{ city }</span>,
+ ', ',
+ <span className="jetpack-address__region">{ region }</span>,
+ ' ',
+ <span className="jetpack-address__postal">{ postal }</span>,
+ ] }
+ </div>
+ ) }
+ { ! city && ( region || postal ) && (
+ <div>
+ { [
+ <span className="jetpack-address__region">{ region }</span>,
+ ' ',
+ <span className="jetpack-address__postal">{ postal }</span>,
+ ] }
+ </div>
+ ) }
+ { country && <div className="jetpack-address__country">{ country }</div> }
+ </Fragment>
+);
+
+export const googleMapsUrl = ( {
+ attributes: { address, addressLine2, addressLine3, city, region, postal, country },
+} ) => {
+ const addressUrl = address ? `${ address },` : '';
+ const addressLine2Url = addressLine2 ? `${ addressLine2 },` : '';
+ const addressLine3Url = addressLine3 ? `${ addressLine3 },` : '';
+ const cityUrl = city ? `+${ city },` : '';
+ let regionUrl = region ? `+${ region },` : '';
+ regionUrl = postal ? `${ regionUrl }+${ postal }` : regionUrl;
+ const countryUrl = country ? `+${ country }` : '';
+
+ return `https://www.google.com/maps/search/${ addressUrl }${ addressLine2Url }${ addressLine3Url }${ cityUrl }${ regionUrl }${ countryUrl }`.replace(
+ ' ',
+ '+'
+ );
+};
+
+const save = props =>
+ hasAddress( props.attributes ) && (
+ <div className={ props.className }>
+ { props.attributes.linkToGoogleMaps && (
+ <a
+ href={ googleMapsUrl( props ) }
+ target="_blank"
+ rel="noopener noreferrer"
+ title={ __( 'Open address in Google Maps', 'jetpack' ) }
+ >
+ <Address { ...props } />
+ </a>
+ ) }
+ { ! props.attributes.linkToGoogleMaps && <Address { ...props } /> }
+ </div>
+ );
+
+export default save;
diff --git a/plugins/jetpack/extensions/blocks/contact-info/class-jetpack-contact-info-block.php b/plugins/jetpack/extensions/blocks/contact-info/class-jetpack-contact-info-block.php
new file mode 100644
index 00000000..7a34cbbb
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-info/class-jetpack-contact-info-block.php
@@ -0,0 +1,114 @@
+<?php
+/**
+ * Class Jetpack_Contact_Info_Block
+ *
+ * @package Jetpack
+ */
+
+/**
+ * Helper class that lets us add schema attributes dynamically because they are not something that is store with the content.
+ * Due to the limitations of wp_kses.
+ *
+ * @since 7.1.0
+ */
+class Jetpack_Contact_Info_Block {
+
+ /**
+ * Adds contact info schema attributes.
+ *
+ * @param array $attr Array containing the contact info block attributes.
+ * @param string $content String containing the contact info block content.
+ *
+ * @return string
+ */
+ public static function render( $attr, $content ) {
+ Jetpack_Gutenberg::load_styles_as_required( 'contact-info' );
+ return str_replace(
+ 'class="wp-block-jetpack-contact-info', // Closing " intentionally ommited to that the user can also add the className as expected.
+ 'itemprop="location" itemscope itemtype="http://schema.org/Organization" class="wp-block-jetpack-contact-info',
+ $content
+ );
+ }
+
+ /**
+ * Adds address schema attributes.
+ *
+ * @param array $attr Array containing the address block attributes.
+ * @param string $content String containing the address block content.
+ *
+ * @return string
+ */
+ public static function render_address( $attr, $content ) {
+ // Returns empty content if the only attribute set is linkToGoogleMaps.
+ if ( ! self::has_attributes( $attr, array( 'linkToGoogleMaps', 'className' ) ) ) {
+ return '';
+ }
+ $find = array(
+ 'class="wp-block-jetpack-address"',
+ 'class="jetpack-address__address',
+ // Closing " left out on purpose - there are multiple address fields and they all need to be updated with the same itemprop.
+ 'class="jetpack-address__region"',
+ 'class="jetpack-address__city"',
+ 'class="jetpack-address__postal"',
+ 'class="jetpack-address__country"',
+ );
+ $replace = array(
+ 'itemprop="address" itemscope itemtype="http://schema.org/PostalAddress" class="wp-block-jetpack-address" ',
+ 'itemprop="streetAddress" class="jetpack-address__address', // Closing " left out on purpose.
+ 'itemprop="addressRegion" class="jetpack-address__region"',
+ 'itemprop="addressLocality" class="jetpack-address__city"',
+ 'itemprop="postalCode" class="jetpack-address__postal"',
+ 'itemprop="addressCountry" class="jetpack-address__country"',
+ );
+
+ return str_replace( $find, $replace, $content );
+ }
+
+ /**
+ * Helper function that lets us determine if a block has any valid attributes.
+ *
+ * @param array $attr Array containing the block attributes.
+ * @param array $omit Array containing the block attributes that we ignore.
+ *
+ * @return string
+ */
+ public static function has_attributes( $attr, $omit = array() ) {
+ foreach ( $attr as $attribute => $value ) {
+ if ( ! in_array( $attribute, $omit, true ) && ! empty( $value ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Adds email schema attributes.
+ *
+ * @param array $attr Array containing the email block attributes.
+ * @param string $content String containing the email block content.
+ *
+ * @return string
+ */
+ public static function render_email( $attr, $content ) {
+ $content = self::has_attributes( $attr, array( 'className' ) ) ?
+ str_replace( 'href="mailto:', 'itemprop="email" href="mailto:', $content ) :
+ '';
+ return $content;
+ }
+
+ /**
+ * Adds phone schema attributes.
+ *
+ * @param array $attr Array containing the phone block attributes.
+ * @param string $content String containing the phone block content.
+ *
+ * @return string
+ */
+ public static function render_phone( $attr, $content ) {
+ $content = self::has_attributes( $attr, array( 'className' ) ) ?
+ str_replace( 'href="tel:', 'itemprop="telephone" href="tel:', $content ) :
+ '';
+ return $content;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/contact-info/contact-info.php b/plugins/jetpack/extensions/blocks/contact-info/contact-info.php
new file mode 100644
index 00000000..c7414517
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-info/contact-info.php
@@ -0,0 +1,40 @@
+<?php
+/**
+ * Contact Info block and its child blocks.
+ *
+ * @since 7.1.0
+ *
+ * @package Jetpack
+ */
+
+jetpack_register_block(
+ 'jetpack/contact-info',
+ array(
+ 'render_callback' => array( 'Jetpack_Contact_Info_Block', 'render' ),
+ )
+);
+
+jetpack_register_block(
+ 'jetpack/address',
+ array(
+ 'parent' => array( 'jetpack/contact-info' ),
+ 'render_callback' => array( 'Jetpack_Contact_Info_Block', 'render_address' ),
+ )
+);
+
+jetpack_register_block(
+ 'jetpack/email',
+ array(
+ 'parent' => array( 'jetpack/contact-info' ),
+ 'render_callback' => array( 'Jetpack_Contact_Info_Block', 'render_email' ),
+ )
+);
+
+jetpack_register_block(
+ 'jetpack/phone',
+ array(
+ 'parent' => array( 'jetpack/contact-info' ),
+ 'render_callback' => array( 'Jetpack_Contact_Info_Block', 'render_phone' ),
+ )
+);
+require_once dirname( __FILE__ ) . '/class-jetpack-contact-info-block.php';
diff --git a/plugins/jetpack/extensions/blocks/contact-info/edit.js b/plugins/jetpack/extensions/blocks/contact-info/edit.js
new file mode 100644
index 00000000..b3ba63a6
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-info/edit.js
@@ -0,0 +1,51 @@
+/**
+ * External dependencies
+ */
+import { InnerBlocks } from '@wordpress/editor';
+import classnames from 'classnames';
+
+/**
+ * Internal dependencies
+ */
+const ALLOWED_BLOCKS = [
+ 'jetpack/markdown',
+ 'jetpack/address',
+ 'jetpack/email',
+ 'jetpack/phone',
+ 'jetpack/map',
+ 'jetpack/business-hours',
+ 'core/paragraph',
+ 'core/image',
+ 'core/heading',
+ 'core/gallery',
+ 'core/list',
+ 'core/quote',
+ 'core/shortcode',
+ 'core/audio',
+ 'core/code',
+ 'core/cover',
+ 'core/html',
+ 'core/separator',
+ 'core/spacer',
+ 'core/subhead',
+ 'core/video',
+];
+
+const TEMPLATE = [ [ 'jetpack/email' ], [ 'jetpack/phone' ], [ 'jetpack/address' ] ];
+
+const ContactInfoEdit = props => {
+ const { isSelected } = props;
+
+ return (
+ <div
+ className={ classnames( {
+ 'jetpack-contact-info-block': true,
+ 'is-selected': isSelected,
+ } ) }
+ >
+ <InnerBlocks allowedBlocks={ ALLOWED_BLOCKS } templateLock={ false } template={ TEMPLATE } />
+ </div>
+ );
+};
+
+export default ContactInfoEdit;
diff --git a/plugins/jetpack/extensions/blocks/contact-info/editor.js b/plugins/jetpack/extensions/blocks/contact-info/editor.js
new file mode 100644
index 00000000..9ffc068d
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-info/editor.js
@@ -0,0 +1,7 @@
+/**
+ * Internal dependencies
+ */
+import registerJetpackBlock from '../../shared/register-jetpack-block';
+import { childBlocks, name, settings } from '.';
+
+registerJetpackBlock( name, settings, childBlocks );
diff --git a/plugins/jetpack/extensions/blocks/contact-info/editor.scss b/plugins/jetpack/extensions/blocks/contact-info/editor.scss
new file mode 100644
index 00000000..c07d148d
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-info/editor.scss
@@ -0,0 +1,18 @@
+.jetpack-contact-info-block {
+ /* css class added to increase specificity */
+ .editor-plain-text.editor-plain-text:focus {
+ box-shadow: none;
+ }
+
+ .editor-plain-text {
+ flex-grow: 1;
+ min-height: unset;
+ padding: 0;
+ box-shadow: none;
+ font-family: inherit;
+ font-size: inherit;
+ color: inherit;
+ line-height: inherit;
+ border: none;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/contact-info/email/edit.js b/plugins/jetpack/extensions/blocks/contact-info/email/edit.js
new file mode 100644
index 00000000..5fd0ccac
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-info/email/edit.js
@@ -0,0 +1,15 @@
+/**
+ * Internal dependencies
+ */
+import save from './save';
+import simpleInput from '../../../shared/simple-input';
+import { __ } from '@wordpress/i18n';
+
+const EmailEdit = props => {
+ const { setAttributes } = props;
+ return simpleInput( 'email', props, __( 'Email', 'jetpack' ), save, nextValue =>
+ setAttributes( { email: nextValue } )
+ );
+};
+
+export default EmailEdit;
diff --git a/plugins/jetpack/extensions/blocks/contact-info/email/editor.js b/plugins/jetpack/extensions/blocks/contact-info/email/editor.js
new file mode 100644
index 00000000..403fddb8
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-info/email/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/plugins/jetpack/extensions/blocks/contact-info/email/index.js b/plugins/jetpack/extensions/blocks/contact-info/email/index.js
new file mode 100644
index 00000000..db086dd0
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-info/email/index.js
@@ -0,0 +1,42 @@
+/**
+ * External dependencies
+ */
+import { __, _x } from '@wordpress/i18n';
+import { Path } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import edit from './edit';
+import renderMaterialIcon from '../../../shared/render-material-icon';
+import save from './save';
+
+const attributes = {
+ email: {
+ type: 'string',
+ default: '',
+ },
+};
+
+export const name = 'email';
+
+export const settings = {
+ title: __( 'Email Address', 'jetpack' ),
+ description: __(
+ 'Lets you add an email address with an automatically generated click-to-email link.',
+ 'jetpack'
+ ),
+ keywords: [
+ 'e-mail', // not translatable on purpose
+ 'email', // not translatable on purpose
+ _x( 'message', 'block search term', 'jetpack' ),
+ ],
+ icon: renderMaterialIcon(
+ <Path d="M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6zm-2 0l-8 5-8-5h16zm0 12H4V8l8 5 8-5v10z" />
+ ),
+ category: 'jetpack',
+ attributes,
+ edit,
+ save,
+ parent: [ 'jetpack/contact-info' ],
+};
diff --git a/plugins/jetpack/extensions/blocks/contact-info/email/save.js b/plugins/jetpack/extensions/blocks/contact-info/email/save.js
new file mode 100644
index 00000000..e0eb0204
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-info/email/save.js
@@ -0,0 +1,36 @@
+/**
+ * External dependencies
+ */
+import emailValidator from 'email-validator';
+import { Fragment } from '@wordpress/element';
+
+const renderEmail = inputText => {
+ const explodedInput = inputText.split( /(\s+)/ ).map( ( email, i ) => {
+ // Remove and punctuation from the end of the email address.
+ const emailToValidate = email.replace( /([.,/#!$%^&*;:{}=\-_`~()\][])+$/g, '' );
+ if ( email.indexOf( '@' ) && emailValidator.validate( emailToValidate ) ) {
+ return email === emailToValidate ? (
+ // Email.
+ <a href={ `mailto:${ email }` } key={ i }>
+ { email }
+ </a>
+ ) : (
+ // Email with punctionation.
+ <Fragment key={ i }>
+ <a href={ `mailto:${ email }` } key={ i }>
+ { emailToValidate }
+ </a>
+ <Fragment>{ email.slice( -( email.length - emailToValidate.length ) ) }</Fragment>
+ </Fragment>
+ );
+ }
+ // Just a plain string.
+ return <Fragment key={ i }>{ email }</Fragment>;
+ } );
+ return explodedInput;
+};
+
+const save = ( { attributes: { email }, className } ) =>
+ email && <div className={ className }>{ renderEmail( email ) }</div>;
+
+export default save;
diff --git a/plugins/jetpack/extensions/blocks/contact-info/index.js b/plugins/jetpack/extensions/blocks/contact-info/index.js
new file mode 100644
index 00000000..bc748acc
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-info/index.js
@@ -0,0 +1,57 @@
+/**
+ * External dependencies
+ */
+import { __, _x } from '@wordpress/i18n';
+import { InnerBlocks } from '@wordpress/editor';
+import { Path } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import edit from './edit';
+import renderMaterialIcon from '../../shared/render-material-icon';
+import './editor.scss';
+import './style.scss';
+import { name as addressName, settings as addressSettings } from './address/';
+import { name as emailName, settings as emailSettings } from './email/';
+import { name as phoneName, settings as phoneSettings } from './phone/';
+
+const attributes = {};
+
+const save = ( { className } ) => (
+ <div className={ className }>
+ <InnerBlocks.Content />
+ </div>
+);
+
+export const name = 'contact-info';
+
+export const settings = {
+ title: __( 'Contact Info', 'jetpack' ),
+ description: __(
+ 'Lets you add an email address, phone number, and physical address with improved markup for better SEO results.',
+ 'jetpack'
+ ),
+ keywords: [
+ _x( 'email', 'block search term', 'jetpack' ),
+ _x( 'phone', 'block search term', 'jetpack' ),
+ _x( 'address', 'block search term', 'jetpack' ),
+ ],
+ icon: renderMaterialIcon(
+ <Path d="M19 5v14H5V5h14m0-2H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 9c-1.65 0-3-1.35-3-3s1.35-3 3-3 3 1.35 3 3-1.35 3-3 3zm0-4c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm6 10H6v-1.53c0-2.5 3.97-3.58 6-3.58s6 1.08 6 3.58V18zm-9.69-2h7.38c-.69-.56-2.38-1.12-3.69-1.12s-3.01.56-3.69 1.12z" />
+ ),
+ category: 'jetpack',
+ supports: {
+ align: [ 'wide', 'full' ],
+ html: false,
+ },
+ attributes,
+ edit,
+ save,
+};
+
+export const childBlocks = [
+ { name: addressName, settings: addressSettings },
+ { name: emailName, settings: emailSettings },
+ { name: phoneName, settings: phoneSettings },
+];
diff --git a/plugins/jetpack/extensions/blocks/contact-info/phone/edit.js b/plugins/jetpack/extensions/blocks/contact-info/phone/edit.js
new file mode 100644
index 00000000..0a55333e
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-info/phone/edit.js
@@ -0,0 +1,19 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import save from './save';
+import simpleInput from '../../../shared/simple-input';
+
+const PhoneEdit = props => {
+ const { setAttributes } = props;
+ return simpleInput( 'phone', props, __( 'Phone number', 'jetpack' ), save, nextValue =>
+ setAttributes( { phone: nextValue } )
+ );
+};
+
+export default PhoneEdit;
diff --git a/plugins/jetpack/extensions/blocks/contact-info/phone/editor.js b/plugins/jetpack/extensions/blocks/contact-info/phone/editor.js
new file mode 100644
index 00000000..403fddb8
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-info/phone/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/plugins/jetpack/extensions/blocks/contact-info/phone/index.js b/plugins/jetpack/extensions/blocks/contact-info/phone/index.js
new file mode 100644
index 00000000..17c51924
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-info/phone/index.js
@@ -0,0 +1,42 @@
+/**
+ * External dependencies
+ */
+import { __, _x } from '@wordpress/i18n';
+import { Path } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import edit from './edit';
+import renderMaterialIcon from '../../../shared/render-material-icon';
+import save from './save';
+
+const attributes = {
+ phone: {
+ type: 'string',
+ default: '',
+ },
+};
+
+export const name = 'phone';
+
+export const settings = {
+ title: __( 'Phone Number', 'jetpack' ),
+ description: __(
+ 'Lets you add a phone number with an automatically generated click-to-call link.',
+ 'jetpack'
+ ),
+ keywords: [
+ _x( 'mobile', 'block search term', 'jetpack' ),
+ _x( 'telephone', 'block search term', 'jetpack' ),
+ _x( 'cell', 'block search term', 'jetpack' ),
+ ],
+ icon: renderMaterialIcon(
+ <Path d="M6.54 5c.06.89.21 1.76.45 2.59l-1.2 1.2c-.41-1.2-.67-2.47-.76-3.79h1.51m9.86 12.02c.85.24 1.72.39 2.6.45v1.49c-1.32-.09-2.59-.35-3.8-.75l1.2-1.19M7.5 3H4c-.55 0-1 .45-1 1 0 9.39 7.61 17 17 17 .55 0 1-.45 1-1v-3.49c0-.55-.45-1-1-1-1.24 0-2.45-.2-3.57-.57-.1-.04-.21-.05-.31-.05-.26 0-.51.1-.71.29l-2.2 2.2c-2.83-1.45-5.15-3.76-6.59-6.59l2.2-2.2c.28-.28.36-.67.25-1.02C8.7 6.45 8.5 5.25 8.5 4c0-.55-.45-1-1-1z" />
+ ),
+ category: 'jetpack',
+ attributes,
+ parent: [ 'jetpack/contact-info' ],
+ edit,
+ save,
+};
diff --git a/plugins/jetpack/extensions/blocks/contact-info/phone/save.js b/plugins/jetpack/extensions/blocks/contact-info/phone/save.js
new file mode 100644
index 00000000..50f67914
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-info/phone/save.js
@@ -0,0 +1,47 @@
+/**
+ * Internal dependencies
+ */
+
+export function renderPhone( inputText ) {
+ const arrayOfNumbers = inputText.match( /\d+\.\d+|\d+\b|\d+(?=\w)/g );
+ if ( ! arrayOfNumbers ) {
+ // No numbers found
+ return inputText;
+ }
+ const indexOfFirstNumber = inputText.indexOf( arrayOfNumbers[ 0 ] );
+
+ // Assume that eveything after the first number should be part of the phone number.
+ // care about the first prefix character.
+ let phoneNumber = indexOfFirstNumber ? inputText.substring( indexOfFirstNumber - 1 ) : inputText;
+ let prefix = indexOfFirstNumber ? inputText.substring( 0, indexOfFirstNumber ) : '';
+
+ let justNumber = phoneNumber.replace( /\D/g, '' );
+ // Phone numbers starting with + should be part of the number.
+ if ( /[0-9/+/(]/.test( phoneNumber[ 0 ] ) ) {
+ // Remove the special character from the prefix so they don't appear twice.
+ prefix = prefix.slice( 0, -1 );
+ // Phone numbers starting with + shoud be part of the number.
+ if ( phoneNumber[ 0 ] === '+' ) {
+ justNumber = '+' + justNumber;
+ }
+ } else {
+ // Remove the first character.
+ phoneNumber = phoneNumber.substring( 1 );
+ }
+ const prefixSpan = prefix.trim() ? (
+ <span key="phonePrefix" className="phone-prefix">
+ { prefix }
+ </span>
+ ) : null;
+ return [
+ prefixSpan,
+ <a key="phoneNumber" href={ `tel:${ justNumber }` }>
+ { phoneNumber }
+ </a>,
+ ];
+}
+
+const save = ( { attributes: { phone }, className } ) =>
+ phone && <div className={ className }>{ renderPhone( phone ) }</div>;
+
+export default save;
diff --git a/plugins/jetpack/extensions/blocks/contact-info/style.scss b/plugins/jetpack/extensions/blocks/contact-info/style.scss
new file mode 100644
index 00000000..8f81ca89
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-info/style.scss
@@ -0,0 +1,3 @@
+.wp-block-jetpack-contact-info {
+ margin-bottom: 1.5em;
+}
diff --git a/plugins/jetpack/extensions/blocks/contact-info/view.js b/plugins/jetpack/extensions/blocks/contact-info/view.js
new file mode 100644
index 00000000..fd92905c
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/contact-info/view.js
@@ -0,0 +1,5 @@
+/**
+ * Internal dependencies
+ */
+
+import './style.scss';
diff --git a/plugins/jetpack/extensions/blocks/gif/edit.js b/plugins/jetpack/extensions/blocks/gif/edit.js
new file mode 100644
index 00000000..d16fa4db
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/gif/edit.js
@@ -0,0 +1,217 @@
+/**
+ * External dependencies
+ */
+import classNames from 'classnames';
+import { __ } from '@wordpress/i18n';
+import { Component, createRef } from '@wordpress/element';
+import { Button, PanelBody, Path, Placeholder, SVG, TextControl } from '@wordpress/components';
+import { InspectorControls, RichText } from '@wordpress/editor';
+
+/**
+ * Internal dependencies
+ */
+import { icon, title } from './';
+
+const GIPHY_API_KEY = 't1PkR1Vq0mzHueIFBvZSZErgFs9NBmYW';
+const INPUT_PROMPT = __( 'Search for a term or paste a Giphy URL', 'jetpack' );
+
+class GifEdit extends Component {
+ textControlRef = createRef();
+
+ state = {
+ captionFocus: false,
+ results: null,
+ };
+
+ onFormSubmit = event => {
+ event.preventDefault();
+ this.onSubmit();
+ };
+
+ onSubmit = () => {
+ const { attributes } = this.props;
+ const { searchText } = attributes;
+ this.parseSearch( searchText );
+ };
+
+ parseSearch = searchText => {
+ let giphyID = null;
+ // If search is hardcoded Giphy URL following this pattern: https://giphy.com/embed/4ZFekt94LMhNK
+ if ( searchText.indexOf( '//giphy.com/gifs' ) !== -1 ) {
+ giphyID = this.splitAndLast( this.splitAndLast( searchText, '/' ), '-' );
+ }
+ // If search is hardcoded Giphy URL following this patterh: http://i.giphy.com/4ZFekt94LMhNK.gif
+ if ( searchText.indexOf( '//i.giphy.com' ) !== -1 ) {
+ giphyID = this.splitAndLast( searchText, '/' ).replace( '.gif', '' );
+ }
+ // https://media.giphy.com/media/gt0hYzKlMpfOg/giphy.gif
+ const match = searchText.match(
+ /http[s]?:\/\/media.giphy.com\/media\/([A-Za-z0-9\-.]+)\/giphy.gif/
+ );
+ if ( match ) {
+ giphyID = match[ 1 ];
+ }
+ if ( giphyID ) {
+ return this.fetch( this.urlForId( giphyID ) );
+ }
+
+ return this.fetch( this.urlForSearch( searchText ) );
+ };
+
+ urlForSearch = searchText => {
+ return `https://api.giphy.com/v1/gifs/search?q=${ encodeURIComponent(
+ searchText
+ ) }&api_key=${ encodeURIComponent( GIPHY_API_KEY ) }&limit=10`;
+ };
+
+ urlForId = giphyId => {
+ return `https://api.giphy.com/v1/gifs/${ encodeURIComponent(
+ giphyId
+ ) }?api_key=${ encodeURIComponent( GIPHY_API_KEY ) }`;
+ };
+
+ splitAndLast = ( array, delimiter ) => {
+ const split = array.split( delimiter );
+ return split[ split.length - 1 ];
+ };
+
+ fetch = url => {
+ const xhr = new XMLHttpRequest();
+ xhr.open( 'GET', url );
+ xhr.onload = () => {
+ if ( xhr.status === 200 ) {
+ const res = JSON.parse( xhr.responseText );
+ // If there is only one result, Giphy's API does not return an array.
+ // The following statement normalizes the data into an array with one member in this case.
+ const results = typeof res.data.images !== 'undefined' ? [ res.data ] : res.data;
+ const giphyData = results[ 0 ];
+ // No results
+ if ( ! giphyData.images ) {
+ return;
+ }
+ this.setState( { results }, () => {
+ this.selectGiphy( giphyData );
+ } );
+ } else {
+ // Error handling TK
+ }
+ };
+ xhr.send();
+ };
+
+ selectGiphy = giphy => {
+ const { setAttributes } = this.props;
+ const calculatedPaddingTop = Math.floor(
+ ( giphy.images.original.height / giphy.images.original.width ) * 100
+ );
+ const paddingTop = `${ calculatedPaddingTop }%`;
+ const giphyUrl = giphy.embed_url;
+ setAttributes( { giphyUrl, paddingTop } );
+ };
+
+ setFocus = () => {
+ this.textControlRef.current.querySelector( 'input' ).focus();
+ this.setState( { captionFocus: false } );
+ };
+
+ hasSearchText = () => {
+ const { attributes } = this.props;
+ const { searchText } = attributes;
+ return searchText && searchText.length > 0;
+ };
+
+ thumbnailClicked = thumbnail => {
+ this.selectGiphy( thumbnail );
+ };
+
+ render() {
+ const { attributes, className, isSelected, setAttributes } = this.props;
+ const { align, caption, giphyUrl, searchText, paddingTop } = attributes;
+ const { captionFocus, results } = this.state;
+ const style = { paddingTop };
+ const classes = classNames( className, `align${ align }` );
+ const inputFields = (
+ <form
+ className="wp-block-jetpack-gif_input-container"
+ onSubmit={ this.onFormSubmit }
+ ref={ this.textControlRef }
+ >
+ <TextControl
+ className="wp-block-jetpack-gif_input"
+ label={ INPUT_PROMPT }
+ placeholder={ INPUT_PROMPT }
+ onChange={ value => setAttributes( { searchText: value } ) }
+ value={ searchText }
+ />
+ <Button isLarge onClick={ this.onSubmit }>
+ { __( 'Search', 'jetpack' ) }
+ </Button>
+ </form>
+ );
+ return (
+ <div className={ classes }>
+ <InspectorControls>
+ <PanelBody className="components-panel__body-gif-branding">
+ <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 202 22">
+ <Path d="M4.6 5.9H0v10h1.6v-3.1h3c4.8 0 4.8-6.9 0-6.9zm0 5.4h-3v-4h3c2.6.1 2.6 4 0 4zM51.2 12.3c2-.3 2.7-1.7 2.7-3.1 0-1.7-1.2-3.3-3.5-3.3h-4.6v10h1.6v-3.4h2.1l3 3.4h1.9l-.2-.3-3-3.3zM47.4 11V7.4h3c1.3 0 1.9.9 1.9 1.8s-.6 1.8-1.9 1.8h-3zM30.6 13.6L28 5.9h-1.1l-2.5 7.7-2.6-7.7H20l3.7 10H25l1.4-3.5L27.5 9l1.1 3.4 1.3 3.5h1.4l3.5-10h-1.7z" />
+ <Path d="M14.4 5.7c-3 0-5.1 2.2-5.1 5.2 0 2.6 1.6 5.1 5.1 5.1 3.5 0 5.1-2.5 5.1-5.2-.1-2.6-1.7-5.1-5.1-5.1zm-.1 8.9c-2.5 0-3.5-1.9-3.5-3.7 0-2.2 1.2-3.8 3.5-3.8 2.4 0 3.5 2 3.5 3.8.1 2-1 3.7-3.5 3.7zM57.7 11.6h5.5v-1.5h-5.5V7.4h5.7V5.9h-7.3v10h7.3v-1.6h-5.7zM38 14.3v-2.7h5.5v-1.5H38V7.4h5.7V5.9h-7.3v10h7.3v-1.6zM93 10.3l-2.7-4.4h-1.9V6l3.8 5.8v4.1h1.6v-4.1l4-5.8v-.1h-2zM69.3 5.9h-3.8v10h3.8c3.5 0 5.1-2.5 5-5.1-.1-2.5-1.6-4.9-5-4.9zm0 8.4h-2.2V7.4h2.2c2.3 0 3.4 1.7 3.4 3.4s-1 3.5-3.4 3.5zM86.3 10.7c.9-.4 1.4-1.1 1.4-2 0-2-1.5-2.8-3.4-2.8h-4.6v10h4.6c2 0 3.7-.7 3.7-2.8 0-.8-.5-2-1.7-2.4zm-5-3.4h3c1.2 0 1.8.7 1.8 1.4 0 .8-.6 1.3-1.8 1.3h-3V7.3zm3 7.1h-3v-2.9h3c.9 0 2.1.5 2.1 1.6 0 1-1.2 1.3-2.1 1.3zM113.9 13.3h5.3V16c-1.2.9-2.9 1.1-4 1.1-4.2 0-5.6-3.3-5.6-6 0-4.1 2.2-6.1 5.6-6.1 1.4 0 3.2.4 4.8 1.8l3.4-3.4C120.7.6 118.1 0 115.2 0c-7.8 0-11.4 5.6-11.4 11s3.1 10.9 11.4 10.9c4 0 7.6-1.4 8.9-4.1V8.6h-10.2v4.7zM171.9 8.5h-7.4V.6h-5.9v20.8h5.9v-7.8h7.4v7.8h5.9V.6h-5.9zM195.1.6l-4.5 7.1-4.3-7.1h-6.6v.2l7.9 12.3v8.3h5.9v-8.3L201.8.9V.6zM127.4.6h5.9v20.8h-5.9zM147.6.6h-10.1v20.8h5.9v-5.6h4.2c5.6-.1 8.3-3.4 8.3-7.6.1-4.1-2.7-7.6-8.3-7.6zm0 10.2h-4.2V5.6h4.2c1.6 0 2.5 1.2 2.5 2.6 0 1.4-.9 2.6-2.5 2.6z" />
+ </SVG>
+ </PanelBody>
+ </InspectorControls>
+ { ! giphyUrl ? (
+ <Placeholder className="wp-block-jetpack-gif_placeholder" icon={ icon } label={ title }>
+ { inputFields }
+ </Placeholder>
+ ) : (
+ <figure>
+ { isSelected && inputFields }
+ { isSelected && results && results.length > 1 && (
+ <div className="wp-block-jetpack-gif_thumbnails-container">
+ { results.map( thumbnail => {
+ const thumbnailStyle = {
+ backgroundImage: `url(${ thumbnail.images.downsized_still.url })`,
+ };
+ return (
+ <button
+ className="wp-block-jetpack-gif_thumbnail-container"
+ key={ thumbnail.id }
+ onClick={ () => {
+ this.thumbnailClicked( thumbnail );
+ } }
+ style={ thumbnailStyle }
+ />
+ );
+ } ) }
+ </div>
+ ) }
+ <div className="wp-block-jetpack-gif-wrapper" style={ style }>
+ <div
+ className="wp-block-jetpack-gif_cover"
+ onClick={ this.setFocus }
+ onKeyDown={ this.setFocus }
+ role="button"
+ tabIndex="0"
+ />
+ <iframe src={ giphyUrl } title={ searchText } />
+ </div>
+ { ( ! RichText.isEmpty( caption ) || isSelected ) && !! giphyUrl && (
+ <RichText
+ className="wp-block-jetpack-gif-caption gallery-caption"
+ inlineToolbar
+ isSelected={ captionFocus }
+ unstableOnFocus={ () => {
+ this.setState( { captionFocus: true } );
+ } }
+ onChange={ value => setAttributes( { caption: value } ) }
+ placeholder={ __( 'Write caption…', 'jetpack' ) }
+ tagName="figcaption"
+ value={ caption }
+ />
+ ) }
+ </figure>
+ ) }
+ </div>
+ );
+ }
+}
+export default GifEdit;
diff --git a/plugins/jetpack/extensions/blocks/gif/editor.js b/plugins/jetpack/extensions/blocks/gif/editor.js
new file mode 100644
index 00000000..d05f4039
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/gif/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/plugins/jetpack/extensions/blocks/gif/editor.scss b/plugins/jetpack/extensions/blocks/gif/editor.scss
new file mode 100644
index 00000000..2f1bd55c
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/gif/editor.scss
@@ -0,0 +1,86 @@
+@import '../../shared/styles/gutenberg-colors.scss';
+
+.wp-block-jetpack-gif {
+ figure {
+ transition: padding-top 125ms ease-in-out;
+ }
+ .components-base-control__field {
+ text-align: center;
+ }
+ .wp-block-jetpack-gif_cover {
+ background: none;
+ border: none;
+ height: 100%;
+ left: 0;
+ margin: 0;
+ padding: 0;
+ position: absolute;
+ top: 0;
+ width: 100%;
+ z-index: 1;
+ &:focus {
+ outline: none;
+ }
+ }
+ .wp-block-jetpack-gif_input-container {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: center;
+ margin: 0 auto;
+ max-width: 400px;
+ width: 100%;
+ z-index: 1;
+ .components-base-control__label {
+ height: 0;
+ margin: 0;
+ text-indent: -9999px;
+ }
+ }
+ .wp-block-jetpack-gif_input {
+ flex-grow: 1;
+ margin-right: 0.5em;
+ }
+ .wp-block-jetpack-gif_thumbnails-container {
+ display: flex;
+ margin: -2px 0 2px 0;
+ margin-left: calc( -4px / 2 );
+ overflow-x: auto;
+ width: calc( 100% + 4px );
+ &::-webkit-scrollbar {
+ display: none;
+ }
+ }
+ .wp-block-jetpack-gif_thumbnail-container {
+ align-items: center;
+ background-size: cover;
+ background-repeat: no-repeat;
+ background-position: 50% 50%;
+ border: none;
+ border-radius: 3px;
+ cursor: pointer;
+ display: flex;
+ justify-content: center;
+ margin: 2px;
+ padding: 0;
+ padding-bottom: calc( 100% / 10 - 4px );
+ width: calc( 100% / 10 - 4px );
+ &:hover {
+ box-shadow: 0 0 0 1px $dark-gray-500;
+ }
+ &:focus {
+ box-shadow: 0 0 0 2px $blue-medium-500;
+ outline: 0;
+ }
+ }
+}
+.components-panel__body-gif-branding {
+ svg {
+ display: block;
+ margin: 0 auto;
+ max-width: 200px;
+ }
+ svg path {
+ fill: $dark-gray-150;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/gif/gif.php b/plugins/jetpack/extensions/blocks/gif/gif.php
new file mode 100644
index 00000000..cb35f3da
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/gif/gif.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * GIF Block.
+ *
+ * @since 7.0.0
+ *
+ * @package Jetpack
+ */
+
+jetpack_register_block(
+ 'jetpack/gif',
+ array(
+ 'render_callback' => 'jetpack_gif_block_render',
+ )
+);
+
+/**
+ * Gif block registration/dependency declaration.
+ *
+ * @param array $attr - Array containing the gif block attributes.
+ *
+ * @return string
+ */
+function jetpack_gif_block_render( $attr ) {
+ $padding_top = isset( $attr['paddingTop'] ) ? $attr['paddingTop'] : 0;
+ $style = 'padding-top:' . $padding_top;
+ $giphy_url = isset( $attr['giphyUrl'] ) ? $attr['giphyUrl'] : null;
+ $search_text = isset( $attr['searchText'] ) ? $attr['searchText'] : '';
+ $caption = isset( $attr['caption'] ) ? $attr['caption'] : null;
+
+ if ( ! $giphy_url ) {
+ return null;
+ }
+
+ /* TODO: replace with centralized block_class function */
+ $align = isset( $attr['align'] ) ? $attr['align'] : 'center';
+ $type = 'gif';
+ $classes = array(
+ 'wp-block-jetpack-' . $type,
+ 'align' . $align,
+ );
+ if ( isset( $attr['className'] ) ) {
+ array_push( $classes, $attr['className'] );
+ }
+ $classes = implode( $classes, ' ' );
+
+ ob_start();
+ ?>
+ <div class="<?php echo esc_attr( $classes ); ?>">
+ <figure>
+ <div class="wp-block-jetpack-gif-wrapper" style="<?php echo esc_attr( $style ); ?>">
+ <iframe src="<?php echo esc_url( $giphy_url ); ?>"
+ title="<?php echo esc_attr( $search_text ); ?>"></iframe>
+ </div>
+ <?php if ( $caption ) : ?>
+ <figcaption
+ class="wp-block-jetpack-gif-caption gallery-caption"><?php echo wp_kses_post( $caption ); ?></figcaption>
+ <?php endif; ?>
+ </figure>
+ </div>
+ <?php
+ $html = ob_get_clean();
+
+ Jetpack_Gutenberg::load_assets_as_required( 'gif' );
+
+ return $html;
+}
diff --git a/plugins/jetpack/extensions/blocks/gif/index.js b/plugins/jetpack/extensions/blocks/gif/index.js
new file mode 100644
index 00000000..54ed026c
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/gif/index.js
@@ -0,0 +1,61 @@
+/**
+ * External dependencies
+ */
+import { __, _x } from '@wordpress/i18n';
+import { Path, SVG } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import edit from './edit';
+
+// Ordering is important! Editor overrides style!
+import './style.scss';
+import './editor.scss';
+
+export const name = 'gif';
+export const title = __( 'GIF', 'jetpack' );
+
+export const icon = (
+ <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <Path fill="none" d="M0 0h24v24H0V0z" />
+ <Path d="M18 13v7H4V6h5.02c.05-.71.22-1.38.48-2H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-5l-2-2zm-1.5 5h-11l2.75-3.53 1.96 2.36 2.75-3.54L16.5 18zm2.8-9.11c.44-.7.7-1.51.7-2.39C20 4.01 17.99 2 15.5 2S11 4.01 11 6.5s2.01 4.5 4.49 4.5c.88 0 1.7-.26 2.39-.7L21 13.42 22.42 12 19.3 8.89zM15.5 9C14.12 9 13 7.88 13 6.5S14.12 4 15.5 4 18 5.12 18 6.5 16.88 9 15.5 9z" />
+ </SVG>
+);
+
+export const settings = {
+ title,
+ icon,
+ category: 'jetpack',
+ keywords: [
+ _x( 'animated', 'block search term', 'jetpack' ),
+ _x( 'giphy', 'block search term', 'jetpack' ),
+ _x( 'image', 'block search term', 'jetpack' ),
+ ],
+ description: __( 'Search for and insert an animated image.', 'jetpack' ),
+ attributes: {
+ align: {
+ type: 'string',
+ default: 'center',
+ },
+ caption: {
+ type: 'string',
+ },
+ giphyUrl: {
+ type: 'string',
+ },
+ searchText: {
+ type: 'string',
+ },
+ paddingTop: {
+ type: 'string',
+ default: '56.2%',
+ },
+ },
+ supports: {
+ html: false,
+ align: true,
+ },
+ edit,
+ save: () => null,
+};
diff --git a/plugins/jetpack/extensions/blocks/gif/style.scss b/plugins/jetpack/extensions/blocks/gif/style.scss
new file mode 100644
index 00000000..5dc188fa
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/gif/style.scss
@@ -0,0 +1,40 @@
+@import '../../shared/styles/gutenberg-colors.scss';
+
+.wp-block-jetpack-gif {
+ clear: both;
+ margin: 0 0 20px;
+ figure {
+ margin: 0;
+ position: relative;
+ width: 100%;
+ }
+ iframe {
+ border: 0;
+ left: 0;
+ height: 100%;
+ position: absolute;
+ top: 0;
+ width: 100%;
+ }
+ &.aligncenter {
+ text-align: center;
+ }
+ &.alignright,
+ &.alignleft {
+ min-width: 300px;
+ }
+ // Mirroring Gutenberg caption-style mixin: https://github.com/WordPress/gutenberg/blob/master/assets/stylesheets/_mixins.scss#L312-L318
+ .wp-block-jetpack-gif-caption {
+ margin-top: 0.5em;
+ margin-bottom: 1em;
+ color: $dark-gray-500;
+ text-align: center;
+ }
+ .wp-block-jetpack-gif-wrapper {
+ height: 0;
+ margin: 0;
+ padding: calc( 56.2% + 12px ) 0 0 0;
+ position: relative;
+ width: 100%;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/gif/view.js b/plugins/jetpack/extensions/blocks/gif/view.js
new file mode 100644
index 00000000..6a6dda31
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/gif/view.js
@@ -0,0 +1,4 @@
+/**
+ * Internal dependencies
+ */
+import './style.scss';
diff --git a/plugins/jetpack/extensions/blocks/likes/editor.js b/plugins/jetpack/extensions/blocks/likes/editor.js
new file mode 100644
index 00000000..9adee220
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/likes/editor.js
@@ -0,0 +1,7 @@
+/**
+ * Internal dependencies
+ */
+import { name, settings } from '.';
+import registerJetpackPlugin from '../../shared/register-jetpack-plugin';
+
+registerJetpackPlugin( name, settings );
diff --git a/plugins/jetpack/extensions/blocks/likes/index.js b/plugins/jetpack/extensions/blocks/likes/index.js
new file mode 100644
index 00000000..693fbdca
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/likes/index.js
@@ -0,0 +1,8 @@
+/**
+ * Internal dependencies
+ */
+import LikesCheckbox from './likes-checkbox';
+
+export const name = 'likes';
+
+export const settings = { render: LikesCheckbox };
diff --git a/plugins/jetpack/extensions/blocks/likes/likes-checkbox.js b/plugins/jetpack/extensions/blocks/likes/likes-checkbox.js
new file mode 100644
index 00000000..f8bf0736
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/likes/likes-checkbox.js
@@ -0,0 +1,45 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { CheckboxControl } from '@wordpress/components';
+import { compose } from '@wordpress/compose';
+import { PostTypeSupportCheck } from '@wordpress/editor';
+import { withDispatch, withSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import JetpackLikesAndSharingPanel from '../../shared/jetpack-likes-and-sharing-panel';
+
+const LikesCheckbox = ( { areLikesEnabled, editPost } ) => (
+ <PostTypeSupportCheck supportKeys="jetpack-post-likes">
+ <JetpackLikesAndSharingPanel>
+ <CheckboxControl
+ label={ __( 'Show likes.', 'jetpack' ) }
+ checked={ areLikesEnabled }
+ onChange={ value => {
+ editPost( { jetpack_likes_enabled: value } );
+ } }
+ />
+ </JetpackLikesAndSharingPanel>
+ </PostTypeSupportCheck>
+);
+
+// Fetch the post meta.
+const applyWithSelect = withSelect( select => {
+ const { getEditedPostAttribute } = select( 'core/editor' );
+ const areLikesEnabled = getEditedPostAttribute( 'jetpack_likes_enabled' );
+
+ return { areLikesEnabled };
+} );
+
+// Provide method to update post meta.
+const applyWithDispatch = withDispatch( dispatch => {
+ const { editPost } = dispatch( 'core/editor' );
+
+ return { editPost };
+} );
+
+// Combine the higher-order components.
+export default compose( [ applyWithSelect, applyWithDispatch ] )( LikesCheckbox );
diff --git a/plugins/jetpack/extensions/blocks/mailchimp/edit.js b/plugins/jetpack/extensions/blocks/mailchimp/edit.js
new file mode 100644
index 00000000..88f2a84f
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/mailchimp/edit.js
@@ -0,0 +1,235 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+import classnames from 'classnames';
+import SubmitButton from '../../shared/submit-button';
+import { __ } from '@wordpress/i18n';
+import {
+ Button,
+ ExternalLink,
+ PanelBody,
+ Placeholder,
+ Spinner,
+ TextControl,
+ withNotices,
+} from '@wordpress/components';
+import { InspectorControls, RichText } from '@wordpress/editor';
+import { Fragment, Component } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { icon } from '.';
+
+const API_STATE_LOADING = 0;
+const API_STATE_CONNECTED = 1;
+const API_STATE_NOTCONNECTED = 2;
+
+const NOTIFICATION_PROCESSING = 'processing';
+const NOTIFICATION_SUCCESS = 'success';
+const NOTIFICATION_ERROR = 'error';
+
+class MailchimpSubscribeEdit extends Component {
+ constructor() {
+ super( ...arguments );
+ this.state = {
+ audition: null,
+ connected: API_STATE_LOADING,
+ connectURL: null,
+ };
+ this.timeout = null;
+ }
+
+ componentDidMount = () => {
+ this.apiCall();
+ };
+
+ onError = message => {
+ const { noticeOperations } = this.props;
+ noticeOperations.removeAllNotices();
+ noticeOperations.createErrorNotice( message );
+ };
+
+ apiCall = () => {
+ const path = '/wpcom/v2/mailchimp';
+ const method = 'GET';
+ const fetch = { path, method };
+ apiFetch( fetch ).then(
+ result => {
+ const connectURL = result.connect_url;
+ const connected =
+ result.code === 'connected' ? API_STATE_CONNECTED : API_STATE_NOTCONNECTED;
+ this.setState( { connected, connectURL } );
+ },
+ result => {
+ const connectURL = null;
+ const connected = API_STATE_NOTCONNECTED;
+ this.setState( { connected, connectURL } );
+ this.onError( result.message );
+ }
+ );
+ };
+
+ auditionNotification = notification => {
+ this.setState( { audition: notification } );
+ if ( this.timeout ) {
+ clearTimeout( this.timeout );
+ }
+ this.timeout = setTimeout( this.clearAudition, 3000 );
+ };
+
+ clearAudition = () => {
+ this.setState( { audition: null } );
+ };
+
+ updateProcessingText = processingLabel => {
+ const { setAttributes } = this.props;
+ setAttributes( { processingLabel } );
+ this.auditionNotification( NOTIFICATION_PROCESSING );
+ };
+
+ updateSuccessText = successLabel => {
+ const { setAttributes } = this.props;
+ setAttributes( { successLabel } );
+ this.auditionNotification( NOTIFICATION_SUCCESS );
+ };
+
+ updateErrorText = errorLabel => {
+ const { setAttributes } = this.props;
+ setAttributes( { errorLabel } );
+ this.auditionNotification( NOTIFICATION_ERROR );
+ };
+
+ updateEmailPlaceholder = emailPlaceholder => {
+ const { setAttributes } = this.props;
+ setAttributes( { emailPlaceholder } );
+ this.clearAudition();
+ };
+
+ labelForAuditionType = audition => {
+ const { attributes } = this.props;
+ const { processingLabel, successLabel, errorLabel } = attributes;
+ if ( audition === NOTIFICATION_PROCESSING ) {
+ return processingLabel;
+ } else if ( audition === NOTIFICATION_SUCCESS ) {
+ return successLabel;
+ } else if ( audition === NOTIFICATION_ERROR ) {
+ return errorLabel;
+ }
+ return null;
+ };
+
+ roleForAuditionType = audition => {
+ if ( audition === NOTIFICATION_ERROR ) {
+ return 'alert';
+ }
+ return 'status';
+ };
+
+ render = () => {
+ const { attributes, className, notices, noticeUI, setAttributes } = this.props;
+ const { audition, connected, connectURL } = this.state;
+ const { emailPlaceholder, consentText, processingLabel, successLabel, errorLabel } = attributes;
+ const classPrefix = 'wp-block-jetpack-mailchimp_';
+ const waiting = (
+ <Placeholder icon={ icon } notices={ notices }>
+ <Spinner />
+ </Placeholder>
+ );
+ const placeholder = (
+ <Placeholder icon={ icon } label={ __( 'Mailchimp', 'jetpack' ) } notices={ notices }>
+ <div className="components-placeholder__instructions">
+ { __(
+ 'You need to connect your Mailchimp account and choose a list in order to start collecting Email subscribers.',
+ 'jetpack'
+ ) }
+ <br />
+ <br />
+ <Button isDefault isLarge href={ connectURL } target="_blank">
+ { __( 'Set up Mailchimp form', 'jetpack' ) }
+ </Button>
+ <br />
+ <br />
+ <Button isLink onClick={ this.apiCall }>
+ { __( 'Re-check Connection', 'jetpack' ) }
+ </Button>
+ </div>
+ </Placeholder>
+ );
+ const inspectorControls = (
+ <InspectorControls>
+ <PanelBody title={ __( 'Text Elements', 'jetpack' ) }>
+ <TextControl
+ label={ __( 'Email Placeholder', 'jetpack' ) }
+ value={ emailPlaceholder }
+ onChange={ this.updateEmailPlaceholder }
+ />
+ </PanelBody>
+ <PanelBody title={ __( 'Notifications', 'jetpack' ) }>
+ <TextControl
+ label={ __( 'Processing text', 'jetpack' ) }
+ value={ processingLabel }
+ onChange={ this.updateProcessingText }
+ />
+ <TextControl
+ label={ __( 'Success text', 'jetpack' ) }
+ value={ successLabel }
+ onChange={ this.updateSuccessText }
+ />
+ <TextControl
+ label={ __( 'Error text', 'jetpack' ) }
+ value={ errorLabel }
+ onChange={ this.updateErrorText }
+ />
+ </PanelBody>
+ <PanelBody title={ __( 'Mailchimp Connection', 'jetpack' ) }>
+ <ExternalLink href={ connectURL }>{ __( 'Manage Connection', 'jetpack' ) }</ExternalLink>
+ </PanelBody>
+ </InspectorControls>
+ );
+ const blockClasses = classnames( className, {
+ [ `${ classPrefix }notication-audition` ]: audition,
+ } );
+ const blockContent = (
+ <div className={ blockClasses }>
+ <TextControl
+ aria-label={ emailPlaceholder }
+ className="wp-block-jetpack-mailchimp_text-input"
+ disabled
+ onChange={ () => false }
+ placeholder={ emailPlaceholder }
+ title={ __( 'You can edit the email placeholder in the sidebar.', 'jetpack' ) }
+ type="email"
+ />
+ <SubmitButton { ...this.props } />
+ <RichText
+ tagName="p"
+ placeholder={ __( 'Write consent text', 'jetpack' ) }
+ value={ consentText }
+ onChange={ value => setAttributes( { consentText: value } ) }
+ inlineToolbar
+ />
+ { audition && (
+ <div
+ className={ `${ classPrefix }notification ${ classPrefix }${ audition }` }
+ role={ this.roleForAuditionType( audition ) }
+ >
+ { this.labelForAuditionType( audition ) }
+ </div>
+ ) }
+ </div>
+ );
+ return (
+ <Fragment>
+ { noticeUI }
+ { connected === API_STATE_LOADING && waiting }
+ { connected === API_STATE_NOTCONNECTED && placeholder }
+ { connected === API_STATE_CONNECTED && inspectorControls }
+ { connected === API_STATE_CONNECTED && blockContent }
+ </Fragment>
+ );
+ };
+}
+
+export default withNotices( MailchimpSubscribeEdit );
diff --git a/plugins/jetpack/extensions/blocks/mailchimp/editor.js b/plugins/jetpack/extensions/blocks/mailchimp/editor.js
new file mode 100644
index 00000000..d05f4039
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/mailchimp/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/plugins/jetpack/extensions/blocks/mailchimp/editor.scss b/plugins/jetpack/extensions/blocks/mailchimp/editor.scss
new file mode 100644
index 00000000..84de7548
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/mailchimp/editor.scss
@@ -0,0 +1,29 @@
+@import './view.scss';
+
+.wp-block-jetpack-mailchimp {
+
+ .wp-block-jetpack-mailchimp_notification {
+ display: block;
+ }
+
+ .editor-rich-text__inline-toolbar {
+ pointer-events: none;
+ .components-toolbar {
+ pointer-events: all;
+ }
+ }
+
+ // Hide everything else except notification when modifying notification labels
+ &.wp-block-jetpack-mailchimp_notication-audition > *:not( .wp-block-jetpack-mailchimp_notification ) {
+ display: none;
+ }
+
+ .wp-block-jetpack-mailchimp_text-input, .jetpack-submit-button {
+ margin-bottom: 1.5rem;
+ }
+
+ .wp-block-button .wp-block-button__link {
+ margin-top: 0;
+ }
+
+}
diff --git a/plugins/jetpack/extensions/blocks/mailchimp/index.js b/plugins/jetpack/extensions/blocks/mailchimp/index.js
new file mode 100644
index 00000000..32412656
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/mailchimp/index.js
@@ -0,0 +1,72 @@
+/**
+ * External dependencies
+ */
+import { __, _x } from '@wordpress/i18n';
+import { Path, SVG } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import edit from './edit';
+import './editor.scss';
+
+export const name = 'mailchimp';
+
+export const icon = (
+ <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <Path fill="none" d="M0 0h24v24H0V0z" />
+ <Path d="M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6zm-2 0l-8 5-8-5h16zm0 12H4V8l8 5 8-5v10z" />
+ </SVG>
+);
+
+export const settings = {
+ title: __( 'Mailchimp', 'jetpack' ),
+ icon,
+ description: __( 'A form enabling readers to join a Mailchimp list.', 'jetpack' ),
+ category: 'jetpack',
+ keywords: [
+ _x( 'email', 'block search term', 'jetpack' ),
+ _x( 'subscription', 'block search term', 'jetpack' ),
+ _x( 'newsletter', 'block search term', 'jetpack' ),
+ ],
+ attributes: {
+ emailPlaceholder: {
+ type: 'string',
+ default: __( 'Enter your email', 'jetpack' ),
+ },
+ submitButtonText: {
+ type: 'string',
+ default: __( 'Join my email list', 'jetpack' ),
+ },
+ customBackgroundButtonColor: {
+ type: 'string',
+ },
+ customTextButtonColor: {
+ type: 'string',
+ },
+ consentText: {
+ type: 'string',
+ default: __(
+ 'By clicking submit, you agree to share your email address with the site owner and Mailchimp to receive marketing, updates, and other emails from the site owner. Use the unsubscribe link in those emails to opt out at any time.',
+ 'jetpack'
+ ),
+ },
+ processingLabel: {
+ type: 'string',
+ default: __( 'Processing…', 'jetpack' ),
+ },
+ successLabel: {
+ type: 'string',
+ default: __( "Success! You're on the list.", 'jetpack' ),
+ },
+ errorLabel: {
+ type: 'string',
+ default: __(
+ "Whoops! There was an error and we couldn't process your subscription. Please reload the page and try again.",
+ 'jetpack'
+ ),
+ },
+ },
+ edit,
+ save: () => null,
+};
diff --git a/plugins/jetpack/extensions/blocks/mailchimp/mailchimp.php b/plugins/jetpack/extensions/blocks/mailchimp/mailchimp.php
new file mode 100644
index 00000000..7be7823a
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/mailchimp/mailchimp.php
@@ -0,0 +1,137 @@
+<?php
+/**
+ * Mailchimp Block.
+ *
+ * @since 7.1.0
+ *
+ * @package Jetpack
+ */
+
+if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) || Jetpack::is_active() ) {
+ jetpack_register_block(
+ 'jetpack/mailchimp',
+ array(
+ 'render_callback' => 'jetpack_mailchimp_block_load_assets',
+ )
+ );
+}
+
+/**
+ * Mailchimp block registration/dependency declaration.
+ *
+ * @param array $attr - Array containing the map block attributes.
+ *
+ * @return string
+ */
+function jetpack_mailchimp_block_load_assets( $attr ) {
+
+ if ( ! jetpack_mailchimp_verify_connection() ) {
+ return null;
+ }
+ $values = array();
+ $blog_id = ( defined( 'IS_WPCOM' ) && IS_WPCOM )
+ ? get_current_blog_id()
+ : Jetpack_Options::get_option( 'id' );
+ Jetpack_Gutenberg::load_assets_as_required( 'mailchimp' );
+ $defaults = array(
+ 'emailPlaceholder' => esc_html__( 'Enter your email', 'jetpack' ),
+ 'submitButtonText' => esc_html__( 'Join my email list', 'jetpack' ),
+ 'consentText' => esc_html__( 'By clicking submit, you agree to share your email address with the site owner and Mailchimp to receive marketing, updates, and other emails from the site owner. Use the unsubscribe link in those emails to opt out at any time.', 'jetpack' ),
+ 'processingLabel' => esc_html__( 'Processing…', 'jetpack' ),
+ 'successLabel' => esc_html__( 'Success! You\'re on the list.', 'jetpack' ),
+ 'errorLabel' => esc_html__( 'Whoops! There was an error and we couldn\'t process your subscription. Please reload the page and try again.', 'jetpack' ),
+ );
+ foreach ( $defaults as $id => $default ) {
+ $values[ $id ] = isset( $attr[ $id ] ) ? $attr[ $id ] : $default;
+ }
+
+ $values['submitButtonText'] = empty( $values['submitButtonText'] ) ? $defaults['submitButtonText'] : $values['submitButtonText'];
+
+ /* TODO: replace with centralized block_class function */
+ $align = isset( $attr['align'] ) ? $attr['align'] : 'center';
+ $type = 'mailchimp';
+ $classes = array(
+ 'wp-block-jetpack-' . $type,
+ 'align' . $align,
+ );
+ if ( isset( $attr['className'] ) ) {
+ array_push( $classes, $attr['className'] );
+ }
+ $classes = implode( $classes, ' ' );
+
+ $button_styles = array();
+ if ( ! empty( $attr['customBackgroundButtonColor'] ) ) {
+ array_push(
+ $button_styles,
+ sprintf(
+ 'background-color: %s',
+ sanitize_hex_color( $attr['customBackgroundButtonColor'] )
+ )
+ );
+ }
+ if ( ! empty( $attr['customTextButtonColor'] ) ) {
+ array_push(
+ $button_styles,
+ sprintf(
+ 'color: %s',
+ sanitize_hex_color( $attr['customTextButtonColor'] )
+ )
+ );
+ }
+ $button_styles = implode( $button_styles, ';' );
+
+ ob_start();
+ ?>
+ <div class="<?php echo esc_attr( $classes ); ?>" data-blog-id="<?php echo esc_attr( $blog_id ); ?>">
+ <div class="components-placeholder">
+ <form aria-describedby="wp-block-jetpack-mailchimp_consent-text">
+ <p>
+ <input
+ aria-label="<?php echo esc_attr( $values['emailPlaceholder'] ); ?>"
+ placeholder="<?php echo esc_attr( $values['emailPlaceholder'] ); ?>"
+ required
+ title="<?php echo esc_attr( $values['emailPlaceholder'] ); ?>"
+ type="email"
+ />
+ </p>
+ <p>
+ <button type="submit" class="components-button is-button is-primary" style="<?php echo esc_attr( $button_styles ); ?>">
+ <?php echo wp_kses_post( $values['submitButtonText'] ); ?>
+ </button>
+ </p>
+ <p id="wp-block-jetpack-mailchimp_consent-text" name="wp-block-jetpack-mailchimp_consent-text">
+ <?php echo wp_kses_post( $values['consentText'] ); ?>
+ </p>
+ </form>
+ <div class="wp-block-jetpack-mailchimp_notification wp-block-jetpack-mailchimp_processing" role="status">
+ <?php echo esc_html( $values['processingLabel'] ); ?>
+ </div>
+ <div class="wp-block-jetpack-mailchimp_notification wp-block-jetpack-mailchimp_success" role="status">
+ <?php echo esc_html( $values['successLabel'] ); ?>
+ </div>
+ <div class="wp-block-jetpack-mailchimp_notification wp-block-jetpack-mailchimp_error" role="alert">
+ <?php echo esc_html( $values['errorLabel'] ); ?>
+ </div>
+ </div>
+ </div>
+ <?php
+ $html = ob_get_clean();
+ return $html;
+}
+
+/**
+ * Mailchimp connection/list selection verification.
+ *
+ * @return boolean
+ */
+function jetpack_mailchimp_verify_connection() {
+ $option = get_option( 'jetpack_mailchimp' );
+ if ( ! $option ) {
+ return false;
+ }
+ $data = json_decode( $option, true );
+ if ( ! $data ) {
+ return false;
+ }
+ return isset( $data['follower_list_id'], $data['keyring_id'] );
+}
diff --git a/plugins/jetpack/extensions/blocks/mailchimp/view.js b/plugins/jetpack/extensions/blocks/mailchimp/view.js
new file mode 100644
index 00000000..a2ec7680
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/mailchimp/view.js
@@ -0,0 +1,91 @@
+/**
+ * Internal dependencies
+ */
+import emailValidator from 'email-validator';
+
+/**
+ * Internal dependencies
+ */
+import './view.scss';
+
+const blockClassName = 'wp-block-jetpack-mailchimp';
+
+function fetchSubscription( blogId, email ) {
+ const url =
+ 'https://public-api.wordpress.com/rest/v1.1/sites/' +
+ encodeURIComponent( blogId ) +
+ '/email_follow/subscribe?email=' +
+ encodeURIComponent( email );
+ return new Promise( function( resolve, reject ) {
+ const xhr = new XMLHttpRequest();
+ xhr.open( 'GET', url );
+ xhr.onload = function() {
+ if ( xhr.status === 200 ) {
+ const res = JSON.parse( xhr.responseText );
+ resolve( res );
+ } else {
+ const res = JSON.parse( xhr.responseText );
+ reject( res );
+ }
+ };
+ xhr.send();
+ } );
+}
+
+function activateSubscription( block, blogId ) {
+ const form = block.querySelector( 'form' );
+ const errorClass = 'error';
+ const processingEl = block.querySelector( '.' + blockClassName + '_processing' );
+ const errorEl = block.querySelector( '.' + blockClassName + '_error' );
+ const successEl = block.querySelector( '.' + blockClassName + '_success' );
+ form.addEventListener( 'submit', e => {
+ e.preventDefault();
+ const emailField = form.querySelector( 'input' );
+ emailField.classList.remove( errorClass );
+ const email = emailField.value;
+ if ( ! emailValidator.validate( email ) ) {
+ emailField.classList.add( errorClass );
+ return;
+ }
+ block.classList.add( 'is-processing' );
+ processingEl.classList.add( 'is-visible' );
+ fetchSubscription( blogId, email ).then(
+ response => {
+ processingEl.classList.remove( 'is-visible' );
+ if ( response.error && response.error !== 'member_exists' ) {
+ errorEl.classList.add( 'is-visible' );
+ } else {
+ successEl.classList.add( 'is-visible' );
+ }
+ },
+ () => {
+ processingEl.classList.remove( 'is-visible' );
+ errorEl.classList.add( 'is-visible' );
+ }
+ );
+ } );
+}
+
+const initializeMailchimpBlocks = () => {
+ const mailchimpBlocks = Array.from( document.querySelectorAll( '.' + blockClassName ) );
+ mailchimpBlocks.forEach( block => {
+ const blog_id = block.getAttribute( 'data-blog-id' );
+ try {
+ activateSubscription( block, blog_id );
+ } catch ( err ) {
+ if ( 'production' !== process.env.NODE_ENV ) {
+ // eslint-disable-next-line no-console
+ console.error( err );
+ }
+ }
+ } );
+};
+
+if ( typeof window !== 'undefined' && typeof document !== 'undefined' ) {
+ // `DOMContentLoaded` may fire before the script has a chance to run
+ if ( document.readyState === 'loading' ) {
+ document.addEventListener( 'DOMContentLoaded', initializeMailchimpBlocks );
+ } else {
+ initializeMailchimpBlocks();
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/mailchimp/view.scss b/plugins/jetpack/extensions/blocks/mailchimp/view.scss
new file mode 100644
index 00000000..c01cec98
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/mailchimp/view.scss
@@ -0,0 +1,34 @@
+@import '../../shared/styles/gutenberg-colors.scss';
+
+.wp-block-jetpack-mailchimp {
+
+ &.is-processing {
+ form {
+ display: none;
+ }
+ }
+
+ .wp-block-jetpack-mailchimp_notification {
+ display: none;
+ margin-bottom: 1.5em;
+ padding: 0.75em;
+ &.is-visible {
+ display: block;
+ }
+
+ &.wp-block-jetpack-mailchimp_error {
+ background-color: var( --muriel-hot-red-500 );
+ color: var( --muriel-white );
+ }
+
+ &.wp-block-jetpack-mailchimp_processing {
+ background-color: rgba( 0, 0, 0, 0.025 ); // This would be "dark-opacity-light-50" which doesn't exist in Gutenberg
+ }
+
+ &.wp-block-jetpack-mailchimp_success {
+ background-color: var( --muriel-hot-green-500 );
+ color: var( --muriel-white );
+ }
+ }
+
+}
diff --git a/plugins/jetpack/extensions/blocks/map/add-point/index.js b/plugins/jetpack/extensions/blocks/map/add-point/index.js
new file mode 100644
index 00000000..a4c6e2e1
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/add-point/index.js
@@ -0,0 +1,42 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Component } from '@wordpress/element';
+import { Button, Dashicon, Popover } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import LocationSearch from '../location-search';
+
+export class AddPoint extends Component {
+ render() {
+ const { onClose, onAddPoint, onError, apiKey } = this.props;
+ return (
+ <Button className="component__add-point">
+ { __( 'Add marker', 'jetpack' ) }
+ <Popover className="component__add-point__popover">
+ <Button className="component__add-point__close" onClick={ onClose }>
+ <Dashicon icon="no" />
+ </Button>
+ <LocationSearch
+ onAddPoint={ onAddPoint }
+ label={ __( 'Add a location', 'jetpack' ) }
+ apiKey={ apiKey }
+ onError={ onError }
+ />
+ </Popover>
+ </Button>
+ );
+ }
+}
+
+AddPoint.defaultProps = {
+ onAddPoint: () => {},
+ onClose: () => {},
+ onError: () => {},
+};
+
+export default AddPoint;
diff --git a/plugins/jetpack/extensions/blocks/map/add-point/oval.svg b/plugins/jetpack/extensions/blocks/map/add-point/oval.svg
new file mode 100644
index 00000000..cb149ec4
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/add-point/oval.svg
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="32px" height="38px" viewBox="0 0 32 38" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <!-- Generator: Sketch 52.1 (67048) - http://www.bohemiancoding.com/sketch -->
+ <title>Oval Copy</title>
+ <desc>Created with Sketch.</desc>
+ <defs>
+ <path d="M119,136 C119,136 135,124.692424 135,114 C135,103.307576 127.836556,98 119,98 C110.163444,98 103,103.307576 103,114 C103,124.692424 119,136 119,136 Z" id="path-1"></path>
+ <mask id="mask-2" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="32" height="38" fill="white">
+ <use xlink:href="#path-1"></use>
+ </mask>
+ </defs>
+ <g id="Map-Block" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-dasharray="4">
+ <g id="Revised-01-Placeholder-Copy" transform="translate(-496.000000, -376.000000)" stroke="#444444" stroke-width="4">
+ <g id="Group" transform="translate(393.000000, 278.000000)">
+ <use id="Oval-Copy" mask="url(#mask-2)" xlink:href="#path-1"></use>
+ </g>
+ </g>
+ </g>
+</svg> \ No newline at end of file
diff --git a/plugins/jetpack/extensions/blocks/map/add-point/style.scss b/plugins/jetpack/extensions/blocks/map/add-point/style.scss
new file mode 100644
index 00000000..dba3c0c0
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/add-point/style.scss
@@ -0,0 +1,46 @@
+@import '../../../shared/styles/gutenberg-colors.scss';
+
+.component__add-point {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 32px;
+ height: 38px;
+ margin-top: -19px;
+ margin-left: -16px;
+ background-image: url( ./oval.svg );
+ background-repeat: no-repeat;
+ text-indent: -9999px;
+ box-shadow: none;
+ background-color: transparent;
+ &.components-button:not( :disabled ):not( [aria-disabled='true'] ):focus {
+ background-color: transparent;
+ box-shadow: none;
+ }
+ &:focus,
+ &:active {
+ background-color: transparent;
+ }
+}
+.component__add-point__popover {
+ .components-button:not( :disabled ):not( [aria-disabled='true'] ):focus {
+ background-color: transparent;
+ box-shadow: none;
+ }
+ .components-popover__content {
+ padding: 0.1rem;
+ }
+ .components-location-search {
+ margin: 0.5rem;
+ }
+}
+.component__add-point__close {
+ margin: 0;
+ padding: 0;
+ border: none;
+ box-shadow: none;
+ float: right;
+ path {
+ color: $dark-gray-150;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/map/component.js b/plugins/jetpack/extensions/blocks/map/component.js
new file mode 100644
index 00000000..c4ea55ae
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/component.js
@@ -0,0 +1,332 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { assign, debounce, get } from 'lodash';
+import { Button, Dashicon, TextareaControl, TextControl } from '@wordpress/components';
+import { Children, Component, createRef, Fragment } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import MapMarker from './map-marker/';
+import InfoWindow from './info-window/';
+import { mapboxMapFormatter } from './mapbox-map-formatter/';
+
+export class Map extends Component {
+ // Lifecycle
+ constructor() {
+ super( ...arguments );
+
+ this.state = {
+ map: null,
+ fit_to_bounds: false,
+ loaded: false,
+ mapboxgl: null,
+ };
+
+ // Refs
+ this.mapRef = createRef();
+
+ // Debouncers
+ this.debouncedSizeMap = debounce( this.sizeMap, 250 );
+ }
+ render() {
+ const { points, admin, children, markerColor } = this.props;
+ const { map, activeMarker, mapboxgl } = this.state;
+ const { onMarkerClick, deleteActiveMarker, updateActiveMarker } = this;
+ const currentPoint = get( activeMarker, 'props.point' ) || {};
+ const { title, caption } = currentPoint;
+ const addPoint = Children.map( children, child => {
+ const tagName = get( child, 'props.tagName' );
+ if ( 'AddPoint' === tagName ) {
+ return child;
+ }
+ } );
+ const mapMarkers =
+ map &&
+ mapboxgl &&
+ points.map( ( point, index ) => {
+ return (
+ <MapMarker
+ key={ index }
+ point={ point }
+ index={ index }
+ map={ map }
+ mapboxgl={ mapboxgl }
+ markerColor={ markerColor }
+ onClick={ onMarkerClick }
+ />
+ );
+ } );
+ const infoWindow = mapboxgl && (
+ <InfoWindow
+ activeMarker={ activeMarker }
+ map={ map }
+ mapboxgl={ mapboxgl }
+ unsetActiveMarker={ () => this.setState( { activeMarker: null } ) }
+ >
+ { activeMarker && admin && (
+ <Fragment>
+ <TextControl
+ label={ __( 'Marker Title', 'jetpack' ) }
+ value={ title }
+ onChange={ value => updateActiveMarker( { title: value } ) }
+ />
+ <TextareaControl
+ className="wp-block-jetpack-map__marker-caption"
+ label={ __( 'Marker Caption', 'jetpack' ) }
+ value={ caption }
+ rows="2"
+ tag="textarea"
+ onChange={ value => updateActiveMarker( { caption: value } ) }
+ />
+ <Button onClick={ deleteActiveMarker } className="wp-block-jetpack-map__delete-btn">
+ <Dashicon icon="trash" size="15" /> { __( 'Delete Marker', 'jetpack' ) }
+ </Button>
+ </Fragment>
+ ) }
+
+ { activeMarker && ! admin && (
+ <Fragment>
+ <h3>{ title }</h3>
+ <p>{ caption }</p>
+ </Fragment>
+ ) }
+ </InfoWindow>
+ );
+ return (
+ <Fragment>
+ <div className="wp-block-jetpack-map__gm-container" ref={ this.mapRef }>
+ { mapMarkers }
+ </div>
+ { infoWindow }
+ { addPoint }
+ </Fragment>
+ );
+ }
+ componentDidMount() {
+ const { apiKey } = this.props;
+ if ( apiKey ) {
+ this.loadMapLibraries();
+ }
+ }
+ componentWillUnmount() {
+ this.debouncedSizeMap.cancel();
+ }
+ componentDidUpdate( prevProps ) {
+ const { apiKey, children, points, mapStyle, mapDetails } = this.props;
+ const { map } = this.state;
+ if ( apiKey && apiKey.length > 0 && apiKey !== prevProps.apiKey ) {
+ this.loadMapLibraries();
+ }
+ // If the user has just clicked to show the Add Point component, hide info window.
+ // AddPoint is the only possible child.
+ if ( children !== prevProps.children && children !== false ) {
+ this.clearCurrentMarker();
+ }
+ if ( points !== prevProps.points ) {
+ this.setBoundsByMarkers();
+ }
+ if ( points.length !== prevProps.points.length ) {
+ this.clearCurrentMarker();
+ }
+ if ( mapStyle !== prevProps.mapStyle || mapDetails !== prevProps.mapDetails ) {
+ map.setStyle( this.getMapStyle() );
+ }
+ }
+ /* Event handling */
+ onMarkerClick = marker => {
+ const { onMarkerClick } = this.props;
+ this.setState( { activeMarker: marker } );
+ onMarkerClick();
+ };
+ onMapClick = () => {
+ this.setState( { activeMarker: null } );
+ };
+ clearCurrentMarker = () => {
+ this.setState( { activeMarker: null } );
+ };
+ updateActiveMarker = updates => {
+ const { points } = this.props;
+ const { activeMarker } = this.state;
+ const { index } = activeMarker.props;
+ const newPoints = points.slice( 0 );
+
+ assign( newPoints[ index ], updates );
+ this.props.onSetPoints( newPoints );
+ };
+ deleteActiveMarker = () => {
+ const { points } = this.props;
+ const { activeMarker } = this.state;
+ const { index } = activeMarker.props;
+ const newPoints = points.slice( 0 );
+
+ newPoints.splice( index, 1 );
+ this.props.onSetPoints( newPoints );
+ this.setState( { activeMarker: null } );
+ };
+ // Various map functions
+ sizeMap = () => {
+ const { map } = this.state;
+ const mapEl = this.mapRef.current;
+ const blockWidth = mapEl.offsetWidth;
+ const maxHeight = window.innerHeight * 0.8;
+ const blockHeight = Math.min( blockWidth * ( 3 / 4 ), maxHeight );
+ mapEl.style.height = blockHeight + 'px';
+ map.resize();
+ this.setBoundsByMarkers();
+ };
+ setBoundsByMarkers = () => {
+ const { zoom, points, onSetZoom } = this.props;
+ const { map, activeMarker, mapboxgl, zoomControl, boundsSetProgrammatically } = this.state;
+ if ( ! map ) {
+ return;
+ }
+ // If there are no points at all, there is no data to set bounds to. Abort the function.
+ if ( ! points.length ) {
+ return;
+ }
+ // If there is an open info window, resizing will probably move the info window which complicates interaction.
+ if ( activeMarker ) {
+ return;
+ }
+ const bounds = new mapboxgl.LngLatBounds();
+ points.forEach( point => {
+ bounds.extend( [ point.coordinates.longitude, point.coordinates.latitude ] );
+ } );
+
+ // If there are multiple points, zoom is determined by the area they cover, and zoom control is removed.
+ if ( points.length > 1 ) {
+ map.fitBounds( bounds, {
+ padding: {
+ top: 40,
+ bottom: 40,
+ left: 20,
+ right: 20,
+ },
+ } );
+ this.setState( { boundsSetProgrammatically: true } );
+ map.removeControl( zoomControl );
+ return;
+ }
+ // If there is only one point, center map around it.
+ map.setCenter( bounds.getCenter() );
+
+ // If the number of markers has just changed from > 1 to 1, set an arbitrary tight zoom, which feels like the original default.
+ if ( boundsSetProgrammatically ) {
+ const newZoom = 12;
+ map.setZoom( newZoom );
+ onSetZoom( newZoom );
+ } else {
+ // If there are one (or zero) points, and this is not a recent change, respect user's chosen zoom.
+ map.setZoom( parseInt( zoom, 10 ) );
+ }
+ map.addControl( zoomControl );
+ this.setState( { boundsSetProgrammatically: false } );
+ };
+ getMapStyle() {
+ const { mapStyle, mapDetails } = this.props;
+ return mapboxMapFormatter( mapStyle, mapDetails );
+ }
+ getMapType() {
+ const { mapStyle } = this.props;
+ switch ( mapStyle ) {
+ case 'satellite':
+ return 'HYBRID';
+ case 'terrain':
+ return 'TERRAIN';
+ case 'black_and_white':
+ default:
+ return 'ROADMAP';
+ }
+ }
+ // Script loading, browser geolocation
+ scriptsLoaded = () => {
+ const { mapCenter, points } = this.props;
+ this.setState( { loaded: true } );
+
+ // If the map has any points, skip geolocation and use what we have.
+ if ( points.length > 0 ) {
+ this.initMap( mapCenter );
+ return;
+ }
+ this.initMap( mapCenter );
+ };
+ loadMapLibraries() {
+ const { apiKey } = this.props;
+ Promise.all( [
+ import( /* webpackChunkName: "map/mapbox-gl" */ 'mapbox-gl' ),
+ import( /* webpackChunkName: "map/mapbox-gl" */ 'mapbox-gl/dist/mapbox-gl.css' ),
+ ] ).then( ( [ { default: mapboxgl } ] ) => {
+ mapboxgl.accessToken = apiKey;
+ this.setState( { mapboxgl: mapboxgl }, this.scriptsLoaded );
+ } );
+ }
+ initMap( mapCenter ) {
+ const { mapboxgl } = this.state;
+ const { zoom, onMapLoaded, onError, admin } = this.props;
+ let map = null;
+ try {
+ map = new mapboxgl.Map( {
+ container: this.mapRef.current,
+ style: this.getMapStyle(),
+ center: this.googlePoint2Mapbox( mapCenter ),
+ zoom: parseInt( zoom, 10 ),
+ pitchWithRotate: false,
+ attributionControl: false,
+ dragRotate: false,
+ } );
+ } catch ( e ) {
+ onError( 'mapbox_error', e.message );
+ return;
+ }
+ map.on( 'error', e => {
+ onError( 'mapbox_error', e.error.message );
+ } );
+ const zoomControl = new mapboxgl.NavigationControl( {
+ showCompass: false,
+ showZoom: true,
+ } );
+ map.on( 'zoomend', () => {
+ this.props.onSetZoom( map.getZoom() );
+ } );
+
+ /* Listen for clicks on the Map background, which hides the current popup. */
+ map.getCanvas().addEventListener( 'click', this.onMapClick );
+ this.setState( { map, zoomControl }, () => {
+ this.debouncedSizeMap();
+ map.addControl( zoomControl );
+ if ( ! admin ) {
+ map.addControl( new mapboxgl.FullscreenControl() );
+ }
+ this.mapRef.current.addEventListener( 'alignmentChanged', this.debouncedSizeMap );
+ map.resize();
+ onMapLoaded();
+ this.setState( { loaded: true } );
+ window.addEventListener( 'resize', this.debouncedSizeMap );
+ } );
+ }
+ googlePoint2Mapbox( google_point ) {
+ const mapCenter = [
+ google_point.longitude ? google_point.longitude : 0,
+ google_point.latitude ? google_point.latitude : 0,
+ ];
+ return mapCenter;
+ }
+}
+
+Map.defaultProps = {
+ points: [],
+ mapStyle: 'default',
+ zoom: 13,
+ onSetZoom: () => {},
+ onMapLoaded: () => {},
+ onMarkerClick: () => {},
+ onError: () => {},
+ markerColor: 'red',
+ apiKey: null,
+ mapCenter: {},
+};
+
+export default Map;
diff --git a/plugins/jetpack/extensions/blocks/map/edit.js b/plugins/jetpack/extensions/blocks/map/edit.js
new file mode 100644
index 00000000..87889f63
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/edit.js
@@ -0,0 +1,283 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+import { __ } from '@wordpress/i18n';
+import { Component, createRef, Fragment } from '@wordpress/element';
+import {
+ Button,
+ ButtonGroup,
+ ExternalLink,
+ IconButton,
+ PanelBody,
+ Placeholder,
+ Spinner,
+ TextControl,
+ ToggleControl,
+ Toolbar,
+ withNotices,
+} from '@wordpress/components';
+import {
+ BlockAlignmentToolbar,
+ BlockControls,
+ InspectorControls,
+ PanelColorSettings,
+} from '@wordpress/editor';
+
+/**
+ * Internal dependencies
+ */
+import AddPoint from './add-point';
+import Locations from './locations';
+import Map from './component.js';
+import MapThemePicker from './map-theme-picker';
+import { settings } from './settings.js';
+
+const API_STATE_LOADING = 0;
+const API_STATE_FAILURE = 1;
+const API_STATE_SUCCESS = 2;
+
+class MapEdit extends Component {
+ constructor() {
+ super( ...arguments );
+ this.state = {
+ addPointVisibility: false,
+ apiState: API_STATE_LOADING,
+ };
+ this.mapRef = createRef();
+ }
+ addPoint = point => {
+ const { attributes, setAttributes } = this.props;
+ const { points } = attributes;
+ const newPoints = points.slice( 0 );
+ let duplicateFound = false;
+ points.map( existingPoint => {
+ if ( existingPoint.id === point.id ) {
+ duplicateFound = true;
+ }
+ } );
+ if ( duplicateFound ) {
+ return;
+ }
+ newPoints.push( point );
+ setAttributes( { points: newPoints } );
+ this.setState( { addPointVisibility: false } );
+ };
+ updateAlignment = value => {
+ this.props.setAttributes( { align: value } );
+ // Allow one cycle for alignment change to take effect
+ setTimeout( this.mapRef.current.sizeMap, 0 );
+ };
+ updateAPIKeyControl = value => {
+ this.setState( {
+ apiKeyControl: value,
+ } );
+ };
+ updateAPIKey = () => {
+ const { noticeOperations } = this.props;
+ const { apiKeyControl } = this.state;
+ noticeOperations.removeAllNotices();
+ apiKeyControl && this.apiCall( apiKeyControl, 'POST' );
+ };
+ removeAPIKey = () => {
+ this.apiCall( null, 'DELETE' );
+ };
+ apiCall( serviceApiKey = null, method = 'GET' ) {
+ const { noticeOperations } = this.props;
+ const { apiKey } = this.state;
+ const path = '/wpcom/v2/service-api-keys/mapbox';
+ const fetch = serviceApiKey
+ ? { path, method, data: { service_api_key: serviceApiKey } }
+ : { path, method };
+ this.setState( { apiRequestOutstanding: true }, () => {
+ apiFetch( fetch ).then(
+ result => {
+ noticeOperations.removeAllNotices();
+ this.setState( {
+ apiState: result.service_api_key ? API_STATE_SUCCESS : API_STATE_FAILURE,
+ apiKey: result.service_api_key,
+ apiKeyControl: result.service_api_key,
+ apiRequestOutstanding: false,
+ } );
+ },
+ result => {
+ this.onError( null, result.message );
+ this.setState( {
+ apiRequestOutstanding: false,
+ apiKeyControl: apiKey,
+ } );
+ }
+ );
+ } );
+ }
+ componentDidMount() {
+ this.apiCall();
+ }
+ onError = ( code, message ) => {
+ const { noticeOperations } = this.props;
+ noticeOperations.removeAllNotices();
+ noticeOperations.createErrorNotice( message );
+ };
+ render() {
+ const { className, setAttributes, attributes, noticeUI, notices } = this.props;
+ const { mapStyle, mapDetails, points, zoom, mapCenter, markerColor, align } = attributes;
+ const {
+ addPointVisibility,
+ apiKey,
+ apiKeyControl,
+ apiState,
+ apiRequestOutstanding,
+ } = this.state;
+ const inspectorControls = (
+ <Fragment>
+ <BlockControls>
+ <BlockAlignmentToolbar
+ value={ align }
+ onChange={ this.updateAlignment }
+ controls={ [ 'center', 'wide', 'full' ] }
+ />
+ <Toolbar>
+ <IconButton
+ icon={ settings.markerIcon }
+ label="Add a marker"
+ onClick={ () => this.setState( { addPointVisibility: true } ) }
+ />
+ </Toolbar>
+ </BlockControls>
+ <InspectorControls>
+ <PanelBody title={ __( 'Map Theme', 'jetpack' ) }>
+ <MapThemePicker
+ value={ mapStyle }
+ onChange={ value => setAttributes( { mapStyle: value } ) }
+ options={ settings.mapStyleOptions }
+ />
+ <ToggleControl
+ label={ __( 'Show street names', 'jetpack' ) }
+ checked={ mapDetails }
+ onChange={ value => setAttributes( { mapDetails: value } ) }
+ />
+ </PanelBody>
+ <PanelColorSettings
+ title={ __( 'Colors', 'jetpack' ) }
+ initialOpen={ true }
+ colorSettings={ [
+ {
+ value: markerColor,
+ onChange: value => setAttributes( { markerColor: value } ),
+ label: 'Marker Color',
+ },
+ ] }
+ />
+ { points.length ? (
+ <PanelBody title={ __( 'Markers', 'jetpack' ) } initialOpen={ false }>
+ <Locations
+ points={ points }
+ onChange={ value => {
+ setAttributes( { points: value } );
+ } }
+ />
+ </PanelBody>
+ ) : null }
+ <PanelBody title={ __( 'Mapbox Access Token', 'jetpack' ) } initialOpen={ false }>
+ <TextControl
+ label={ __( 'Mapbox Access Token', 'jetpack' ) }
+ value={ apiKeyControl }
+ onChange={ value => this.setState( { apiKeyControl: value } ) }
+ />
+ <ButtonGroup>
+ <Button type="button" onClick={ this.updateAPIKey } isDefault>
+ { __( 'Update Token', 'jetpack' ) }
+ </Button>
+ <Button type="button" onClick={ this.removeAPIKey } isDefault>
+ { __( 'Remove Token', 'jetpack' ) }
+ </Button>
+ </ButtonGroup>
+ </PanelBody>
+ </InspectorControls>
+ </Fragment>
+ );
+ const placholderAPIStateLoading = (
+ <Placeholder icon={ settings.icon }>
+ <Spinner />
+ </Placeholder>
+ );
+ const placeholderAPIStateFailure = (
+ <Placeholder icon={ settings.icon } label={ __( 'Map', 'jetpack' ) } notices={ notices }>
+ <Fragment>
+ <div className="components-placeholder__instructions">
+ { __( 'To use the map block, you need an Access Token.', 'jetpack' ) }
+ <br />
+ <ExternalLink href="https://www.mapbox.com">
+ { __( 'Create an account or log in to Mapbox.', 'jetpack' ) }
+ </ExternalLink>
+ <br />
+ { __(
+ 'Locate and copy the default access token. Then, paste it into the field below.',
+ 'jetpack'
+ ) }
+ </div>
+ <TextControl
+ className="wp-block-jetpack-map-components-text-control-api-key"
+ disabled={ apiRequestOutstanding }
+ placeholder={ __( 'Paste Token Here', 'jetpack' ) }
+ value={ apiKeyControl }
+ onChange={ this.updateAPIKeyControl }
+ />
+ <Button
+ className="wp-block-jetpack-map-components-text-control-api-key-submit"
+ isLarge
+ disabled={ apiRequestOutstanding || ! apiKeyControl || apiKeyControl.length < 1 }
+ onClick={ this.updateAPIKey }
+ >
+ { __( 'Set Token', 'jetpack' ) }
+ </Button>
+ </Fragment>
+ </Placeholder>
+ );
+ const placeholderAPIStateSuccess = (
+ <Fragment>
+ { inspectorControls }
+ <div className={ className }>
+ <Map
+ ref={ this.mapRef }
+ mapStyle={ mapStyle }
+ mapDetails={ mapDetails }
+ points={ points }
+ zoom={ zoom }
+ mapCenter={ mapCenter }
+ markerColor={ markerColor }
+ onSetZoom={ value => {
+ setAttributes( { zoom: value } );
+ } }
+ admin={ true }
+ apiKey={ apiKey }
+ onSetPoints={ value => setAttributes( { points: value } ) }
+ onMapLoaded={ () => this.setState( { addPointVisibility: true } ) }
+ onMarkerClick={ () => this.setState( { addPointVisibility: false } ) }
+ onError={ this.onError }
+ >
+ { addPointVisibility && (
+ <AddPoint
+ onAddPoint={ this.addPoint }
+ onClose={ () => this.setState( { addPointVisibility: false } ) }
+ apiKey={ apiKey }
+ onError={ this.onError }
+ tagName="AddPoint"
+ />
+ ) }
+ </Map>
+ </div>
+ </Fragment>
+ );
+ return (
+ <Fragment>
+ { noticeUI }
+ { apiState === API_STATE_LOADING && placholderAPIStateLoading }
+ { apiState === API_STATE_FAILURE && placeholderAPIStateFailure }
+ { apiState === API_STATE_SUCCESS && placeholderAPIStateSuccess }
+ </Fragment>
+ );
+ }
+}
+
+export default withNotices( MapEdit );
diff --git a/plugins/jetpack/extensions/blocks/map/editor.js b/plugins/jetpack/extensions/blocks/map/editor.js
new file mode 100644
index 00000000..d05f4039
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/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/plugins/jetpack/extensions/blocks/map/editor.scss b/plugins/jetpack/extensions/blocks/map/editor.scss
new file mode 100644
index 00000000..ab66d12b
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/editor.scss
@@ -0,0 +1,28 @@
+
+.wp-block-jetpack-map__delete-btn {
+ padding: 0;
+ svg {
+ margin-right: 0.4em;
+ }
+}
+.wp-block-jetpack-map-components-text-control-api-key {
+ margin-right: 4px;
+ &.components-base-control .components-base-control__field {
+ margin-bottom: 0;
+ }
+}
+.wp-block-jetpack-map-components-text-control-api-key-submit.is-large {
+ height: 31px;
+}
+.wp-block-jetpack-map-components-text-control-api-key-submit:disabled {
+ opacity: 1;
+}
+.wp-block[data-type='jetpack/map'] {
+ .components-placeholder__label {
+ svg {
+ fill: currentColor;
+ margin-right: 6px;
+ margin-right: 1ch;
+ }
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/map/index.js b/plugins/jetpack/extensions/blocks/map/index.js
new file mode 100644
index 00000000..2e66caae
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/index.js
@@ -0,0 +1,28 @@
+/**
+ * Internal dependencies
+ */
+import { settings as mapSettings } from './settings.js';
+import edit from './edit';
+import save from './save';
+import './style.scss';
+import './editor.scss';
+
+export const { name } = mapSettings;
+
+export const settings = {
+ title: mapSettings.title,
+ icon: mapSettings.icon,
+ category: mapSettings.category,
+ keywords: mapSettings.keywords,
+ description: mapSettings.description,
+ attributes: mapSettings.attributes,
+ supports: mapSettings.supports,
+ getEditWrapperProps( attributes ) {
+ const { align } = attributes;
+ if ( -1 !== mapSettings.validAlignments.indexOf( align ) ) {
+ return { 'data-align': align };
+ }
+ },
+ edit,
+ save,
+};
diff --git a/plugins/jetpack/extensions/blocks/map/info-window/index.js b/plugins/jetpack/extensions/blocks/map/info-window/index.js
new file mode 100644
index 00000000..f469efad
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/info-window/index.js
@@ -0,0 +1,52 @@
+/**
+ * External dependencies
+ */
+
+import { Component, createPortal } from '@wordpress/element';
+
+export class InfoWindow extends Component {
+ componentDidMount() {
+ const { mapboxgl } = this.props;
+ this.el = document.createElement( 'DIV' );
+ this.infowindow = new mapboxgl.Popup( {
+ closeButton: true,
+ closeOnClick: false,
+ offset: {
+ left: [ 0, 0 ],
+ top: [ 0, 5 ],
+ right: [ 0, 0 ],
+ bottom: [ 0, -40 ],
+ },
+ } );
+ this.infowindow.setDOMContent( this.el );
+ this.infowindow.on( 'close', this.closeClick );
+ }
+ componentDidUpdate( prevProps ) {
+ if ( this.props.activeMarker !== prevProps.activeMarker ) {
+ this.props.activeMarker ? this.openWindow() : this.closeWindow();
+ }
+ }
+ render() {
+ // Use React portal to render components directly into the Mapbox info window.
+ return this.el ? createPortal( this.props.children, this.el ) : null;
+ }
+ closeClick = () => {
+ this.props.unsetActiveMarker();
+ };
+ openWindow() {
+ const { map, activeMarker } = this.props;
+ this.infowindow.setLngLat( activeMarker.getPoint() ).addTo( map );
+ }
+ closeWindow() {
+ this.infowindow.remove();
+ }
+}
+
+InfoWindow.defaultProps = {
+ unsetActiveMarker: () => {},
+ activeMarker: null,
+ map: null,
+ mapboxgl: null,
+};
+
+export default InfoWindow;
diff --git a/plugins/jetpack/extensions/blocks/map/location-search/index.js b/plugins/jetpack/extensions/blocks/map/location-search/index.js
new file mode 100644
index 00000000..aad845ca
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/location-search/index.js
@@ -0,0 +1,108 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Component, createRef } from '@wordpress/element';
+import { BaseControl, TextControl } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import Lookup from '../lookup';
+
+const placeholderText = __( 'Add a marker…', 'jetpack' );
+
+export class LocationSearch extends Component {
+ constructor() {
+ super( ...arguments );
+
+ this.textRef = createRef();
+ this.containerRef = createRef();
+ this.state = {
+ isEmpty: true,
+ };
+ this.autocompleter = {
+ name: 'placeSearch',
+ options: this.search,
+ isDebounced: true,
+ getOptionLabel: option => <span>{ option.place_name }</span>,
+ getOptionKeywords: option => [ option.place_name ],
+ getOptionCompletion: this.getOptionCompletion,
+ };
+ }
+ componentDidMount() {
+ setTimeout( () => {
+ this.containerRef.current.querySelector( 'input' ).focus();
+ }, 50 );
+ }
+ getOptionCompletion = option => {
+ const { value } = option;
+ const point = {
+ placeTitle: value.text,
+ title: value.text,
+ caption: value.place_name,
+ id: value.id,
+ coordinates: {
+ longitude: value.geometry.coordinates[ 0 ],
+ latitude: value.geometry.coordinates[ 1 ],
+ },
+ };
+ this.props.onAddPoint( point );
+ return value.text;
+ };
+
+ search = value => {
+ const { apiKey, onError } = this.props;
+ const url =
+ 'https://api.mapbox.com/geocoding/v5/mapbox.places/' +
+ encodeURI( value ) +
+ '.json?access_token=' +
+ apiKey;
+ return new Promise( function( resolve, reject ) {
+ const xhr = new XMLHttpRequest();
+ xhr.open( 'GET', url );
+ xhr.onload = function() {
+ if ( xhr.status === 200 ) {
+ const res = JSON.parse( xhr.responseText );
+ resolve( res.features );
+ } else {
+ const res = JSON.parse( xhr.responseText );
+ onError( res.statusText, res.responseJSON.message );
+ reject( new Error( 'Mapbox Places Error' ) );
+ }
+ };
+ xhr.send();
+ } );
+ };
+ onReset = () => {
+ this.textRef.current.value = null;
+ };
+ render() {
+ const { label } = this.props;
+ return (
+ <div ref={ this.containerRef }>
+ <BaseControl label={ label } className="components-location-search">
+ <Lookup completer={ this.autocompleter } onReset={ this.onReset }>
+ { ( { isExpanded, listBoxId, activeId, onChange, onKeyDown } ) => (
+ <TextControl
+ placeholder={ placeholderText }
+ ref={ this.textRef }
+ onChange={ onChange }
+ aria-expanded={ isExpanded }
+ aria-owns={ listBoxId }
+ aria-activedescendant={ activeId }
+ onKeyDown={ onKeyDown }
+ />
+ ) }
+ </Lookup>
+ </BaseControl>
+ </div>
+ );
+ }
+}
+
+LocationSearch.defaultProps = {
+ onError: () => {},
+};
+
+export default LocationSearch;
diff --git a/plugins/jetpack/extensions/blocks/map/locations/index.js b/plugins/jetpack/extensions/blocks/map/locations/index.js
new file mode 100644
index 00000000..80385891
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/locations/index.js
@@ -0,0 +1,81 @@
+/**
+ * External dependencies
+ */
+import {
+ Button,
+ Dashicon,
+ Panel,
+ PanelBody,
+ TextareaControl,
+ TextControl,
+} from '@wordpress/components';
+import { Component } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+
+export class Locations extends Component {
+ constructor() {
+ super( ...arguments );
+ this.state = {
+ selectedCell: null,
+ };
+ }
+
+ onDeletePoint = e => {
+ const index = parseInt( e.target.getAttribute( 'data-id' ) );
+ const { points, onChange } = this.props;
+
+ const newPoints = points.slice( 0 );
+ newPoints.splice( index, 1 );
+ onChange( newPoints );
+ };
+
+ setMarkerField( field, value, index ) {
+ const { points, onChange } = this.props;
+
+ const newPoints = points.slice( 0 );
+ newPoints[ index ][ field ] = value;
+ onChange( newPoints );
+ }
+
+ render() {
+ const { points } = this.props;
+ const rows = points.map( ( point, index ) => (
+ <PanelBody title={ point.placeTitle } key={ point.id } initialOpen={ false }>
+ <TextControl
+ label="Marker Title"
+ value={ point.title }
+ onChange={ title => this.setMarkerField( 'title', title, index ) }
+ />
+ <TextareaControl
+ label="Marker Caption"
+ value={ point.caption }
+ rows="3"
+ onChange={ caption => this.setMarkerField( 'caption', caption, index ) }
+ />
+ <Button
+ data-id={ index }
+ onClick={ this.onDeletePoint }
+ className="component__locations__delete-btn"
+ >
+ <Dashicon icon="trash" size="15" /> Delete Marker
+ </Button>
+ </PanelBody>
+ ) );
+ return (
+ <div className="component__locations">
+ <Panel className="component__locations__panel">{ rows }</Panel>
+ </div>
+ );
+ }
+}
+
+Locations.defaultProps = {
+ points: Object.freeze( [] ),
+ onChange: () => {},
+};
+
+export default Locations;
diff --git a/plugins/jetpack/extensions/blocks/map/locations/style.scss b/plugins/jetpack/extensions/blocks/map/locations/style.scss
new file mode 100644
index 00000000..73f5e8b5
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/locations/style.scss
@@ -0,0 +1,27 @@
+
+.component__locations__panel {
+ .edit-post-settings-sidebar__panel-block & {
+ margin-bottom: 1em;
+ &:empty {
+ display: none;
+ }
+ .components-panel__body:first-child {
+ border-top: none;
+ }
+ .components-panel__body,
+ .components-panel__body:first-child,
+ .components-panel__body:last-child {
+ max-width: 100%;
+ margin: 0;
+ }
+ .components-panel__body button {
+ padding-right: 40px;
+ }
+ }
+}
+.component__locations__delete-btn {
+ padding: 0;
+ svg {
+ margin-right: 0.4em;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/map/lookup/index.js b/plugins/jetpack/extensions/blocks/map/lookup/index.js
new file mode 100644
index 00000000..c9d41969
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/lookup/index.js
@@ -0,0 +1,230 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+import { __, _n, sprintf } from '@wordpress/i18n';
+import { Button, Popover, withFocusOutside, withSpokenMessages } from '@wordpress/components';
+import { Component } from '@wordpress/element';
+import { debounce, map } from 'lodash';
+import { ENTER, ESCAPE, UP, DOWN, LEFT, RIGHT } from '@wordpress/keycodes';
+import { withInstanceId, compose } from '@wordpress/compose';
+
+function filterOptions( options = [], maxResults = 10 ) {
+ const filtered = [];
+ for ( let i = 0; i < options.length; i++ ) {
+ const option = options[ i ];
+
+ // Merge label into keywords
+ let { keywords = [] } = option;
+ if ( 'string' === typeof option.label ) {
+ keywords = [ ...keywords, option.label ];
+ }
+
+ filtered.push( option );
+
+ // Abort early if max reached
+ if ( filtered.length === maxResults ) {
+ break;
+ }
+ }
+
+ return filtered;
+}
+
+export class Lookup extends Component {
+ static getInitialState() {
+ return {
+ selectedIndex: 0,
+ query: undefined,
+ filteredOptions: [],
+ isOpen: false,
+ };
+ }
+
+ constructor() {
+ super( ...arguments );
+ this.debouncedLoadOptions = debounce( this.loadOptions, 250 );
+ this.state = this.constructor.getInitialState();
+ }
+
+ componentWillUnmount() {
+ this.debouncedLoadOptions.cancel();
+ }
+
+ select = option => {
+ const { completer } = this.props;
+ const getOptionCompletion = completer.getOptionCompletion || {};
+ getOptionCompletion( option );
+ this.reset();
+ };
+
+ reset = () => {
+ this.setState( this.constructor.getInitialState() );
+ };
+
+ handleFocusOutside() {
+ this.reset();
+ }
+
+ loadOptions( completer, query ) {
+ const { options } = completer;
+ const promise = ( this.activePromise = Promise.resolve(
+ typeof options === 'function' ? options( query ) : options
+ ).then( optionsData => {
+ if ( promise !== this.activePromise ) {
+ // Another promise has become active since this one was asked to resolve, so do nothing,
+ // or else we might end triggering a race condition updating the state.
+ return;
+ }
+ const keyedOptions = optionsData.map( ( optionData, optionIndex ) => ( {
+ key: `${ optionIndex }`,
+ value: optionData,
+ label: completer.getOptionLabel( optionData ),
+ keywords: completer.getOptionKeywords ? completer.getOptionKeywords( optionData ) : [],
+ } ) );
+
+ const filteredOptions = filterOptions( keyedOptions );
+ const selectedIndex =
+ filteredOptions.length === this.state.filteredOptions.length ? this.state.selectedIndex : 0;
+ this.setState( {
+ [ 'options' ]: keyedOptions,
+ filteredOptions,
+ selectedIndex,
+ isOpen: filteredOptions.length > 0,
+ } );
+ this.announce( filteredOptions );
+ } ) );
+ }
+
+ onChange = query => {
+ const { completer } = this.props;
+ const { options } = this.state;
+
+ if ( ! query ) {
+ this.reset();
+ return;
+ }
+
+ if ( completer ) {
+ if ( completer.isDebounced ) {
+ this.debouncedLoadOptions( completer, query );
+ } else {
+ this.loadOptions( completer, query );
+ }
+ }
+
+ const filteredOptions = completer ? filterOptions( options ) : [];
+ if ( completer ) {
+ this.setState( { selectedIndex: 0, filteredOptions, query } );
+ }
+ };
+
+ onKeyDown = event => {
+ const { isOpen, selectedIndex, filteredOptions } = this.state;
+ if ( ! isOpen ) {
+ return;
+ }
+ let nextSelectedIndex;
+ switch ( event.keyCode ) {
+ case UP:
+ nextSelectedIndex = ( selectedIndex === 0 ? filteredOptions.length : selectedIndex ) - 1;
+ this.setState( { selectedIndex: nextSelectedIndex } );
+ break;
+
+ case DOWN:
+ nextSelectedIndex = ( selectedIndex + 1 ) % filteredOptions.length;
+ this.setState( { selectedIndex: nextSelectedIndex } );
+ break;
+
+ case ENTER:
+ this.select( filteredOptions[ selectedIndex ] );
+ break;
+
+ case LEFT:
+ case RIGHT:
+ case ESCAPE:
+ this.reset();
+ return;
+
+ default:
+ return;
+ }
+
+ // Any handled keycode should prevent original behavior. This relies on
+ // the early return in the default case.
+ event.preventDefault();
+ event.stopPropagation();
+ };
+ announce( filteredOptions ) {
+ const { debouncedSpeak } = this.props;
+ if ( ! debouncedSpeak ) {
+ return;
+ }
+ if ( filteredOptions.length ) {
+ debouncedSpeak(
+ sprintf(
+ _n(
+ '%d result found, use up and down arrow keys to navigate.',
+ '%d results found, use up and down arrow keys to navigate.',
+ filteredOptions.length,
+ 'jetpack',
+ 'jetpack'
+ ),
+ filteredOptions.length
+ ),
+ 'assertive'
+ );
+ } else {
+ debouncedSpeak( __( 'No results.', 'jetpack' ), 'assertive' );
+ }
+ }
+ render() {
+ const { onChange, onKeyDown } = this;
+ const { children, instanceId, completer } = this.props;
+ const { selectedIndex, filteredOptions } = this.state;
+ const { key: selectedKey = '' } = filteredOptions[ selectedIndex ] || {};
+ const { className } = completer;
+ const isExpanded = filteredOptions.length > 0;
+ const listBoxId = isExpanded ? `components-autocomplete-listbox-${ instanceId }` : null;
+ const activeId = isExpanded
+ ? `components-autocomplete-item-${ instanceId }-${ selectedKey }`
+ : null;
+ return (
+ <div className="components-autocomplete">
+ { children( { isExpanded, listBoxId, activeId, onChange, onKeyDown } ) }
+ { isExpanded && (
+ <Popover
+ focusOnMount={ false }
+ onClose={ this.reset }
+ position="top center"
+ className="components-autocomplete__popover"
+ noArrow
+ >
+ <div id={ listBoxId } role="listbox" className="components-autocomplete__results">
+ { map( filteredOptions, ( option, index ) => (
+ <Button
+ key={ option.key }
+ id={ `components-autocomplete-item-${ instanceId }-${ option.key }` }
+ role="option"
+ aria-selected={ index === selectedIndex }
+ disabled={ option.isDisabled }
+ className={ classnames( 'components-autocomplete__result', className, {
+ 'is-selected': index === selectedIndex,
+ } ) }
+ onClick={ () => this.select( option ) }
+ >
+ { option.label }
+ </Button>
+ ) ) }
+ </div>
+ </Popover>
+ ) }
+ </div>
+ );
+ }
+}
+export default compose( [
+ withSpokenMessages,
+ withInstanceId,
+ withFocusOutside, // this MUST be the innermost HOC as it calls handleFocusOutside
+] )( Lookup );
diff --git a/plugins/jetpack/extensions/blocks/map/map-marker/index.js b/plugins/jetpack/extensions/blocks/map/map-marker/index.js
new file mode 100644
index 00000000..e8db9934
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/map-marker/index.js
@@ -0,0 +1,65 @@
+/**
+ * External dependencies
+ */
+import { Component } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+
+export class MapMarker extends Component {
+ componentDidMount() {
+ this.renderMarker();
+ }
+ componentWillUnmount() {
+ if ( this.marker ) {
+ this.marker.remove();
+ }
+ }
+ componentDidUpdate() {
+ this.renderMarker();
+ }
+ handleClick = () => {
+ const { onClick } = this.props;
+ onClick( this );
+ };
+ getPoint = () => {
+ const { point } = this.props;
+ return [ point.coordinates.longitude, point.coordinates.latitude ];
+ };
+ renderMarker() {
+ const { map, point, mapboxgl, markerColor } = this.props;
+ const { handleClick } = this;
+ const mapboxPoint = [ point.coordinates.longitude, point.coordinates.latitude ];
+ const el = this.marker ? this.marker.getElement() : document.createElement( 'div' );
+ if ( this.marker ) {
+ this.marker.setLngLat( mapboxPoint );
+ } else {
+ el.className = 'wp-block-jetpack-map-marker';
+ this.marker = new mapboxgl.Marker( el )
+ .setLngLat( mapboxPoint )
+ .setOffset( [ 0, -19 ] )
+ .addTo( map );
+
+ this.marker.getElement().addEventListener( 'click', handleClick );
+ }
+ el.innerHTML =
+ '<?xml version="1.0" encoding="UTF-8"?><svg version="1.1" viewBox="0 0 32 38" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g fill-rule="evenodd"><path id="d" d="m16 38s16-11.308 16-22-7.1634-16-16-16-16 5.3076-16 16 16 22 16 22z" fill="' +
+ markerColor +
+ '" mask="url(#c)"/></g></svg>';
+ }
+ render() {
+ return null;
+ }
+}
+
+MapMarker.defaultProps = {
+ point: {},
+ map: null,
+ markerColor: '#000000',
+ mapboxgl: null,
+ onClick: () => {},
+};
+
+export default MapMarker;
diff --git a/plugins/jetpack/extensions/blocks/map/map-marker/style.scss b/plugins/jetpack/extensions/blocks/map/map-marker/style.scss
new file mode 100644
index 00000000..6c5a2a65
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/map-marker/style.scss
@@ -0,0 +1,6 @@
+
+.wp-block-jetpack-map-marker {
+ width: 32px;
+ height: 38px;
+ opacity: 0.9;
+}
diff --git a/plugins/jetpack/extensions/blocks/map/map-theme-picker/index.js b/plugins/jetpack/extensions/blocks/map/map-theme-picker/index.js
new file mode 100644
index 00000000..e226534a
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/map-theme-picker/index.js
@@ -0,0 +1,50 @@
+/**
+ * External dependencies
+ */
+import { Component } from '@wordpress/element';
+import { Button, ButtonGroup } from '@wordpress/components';
+import classnames from 'classnames';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+
+export class MapThemePicker extends Component {
+ render() {
+ const { options, value, onChange, label } = this.props;
+ const buttons = options.map( ( option, index ) => {
+ const classes = classnames(
+ 'component__map-theme-picker__button',
+ 'is-theme-' + option.value,
+ option.value === value ? 'is-selected' : ''
+ );
+ return (
+ <Button
+ className={ classes }
+ title={ option.label }
+ key={ index }
+ onClick={ () => onChange( option.value ) }
+ >
+ { option.label }
+ </Button>
+ );
+ } );
+ return (
+ <div className="component__map-theme-picker components-base-control">
+ { /* eslint-disable-next-line jsx-a11y/label-has-for */ }
+ <label className="components-base-control__label">{ label }</label>
+ <ButtonGroup>{ buttons }</ButtonGroup>
+ </div>
+ );
+ }
+}
+
+MapThemePicker.defaultProps = {
+ label: '',
+ options: [],
+ value: null,
+ onChange: () => {},
+};
+
+export default MapThemePicker;
diff --git a/plugins/jetpack/extensions/blocks/map/map-theme-picker/map-theme_black_and_white.jpg b/plugins/jetpack/extensions/blocks/map/map-theme-picker/map-theme_black_and_white.jpg
new file mode 100644
index 00000000..34cc1412
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/map-theme-picker/map-theme_black_and_white.jpg
Binary files differ
diff --git a/plugins/jetpack/extensions/blocks/map/map-theme-picker/map-theme_default.jpg b/plugins/jetpack/extensions/blocks/map/map-theme-picker/map-theme_default.jpg
new file mode 100644
index 00000000..35505eb1
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/map-theme-picker/map-theme_default.jpg
Binary files differ
diff --git a/plugins/jetpack/extensions/blocks/map/map-theme-picker/map-theme_satellite.jpg b/plugins/jetpack/extensions/blocks/map/map-theme-picker/map-theme_satellite.jpg
new file mode 100644
index 00000000..ef6ae417
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/map-theme-picker/map-theme_satellite.jpg
Binary files differ
diff --git a/plugins/jetpack/extensions/blocks/map/map-theme-picker/map-theme_terrain.jpg b/plugins/jetpack/extensions/blocks/map/map-theme-picker/map-theme_terrain.jpg
new file mode 100644
index 00000000..eee1a2da
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/map-theme-picker/map-theme_terrain.jpg
Binary files differ
diff --git a/plugins/jetpack/extensions/blocks/map/map-theme-picker/style.scss b/plugins/jetpack/extensions/blocks/map/map-theme-picker/style.scss
new file mode 100644
index 00000000..eb444c1c
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/map-theme-picker/style.scss
@@ -0,0 +1,36 @@
+@import '../../../shared/styles/gutenberg-colors.scss';
+
+.component__map-theme-picker__button {
+ .edit-post-settings-sidebar__panel-block & {
+ border: 1px solid $light-gray-500;
+ border-radius: 100%;
+ width: 56px;
+ height: 56px;
+ margin: 2px;
+ text-indent: -9999px;
+ background-color: $light-gray-500;
+ background-position: center center;
+ background-repeat: no-repeat;
+ background-size: contain;
+ transform: scale( 1 );
+ transition: transform 0.2s ease;
+ &:hover {
+ transform: scale( 1.1 );
+ }
+ &.is-selected {
+ border-color: $black;
+ }
+ &.is-theme-default {
+ background-image: url( './map-theme_default.jpg' );
+ }
+ &.is-theme-black_and_white {
+ background-image: url( './map-theme_black_and_white.jpg' );
+ }
+ &.is-theme-satellite {
+ background-image: url( './map-theme_satellite.jpg' );
+ }
+ &.is-theme-terrain {
+ background-image: url( './map-theme_terrain.jpg' );
+ }
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/map/map.php b/plugins/jetpack/extensions/blocks/map/map.php
new file mode 100644
index 00000000..8b8532c0
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/map.php
@@ -0,0 +1,31 @@
+<?php
+/**
+ * Map block.
+ *
+ * @since 6.8.0
+ *
+ * @package Jetpack
+ */
+
+jetpack_register_block(
+ 'jetpack/map',
+ array(
+ 'render_callback' => 'jetpack_map_block_load_assets',
+ )
+);
+
+/**
+ * Map block registration/dependency declaration.
+ *
+ * @param array $attr Array containing the map block attributes.
+ * @param string $content String containing the map block content.
+ *
+ * @return string
+ */
+function jetpack_map_block_load_assets( $attr, $content ) {
+ $api_key = Jetpack_Options::get_option( 'mapbox_api_key' );
+
+ Jetpack_Gutenberg::load_assets_as_required( 'map' );
+
+ return preg_replace( '/<div /', '<div data-api-key="' . esc_attr( $api_key ) . '" ', $content, 1 );
+}
diff --git a/plugins/jetpack/extensions/blocks/map/mapbox-map-formatter/index.js b/plugins/jetpack/extensions/blocks/map/mapbox-map-formatter/index.js
new file mode 100644
index 00000000..6ec21ad8
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/mapbox-map-formatter/index.js
@@ -0,0 +1,22 @@
+export function mapboxMapFormatter( mapStyle, mapDetails ) {
+ const style_urls = {
+ default: {
+ details: 'mapbox://styles/automattic/cjolkhmez0qdd2ro82dwog1in',
+ no_details: 'mapbox://styles/automattic/cjolkci3905d82soef4zlmkdo',
+ },
+ black_and_white: {
+ details: 'mapbox://styles/automattic/cjolkixvv0ty42spgt2k4j434',
+ no_details: 'mapbox://styles/automattic/cjolkgc540tvj2spgzzoq37k4',
+ },
+ satellite: {
+ details: 'mapbox://styles/mapbox/satellite-streets-v10',
+ no_details: 'mapbox://styles/mapbox/satellite-v9',
+ },
+ terrain: {
+ details: 'mapbox://styles/automattic/cjolkf8p405fh2soet2rdt96b',
+ no_details: 'mapbox://styles/automattic/cjolke6fz12ys2rpbpvgl12ha',
+ },
+ };
+ const style_url = style_urls[ mapStyle ][ mapDetails ? 'details' : 'no_details' ];
+ return style_url;
+}
diff --git a/plugins/jetpack/extensions/blocks/map/save.js b/plugins/jetpack/extensions/blocks/map/save.js
new file mode 100644
index 00000000..ffa82641
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/save.js
@@ -0,0 +1,38 @@
+/**
+ * External dependencies
+ */
+
+import { Component } from '@wordpress/element';
+
+class MapSave extends Component {
+ render() {
+ const { attributes } = this.props;
+ const { align, mapStyle, mapDetails, points, zoom, mapCenter, markerColor } = attributes;
+ const pointsList = points.map( ( point, index ) => {
+ const { longitude, latitude } = point.coordinates;
+ const url = 'https://www.google.com/maps/search/?api=1&query=' + latitude + ',' + longitude;
+ return (
+ <li key={ index }>
+ <a href={ url }>{ point.title }</a>
+ </li>
+ );
+ } );
+ const alignClassName = align ? `align${ align }` : null;
+ // All camelCase attribute names converted to snake_case data attributes
+ return (
+ <div
+ className={ alignClassName }
+ data-map-style={ mapStyle }
+ data-map-details={ mapDetails }
+ data-points={ JSON.stringify( points ) }
+ data-zoom={ zoom }
+ data-map-center={ JSON.stringify( mapCenter ) }
+ data-marker-color={ markerColor }
+ >
+ { points.length > 0 && <ul>{ pointsList }</ul> }
+ </div>
+ );
+ }
+}
+
+export default MapSave;
diff --git a/plugins/jetpack/extensions/blocks/map/settings.js b/plugins/jetpack/extensions/blocks/map/settings.js
new file mode 100644
index 00000000..e8bc87d2
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/settings.js
@@ -0,0 +1,104 @@
+// Disable forbidden <svg> etc. so that frontend component does not depend on @wordpress/component
+/* eslint-disable react/forbid-elements */
+/**
+ * External dependencies
+ */
+import { __, _x } from '@wordpress/i18n';
+
+export const settings = {
+ name: 'map',
+ prefix: 'jetpack',
+ title: __( 'Map', 'jetpack' ),
+ icon: (
+ /* Do not use SVG components from @wordpress/component to avoid frontend bloat */
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="24"
+ height="24"
+ viewBox="0 0 24 24"
+ role="img"
+ aria-hidden="true"
+ focusable="false"
+ >
+ <path fill="none" d="M0 0h24v24H0V0z" />
+ <path d="M20.5 3l-.16.03L15 5.1 9 3 3.36 4.9c-.21.07-.36.25-.36.48V20.5c0 .28.22.5.5.5l.16-.03L9 18.9l6 2.1 5.64-1.9c.21-.07.36-.25.36-.48V3.5c0-.28-.22-.5-.5-.5zM10 5.47l4 1.4v11.66l-4-1.4V5.47zm-5 .99l3-1.01v11.7l-3 1.16V6.46zm14 11.08l-3 1.01V6.86l3-1.16v11.84z" />
+ </svg>
+ ),
+ category: 'jetpack',
+ keywords: [
+ _x( 'map', 'block search term', 'jetpack' ),
+ _x( 'location', 'block search term', 'jetpack' ),
+ _x( 'navigation', 'block search term', 'jetpack' ),
+ ],
+ description: __( 'Add an interactive map showing one or more locations.', 'jetpack' ),
+ attributes: {
+ align: {
+ type: 'string',
+ },
+ points: {
+ type: 'array',
+ default: [],
+ },
+ mapStyle: {
+ type: 'string',
+ default: 'default',
+ },
+ mapDetails: {
+ type: 'boolean',
+ default: true,
+ },
+ zoom: {
+ type: 'integer',
+ default: 13,
+ },
+ mapCenter: {
+ type: 'object',
+ default: {
+ longitude: -122.41941550000001,
+ latitude: 37.7749295,
+ },
+ },
+ markerColor: {
+ type: 'string',
+ default: 'red',
+ },
+ },
+ supports: {
+ html: false,
+ },
+ mapStyleOptions: [
+ {
+ value: 'default',
+ label: __( 'Basic', 'jetpack' ),
+ },
+ {
+ value: 'black_and_white',
+ label: __( 'Black and white', 'jetpack' ),
+ },
+ {
+ value: 'satellite',
+ label: __( 'Satellite', 'jetpack' ),
+ },
+ {
+ value: 'terrain',
+ label: __( 'Terrain', 'jetpack' ),
+ },
+ ],
+ validAlignments: [ 'center', 'wide', 'full' ],
+ markerIcon: (
+ /* Do not use SVG components from @wordpress/component to avoid frontend bloat */
+ <svg width="14" height="20" viewBox="0 0 14 20" xmlns="http://www.w3.org/2000/svg">
+ <g id="Page-1" fill="none" fillRule="evenodd">
+ <g id="outline-add_location-24px" transform="translate(-5 -2)">
+ <polygon id="Shape" points="0 0 24 0 24 24 0 24" />
+ <path
+ d="M12,2 C8.14,2 5,5.14 5,9 C5,14.25 12,22 12,22 C12,22 19,14.25 19,9 C19,5.14 15.86,2 12,2 Z M7,9 C7,6.24 9.24,4 12,4 C14.76,4 17,6.24 17,9 C17,11.88 14.12,16.19 12,18.88 C9.92,16.21 7,11.85 7,9 Z M13,6 L11,6 L11,8 L9,8 L9,10 L11,10 L11,12 L13,12 L13,10 L15,10 L15,8 L13,8 L13,6 Z"
+ id="Shape"
+ fill="#000"
+ fillRule="nonzero"
+ />
+ </g>
+ </g>
+ </svg>
+ ),
+};
diff --git a/plugins/jetpack/extensions/blocks/map/style.scss b/plugins/jetpack/extensions/blocks/map/style.scss
new file mode 100644
index 00000000..a3a25f29
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/style.scss
@@ -0,0 +1,22 @@
+@import '../../shared/styles/gutenberg-colors.scss';
+
+.wp-block-jetpack-map {
+ .wp-block-jetpack-map__gm-container {
+ width: 100%;
+ overflow: hidden;
+ background: $light-gray-500;
+ min-height: 400px;
+ text-align: left;
+ }
+ .mapboxgl-popup {
+ h3 {
+ font-size: 1.3125em;
+ font-weight: 400;
+ margin-bottom: 0.5rem;
+ }
+ p {
+ margin-bottom: 0;
+ }
+ max-width: 300px;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/map/view.js b/plugins/jetpack/extensions/blocks/map/view.js
new file mode 100644
index 00000000..fc825d67
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/map/view.js
@@ -0,0 +1,33 @@
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import component from './component.js';
+import { settings } from './settings.js';
+import FrontendManagement from '../../shared/frontend-management.js';
+
+typeof window !== 'undefined' &&
+ window.addEventListener( 'load', function() {
+ const frontendManagement = new FrontendManagement();
+ // Add apiKey to attibutes so FrontendManagement knows about it.
+ // It is dynamically being added on the php side.
+ // So that it can be updated accross all the map blocks at the same time.
+ const apiKey = {
+ type: 'string',
+ default: '',
+ };
+ frontendManagement.blockIterator( document, [
+ {
+ component: component,
+ options: {
+ settings: {
+ ...settings,
+ attributes: {
+ ...settings.attributes,
+ apiKey,
+ },
+ },
+ },
+ },
+ ] );
+ } );
diff --git a/plugins/jetpack/extensions/blocks/markdown/edit.js b/plugins/jetpack/extensions/blocks/markdown/edit.js
new file mode 100644
index 00000000..13dff7f4
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/markdown/edit.js
@@ -0,0 +1,127 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { BlockControls, PlainText } from '@wordpress/editor';
+import { Component } from '@wordpress/element';
+import { compose } from '@wordpress/compose';
+import { withDispatch, withSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import MarkdownRenderer from './renderer';
+
+/**
+ * Module variables
+ */
+const PANEL_EDITOR = 'editor';
+const PANEL_PREVIEW = 'preview';
+
+class MarkdownEdit extends Component {
+ input = null;
+
+ state = {
+ activePanel: PANEL_EDITOR,
+ };
+
+ bindInput = ref => void ( this.input = ref );
+
+ componentDidUpdate( prevProps ) {
+ if (
+ prevProps.isSelected &&
+ ! this.props.isSelected &&
+ this.state.activePanel === PANEL_PREVIEW
+ ) {
+ this.toggleMode( PANEL_EDITOR )();
+ }
+ if (
+ ! prevProps.isSelected &&
+ this.props.isSelected &&
+ this.state.activePanel === PANEL_EDITOR &&
+ this.input
+ ) {
+ this.input.focus();
+ }
+ }
+
+ isEmpty() {
+ const source = this.props.attributes.source;
+ return ! source || source.trim() === '';
+ }
+
+ updateSource = source => this.props.setAttributes( { source } );
+
+ handleKeyDown = e => {
+ const { attributes, removeBlock } = this.props;
+ const { source } = attributes;
+
+ // Remove the block if source is empty and we're pressing the Backspace key
+ if ( e.keyCode === 8 && source === '' ) {
+ removeBlock();
+ e.preventDefault();
+ }
+ };
+
+ toggleMode = mode => () => this.setState( { activePanel: mode } );
+
+ renderToolbarButton( mode, label ) {
+ const { activePanel } = this.state;
+
+ return (
+ <button
+ className={ `components-tab-button ${ activePanel === mode ? 'is-active' : '' }` }
+ onClick={ this.toggleMode( mode ) }
+ >
+ <span>{ label }</span>
+ </button>
+ );
+ }
+
+ render() {
+ const { attributes, className, isSelected } = this.props;
+ const { source } = attributes;
+ const { activePanel } = this.state;
+
+ if ( ! isSelected && this.isEmpty() ) {
+ return (
+ <p className={ `${ className }__placeholder` }>
+ { __( 'Write your _Markdown_ **here**…', 'jetpack' ) }
+ </p>
+ );
+ }
+
+ return (
+ <div className={ className }>
+ <BlockControls>
+ <div className="components-toolbar">
+ { this.renderToolbarButton( PANEL_EDITOR, __( 'Markdown', 'jetpack' ) ) }
+ { this.renderToolbarButton( PANEL_PREVIEW, __( 'Preview', 'jetpack' ) ) }
+ </div>
+ </BlockControls>
+
+ { activePanel === PANEL_PREVIEW || ! isSelected ? (
+ <MarkdownRenderer className={ `${ className }__preview` } source={ source } />
+ ) : (
+ <PlainText
+ className={ `${ className }__editor` }
+ onChange={ this.updateSource }
+ onKeyDown={ this.handleKeyDown }
+ aria-label={ __( 'Markdown', 'jetpack' ) }
+ innerRef={ this.bindInput }
+ value={ source }
+ />
+ ) }
+ </div>
+ );
+ }
+}
+
+export default compose( [
+ withSelect( select => ( {
+ currentBlockId: select( 'core/editor' ).getSelectedBlockClientId(),
+ } ) ),
+ withDispatch( ( dispatch, { currentBlockId } ) => ( {
+ removeBlock: () => dispatch( 'core/editor' ).removeBlocks( currentBlockId ),
+ } ) ),
+] )( MarkdownEdit );
diff --git a/plugins/jetpack/extensions/blocks/markdown/editor.js b/plugins/jetpack/extensions/blocks/markdown/editor.js
new file mode 100644
index 00000000..d05f4039
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/markdown/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/plugins/jetpack/extensions/blocks/markdown/editor.scss b/plugins/jetpack/extensions/blocks/markdown/editor.scss
new file mode 100644
index 00000000..0cb11581
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/markdown/editor.scss
@@ -0,0 +1,144 @@
+@import '../../shared/styles/gutenberg-colors.scss';
+@import '../../shared/styles/gutenberg-variables.scss';
+
+.wp-block-jetpack-markdown__placeholder {
+ opacity: 0.62; // See https://github.com/WordPress/gutenberg/blob/db7decd27f7c476684bc8edde381ffab4c916cb2/packages/block-editor/src/components/rich-text/style.scss#L72
+ pointer-events: none;
+}
+
+// @TODO: Remove all these specific styles when related Gutenberg core styles become more generic
+.editor-block-list__block {
+ .wp-block-jetpack-markdown__preview {
+ min-height: 1.8em;
+ line-height: 1.8;
+
+ & > * {
+ margin-top: 32px;
+ margin-bottom: 32px;
+ }
+
+ // See https://github.com/WordPress/gutenberg/blob/db7decd27f7c476684bc8edde381ffab4c916cb2/packages/block-library/src/heading/editor.scss#L12-L35
+ h1,
+ h2,
+ h3 {
+ line-height: 1.4;
+ }
+
+ h1 {
+ font-size: 2.44em;
+ }
+
+ h2 {
+ font-size: 1.95em;
+ }
+
+ h3 {
+ font-size: 1.56em;
+ }
+
+ h4 {
+ font-size: 1.25em;
+ line-height: 1.5;
+ }
+
+ h5 {
+ font-size: 1em;
+ }
+
+ h6 {
+ font-size: 0.8em;
+ }
+
+ hr {
+ border: none;
+ border-bottom: 2px solid $dark-gray-100;
+ margin: 2em auto;
+ max-width: 100px;
+ }
+
+ p {
+ line-height: 1.8;
+ }
+
+ blockquote {
+ border-left: 4px solid $black;
+ margin-left: 0;
+ margin-right: 0;
+ padding-left: 1em;
+
+ p {
+ line-height: 1.5;
+ margin: 1em 0;
+ }
+ }
+
+ ul,
+ ol {
+ margin-left: 1.3em;
+ padding-left: 1.3em;
+ }
+
+ li {
+ p {
+ margin: 0;
+ }
+ }
+
+ // See https://github.com/WordPress/gutenberg/blob/db7decd27f7c476684bc8edde381ffab4c916cb2/packages/block-editor/src/components/rich-text/style.scss#L28-L39
+ code,
+ pre {
+ color: $dark-gray-800;
+ font-family: $editor-html-font;
+ }
+
+ code {
+ background: $light-gray-200;
+ border-radius: 2px;
+ font-size: inherit; // This is necessary to override upstream CSS.
+ padding: 2px;
+ }
+
+ pre {
+ border-radius: 4px;
+ border: 1px solid $light-gray-500;
+ font-size: $text-editor-font-size;
+ padding: 0.8em 1em;
+
+ code {
+ background: transparent;
+ padding: 0;
+ }
+ }
+
+ table {
+ overflow-x: auto;
+ border-collapse: collapse;
+ width: 100%;
+ }
+
+ thead,
+ tbody,
+ tfoot {
+ width: 100%;
+ min-width: 240px;
+ }
+
+ td,
+ th {
+ padding: 0.5em;
+ border: 1px solid currentColor;
+ }
+ }
+}
+
+.wp-block-jetpack-markdown {
+ .wp-block-jetpack-markdown__editor {
+ font-family: $editor-html-font;
+ font-size: $text-editor-font-size;
+
+ &:focus {
+ border-color: transparent;
+ box-shadow: 0 0 0 transparent;
+ }
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/markdown/index.js b/plugins/jetpack/extensions/blocks/markdown/index.js
new file mode 100644
index 00000000..ff5d3dc8
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/markdown/index.js
@@ -0,0 +1,70 @@
+/**
+ * External dependencies
+ */
+import { __, _x } from '@wordpress/i18n';
+import { ExternalLink, Path, Rect, SVG } from '@wordpress/components';
+import { Fragment } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import './editor.scss';
+import edit from './edit';
+import save from './save';
+
+export const name = 'markdown';
+
+export const settings = {
+ title: __( 'Markdown', 'jetpack' ),
+
+ description: (
+ <Fragment>
+ <p>
+ { __(
+ 'Use regular characters and punctuation to style text, links, and lists.',
+ 'jetpack'
+ ) }
+ </p>
+ <ExternalLink href="https://en.support.wordpress.com/markdown-quick-reference/">
+ { __( 'Support reference', 'jetpack' ) }
+ </ExternalLink>
+ </Fragment>
+ ),
+
+ icon: (
+ <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 208 128">
+ <Rect
+ width="198"
+ height="118"
+ x="5"
+ y="5"
+ ry="10"
+ stroke="currentColor"
+ strokeWidth="10"
+ fill="none"
+ />
+ <Path d="M30 98v-68h20l20 25 20-25h20v68h-20v-39l-20 25-20-25v39zM155 98l-30-33h20v-35h20v35h20z" />
+ </SVG>
+ ),
+
+ category: 'jetpack',
+
+ keywords: [
+ _x( 'formatting', 'block search term', 'jetpack' ),
+ _x( 'syntax', 'block search term', 'jetpack' ),
+ _x( 'markup', 'block search term', 'jetpack' ),
+ ],
+
+ attributes: {
+ //The Markdown source is saved in the block content comments delimiter
+ source: { type: 'string' },
+ },
+
+ supports: {
+ html: false,
+ },
+
+ edit,
+
+ save,
+};
diff --git a/plugins/jetpack/extensions/blocks/markdown/markdown.php b/plugins/jetpack/extensions/blocks/markdown/markdown.php
new file mode 100644
index 00000000..7490b9d2
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/markdown/markdown.php
@@ -0,0 +1,20 @@
+<?php
+/**
+ * Markdown Block.
+ *
+ * @since 6.8.0
+ *
+ * @package Jetpack
+ */
+
+/**
+ * The block depends on the Markdown module to be active for now.
+ * Related discussion: https://github.com/Automattic/jetpack/issues/10294
+ */
+if (
+ ( defined( 'IS_WPCOM' ) && IS_WPCOM )
+ || ( method_exists( 'Jetpack', 'is_module_active' ) && Jetpack::is_module_active( 'markdown' ) )
+) {
+ jetpack_register_block( 'jetpack/markdown' );
+}
+
diff --git a/plugins/jetpack/extensions/blocks/markdown/renderer.js b/plugins/jetpack/extensions/blocks/markdown/renderer.js
new file mode 100644
index 00000000..ae87568a
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/markdown/renderer.js
@@ -0,0 +1,28 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import MarkdownIt from 'markdown-it';
+import { RawHTML } from '@wordpress/element';
+
+/**
+ * Module variables
+ */
+const markdownConverter = new MarkdownIt();
+const handleLinkClick = event => {
+ if ( event.target.nodeName === 'A' ) {
+ const hasConfirmed = window.confirm(
+ __( 'Are you sure you wish to leave this page?', 'jetpack' )
+ );
+
+ if ( ! hasConfirmed ) {
+ event.preventDefault();
+ }
+ }
+};
+
+export default ( { className, source = '' } ) => (
+ <RawHTML className={ className } onClick={ handleLinkClick }>
+ { source.length ? markdownConverter.render( source ) : '' }
+ </RawHTML>
+);
diff --git a/plugins/jetpack/extensions/blocks/markdown/save.js b/plugins/jetpack/extensions/blocks/markdown/save.js
new file mode 100644
index 00000000..06d08138
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/markdown/save.js
@@ -0,0 +1,8 @@
+/**
+ * Internal dependencies
+ */
+import MarkdownRenderer from './renderer';
+
+export default ( { attributes, className } ) => (
+ <MarkdownRenderer className={ className } source={ attributes.source } />
+);
diff --git a/plugins/jetpack/extensions/blocks/markdown/test/__snapshots__/markdown-renderer.js.snap b/plugins/jetpack/extensions/blocks/markdown/test/__snapshots__/markdown-renderer.js.snap
new file mode 100644
index 00000000..ebc9bfc2
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/markdown/test/__snapshots__/markdown-renderer.js.snap
@@ -0,0 +1,63 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`MarkdownRenderer renders markdown to HTML as expected 1`] = `
+<RawHTML
+ className="markdown"
+ onClick={[Function]}
+>
+ &lt;h1&gt;Heading&lt;/h1&gt;
+&lt;h2&gt;2nd Heading&lt;/h2&gt;
+&lt;ul&gt;
+&lt;li&gt;List 1&lt;/li&gt;
+&lt;li&gt;List 1&lt;/li&gt;
+&lt;/ul&gt;
+&lt;ul&gt;
+&lt;li&gt;List 2&lt;/li&gt;
+&lt;li&gt;List 2&lt;/li&gt;
+&lt;/ul&gt;
+&lt;ul&gt;
+&lt;li&gt;List 3&lt;/li&gt;
+&lt;li&gt;List 3&lt;/li&gt;
+&lt;/ul&gt;
+&lt;ol&gt;
+&lt;li&gt;Red&lt;/li&gt;
+&lt;li&gt;Green&lt;/li&gt;
+&lt;li&gt;Blue&lt;/li&gt;
+&lt;/ol&gt;
+&lt;ul&gt;
+&lt;li&gt;
+&lt;p&gt;A list item.&lt;/p&gt;
+&lt;p&gt;With multiple paragraphs.&lt;/p&gt;
+&lt;/li&gt;
+&lt;li&gt;
+&lt;p&gt;Another item in the list.&lt;/p&gt;
+&lt;/li&gt;
+&lt;/ul&gt;
+&lt;p&gt;&lt;em&gt;em&lt;/em&gt;
+&lt;em&gt;em&lt;/em&gt;
+&lt;strong&gt;strong&lt;/strong&gt;
+&lt;strong&gt;strong&lt;/strong&gt;
+&lt;em&gt;&lt;strong&gt;em strong&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;
+&lt;p&gt;*Literal asterisks*&lt;/p&gt;
+&lt;p&gt;Link to &lt;a href="https://wordpress.com"&gt;WordPress&lt;/a&gt; and &lt;a href="https://jetpack.com/"&gt;https://jetpack.com/&lt;/a&gt;&lt;/p&gt;
+&lt;p&gt;email me: &lt;a href="mailto:address@example.com"&gt;address@example.com&lt;/a&gt;&lt;/p&gt;
+&lt;p&gt;Inline &lt;code&gt;code&lt;/code&gt; here.&lt;/p&gt;
+&lt;pre&gt;&lt;code&gt;Block of code with backticks.
+&lt;/code&gt;&lt;/pre&gt;
+&lt;pre&gt;&lt;code&gt;Block of code prefixed by four spaces
+&lt;/code&gt;&lt;/pre&gt;
+&lt;blockquote&gt;
+&lt;p&gt;a blockquote.&lt;/p&gt;
+&lt;p&gt;2nd paragraph in the blockquote.&lt;/p&gt;
+&lt;h2&gt;H2 in a blockquote&lt;/h2&gt;
+&lt;/blockquote&gt;
+&lt;p&gt;A bunch of horizontal rules:&lt;/p&gt;
+&lt;hr&gt;
+&lt;hr&gt;
+&lt;hr&gt;
+&lt;hr&gt;
+&lt;hr&gt;
+&lt;p&gt;👋&lt;/p&gt;
+
+</RawHTML>
+`;
diff --git a/plugins/jetpack/extensions/blocks/markdown/test/fixtures/source.js b/plugins/jetpack/extensions/blocks/markdown/test/fixtures/source.js
new file mode 100644
index 00000000..0c24e6ba
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/markdown/test/fixtures/source.js
@@ -0,0 +1,63 @@
+export const source = `
+# Heading
+
+## 2nd Heading
+
+- List 1
+- List 1
+
+* List 2
+* List 2
+
++ List 3
++ List 3
+
+1. Red
+2. Green
+3. Blue
+
+* A list item.
+
+ With multiple paragraphs.
+
+* Another item in the list.
+
+_em_
+*em*
+**strong**
+__strong__
+***em strong***
+
+\\\*Literal asterisks\\\*
+
+Link to [WordPress](https://wordpress.com) and <https://jetpack.com/>
+
+email me: <address@example.com>
+
+Inline \`code\` here.
+
+\`\`\`
+Block of code with backticks.
+\`\`\`
+
+ Block of code prefixed by four spaces
+
+> a blockquote.
+>
+> 2nd paragraph in the blockquote.
+>
+> ## H2 in a blockquote
+
+A bunch of horizontal rules:
+
+* * *
+
+***
+
+*****
+
+- - -
+
+---------------------------------------
+
+👋`;
diff --git a/plugins/jetpack/extensions/blocks/markdown/test/markdown-renderer.js b/plugins/jetpack/extensions/blocks/markdown/test/markdown-renderer.js
new file mode 100644
index 00000000..f8890eb3
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/markdown/test/markdown-renderer.js
@@ -0,0 +1,17 @@
+/**
+ * External dependencies
+ */
+import { shallow } from 'enzyme';
+import React from 'react';
+
+/**
+ * Internal dependencies
+ */
+import { source } from './fixtures/source'
+import MarkdownRenderer from '../renderer';
+
+describe( 'MarkdownRenderer', () => {
+ test( 'renders markdown to HTML as expected', () => {
+ expect( shallow( <MarkdownRenderer className='markdown' source={ source } /> ) ).toMatchSnapshot();
+ } );
+} );
diff --git a/plugins/jetpack/extensions/blocks/membership-button/edit.jsx b/plugins/jetpack/extensions/blocks/membership-button/edit.jsx
new file mode 100644
index 00000000..4843f802
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/membership-button/edit.jsx
@@ -0,0 +1,392 @@
+/**
+ * External dependencies
+ */
+
+import classnames from 'classnames';
+import SubmitButton from '../../shared/submit-button';
+import apiFetch from '@wordpress/api-fetch';
+import { __ } from '@wordpress/i18n';
+import { trimEnd } from 'lodash';
+import formatCurrency, { getCurrencyDefaults } from '@automattic/format-currency';
+
+import {
+ Button,
+ ExternalLink,
+ PanelBody,
+ Placeholder,
+ Spinner,
+ TextControl,
+ withNotices,
+ SelectControl,
+} from '@wordpress/components';
+import { InspectorControls, BlockIcon } from '@wordpress/editor';
+import { Fragment, Component } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { icon, SUPPORTED_CURRENCY_LIST } from '.';
+
+const API_STATE_LOADING = 0;
+const API_STATE_CONNECTED = 1;
+const API_STATE_NOTCONNECTED = 2;
+
+const PRODUCT_NOT_ADDING = 0;
+const PRODUCT_FORM = 1;
+const PRODUCT_FORM_SUBMITTED = 2;
+
+class MembershipsButtonEdit extends Component {
+ constructor() {
+ super( ...arguments );
+ this.state = {
+ connected: API_STATE_LOADING,
+ connectURL: null,
+ addingMembershipAmount: PRODUCT_NOT_ADDING,
+ products: [],
+ editedProductCurrency: 'USD',
+ editedProductPrice: 5,
+ editedProductPriceValid: true,
+ editedProductTitle: '',
+ editedProductTitleValid: true,
+ editedProductRenewInterval: '1 month',
+ };
+ this.timeout = null;
+ }
+
+ componentDidMount = () => {
+ this.apiCall();
+ };
+
+ onError = message => {
+ const { noticeOperations } = this.props;
+ noticeOperations.removeAllNotices();
+ noticeOperations.createErrorNotice( message );
+ };
+
+ apiCall = () => {
+ const path = '/wpcom/v2/memberships/status';
+ const method = 'GET';
+ const fetch = { path, method };
+ apiFetch( fetch ).then(
+ result => {
+ const connectURL = result.connect_url;
+ const products = result.products;
+ const connected = result.connected_account_id
+ ? API_STATE_CONNECTED
+ : API_STATE_NOTCONNECTED;
+ this.setState( { connected, connectURL, products } );
+ },
+ result => {
+ const connectURL = null;
+ const connected = API_STATE_NOTCONNECTED;
+ this.setState( { connected, connectURL } );
+ this.onError( result.message );
+ }
+ );
+ };
+ getCurrencyList = SUPPORTED_CURRENCY_LIST.map( value => {
+ const { symbol } = getCurrencyDefaults( value );
+ // if symbol is equal to the code (e.g., 'CHF' === 'CHF'), don't duplicate it.
+ // trim the dot at the end, e.g., 'kr.' becomes 'kr'
+ const label = symbol === value ? value : `${ value } ${ trimEnd( symbol, '.' ) }`;
+ return { value, label };
+ } );
+
+ handleCurrencyChange = editedProductCurrency => this.setState( { editedProductCurrency } );
+ handleRenewIntervalChange = editedProductRenewInterval =>
+ this.setState( { editedProductRenewInterval } );
+
+ handlePriceChange = price => {
+ price = parseFloat( price );
+ this.setState( {
+ editedProductPrice: price,
+ editedProductPriceValid: ! isNaN( price ) && price >= 5,
+ } );
+ };
+
+ handleTitleChange = editedProductTitle =>
+ this.setState( {
+ editedProductTitle,
+ editedProductTitleValid: editedProductTitle.length > 0,
+ } );
+ // eslint-disable-next-line
+ saveProduct = () => {
+ if ( ! this.state.editedProductTitle || this.state.editedProductTitle.length === 0 ) {
+ this.setState( { editedProductTitleValid: false } );
+ return;
+ }
+ if (
+ ! this.state.editedProductPrice ||
+ isNaN( this.state.editedProductPrice ) ||
+ this.state.editedProductPrice < 5
+ ) {
+ this.setState( { editedProductPriceValid: false } );
+ return;
+ }
+ this.setState( { addingMembershipAmount: PRODUCT_FORM_SUBMITTED } );
+ const path = '/wpcom/v2/memberships/product';
+ const method = 'POST';
+ const data = {
+ currency: this.state.editedProductCurrency,
+ price: this.state.editedProductPrice,
+ title: this.state.editedProductTitle,
+ interval: this.state.editedProductRenewInterval,
+ };
+ const fetch = { path, method, data };
+ apiFetch( fetch ).then(
+ result => {
+ this.setState( {
+ addingMembershipAmount: PRODUCT_NOT_ADDING,
+ products: this.state.products.concat( [
+ {
+ id: result.id,
+ title: result.title,
+ interval: result.interval,
+ price: result.price,
+ },
+ ] ),
+ } );
+ },
+ result => {
+ this.setState( { addingMembershipAmount: PRODUCT_FORM } );
+ this.onError( result.message );
+ }
+ );
+ };
+
+ renderAddMembershipAmount = () => {
+ if ( this.state.addingMembershipAmount === PRODUCT_NOT_ADDING ) {
+ return (
+ <Button
+ isDefault
+ isLarge
+ onClick={ () => this.setState( { addingMembershipAmount: PRODUCT_FORM } ) }
+ >
+ { __( 'Add Memberships Amounts', 'jetpack' ) }
+ </Button>
+ );
+ }
+ if ( this.state.addingMembershipAmount === PRODUCT_FORM_SUBMITTED ) {
+ return;
+ }
+
+ return (
+ <div>
+ <div className="membership-button__price-container">
+ <SelectControl
+ className="membership-button__field membership-button__field-currency"
+ label={ __( 'Currency', 'jetpack' ) }
+ onChange={ this.handleCurrencyChange }
+ options={ this.getCurrencyList }
+ value={ this.state.editedProductCurrency }
+ />
+ <TextControl
+ label={ __( 'Price', 'jetpack' ) }
+ className={ classnames( {
+ 'membership-membership-button__field': true,
+ 'membership-button__field-price': true,
+ 'membership-button__field-error': ! this.state.editedProductPriceValid,
+ } ) }
+ onChange={ this.handlePriceChange }
+ placeholder={ formatCurrency( 0, this.state.editedProductCurrency ) }
+ required
+ step="1"
+ type="number"
+ value={ this.state.editedProductPrice || '' }
+ />
+ </div>
+ <TextControl
+ className={ classnames( {
+ 'membership-button__field': true,
+ 'membership-button__field-error': ! this.state.editedProductTitleValid,
+ } ) }
+ label={ __( 'Describe your subscription in a few words', 'jetpack' ) }
+ onChange={ this.handleTitleChange }
+ placeholder={ __( 'Subscription description', 'jetpack' ) }
+ value={ this.state.editedProductTitle }
+ />
+ <SelectControl
+ label={ __( 'Renew interval', 'jetpack' ) }
+ onChange={ this.handleRenewIntervalChange }
+ options={ [
+ {
+ label: __( 'Monthly', 'jetpack' ),
+ value: '1 month',
+ },
+ {
+ label: __( 'Yearly', 'jetpack' ),
+ value: '1 year',
+ },
+ ] }
+ value={ this.state.editedProductRenewInterval }
+ />
+ <div>
+ <Button
+ isDefault
+ isLarge
+ className="membership-button__field-button"
+ onClick={ this.saveProduct }
+ >
+ { __( 'Add Amount', 'jetpack' ) }
+ </Button>
+ <Button
+ isLarge
+ className="membership-button__field-button"
+ onClick={ () => this.setState( { addingMembershipAmount: PRODUCT_NOT_ADDING } ) }
+ >
+ { __( 'Cancel', 'jetpack' ) }
+ </Button>
+ </div>
+ </div>
+ );
+ };
+ getFormattedPriceByProductId = id => {
+ const product = this.state.products
+ .filter( prod => parseInt( prod.id ) === parseInt( id ) )
+ .pop();
+ return formatCurrency( parseFloat( product.price ), product.currency );
+ };
+
+ setMembershipAmount = id =>
+ this.props.setAttributes( {
+ planId: id,
+ submitButtonText: this.getFormattedPriceByProductId( id ) + __( ' Contribution', 'jetpack' ),
+ } );
+
+ renderMembershipAmounts = () => (
+ <div>
+ { this.state.products.map( product => (
+ <Button
+ className="membership-button__field-button"
+ isLarge
+ key={ product.id }
+ onClick={ () => this.setMembershipAmount( product.id ) }
+ >
+ { formatCurrency( parseFloat( product.price ), product.currency ) }
+ </Button>
+ ) ) }
+ </div>
+ );
+
+ renderDisclaimer = () => {
+ return (
+ <div className="membership-button__disclaimer">
+ <ExternalLink href="https://en.support.wordpress.com/memberships/#related-fees">
+ { __( 'Read more about memberships and related fees.', 'jetpack' ) }
+ </ExternalLink>
+ </div>
+ );
+ };
+
+ render = () => {
+ const { className, notices } = this.props;
+ const { connected, connectURL, products } = this.state;
+
+ const inspectorControls = (
+ <InspectorControls>
+ <PanelBody title={ __( 'Product', 'jetpack' ) }>
+ <SelectControl
+ label="Membership plan"
+ value={ this.props.attributes.planId }
+ onChange={ this.setMembershipAmount }
+ options={ this.state.products.map( product => ( {
+ label: formatCurrency( parseFloat( product.price ), product.currency ),
+ value: product.id,
+ key: product.id,
+ } ) ) }
+ />
+ </PanelBody>
+ </InspectorControls>
+ );
+ const blockClasses = classnames( className, [
+ 'components-button',
+ 'is-primary',
+ 'is-button',
+ ] );
+ const blockContent = (
+ <SubmitButton
+ className={ blockClasses }
+ submitButtonText={ this.props.attributes.submitButtonText }
+ attributes={ this.props.attributes }
+ setAttributes={ this.props.setAttributes }
+ />
+ );
+ return (
+ <Fragment>
+ { this.props.noticeUI }
+ { ( connected === API_STATE_LOADING ||
+ this.state.addingMembershipAmount === PRODUCT_FORM_SUBMITTED ) &&
+ ! this.props.attributes.planId && (
+ <Placeholder icon={ <BlockIcon icon={ icon } /> } notices={ notices }>
+ <Spinner />
+ </Placeholder>
+ ) }
+ { ! this.props.attributes.planId && connected === API_STATE_NOTCONNECTED && (
+ <Placeholder
+ icon={ <BlockIcon icon={ icon } /> }
+ label={ __( 'Memberships', 'jetpack' ) }
+ notices={ notices }
+ >
+ <div className="components-placeholder__instructions wp-block-jetpack-membership-button">
+ { __(
+ 'In order to start selling Membership plans, you have to connect to Stripe:',
+ 'jetpack'
+ ) }
+ <br />
+ <br />
+ <Button isDefault isLarge href={ connectURL } target="_blank">
+ { __( 'Connect to Stripe or set up an account', 'jetpack' ) }
+ </Button>
+ <br />
+ <br />
+ <Button isLink onClick={ this.apiCall }>
+ { __( 'Re-check Connection', 'jetpack' ) }
+ </Button>
+ { this.renderDisclaimer() }
+ </div>
+ </Placeholder>
+ ) }
+ { ! this.props.attributes.planId &&
+ connected === API_STATE_CONNECTED &&
+ products.length === 0 && (
+ <Placeholder
+ icon={ <BlockIcon icon={ icon } /> }
+ label={ __( 'Memberships', 'jetpack' ) }
+ notices={ notices }
+ >
+ <div className="components-placeholder__instructions wp-block-jetpack-membership-button">
+ { __( 'Add your first Membership amount:', 'jetpack' ) }
+ <br />
+ <br />
+ { this.renderAddMembershipAmount() }
+ { this.renderDisclaimer() }
+ </div>
+ </Placeholder>
+ ) }
+ { ! this.props.attributes.planId &&
+ this.state.addingMembershipAmount !== PRODUCT_FORM_SUBMITTED &&
+ connected === API_STATE_CONNECTED &&
+ products.length > 0 && (
+ <Placeholder
+ icon={ <BlockIcon icon={ icon } /> }
+ label={ __( 'Memberships', 'jetpack' ) }
+ notices={ notices }
+ >
+ <div className="components-placeholder__instructions wp-block-jetpack-membership-button">
+ { __( 'Select payment amount:', 'jetpack' ) }
+ { this.renderMembershipAmounts() }
+ { __( 'Or add another membership amount:', 'jetpack' ) }
+ <br />
+ { this.renderAddMembershipAmount() }
+ { this.renderDisclaimer() }
+ </div>
+ </Placeholder>
+ ) }
+ { this.state.products && inspectorControls }
+ { this.props.attributes.planId && blockContent }
+ </Fragment>
+ );
+ };
+}
+
+export default withNotices( MembershipsButtonEdit );
diff --git a/plugins/jetpack/extensions/blocks/membership-button/editor.js b/plugins/jetpack/extensions/blocks/membership-button/editor.js
new file mode 100644
index 00000000..d05f4039
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/membership-button/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/plugins/jetpack/extensions/blocks/membership-button/editor.scss b/plugins/jetpack/extensions/blocks/membership-button/editor.scss
new file mode 100644
index 00000000..be104f57
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/membership-button/editor.scss
@@ -0,0 +1,38 @@
+@import './view.scss';
+
+.wp-block-jetpack-membership-button {
+ font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen-Sans, Ubuntu, Cantarell,
+ Helvetica Neue, sans-serif;
+
+ .membership-button__price-container {
+ display: flex;
+ flex-wrap: wrap;
+ }
+ .membership-button__field-price {
+ margin-left: 10px;
+ }
+ .wp-block-jetpack-membership-button_notification {
+ display: block;
+ }
+
+ .editor-rich-text__inline-toolbar {
+ pointer-events: none;
+ .components-toolbar {
+ pointer-events: all;
+ }
+ }
+
+ .membership-button__field-button {
+ margin: 4px;
+ }
+
+ .membership-button__field-error .components-text-control__input {
+ border: 1px solid;
+ border-color: var( --color-error );
+ }
+
+ .membership-button__disclaimer {
+ margin-top: 20px;
+ font-style: italic;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/membership-button/index.js b/plugins/jetpack/extensions/blocks/membership-button/index.js
new file mode 100644
index 00000000..69578a79
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/membership-button/index.js
@@ -0,0 +1,69 @@
+/**
+ * External dependencies
+ */
+import { Path, Rect, SVG, G } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import { __, _x } from '@wordpress/i18n';
+import edit from './edit';
+import './editor.scss';
+
+export const name = 'membership-button';
+
+export const icon = (
+ <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
+ <Rect x="0" fill="none" width="24" height="24" />
+ <G>
+ <Path d="M20 4H4c-1.105 0-2 .895-2 2v12c0 1.105.895 2 2 2h16c1.105 0 2-.895 2-2V6c0-1.105-.895-2-2-2zm0 2v2H4V6h16zM4 18v-6h16v6H4zm2-4h7v2H6v-2zm9 0h3v2h-3v-2z" />
+ </G>
+ </SVG>
+);
+
+export const settings = {
+ title: __( 'Membership Button', 'jetpack' ),
+ icon,
+ description: __( 'Button allowing you to sell subscription products.', 'jetpack' ),
+ category: 'jetpack',
+ keywords: [
+ _x( 'sell', 'block search term', 'jetpack' ),
+ _x( 'subscription', 'block search term', 'jetpack' ),
+ 'stripe',
+ ],
+ attributes: {
+ planId: {
+ type: 'integer',
+ },
+ submitButtonText: {
+ type: 'string',
+ },
+ customBackgroundButtonColor: {
+ type: 'string',
+ },
+ customTextButtonColor: {
+ type: 'string',
+ },
+ },
+ edit,
+ save: () => null,
+};
+
+// These are Stripe Settlement currencies https://stripe.com/docs/currencies since memberships supports only Stripe ATM.
+export const SUPPORTED_CURRENCY_LIST = [
+ 'USD',
+ 'AUD',
+ 'BRL',
+ 'CAD',
+ 'CHF',
+ 'DKK',
+ 'EUR',
+ 'GBP',
+ 'HKD',
+ 'JPY',
+ 'MXN',
+ 'NOK',
+ 'NZD',
+ 'SEK',
+ 'SGD',
+];
diff --git a/plugins/jetpack/extensions/blocks/membership-button/membership-button.php b/plugins/jetpack/extensions/blocks/membership-button/membership-button.php
new file mode 100644
index 00000000..d8488cf2
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/membership-button/membership-button.php
@@ -0,0 +1,19 @@
+<?php // phpcs:disable Squiz.Commenting.FileComment.Missing
+/**
+ * Memberships block.
+ *
+ * @since 7.3.0
+ *
+ * @package Jetpack
+ */
+
+if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) || Jetpack::is_active() ) {
+ require_once JETPACK__PLUGIN_DIR . '/modules/memberships/class-jetpack-memberships.php';
+
+ jetpack_register_block(
+ 'jetpack/membership-button',
+ array(
+ 'render_callback' => array( Jetpack_Memberships::get_instance(), 'render_button' ),
+ )
+ );
+}
diff --git a/plugins/jetpack/extensions/blocks/membership-button/view.js b/plugins/jetpack/extensions/blocks/membership-button/view.js
new file mode 100644
index 00000000..6e10a1d3
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/membership-button/view.js
@@ -0,0 +1,79 @@
+/* global tb_show, tb_remove */
+
+/**
+ * Internal dependencies
+ */
+import './view.scss';
+const name = 'membership-button';
+const blockClassName = 'wp-block-jetpack-' + name;
+
+/**
+ * Since "close" button is inside our checkout iframe, in order to close it, it has to pass a message to higher scope to close the modal.
+ *
+ * @param {event} eventFromIframe - message event that gets emmited in the checkout iframe.
+ * @listens message
+ */
+function handleIframeResult( eventFromIframe ) {
+ if ( eventFromIframe.origin === 'https://subscribe.wordpress.com' && eventFromIframe.data ) {
+ const data = JSON.parse( eventFromIframe.data );
+ if ( data && data.action === 'close' ) {
+ window.removeEventListener( 'message', handleIframeResult );
+ tb_remove();
+ }
+ }
+}
+
+function activateSubscription( block, blogId, planId, poweredText, lang ) {
+ block.addEventListener( 'click', () => {
+ tb_show(
+ null,
+ 'https://subscribe.wordpress.com/memberships/?blog=' +
+ blogId +
+ '&plan=' +
+ planId +
+ '&lang=' +
+ lang +
+ 'TB_iframe=true&height=600&width=400',
+ null
+ );
+ window.addEventListener( 'message', handleIframeResult, false );
+ const tbWindow = document.querySelector( '#TB_window' );
+ tbWindow.classList.add( 'jetpack-memberships-modal' );
+ const footer = document.createElement( 'DIV' );
+ footer.classList.add( 'TB_footer' );
+ footer.innerHTML = poweredText;
+ tbWindow.appendChild( footer );
+ } );
+}
+
+const initializeMembershipButtonBlocks = () => {
+ const membershipButtonBlocks = Array.prototype.slice.call(
+ document.querySelectorAll( '.' + blockClassName )
+ );
+ membershipButtonBlocks.forEach( block => {
+ const blogId = block.getAttribute( 'data-blog-id' );
+ const planId = block.getAttribute( 'data-plan-id' );
+ const lang = block.getAttribute( 'data-lang' );
+ const poweredText = block
+ .getAttribute( 'data-powered-text' )
+ .replace(
+ 'WordPress.com',
+ '<a href="https://wordpress.com" target="_blank" rel="noreferrer noopener">WordPress.com</a>'
+ );
+ try {
+ activateSubscription( block, blogId, planId, poweredText, lang );
+ } catch ( err ) {
+ // eslint-disable-next-line no-console
+ console.error( 'Problem activating Membership Button ' + planId, err );
+ }
+ } );
+};
+
+if ( typeof window !== 'undefined' && typeof document !== 'undefined' ) {
+ // `DOMContentLoaded` may fire before the script has a chance to run
+ if ( document.readyState === 'loading' ) {
+ document.addEventListener( 'DOMContentLoaded', initializeMembershipButtonBlocks );
+ } else {
+ initializeMembershipButtonBlocks();
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/membership-button/view.scss b/plugins/jetpack/extensions/blocks/membership-button/view.scss
new file mode 100644
index 00000000..cc0eb71c
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/membership-button/view.scss
@@ -0,0 +1,49 @@
+/* Additional styling to thickbox that displays modal */
+/* stylelint-disable selector-max-id */
+
+.jetpack-memberships-modal #TB_title {
+ border-radius: 4px 4px 0 0;
+}
+#TB_window.jetpack-memberships-modal {
+ border-radius: 4px;
+ background-color: $muriel-gray-0;
+ background-image: url( 'https://s0.wp.com/i/loading/loading-64.gif' );
+ background-repeat: no-repeat;
+ background-position: center;
+ bottom: 10%;
+ margin-top: 0 !important;
+ top: 10%;
+}
+
+.jetpack-memberships-modal #TB_iframeContent {
+ height: calc( 100% - 50px ) !important;
+}
+@media only screen and ( max-width: 480px ) {
+ #TB_window.jetpack-memberships-modal {
+ bottom: 0;
+ left: 0;
+ margin-left: 0 !important;
+ right: 0;
+ top: 0;
+ width: 100% !important;
+ }
+ .jetpack-memberships-modal #TB_iframeContent {
+ width: 100% !important;
+ }
+}
+
+.jetpack-memberships-modal #TB_iframeContent {
+ height: calc( 100% - 80px ) !important;
+}
+.jetpack-memberships-modal .TB_footer {
+ border-top: 1px solid $muriel-gray-50;
+ color: $muriel-blue-200;
+ font-size: 13px;
+ padding: 4px 0;
+ text-align: center;
+}
+.jetpack-memberships-modal .TB_footer a,
+.jetpack-memberships-modal .TB_footer a:hover,
+.jetpack-memberships-modal .TB_footer a:visited {
+ color: $muriel-hot-blue-500;
+}
diff --git a/plugins/jetpack/extensions/blocks/publicize/connection-verify.js b/plugins/jetpack/extensions/blocks/publicize/connection-verify.js
new file mode 100644
index 00000000..030ebb11
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/publicize/connection-verify.js
@@ -0,0 +1,112 @@
+/**
+ * Publicize connections verification component.
+ *
+ * Component to create Ajax request to check
+ * all connections. If any connection tests failed,
+ * a refresh link may be provided to the user. If
+ * no connection tests fail, this component will
+ * not render anything.
+ */
+
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Button, Notice } from '@wordpress/components';
+import { Component, Fragment } from '@wordpress/element';
+import { compose } from '@wordpress/compose';
+import { withDispatch, withSelect } from '@wordpress/data';
+
+class PublicizeConnectionVerify extends Component {
+ componentDidMount() {
+ this.props.refreshConnections();
+ }
+
+ /**
+ * Opens up popup so user can refresh connection
+ *
+ * Displays pop up with to specified URL where user
+ * can refresh a specific connection.
+ *
+ * @param {object} event Event instance for onClick.
+ */
+ refreshConnectionClick = event => {
+ const { href, title } = event.target;
+ event.preventDefault();
+ // open a popup window
+ // when it is closed, kick off the tests again
+ const popupWin = window.open( href, title, '' );
+ const popupTimer = window.setInterval( () => {
+ if ( false !== popupWin.closed ) {
+ window.clearInterval( popupTimer );
+ this.props.refreshConnections();
+ }
+ }, 500 );
+ };
+
+ renderRefreshableConnections() {
+ const { failedConnections } = this.props;
+ const refreshableConnections = failedConnections.filter( connection => connection.can_refresh );
+
+ if ( refreshableConnections.length ) {
+ return (
+ <Notice className="jetpack-publicize-notice" isDismissible={ false } status="error">
+ <p>
+ { __(
+ 'Before you hit Publish, please refresh the following connection(s) to make sure we can Publicize your post:',
+ 'jetpack'
+ ) }
+ </p>
+ { refreshableConnections.map( connection => (
+ <Button
+ href={ connection.refresh_url }
+ isSmall
+ key={ connection.id }
+ onClick={ this.refreshConnectionClick }
+ title={ connection.refresh_text }
+ >
+ { connection.refresh_text }
+ </Button>
+ ) ) }
+ </Notice>
+ );
+ }
+
+ return null;
+ }
+
+ renderNonRefreshableConnections() {
+ const { failedConnections } = this.props;
+ const nonRefreshableConnections = failedConnections.filter(
+ connection => ! connection.can_refresh
+ );
+
+ if ( nonRefreshableConnections.length ) {
+ return nonRefreshableConnections.map( connection => (
+ <Notice className="jetpack-publicize-notice" isDismissible={ false } status="error">
+ <p>{ connection.test_message }</p>
+ </Notice>
+ ) );
+ }
+
+ return null;
+ }
+
+ render() {
+ return (
+ <Fragment>
+ { this.renderRefreshableConnections() }
+ { this.renderNonRefreshableConnections() }
+ </Fragment>
+ );
+ }
+}
+
+export default compose( [
+ withSelect( select => ( {
+ failedConnections: select( 'jetpack/publicize' ).getFailedConnections(),
+ } ) ),
+ withDispatch( dispatch => ( {
+ refreshConnections: dispatch( 'jetpack/publicize' ).refreshConnectionTestResults,
+ } ) ),
+] )( PublicizeConnectionVerify );
diff --git a/plugins/jetpack/extensions/blocks/publicize/connection.js b/plugins/jetpack/extensions/blocks/publicize/connection.js
new file mode 100644
index 00000000..071a275d
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/publicize/connection.js
@@ -0,0 +1,100 @@
+/**
+ * Publicize connection form component.
+ *
+ * Component to display connection label and a
+ * checkbox to enable/disable the connection for sharing.
+ */
+
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Component } from '@wordpress/element';
+import { Disabled, FormToggle, Notice, ExternalLink } from '@wordpress/components';
+import { withSelect } from '@wordpress/data';
+import { includes } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+import PublicizeServiceIcon from './service-icon';
+import getSiteFragment from '../../shared/get-site-fragment';
+
+class PublicizeConnection extends Component {
+ /**
+ * Displays a message when a connection requires reauthentication. We used this when migrating LinkedIn API usage from v1 to v2,
+ * since the prevous OAuth1 tokens were incompatible with OAuth2.
+ *
+ * @returns {object|?null} Notice about reauthentication
+ */
+ maybeDisplayLinkedInNotice = () =>
+ this.connectionNeedsReauth() && (
+ <Notice className="jetpack-publicize-notice" isDismissible={ false } status="error">
+ <p>
+ { __(
+ 'Your LinkedIn connection needs to be reauthenticated ' +
+ 'to continue working – head to Sharing to take care of it.',
+ 'jetpack'
+ ) }
+ </p>
+ <ExternalLink href={ `https://wordpress.com/marketing/connections/${ getSiteFragment() }` }>
+ { __( 'Go to Sharing settings', 'jetpack' ) }
+ </ExternalLink>
+ </Notice>
+ );
+
+ /**
+ * Check whether the connection needs to be reauthenticated.
+ *
+ * @returns {boolean} True if connection must be reauthenticated.
+ */
+ connectionNeedsReauth = () => includes( this.props.mustReauthConnections, this.props.name );
+
+ onConnectionChange = () => {
+ const { id } = this.props;
+ this.props.toggleConnection( id );
+ };
+
+ connectionIsFailing() {
+ const { failedConnections, name } = this.props;
+ return failedConnections.some( connection => connection.service_name === name );
+ }
+
+ render() {
+ const { disabled, enabled, id, label, name } = this.props;
+ const fieldId = 'connection-' + name + '-' + id;
+ // Genericon names are dash separated
+ const serviceName = name.replace( '_', '-' );
+
+ let toggle = (
+ <FormToggle
+ id={ fieldId }
+ className="jetpack-publicize-connection-toggle"
+ checked={ enabled }
+ onChange={ this.onConnectionChange }
+ />
+ );
+
+ if ( disabled || this.connectionIsFailing() || this.connectionNeedsReauth() ) {
+ toggle = <Disabled>{ toggle }</Disabled>;
+ }
+
+ return (
+ <li>
+ { this.maybeDisplayLinkedInNotice() }
+ <div className="publicize-jetpack-connection-container">
+ <label htmlFor={ fieldId } className="jetpack-publicize-connection-label">
+ <PublicizeServiceIcon serviceName={ serviceName } />
+ <span className="jetpack-publicize-connection-label-copy">{ label }</span>
+ </label>
+ { toggle }
+ </div>
+ </li>
+ );
+ }
+}
+
+export default withSelect( select => ( {
+ failedConnections: select( 'jetpack/publicize' ).getFailedConnections(),
+ mustReauthConnections: select( 'jetpack/publicize' ).getMustReauthConnections(),
+} ) )( PublicizeConnection );
diff --git a/plugins/jetpack/extensions/blocks/publicize/editor.js b/plugins/jetpack/extensions/blocks/publicize/editor.js
new file mode 100644
index 00000000..9adee220
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/publicize/editor.js
@@ -0,0 +1,7 @@
+/**
+ * Internal dependencies
+ */
+import { name, settings } from '.';
+import registerJetpackPlugin from '../../shared/register-jetpack-plugin';
+
+registerJetpackPlugin( name, settings );
diff --git a/plugins/jetpack/extensions/blocks/publicize/editor.scss b/plugins/jetpack/extensions/blocks/publicize/editor.scss
new file mode 100644
index 00000000..0704a5fa
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/publicize/editor.scss
@@ -0,0 +1,100 @@
+@import '../../shared/styles/gutenberg-colors.scss';
+
+.jetpack-publicize-message-box {
+ background-color: $light-gray-300;
+ border-radius: 4px;
+}
+
+.jetpack-publicize-message-box textarea {
+ width: 100%;
+}
+
+.jetpack-publicize-character-count {
+ padding-bottom: 5px;
+ padding-left: 5px;
+}
+
+.jetpack-publicize__connections-list {
+ list-style-type: none;
+ margin: 13px 0;
+}
+
+.publicize-jetpack-connection-container {
+ display: flex;
+}
+
+.jetpack-publicize-gutenberg-social-icon {
+ fill: $dark-gray-500;
+ margin-right: 5px;
+
+ &.is-facebook {
+ fill: var( --color-facebook );
+ }
+ &.is-twitter {
+ fill: var( --color-twitter );
+ }
+ &.is-linkedin {
+ fill: var( --color-linkedin );
+ }
+ &.is-tumblr {
+ fill: var( --color-tumblr );
+ }
+}
+
+.jetpack-publicize-connection-label {
+ flex: 1;
+ margin-right: 5px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ .jetpack-publicize-gutenberg-social-icon,
+ .jetpack-publicize-connection-label-copy {
+ display: inline-block;
+ vertical-align: middle;
+ }
+}
+
+.jetpack-publicize-connection-toggle {
+ margin-top: 3px;
+}
+
+.jetpack-publicize-notice {
+ &.components-notice {
+ margin-left: 0;
+ margin-right: 0;
+ margin-bottom: 13px;
+ }
+
+ .components-button + .components-button {
+ margin-top: 5px;
+ }
+}
+
+.jetpack-publicize-message-note {
+ display: inline-block;
+ margin-bottom: 4px;
+ margin-top: 13px;
+}
+
+.jetpack-publicize-add-connection-wrapper {
+ margin: 15px 0;
+}
+
+.jetpack-publicize-add-connection-container {
+ display: flex;
+
+ a {
+ cursor: pointer;
+ }
+
+ span {
+ vertical-align: middle;
+ }
+}
+
+.jetpack-publicize__connections-list {
+ .components-notice {
+ margin: 5px 0 10px;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/publicize/form-unwrapped.js b/plugins/jetpack/extensions/blocks/publicize/form-unwrapped.js
new file mode 100644
index 00000000..04efc7eb
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/publicize/form-unwrapped.js
@@ -0,0 +1,118 @@
+/**
+ * Publicize sharing form component.
+ *
+ * Displays text area and connection list to allow user
+ * to select connections to share to and write a custom
+ * sharing message.
+ */
+
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+import { __, _n, sprintf } from '@wordpress/i18n';
+import { Component, Fragment } from '@wordpress/element';
+import { uniqueId } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+import PublicizeConnection from './connection';
+import PublicizeSettingsButton from './settings-button';
+
+export const MAXIMUM_MESSAGE_LENGTH = 256;
+
+class PublicizeFormUnwrapped extends Component {
+ state = {
+ hasEditedShareMessage: false,
+ };
+
+ fieldId = uniqueId( 'jetpack-publicize-message-field-' );
+
+ /**
+ * Check to see if form should be disabled.
+ *
+ * Checks full connection list to determine if all are disabled.
+ * If they all are, it returns true to disable whole form.
+ *
+ * @return {boolean} True if whole form should be disabled.
+ */
+ isDisabled() {
+ return this.props.connections.every( connection => ! connection.toggleable );
+ }
+
+ getShareMessage() {
+ const { shareMessage, defaultShareMessage } = this.props;
+ return ! this.state.hasEditedShareMessage && shareMessage === ''
+ ? defaultShareMessage
+ : shareMessage;
+ }
+
+ onMessageChange = event => {
+ const { messageChange } = this.props;
+ this.setState( { hasEditedShareMessage: true } );
+ messageChange( event );
+ };
+
+ render() {
+ const { connections, toggleConnection, refreshCallback } = this.props;
+ const shareMessage = this.getShareMessage();
+ const charactersRemaining = MAXIMUM_MESSAGE_LENGTH - shareMessage.length;
+ const characterCountClass = classnames( 'jetpack-publicize-character-count', {
+ 'wpas-twitter-length-limit': charactersRemaining <= 0,
+ } );
+
+ return (
+ <div id="publicize-form">
+ <ul className="jetpack-publicize__connections-list">
+ { connections.map( ( { display_name, enabled, id, service_name, toggleable } ) => (
+ <PublicizeConnection
+ disabled={ ! toggleable }
+ enabled={ enabled }
+ key={ id }
+ id={ id }
+ label={ display_name }
+ name={ service_name }
+ toggleConnection={ toggleConnection }
+ />
+ ) ) }
+ </ul>
+ <PublicizeSettingsButton refreshCallback={ refreshCallback } />
+ { connections.some( connection => connection.enabled ) && (
+ <Fragment>
+ <label className="jetpack-publicize-message-note" htmlFor={ this.fieldId }>
+ { __( 'Customize your message', 'jetpack' ) }
+ </label>
+ <div className="jetpack-publicize-message-box">
+ <textarea
+ id={ this.fieldId }
+ value={ shareMessage }
+ onChange={ this.onMessageChange }
+ disabled={ this.isDisabled() }
+ maxLength={ MAXIMUM_MESSAGE_LENGTH }
+ placeholder={ __(
+ "Write a message for your audience here. If you leave this blank, we'll use the post title as the message.",
+ 'jetpack'
+ ) }
+ rows={ 4 }
+ />
+ <div className={ characterCountClass }>
+ { sprintf(
+ _n(
+ '%d character remaining',
+ '%d characters remaining',
+ charactersRemaining,
+ 'jetpack'
+ ),
+ charactersRemaining
+ ) }
+ </div>
+ </div>
+ </Fragment>
+ ) }
+ </div>
+ );
+ }
+}
+
+export default PublicizeFormUnwrapped;
diff --git a/plugins/jetpack/extensions/blocks/publicize/form.js b/plugins/jetpack/extensions/blocks/publicize/form.js
new file mode 100644
index 00000000..cb76b54f
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/publicize/form.js
@@ -0,0 +1,72 @@
+/**
+ * Higher Order Publicize sharing form composition.
+ *
+ * Uses Gutenberg data API to dispatch publicize form data to
+ * editor post data in format to match 'publicize' field schema.
+ */
+
+/**
+ * External dependencies
+ */
+import { get } from 'lodash';
+import { compose } from '@wordpress/compose';
+import { withSelect, withDispatch } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import PublicizeFormUnwrapped, { MAXIMUM_MESSAGE_LENGTH } from './form-unwrapped';
+
+const PublicizeForm = compose( [
+ withSelect( select => {
+ const meta = select( 'core/editor' ).getEditedPostAttribute( 'meta' );
+ const postTitle = select( 'core/editor' ).getEditedPostAttribute( 'title' );
+ const message = get( meta, [ 'jetpack_publicize_message' ], '' );
+
+ return {
+ connections: select( 'core/editor' ).getEditedPostAttribute(
+ 'jetpack_publicize_connections'
+ ),
+ defaultShareMessage: postTitle.substr( 0, MAXIMUM_MESSAGE_LENGTH ),
+ shareMessage: message.substr( 0, MAXIMUM_MESSAGE_LENGTH ),
+ };
+ } ),
+ withDispatch( ( dispatch, { connections } ) => ( {
+ /**
+ * Toggle connection enable/disable state based on checkbox.
+ *
+ * Saves enable/disable value to connections property in editor
+ * in field 'jetpack_publicize_connections'.
+ *
+ * @param {number} id ID of the connection being enabled/disabled
+ */
+ toggleConnection( id ) {
+ const newConnections = connections.map( connection => ( {
+ ...connection,
+ enabled: connection.id === id ? ! connection.enabled : connection.enabled,
+ } ) );
+
+ dispatch( 'core/editor' ).editPost( {
+ jetpack_publicize_connections: newConnections,
+ } );
+ },
+
+ /**
+ * Handler for when sharing message is edited.
+ *
+ * Saves edited message to state and to the editor
+ * in field 'jetpack_publicize_message'.
+ *
+ * @param {object} event Change event data from textarea element.
+ */
+ messageChange( event ) {
+ dispatch( 'core/editor' ).editPost( {
+ meta: {
+ jetpack_publicize_message: event.target.value,
+ },
+ } );
+ },
+ } ) ),
+] )( PublicizeFormUnwrapped );
+
+export default PublicizeForm;
diff --git a/plugins/jetpack/extensions/blocks/publicize/index.js b/plugins/jetpack/extensions/blocks/publicize/index.js
new file mode 100644
index 00000000..9d553873
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/publicize/index.js
@@ -0,0 +1,50 @@
+/**
+ * Top-level Publicize plugin for Gutenberg editor.
+ *
+ * Hooks into Gutenberg's PluginPrePublishPanel
+ * to display Jetpack's Publicize UI in the pre-publish flow.
+ *
+ * It also hooks into our dedicated Jetpack plugin sidebar and
+ * displays the Publicize UI there.
+ */
+
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { PanelBody } from '@wordpress/components';
+import { PluginPrePublishPanel } from '@wordpress/edit-post';
+import { PostTypeSupportCheck } from '@wordpress/editor';
+
+/**
+ * Internal dependencies
+ */
+import './editor.scss';
+import './store';
+import JetpackPluginSidebar from '../../shared/jetpack-plugin-sidebar';
+import PublicizePanel from './panel';
+
+export const name = 'publicize';
+
+export const settings = {
+ render: () => (
+ <PostTypeSupportCheck supportKeys="publicize">
+ <JetpackPluginSidebar>
+ <PanelBody title={ __( 'Share this post', 'jetpack' ) }>
+ <PublicizePanel />
+ </PanelBody>
+ </JetpackPluginSidebar>
+ <PluginPrePublishPanel
+ initialOpen
+ id="publicize-title"
+ title={
+ <span id="publicize-defaults" key="publicize-title-span">
+ { __( 'Share this post', 'jetpack' ) }
+ </span>
+ }
+ >
+ <PublicizePanel />
+ </PluginPrePublishPanel>
+ </PostTypeSupportCheck>
+ ),
+};
diff --git a/plugins/jetpack/extensions/blocks/publicize/panel.js b/plugins/jetpack/extensions/blocks/publicize/panel.js
new file mode 100644
index 00000000..81735c48
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/publicize/panel.js
@@ -0,0 +1,51 @@
+/**
+ * Publicize sharing panel component.
+ *
+ * Displays Publicize notifications if no
+ * services are connected or displays form if
+ * services are connected.
+ */
+
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { compose } from '@wordpress/compose';
+import { Fragment } from '@wordpress/element';
+import { withDispatch, withSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import PublicizeConnectionVerify from './connection-verify';
+import PublicizeForm from './form';
+import PublicizeSettingsButton from './settings-button';
+
+const PublicizePanel = ( { connections, refreshConnections } ) => (
+ <Fragment>
+ { connections && connections.some( connection => connection.enabled ) && (
+ <PublicizeConnectionVerify />
+ ) }
+ <div>
+ { __( "Connect and select the accounts where you'd like to share your post.", 'jetpack' ) }
+ </div>
+ { connections && connections.length > 0 && (
+ <PublicizeForm refreshCallback={ refreshConnections } />
+ ) }
+ { connections && 0 === connections.length && (
+ <PublicizeSettingsButton
+ className="jetpack-publicize-add-connection-wrapper"
+ refreshCallback={ refreshConnections }
+ />
+ ) }
+ </Fragment>
+);
+
+export default compose( [
+ withSelect( select => ( {
+ connections: select( 'core/editor' ).getEditedPostAttribute( 'jetpack_publicize_connections' ),
+ } ) ),
+ withDispatch( dispatch => ( {
+ refreshConnections: dispatch( 'core/editor' ).refreshPost,
+ } ) ),
+] )( PublicizePanel );
diff --git a/plugins/jetpack/extensions/blocks/publicize/service-icon.js b/plugins/jetpack/extensions/blocks/publicize/service-icon.js
new file mode 100644
index 00000000..dc5dc392
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/publicize/service-icon.js
@@ -0,0 +1,64 @@
+/**
+ * External dependencies
+ */
+import { G, Icon, Path, Rect, SVG } from '@wordpress/components';
+
+/**
+ * Module variables
+ */
+// @TODO: Import those from https://github.com/Automattic/social-logos when that's possible.
+// Currently we can't directly import icons from there, because all icons are bundled in a single file.
+// This means that to import an icon from there, we'll need to add the entire bundle with all icons to our build.
+// In the future we'd want to export each icon in that repo separately, and then import them separately here.
+const FacebookIcon = (
+ <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+ <Rect x="0" fill="none" width="24" height="24" />
+ <G>
+ <Path d="M20.007 3H3.993C3.445 3 3 3.445 3 3.993v16.013c0 .55.445.994.993.994h8.62v-6.97H10.27V11.31h2.346V9.31c0-2.325 1.42-3.59 3.494-3.59.993 0 1.847.073 2.096.106v2.43h-1.438c-1.128 0-1.346.537-1.346 1.324v1.734h2.69l-.35 2.717h-2.34V21h4.587c.548 0 .993-.445.993-.993V3.993c0-.548-.445-.993-.993-.993z" />
+ </G>
+ </SVG>
+);
+const TwitterIcon = (
+ <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+ <Rect x="0" fill="none" width="24" height="24" />
+ <G>
+ <Path d="M22.23 5.924c-.736.326-1.527.547-2.357.646.847-.508 1.498-1.312 1.804-2.27-.793.47-1.67.812-2.606.996C18.325 4.498 17.258 4 16.078 4c-2.266 0-4.103 1.837-4.103 4.103 0 .322.036.635.106.935-3.41-.17-6.433-1.804-8.457-4.287-.353.607-.556 1.312-.556 2.064 0 1.424.724 2.68 1.825 3.415-.673-.022-1.305-.207-1.86-.514v.052c0 1.988 1.415 3.647 3.293 4.023-.344.095-.707.145-1.08.145-.265 0-.522-.026-.773-.074.522 1.63 2.038 2.817 3.833 2.85-1.404 1.1-3.174 1.757-5.096 1.757-.332 0-.66-.02-.98-.057 1.816 1.164 3.973 1.843 6.29 1.843 7.547 0 11.675-6.252 11.675-11.675 0-.178-.004-.355-.012-.53.802-.578 1.497-1.3 2.047-2.124z" />
+ </G>
+ </SVG>
+);
+const LinkedinIcon = (
+ <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+ <Rect x="0" fill="none" width="24" height="24" />
+ <G>
+ <Path d="M19.7 3H4.3C3.582 3 3 3.582 3 4.3v15.4c0 .718.582 1.3 1.3 1.3h15.4c.718 0 1.3-.582 1.3-1.3V4.3c0-.718-.582-1.3-1.3-1.3zM8.34 18.338H5.666v-8.59H8.34v8.59zM7.003 8.574c-.857 0-1.55-.694-1.55-1.548 0-.855.692-1.548 1.55-1.548.854 0 1.547.694 1.547 1.548 0 .855-.692 1.548-1.546 1.548zm11.335 9.764h-2.67V14.16c0-.995-.017-2.277-1.387-2.277-1.39 0-1.6 1.086-1.6 2.206v4.248h-2.668v-8.59h2.56v1.174h.036c.357-.675 1.228-1.387 2.527-1.387 2.703 0 3.203 1.78 3.203 4.092v4.71z" />
+ </G>
+ </SVG>
+);
+const TumblrIcon = (
+ <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+ <Rect x="0" fill="none" width="24" height="24" />
+ <G>
+ <Path d="M19 3H5c-1.105 0-2 .895-2 2v14c0 1.105.895 2 2 2h14c1.105 0 2-.895 2-2V5c0-1.105-.895-2-2-2zm-5.57 14.265c-2.445.042-3.37-1.742-3.37-2.998V10.6H8.922V9.15c1.703-.615 2.113-2.15 2.21-3.026.006-.06.053-.084.08-.084h1.645V8.9h2.246v1.7H12.85v3.495c.008.476.182 1.13 1.08 1.107.3-.008.698-.094.907-.194l.54 1.6c-.205.297-1.12.642-1.946.657z" />
+ </G>
+ </SVG>
+);
+
+export default ( { serviceName } ) => {
+ const defaultProps = {
+ className: `jetpack-publicize-gutenberg-social-icon is-${ serviceName }`,
+ size: 24,
+ };
+
+ switch ( serviceName ) {
+ case 'facebook':
+ return <Icon icon={ FacebookIcon } { ...defaultProps } />;
+ case 'twitter':
+ return <Icon icon={ TwitterIcon } { ...defaultProps } />;
+ case 'linkedin':
+ return <Icon icon={ LinkedinIcon } { ...defaultProps } />;
+ case 'tumblr':
+ return <Icon icon={ TumblrIcon } { ...defaultProps } />;
+ }
+
+ return null;
+};
diff --git a/plugins/jetpack/extensions/blocks/publicize/settings-button.js b/plugins/jetpack/extensions/blocks/publicize/settings-button.js
new file mode 100644
index 00000000..8e22ee82
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/publicize/settings-button.js
@@ -0,0 +1,73 @@
+/**
+ * Publicize settings button component.
+ *
+ * Component which allows user to click to open settings
+ * in a new window/tab. If window/tab is closed, then
+ * connections will be automatically refreshed.
+ */
+
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import classnames from 'classnames';
+import { Component } from '@wordpress/element';
+import { ExternalLink } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import getSiteFragment from '../../shared/get-site-fragment';
+
+class PublicizeSettingsButton extends Component {
+ getButtonLink() {
+ const siteFragment = getSiteFragment();
+
+ // If running in WP.com wp-admin or in Calypso, we redirect to Calypso sharing settings.
+ if ( siteFragment ) {
+ return `https://wordpress.com/marketing/connections/${ siteFragment }`;
+ }
+
+ // If running in WordPress.org wp-admin we redirect to Sharing settings in wp-admin.
+ return 'options-general.php?page=sharing&publicize_popup=true';
+ }
+
+ /**
+ * Opens up popup so user can view/modify connections
+ *
+ * @param {object} event Event instance for onClick.
+ */
+ settingsClick = event => {
+ const href = this.getButtonLink();
+ const { refreshCallback } = this.props;
+ event.preventDefault();
+ /**
+ * Open a popup window, and
+ * when it is closed, refresh connections
+ */
+ const popupWin = window.open( href, '', '' );
+ const popupTimer = window.setInterval( () => {
+ if ( false !== popupWin.closed ) {
+ window.clearInterval( popupTimer );
+ refreshCallback();
+ }
+ }, 500 );
+ };
+
+ render() {
+ const className = classnames(
+ 'jetpack-publicize-add-connection-container',
+ this.props.className
+ );
+
+ return (
+ <div className={ className }>
+ <ExternalLink onClick={ this.settingsClick }>
+ { __( 'Connect an account', 'jetpack' ) }
+ </ExternalLink>
+ </div>
+ );
+ }
+}
+
+export default PublicizeSettingsButton;
diff --git a/plugins/jetpack/extensions/blocks/publicize/store/actions.js b/plugins/jetpack/extensions/blocks/publicize/store/actions.js
new file mode 100644
index 00000000..e5b71694
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/publicize/store/actions.js
@@ -0,0 +1,41 @@
+/**
+ * Returns an action object used in signalling that
+ * we're setting the Publicize connection test results.
+ *
+ * @param {Array} results Connection test results.
+ *
+ * @return {Object} Action object.
+ */
+export function setConnectionTestResults( results ) {
+ return {
+ type: 'SET_CONNECTION_TEST_RESULTS',
+ results,
+ };
+}
+
+/**
+ * Returns an action object used in signalling that
+ * we're refreshing the Publicize connection test results.
+ *
+ * @return {Object} Action object.
+ */
+export function refreshConnectionTestResults() {
+ return {
+ type: 'REFRESH_CONNECTION_TEST_RESULTS',
+ };
+}
+
+/**
+ * Returns an action object used in signalling that
+ * we're initiating a fetch request to the REST API.
+ *
+ * @param {String} path API endpoint path.
+ *
+ * @return {Object} Action object.
+ */
+export function fetchFromAPI( path ) {
+ return {
+ type: 'FETCH_FROM_API',
+ path,
+ };
+}
diff --git a/plugins/jetpack/extensions/blocks/publicize/store/controls.js b/plugins/jetpack/extensions/blocks/publicize/store/controls.js
new file mode 100644
index 00000000..afe6eccd
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/publicize/store/controls.js
@@ -0,0 +1,19 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Trigger an API Fetch request.
+ *
+ * @param {Object} action Action Object.
+ *
+ * @return {Promise} Fetch request promise.
+ */
+const fetchFromApi = ( { path } ) => {
+ return apiFetch( { path } );
+};
+
+export default {
+ FETCH_FROM_API: fetchFromApi,
+};
diff --git a/plugins/jetpack/extensions/blocks/publicize/store/effects.js b/plugins/jetpack/extensions/blocks/publicize/store/effects.js
new file mode 100644
index 00000000..594c8b72
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/publicize/store/effects.js
@@ -0,0 +1,32 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import { setConnectionTestResults } from './actions';
+
+/**
+ * Effect handler which will refresh the connection test results.
+ *
+ * @param {Object} action Action which had initiated the effect handler.
+ * @param {Object} store Store instance.
+ *
+ * @return {Object} Refresh connection test results action.
+ */
+export async function refreshConnectionTestResults( action, store ) {
+ const { dispatch } = store;
+
+ try {
+ const results = await apiFetch( { path: '/wpcom/v2/publicize/connection-test-results' } );
+ return dispatch( setConnectionTestResults( results ) );
+ } catch ( error ) {
+ // Refreshing connections failed
+ }
+}
+
+export default {
+ REFRESH_CONNECTION_TEST_RESULTS: refreshConnectionTestResults,
+};
diff --git a/plugins/jetpack/extensions/blocks/publicize/store/index.js b/plugins/jetpack/extensions/blocks/publicize/store/index.js
new file mode 100644
index 00000000..337167bc
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/publicize/store/index.js
@@ -0,0 +1,24 @@
+/**
+ * External dependencies
+ */
+import { registerStore } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import * as actions from './actions';
+import * as selectors from './selectors';
+import applyMiddlewares from './middlewares';
+import controls from './controls';
+import reducer from './reducer';
+
+const store = registerStore( 'jetpack/publicize', {
+ actions,
+ controls,
+ reducer,
+ selectors,
+} );
+
+applyMiddlewares( store );
+
+export default store;
diff --git a/plugins/jetpack/extensions/blocks/publicize/store/middlewares.js b/plugins/jetpack/extensions/blocks/publicize/store/middlewares.js
new file mode 100644
index 00000000..1403b808
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/publicize/store/middlewares.js
@@ -0,0 +1,40 @@
+/**
+ * External dependencies
+ */
+import refx from 'refx';
+import { flowRight } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+import effects from './effects';
+
+/**
+ * Applies the custom middlewares used specifically in the Publicize extension.
+ *
+ * @param {Object} store Store Object.
+ *
+ * @return {Object} Update Store Object.
+ */
+export default function applyMiddlewares( store ) {
+ const middlewares = [ refx( effects ) ];
+
+ let enhancedDispatch = () => {
+ throw new Error(
+ 'Dispatching while constructing your middleware is not allowed. ' +
+ 'Other middleware would not be applied to this dispatch.'
+ );
+ };
+ let chain = [];
+
+ const middlewareAPI = {
+ getState: store.getState,
+ dispatch: ( ...args ) => enhancedDispatch( ...args ),
+ };
+ chain = middlewares.map( middleware => middleware( middlewareAPI ) );
+ enhancedDispatch = flowRight( ...chain )( store.dispatch );
+
+ store.dispatch = enhancedDispatch;
+
+ return store;
+}
diff --git a/plugins/jetpack/extensions/blocks/publicize/store/reducer.js b/plugins/jetpack/extensions/blocks/publicize/store/reducer.js
new file mode 100644
index 00000000..80af0701
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/publicize/store/reducer.js
@@ -0,0 +1,18 @@
+/**
+ * Reducer managing Publicize connection test results.
+ *
+ * @param {Object} state Current state.
+ * @param {Object} action Dispatched action.
+ *
+ * @return {Object} Updated state.
+ */
+export default function( state = [], action ) {
+ switch ( action.type ) {
+ case 'SET_CONNECTION_TEST_RESULTS':
+ return action.results;
+ case 'REFRESH_CONNECTION_TEST_RESULTS':
+ return [];
+ }
+
+ return state;
+}
diff --git a/plugins/jetpack/extensions/blocks/publicize/store/selectors.js b/plugins/jetpack/extensions/blocks/publicize/store/selectors.js
new file mode 100644
index 00000000..db86a4fe
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/publicize/store/selectors.js
@@ -0,0 +1,24 @@
+/**
+ * Returns the failed Publicize connections.
+ *
+ * @param {Object} state State object.
+ *
+ * @return {Array} List of connections.
+ */
+export function getFailedConnections( state ) {
+ return state.filter( connection => false === connection.test_success );
+}
+
+/**
+ * Returns a list of Publicize connection service names that require reauthentication from users.
+ * iFor example, when LinkedIn switched its API from v1 to v2.
+ *
+ * @param {Object} state State object.
+ *
+ * @return {Array} List of service names that need reauthentication.
+ */
+export function getMustReauthConnections( state ) {
+ return state
+ .filter( connection => 'must_reauth' === connection.test_success )
+ .map( connection => connection.service_name );
+}
diff --git a/plugins/jetpack/extensions/blocks/related-posts/edit.js b/plugins/jetpack/extensions/blocks/related-posts/edit.js
new file mode 100644
index 00000000..7de102f4
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/related-posts/edit.js
@@ -0,0 +1,252 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { BlockControls, InspectorControls } from '@wordpress/editor';
+import { PanelBody, RangeControl, ToggleControl, Toolbar, Path, SVG } from '@wordpress/components';
+import { Component, Fragment } from '@wordpress/element';
+import { get } from 'lodash';
+import { withSelect } from '@wordpress/data';
+import { compose, withInstanceId } from '@wordpress/compose';
+
+export const MAX_POSTS_TO_SHOW = 6;
+
+function PlaceholderPostEdit( props ) {
+ return (
+ <div
+ className="jp-related-posts-i2__post"
+ id={ props.id }
+ aria-labelledby={ props.id + '-heading' }
+ >
+ <strong id={ props.id + '-heading' } className="jp-related-posts-i2__post-link">
+ { __(
+ "Preview unavailable: you haven't published enough posts with similar content.",
+ 'jetpack'
+ ) }
+ </strong>
+ { props.displayThumbnails && (
+ <figure
+ className="jp-related-posts-i2__post-image-placeholder"
+ aria-label={ __( 'Placeholder image', 'jetpack' ) }
+ >
+ <SVG
+ className="jp-related-posts-i2__post-image-placeholder-square"
+ xmlns="http://www.w3.org/2000/svg"
+ width="100%"
+ height="100%"
+ viewBox="0 0 350 200"
+ >
+ <title>{ __( 'Grey square', 'jetpack' ) }</title>
+ <Path d="M0 0h350v200H0z" fill="#8B8B96" fill-opacity=".1" />
+ </SVG>
+ <SVG
+ className="jp-related-posts-i2__post-image-placeholder-icon"
+ xmlns="http://www.w3.org/2000/svg"
+ width="24"
+ height="24"
+ viewBox="0 0 24 24"
+ >
+ <title>{ __( 'Icon for image', 'jetpack' ) }</title>
+ <Path fill="none" d="M0 0h24v24H0V0z" />
+ <Path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4.86 8.86l-3 3.87L9 13.14 6 17h12l-3.86-5.14z" />
+ </SVG>
+ </figure>
+ ) }
+
+ { props.displayDate && (
+ <div className="jp-related-posts-i2__post-date has-small-font-size">
+ { __( 'August 3, 2018', 'jetpack' ) }
+ </div>
+ ) }
+ { props.displayContext && (
+ <div className="jp-related-posts-i2__post-context has-small-font-size">
+ { __( 'In “Uncategorized”', 'jetpack' ) }
+ </div>
+ ) }
+ </div>
+ );
+}
+
+function RelatedPostsEditItem( props ) {
+ return (
+ <div
+ className="jp-related-posts-i2__post"
+ id={ props.id }
+ aria-labelledby={ props.id + '-heading' }
+ >
+ <a
+ className="jp-related-posts-i2__post-link"
+ id={ props.id + '-heading' }
+ href={ props.post.url }
+ rel="nofollow noopener noreferrer"
+ target="_blank"
+ >
+ { props.post.title }
+ </a>
+ { props.displayThumbnails && props.post.img && props.post.img.src && (
+ <a className="jp-related-posts-i2__post-img-link" href={ props.post.url }>
+ <img
+ className="jp-related-posts-i2__post-img"
+ src={ props.post.img.src }
+ alt={ props.post.title }
+ rel="nofollow noopener noreferrer"
+ target="_blank"
+ />
+ </a>
+ ) }
+ { props.displayDate && (
+ <div className="jp-related-posts-i2__post-date has-small-font-size">
+ { props.post.date }
+ </div>
+ ) }
+ { props.displayContext && (
+ <div className="jp-related-posts-i2__post-context has-small-font-size">
+ { props.post.context }
+ </div>
+ ) }
+ </div>
+ );
+}
+
+function RelatedPostsPreviewRows( props ) {
+ const className = 'jp-related-posts-i2__row';
+
+ let topRowEnd = 0;
+ const displayLowerRow = props.posts.length > 3;
+
+ switch ( props.posts.length ) {
+ case 2:
+ case 4:
+ case 5:
+ topRowEnd = 2;
+ break;
+ default:
+ topRowEnd = 3;
+ break;
+ }
+
+ return (
+ <div>
+ <div className={ className } data-post-count={ props.posts.slice( 0, topRowEnd ).length }>
+ { props.posts.slice( 0, topRowEnd ) }
+ </div>
+ { displayLowerRow && (
+ <div className={ className } data-post-count={ props.posts.slice( topRowEnd ).length }>
+ { props.posts.slice( topRowEnd ) }
+ </div>
+ ) }
+ </div>
+ );
+}
+
+class RelatedPostsEdit extends Component {
+ render() {
+ const { attributes, className, posts, setAttributes, instanceId } = this.props;
+ const { displayContext, displayDate, displayThumbnails, postLayout, postsToShow } = attributes;
+
+ const layoutControls = [
+ {
+ icon: 'grid-view',
+ title: __( 'Grid View', 'jetpack' ),
+ onClick: () => setAttributes( { postLayout: 'grid' } ),
+ isActive: postLayout === 'grid',
+ },
+ {
+ icon: 'list-view',
+ title: __( 'List View', 'jetpack' ),
+ onClick: () => setAttributes( { postLayout: 'list' } ),
+ isActive: postLayout === 'list',
+ },
+ ];
+
+ // To prevent the block from crashing, we need to limit ourselves to the
+ // posts returned by the backend - so if we want 6 posts, but only 3 are
+ // returned, we need to limit ourselves to those 3 and fill in the rest
+ // with placeholders.
+ //
+ // Also, if the site does not have sufficient posts to display related ones
+ // (minimum 10 posts), we also use this code block to fill in the
+ // placeholders.
+ const previewClassName = 'jp-relatedposts-i2';
+ const displayPosts = [];
+ for ( let i = 0; i < postsToShow; i++ ) {
+ if ( posts[ i ] ) {
+ displayPosts.push(
+ <RelatedPostsEditItem
+ id={ `related-posts-${ instanceId }-post-${ i }` }
+ key={ previewClassName + '-' + i }
+ post={ posts[ i ] }
+ displayThumbnails={ displayThumbnails }
+ displayDate={ displayDate }
+ displayContext={ displayContext }
+ />
+ );
+ } else {
+ displayPosts.push(
+ <PlaceholderPostEdit
+ id={ `related-posts-${ instanceId }-post-${ i }` }
+ key={ 'related-post-placeholder-' + i }
+ displayThumbnails={ displayThumbnails }
+ displayDate={ displayDate }
+ displayContext={ displayContext }
+ />
+ );
+ }
+ }
+
+ return (
+ <Fragment>
+ <InspectorControls>
+ <PanelBody title={ __( 'Related Posts Settings', 'jetpack' ) }>
+ <ToggleControl
+ label={ __( 'Display thumbnails', 'jetpack' ) }
+ checked={ displayThumbnails }
+ onChange={ value => setAttributes( { displayThumbnails: value } ) }
+ />
+ <ToggleControl
+ label={ __( 'Display date', 'jetpack' ) }
+ checked={ displayDate }
+ onChange={ value => setAttributes( { displayDate: value } ) }
+ />
+ <ToggleControl
+ label={ __( 'Display context (category or tag)', 'jetpack' ) }
+ checked={ displayContext }
+ onChange={ value => setAttributes( { displayContext: value } ) }
+ />
+ <RangeControl
+ label={ __( 'Number of posts', 'jetpack' ) }
+ value={ postsToShow }
+ onChange={ value =>
+ setAttributes( { postsToShow: Math.min( value, MAX_POSTS_TO_SHOW ) } )
+ }
+ min={ 1 }
+ max={ MAX_POSTS_TO_SHOW }
+ />
+ </PanelBody>
+ </InspectorControls>
+
+ <BlockControls>
+ <Toolbar controls={ layoutControls } />
+ </BlockControls>
+
+ <div className={ className } id={ `related-posts-${ instanceId }` }>
+ <div className={ previewClassName } data-layout={ postLayout }>
+ <RelatedPostsPreviewRows posts={ displayPosts } />
+ </div>
+ </div>
+ </Fragment>
+ );
+ }
+}
+
+export default compose(
+ withInstanceId,
+ withSelect( select => {
+ const { getCurrentPost } = select( 'core/editor' );
+ const posts = get( getCurrentPost(), 'jetpack-related-posts', [] );
+
+ return {
+ posts,
+ };
+ } )
+)( RelatedPostsEdit );
diff --git a/plugins/jetpack/extensions/blocks/related-posts/editor.js b/plugins/jetpack/extensions/blocks/related-posts/editor.js
new file mode 100644
index 00000000..d05f4039
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/related-posts/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/plugins/jetpack/extensions/blocks/related-posts/index.js b/plugins/jetpack/extensions/blocks/related-posts/index.js
new file mode 100644
index 00000000..c9f01c58
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/related-posts/index.js
@@ -0,0 +1,75 @@
+/**
+ * External dependencies
+ */
+import { __, _x } from '@wordpress/i18n';
+import { G, Path, SVG } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import edit from './edit';
+import './style.scss';
+
+export const name = 'related-posts';
+
+export const settings = {
+ title: __( 'Related Posts', 'jetpack' ),
+
+ icon: (
+ <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+ <G stroke="currentColor" strokeWidth="2" strokeLinecap="square">
+ <Path d="M4,4 L4,19 M4,4 L19,4 M4,9 L19,9 M4,14 L19,14 M4,19 L19,19 M9,4 L9,19 M19,4 L19,19" />
+ </G>
+ </SVG>
+ ),
+
+ category: 'jetpack',
+
+ keywords: [
+ _x( 'Similar content', 'block search term', 'jetpack' ),
+ _x( 'Linked', 'block search term', 'jetpack' ),
+ _x( 'Connected', 'block search term', 'jetpack' ),
+ ],
+
+ attributes: {
+ postLayout: {
+ type: 'string',
+ default: 'grid',
+ },
+ displayDate: {
+ type: 'boolean',
+ default: true,
+ },
+ displayThumbnails: {
+ type: 'boolean',
+ default: false,
+ },
+ displayContext: {
+ type: 'boolean',
+ default: false,
+ },
+ postsToShow: {
+ type: 'number',
+ default: 3,
+ },
+ },
+
+ supports: {
+ html: false,
+ multiple: false,
+ reusable: false,
+ },
+
+ transforms: {
+ from: [
+ {
+ type: 'shortcode',
+ tag: 'jetpack-related-posts',
+ },
+ ],
+ },
+
+ edit,
+
+ save: () => null,
+};
diff --git a/plugins/jetpack/extensions/blocks/related-posts/style.scss b/plugins/jetpack/extensions/blocks/related-posts/style.scss
new file mode 100644
index 00000000..bc753513
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/related-posts/style.scss
@@ -0,0 +1,85 @@
+.jp-related-posts-i2 {
+ &__row {
+ display: flex;
+ margin-top: 1.5rem;
+
+ &:first-child {
+ margin-top: 0;
+ }
+
+ &[data-post-count='3'] .jp-related-posts-i2__post {
+ max-width: calc( 33% - 20px );
+ }
+
+ &[data-post-count='2'] .jp-related-posts-i2__post,
+ &[data-post-count='1'] .jp-related-posts-i2__post {
+ max-width: calc( 50% - 20px );
+ }
+ }
+
+ &__post {
+ flex-grow: 1;
+ flex-basis: 0;
+ margin: 0 10px;
+ display: flex;
+ flex-direction: column;
+ }
+
+ &__post-heading, &__post-img-link, &__post-date, &__post-context {
+ flex-direction: row;
+ }
+
+ &__post-img-link, &__post-image-placeholder {
+ order: -1;
+ }
+
+ &__post-heading {
+ margin: 0.5rem 0;
+ font-size: 1rem;
+ line-height: 1.2em;
+ }
+
+ &__post-link {
+ display: block;
+ width: 100%;
+ line-height: 1.2em;
+ margin: 0.2em 0;
+ }
+
+ &__post-img {
+ width: 100%;
+ }
+
+ &__post-image-placeholder {
+ display: block;
+ position: relative;
+ margin: 0 auto;
+ max-width: 350px;
+ &-icon {
+ position: absolute;
+ top: calc( 50% - 12px );
+ left: calc( 50% - 12px );
+ }
+ }
+}
+
+/* List view */
+
+.jp-relatedposts-i2[data-layout='list'] {
+ .jp-related-posts-i2__row {
+ margin-top: 0;
+ display: block;
+ }
+ .jp-related-posts-i2__post {
+ max-width: none;
+ margin: 0;
+ margin-top: 1rem;
+ }
+ .jp-related-posts-i2__post-image-placeholder {
+ max-width: 350px;
+ margin: 0;
+ }
+ .jp-related-posts-i2__post-img-link {
+ margin-top: 1rem;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/repeat-visitor/components/edit.js b/plugins/jetpack/extensions/blocks/repeat-visitor/components/edit.js
new file mode 100644
index 00000000..ff954162
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/repeat-visitor/components/edit.js
@@ -0,0 +1,114 @@
+/**
+ * External dependencies
+ */
+import { __, _n, sprintf } from '@wordpress/i18n';
+import { Notice, TextControl, RadioControl, Placeholder } from '@wordpress/components';
+import { Component } from '@wordpress/element';
+import { InnerBlocks } from '@wordpress/editor';
+import { withSelect } from '@wordpress/data';
+import classNames from 'classnames';
+
+/**
+ * Internal dependencies
+ */
+import { CRITERIA_AFTER, CRITERIA_BEFORE } from '../constants';
+import { icon } from '../index';
+
+const RADIO_OPTIONS = [
+ {
+ value: CRITERIA_AFTER,
+ label: __( 'Show after threshold', 'jetpack' ),
+ },
+ {
+ value: CRITERIA_BEFORE,
+ label: __( 'Show before threshold', 'jetpack' ),
+ },
+];
+
+class RepeatVisitorEdit extends Component {
+ state = {
+ isThresholdValid: true,
+ };
+
+ setCriteria = criteria => this.props.setAttributes( { criteria } );
+ setThreshold = threshold => {
+ if ( /^\d+$/.test( threshold ) && +threshold > 0 ) {
+ this.props.setAttributes( { threshold: +threshold } );
+ this.setState( { isThresholdValid: true } );
+ return;
+ }
+ this.setState( { isThresholdValid: false } );
+ };
+
+ getNoticeLabel() {
+ if ( this.props.attributes.criteria === CRITERIA_AFTER ) {
+ return sprintf(
+ _n(
+ 'This block will only appear to people who have visited this page more than once.',
+ 'This block will only appear to people who have visited this page more than %d times.',
+ +this.props.attributes.threshold,
+ 'jetpack'
+ ),
+ this.props.attributes.threshold
+ );
+ }
+
+ return sprintf(
+ _n(
+ 'This block will only appear to people who are visiting this page for the first time.',
+ 'This block will only appear to people who have visited this page at most %d times.',
+ +this.props.attributes.threshold,
+ 'jetpack'
+ ),
+ this.props.attributes.threshold
+ );
+ }
+
+ render() {
+ return (
+ <div
+ className={ classNames( this.props.className, {
+ 'wp-block-jetpack-repeat-visitor--is-unselected': ! this.props.isSelected,
+ } ) }
+ >
+ <Placeholder
+ icon={ icon }
+ label={ __( 'Repeat Visitor', 'jetpack' ) }
+ className="wp-block-jetpack-repeat-visitor-placeholder"
+ >
+ <TextControl
+ className="wp-block-jetpack-repeat-visitor-threshold"
+ defaultValue={ this.props.attributes.threshold }
+ help={
+ this.state.isThresholdValid ? '' : __( 'Please enter a valid number.', 'jetpack' )
+ }
+ label={ __( 'Visit count threshold', 'jetpack' ) }
+ min="1"
+ onChange={ this.setThreshold }
+ pattern="[0-9]"
+ type="number"
+ />
+
+ <RadioControl
+ label={ __( 'Visibility', 'jetpack' ) }
+ selected={ this.props.attributes.criteria }
+ options={ RADIO_OPTIONS }
+ onChange={ this.setCriteria }
+ />
+ </Placeholder>
+
+ <Notice status="info" isDismissible={ false }>
+ { this.getNoticeLabel() }
+ </Notice>
+ <InnerBlocks />
+ </div>
+ );
+ }
+}
+
+export default withSelect( ( select, ownProps ) => {
+ const { isBlockSelected, hasSelectedInnerBlock } = select( 'core/editor' );
+ return {
+ isSelected: isBlockSelected( ownProps.clientId ) || hasSelectedInnerBlock( ownProps.clientId ),
+ };
+} )( RepeatVisitorEdit );
diff --git a/plugins/jetpack/extensions/blocks/repeat-visitor/components/save.js b/plugins/jetpack/extensions/blocks/repeat-visitor/components/save.js
new file mode 100644
index 00000000..7484c06d
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/repeat-visitor/components/save.js
@@ -0,0 +1,12 @@
+/**
+ * External dependencies
+ */
+import { InnerBlocks } from '@wordpress/editor';
+
+export default ( { className } ) => {
+ return (
+ <div className={ className }>
+ <InnerBlocks.Content />
+ </div>
+ );
+};
diff --git a/plugins/jetpack/extensions/blocks/repeat-visitor/constants.js b/plugins/jetpack/extensions/blocks/repeat-visitor/constants.js
new file mode 100644
index 00000000..09f459d2
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/repeat-visitor/constants.js
@@ -0,0 +1,5 @@
+export const CRITERIA_AFTER = 'after-visits';
+export const CRITERIA_BEFORE = 'before-visits';
+export const DEFAULT_THRESHOLD = 3;
+export const COOKIE_NAME = 'jp-visit-counter';
+export const MAX_COOKIE_AGE = 6 * 30 * 24 * 60 * 60; // 6 months
diff --git a/plugins/jetpack/extensions/blocks/repeat-visitor/editor.js b/plugins/jetpack/extensions/blocks/repeat-visitor/editor.js
new file mode 100644
index 00000000..d05f4039
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/repeat-visitor/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/plugins/jetpack/extensions/blocks/repeat-visitor/editor.scss b/plugins/jetpack/extensions/blocks/repeat-visitor/editor.scss
new file mode 100644
index 00000000..6a4dd9a5
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/repeat-visitor/editor.scss
@@ -0,0 +1,55 @@
+.wp-block-jetpack-repeat-visitor {
+ .components-notice {
+ margin: 1em 0 0;
+ }
+ .components-radio-control__option {
+ text-align: left;
+ }
+ .components-notice__content {
+ margin: 0.5em 0;
+ font-size: 0.8em;
+
+ .components-base-control {
+ display: inline-block;
+ max-width: 8em;
+ vertical-align: middle;
+
+ .components-base-control__field {
+ margin-bottom: 0;
+ }
+ }
+ }
+}
+
+.wp-block-jetpack-repeat-visitor-placeholder {
+ min-height: inherit;
+
+ .components-placeholder__label svg {
+ margin-right: 0.5ch;
+ }
+
+ .components-placeholder__fieldset {
+ flex-wrap: nowrap;
+ .components-base-control {
+ flex-basis: 100%;
+ }
+ }
+
+ .components-base-control__help {
+ color: var( --muriel-hot-red-500 );
+ font-size: 13px;
+ }
+}
+
+.wp-block-jetpack-repeat-visitor--is-unselected .wp-block-jetpack-repeat-visitor-placeholder {
+ display: none;
+}
+
+.wp-block-jetpack-repeat-visitor-threshold {
+ margin-right: 20px;
+
+ .components-text-control__input {
+ width: 5em;
+ text-align: center;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/repeat-visitor/index.js b/plugins/jetpack/extensions/blocks/repeat-visitor/index.js
new file mode 100644
index 00000000..067c1177
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/repeat-visitor/index.js
@@ -0,0 +1,46 @@
+/**
+ * External dependencies
+ */
+import { __, _x } from '@wordpress/i18n';
+import { Path } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import renderMaterialIcon from '../../shared/render-material-icon';
+import edit from './components/edit';
+import save from './components/save';
+import { CRITERIA_AFTER, DEFAULT_THRESHOLD } from './constants';
+import './editor.scss';
+
+export const name = 'repeat-visitor';
+export const icon = renderMaterialIcon(
+ <Path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z" />
+);
+export const settings = {
+ attributes: {
+ criteria: {
+ type: 'string',
+ default: CRITERIA_AFTER,
+ },
+ threshold: {
+ type: 'number',
+ default: DEFAULT_THRESHOLD,
+ },
+ },
+ category: 'jetpack',
+ description: __(
+ 'Control block visibility based on how often a visitor has viewed the page.',
+ 'jetpack'
+ ),
+ icon,
+ keywords: [
+ _x( 'return', 'block search term', 'jetpack' ),
+ _x( 'visitors', 'block search term', 'jetpack' ),
+ _x( 'visibility', 'block search term', 'jetpack' ),
+ ],
+ supports: { html: false },
+ title: __( 'Repeat Visitor', 'jetpack' ),
+ edit,
+ save,
+};
diff --git a/plugins/jetpack/extensions/blocks/repeat-visitor/repeat-visitor.php b/plugins/jetpack/extensions/blocks/repeat-visitor/repeat-visitor.php
new file mode 100644
index 00000000..ccde0648
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/repeat-visitor/repeat-visitor.php
@@ -0,0 +1,41 @@
+<?php
+/**
+ * Repeat Visitor Block
+ *
+ * @since 7.2.0
+ *
+ * @package Jetpack
+ */
+
+jetpack_register_block(
+ 'jetpack/repeat-visitor',
+ array(
+ 'render_callback' => 'jetpack_repeat_visitor_block_render',
+ )
+);
+
+/**
+ * Repeat Visitor block dependency declaration.
+ *
+ * @param array $attributes Array containing the block attributes.
+ * @param string $content String containing the block content.
+ *
+ * @return string
+ */
+function jetpack_repeat_visitor_block_render( $attributes, $content ) {
+ Jetpack_Gutenberg::load_assets_as_required( 'repeat-visitor' );
+
+ $count = isset( $_COOKIE['jp-visit-counter'] ) ? intval( $_COOKIE['jp-visit-counter'] ) : 0;
+ $criteria = isset( $attributes['criteria'] ) ? $attributes['criteria'] : 'after-visits';
+ $threshold = isset( $attributes['threshold'] ) ? intval( $attributes['threshold'] ) : 3;
+
+ if (
+ ( 'after-visits' === $criteria && $count >= $threshold ) ||
+ ( 'before-visits' === $criteria && $count < $threshold )
+ ) {
+ return $content;
+ }
+
+ // return an empty div so that view script increments the visit counter in the cookie.
+ return '<div class="wp-block-jetpack-repeat-visitor"></div>';
+}
diff --git a/plugins/jetpack/extensions/blocks/repeat-visitor/view.js b/plugins/jetpack/extensions/blocks/repeat-visitor/view.js
new file mode 100644
index 00000000..0273932c
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/repeat-visitor/view.js
@@ -0,0 +1,35 @@
+/**
+ * External dependencies
+ */
+import cookie from 'cookie';
+
+/**
+ * Internal dependencies
+ */
+import { COOKIE_NAME, MAX_COOKIE_AGE } from './constants';
+
+function getViewCount() {
+ const cookies = cookie.parse( document.cookie );
+ const value = cookies[ COOKIE_NAME ] || 0;
+ return +value;
+}
+
+function setViewCount( value ) {
+ document.cookie = cookie.serialize( COOKIE_NAME, value, {
+ path: window.location.pathname,
+ maxAge: MAX_COOKIE_AGE,
+ } );
+}
+
+function incrementCookieValue() {
+ const repeatVisitorBlocks = Array.from(
+ document.querySelectorAll( '.wp-block-jetpack-repeat-visitor' )
+ );
+ if ( repeatVisitorBlocks.length === 0 ) {
+ return;
+ }
+
+ setViewCount( getViewCount() + 1 );
+}
+
+window && window.addEventListener( 'load', incrementCookieValue );
diff --git a/plugins/jetpack/extensions/blocks/seo/editor.js b/plugins/jetpack/extensions/blocks/seo/editor.js
new file mode 100644
index 00000000..9adee220
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/seo/editor.js
@@ -0,0 +1,7 @@
+/**
+ * Internal dependencies
+ */
+import { name, settings } from '.';
+import registerJetpackPlugin from '../../shared/register-jetpack-plugin';
+
+registerJetpackPlugin( name, settings );
diff --git a/plugins/jetpack/extensions/blocks/seo/editor.scss b/plugins/jetpack/extensions/blocks/seo/editor.scss
new file mode 100644
index 00000000..70c801d2
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/seo/editor.scss
@@ -0,0 +1,15 @@
+@import '../../shared/styles/gutenberg-colors.scss';
+
+.jetpack-seo-message-box {
+ background-color: $light-gray-300;
+ border-radius: 4px;
+}
+
+.jetpack-seo-message-box textarea {
+ width: 100%;
+}
+
+.jetpack-seo-character-count {
+ padding-bottom: 5px;
+ padding-left: 5px;
+}
diff --git a/plugins/jetpack/extensions/blocks/seo/index.js b/plugins/jetpack/extensions/blocks/seo/index.js
new file mode 100644
index 00000000..98bdca97
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/seo/index.js
@@ -0,0 +1,39 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Fragment } from '@wordpress/element';
+import { PanelBody } from '@wordpress/components';
+import { PluginPrePublishPanel } from '@wordpress/edit-post';
+
+/**
+ * Internal dependencies
+ */
+import './editor.scss';
+import JetpackPluginSidebar from '../../shared/jetpack-plugin-sidebar';
+import SeoPanel from './panel';
+
+export const name = 'seo';
+
+export const settings = {
+ render: () => (
+ <Fragment>
+ <JetpackPluginSidebar>
+ <PanelBody title={ __( 'SEO Description', 'jetpack' ) }>
+ <SeoPanel />
+ </PanelBody>
+ </JetpackPluginSidebar>
+ <PluginPrePublishPanel
+ initialOpen
+ id="seo-title"
+ title={
+ <span id="seo-defaults" key="seo-title-span">
+ { __( 'SEO Description', 'jetpack' ) }
+ </span>
+ }
+ >
+ <SeoPanel />
+ </PluginPrePublishPanel>
+ </Fragment>
+ ),
+};
diff --git a/plugins/jetpack/extensions/blocks/seo/panel.js b/plugins/jetpack/extensions/blocks/seo/panel.js
new file mode 100644
index 00000000..38496052
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/seo/panel.js
@@ -0,0 +1,54 @@
+/**
+ * External dependencies
+ */
+import { __, _n, sprintf } from '@wordpress/i18n';
+import { Component } from '@wordpress/element';
+import { compose } from '@wordpress/compose';
+import { get } from 'lodash';
+import { withDispatch, withSelect } from '@wordpress/data';
+
+class SeoPanel extends Component {
+ onMessageChange = event => {
+ this.props.updateSeoDescription( event.target.value );
+ };
+
+ render() {
+ const { seoDescription } = this.props;
+
+ return (
+ <div className="jetpack-seo-message-box">
+ <textarea
+ value={ seoDescription }
+ onChange={ this.onMessageChange }
+ placeholder={ __( 'Write a description…', 'jetpack' ) }
+ rows={ 4 }
+ />
+ <div className="jetpack-seo-character-count">
+ { sprintf(
+ _n( '%d character', '%d characters', seoDescription.length, 'jetpack' ),
+ seoDescription.length
+ ) }
+ </div>
+ </div>
+ );
+ }
+}
+
+export default compose( [
+ withSelect( select => ( {
+ seoDescription: get(
+ select( 'core/editor' ).getEditedPostAttribute( 'meta' ),
+ [ 'advanced_seo_description' ],
+ ''
+ ),
+ } ) ),
+ withDispatch( dispatch => ( {
+ updateSeoDescription( seoDescription ) {
+ dispatch( 'core/editor' ).editPost( {
+ meta: {
+ advanced_seo_description: seoDescription,
+ },
+ } );
+ },
+ } ) ),
+] )( SeoPanel );
diff --git a/plugins/jetpack/extensions/blocks/sharing/editor.js b/plugins/jetpack/extensions/blocks/sharing/editor.js
new file mode 100644
index 00000000..9adee220
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/sharing/editor.js
@@ -0,0 +1,7 @@
+/**
+ * Internal dependencies
+ */
+import { name, settings } from '.';
+import registerJetpackPlugin from '../../shared/register-jetpack-plugin';
+
+registerJetpackPlugin( name, settings );
diff --git a/plugins/jetpack/extensions/blocks/sharing/index.js b/plugins/jetpack/extensions/blocks/sharing/index.js
new file mode 100644
index 00000000..7f1e0df2
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/sharing/index.js
@@ -0,0 +1,8 @@
+/**
+ * Internal dependencies
+ */
+import SharingCheckbox from './sharing-checkbox';
+
+export const name = 'sharing';
+
+export const settings = { render: SharingCheckbox };
diff --git a/plugins/jetpack/extensions/blocks/sharing/sharing-checkbox.js b/plugins/jetpack/extensions/blocks/sharing/sharing-checkbox.js
new file mode 100644
index 00000000..dfe1e6c5
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/sharing/sharing-checkbox.js
@@ -0,0 +1,45 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { CheckboxControl } from '@wordpress/components';
+import { compose } from '@wordpress/compose';
+import { PostTypeSupportCheck } from '@wordpress/editor';
+import { withDispatch, withSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import JetpackLikesAndSharingPanel from '../../shared/jetpack-likes-and-sharing-panel';
+
+const SharingCheckbox = ( { isSharingEnabled, editPost } ) => (
+ <PostTypeSupportCheck supportKeys="jetpack-sharing-buttons">
+ <JetpackLikesAndSharingPanel>
+ <CheckboxControl
+ label={ __( 'Show sharing buttons.', 'jetpack' ) }
+ checked={ isSharingEnabled }
+ onChange={ value => {
+ editPost( { jetpack_sharing_enabled: value } );
+ } }
+ />
+ </JetpackLikesAndSharingPanel>
+ </PostTypeSupportCheck>
+);
+
+// Fetch the post meta.
+const applyWithSelect = withSelect( select => {
+ const { getEditedPostAttribute } = select( 'core/editor' );
+ const isSharingEnabled = getEditedPostAttribute( 'jetpack_sharing_enabled' );
+
+ return { isSharingEnabled };
+} );
+
+// Provide method to update post meta.
+const applyWithDispatch = withDispatch( dispatch => {
+ const { editPost } = dispatch( 'core/editor' );
+
+ return { editPost };
+} );
+
+// Combine the higher-order components.
+export default compose( [ applyWithSelect, applyWithDispatch ] )( SharingCheckbox );
diff --git a/plugins/jetpack/extensions/blocks/shortlinks/editor.js b/plugins/jetpack/extensions/blocks/shortlinks/editor.js
new file mode 100644
index 00000000..9adee220
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/shortlinks/editor.js
@@ -0,0 +1,7 @@
+/**
+ * Internal dependencies
+ */
+import { name, settings } from '.';
+import registerJetpackPlugin from '../../shared/register-jetpack-plugin';
+
+registerJetpackPlugin( name, settings );
diff --git a/plugins/jetpack/extensions/blocks/shortlinks/index.js b/plugins/jetpack/extensions/blocks/shortlinks/index.js
new file mode 100644
index 00000000..16d9b8b7
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/shortlinks/index.js
@@ -0,0 +1,45 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Component } from '@wordpress/element';
+import { get } from 'lodash';
+import { PanelBody } from '@wordpress/components';
+import { withSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import ClipboardInput from '../../shared/clipboard-input';
+import JetpackPluginSidebar from '../../shared/jetpack-plugin-sidebar';
+
+export const name = 'shortlinks';
+
+export const settings = {
+ render: () => <Shortlinks />,
+};
+
+class ShortlinksPanel extends Component {
+ render() {
+ const { shortlink } = this.props;
+
+ if ( ! shortlink ) {
+ return null;
+ }
+
+ return (
+ <JetpackPluginSidebar>
+ <PanelBody title={ __( 'Shortlink', 'jetpack' ) } className="jetpack-shortlinks__panel">
+ <ClipboardInput link={ shortlink } />
+ </PanelBody>
+ </JetpackPluginSidebar>
+ );
+ }
+}
+
+const Shortlinks = withSelect( select => {
+ const currentPost = select( 'core/editor' ).getCurrentPost();
+ return {
+ shortlink: get( currentPost, 'jetpack_shortlink', '' ),
+ };
+} )( ShortlinksPanel );
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/constants.js b/plugins/jetpack/extensions/blocks/simple-payments/constants.js
new file mode 100644
index 00000000..e593f947
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/constants.js
@@ -0,0 +1,39 @@
+export const SIMPLE_PAYMENTS_PRODUCT_POST_TYPE = 'jp_pay_product';
+
+export const DEFAULT_CURRENCY = 'USD';
+
+// https://developer.paypal.com/docs/integration/direct/rest/currency-codes/
+// If this list changes, Simple Payments in Jetpack must be updated as well.
+// See https://github.com/Automattic/jetpack/blob/master/modules/simple-payments/simple-payments.php
+
+/**
+ * Indian Rupee not supported because at the time of the creation of this file
+ * because it's limited to in-country PayPal India accounts only.
+ * Discussion: https://github.com/Automattic/wp-calypso/pull/28236
+ */
+export const SUPPORTED_CURRENCY_LIST = [
+ DEFAULT_CURRENCY,
+ 'EUR',
+ 'AUD',
+ 'BRL',
+ 'CAD',
+ 'CZK',
+ 'DKK',
+ 'HKD',
+ 'HUF',
+ 'ILS',
+ 'JPY',
+ 'MYR',
+ 'MXN',
+ 'TWD',
+ 'NZD',
+ 'NOK',
+ 'PHP',
+ 'PLN',
+ 'GBP',
+ 'RUB',
+ 'SGD',
+ 'SEK',
+ 'CHF',
+ 'THB',
+];
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/edit.js b/plugins/jetpack/extensions/blocks/simple-payments/edit.js
new file mode 100644
index 00000000..f49ca94d
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/edit.js
@@ -0,0 +1,579 @@
+/**
+ * External dependencies
+ */
+import classNames from 'classnames';
+import emailValidator from 'email-validator';
+import { __, _n, sprintf } from '@wordpress/i18n';
+import { Component } from '@wordpress/element';
+import { compose, withInstanceId } from '@wordpress/compose';
+import { dispatch, withSelect } from '@wordpress/data';
+import { get, isEmpty, isEqual, pick, trimEnd } from 'lodash';
+import { getCurrencyDefaults } from '@automattic/format-currency';
+import {
+ Disabled,
+ ExternalLink,
+ SelectControl,
+ TextareaControl,
+ TextControl,
+ ToggleControl,
+} from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import HelpMessage from './help-message';
+import ProductPlaceholder from './product-placeholder';
+import FeaturedMedia from './featured-media';
+import { decimalPlaces, formatPrice } from './utils';
+import { SIMPLE_PAYMENTS_PRODUCT_POST_TYPE, SUPPORTED_CURRENCY_LIST } from './constants';
+
+class SimplePaymentsEdit extends Component {
+ state = {
+ fieldEmailError: null,
+ fieldPriceError: null,
+ fieldTitleError: null,
+ isSavingProduct: false,
+ };
+
+ /**
+ * We'll use this flag to inject attributes one time when the product entity is loaded.
+ *
+ * It is based on the presence of a `productId` attribute.
+ *
+ * If present, initially we are waiting for attributes to be injected.
+ * If absent, we may save the product in the future but do not need to inject attributes based
+ * on the response as they will have come from our product submission.
+ */
+ shouldInjectPaymentAttributes = !! this.props.attributes.productId;
+
+ componentDidMount() {
+ // Try to get the simplePayment loaded into attributes if possible.
+ this.injectPaymentAttributes();
+
+ const { attributes, hasPublishAction } = this.props;
+ const { productId } = attributes;
+
+ // If the user can publish save an empty product so that we have an ID and can save
+ // concurrently with the post that contains the Simple Payment.
+ if ( ! productId && hasPublishAction ) {
+ this.saveProduct();
+ }
+ }
+
+ componentDidUpdate( prevProps ) {
+ const { hasPublishAction, isSelected } = this.props;
+
+ if ( ! isEqual( prevProps.simplePayment, this.props.simplePayment ) ) {
+ this.injectPaymentAttributes();
+ }
+
+ if (
+ ! prevProps.isSaving &&
+ this.props.isSaving &&
+ hasPublishAction &&
+ this.validateAttributes()
+ ) {
+ // Validate and save product on post save
+ this.saveProduct();
+ } else if ( prevProps.isSelected && ! isSelected ) {
+ // Validate on block deselect
+ this.validateAttributes();
+ }
+ }
+
+ injectPaymentAttributes() {
+ /**
+ * Prevent injecting the product attributes when not desired.
+ *
+ * When we first load a product, we should inject its attributes as our initial form state.
+ * When subsequent saves occur, we should avoid injecting attributes so that we do not
+ * overwrite changes that the user has made with stale state from the previous save.
+ */
+
+ const { simplePayment } = this.props;
+ if ( ! this.shouldInjectPaymentAttributes || isEmpty( simplePayment ) ) {
+ return;
+ }
+
+ const { attributes, setAttributes } = this.props;
+ const { content, currency, email, featuredMediaId, multiple, price, title } = attributes;
+
+ setAttributes( {
+ content: get( simplePayment, [ 'content', 'raw' ], content ),
+ currency: get( simplePayment, [ 'meta', 'spay_currency' ], currency ),
+ email: get( simplePayment, [ 'meta', 'spay_email' ], email ),
+ featuredMediaId: get( simplePayment, [ 'featured_media' ], featuredMediaId ),
+ multiple: Boolean( get( simplePayment, [ 'meta', 'spay_multiple' ], Boolean( multiple ) ) ),
+ price: get( simplePayment, [ 'meta', 'spay_price' ], price || undefined ),
+ title: get( simplePayment, [ 'title', 'raw' ], title ),
+ } );
+
+ this.shouldInjectPaymentAttributes = ! this.shouldInjectPaymentAttributes;
+ }
+
+ toApi() {
+ const { attributes } = this.props;
+ const {
+ content,
+ currency,
+ email,
+ featuredMediaId,
+ multiple,
+ price,
+ productId,
+ title,
+ } = attributes;
+
+ return {
+ id: productId,
+ content,
+ featured_media: featuredMediaId,
+ meta: {
+ spay_currency: currency,
+ spay_email: email,
+ spay_multiple: multiple,
+ spay_price: price,
+ },
+ status: productId ? 'publish' : 'draft',
+ title,
+ };
+ }
+
+ saveProduct() {
+ if ( this.state.isSavingProduct ) {
+ return;
+ }
+
+ const { attributes, setAttributes } = this.props;
+ const { email } = attributes;
+ const { saveEntityRecord } = dispatch( 'core' );
+
+ this.setState( { isSavingProduct: true }, () => {
+ saveEntityRecord( 'postType', SIMPLE_PAYMENTS_PRODUCT_POST_TYPE, this.toApi() )
+ .then( record => {
+ if ( record ) {
+ setAttributes( { productId: record.id } );
+ }
+
+ return record;
+ } )
+ .catch( error => {
+ // Nothing we can do about errors without details at the moment
+ if ( ! error || ! error.data ) {
+ return;
+ }
+
+ const {
+ data: { key: apiErrorKey },
+ } = error;
+
+ // @TODO errors in other fields
+ this.setState( {
+ fieldEmailError:
+ apiErrorKey === 'spay_email'
+ ? sprintf( __( '%s is not a valid email address.', 'jetpack' ), email )
+ : null,
+ fieldPriceError:
+ apiErrorKey === 'spay_price' ? __( 'Invalid price.', 'jetpack' ) : null,
+ } );
+ } )
+ .finally( () => {
+ this.setState( {
+ isSavingProduct: false,
+ } );
+ } );
+ } );
+ }
+
+ validateAttributes = () => {
+ const isPriceValid = this.validatePrice();
+ const isTitleValid = this.validateTitle();
+ const isEmailValid = this.validateEmail();
+ const isCurrencyValid = this.validateCurrency();
+
+ return isPriceValid && isTitleValid && isEmailValid && isCurrencyValid;
+ };
+
+ /**
+ * Validate currency
+ *
+ * This method does not include validation UI. Currency selection should not allow for invalid
+ * values. It is primarily to ensure that the currency is valid to save.
+ *
+ * @return {boolean} True if currency is valid
+ */
+ validateCurrency = () => {
+ const { currency } = this.props.attributes;
+ return SUPPORTED_CURRENCY_LIST.includes( currency );
+ };
+
+ /**
+ * Validate price
+ *
+ * Stores error message in state.fieldPriceError
+ *
+ * @returns {Boolean} True when valid, false when invalid
+ */
+ validatePrice = () => {
+ const { currency, price } = this.props.attributes;
+ const { precision } = getCurrencyDefaults( currency );
+
+ if ( ! price || parseFloat( price ) === 0 ) {
+ this.setState( {
+ fieldPriceError: __(
+ 'If you’re selling something, you need a price tag. Add yours here.',
+ 'jetpack'
+ ),
+ } );
+ return false;
+ }
+
+ if ( Number.isNaN( parseFloat( price ) ) ) {
+ this.setState( {
+ fieldPriceError: __( 'Invalid price', 'jetpack' ),
+ } );
+ return false;
+ }
+
+ if ( parseFloat( price ) < 0 ) {
+ this.setState( {
+ fieldPriceError: __(
+ 'Your price is negative — enter a positive number so people can pay the right amount.',
+ 'jetpack'
+ ),
+ } );
+ return false;
+ }
+
+ if ( decimalPlaces( price ) > precision ) {
+ if ( precision === 0 ) {
+ this.setState( {
+ fieldPriceError: __(
+ 'We know every penny counts, but prices in this currency can’t contain decimal values.',
+ 'jetpack'
+ ),
+ } );
+ return false;
+ }
+
+ this.setState( {
+ fieldPriceError: sprintf(
+ _n(
+ 'The price cannot have more than %d decimal place.',
+ 'The price cannot have more than %d decimal places.',
+ precision,
+ 'jetpack'
+ ),
+ precision
+ ),
+ } );
+ return false;
+ }
+
+ if ( this.state.fieldPriceError ) {
+ this.setState( { fieldPriceError: null } );
+ }
+
+ return true;
+ };
+
+ /**
+ * Validate email
+ *
+ * Stores error message in state.fieldEmailError
+ *
+ * @returns {Boolean} True when valid, false when invalid
+ */
+ validateEmail = () => {
+ const { email } = this.props.attributes;
+ if ( ! email ) {
+ this.setState( {
+ fieldEmailError: __(
+ 'We want to make sure payments reach you, so please add an email address.',
+ 'jetpack'
+ ),
+ } );
+ return false;
+ }
+
+ if ( ! emailValidator.validate( email ) ) {
+ this.setState( {
+ fieldEmailError: sprintf( __( '%s is not a valid email address.', 'jetpack' ), email ),
+ } );
+ return false;
+ }
+
+ if ( this.state.fieldEmailError ) {
+ this.setState( { fieldEmailError: null } );
+ }
+
+ return true;
+ };
+
+ /**
+ * Validate title
+ *
+ * Stores error message in state.fieldTitleError
+ *
+ * @returns {Boolean} True when valid, false when invalid
+ */
+ validateTitle = () => {
+ const { title } = this.props.attributes;
+ if ( ! title ) {
+ this.setState( {
+ fieldTitleError: __(
+ 'Please add a brief title so that people know what they’re paying for.',
+ 'jetpack'
+ ),
+ } );
+ return false;
+ }
+
+ if ( this.state.fieldTitleError ) {
+ this.setState( { fieldTitleError: null } );
+ }
+
+ return true;
+ };
+
+ handleEmailChange = email => {
+ this.props.setAttributes( { email } );
+ this.setState( { fieldEmailError: null } );
+ };
+
+ handleFeaturedMediaSelect = media => {
+ this.props.setAttributes( { featuredMediaId: get( media, 'id', 0 ) } );
+ };
+
+ handleContentChange = content => {
+ this.props.setAttributes( { content } );
+ };
+
+ handlePriceChange = price => {
+ price = parseFloat( price );
+ if ( ! isNaN( price ) ) {
+ this.props.setAttributes( { price } );
+ } else {
+ this.props.setAttributes( { price: undefined } );
+ }
+ this.setState( { fieldPriceError: null } );
+ };
+
+ handleCurrencyChange = currency => {
+ this.props.setAttributes( { currency } );
+ };
+
+ handleMultipleChange = multiple => {
+ this.props.setAttributes( { multiple: !! multiple } );
+ };
+
+ handleTitleChange = title => {
+ this.props.setAttributes( { title } );
+ this.setState( { fieldTitleError: null } );
+ };
+
+ getCurrencyList = SUPPORTED_CURRENCY_LIST.map( value => {
+ const { symbol } = getCurrencyDefaults( value );
+ // if symbol is equal to the code (e.g., 'CHF' === 'CHF'), don't duplicate it.
+ // trim the dot at the end, e.g., 'kr.' becomes 'kr'
+ const label = symbol === value ? value : `${ value } ${ trimEnd( symbol, '.' ) }`;
+ return { value, label };
+ } );
+
+ render() {
+ const { fieldEmailError, fieldPriceError, fieldTitleError } = this.state;
+ const {
+ attributes,
+ featuredMedia,
+ instanceId,
+ isSelected,
+ setAttributes,
+ simplePayment,
+ } = this.props;
+ const {
+ content,
+ currency,
+ email,
+ featuredMediaId,
+ featuredMediaUrl: featuredMediaUrlAttribute,
+ featuredMediaTitle: featuredMediaTitleAttribute,
+ multiple,
+ price,
+ productId,
+ title,
+ } = attributes;
+
+ const featuredMediaUrl =
+ featuredMediaUrlAttribute || ( featuredMedia && featuredMedia.source_url );
+ const featuredMediaTitle =
+ featuredMediaTitleAttribute || ( featuredMedia && featuredMedia.alt_text );
+
+ /**
+ * The only disabled state that concerns us is when we expect a product but don't have it in
+ * local state.
+ */
+ const isDisabled = productId && isEmpty( simplePayment );
+
+ if ( ! isSelected && isDisabled ) {
+ return (
+ <div className="simple-payments__loading">
+ <ProductPlaceholder
+ aria-busy="true"
+ content="█████"
+ formattedPrice="█████"
+ title="█████"
+ />
+ </div>
+ );
+ }
+
+ if (
+ ! isSelected &&
+ email &&
+ price &&
+ title &&
+ ! fieldEmailError &&
+ ! fieldPriceError &&
+ ! fieldTitleError
+ ) {
+ return (
+ <ProductPlaceholder
+ aria-busy="false"
+ content={ content }
+ featuredMediaUrl={ featuredMediaUrl }
+ featuredMediaTitle={ featuredMediaTitle }
+ formattedPrice={ formatPrice( price, currency ) }
+ multiple={ multiple }
+ title={ title }
+ />
+ );
+ }
+
+ const Wrapper = isDisabled ? Disabled : 'div';
+
+ return (
+ <Wrapper className="wp-block-jetpack-simple-payments">
+ <FeaturedMedia
+ { ...{ featuredMediaId, featuredMediaUrl, featuredMediaTitle, setAttributes } }
+ />
+ <div>
+ <TextControl
+ aria-describedby={ `${ instanceId }-title-error` }
+ className={ classNames( 'simple-payments__field', 'simple-payments__field-title', {
+ 'simple-payments__field-has-error': fieldTitleError,
+ } ) }
+ label={ __( 'Item name', 'jetpack' ) }
+ onChange={ this.handleTitleChange }
+ placeholder={ __( 'Item name', 'jetpack' ) }
+ required
+ type="text"
+ value={ title }
+ />
+ <HelpMessage id={ `${ instanceId }-title-error` } isError>
+ { fieldTitleError }
+ </HelpMessage>
+
+ <TextareaControl
+ className="simple-payments__field simple-payments__field-content"
+ label={ __( 'Describe your item in a few words', 'jetpack' ) }
+ onChange={ this.handleContentChange }
+ placeholder={ __( 'Describe your item in a few words', 'jetpack' ) }
+ value={ content }
+ />
+
+ <div className="simple-payments__price-container">
+ <SelectControl
+ className="simple-payments__field simple-payments__field-currency"
+ label={ __( 'Currency', 'jetpack' ) }
+ onChange={ this.handleCurrencyChange }
+ options={ this.getCurrencyList }
+ value={ currency }
+ />
+ <TextControl
+ aria-describedby={ `${ instanceId }-price-error` }
+ className={ classNames( 'simple-payments__field', 'simple-payments__field-price', {
+ 'simple-payments__field-has-error': fieldPriceError,
+ } ) }
+ label={ __( 'Price', 'jetpack' ) }
+ onChange={ this.handlePriceChange }
+ placeholder={ formatPrice( 0, currency, false ) }
+ required
+ step="1"
+ type="number"
+ value={ price || '' }
+ />
+ <HelpMessage id={ `${ instanceId }-price-error` } isError>
+ { fieldPriceError }
+ </HelpMessage>
+ </div>
+
+ <div className="simple-payments__field-multiple">
+ <ToggleControl
+ checked={ Boolean( multiple ) }
+ label={ __( 'Allow people to buy more than one item at a time', 'jetpack' ) }
+ onChange={ this.handleMultipleChange }
+ />
+ </div>
+
+ <TextControl
+ aria-describedby={ `${ instanceId }-email-${ fieldEmailError ? 'error' : 'help' }` }
+ className={ classNames( 'simple-payments__field', 'simple-payments__field-email', {
+ 'simple-payments__field-has-error': fieldEmailError,
+ } ) }
+ label={ __( 'Email', 'jetpack' ) }
+ onChange={ this.handleEmailChange }
+ placeholder={ __( 'Email', 'jetpack' ) }
+ required
+ type="email"
+ value={ email }
+ />
+ <HelpMessage id={ `${ instanceId }-email-error` } isError>
+ { fieldEmailError }
+ </HelpMessage>
+ <HelpMessage id={ `${ instanceId }-email-help` }>
+ { __(
+ 'Enter the email address associated with your PayPal account. Don’t have an account?',
+ 'jetpack'
+ ) + ' ' }
+ <ExternalLink href="https://www.paypal.com/">
+ { __( 'Create one on PayPal', 'jetpack' ) }
+ </ExternalLink>
+ </HelpMessage>
+ </div>
+ </Wrapper>
+ );
+ }
+}
+
+const mapSelectToProps = withSelect( ( select, props ) => {
+ const { getEntityRecord, getMedia } = select( 'core' );
+ const { isSavingPost, getCurrentPost } = select( 'core/editor' );
+
+ const { productId, featuredMediaId } = props.attributes;
+
+ const fields = [
+ [ 'content' ],
+ [ 'meta', 'spay_currency' ],
+ [ 'meta', 'spay_email' ],
+ [ 'meta', 'spay_multiple' ],
+ [ 'meta', 'spay_price' ],
+ [ 'title', 'raw' ],
+ [ 'featured_media' ],
+ ];
+
+ const simplePayment = productId
+ ? pick( getEntityRecord( 'postType', SIMPLE_PAYMENTS_PRODUCT_POST_TYPE, productId ), fields )
+ : undefined;
+
+ return {
+ hasPublishAction: !! get( getCurrentPost(), [ '_links', 'wp:action-publish' ] ),
+ isSaving: !! isSavingPost(),
+ simplePayment,
+ featuredMedia: featuredMediaId ? getMedia( featuredMediaId ) : null,
+ };
+} );
+
+export default compose(
+ mapSelectToProps,
+ withInstanceId
+)( SimplePaymentsEdit );
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/editor.js b/plugins/jetpack/extensions/blocks/simple-payments/editor.js
new file mode 100644
index 00000000..d05f4039
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/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/plugins/jetpack/extensions/blocks/simple-payments/editor.scss b/plugins/jetpack/extensions/blocks/simple-payments/editor.scss
new file mode 100644
index 00000000..3345a324
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/editor.scss
@@ -0,0 +1,63 @@
+@import '../../shared/styles/gutenberg-colors.scss';
+@import '../../shared/styles/gutenberg-variables.scss';
+
+.wp-block-jetpack-simple-payments {
+ font-family: $default-font;
+ display: grid;
+ grid-template-columns: 200px auto;
+ grid-column-gap: 10px;
+
+ .simple-payments__field {
+ .components-base-control__label {
+ display: none;
+ }
+ .components-base-control__field {
+ margin-bottom: 1em;
+ }
+ // Reset empty space under textarea on Chrome
+ textarea {
+ display: block;
+ }
+ }
+
+ .simple-payments__field-has-error {
+ .components-text-control__input,
+ .components-textarea-control__input {
+ border-color: var( --color-error );
+ }
+ }
+
+ .simple-payments__price-container {
+ display: flex;
+ flex-wrap: wrap;
+ .simple-payments__field {
+ margin-right: 10px;
+ }
+ .simple-payments__help-message {
+ flex: 1 1 100%;
+ margin-top: 0;
+ }
+ }
+
+ .simple-payments__field-price {
+ .components-text-control__input {
+ max-width: 90px;
+ }
+ }
+
+ .simple-payments__field-email {
+ .components-text-control__input {
+ max-width: 400px;
+ }
+ }
+
+ .simple-payments__field-multiple {
+ .components-toggle-control__label {
+ line-height: 1.4em;
+ }
+ }
+
+ .simple-payments__field-content .components-textarea-control__input {
+ min-height: 32px;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/featured-media.js b/plugins/jetpack/extensions/blocks/simple-payments/featured-media.js
new file mode 100644
index 00000000..c0da48a9
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/featured-media.js
@@ -0,0 +1,69 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { BlockControls, MediaPlaceholder, MediaUpload } from '@wordpress/editor';
+import { Fragment } from '@wordpress/element';
+import { get } from 'lodash';
+import { IconButton, Toolbar, ToolbarButton } from '@wordpress/components';
+
+const onSelectMedia = setAttributes => media =>
+ setAttributes( {
+ featuredMediaId: get( media, 'id', 0 ),
+ featuredMediaUrl: get( media, 'url', null ),
+ featuredMediaTitle: get( media, 'title', null ),
+ } );
+
+export default ( { featuredMediaId, featuredMediaUrl, featuredMediaTitle, setAttributes } ) => {
+ if ( ! featuredMediaId ) {
+ return (
+ <MediaPlaceholder
+ icon="format-image"
+ labels={ {
+ title: __( 'Product Image', 'jetpack' ),
+ } }
+ accept="image/*"
+ allowedTypes={ [ 'image' ] }
+ onSelect={ onSelectMedia( setAttributes ) }
+ />
+ );
+ }
+
+ return (
+ <div>
+ <Fragment>
+ <BlockControls>
+ <Toolbar>
+ <MediaUpload
+ onSelect={ onSelectMedia( setAttributes ) }
+ allowedTypes={ [ 'image' ] }
+ value={ featuredMediaId }
+ render={ ( { open } ) => (
+ <IconButton
+ className="components-toolbar__control"
+ label={ __( 'Edit Image', 'jetpack' ) }
+ icon="edit"
+ onClick={ open }
+ />
+ ) }
+ />
+ <ToolbarButton
+ icon={ 'trash' }
+ title={ __( 'Remove Image', 'jetpack' ) }
+ onClick={ () =>
+ setAttributes( {
+ featuredMediaId: null,
+ featuredMediaUrl: null,
+ featuredMediaTitle: null,
+ } )
+ }
+ />
+ </Toolbar>
+ </BlockControls>
+ <figure>
+ <img src={ featuredMediaUrl } alt={ featuredMediaTitle } />
+ </figure>
+ </Fragment>
+ </div>
+ );
+};
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/help-message.js b/plugins/jetpack/extensions/blocks/simple-payments/help-message.js
new file mode 100644
index 00000000..57a6e681
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/help-message.js
@@ -0,0 +1,25 @@
+/**
+ * External dependencies
+ */
+import classNames from 'classnames';
+
+/**
+ * Internal dependencies
+ */
+import GridiconNoticeOutline from 'gridicons/dist/notice-outline';
+import './help-message.scss';
+
+export default ( { children = null, isError = false, ...props } ) => {
+ const classes = classNames( 'simple-payments__help-message', {
+ 'simple-payments__help-message-is-error': isError,
+ } );
+
+ return (
+ children && (
+ <div className={ classes } { ...props }>
+ { isError && <GridiconNoticeOutline size="24" /> }
+ <span>{ children }</span>
+ </div>
+ )
+ );
+};
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/help-message.scss b/plugins/jetpack/extensions/blocks/simple-payments/help-message.scss
new file mode 100644
index 00000000..86f50f9e
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/help-message.scss
@@ -0,0 +1,23 @@
+
+.wp-block-jetpack-simple-payments {
+ .simple-payments__help-message {
+ display: flex;
+ font-size: 13px;
+ line-height: 1.4em;
+ margin-bottom: 1em;
+ margin-top: -0.5em;
+ svg {
+ margin-right: 5px;
+ min-width: 24px;
+ }
+ > span {
+ margin-top: 2px;
+ }
+ &.simple-payments__help-message-is-error {
+ color: var( --color-error );
+ svg {
+ fill: var( --color-error );
+ }
+ }
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/index.js b/plugins/jetpack/extensions/blocks/simple-payments/index.js
new file mode 100644
index 00000000..a1f0e0ed
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/index.js
@@ -0,0 +1,131 @@
+/**
+ * External dependencies
+ */
+import { __, _x } from '@wordpress/i18n';
+import { ExternalLink, Path, SVG } from '@wordpress/components';
+import { Fragment } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import edit from './edit';
+import save from './save';
+import { DEFAULT_CURRENCY } from './constants';
+
+/**
+ * Styles
+ */
+import './editor.scss';
+
+export const name = 'simple-payments';
+
+export const settings = {
+ title: __( 'Simple Payments button', 'jetpack' ),
+
+ description: (
+ <Fragment>
+ <p>
+ { __(
+ 'Lets you create and embed credit and debit card payment buttons with minimal setup.',
+ 'jetpack'
+ ) }
+ </p>
+ <ExternalLink href="https://support.wordpress.com/simple-payments/">
+ { __( 'Support reference', 'jetpack' ) }
+ </ExternalLink>
+ </Fragment>
+ ),
+
+ icon: (
+ <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+ <Path fill="none" d="M0 0h24v24H0V0z" />
+ <Path d="M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z" />
+ </SVG>
+ ),
+
+ category: 'jetpack',
+
+ keywords: [
+ _x( 'shop', 'block search term', 'jetpack' ),
+ _x( 'sell', 'block search term', 'jetpack' ),
+ 'PayPal',
+ ],
+
+ attributes: {
+ currency: {
+ type: 'string',
+ default: DEFAULT_CURRENCY,
+ },
+ content: {
+ type: 'string',
+ default: '',
+ },
+ email: {
+ type: 'string',
+ default: '',
+ },
+ featuredMediaId: {
+ type: 'number',
+ default: 0,
+ },
+ featuredMediaUrl: {
+ type: 'string',
+ default: null,
+ },
+ featuredMediaTitle: {
+ type: 'string',
+ default: null,
+ },
+ multiple: {
+ type: 'boolean',
+ default: false,
+ },
+ price: {
+ type: 'number',
+ },
+ productId: {
+ type: 'number',
+ },
+ title: {
+ type: 'string',
+ default: '',
+ },
+ },
+
+ transforms: {
+ from: [
+ {
+ type: 'shortcode',
+ tag: 'simple-payment',
+ attributes: {
+ productId: {
+ type: 'number',
+ shortcode: ( { named: { id } } ) => {
+ if ( ! id ) {
+ return;
+ }
+
+ const result = parseInt( id, 10 );
+ if ( result ) {
+ return result;
+ }
+ },
+ },
+ },
+ },
+ ],
+ },
+
+ edit,
+
+ save,
+
+ supports: {
+ className: false,
+ customClassName: false,
+ html: false,
+ // Disabled due several problems because the block uses custom post type to store information
+ // https://github.com/Automattic/jetpack/issues/11789
+ reusable: false,
+ },
+};
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/paypal-button-2x.png b/plugins/jetpack/extensions/blocks/simple-payments/paypal-button-2x.png
new file mode 100644
index 00000000..ceea141d
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/paypal-button-2x.png
Binary files differ
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/paypal-button.png b/plugins/jetpack/extensions/blocks/simple-payments/paypal-button.png
new file mode 100644
index 00000000..13bbad02
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/paypal-button.png
Binary files differ
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/product-placeholder.js b/plugins/jetpack/extensions/blocks/simple-payments/product-placeholder.js
new file mode 100644
index 00000000..3f80c79c
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/product-placeholder.js
@@ -0,0 +1,68 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import './product-placeholder.scss';
+import paypalImage from './paypal-button.png';
+import paypalImage2x from './paypal-button-2x.png';
+
+export default ( {
+ title = '',
+ content = '',
+ formattedPrice = '',
+ multiple = false,
+ featuredMediaUrl = null,
+ featuredMediaTitle = null,
+} ) => (
+ <div className="jetpack-simple-payments-wrapper">
+ <div className="jetpack-simple-payments-product">
+ { featuredMediaUrl && (
+ <div className="jetpack-simple-payments-product-image">
+ <figure className="jetpack-simple-payments-image">
+ <img src={ featuredMediaUrl } alt={ featuredMediaTitle } />
+ </figure>
+ </div>
+ ) }
+ <div className="jetpack-simple-payments-details">
+ { title && (
+ <div className="jetpack-simple-payments-title">
+ <p>{ title }</p>
+ </div>
+ ) }
+ { content && (
+ <div className="jetpack-simple-payments-description">
+ <p>{ content }</p>
+ </div>
+ ) }
+ { formattedPrice && (
+ <div className="jetpack-simple-payments-price">
+ <p>{ formattedPrice }</p>
+ </div>
+ ) }
+ <div className="jetpack-simple-payments-purchase-box">
+ { multiple && (
+ <div className="jetpack-simple-payments-items">
+ <input
+ className="jetpack-simple-payments-items-number"
+ readOnly
+ type="number"
+ value="1"
+ />
+ </div>
+ ) }
+ <div className="jetpack-simple-payments-button">
+ <img
+ alt={ __( 'Pay with PayPal', 'jetpack' ) }
+ src={ paypalImage }
+ srcSet={ `${ paypalImage2x } 2x` }
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+);
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/product-placeholder.scss b/plugins/jetpack/extensions/blocks/simple-payments/product-placeholder.scss
new file mode 100644
index 00000000..e138c863
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/product-placeholder.scss
@@ -0,0 +1,93 @@
+@import '../../shared/styles/jetpack-variables.scss';
+
+.simple-payments__loading {
+ animation: simple-payments-loading 1600ms ease-in-out infinite;
+}
+
+@keyframes simple-payments-loading {
+ 0% {
+ opacity: 0.5;
+ }
+ 50% {
+ opacity: 0.7;
+ }
+ 100% {
+ opacity: 0.5;
+ }
+}
+
+.jetpack-simple-payments-wrapper {
+ margin-bottom: $jetpack-block-margin-bottom;
+}
+
+/* Higher specificity in order to reset paragraph style */
+body .jetpack-simple-payments-wrapper .jetpack-simple-payments-details p {
+ margin: 0 0 $jetpack-block-margin-bottom;
+ padding: 0;
+}
+
+.jetpack-simple-payments-product {
+ display: flex;
+ flex-direction: column;
+}
+
+.jetpack-simple-payments-product-image {
+ flex: 0 0 30%;
+ margin-bottom: $jetpack-block-margin-bottom;
+}
+
+.jetpack-simple-payments-image {
+ box-sizing: border-box;
+ min-width: 70px;
+ padding-top: 100%;
+ position: relative;
+}
+
+.jetpack-simple-payments-image img {
+ border: 0;
+ border-radius: 0;
+ height: auto;
+ left: 50%;
+ margin: 0;
+ max-height: 100%;
+ max-width: 100%;
+ padding: 0;
+ position: absolute;
+ top: 50%;
+ transform: translate( -50%, -50% );
+ width: auto;
+}
+
+.jetpack-simple-payments-title p,
+.jetpack-simple-payments-price p {
+ font-weight: bold;
+}
+
+.jetpack-simple-payments-purchase-box {
+ align-items: flex-start;
+ display: flex;
+}
+
+.jetpack-simple-payments-items {
+ flex: 0 0 auto;
+ margin-right: 10px;
+}
+
+input[type='number'].jetpack-simple-payments-items-number {
+ background: var( --color-white );
+ font-size: 16px;
+ line-height: 1;
+ max-width: 60px;
+ padding: 4px 8px;
+}
+
+@media screen and ( min-width: 400px ) {
+ .jetpack-simple-payments-product {
+ flex-direction: row;
+ }
+
+ .jetpack-simple-payments-product-image + .jetpack-simple-payments-details {
+ flex-basis: 70%;
+ padding-left: 1em;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/save.js b/plugins/jetpack/extensions/blocks/simple-payments/save.js
new file mode 100644
index 00000000..ed81e7a8
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/save.js
@@ -0,0 +1,9 @@
+/**
+ * External dependencies
+ */
+import { RawHTML } from '@wordpress/element';
+
+export default function Save( { attributes } ) {
+ const { productId } = attributes;
+ return productId ? <RawHTML>{ `[simple-payment id="${ productId }"]` }</RawHTML> : null;
+}
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/utils.js b/plugins/jetpack/extensions/blocks/simple-payments/utils.js
new file mode 100644
index 00000000..c29e367b
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/utils.js
@@ -0,0 +1,29 @@
+/**
+ * External dependencies
+ */
+import { getCurrencyDefaults } from '@automattic/format-currency';
+import { trimEnd } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+import { SIMPLE_PAYMENTS_PRODUCT_POST_TYPE } from './constants';
+
+export const isValidSimplePaymentsProduct = product =>
+ product.type === SIMPLE_PAYMENTS_PRODUCT_POST_TYPE && product.status === 'publish';
+
+// based on https://stackoverflow.com/a/10454560/59752
+export const decimalPlaces = number => {
+ const match = ( '' + number ).match( /(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/ );
+ if ( ! match ) {
+ return 0;
+ }
+ return Math.max( 0, ( match[ 1 ] ? match[ 1 ].length : 0 ) - ( match[ 2 ] ? +match[ 2 ] : 0 ) );
+};
+
+export const formatPrice = ( price, currency, withSymbol = true ) => {
+ const { precision, symbol } = getCurrencyDefaults( currency );
+ const value = price.toFixed( precision );
+ // Trim the dot at the end of symbol, e.g., 'kr.' becomes 'kr'
+ return withSymbol ? `${ value } ${ trimEnd( symbol, '.' ) }` : value;
+};
diff --git a/plugins/jetpack/extensions/blocks/slideshow/create-swiper.js b/plugins/jetpack/extensions/blocks/slideshow/create-swiper.js
new file mode 100644
index 00000000..72a54f56
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/slideshow/create-swiper.js
@@ -0,0 +1,48 @@
+/**
+ * External dependencies
+ */
+import { mapValues, merge } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+
+export default async function createSwiper(
+ container = '.swiper-container',
+ params = {},
+ callbacks = {}
+) {
+ const defaultParams = {
+ effect: 'slide',
+ grabCursor: true,
+ init: true,
+ initialSlide: 0,
+ navigation: {
+ nextEl: '.swiper-button-next',
+ prevEl: '.swiper-button-prev',
+ },
+ pagination: {
+ bulletElement: 'button',
+ clickable: true,
+ el: '.swiper-pagination',
+ type: 'bullets',
+ },
+ preventClicksPropagation: false /* Necessary for normal block interactions */,
+ releaseFormElements: false,
+ setWrapperSize: true,
+ touchStartPreventDefault: false,
+ on: mapValues(
+ callbacks,
+ callback =>
+ function() {
+ callback( this );
+ }
+ ),
+ };
+ const [ { default: Swiper } ] = await Promise.all( [
+ import( /* webpackChunkName: "swiper" */ 'swiper/dist/js/swiper.js' ),
+ import( /* webpackChunkName: "swiper" */ 'swiper/dist/css/swiper.css' ),
+ ] );
+ return new Swiper( container, merge( {}, defaultParams, params ) );
+}
diff --git a/plugins/jetpack/extensions/blocks/slideshow/edit.js b/plugins/jetpack/extensions/blocks/slideshow/edit.js
new file mode 100644
index 00000000..9c03f029
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/slideshow/edit.js
@@ -0,0 +1,249 @@
+/**
+ * External dependencies
+ */
+import { __, _x } from '@wordpress/i18n';
+import { Component, Fragment } from '@wordpress/element';
+import { compose } from '@wordpress/compose';
+import { filter, pick } from 'lodash';
+import { isBlobURL } from '@wordpress/blob';
+import { withDispatch } from '@wordpress/data';
+import {
+ BlockControls,
+ BlockIcon,
+ InspectorControls,
+ MediaPlaceholder,
+ MediaUpload,
+ mediaUpload,
+} from '@wordpress/editor';
+import {
+ DropZone,
+ FormFileUpload,
+ IconButton,
+ PanelBody,
+ RangeControl,
+ SelectControl,
+ ToggleControl,
+ Toolbar,
+ withNotices,
+} from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import { icon } from '.';
+import Slideshow from './slideshow';
+import './editor.scss';
+
+const ALLOWED_MEDIA_TYPES = [ 'image' ];
+
+const effectOptions = [
+ { label: _x( 'Slide', 'Slideshow transition effect', 'jetpack' ), value: 'slide' },
+ { label: _x( 'Fade', 'Slideshow transition effect', 'jetpack' ), value: 'fade' },
+];
+
+export const pickRelevantMediaFiles = image =>
+ pick( image, [ 'alt', 'id', 'link', 'url', 'caption' ] );
+
+class SlideshowEdit extends Component {
+ constructor() {
+ super( ...arguments );
+ this.state = {
+ selectedImage: null,
+ };
+ }
+ setAttributes( attributes ) {
+ if ( attributes.ids ) {
+ throw new Error(
+ 'The "ids" attribute should not be changed directly. It is managed automatically when "images" attribute changes'
+ );
+ }
+
+ if ( attributes.images ) {
+ attributes = {
+ ...attributes,
+ ids: attributes.images.map( ( { id } ) => parseInt( id, 10 ) ),
+ };
+ }
+
+ this.props.setAttributes( attributes );
+ }
+ onSelectImages = images => {
+ const mapped = images.map( image => pickRelevantMediaFiles( image ) );
+ this.setAttributes( {
+ images: mapped,
+ } );
+ };
+ onRemoveImage = index => {
+ return () => {
+ const images = filter( this.props.attributes.images, ( img, i ) => index !== i );
+ this.setState( { selectedImage: null } );
+ this.setAttributes( { images } );
+ };
+ };
+ addFiles = files => {
+ const currentImages = this.props.attributes.images || [];
+ const { lockPostSaving, unlockPostSaving, noticeOperations } = this.props;
+ const lockName = 'slideshowBlockLock';
+ lockPostSaving( lockName );
+ mediaUpload( {
+ allowedTypes: ALLOWED_MEDIA_TYPES,
+ filesList: files,
+ onFileChange: images => {
+ const imagesNormalized = images.map( image => pickRelevantMediaFiles( image ) );
+ this.setAttributes( {
+ images: [ ...currentImages, ...imagesNormalized ],
+ } );
+ if ( ! imagesNormalized.every( image => isBlobURL( image.url ) ) ) {
+ unlockPostSaving( lockName );
+ }
+ },
+ onError: noticeOperations.createErrorNotice,
+ } );
+ };
+ uploadFromFiles = event => this.addFiles( event.target.files );
+ render() {
+ const {
+ attributes,
+ className,
+ isSelected,
+ noticeOperations,
+ noticeUI,
+ setAttributes,
+ } = this.props;
+ const { align, autoplay, delay, effect, images } = attributes;
+ const prefersReducedMotion =
+ typeof window !== 'undefined' &&
+ window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
+ const controls = (
+ <Fragment>
+ <InspectorControls>
+ <PanelBody title={ __( 'Autoplay', 'jetpack' ) }>
+ <ToggleControl
+ label={ __( 'Autoplay', 'jetpack' ) }
+ help={ __( 'Autoplay between slides', 'jetpack' ) }
+ checked={ autoplay }
+ onChange={ value => {
+ setAttributes( { autoplay: value } );
+ } }
+ />
+ { autoplay && (
+ <RangeControl
+ label={ __( 'Delay between transitions (in seconds)', 'jetpack' ) }
+ value={ delay }
+ onChange={ value => {
+ setAttributes( { delay: value } );
+ } }
+ min={ 1 }
+ max={ 5 }
+ />
+ ) }
+ { autoplay && prefersReducedMotion && (
+ <span>
+ { __(
+ 'The Reduce Motion accessibility option is selected, therefore autoplay will be disabled in this browser.',
+ 'jetpack'
+ ) }
+ </span>
+ ) }
+ </PanelBody>
+ <PanelBody title={ __( 'Effects', 'jetpack' ) }>
+ <SelectControl
+ label={ __( 'Transition effect', 'jetpack' ) }
+ value={ effect }
+ onChange={ value => {
+ setAttributes( { effect: value } );
+ } }
+ options={ effectOptions }
+ />
+ </PanelBody>
+ </InspectorControls>
+ <BlockControls>
+ { !! images.length && (
+ <Toolbar>
+ <MediaUpload
+ onSelect={ this.onSelectImages }
+ allowedTypes={ ALLOWED_MEDIA_TYPES }
+ multiple
+ gallery
+ value={ images.map( img => img.id ) }
+ render={ ( { open } ) => (
+ <IconButton
+ className="components-toolbar__control"
+ label={ __( 'Edit Slideshow', 'jetpack' ) }
+ icon="edit"
+ onClick={ open }
+ />
+ ) }
+ />
+ </Toolbar>
+ ) }
+ </BlockControls>
+ </Fragment>
+ );
+
+ if ( images.length === 0 ) {
+ return (
+ <Fragment>
+ { controls }
+ <MediaPlaceholder
+ icon={ <BlockIcon icon={ icon } /> }
+ className={ className }
+ labels={ {
+ title: __( 'Slideshow', 'jetpack' ),
+ instructions: __(
+ 'Drag images, upload new ones or select files from your library.',
+ 'jetpack'
+ ),
+ } }
+ onSelect={ this.onSelectImages }
+ accept="image/*"
+ allowedTypes={ ALLOWED_MEDIA_TYPES }
+ multiple
+ notices={ noticeUI }
+ onError={ noticeOperations.createErrorNotice }
+ />
+ </Fragment>
+ );
+ }
+ return (
+ <Fragment>
+ { controls }
+ { noticeUI }
+ <Slideshow
+ align={ align }
+ autoplay={ autoplay }
+ className={ className }
+ delay={ delay }
+ effect={ effect }
+ images={ images }
+ onError={ noticeOperations.createErrorNotice }
+ />
+ <DropZone onFilesDrop={ this.addFiles } />
+ { isSelected && (
+ <div className="wp-block-jetpack-slideshow__add-item">
+ <FormFileUpload
+ multiple
+ isLarge
+ className="wp-block-jetpack-slideshow__add-item-button"
+ onChange={ this.uploadFromFiles }
+ accept="image/*"
+ icon="insert"
+ >
+ { __( 'Upload an image', 'jetpack' ) }
+ </FormFileUpload>
+ </div>
+ ) }
+ </Fragment>
+ );
+ }
+}
+export default compose(
+ withDispatch( dispatch => {
+ const { lockPostSaving, unlockPostSaving } = dispatch( 'core/editor' );
+ return {
+ lockPostSaving,
+ unlockPostSaving,
+ };
+ } ),
+ withNotices
+)( SlideshowEdit );
diff --git a/plugins/jetpack/extensions/blocks/slideshow/editor.js b/plugins/jetpack/extensions/blocks/slideshow/editor.js
new file mode 100644
index 00000000..d05f4039
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/slideshow/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/plugins/jetpack/extensions/blocks/slideshow/editor.scss b/plugins/jetpack/extensions/blocks/slideshow/editor.scss
new file mode 100644
index 00000000..11c07c25
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/slideshow/editor.scss
@@ -0,0 +1,44 @@
+@import '../../shared/styles/gutenberg-colors.scss';
+
+.wp-block-jetpack-slideshow__add-item {
+ margin-top: 4px;
+ width: 100%;
+
+ .components-form-file-upload,
+ .components-button.wp-block-jetpack-slideshow__add-item-button {
+ width: 100%;
+ height: 100%;
+ }
+
+ .components-button.wp-block-jetpack-slideshow__add-item-button {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ box-shadow: none;
+ border: none;
+ border-radius: 0;
+ min-height: 100px;
+
+ .dashicon {
+ margin-top: 10px;
+ }
+
+ &:hover,
+ &:focus {
+ border: 1px solid $dark-gray-500;
+ }
+ }
+}
+
+.wp-block-jetpack-slideshow_slide {
+ .components-spinner {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin-top: -9px;
+ margin-left: -9px;
+ }
+ &.is-transient img {
+ opacity: 0.3;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/slideshow/index.js b/plugins/jetpack/extensions/blocks/slideshow/index.js
new file mode 100644
index 00000000..9f4b2807
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/slideshow/index.js
@@ -0,0 +1,93 @@
+/**
+ * External dependencies
+ */
+import { __, _x } from '@wordpress/i18n';
+import { Path, SVG } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import edit from './edit';
+import save from './save';
+import transforms from './transforms';
+
+export const icon = (
+ <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <Path d="M0 0h24v24H0z" fill="none" />
+ <Path d="M10 8v8l5-4-5-4zm9-5H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z" />
+ </SVG>
+);
+
+const attributes = {
+ align: {
+ default: 'center',
+ type: 'string',
+ },
+ autoplay: {
+ type: 'boolean',
+ default: false,
+ },
+ delay: {
+ type: 'number',
+ default: 3,
+ },
+ ids: {
+ default: [],
+ type: 'array',
+ },
+ images: {
+ type: 'array',
+ default: [],
+ source: 'query',
+ selector: '.swiper-slide',
+ query: {
+ alt: {
+ source: 'attribute',
+ selector: 'img',
+ attribute: 'alt',
+ default: '',
+ },
+ caption: {
+ type: 'string',
+ source: 'html',
+ selector: 'figcaption',
+ },
+ id: {
+ source: 'attribute',
+ selector: 'img',
+ attribute: 'data-id',
+ },
+ url: {
+ source: 'attribute',
+ selector: 'img',
+ attribute: 'src',
+ },
+ },
+ },
+ effect: {
+ type: 'string',
+ default: 'slide',
+ },
+};
+
+export const name = 'slideshow';
+
+export const settings = {
+ title: __( 'Slideshow', 'jetpack' ),
+ category: 'jetpack',
+ keywords: [
+ _x( 'image', 'block search term', 'jetpack' ),
+ _x( 'gallery', 'block search term', 'jetpack' ),
+ _x( 'slider', 'block search term', 'jetpack' ),
+ ],
+ description: __( 'Add an interactive slideshow.', 'jetpack' ),
+ attributes,
+ supports: {
+ align: [ 'center', 'wide', 'full' ],
+ html: false,
+ },
+ icon,
+ edit,
+ save,
+ transforms,
+};
diff --git a/plugins/jetpack/extensions/blocks/slideshow/save.js b/plugins/jetpack/extensions/blocks/slideshow/save.js
new file mode 100644
index 00000000..59879ded
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/slideshow/save.js
@@ -0,0 +1,15 @@
+/**
+ * Internal dependencies
+ */
+import Slideshow from './slideshow';
+
+export default ( { attributes: { align, autoplay, delay, effect, images }, className } ) => (
+ <Slideshow
+ align={ align }
+ autoplay={ autoplay }
+ className={ className }
+ delay={ delay }
+ effect={ effect }
+ images={ images }
+ />
+);
diff --git a/plugins/jetpack/extensions/blocks/slideshow/slideshow.js b/plugins/jetpack/extensions/blocks/slideshow/slideshow.js
new file mode 100644
index 00000000..b7d97c1a
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/slideshow/slideshow.js
@@ -0,0 +1,232 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+import ResizeObserver from 'resize-observer-polyfill';
+import { __ } from '@wordpress/i18n';
+import { Component, createRef } from '@wordpress/element';
+import { isBlobURL } from '@wordpress/blob';
+import { isEqual } from 'lodash';
+import { RichText } from '@wordpress/editor';
+import { Spinner } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import createSwiper from './create-swiper';
+import {
+ swiperApplyAria,
+ swiperInit,
+ swiperPaginationRender,
+ swiperResize,
+} from './swiper-callbacks';
+
+class Slideshow extends Component {
+ pendingRequestAnimationFrame = null;
+ resizeObserver = null;
+ static defaultProps = {
+ effect: 'slide',
+ };
+
+ constructor( props ) {
+ super( props );
+
+ this.slideshowRef = createRef();
+ this.btnNextRef = createRef();
+ this.btnPrevRef = createRef();
+ this.paginationRef = createRef();
+ }
+
+ componentDidMount() {
+ const { onError } = this.props;
+ this.buildSwiper()
+ .then( swiper => {
+ this.swiperInstance = swiper;
+ this.initializeResizeObserver( swiper );
+ } )
+ .catch( () => {
+ onError( __( 'The Swiper library could not be loaded.', 'jetpack' ) );
+ } );
+ }
+
+ componentWillUnmount() {
+ this.clearResizeObserver();
+ this.clearPendingRequestAnimationFrame();
+ }
+
+ componentDidUpdate( prevProps ) {
+ const { align, autoplay, delay, effect, images, onError } = this.props;
+
+ /* A change in alignment or images only needs an update */
+ if ( align !== prevProps.align || ! isEqual( images, prevProps.images ) ) {
+ this.swiperInstance && this.swiperInstance.update();
+ }
+ /* A change in effect requires a full rebuild */
+ if (
+ effect !== prevProps.effect ||
+ autoplay !== prevProps.autoplay ||
+ delay !== prevProps.delay ||
+ images !== prevProps.images
+ ) {
+ let realIndex;
+ if ( ! this.swiperIndex ) {
+ realIndex = 0;
+ } else if ( images.length === prevProps.images.length ) {
+ realIndex = this.swiperInstance.realIndex;
+ } else {
+ realIndex = prevProps.images.length;
+ }
+ this.swiperInstance && this.swiperInstance.destroy( true, true );
+ this.buildSwiper( realIndex )
+ .then( swiper => {
+ this.swiperInstance = swiper;
+ this.initializeResizeObserver( swiper );
+ } )
+ .catch( () => {
+ onError( __( 'The Swiper library could not be loaded.', 'jetpack' ) );
+ } );
+ }
+ }
+
+ initializeResizeObserver = swiper => {
+ this.clearResizeObserver();
+ this.resizeObserver = new ResizeObserver( () => {
+ this.clearPendingRequestAnimationFrame();
+ this.pendingRequestAnimationFrame = requestAnimationFrame( () => {
+ swiperResize( swiper );
+ swiper.update();
+ } );
+ } );
+ this.resizeObserver.observe( swiper.el );
+ };
+
+ clearPendingRequestAnimationFrame = () => {
+ if ( this.pendingRequestAnimationFrame ) {
+ cancelAnimationFrame( this.pendingRequestAnimationFrame );
+ this.pendingRequestAnimationFrame = null;
+ }
+ };
+
+ clearResizeObserver = () => {
+ if ( this.resizeObserver ) {
+ this.resizeObserver.disconnect();
+ this.resizeObserver = null;
+ }
+ };
+
+ render() {
+ const { autoplay, className, delay, effect, images } = this.props;
+ // Note: React omits the data attribute if the value is null, but NOT if it is false.
+ // This is the reason for the unusual logic related to autoplay below.
+ /* eslint-disable jsx-a11y/anchor-is-valid */
+ return (
+ <div
+ className={ className }
+ data-autoplay={ autoplay || null }
+ data-delay={ autoplay ? delay : null }
+ data-effect={ effect }
+ >
+ <div
+ className="wp-block-jetpack-slideshow_container swiper-container"
+ ref={ this.slideshowRef }
+ >
+ <ul className="wp-block-jetpack-slideshow_swiper-wrappper swiper-wrapper">
+ { images.map( ( { alt, caption, id, url } ) => (
+ <li
+ className={ classnames(
+ 'wp-block-jetpack-slideshow_slide',
+ 'swiper-slide',
+ isBlobURL( url ) && 'is-transient'
+ ) }
+ key={ id }
+ >
+ <figure>
+ <img
+ alt={ alt }
+ className={
+ `wp-block-jetpack-slideshow_image wp-image-${ id }` /* wp-image-${ id } makes WordPress add a srcset */
+ }
+ data-id={ id }
+ src={ url }
+ />
+ { isBlobURL( url ) && <Spinner /> }
+ { caption && (
+ <RichText.Content
+ className="wp-block-jetpack-slideshow_caption gallery-caption"
+ tagName="figcaption"
+ value={ caption }
+ />
+ ) }
+ </figure>
+ </li>
+ ) ) }
+ </ul>
+ <a
+ className="wp-block-jetpack-slideshow_button-prev swiper-button-prev swiper-button-white"
+ ref={ this.btnPrevRef }
+ role="button"
+ />
+ <a
+ className="wp-block-jetpack-slideshow_button-next swiper-button-next swiper-button-white"
+ ref={ this.btnNextRef }
+ role="button"
+ />
+ <a
+ aria-label="Pause Slideshow"
+ className="wp-block-jetpack-slideshow_button-pause"
+ role="button"
+ />
+ <div
+ className="wp-block-jetpack-slideshow_pagination swiper-pagination swiper-pagination-white"
+ ref={ this.paginationRef }
+ />
+ </div>
+ </div>
+ );
+ /* eslint-enable jsx-a11y/anchor-is-valid */
+ }
+
+ prefersReducedMotion = () => {
+ return (
+ typeof window !== 'undefined' &&
+ window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches
+ );
+ };
+
+ buildSwiper = ( initialSlide = 0 ) =>
+ // Using refs instead of className-based selectors allows us to
+ // have multiple swipers on one page without collisions, and
+ // without needing to add IDs or the like.
+ createSwiper(
+ this.slideshowRef.current,
+ {
+ autoplay:
+ this.props.autoplay && ! this.prefersReducedMotion()
+ ? {
+ delay: this.props.delay * 1000,
+ disableOnInteraction: false,
+ }
+ : false,
+ effect: this.props.effect,
+ loop: true,
+ initialSlide,
+ navigation: {
+ nextEl: this.btnNextRef.current,
+ prevEl: this.btnPrevRef.current,
+ },
+ pagination: {
+ clickable: true,
+ el: this.paginationRef.current,
+ type: 'bullets',
+ },
+ },
+ {
+ init: swiperInit,
+ imagesReady: swiperResize,
+ paginationRender: swiperPaginationRender,
+ transitionEnd: swiperApplyAria,
+ }
+ );
+}
+
+export default Slideshow;
diff --git a/plugins/jetpack/extensions/blocks/slideshow/slideshow.php b/plugins/jetpack/extensions/blocks/slideshow/slideshow.php
new file mode 100644
index 00000000..be9ae6c0
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/slideshow/slideshow.php
@@ -0,0 +1,28 @@
+<?php
+/**
+ * Slideshow Block.
+ *
+ * @since 7.1.0
+ *
+ * @package Jetpack
+ */
+
+jetpack_register_block(
+ 'jetpack/slideshow',
+ array(
+ 'render_callback' => 'jetpack_slideshow_block_load_assets',
+ )
+);
+
+/**
+ * Slideshow block registration/dependency declaration.
+ *
+ * @param array $attr Array containing the slideshow block attributes.
+ * @param string $content String containing the slideshow block content.
+ *
+ * @return string
+ */
+function jetpack_slideshow_block_load_assets( $attr, $content ) {
+ Jetpack_Gutenberg::load_assets_as_required( 'slideshow' );
+ return $content;
+}
diff --git a/plugins/jetpack/extensions/blocks/slideshow/style.scss b/plugins/jetpack/extensions/blocks/slideshow/style.scss
new file mode 100644
index 00000000..c1d1fc50
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/slideshow/style.scss
@@ -0,0 +1,165 @@
+@import '../../shared/styles/gutenberg-colors.scss';
+@import '../../shared/styles/jetpack-variables.scss';
+
+.wp-block-jetpack-slideshow {
+ margin-bottom: $jetpack-block-margin-bottom;
+ position: relative;
+
+ .wp-block-jetpack-slideshow_container {
+ width: 100%;
+ overflow: hidden;
+ opacity: 0;
+
+ &.wp-swiper-initialized {
+ opacity: 1;
+ }
+
+ // High specifity to override theme styles
+ .wp-block-jetpack-slideshow_swiper-wrappper,
+ .wp-block-jetpack-slideshow_slide {
+ padding: 0;
+ margin: 0;
+ line-height: normal;
+ }
+ }
+
+ .wp-block-jetpack-slideshow_slide {
+ background: rgba( 0, 0, 0, 0.1 );
+ display: flex;
+ height: 100%;
+ width: 100%;
+ figure {
+ align-items: center;
+ display: flex;
+ height: 100%;
+ justify-content: center;
+ margin: 0;
+ position: relative;
+ width: 100%;
+ }
+ }
+
+ .swiper-container-fade .wp-block-jetpack-slideshow_slide {
+ background: var( --color-neutral-0 );
+ }
+
+ .wp-block-jetpack-slideshow_image {
+ display: block;
+ height: auto;
+ max-height: 100%;
+ max-width: 100%;
+ width: auto;
+ object-fit: contain;
+ }
+
+ .wp-block-jetpack-slideshow_button-prev,
+ .wp-block-jetpack-slideshow_button-next,
+ .wp-block-jetpack-slideshow_button-pause {
+ background-color: rgba( 0, 0, 0, 0.5 );
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: 24px;
+ border: 0;
+ border-radius: 4px;
+ box-shadow: none;
+ height: 48px;
+ margin: -24px 0 0;
+ padding: 0;
+ transition: background-color 250ms;
+ width: 48px;
+
+ &:focus,
+ &:hover {
+ background-color: rgba( 0, 0, 0, 0.75 );
+ }
+
+ &:focus {
+ outline: thin dotted $white;
+ outline-offset: -4px;
+ }
+ }
+
+ &.swiper-container-rtl .swiper-button-prev.swiper-button-white,
+ &.swiper-container-rtl .wp-block-jetpack-slideshow_button-prev,
+ .swiper-button-next.swiper-button-white,
+ .wp-block-jetpack-slideshow_button-next {
+ background-image: url( "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M5.88 4.12L13.76 12l-7.88 7.88L8 22l10-10L8 2z' fill='white'/%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3C/svg%3E" );
+ }
+
+ &.swiper-container-rtl .swiper-button-next.swiper-button-white,
+ &.swiper-container-rtl .wp-block-jetpack-slideshow_button-next,
+ .swiper-button-prev.swiper-button-white,
+ .wp-block-jetpack-slideshow_button-prev {
+ background-image: url( "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M18 4.12L10.12 12 18 19.88 15.88 22l-10-10 10-10z' fill='white'/%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3C/svg%3E" );
+ }
+
+ .wp-block-jetpack-slideshow_button-pause {
+ background-image: url( "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M6 19h4V5H6v14zm8-14v14h4V5h-4z' fill='white'/%3E%3Cpath d='M0 0h24v24H0z' fill='none'/%3E%3C/svg%3E" );
+ display: none;
+ margin-top: 0;
+ position: absolute;
+ right: 10px;
+ top: 10px;
+ z-index: 1;
+ }
+
+ .wp-block-jetpack-slideshow_autoplay-paused .wp-block-jetpack-slideshow_button-pause {
+ background-image: url( "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M8 5v14l11-7z' fill='white'/%3E%3Cpath d='M0 0h24v24H0z' fill='none'/%3E%3C/svg%3E" );
+ }
+
+ &[data-autoplay='true'] .wp-block-jetpack-slideshow_button-pause {
+ display: block;
+ }
+
+ .wp-block-jetpack-slideshow_caption.gallery-caption {
+ background-color: rgba( 0, 0, 0, 0.5 );
+ box-sizing: border-box;
+ bottom: 0;
+ color: $white;
+ cursor: text;
+ left: 0;
+ margin: 0 !important;
+ padding: 0.75em;
+ position: absolute;
+ right: 0;
+ text-align: initial;
+ z-index: 1;
+ a {
+ color: inherit;
+ }
+ }
+
+ .wp-block-jetpack-slideshow_pagination.swiper-pagination-bullets {
+ bottom: 0;
+ line-height: 24px;
+ padding: 10px 0 2px;
+ position: relative;
+
+ .swiper-pagination-bullet {
+ background: currentColor;
+ color: currentColor;
+ height: 16px;
+ opacity: 0.5;
+ transform: scale( 0.75 );
+ transition: opacity 250ms, transform 250ms;
+ vertical-align: top;
+ width: 16px;
+
+ &:focus,
+ &:hover {
+ opacity: 1;
+ }
+
+ &:focus {
+ outline: thin dotted;
+ outline-offset: 0;
+ }
+ }
+
+ .swiper-pagination-bullet-active {
+ background-color: currentColor;
+ opacity: 1;
+ transform: scale( 1 );
+ }
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/slideshow/swiper-callbacks.js b/plugins/jetpack/extensions/blocks/slideshow/swiper-callbacks.js
new file mode 100644
index 00000000..2410f234
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/slideshow/swiper-callbacks.js
@@ -0,0 +1,95 @@
+/**
+ * External dependencies
+ */
+import { escapeHTML } from '@wordpress/escape-html';
+import { forEach } from 'lodash';
+
+const SIXTEEN_BY_NINE = 16 / 9;
+const MAX_HEIGHT_PERCENT_OF_WINDOW_HEIGHT = 0.8;
+const SANITY_MAX_HEIGHT = 600;
+const PAUSE_CLASS = 'wp-block-jetpack-slideshow_autoplay-paused';
+
+function swiperInit( swiper ) {
+ swiperResize( swiper );
+ swiperApplyAria( swiper );
+ swiper.el
+ .querySelector( '.wp-block-jetpack-slideshow_button-pause' )
+ .addEventListener( 'click', function() {
+ // Handle destroyed Swiper instances
+ if ( ! swiper.el ) {
+ return;
+ }
+ if ( swiper.el.classList.contains( PAUSE_CLASS ) ) {
+ swiper.el.classList.remove( PAUSE_CLASS );
+ swiper.autoplay.start();
+ this.setAttribute( 'aria-label', 'Pause Slideshow' );
+ } else {
+ swiper.el.classList.add( PAUSE_CLASS );
+ swiper.autoplay.stop();
+ this.setAttribute( 'aria-label', 'Play Slideshow' );
+ }
+ } );
+}
+
+function swiperResize( swiper ) {
+ if ( ! swiper || ! swiper.el ) {
+ return;
+ }
+ const img = swiper.el.querySelector( '.swiper-slide[data-swiper-slide-index="0"] img' );
+ if ( ! img ) {
+ return;
+ }
+ const aspectRatio = img.clientWidth / img.clientHeight;
+ const sanityAspectRatio = Math.max( Math.min( aspectRatio, SIXTEEN_BY_NINE ), 1 );
+ const sanityHeight =
+ typeof window !== 'undefined'
+ ? window.innerHeight * MAX_HEIGHT_PERCENT_OF_WINDOW_HEIGHT
+ : SANITY_MAX_HEIGHT;
+ const swiperHeight = Math.min( swiper.width / sanityAspectRatio, sanityHeight );
+ const wrapperHeight = `${ Math.floor( swiperHeight ) }px`;
+ const buttonTop = `${ Math.floor( swiperHeight / 2 ) }px`;
+
+ swiper.el.classList.add( 'wp-swiper-initialized' );
+ swiper.wrapperEl.style.height = wrapperHeight;
+ swiper.el.querySelector( '.wp-block-jetpack-slideshow_button-prev' ).style.top = buttonTop;
+ swiper.el.querySelector( '.wp-block-jetpack-slideshow_button-next' ).style.top = buttonTop;
+}
+
+function announceCurrentSlide( swiper ) {
+ const currentSlide = swiper.slides[ swiper.activeIndex ];
+ if ( ! currentSlide ) {
+ return;
+ }
+ const figcaption = currentSlide.getElementsByTagName( 'FIGCAPTION' )[ 0 ];
+ const img = currentSlide.getElementsByTagName( 'IMG' )[ 0 ];
+ if ( swiper.a11y.liveRegion ) {
+ swiper.a11y.liveRegion[ 0 ].innerHTML = figcaption
+ ? figcaption.innerHTML
+ : escapeHTML( img.alt );
+ }
+}
+
+function swiperApplyAria( swiper ) {
+ forEach( swiper.slides, ( slide, index ) => {
+ slide.setAttribute( 'aria-hidden', index === swiper.activeIndex ? 'false' : 'true' );
+ if ( index === swiper.activeIndex ) {
+ slide.setAttribute( 'tabindex', '-1' );
+ } else {
+ slide.removeAttribute( 'tabindex' );
+ }
+ } );
+ announceCurrentSlide( swiper );
+}
+
+function swiperPaginationRender( swiper ) {
+ forEach( swiper.pagination.bullets, bullet => {
+ bullet.addEventListener( 'click', () => {
+ const currentSlide = swiper.slides[ swiper.realIndex ];
+ setTimeout( () => {
+ currentSlide.focus();
+ }, 500 );
+ } );
+ } );
+}
+
+export { swiperApplyAria, swiperInit, swiperPaginationRender, swiperResize };
diff --git a/plugins/jetpack/extensions/blocks/slideshow/transforms.js b/plugins/jetpack/extensions/blocks/slideshow/transforms.js
new file mode 100644
index 00000000..bcff28e5
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/slideshow/transforms.js
@@ -0,0 +1,78 @@
+/**
+ * External dependencies
+ */
+import { createBlock } from '@wordpress/blocks';
+import { filter } from 'lodash';
+
+/**
+ * Filter valid images
+ *
+ * @param {array} images Array of image objects
+ * @return {array} Array of image objects which have id and url
+ */
+function getValidImages( images ) {
+ return filter( images, ( { id, url } ) => id && url );
+}
+
+const transforms = {
+ from: [
+ {
+ type: 'block',
+ isMultiBlock: true,
+ blocks: [ 'core/image' ],
+ isMatch: images => getValidImages( images ).length > 0,
+ transform: images => {
+ const validImages = getValidImages( images );
+ return createBlock( 'jetpack/slideshow', {
+ images: validImages.map( ( { alt, caption, id, url } ) => ( {
+ alt,
+ caption,
+ id,
+ url,
+ } ) ),
+ ids: validImages.map( ( { id } ) => id ),
+ } );
+ },
+ },
+ {
+ type: 'block',
+ blocks: [ 'core/gallery', 'jetpack/tiled-gallery' ],
+ transform: ( { images } ) => {
+ const validImages = getValidImages( images );
+ if ( validImages.length > 0 ) {
+ return createBlock( 'jetpack/slideshow', {
+ images: validImages.map( ( { alt, caption, id, url } ) => ( {
+ alt,
+ caption,
+ id,
+ url,
+ } ) ),
+ ids: validImages.map( ( { id } ) => id ),
+ } );
+ }
+ return createBlock( 'jetpack/slideshow' );
+ },
+ },
+ ],
+ to: [
+ {
+ type: 'block',
+ blocks: [ 'core/gallery' ],
+ transform: ( { images, ids } ) => createBlock( 'core/gallery', { images, ids } ),
+ },
+ {
+ type: 'block',
+ blocks: [ 'core/image' ],
+ transform: ( { images } ) => {
+ if ( images.length > 0 ) {
+ return images.map( ( { id, url, alt, caption } ) =>
+ createBlock( 'core/image', { id, url, alt, caption } )
+ );
+ }
+ return createBlock( 'core/image' );
+ },
+ },
+ ],
+};
+
+export default transforms;
diff --git a/plugins/jetpack/extensions/blocks/slideshow/view.js b/plugins/jetpack/extensions/blocks/slideshow/view.js
new file mode 100644
index 00000000..6d807897
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/slideshow/view.js
@@ -0,0 +1,70 @@
+/**
+ * External dependencies
+ */
+import { forEach } from 'lodash';
+import ResizeObserver from 'resize-observer-polyfill';
+
+/**
+ * Internal dependencies
+ */
+import createSwiper from './create-swiper';
+import {
+ swiperApplyAria,
+ swiperInit,
+ swiperPaginationRender,
+ swiperResize,
+} from './swiper-callbacks';
+
+typeof window !== 'undefined' &&
+ window.addEventListener( 'load', function() {
+ const slideshowBlocks = document.getElementsByClassName( 'wp-block-jetpack-slideshow' );
+ forEach( slideshowBlocks, slideshowBlock => {
+ const { autoplay, delay, effect } = slideshowBlock.dataset;
+ const prefersReducedMotion = window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
+ const shouldAutoplay = autoplay && ! prefersReducedMotion;
+ const slideshowContainer = slideshowBlock.getElementsByClassName( 'swiper-container' )[ 0 ];
+ let pendingRequestAnimationFrame = null;
+ createSwiper(
+ slideshowContainer,
+ {
+ autoplay: shouldAutoplay
+ ? {
+ delay: delay * 1000,
+ disableOnInteraction: false,
+ }
+ : false,
+ effect,
+ init: true,
+ initialSlide: 0,
+ loop: true,
+ keyboard: {
+ enabled: true,
+ onlyInViewport: true,
+ },
+ },
+ {
+ init: swiperInit,
+ imagesReady: swiperResize,
+ paginationRender: swiperPaginationRender,
+ transitionEnd: swiperApplyAria,
+ }
+ )
+ .then( swiper => {
+ new ResizeObserver( () => {
+ if ( pendingRequestAnimationFrame ) {
+ cancelAnimationFrame( pendingRequestAnimationFrame );
+ pendingRequestAnimationFrame = null;
+ }
+ pendingRequestAnimationFrame = requestAnimationFrame( () => {
+ swiperResize( swiper );
+ swiper.update();
+ } );
+ } ).observe( swiper.el );
+ } )
+ .catch( () => {
+ slideshowBlock
+ .querySelector( '.wp-block-jetpack-slideshow_container' )
+ .classList.add( 'wp-swiper-initialized' );
+ } );
+ } );
+ } );
diff --git a/plugins/jetpack/extensions/blocks/subscriptions/edit.js b/plugins/jetpack/extensions/blocks/subscriptions/edit.js
new file mode 100644
index 00000000..25482110
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/subscriptions/edit.js
@@ -0,0 +1,81 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+import { __, _n, sprintf } from '@wordpress/i18n';
+import { Component } from '@wordpress/element';
+import { TextControl, ToggleControl } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import SubmitButton from '../../shared/submit-button';
+
+class SubscriptionEdit extends Component {
+ state = {
+ subscriberCountString: '',
+ };
+
+ componentDidMount() {
+ // Get the subscriber count so it is available right away if the user toggles the setting
+ this.get_subscriber_count();
+ }
+
+ render() {
+ const { attributes, className, isSelected, setAttributes } = this.props;
+ const { subscribePlaceholder, showSubscribersTotal } = attributes;
+
+ if ( isSelected ) {
+ return (
+ <div className={ className } role="form">
+ <ToggleControl
+ label={ __( 'Show total subscribers', 'jetpack' ) }
+ checked={ showSubscribersTotal }
+ onChange={ () => {
+ setAttributes( { showSubscribersTotal: ! showSubscribersTotal } );
+ } }
+ />
+ <TextControl
+ placeholder={ subscribePlaceholder }
+ disabled={ true }
+ onChange={ () => {} }
+ />
+ <SubmitButton { ...this.props } />
+ </div>
+ );
+ }
+
+ return (
+ <div className={ className } role="form">
+ { showSubscribersTotal && <p role="heading">{ this.state.subscriberCountString }</p> }
+ <TextControl placeholder={ subscribePlaceholder } />
+
+ <SubmitButton { ...this.props } />
+ </div>
+ );
+ }
+
+ get_subscriber_count() {
+ apiFetch( { path: '/wpcom/v2/subscribers/count' } ).then( count => {
+ // Handle error condition
+ if ( ! count.hasOwnProperty( 'count' ) ) {
+ this.setState( {
+ subscriberCountString: __( 'Subscriber count unavailable', 'jetpack' ),
+ } );
+ } else {
+ this.setState( {
+ subscriberCountString: sprintf(
+ _n( 'Join %s other subscriber', 'Join %s other subscribers', count.count, 'jetpack' ),
+ count.count
+ ),
+ } );
+ }
+ } );
+ }
+
+ onChangeSubmit( submitButtonText ) {
+ this.props.setAttributes( { submitButtonText } );
+ }
+}
+
+export default SubscriptionEdit;
diff --git a/plugins/jetpack/extensions/blocks/subscriptions/editor.js b/plugins/jetpack/extensions/blocks/subscriptions/editor.js
new file mode 100644
index 00000000..d05f4039
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/subscriptions/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/plugins/jetpack/extensions/blocks/subscriptions/index.js b/plugins/jetpack/extensions/blocks/subscriptions/index.js
new file mode 100644
index 00000000..007b0d1e
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/subscriptions/index.js
@@ -0,0 +1,85 @@
+/**
+ * External dependencies
+ */
+import { __, _x } from '@wordpress/i18n';
+import { isEmpty } from 'lodash';
+import { Path } from '@wordpress/components';
+import { RawHTML } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import edit from './edit';
+import save from './save';
+import renderMaterialIcon from '../../shared/render-material-icon';
+
+export const name = 'subscriptions';
+export const settings = {
+ title: __( 'Subscription Form', 'jetpack' ),
+
+ description: (
+ <p>
+ { __(
+ 'A form enabling readers to get notifications when new posts are published from this site.',
+ 'jetpack'
+ ) }
+ </p>
+ ),
+ icon: renderMaterialIcon(
+ <Path d="M23 16v2h-3v3h-2v-3h-3v-2h3v-3h2v3h3zM20 2v9h-4v3h-3v4H4c-1.1 0-2-.9-2-2V2h18zM8 13v-1H4v1h4zm3-3H4v1h7v-1zm0-2H4v1h7V8zm7-4H4v2h14V4z" />
+ ),
+ category: 'jetpack',
+
+ keywords: [
+ _x( 'subscribe', 'block search term', 'jetpack' ),
+ _x( 'join', 'block search term', 'jetpack' ),
+ _x( 'follow', 'block search term', 'jetpack' ),
+ ],
+
+ attributes: {
+ subscribePlaceholder: { type: 'string', default: __( 'Email Address', 'jetpack' ) },
+ subscribeButton: { type: 'string', default: __( 'Subscribe', 'jetpack' ) },
+ showSubscribersTotal: { type: 'boolean', default: false },
+ submitButtonText: {
+ type: 'string',
+ default: __( 'Subscribe', 'jetpack' ),
+ },
+ customBackgroundButtonColor: { type: 'string' },
+ customTextButtonColor: { type: 'string' },
+ submitButtonClasses: { type: 'string' },
+ },
+ edit,
+ save,
+ deprecated: [
+ {
+ attributes: {
+ subscribeButton: { type: 'string', default: __( 'Subscribe', 'jetpack' ) },
+ showSubscribersTotal: { type: 'boolean', default: false },
+ },
+ migrate: attr => {
+ return {
+ subscribeButton: '',
+ submitButtonText: attr.subscribeButton,
+ showSubscribersTotal: attr.showSubscribersTotal,
+ customBackgroundButtonColor: '',
+ customTextButtonColor: '',
+ submitButtonClasses: '',
+ };
+ },
+
+ isEligible: attr => {
+ if ( ! isEmpty( attr.subscribeButton ) ) {
+ return false;
+ }
+ return true;
+ },
+ save: function( { attributes } ) {
+ return (
+ <RawHTML>{ `[jetpack_subscription_form show_subscribers_total="${
+ attributes.showSubscribersTotal
+ }" show_only_email_and_button="true"]` }</RawHTML>
+ );
+ },
+ },
+ ],
+};
diff --git a/plugins/jetpack/extensions/blocks/subscriptions/save.js b/plugins/jetpack/extensions/blocks/subscriptions/save.js
new file mode 100644
index 00000000..a7db7fe6
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/subscriptions/save.js
@@ -0,0 +1,17 @@
+/**
+ * External dependencies
+ */
+import { RawHTML } from '@wordpress/element';
+
+export default function Save( { attributes } ) {
+ const {
+ showSubscribersTotal,
+ submitButtonClasses,
+ customBackgroundButtonColor,
+ customTextButtonColor,
+ submitButtonText,
+ } = attributes;
+ return (
+ <RawHTML>{ `[jetpack_subscription_form show_only_email_and_button="true" custom_background_button_color="${ customBackgroundButtonColor }" custom_text_button_color="${ customTextButtonColor }" submit_button_text="${ submitButtonText }" submit_button_classes="${ submitButtonClasses }" show_subscribers_total="${ showSubscribersTotal }" ]` }</RawHTML>
+ );
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/constants.js b/plugins/jetpack/extensions/blocks/tiled-gallery/constants.js
new file mode 100644
index 00000000..0df90737
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/constants.js
@@ -0,0 +1,28 @@
+export const ALLOWED_MEDIA_TYPES = [ 'image' ];
+export const DEFAULT_GALLERY_WIDTH = 580;
+export const GUTTER_WIDTH = 4;
+export const MAX_COLUMNS = 20;
+export const PHOTON_MAX_RESIZE = 2000;
+
+/**
+ * Layouts
+ */
+export const LAYOUT_CIRCLE = 'circle';
+export const LAYOUT_COLUMN = 'columns';
+export const LAYOUT_DEFAULT = 'rectangular';
+export const LAYOUT_SQUARE = 'square';
+export const LAYOUT_STYLES = [
+ {
+ isDefault: true,
+ name: LAYOUT_DEFAULT,
+ },
+ {
+ name: LAYOUT_CIRCLE,
+ },
+ {
+ name: LAYOUT_SQUARE,
+ },
+ {
+ name: LAYOUT_COLUMN,
+ },
+];
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/css-gram.scss b/plugins/jetpack/extensions/blocks/tiled-gallery/css-gram.scss
new file mode 100644
index 00000000..9fd2f49c
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/css-gram.scss
@@ -0,0 +1,86 @@
+/**
+ * This code is based on CSS gram:
+ * https://github.com/una/CSSgram/tree/master
+ *
+ * Due to the packaging options available, the source has been duplicated and adapted here
+ * to best fit our specific needs.
+ */
+
+/* From https://github.com/una/CSSgram/blob/0.1.12/source/scss/_shared.scss */
+@mixin pseudo-elem {
+ content: '';
+ display: block;
+ height: 100%;
+ width: 100%;
+ top: 0;
+ left: 0;
+ position: absolute;
+ pointer-events: none;
+}
+
+@mixin filter-base {
+ position: relative;
+
+ img {
+ width: 100%;
+ z-index: 1;
+ }
+
+ &::before {
+ @include pseudo-elem;
+ z-index: 2;
+ }
+
+ &::after {
+ @include pseudo-elem;
+ z-index: 3;
+ }
+}
+
+/**
+ * 1977
+ * From https://github.com/una/CSSgram/blob/0.1.12/source/scss/1977.scss
+ */
+@mixin _1977( $filters... ) {
+ @include filter-base;
+ filter: contrast( 1.1 ) brightness( 1.1 ) saturate( 1.3 ) $filters;
+
+ &::after {
+ background: rgba( 243, 106, 188, 0.3 );
+ mix-blend-mode: screen;
+ }
+
+ @content;
+}
+
+/*
+ * Clarendon
+ * From https://github.com/una/CSSgram/blob/0.1.12/source/scss/clarendon.scss
+ */
+@mixin clarendon( $filters... ) {
+ @include filter-base;
+ filter: contrast( 1.2 ) saturate( 1.35 ) $filters;
+
+ &::before {
+ background: rgba( 127, 187, 227, 0.2 );
+ mix-blend-mode: overlay;
+ }
+
+ @content;
+}
+
+/**
+ * Gingham
+ * From https://github.com/una/CSSgram/blob/0.1.12/source/scss/gingham.scss
+ */
+@mixin gingham( $filters... ) {
+ @include filter-base;
+ filter: brightness( 1.05 ) hue-rotate( -10deg ) $filters;
+
+ &::after {
+ background: rgb( 230, 230, 250 );
+ mix-blend-mode: soft-light;
+ }
+
+ @content;
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/constants.js b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/constants.js
new file mode 100644
index 00000000..55a451fc
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/constants.js
@@ -0,0 +1,27 @@
+export const ALLOWED_MEDIA_TYPES = [ 'image' ];
+export const GUTTER_WIDTH = 4;
+export const MAX_COLUMNS = 20;
+export const PHOTON_MAX_RESIZE = 2000;
+
+/**
+ * Layouts
+ */
+export const LAYOUT_CIRCLE = 'circle';
+export const LAYOUT_COLUMN = 'columns';
+export const LAYOUT_DEFAULT = 'rectangular';
+export const LAYOUT_SQUARE = 'square';
+export const LAYOUT_STYLES = [
+ {
+ isDefault: true,
+ name: LAYOUT_DEFAULT,
+ },
+ {
+ name: LAYOUT_CIRCLE,
+ },
+ {
+ name: LAYOUT_SQUARE,
+ },
+ {
+ name: LAYOUT_COLUMN,
+ },
+];
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/image.js b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/image.js
new file mode 100644
index 00000000..61d4a2cd
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/image.js
@@ -0,0 +1,51 @@
+/**
+ * External Dependencies
+ */
+import { isBlobURL } from '@wordpress/blob';
+
+export default function GalleryImageSave( props ) {
+ const {
+ 'aria-label': ariaLabel,
+ alt,
+ // caption,
+ height,
+ id,
+ link,
+ linkTo,
+ origUrl,
+ url,
+ width,
+ } = props;
+
+ if ( isBlobURL( origUrl ) ) {
+ return null;
+ }
+
+ let href;
+
+ switch ( linkTo ) {
+ case 'media':
+ href = url;
+ break;
+ case 'attachment':
+ href = link;
+ break;
+ }
+
+ const img = (
+ <img
+ alt={ alt }
+ aria-label={ ariaLabel }
+ data-height={ height }
+ data-id={ id }
+ data-link={ link }
+ data-url={ origUrl }
+ data-width={ width }
+ src={ url }
+ />
+ );
+
+ return (
+ <figure className="tiled-gallery__item">{ href ? <a href={ href }>{ img }</a> : img }</figure>
+ );
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/index.js b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/index.js
new file mode 100644
index 00000000..69539d00
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/index.js
@@ -0,0 +1,81 @@
+/**
+ * Internal dependencies
+ */
+export { default as save } from './save';
+import { LAYOUT_DEFAULT } from './constants';
+
+export const attributes = {
+ // Set default align
+ align: {
+ default: 'center',
+ type: 'string',
+ },
+ // Set default className (used with block styles)
+ className: {
+ default: `is-style-${ LAYOUT_DEFAULT }`,
+ type: 'string',
+ },
+ columns: {
+ type: 'number',
+ },
+ ids: {
+ default: [],
+ type: 'array',
+ },
+ images: {
+ type: 'array',
+ default: [],
+ source: 'query',
+ selector: '.tiled-gallery__item',
+ query: {
+ alt: {
+ attribute: 'alt',
+ default: '',
+ selector: 'img',
+ source: 'attribute',
+ },
+ caption: {
+ selector: 'figcaption',
+ source: 'html',
+ type: 'string',
+ },
+ height: {
+ attribute: 'data-height',
+ selector: 'img',
+ source: 'attribute',
+ type: 'number',
+ },
+ id: {
+ attribute: 'data-id',
+ selector: 'img',
+ source: 'attribute',
+ },
+ link: {
+ attribute: 'data-link',
+ selector: 'img',
+ source: 'attribute',
+ },
+ url: {
+ attribute: 'data-url',
+ selector: 'img',
+ source: 'attribute',
+ },
+ width: {
+ attribute: 'data-width',
+ selector: 'img',
+ source: 'attribute',
+ type: 'number',
+ },
+ },
+ },
+ linkTo: {
+ default: 'none',
+ type: 'string',
+ },
+};
+
+export const support = {
+ align: [ 'center', 'wide', 'full' ],
+ customClassName: false,
+ html: false,
+};
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/column.js b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/column.js
new file mode 100644
index 00000000..a3ed5cdf
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/column.js
@@ -0,0 +1,3 @@
+export default function Column( { children } ) {
+ return <div className="tiled-gallery__col">{ children }</div>;
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/gallery.js b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/gallery.js
new file mode 100644
index 00000000..94fc61e4
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/gallery.js
@@ -0,0 +1,7 @@
+export default function Gallery( { children, galleryRef } ) {
+ return (
+ <div className="tiled-gallery__gallery" ref={ galleryRef }>
+ { children }
+ </div>
+ );
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/index.js b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/index.js
new file mode 100644
index 00000000..3b434fe4
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/index.js
@@ -0,0 +1,144 @@
+/**
+ * External dependencies
+ */
+import photon from 'photon';
+import { __, sprintf } from '@wordpress/i18n';
+import { Component } from '@wordpress/element';
+import { format as formatUrl, parse as parseUrl } from 'url';
+import { isBlobURL } from '@wordpress/blob';
+
+/**
+ * Internal dependencies
+ */
+import Image from '../image';
+import Mosaic from './mosaic';
+import Square from './square';
+import { PHOTON_MAX_RESIZE } from '../constants';
+
+export default class Layout extends Component {
+ photonize( { height, width, url } ) {
+ if ( ! url ) {
+ return;
+ }
+
+ // Do not Photonize images that are still uploading or from localhost
+ if ( isBlobURL( url ) || /^https?:\/\/localhost/.test( url ) ) {
+ return url;
+ }
+
+ // Drop query args, photon URLs can't handle them
+ // This should be the "raw" url, we'll add dimensions later
+ const cleanUrl = url.split( '?', 1 )[ 0 ];
+
+ const photonImplementation = isWpcomFilesUrl( url ) ? photonWpcomImage : photon;
+
+ const { layoutStyle } = this.props;
+
+ if ( isSquareishLayout( layoutStyle ) && width && height ) {
+ const size = Math.min( PHOTON_MAX_RESIZE, width, height );
+ return photonImplementation( cleanUrl, { resize: `${ size },${ size }` } );
+ }
+ return photonImplementation( cleanUrl );
+ }
+
+ // This is tricky:
+ // - We need to "photonize" to resize the images at appropriate dimensions
+ // - The resize will depend on the image size and the layout in some cases
+ // - Handlers need to be created by index so that the image changes can be applied correctly.
+ // This is because the images are stored in an array in the block attributes.
+ renderImage( img, i ) {
+ const { images, linkTo, selectedImage } = this.props;
+
+ /* translators: %1$d is the order number of the image, %2$d is the total number of images. */
+ const ariaLabel = sprintf(
+ __( 'image %1$d of %2$d in gallery', 'jetpack' ),
+ i + 1,
+ images.length
+ );
+ return (
+ <Image
+ alt={ img.alt }
+ aria-label={ ariaLabel }
+ height={ img.height }
+ id={ img.id }
+ origUrl={ img.url }
+ isSelected={ selectedImage === i }
+ key={ i }
+ link={ img.link }
+ linkTo={ linkTo }
+ url={ this.photonize( img ) }
+ width={ img.width }
+ />
+ );
+ }
+
+ render() {
+ const { align, children, className, columns, images, layoutStyle } = this.props;
+
+ const LayoutRenderer = isSquareishLayout( layoutStyle ) ? Square : Mosaic;
+
+ const renderedImages = this.props.images.map( this.renderImage, this );
+
+ return (
+ <div className={ className }>
+ <LayoutRenderer
+ align={ align }
+ columns={ columns }
+ images={ images }
+ layoutStyle={ layoutStyle }
+ renderedImages={ renderedImages }
+ />
+ { children }
+ </div>
+ );
+ }
+}
+
+function isSquareishLayout( layout ) {
+ return [ 'circle', 'square' ].includes( layout );
+}
+
+function isWpcomFilesUrl( url ) {
+ const { host } = parseUrl( url );
+ return /\.files\.wordpress\.com$/.test( host );
+}
+
+/**
+ * Apply photon arguments to *.files.wordpress.com images
+ *
+ * This function largely duplicates the functionlity of the photon.js lib.
+ * This is necessary because we want to serve images from *.files.wordpress.com so that private
+ * WordPress.com sites can use this block which depends on a Photon-like image service.
+ *
+ * If we pass all images through Photon servers, some images are unreachable. *.files.wordpress.com
+ * is already photon-like so we can pass it the same parameters for image resizing.
+ *
+ * @param {string} url Image url
+ * @param {Object} opts Options to pass to photon
+ *
+ * @return {string} Url string with options applied
+ */
+function photonWpcomImage( url, opts = {} ) {
+ // Adhere to the same options API as the photon.js lib
+ const photonLibMappings = {
+ width: 'w',
+ height: 'h',
+ letterboxing: 'lb',
+ removeLetterboxing: 'ulb',
+ };
+
+ // Discard some param parts
+ const { auth, hash, port, query, search, ...urlParts } = parseUrl( url );
+
+ // Build query
+ // This reduction intentionally mutates the query as it is built internally.
+ urlParts.query = Object.keys( opts ).reduce(
+ ( q, key ) =>
+ Object.assign( q, {
+ [ photonLibMappings.hasOwnProperty( key ) ? photonLibMappings[ key ] : key ]: opts[ key ],
+ } ),
+ {}
+ );
+
+ return formatUrl( urlParts );
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/index.js b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/index.js
new file mode 100644
index 00000000..8c56b164
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/index.js
@@ -0,0 +1,104 @@
+/**
+ * External dependencies
+ */
+import { Component, createRef } from '@wordpress/element';
+import ResizeObserver from 'resize-observer-polyfill';
+
+/**
+ * Internal dependencies
+ */
+import Column from '../column';
+import Gallery from '../gallery';
+import Row from '../row';
+import { getGalleryRows, handleRowResize } from './resize';
+import { imagesToRatios, ratiosToColumns, ratiosToMosaicRows } from './ratios';
+
+export default class Mosaic extends Component {
+ gallery = createRef();
+ pendingRaf = null;
+ ro = null; // resizeObserver instance
+
+ componentDidMount() {
+ this.observeResize();
+ }
+
+ componentWillUnmount() {
+ this.unobserveResize();
+ }
+
+ componentDidUpdate( prevProps ) {
+ if ( prevProps.images !== this.props.images || prevProps.align !== this.props.align ) {
+ this.triggerResize();
+ } else if ( 'columns' === this.props.layoutStyle && prevProps.columns !== this.props.columns ) {
+ this.triggerResize();
+ }
+ }
+
+ handleGalleryResize = entries => {
+ if ( this.pendingRaf ) {
+ cancelAnimationFrame( this.pendingRaf );
+ this.pendingRaf = null;
+ }
+ this.pendingRaf = requestAnimationFrame( () => {
+ for ( const { contentRect, target } of entries ) {
+ const { width } = contentRect;
+ getGalleryRows( target ).forEach( row => handleRowResize( row, width ) );
+ }
+ } );
+ };
+
+ triggerResize() {
+ if ( this.gallery.current ) {
+ this.handleGalleryResize( [
+ {
+ target: this.gallery.current,
+ contentRect: { width: this.gallery.current.clientWidth },
+ },
+ ] );
+ }
+ }
+
+ observeResize() {
+ this.triggerResize();
+ this.ro = new ResizeObserver( this.handleGalleryResize );
+ if ( this.gallery.current ) {
+ this.ro.observe( this.gallery.current );
+ }
+ }
+
+ unobserveResize() {
+ if ( this.ro ) {
+ this.ro.disconnect();
+ this.ro = null;
+ }
+ if ( this.pendingRaf ) {
+ cancelAnimationFrame( this.pendingRaf );
+ this.pendingRaf = null;
+ }
+ }
+
+ render() {
+ const { align, columns, images, layoutStyle, renderedImages } = this.props;
+
+ const ratios = imagesToRatios( images );
+ const rows =
+ 'columns' === layoutStyle
+ ? ratiosToColumns( ratios, columns )
+ : ratiosToMosaicRows( ratios, { isWide: [ 'full', 'wide' ].includes( align ) } );
+
+ let cursor = 0;
+ return (
+ <Gallery galleryRef={ this.gallery }>
+ { rows.map( ( row, rowIndex ) => (
+ <Row key={ rowIndex }>
+ { row.map( ( colSize, colIndex ) => {
+ const columnImages = renderedImages.slice( cursor, cursor + colSize );
+ cursor += colSize;
+ return <Column key={ colIndex }>{ columnImages }</Column>;
+ } ) }
+ </Row>
+ ) ) }
+ </Gallery>
+ );
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/ratios.js b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/ratios.js
new file mode 100644
index 00000000..8accd552
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/ratios.js
@@ -0,0 +1,280 @@
+/**
+ * External dependencies
+ */
+import {
+ drop,
+ every,
+ isEqual,
+ map,
+ overEvery,
+ some,
+ sum,
+ take,
+ takeRight,
+ takeWhile,
+ zipWith,
+} from 'lodash';
+
+export function imagesToRatios( images ) {
+ return map( images, ratioFromImage );
+}
+
+export function ratioFromImage( { height, width } ) {
+ return height && width ? width / height : 1;
+}
+
+/**
+ * Build three columns, each of which should contain approximately 1/3 of the total ratio
+ *
+ * @param {Array.<number>} ratios Ratios of images put into shape
+ * @param {number} columnCount Number of columns
+ *
+ * @return {Array.<Array.<number>>} Shape of rows and columns
+ */
+export function ratiosToColumns( ratios, columnCount ) {
+ // If we don't have more than 1 per column, just return a simple 1 ratio per column shape
+ if ( ratios.length <= columnCount ) {
+ return [ Array( ratios.length ).fill( 1 ) ];
+ }
+
+ const total = sum( ratios );
+ const targetColRatio = total / columnCount;
+
+ const row = [];
+ let toProcess = ratios;
+ let accumulatedRatio = 0;
+
+ // We skip the last column in the loop and add rest later
+ for ( let i = 0; i < columnCount - 1; i++ ) {
+ const colSize = takeWhile( toProcess, ratio => {
+ const shouldTake = accumulatedRatio <= ( i + 1 ) * targetColRatio;
+ if ( shouldTake ) {
+ accumulatedRatio += ratio;
+ }
+ return shouldTake;
+ } ).length;
+ row.push( colSize );
+ toProcess = drop( toProcess, colSize );
+ }
+
+ // Don't calculate last column, just add what's left
+ row.push( toProcess.length );
+
+ // A shape is an array of rows. Wrap our row in an array.
+ return [ row ];
+}
+
+/**
+ * These are partially applied functions.
+ * They rely on helper function (defined below) to create a function that expects to be passed ratios
+ * during processing.
+ *
+ * …FitsNextImages() functions should be passed ratios to be processed
+ * …IsNotRecent() functions should be passed the processed shapes
+ */
+
+const reverseSymmetricRowIsNotRecent = isNotRecentShape( [ 2, 1, 2 ], 5 );
+const reverseSymmetricFitsNextImages = checkNextRatios( [
+ isLandscape,
+ isLandscape,
+ isPortrait,
+ isLandscape,
+ isLandscape,
+] );
+const longSymmetricRowFitsNextImages = checkNextRatios( [
+ isLandscape,
+ isLandscape,
+ isLandscape,
+ isPortrait,
+ isLandscape,
+ isLandscape,
+ isLandscape,
+] );
+const longSymmetricRowIsNotRecent = isNotRecentShape( [ 3, 1, 3 ], 5 );
+const symmetricRowFitsNextImages = checkNextRatios( [
+ isPortrait,
+ isLandscape,
+ isLandscape,
+ isPortrait,
+] );
+const symmetricRowIsNotRecent = isNotRecentShape( [ 1, 2, 1 ], 5 );
+const oneThreeFitsNextImages = checkNextRatios( [
+ isPortrait,
+ isLandscape,
+ isLandscape,
+ isLandscape,
+] );
+const oneThreeIsNotRecent = isNotRecentShape( [ 1, 3 ], 3 );
+const threeOneIsFitsNextImages = checkNextRatios( [
+ isLandscape,
+ isLandscape,
+ isLandscape,
+ isPortrait,
+] );
+const threeOneIsNotRecent = isNotRecentShape( [ 3, 1 ], 3 );
+const oneTwoFitsNextImages = checkNextRatios( [
+ lt( 1.6 ),
+ overEvery( gte( 0.9 ), lt( 2 ) ),
+ overEvery( gte( 0.9 ), lt( 2 ) ),
+] );
+const oneTwoIsNotRecent = isNotRecentShape( [ 1, 2 ], 3 );
+const fiveIsNotRecent = isNotRecentShape( [ 1, 1, 1, 1, 1 ], 1 );
+const fourIsNotRecent = isNotRecentShape( [ 1, 1, 1, 1 ], 1 );
+const threeIsNotRecent = isNotRecentShape( [ 1, 1, 1 ], 3 );
+const twoOneFitsNextImages = checkNextRatios( [
+ overEvery( gte( 0.9 ), lt( 2 ) ),
+ overEvery( gte( 0.9 ), lt( 2 ) ),
+ lt( 1.6 ),
+] );
+const twoOneIsNotRecent = isNotRecentShape( [ 2, 1 ], 3 );
+const panoramicFitsNextImages = checkNextRatios( [ isPanoramic ] );
+
+export function ratiosToMosaicRows( ratios, { isWide } = {} ) {
+ // This function will recursively process the input until it is consumed
+ const go = ( processed, toProcess ) => {
+ if ( ! toProcess.length ) {
+ return processed;
+ }
+
+ let next;
+
+ if (
+ /* Reverse_Symmetric_Row */
+ toProcess.length > 15 &&
+ reverseSymmetricFitsNextImages( toProcess ) &&
+ reverseSymmetricRowIsNotRecent( processed )
+ ) {
+ next = [ 2, 1, 2 ];
+ } else if (
+ /* Long_Symmetric_Row */
+ toProcess.length > 15 &&
+ longSymmetricRowFitsNextImages( toProcess ) &&
+ longSymmetricRowIsNotRecent( processed )
+ ) {
+ next = [ 3, 1, 3 ];
+ } else if (
+ /* Symmetric_Row */
+ toProcess.length !== 5 &&
+ symmetricRowFitsNextImages( toProcess ) &&
+ symmetricRowIsNotRecent( processed )
+ ) {
+ next = [ 1, 2, 1 ];
+ } else if (
+ /* One_Three */
+ oneThreeFitsNextImages( toProcess ) &&
+ oneThreeIsNotRecent( processed )
+ ) {
+ next = [ 1, 3 ];
+ } else if (
+ /* Three_One */
+ threeOneIsFitsNextImages( toProcess ) &&
+ threeOneIsNotRecent( processed )
+ ) {
+ next = [ 3, 1 ];
+ } else if (
+ /* One_Two */
+ oneTwoFitsNextImages( toProcess ) &&
+ oneTwoIsNotRecent( processed )
+ ) {
+ next = [ 1, 2 ];
+ } else if (
+ /* Five */
+ isWide &&
+ ( toProcess.length === 5 || ( toProcess.length !== 10 && toProcess.length > 6 ) ) &&
+ fiveIsNotRecent( processed ) &&
+ sum( take( toProcess, 5 ) ) < 5
+ ) {
+ next = [ 1, 1, 1, 1, 1 ];
+ } else if (
+ /* Four */
+ isFourValidCandidate( processed, toProcess )
+ ) {
+ next = [ 1, 1, 1, 1 ];
+ } else if (
+ /* Three */
+ isThreeValidCandidate( processed, toProcess, isWide )
+ ) {
+ next = [ 1, 1, 1 ];
+ } else if (
+ /* Two_One */
+ twoOneFitsNextImages( toProcess ) &&
+ twoOneIsNotRecent( processed )
+ ) {
+ next = [ 2, 1 ];
+ } else if ( /* Panoramic */ panoramicFitsNextImages( toProcess ) ) {
+ next = [ 1 ];
+ } else if ( /* One_One */ toProcess.length > 3 ) {
+ next = [ 1, 1 ];
+ } else {
+ // Everything left
+ next = Array( toProcess.length ).fill( 1 );
+ }
+
+ // Add row
+ const nextProcessed = processed.concat( [ next ] );
+
+ // Trim consumed images from next processing step
+ const consumedImages = sum( next );
+ const nextToProcess = toProcess.slice( consumedImages );
+
+ return go( nextProcessed, nextToProcess );
+ };
+ return go( [], ratios );
+}
+
+function isThreeValidCandidate( processed, toProcess, isWide ) {
+ const ratio = sum( take( toProcess, 3 ) );
+ return (
+ toProcess.length >= 3 &&
+ toProcess.length !== 4 &&
+ toProcess.length !== 6 &&
+ threeIsNotRecent( processed ) &&
+ ( ratio < 2.5 ||
+ ( ratio < 5 &&
+ /* nextAreSymettric */
+ ( toProcess.length >= 3 &&
+ /* @FIXME floating point equality?? */ toProcess[ 0 ] === toProcess[ 2 ] ) ) ||
+ isWide )
+ );
+}
+
+function isFourValidCandidate( processed, toProcess ) {
+ const ratio = sum( take( toProcess, 4 ) );
+ return (
+ ( fourIsNotRecent( processed ) && ( ratio < 3.5 && toProcess.length > 5 ) ) ||
+ ( ratio < 7 && toProcess.length === 4 )
+ );
+}
+
+function isNotRecentShape( shape, numRecents ) {
+ return recents =>
+ ! some( takeRight( recents, numRecents ), recentShape => isEqual( recentShape, shape ) );
+}
+
+function checkNextRatios( shape ) {
+ return ratios =>
+ ratios.length >= shape.length &&
+ every( zipWith( shape, ratios.slice( 0, shape.length ), ( f, r ) => f( r ) ) );
+}
+
+function isLandscape( ratio ) {
+ return ratio >= 1 && ratio < 2;
+}
+
+function isPortrait( ratio ) {
+ return ratio < 1;
+}
+
+function isPanoramic( ratio ) {
+ return ratio >= 2;
+}
+
+// >=
+function gte( n ) {
+ return m => m >= n;
+}
+
+// <
+function lt( n ) {
+ return m => m < n;
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/resize.js b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/resize.js
new file mode 100644
index 00000000..022729c8
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/resize.js
@@ -0,0 +1,107 @@
+/**
+ * Internal dependencies
+ */
+import { GUTTER_WIDTH } from '../../constants';
+
+/**
+ * Distribute a difference across ns so that their sum matches the target
+ *
+ * @param {Array<number>} parts Array of numbers to fit
+ * @param {number} target Number that sum should match
+ * @return {Array<number>} Adjusted parts
+ */
+function adjustFit( parts, target ) {
+ const diff = target - parts.reduce( ( sum, n ) => sum + n, 0 );
+ const partialDiff = diff / parts.length;
+ return parts.map( p => p + partialDiff );
+}
+
+export function handleRowResize( row, width ) {
+ applyRowRatio( row, getRowRatio( row ), width );
+}
+
+function getRowRatio( row ) {
+ const result = getRowCols( row )
+ .map( getColumnRatio )
+ .reduce(
+ ( [ ratioA, weightedRatioA ], [ ratioB, weightedRatioB ] ) => {
+ return [ ratioA + ratioB, weightedRatioA + weightedRatioB ];
+ },
+ [ 0, 0 ]
+ );
+ return result;
+}
+
+export function getGalleryRows( gallery ) {
+ return Array.from( gallery.querySelectorAll( '.tiled-gallery__row' ) );
+}
+
+function getRowCols( row ) {
+ return Array.from( row.querySelectorAll( '.tiled-gallery__col' ) );
+}
+
+function getColImgs( col ) {
+ return Array.from(
+ col.querySelectorAll( '.tiled-gallery__item > img, .tiled-gallery__item > a > img' )
+ );
+}
+
+function getColumnRatio( col ) {
+ const imgs = getColImgs( col );
+ const imgCount = imgs.length;
+ const ratio =
+ 1 /
+ imgs.map( getImageRatio ).reduce( ( partialColRatio, imgRatio ) => {
+ return partialColRatio + 1 / imgRatio;
+ }, 0 );
+ const result = [ ratio, ratio * imgCount || 1 ];
+ return result;
+}
+
+function getImageRatio( img ) {
+ const w = parseInt( img.dataset.width, 10 );
+ const h = parseInt( img.dataset.height, 10 );
+ const result = w && ! Number.isNaN( w ) && h && ! Number.isNaN( h ) ? w / h : 1;
+ return result;
+}
+
+function applyRowRatio( row, [ ratio, weightedRatio ], width ) {
+ const rawHeight =
+ ( 1 / ratio ) * ( width - GUTTER_WIDTH * ( row.childElementCount - 1 ) - weightedRatio );
+
+ applyColRatio( row, {
+ rawHeight,
+ rowWidth: width - GUTTER_WIDTH * ( row.childElementCount - 1 ),
+ } );
+}
+
+function applyColRatio( row, { rawHeight, rowWidth } ) {
+ const cols = getRowCols( row );
+
+ const colWidths = cols.map(
+ col => ( rawHeight - GUTTER_WIDTH * ( col.childElementCount - 1 ) ) * getColumnRatio( col )[ 0 ]
+ );
+
+ const adjustedWidths = adjustFit( colWidths, rowWidth );
+
+ cols.forEach( ( col, i ) => {
+ const rawWidth = colWidths[ i ];
+ const width = adjustedWidths[ i ];
+ applyImgRatio( col, {
+ colHeight: rawHeight - GUTTER_WIDTH * ( col.childElementCount - 1 ),
+ width,
+ rawWidth,
+ } );
+ } );
+}
+
+function applyImgRatio( col, { colHeight, width, rawWidth } ) {
+ const imgHeights = getColImgs( col ).map( img => rawWidth / getImageRatio( img ) );
+ const adjustedHeights = adjustFit( imgHeights, colHeight );
+
+ // Set size of col children, not the <img /> element
+ Array.from( col.children ).forEach( ( item, i ) => {
+ const height = adjustedHeights[ i ];
+ item.setAttribute( 'style', `height:${ height }px;width:${ width }px;` );
+ } );
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/row.js b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/row.js
new file mode 100644
index 00000000..200a58c2
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/row.js
@@ -0,0 +1,8 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+
+export default function Row( { children, className } ) {
+ return <div className={ classnames( 'tiled-gallery__row', className ) }>{ children }</div>;
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/square.js b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/square.js
new file mode 100644
index 00000000..2a1ab888
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/layout/square.js
@@ -0,0 +1,33 @@
+/**
+ * External dependencies
+ */
+import { chunk, drop, take } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+import Row from './row';
+import Column from './column';
+import Gallery from './gallery';
+import { MAX_COLUMNS } from '../constants';
+
+export default function Square( { columns, renderedImages } ) {
+ const columnCount = Math.min( MAX_COLUMNS, columns );
+
+ const remainder = renderedImages.length % columnCount;
+
+ return (
+ <Gallery>
+ { [
+ ...( remainder ? [ take( renderedImages, remainder ) ] : [] ),
+ ...chunk( drop( renderedImages, remainder ), columnCount ),
+ ].map( ( imagesInRow, rowIndex ) => (
+ <Row key={ rowIndex } className={ `columns-${ imagesInRow.length }` }>
+ { imagesInRow.map( ( image, colIndex ) => (
+ <Column key={ colIndex }>{ image }</Column>
+ ) ) }
+ </Row>
+ ) ) }
+ </Gallery>
+ );
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/save.js b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/save.js
new file mode 100644
index 00000000..2a157a30
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/deprecated/v1/save.js
@@ -0,0 +1,31 @@
+/**
+ * Internal dependencies
+ */
+import Layout from './layout';
+import { getActiveStyleName } from '../../../../shared/block-styles';
+import { LAYOUT_STYLES } from './constants';
+
+export function defaultColumnsNumber( attributes ) {
+ return Math.min( 3, attributes.images.length );
+}
+
+export default function TiledGallerySave( { attributes } ) {
+ const { images } = attributes;
+
+ if ( ! images.length ) {
+ return null;
+ }
+
+ const { align, className, columns = defaultColumnsNumber( attributes ), linkTo } = attributes;
+
+ return (
+ <Layout
+ align={ align }
+ className={ className }
+ columns={ columns }
+ images={ images }
+ layoutStyle={ getActiveStyleName( LAYOUT_STYLES, className ) }
+ linkTo={ linkTo }
+ />
+ );
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/edit.js b/plugins/jetpack/extensions/blocks/tiled-gallery/edit.js
new file mode 100644
index 00000000..45574252
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/edit.js
@@ -0,0 +1,289 @@
+/**
+ * External Dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Component, Fragment } from '@wordpress/element';
+import { filter, get, pick } from 'lodash';
+import {
+ BlockControls,
+ BlockIcon,
+ InspectorControls,
+ MediaPlaceholder,
+ MediaUpload,
+ mediaUpload,
+} from '@wordpress/editor';
+import {
+ DropZone,
+ FormFileUpload,
+ IconButton,
+ PanelBody,
+ RangeControl,
+ SelectControl,
+ Toolbar,
+ withNotices,
+} from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import FilterToolbar from './filter-toolbar';
+import Layout from './layout';
+import { ALLOWED_MEDIA_TYPES, LAYOUT_STYLES, MAX_COLUMNS } from './constants';
+import { getActiveStyleName } from '../../shared/block-styles';
+import { icon } from '.';
+
+const linkOptions = [
+ { value: 'attachment', label: __( 'Attachment Page', 'jetpack' ) },
+ { value: 'media', label: __( 'Media File', 'jetpack' ) },
+ { value: 'none', label: __( 'None', 'jetpack' ) },
+];
+
+// @TODO keep here or move to ./layout ?
+function layoutSupportsColumns( layout ) {
+ return [ 'columns', 'circle', 'square' ].includes( layout );
+}
+
+export function defaultColumnsNumber( attributes ) {
+ return Math.min( 3, attributes.images.length );
+}
+
+export const pickRelevantMediaFiles = image => {
+ const imageProps = pick( image, [ [ 'alt' ], [ 'id' ], [ 'link' ] ] );
+ imageProps.url =
+ get( image, [ 'sizes', 'large', 'url' ] ) ||
+ get( image, [ 'media_details', 'sizes', 'large', 'source_url' ] ) ||
+ image.url;
+ return imageProps;
+};
+
+class TiledGalleryEdit extends Component {
+ state = {
+ selectedImage: null,
+ };
+
+ static getDerivedStateFromProps( props, state ) {
+ // Deselect images when deselecting the block
+ if ( ! props.isSelected && null !== state.selectedImage ) {
+ return { selectedImage: null };
+ }
+ return null;
+ }
+
+ setAttributes( attributes ) {
+ if ( attributes.ids ) {
+ throw new Error(
+ 'The "ids" attribute should not be changed directly. It is managed automatically when "images" attribute changes'
+ );
+ }
+
+ if ( attributes.images ) {
+ attributes = {
+ ...attributes,
+ ids: attributes.images.map( ( { id } ) => parseInt( id, 10 ) ),
+ };
+ }
+
+ this.props.setAttributes( attributes );
+ }
+
+ addFiles = files => {
+ const currentImages = this.props.attributes.images || [];
+ const { noticeOperations } = this.props;
+ mediaUpload( {
+ allowedTypes: ALLOWED_MEDIA_TYPES,
+ filesList: files,
+ onFileChange: images => {
+ const imagesNormalized = images.map( image => pickRelevantMediaFiles( image ) );
+ this.setAttributes( { images: currentImages.concat( imagesNormalized ) } );
+ },
+ onError: noticeOperations.createErrorNotice,
+ } );
+ };
+
+ onRemoveImage = index => () => {
+ const images = filter( this.props.attributes.images, ( img, i ) => index !== i );
+ const { columns } = this.props.attributes;
+ this.setState( {
+ selectedImage: null,
+ } );
+ this.setAttributes( {
+ images,
+ columns: columns ? Math.min( images.length, columns ) : columns,
+ } );
+ };
+
+ onSelectImage = index => () => {
+ if ( this.state.selectedImage !== index ) {
+ this.setState( {
+ selectedImage: index,
+ } );
+ }
+ };
+
+ onSelectImages = images => {
+ const { columns } = this.props.attributes;
+ this.setAttributes( {
+ columns: columns ? Math.min( images.length, columns ) : columns,
+ images: images.map( image => pickRelevantMediaFiles( image ) ),
+ } );
+ };
+
+ setColumnsNumber = value => this.setAttributes( { columns: value } );
+
+ setImageAttributes = index => attributes => {
+ const {
+ attributes: { images },
+ } = this.props;
+ if ( ! images[ index ] ) {
+ return;
+ }
+ this.setAttributes( {
+ images: [
+ ...images.slice( 0, index ),
+ { ...images[ index ], ...attributes },
+ ...images.slice( index + 1 ),
+ ],
+ } );
+ };
+
+ setLinkTo = value => this.setAttributes( { linkTo: value } );
+
+ uploadFromFiles = event => this.addFiles( event.target.files );
+
+ render() {
+ const { selectedImage } = this.state;
+ const {
+ attributes,
+ isSelected,
+ className,
+ noticeOperations,
+ noticeUI,
+ setAttributes,
+ } = this.props;
+ const {
+ align,
+ columns = defaultColumnsNumber( attributes ),
+ imageFilter,
+ images,
+ linkTo,
+ } = attributes;
+
+ const dropZone = <DropZone onFilesDrop={ this.addFiles } />;
+
+ const controls = (
+ <BlockControls>
+ { !! images.length && (
+ <Fragment>
+ <Toolbar>
+ <MediaUpload
+ onSelect={ this.onSelectImages }
+ allowedTypes={ ALLOWED_MEDIA_TYPES }
+ multiple
+ gallery
+ value={ images.map( img => img.id ) }
+ render={ ( { open } ) => (
+ <IconButton
+ className="components-toolbar__control"
+ label={ __( 'Edit Gallery', 'jetpack' ) }
+ icon="edit"
+ onClick={ open }
+ />
+ ) }
+ />
+ </Toolbar>
+ <FilterToolbar
+ value={ imageFilter }
+ onChange={ value => {
+ setAttributes( { imageFilter: value } );
+ this.setState( { selectedImage: null } );
+ } }
+ />
+ </Fragment>
+ ) }
+ </BlockControls>
+ );
+
+ if ( images.length === 0 ) {
+ return (
+ <Fragment>
+ { controls }
+ <MediaPlaceholder
+ icon={ <BlockIcon icon={ icon } /> }
+ className={ className }
+ labels={ {
+ title: __( 'Tiled Gallery', 'jetpack' ),
+ name: __( 'images', 'jetpack' ),
+ } }
+ onSelect={ this.onSelectImages }
+ accept="image/*"
+ allowedTypes={ ALLOWED_MEDIA_TYPES }
+ multiple
+ notices={ noticeUI }
+ onError={ noticeOperations.createErrorNotice }
+ />
+ </Fragment>
+ );
+ }
+
+ const layoutStyle = getActiveStyleName( LAYOUT_STYLES, attributes.className );
+
+ return (
+ <Fragment>
+ { controls }
+ <InspectorControls>
+ <PanelBody title={ __( 'Tiled Gallery settings', 'jetpack' ) }>
+ { layoutSupportsColumns( layoutStyle ) && images.length > 1 && (
+ <RangeControl
+ label={ __( 'Columns', 'jetpack' ) }
+ value={ columns }
+ onChange={ this.setColumnsNumber }
+ min={ 1 }
+ max={ Math.min( MAX_COLUMNS, images.length ) }
+ />
+ ) }
+ <SelectControl
+ label={ __( 'Link To', 'jetpack' ) }
+ value={ linkTo }
+ onChange={ this.setLinkTo }
+ options={ linkOptions }
+ />
+ </PanelBody>
+ </InspectorControls>
+
+ { noticeUI }
+
+ <Layout
+ align={ align }
+ className={ className }
+ columns={ columns }
+ imageFilter={ imageFilter }
+ images={ images }
+ layoutStyle={ layoutStyle }
+ linkTo={ linkTo }
+ onRemoveImage={ this.onRemoveImage }
+ onSelectImage={ this.onSelectImage }
+ selectedImage={ isSelected ? selectedImage : null }
+ setImageAttributes={ this.setImageAttributes }
+ >
+ { dropZone }
+ { isSelected && (
+ <div className="tiled-gallery__add-item">
+ <FormFileUpload
+ multiple
+ isLarge
+ className="tiled-gallery__add-item-button"
+ onChange={ this.uploadFromFiles }
+ accept="image/*"
+ icon="insert"
+ >
+ { __( 'Upload an image', 'jetpack' ) }
+ </FormFileUpload>
+ </div>
+ ) }
+ </Layout>
+ </Fragment>
+ );
+ }
+}
+
+export default withNotices( TiledGalleryEdit );
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/editor.js b/plugins/jetpack/extensions/blocks/tiled-gallery/editor.js
new file mode 100644
index 00000000..d05f4039
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/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/plugins/jetpack/extensions/blocks/tiled-gallery/editor.scss b/plugins/jetpack/extensions/blocks/tiled-gallery/editor.scss
new file mode 100644
index 00000000..7663d70c
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/editor.scss
@@ -0,0 +1,148 @@
+@import './view.scss';
+@import './variables.scss';
+@import '../../shared/styles/gutenberg-colors.scss';
+
+// inspired by from assets/shared/_animations loading-fade
+@keyframes tiled-gallery-img-placeholder {
+ 0% {
+ background-color: var( --color-neutral-0 );
+ }
+ 50% {
+ background-color: rgba( var( --color-neutral-0-rgb ), 0.5 );
+ }
+ 100% {
+ background-color: var( --color-neutral-0 );
+ }
+}
+
+.wp-block-jetpack-tiled-gallery {
+ // Ensure that selected image outlines are visibile
+ padding-left: 4px;
+ padding-right: 4px;
+
+ &.is-style-square,
+ &.is-style-circle {
+ .tiled-gallery__item.is-transient img {
+ // Transient images (no src attribute) occupy no vertical space.
+ // If on a row by themself, the row is hidden.
+ // By setting the bottom margin, ensure they occupy the correct vertical space.
+ margin-bottom: 100%;
+ }
+ }
+
+ .tiled-gallery__item {
+ // Hide the focus outline that otherwise briefly appears when selecting a block.
+ > img:focus {
+ outline: none;
+ }
+
+ > img {
+ // Inspired by Calypso's placeholder mixin
+ animation: tiled-gallery-img-placeholder 1.6s ease-in-out infinite;
+ }
+
+ &.is-selected {
+ outline: 4px solid $tiled-gallery-selection;
+
+ // Disable filters when selected
+ filter: none;
+ &::before,
+ &::after {
+ content: none;
+ }
+ }
+
+ &.is-transient {
+ height: 100%;
+ width: 100%;
+ img {
+ background-position: center;
+ background-size: cover;
+ height: 100%;
+ opacity: 0.3;
+ width: 100%;
+ }
+ }
+ }
+
+ .tiled-gallery__add-item {
+ margin-top: $tiled-gallery-gutter;
+ width: 100%;
+
+ .components-form-file-upload,
+ .components-button.tiled-gallery__add-item-button {
+ width: 100%;
+ height: 100%;
+ }
+
+ .components-button.tiled-gallery__add-item-button {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ box-shadow: none;
+ border: none;
+ border-radius: 0;
+ min-height: 100px;
+
+ .dashicon {
+ margin-top: 10px;
+ }
+
+ &:hover,
+ &:focus {
+ border: 1px solid $dark-gray-500;
+ }
+ }
+ }
+
+ .tiled-gallery__item__inline-menu {
+ background-color: $tiled-gallery-selection;
+ display: inline-flex;
+ padding: 0 0 2px 2px;
+ position: absolute;
+ right: 0;
+ top: 0;
+
+ .components-button {
+ color: var( --color-white );
+ &:hover,
+ &:focus {
+ color: var( --color-white );
+ }
+ }
+ }
+
+ .tiled-gallery__item__remove {
+ padding: 0;
+ }
+
+ .tiled-gallery__item .components-spinner {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin: 0;
+ transform: translate( -50%, -50% );
+ }
+
+ // Hide upload buttons in style picker preview
+ .editor-block-preview__content & {
+ .editor-media-placeholder {
+ display: none;
+ }
+ }
+}
+
+.tiled-gallery__filter-picker-menu {
+ $active-item-outline-width: 2px;
+ padding: 7px;
+
+ // Leave space between elements for active state styling
+ .components-menu-item__button + .components-menu-item__button {
+ margin-top: $active-item-outline-width;
+ }
+
+ .components-menu-item__button.is-active {
+ color: $dark-gray-900;
+ box-shadow: 0 0 0 $active-item-outline-width $dark-gray-500 !important;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/filter-toolbar.js b/plugins/jetpack/extensions/blocks/tiled-gallery/filter-toolbar.js
new file mode 100644
index 00000000..7d86f335
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/filter-toolbar.js
@@ -0,0 +1,136 @@
+/**
+ * External Dependencies
+ */
+import { __, _x } from '@wordpress/i18n';
+import { Dropdown, MenuItem, NavigableMenu, Path, SVG, Toolbar } from '@wordpress/components';
+
+const availableFilters = [
+ {
+ icon: (
+ /* No filter */
+ <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <Path fill="none" d="M0 0h24v24H0V0z" />
+ <Path d="M3 5H1v16c0 1.1.9 2 2 2h16v-2H3V5zm18-4H7c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm0 16H7V3h14v14z" />
+ </SVG>
+ ),
+ title: _x( 'Original', 'image style', 'jetpack' ),
+ value: undefined,
+ },
+ {
+ icon: (
+ /* 1 */
+ <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <Path fill="none" d="M0 0h24v24H0V0z" />
+ <Path d="M3 5H1v16c0 1.1.9 2 2 2h16v-2H3V5zm11 10h2V5h-4v2h2v8zm7-14H7c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm0 16H7V3h14v14z" />
+ </SVG>
+ ),
+ title: _x( 'Black and White', 'image style', 'jetpack' ),
+ value: 'black-and-white',
+ },
+ {
+ icon: (
+ /* 2 */
+ <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <Path fill="none" d="M0 0h24v24H0V0z" />
+ <Path d="M3 5H1v16c0 1.1.9 2 2 2h16v-2H3V5zm18-4H7c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm0 16H7V3h14v14zm-4-4h-4v-2h2c1.1 0 2-.89 2-2V7c0-1.11-.9-2-2-2h-4v2h4v2h-2c-1.1 0-2 .89-2 2v4h6v-2z" />
+ </SVG>
+ ),
+ title: _x( 'Sepia', 'image style', 'jetpack' ),
+ value: 'sepia',
+ },
+ {
+ icon: (
+ /* 3 */
+ <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <Path fill="none" d="M0 0h24v24H0V0z" />
+ <Path d="M21 1H7c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm0 16H7V3h14v14zM3 5H1v16c0 1.1.9 2 2 2h16v-2H3V5zm14 8v-1.5c0-.83-.67-1.5-1.5-1.5.83 0 1.5-.67 1.5-1.5V7c0-1.11-.9-2-2-2h-4v2h4v2h-2v2h2v2h-4v2h4c1.1 0 2-.89 2-2z" />
+ </SVG>
+ ),
+ title: '1977',
+ value: '1977',
+ },
+ {
+ icon: (
+ /* 4 */
+ <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <Path fill="none" d="M0 0h24v24H0V0z" />
+ <Path d="M3 5H1v16c0 1.1.9 2 2 2h16v-2H3V5zm12 10h2V5h-2v4h-2V5h-2v6h4v4zm6-14H7c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm0 16H7V3h14v14z" />
+ </SVG>
+ ),
+ title: _x( 'Clarendon', 'image style', 'jetpack' ),
+ value: 'clarendon',
+ },
+ {
+ icon: (
+ /* 5 */
+ <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <Path fill="none" d="M0 0h24v24H0z" />
+ <Path d="M21 1H7c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm0 16H7V3h14v14zM3 5H1v16c0 1.1.9 2 2 2h16v-2H3V5zm14 8v-2c0-1.11-.9-2-2-2h-2V7h4V5h-6v6h4v2h-4v2h4c1.1 0 2-.89 2-2z" />
+ </SVG>
+ ),
+ title: _x( 'Gingham', 'image style', 'jetpack' ),
+ value: 'gingham',
+ },
+];
+
+const label = __( 'Pick an image filter', 'jetpack' );
+
+export default function FilterToolbar( { value, onChange } ) {
+ return (
+ <Dropdown
+ position="bottom right"
+ className="editor-block-switcher"
+ contentClassName="editor-block-switcher__popover"
+ renderToggle={ ( { onToggle, isOpen } ) => {
+ return (
+ <Toolbar
+ controls={ [
+ {
+ onClick: onToggle,
+ extraProps: {
+ 'aria-haspopup': 'true',
+ 'aria-expanded': isOpen,
+ },
+ title: label,
+ tooltip: label,
+ icon: (
+ <SVG
+ xmlns="http://www.w3.org/2000/svg"
+ width="24"
+ height="24"
+ viewBox="0 0 24 24"
+ >
+ <Path fill="none" d="M0 0h24v24H0V0z" />
+ <Path d="M19 10v9H4.98V5h9V3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-9h-2zm-2.94-2.06L17 10l.94-2.06L20 7l-2.06-.94L17 4l-.94 2.06L14 7zM12 8l-1.25 2.75L8 12l2.75 1.25L12 16l1.25-2.75L16 12l-2.75-1.25z" />
+ </SVG>
+ ),
+ },
+ ] }
+ />
+ );
+ } }
+ renderContent={ ( { onClose } ) => {
+ const applyOrUnset = nextValue => () => {
+ onChange( value === nextValue ? undefined : nextValue );
+ onClose();
+ };
+ return (
+ <NavigableMenu className="tiled-gallery__filter-picker-menu">
+ { availableFilters.map( ( { icon, title, value: filterValue } ) => (
+ <MenuItem
+ className={ value === filterValue ? 'is-active' : undefined }
+ icon={ icon }
+ isSelected={ value === filterValue }
+ key={ filterValue || 'original' }
+ onClick={ applyOrUnset( filterValue ) }
+ role="menuitemcheckbox"
+ >
+ { title }
+ </MenuItem>
+ ) ) }
+ </NavigableMenu>
+ );
+ } }
+ />
+ );
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/gallery-image/edit.js b/plugins/jetpack/extensions/blocks/tiled-gallery/gallery-image/edit.js
new file mode 100644
index 00000000..e4d77349
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/gallery-image/edit.js
@@ -0,0 +1,147 @@
+/**
+ * External Dependencies
+ */
+import classnames from 'classnames';
+import { __ } from '@wordpress/i18n';
+import { BACKSPACE, DELETE } from '@wordpress/keycodes';
+import { Component, createRef, Fragment } from '@wordpress/element';
+import { IconButton, Spinner } from '@wordpress/components';
+import { isBlobURL } from '@wordpress/blob';
+import { withSelect } from '@wordpress/data';
+
+class GalleryImageEdit extends Component {
+ img = createRef();
+
+ onImageClick = () => {
+ if ( ! this.props.isSelected ) {
+ this.props.onSelect();
+ }
+ };
+
+ onImageKeyDown = event => {
+ if (
+ this.img.current === document.activeElement &&
+ this.props.isSelected &&
+ [ BACKSPACE, DELETE ].includes( event.keyCode )
+ ) {
+ this.props.onRemove();
+ }
+ };
+
+ componentDidUpdate() {
+ const { alt, height, image, link, url, width } = this.props;
+
+ if ( image ) {
+ const nextAtts = {};
+
+ if ( ! alt && image.alt_text ) {
+ nextAtts.alt = image.alt_text;
+ }
+ if ( ! height && image.media_details && image.media_details.height ) {
+ nextAtts.height = +image.media_details.height;
+ }
+ if ( ! link && image.link ) {
+ nextAtts.link = image.link;
+ }
+ if ( ! url && image.source_url ) {
+ nextAtts.url = image.source_url;
+ }
+ if ( ! width && image.media_details && image.media_details.width ) {
+ nextAtts.width = +image.media_details.width;
+ }
+
+ if ( Object.keys( nextAtts ).length ) {
+ this.props.setAttributes( nextAtts );
+ }
+ }
+ }
+
+ render() {
+ const {
+ 'aria-label': ariaLabel,
+ alt,
+ height,
+ id,
+ imageFilter,
+ isSelected,
+ link,
+ linkTo,
+ onRemove,
+ origUrl,
+ url,
+ width,
+ } = this.props;
+
+ let href;
+
+ switch ( linkTo ) {
+ case 'media':
+ href = url;
+ break;
+ case 'attachment':
+ href = link;
+ break;
+ }
+
+ const isTransient = isBlobURL( origUrl );
+
+ const img = (
+ // Disable reason: Image itself is not meant to be interactive, but should
+ // direct image selection and unfocus caption fields.
+ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */
+ <Fragment>
+ <img
+ alt={ alt }
+ aria-label={ ariaLabel }
+ data-height={ height }
+ data-id={ id }
+ data-link={ link }
+ data-url={ origUrl }
+ data-width={ width }
+ onClick={ this.onImageClick }
+ onKeyDown={ this.onImageKeyDown }
+ ref={ this.img }
+ src={ isTransient ? undefined : url }
+ tabIndex="0"
+ style={ isTransient ? { backgroundImage: `url(${ url })` } : undefined }
+ />
+ { isTransient && <Spinner /> }
+ </Fragment>
+ /* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */
+ );
+
+ // Disable reason: Each block can be selected by clicking on it and we should keep the same saved markup
+ return (
+ <figure
+ className={ classnames( 'tiled-gallery__item', {
+ 'is-selected': isSelected,
+ 'is-transient': isTransient,
+ [ `filter__${ imageFilter }` ]: !! imageFilter,
+ } ) }
+ >
+ { isSelected && (
+ <div className="tiled-gallery__item__inline-menu">
+ <IconButton
+ icon="no-alt"
+ onClick={ onRemove }
+ className="tiled-gallery__item__remove"
+ label={ __( 'Remove Image', 'jetpack' ) }
+ />
+ </div>
+ ) }
+ { /* Keep the <a> HTML structure, but ensure there is no navigation from edit */
+ /* eslint-disable-next-line jsx-a11y/anchor-is-valid */ }
+ { href ? <a>{ img }</a> : img }
+ </figure>
+ );
+ }
+}
+
+export default withSelect( ( select, ownProps ) => {
+ const { getMedia } = select( 'core' );
+ const { id } = ownProps;
+
+ return {
+ image: id ? getMedia( id ) : null,
+ };
+} )( GalleryImageEdit );
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/gallery-image/save.js b/plugins/jetpack/extensions/blocks/tiled-gallery/gallery-image/save.js
new file mode 100644
index 00000000..347a6f85
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/gallery-image/save.js
@@ -0,0 +1,46 @@
+/**
+ * External Dependencies
+ */
+import classnames from 'classnames';
+import { isBlobURL } from '@wordpress/blob';
+
+export default function GalleryImageSave( props ) {
+ const { alt, imageFilter, height, id, link, linkTo, origUrl, url, width } = props;
+
+ if ( isBlobURL( origUrl ) ) {
+ return null;
+ }
+
+ let href;
+
+ switch ( linkTo ) {
+ case 'media':
+ href = url;
+ break;
+ case 'attachment':
+ href = link;
+ break;
+ }
+
+ const img = (
+ <img
+ alt={ alt }
+ data-height={ height }
+ data-id={ id }
+ data-link={ link }
+ data-url={ origUrl }
+ data-width={ width }
+ src={ url }
+ />
+ );
+
+ return (
+ <figure
+ className={ classnames( 'tiled-gallery__item', {
+ [ `filter__${ imageFilter }` ]: !! imageFilter,
+ } ) }
+ >
+ { href ? <a href={ href }>{ img }</a> : img }
+ </figure>
+ );
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/index.js b/plugins/jetpack/extensions/blocks/tiled-gallery/index.js
new file mode 100644
index 00000000..42079113
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/index.js
@@ -0,0 +1,212 @@
+/**
+ * External dependencies
+ */
+import { __, _x } from '@wordpress/i18n';
+import { createBlock } from '@wordpress/blocks';
+import { filter } from 'lodash';
+import { Path, SVG } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import edit from './edit';
+import save from './save';
+import {
+ LAYOUT_CIRCLE,
+ LAYOUT_COLUMN,
+ LAYOUT_DEFAULT,
+ LAYOUT_SQUARE,
+ LAYOUT_STYLES,
+} from './constants';
+
+/**
+ * Style dependencies
+ */
+import './editor.scss';
+
+import * as deprecatedV1 from './deprecated/v1';
+
+// Style names are translated. Avoid introducing an i18n dependency elsewhere (view)
+// by only including the labels here, the only place they're needed.
+//
+// Map style names to labels and merge them together.
+const styleNames = {
+ [ LAYOUT_DEFAULT ]: _x( 'Tiled mosaic', 'Tiled gallery layout', 'jetpack' ),
+ [ LAYOUT_CIRCLE ]: _x( 'Circles', 'Tiled gallery layout', 'jetpack' ),
+ [ LAYOUT_COLUMN ]: _x( 'Tiled columns', 'Tiled gallery layout', 'jetpack' ),
+ [ LAYOUT_SQUARE ]: _x( 'Square tiles', 'Tiled gallery layout', 'jetpack' ),
+};
+const layoutStylesWithLabels = LAYOUT_STYLES.map( style => ( {
+ ...style,
+ label: styleNames[ style.name ],
+} ) );
+
+/**
+ * Filter valid images
+ *
+ * @param {array} images Array of image objects
+ * @return {array} Array of image objects which have id and url
+ */
+function getValidImages( images ) {
+ return filter( images, ( { id, url } ) => id && url );
+}
+
+const blockAttributes = {
+ // Set default align
+ align: {
+ default: 'center',
+ type: 'string',
+ },
+ // Set default className (used with block styles)
+ className: {
+ default: `is-style-${ LAYOUT_DEFAULT }`,
+ type: 'string',
+ },
+ columns: {
+ type: 'number',
+ },
+ ids: {
+ default: [],
+ type: 'array',
+ },
+ imageFilter: {
+ type: 'string',
+ },
+ images: {
+ type: 'array',
+ default: [],
+ source: 'query',
+ selector: '.tiled-gallery__item',
+ query: {
+ alt: {
+ attribute: 'alt',
+ default: '',
+ selector: 'img',
+ source: 'attribute',
+ },
+ height: {
+ attribute: 'data-height',
+ selector: 'img',
+ source: 'attribute',
+ type: 'number',
+ },
+ id: {
+ attribute: 'data-id',
+ selector: 'img',
+ source: 'attribute',
+ },
+ link: {
+ attribute: 'data-link',
+ selector: 'img',
+ source: 'attribute',
+ },
+ url: {
+ attribute: 'data-url',
+ selector: 'img',
+ source: 'attribute',
+ },
+ width: {
+ attribute: 'data-width',
+ selector: 'img',
+ source: 'attribute',
+ type: 'number',
+ },
+ },
+ },
+ linkTo: {
+ default: 'none',
+ type: 'string',
+ },
+};
+
+export const name = 'tiled-gallery';
+
+export const icon = (
+ <SVG viewBox="0 0 24 24" width={ 24 } height={ 24 }>
+ <Path
+ fill="currentColor"
+ d="M19 5v2h-4V5h4M9 5v6H5V5h4m10 8v6h-4v-6h4M9 17v2H5v-2h4M21 3h-8v6h8V3zM11 3H3v10h8V3zm10 8h-8v10h8V11zm-10 4H3v6h8v-6z"
+ />
+ </SVG>
+);
+
+export const settings = {
+ attributes: blockAttributes,
+ category: 'jetpack',
+ description: __( 'Display multiple images in an elegantly organized tiled layout.', 'jetpack' ),
+ icon,
+ keywords: [
+ _x( 'images', 'block search term', 'jetpack' ),
+ _x( 'photos', 'block search term', 'jetpack' ),
+ _x( 'pictures', 'block search term', 'jetpack' ),
+ ],
+ styles: layoutStylesWithLabels,
+ supports: {
+ align: [ 'center', 'wide', 'full' ],
+ customClassName: false,
+ html: false,
+ },
+ title: __( 'Tiled Gallery', 'jetpack' ),
+ transforms: {
+ from: [
+ {
+ type: 'block',
+ isMultiBlock: true,
+ blocks: [ 'core/image' ],
+ isMatch: images => getValidImages( images ).length > 0,
+ transform: images => {
+ const validImages = getValidImages( images );
+ return createBlock( `jetpack/${ name }`, {
+ images: validImages.map( ( { id, url, alt } ) => ( {
+ id,
+ url,
+ alt,
+ } ) ),
+ ids: validImages.map( ( { id } ) => id ),
+ } );
+ },
+ },
+ {
+ type: 'block',
+ blocks: [ 'core/gallery', 'jetpack/slideshow' ],
+ transform: ( { images } ) => {
+ const validImages = getValidImages( images );
+ if ( validImages.length > 0 ) {
+ return createBlock( `jetpack/${ name }`, {
+ images: validImages.map( ( { id, url, alt } ) => ( {
+ id,
+ url,
+ alt,
+ } ) ),
+ ids: validImages.map( ( { id } ) => id ),
+ } );
+ }
+ return createBlock( `jetpack/${ name }` );
+ },
+ },
+ ],
+ to: [
+ {
+ type: 'block',
+ blocks: [ 'core/gallery' ],
+ transform: ( { images, ids, columns, linkTo } ) =>
+ createBlock( 'core/gallery', { images, ids, columns, imageCrop: true, linkTo } ),
+ },
+ {
+ type: 'block',
+ blocks: [ 'core/image' ],
+ transform: ( { align, images } ) => {
+ if ( images.length > 0 ) {
+ return images.map( ( { id, url, alt } ) =>
+ createBlock( 'core/image', { align, id, url, alt } )
+ );
+ }
+ return createBlock( 'core/image' );
+ },
+ },
+ ],
+ },
+ edit,
+ save,
+ deprecated: [ deprecatedV1 ],
+};
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/column.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/column.js
new file mode 100644
index 00000000..a3ed5cdf
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/column.js
@@ -0,0 +1,3 @@
+export default function Column( { children } ) {
+ return <div className="tiled-gallery__col">{ children }</div>;
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/gallery.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/gallery.js
new file mode 100644
index 00000000..94fc61e4
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/gallery.js
@@ -0,0 +1,7 @@
+export default function Gallery( { children, galleryRef } ) {
+ return (
+ <div className="tiled-gallery__gallery" ref={ galleryRef }>
+ { children }
+ </div>
+ );
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/index.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/index.js
new file mode 100644
index 00000000..abcb5641
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/index.js
@@ -0,0 +1,160 @@
+/**
+ * External dependencies
+ */
+import photon from 'photon';
+import { __, sprintf } from '@wordpress/i18n';
+import { Component } from '@wordpress/element';
+import { format as formatUrl, parse as parseUrl } from 'url';
+import { isBlobURL } from '@wordpress/blob';
+
+/**
+ * Internal dependencies
+ */
+import GalleryImageEdit from '../gallery-image/edit';
+import GalleryImageSave from '../gallery-image/save';
+import Mosaic from './mosaic';
+import Square from './square';
+import { PHOTON_MAX_RESIZE } from '../constants';
+
+export default class Layout extends Component {
+ photonize( { height, width, url } ) {
+ if ( ! url ) {
+ return;
+ }
+
+ // Do not Photonize images that are still uploading or from localhost
+ if ( isBlobURL( url ) || /^https?:\/\/localhost/.test( url ) ) {
+ return url;
+ }
+
+ // Drop query args, photon URLs can't handle them
+ // This should be the "raw" url, we'll add dimensions later
+ const cleanUrl = url.split( '?', 1 )[ 0 ];
+
+ const photonImplementation = isWpcomFilesUrl( url ) ? photonWpcomImage : photon;
+
+ const { layoutStyle } = this.props;
+
+ if ( isSquareishLayout( layoutStyle ) && width && height ) {
+ const size = Math.min( PHOTON_MAX_RESIZE, width, height );
+ return photonImplementation( cleanUrl, { resize: `${ size },${ size }` } );
+ }
+ return photonImplementation( cleanUrl );
+ }
+
+ // This is tricky:
+ // - We need to "photonize" to resize the images at appropriate dimensions
+ // - The resize will depend on the image size and the layout in some cases
+ // - Handlers need to be created by index so that the image changes can be applied correctly.
+ // This is because the images are stored in an array in the block attributes.
+ renderImage( img, i ) {
+ const {
+ imageFilter,
+ images,
+ isSave,
+ linkTo,
+ onRemoveImage,
+ onSelectImage,
+ selectedImage,
+ setImageAttributes,
+ } = this.props;
+
+ /* translators: %1$d is the order number of the image, %2$d is the total number of images. */
+ const ariaLabel = sprintf(
+ __( 'image %1$d of %2$d in gallery', 'jetpack' ),
+ i + 1,
+ images.length
+ );
+ const Image = isSave ? GalleryImageSave : GalleryImageEdit;
+
+ return (
+ <Image
+ alt={ img.alt }
+ aria-label={ ariaLabel }
+ height={ img.height }
+ id={ img.id }
+ imageFilter={ imageFilter }
+ isSelected={ selectedImage === i }
+ key={ i }
+ link={ img.link }
+ linkTo={ linkTo }
+ onRemove={ isSave ? undefined : onRemoveImage( i ) }
+ onSelect={ isSave ? undefined : onSelectImage( i ) }
+ origUrl={ img.url }
+ setAttributes={ isSave ? undefined : setImageAttributes( i ) }
+ url={ this.photonize( img ) }
+ width={ img.width }
+ />
+ );
+ }
+
+ render() {
+ const { align, children, className, columns, images, layoutStyle } = this.props;
+
+ const LayoutRenderer = isSquareishLayout( layoutStyle ) ? Square : Mosaic;
+
+ const renderedImages = this.props.images.map( this.renderImage, this );
+
+ return (
+ <div className={ className }>
+ <LayoutRenderer
+ align={ align }
+ columns={ columns }
+ images={ images }
+ layoutStyle={ layoutStyle }
+ renderedImages={ renderedImages }
+ />
+ { children }
+ </div>
+ );
+ }
+}
+
+function isSquareishLayout( layout ) {
+ return [ 'circle', 'square' ].includes( layout );
+}
+
+function isWpcomFilesUrl( url ) {
+ const { host } = parseUrl( url );
+ return /\.files\.wordpress\.com$/.test( host );
+}
+
+/**
+ * Apply photon arguments to *.files.wordpress.com images
+ *
+ * This function largely duplicates the functionlity of the photon.js lib.
+ * This is necessary because we want to serve images from *.files.wordpress.com so that private
+ * WordPress.com sites can use this block which depends on a Photon-like image service.
+ *
+ * If we pass all images through Photon servers, some images are unreachable. *.files.wordpress.com
+ * is already photon-like so we can pass it the same parameters for image resizing.
+ *
+ * @param {string} url Image url
+ * @param {Object} opts Options to pass to photon
+ *
+ * @return {string} Url string with options applied
+ */
+function photonWpcomImage( url, opts = {} ) {
+ // Adhere to the same options API as the photon.js lib
+ const photonLibMappings = {
+ width: 'w',
+ height: 'h',
+ letterboxing: 'lb',
+ removeLetterboxing: 'ulb',
+ };
+
+ // Discard some param parts
+ const { auth, hash, port, query, search, ...urlParts } = parseUrl( url );
+
+ // Build query
+ // This reduction intentionally mutates the query as it is built internally.
+ urlParts.query = Object.keys( opts ).reduce(
+ ( q, key ) =>
+ Object.assign( q, {
+ [ photonLibMappings.hasOwnProperty( key ) ? photonLibMappings[ key ] : key ]: opts[ key ],
+ } ),
+ {}
+ );
+
+ return formatUrl( urlParts );
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/index.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/index.js
new file mode 100644
index 00000000..8c56b164
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/index.js
@@ -0,0 +1,104 @@
+/**
+ * External dependencies
+ */
+import { Component, createRef } from '@wordpress/element';
+import ResizeObserver from 'resize-observer-polyfill';
+
+/**
+ * Internal dependencies
+ */
+import Column from '../column';
+import Gallery from '../gallery';
+import Row from '../row';
+import { getGalleryRows, handleRowResize } from './resize';
+import { imagesToRatios, ratiosToColumns, ratiosToMosaicRows } from './ratios';
+
+export default class Mosaic extends Component {
+ gallery = createRef();
+ pendingRaf = null;
+ ro = null; // resizeObserver instance
+
+ componentDidMount() {
+ this.observeResize();
+ }
+
+ componentWillUnmount() {
+ this.unobserveResize();
+ }
+
+ componentDidUpdate( prevProps ) {
+ if ( prevProps.images !== this.props.images || prevProps.align !== this.props.align ) {
+ this.triggerResize();
+ } else if ( 'columns' === this.props.layoutStyle && prevProps.columns !== this.props.columns ) {
+ this.triggerResize();
+ }
+ }
+
+ handleGalleryResize = entries => {
+ if ( this.pendingRaf ) {
+ cancelAnimationFrame( this.pendingRaf );
+ this.pendingRaf = null;
+ }
+ this.pendingRaf = requestAnimationFrame( () => {
+ for ( const { contentRect, target } of entries ) {
+ const { width } = contentRect;
+ getGalleryRows( target ).forEach( row => handleRowResize( row, width ) );
+ }
+ } );
+ };
+
+ triggerResize() {
+ if ( this.gallery.current ) {
+ this.handleGalleryResize( [
+ {
+ target: this.gallery.current,
+ contentRect: { width: this.gallery.current.clientWidth },
+ },
+ ] );
+ }
+ }
+
+ observeResize() {
+ this.triggerResize();
+ this.ro = new ResizeObserver( this.handleGalleryResize );
+ if ( this.gallery.current ) {
+ this.ro.observe( this.gallery.current );
+ }
+ }
+
+ unobserveResize() {
+ if ( this.ro ) {
+ this.ro.disconnect();
+ this.ro = null;
+ }
+ if ( this.pendingRaf ) {
+ cancelAnimationFrame( this.pendingRaf );
+ this.pendingRaf = null;
+ }
+ }
+
+ render() {
+ const { align, columns, images, layoutStyle, renderedImages } = this.props;
+
+ const ratios = imagesToRatios( images );
+ const rows =
+ 'columns' === layoutStyle
+ ? ratiosToColumns( ratios, columns )
+ : ratiosToMosaicRows( ratios, { isWide: [ 'full', 'wide' ].includes( align ) } );
+
+ let cursor = 0;
+ return (
+ <Gallery galleryRef={ this.gallery }>
+ { rows.map( ( row, rowIndex ) => (
+ <Row key={ rowIndex }>
+ { row.map( ( colSize, colIndex ) => {
+ const columnImages = renderedImages.slice( cursor, cursor + colSize );
+ cursor += colSize;
+ return <Column key={ colIndex }>{ columnImages }</Column>;
+ } ) }
+ </Row>
+ ) ) }
+ </Gallery>
+ );
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/ratios.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/ratios.js
new file mode 100644
index 00000000..8accd552
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/ratios.js
@@ -0,0 +1,280 @@
+/**
+ * External dependencies
+ */
+import {
+ drop,
+ every,
+ isEqual,
+ map,
+ overEvery,
+ some,
+ sum,
+ take,
+ takeRight,
+ takeWhile,
+ zipWith,
+} from 'lodash';
+
+export function imagesToRatios( images ) {
+ return map( images, ratioFromImage );
+}
+
+export function ratioFromImage( { height, width } ) {
+ return height && width ? width / height : 1;
+}
+
+/**
+ * Build three columns, each of which should contain approximately 1/3 of the total ratio
+ *
+ * @param {Array.<number>} ratios Ratios of images put into shape
+ * @param {number} columnCount Number of columns
+ *
+ * @return {Array.<Array.<number>>} Shape of rows and columns
+ */
+export function ratiosToColumns( ratios, columnCount ) {
+ // If we don't have more than 1 per column, just return a simple 1 ratio per column shape
+ if ( ratios.length <= columnCount ) {
+ return [ Array( ratios.length ).fill( 1 ) ];
+ }
+
+ const total = sum( ratios );
+ const targetColRatio = total / columnCount;
+
+ const row = [];
+ let toProcess = ratios;
+ let accumulatedRatio = 0;
+
+ // We skip the last column in the loop and add rest later
+ for ( let i = 0; i < columnCount - 1; i++ ) {
+ const colSize = takeWhile( toProcess, ratio => {
+ const shouldTake = accumulatedRatio <= ( i + 1 ) * targetColRatio;
+ if ( shouldTake ) {
+ accumulatedRatio += ratio;
+ }
+ return shouldTake;
+ } ).length;
+ row.push( colSize );
+ toProcess = drop( toProcess, colSize );
+ }
+
+ // Don't calculate last column, just add what's left
+ row.push( toProcess.length );
+
+ // A shape is an array of rows. Wrap our row in an array.
+ return [ row ];
+}
+
+/**
+ * These are partially applied functions.
+ * They rely on helper function (defined below) to create a function that expects to be passed ratios
+ * during processing.
+ *
+ * …FitsNextImages() functions should be passed ratios to be processed
+ * …IsNotRecent() functions should be passed the processed shapes
+ */
+
+const reverseSymmetricRowIsNotRecent = isNotRecentShape( [ 2, 1, 2 ], 5 );
+const reverseSymmetricFitsNextImages = checkNextRatios( [
+ isLandscape,
+ isLandscape,
+ isPortrait,
+ isLandscape,
+ isLandscape,
+] );
+const longSymmetricRowFitsNextImages = checkNextRatios( [
+ isLandscape,
+ isLandscape,
+ isLandscape,
+ isPortrait,
+ isLandscape,
+ isLandscape,
+ isLandscape,
+] );
+const longSymmetricRowIsNotRecent = isNotRecentShape( [ 3, 1, 3 ], 5 );
+const symmetricRowFitsNextImages = checkNextRatios( [
+ isPortrait,
+ isLandscape,
+ isLandscape,
+ isPortrait,
+] );
+const symmetricRowIsNotRecent = isNotRecentShape( [ 1, 2, 1 ], 5 );
+const oneThreeFitsNextImages = checkNextRatios( [
+ isPortrait,
+ isLandscape,
+ isLandscape,
+ isLandscape,
+] );
+const oneThreeIsNotRecent = isNotRecentShape( [ 1, 3 ], 3 );
+const threeOneIsFitsNextImages = checkNextRatios( [
+ isLandscape,
+ isLandscape,
+ isLandscape,
+ isPortrait,
+] );
+const threeOneIsNotRecent = isNotRecentShape( [ 3, 1 ], 3 );
+const oneTwoFitsNextImages = checkNextRatios( [
+ lt( 1.6 ),
+ overEvery( gte( 0.9 ), lt( 2 ) ),
+ overEvery( gte( 0.9 ), lt( 2 ) ),
+] );
+const oneTwoIsNotRecent = isNotRecentShape( [ 1, 2 ], 3 );
+const fiveIsNotRecent = isNotRecentShape( [ 1, 1, 1, 1, 1 ], 1 );
+const fourIsNotRecent = isNotRecentShape( [ 1, 1, 1, 1 ], 1 );
+const threeIsNotRecent = isNotRecentShape( [ 1, 1, 1 ], 3 );
+const twoOneFitsNextImages = checkNextRatios( [
+ overEvery( gte( 0.9 ), lt( 2 ) ),
+ overEvery( gte( 0.9 ), lt( 2 ) ),
+ lt( 1.6 ),
+] );
+const twoOneIsNotRecent = isNotRecentShape( [ 2, 1 ], 3 );
+const panoramicFitsNextImages = checkNextRatios( [ isPanoramic ] );
+
+export function ratiosToMosaicRows( ratios, { isWide } = {} ) {
+ // This function will recursively process the input until it is consumed
+ const go = ( processed, toProcess ) => {
+ if ( ! toProcess.length ) {
+ return processed;
+ }
+
+ let next;
+
+ if (
+ /* Reverse_Symmetric_Row */
+ toProcess.length > 15 &&
+ reverseSymmetricFitsNextImages( toProcess ) &&
+ reverseSymmetricRowIsNotRecent( processed )
+ ) {
+ next = [ 2, 1, 2 ];
+ } else if (
+ /* Long_Symmetric_Row */
+ toProcess.length > 15 &&
+ longSymmetricRowFitsNextImages( toProcess ) &&
+ longSymmetricRowIsNotRecent( processed )
+ ) {
+ next = [ 3, 1, 3 ];
+ } else if (
+ /* Symmetric_Row */
+ toProcess.length !== 5 &&
+ symmetricRowFitsNextImages( toProcess ) &&
+ symmetricRowIsNotRecent( processed )
+ ) {
+ next = [ 1, 2, 1 ];
+ } else if (
+ /* One_Three */
+ oneThreeFitsNextImages( toProcess ) &&
+ oneThreeIsNotRecent( processed )
+ ) {
+ next = [ 1, 3 ];
+ } else if (
+ /* Three_One */
+ threeOneIsFitsNextImages( toProcess ) &&
+ threeOneIsNotRecent( processed )
+ ) {
+ next = [ 3, 1 ];
+ } else if (
+ /* One_Two */
+ oneTwoFitsNextImages( toProcess ) &&
+ oneTwoIsNotRecent( processed )
+ ) {
+ next = [ 1, 2 ];
+ } else if (
+ /* Five */
+ isWide &&
+ ( toProcess.length === 5 || ( toProcess.length !== 10 && toProcess.length > 6 ) ) &&
+ fiveIsNotRecent( processed ) &&
+ sum( take( toProcess, 5 ) ) < 5
+ ) {
+ next = [ 1, 1, 1, 1, 1 ];
+ } else if (
+ /* Four */
+ isFourValidCandidate( processed, toProcess )
+ ) {
+ next = [ 1, 1, 1, 1 ];
+ } else if (
+ /* Three */
+ isThreeValidCandidate( processed, toProcess, isWide )
+ ) {
+ next = [ 1, 1, 1 ];
+ } else if (
+ /* Two_One */
+ twoOneFitsNextImages( toProcess ) &&
+ twoOneIsNotRecent( processed )
+ ) {
+ next = [ 2, 1 ];
+ } else if ( /* Panoramic */ panoramicFitsNextImages( toProcess ) ) {
+ next = [ 1 ];
+ } else if ( /* One_One */ toProcess.length > 3 ) {
+ next = [ 1, 1 ];
+ } else {
+ // Everything left
+ next = Array( toProcess.length ).fill( 1 );
+ }
+
+ // Add row
+ const nextProcessed = processed.concat( [ next ] );
+
+ // Trim consumed images from next processing step
+ const consumedImages = sum( next );
+ const nextToProcess = toProcess.slice( consumedImages );
+
+ return go( nextProcessed, nextToProcess );
+ };
+ return go( [], ratios );
+}
+
+function isThreeValidCandidate( processed, toProcess, isWide ) {
+ const ratio = sum( take( toProcess, 3 ) );
+ return (
+ toProcess.length >= 3 &&
+ toProcess.length !== 4 &&
+ toProcess.length !== 6 &&
+ threeIsNotRecent( processed ) &&
+ ( ratio < 2.5 ||
+ ( ratio < 5 &&
+ /* nextAreSymettric */
+ ( toProcess.length >= 3 &&
+ /* @FIXME floating point equality?? */ toProcess[ 0 ] === toProcess[ 2 ] ) ) ||
+ isWide )
+ );
+}
+
+function isFourValidCandidate( processed, toProcess ) {
+ const ratio = sum( take( toProcess, 4 ) );
+ return (
+ ( fourIsNotRecent( processed ) && ( ratio < 3.5 && toProcess.length > 5 ) ) ||
+ ( ratio < 7 && toProcess.length === 4 )
+ );
+}
+
+function isNotRecentShape( shape, numRecents ) {
+ return recents =>
+ ! some( takeRight( recents, numRecents ), recentShape => isEqual( recentShape, shape ) );
+}
+
+function checkNextRatios( shape ) {
+ return ratios =>
+ ratios.length >= shape.length &&
+ every( zipWith( shape, ratios.slice( 0, shape.length ), ( f, r ) => f( r ) ) );
+}
+
+function isLandscape( ratio ) {
+ return ratio >= 1 && ratio < 2;
+}
+
+function isPortrait( ratio ) {
+ return ratio < 1;
+}
+
+function isPanoramic( ratio ) {
+ return ratio >= 2;
+}
+
+// >=
+function gte( n ) {
+ return m => m >= n;
+}
+
+// <
+function lt( n ) {
+ return m => m < n;
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/resize.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/resize.js
new file mode 100644
index 00000000..022729c8
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/resize.js
@@ -0,0 +1,107 @@
+/**
+ * Internal dependencies
+ */
+import { GUTTER_WIDTH } from '../../constants';
+
+/**
+ * Distribute a difference across ns so that their sum matches the target
+ *
+ * @param {Array<number>} parts Array of numbers to fit
+ * @param {number} target Number that sum should match
+ * @return {Array<number>} Adjusted parts
+ */
+function adjustFit( parts, target ) {
+ const diff = target - parts.reduce( ( sum, n ) => sum + n, 0 );
+ const partialDiff = diff / parts.length;
+ return parts.map( p => p + partialDiff );
+}
+
+export function handleRowResize( row, width ) {
+ applyRowRatio( row, getRowRatio( row ), width );
+}
+
+function getRowRatio( row ) {
+ const result = getRowCols( row )
+ .map( getColumnRatio )
+ .reduce(
+ ( [ ratioA, weightedRatioA ], [ ratioB, weightedRatioB ] ) => {
+ return [ ratioA + ratioB, weightedRatioA + weightedRatioB ];
+ },
+ [ 0, 0 ]
+ );
+ return result;
+}
+
+export function getGalleryRows( gallery ) {
+ return Array.from( gallery.querySelectorAll( '.tiled-gallery__row' ) );
+}
+
+function getRowCols( row ) {
+ return Array.from( row.querySelectorAll( '.tiled-gallery__col' ) );
+}
+
+function getColImgs( col ) {
+ return Array.from(
+ col.querySelectorAll( '.tiled-gallery__item > img, .tiled-gallery__item > a > img' )
+ );
+}
+
+function getColumnRatio( col ) {
+ const imgs = getColImgs( col );
+ const imgCount = imgs.length;
+ const ratio =
+ 1 /
+ imgs.map( getImageRatio ).reduce( ( partialColRatio, imgRatio ) => {
+ return partialColRatio + 1 / imgRatio;
+ }, 0 );
+ const result = [ ratio, ratio * imgCount || 1 ];
+ return result;
+}
+
+function getImageRatio( img ) {
+ const w = parseInt( img.dataset.width, 10 );
+ const h = parseInt( img.dataset.height, 10 );
+ const result = w && ! Number.isNaN( w ) && h && ! Number.isNaN( h ) ? w / h : 1;
+ return result;
+}
+
+function applyRowRatio( row, [ ratio, weightedRatio ], width ) {
+ const rawHeight =
+ ( 1 / ratio ) * ( width - GUTTER_WIDTH * ( row.childElementCount - 1 ) - weightedRatio );
+
+ applyColRatio( row, {
+ rawHeight,
+ rowWidth: width - GUTTER_WIDTH * ( row.childElementCount - 1 ),
+ } );
+}
+
+function applyColRatio( row, { rawHeight, rowWidth } ) {
+ const cols = getRowCols( row );
+
+ const colWidths = cols.map(
+ col => ( rawHeight - GUTTER_WIDTH * ( col.childElementCount - 1 ) ) * getColumnRatio( col )[ 0 ]
+ );
+
+ const adjustedWidths = adjustFit( colWidths, rowWidth );
+
+ cols.forEach( ( col, i ) => {
+ const rawWidth = colWidths[ i ];
+ const width = adjustedWidths[ i ];
+ applyImgRatio( col, {
+ colHeight: rawHeight - GUTTER_WIDTH * ( col.childElementCount - 1 ),
+ width,
+ rawWidth,
+ } );
+ } );
+}
+
+function applyImgRatio( col, { colHeight, width, rawWidth } ) {
+ const imgHeights = getColImgs( col ).map( img => rawWidth / getImageRatio( img ) );
+ const adjustedHeights = adjustFit( imgHeights, colHeight );
+
+ // Set size of col children, not the <img /> element
+ Array.from( col.children ).forEach( ( item, i ) => {
+ const height = adjustedHeights[ i ];
+ item.setAttribute( 'style', `height:${ height }px;width:${ width }px;` );
+ } );
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/index.js.snap b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/index.js.snap
new file mode 100644
index 00000000..e726fa52
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/index.js.snap
@@ -0,0 +1,98 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders as expected 1`] = `
+<Gallery
+ galleryRef={
+ Object {
+ "current": null,
+ }
+ }
+>
+ <Row
+ key="0"
+ >
+ <Column
+ key="0"
+ >
+ 0
+ </Column>
+ </Row>
+ <Row
+ key="1"
+ >
+ <Column
+ key="0"
+ >
+ 1
+ </Column>
+ </Row>
+ <Row
+ key="2"
+ >
+ <Column
+ key="0"
+ >
+ 2
+ </Column>
+ <Column
+ key="1"
+ >
+ 3
+ </Column>
+ <Column
+ key="2"
+ >
+ 4
+ </Column>
+ <Column
+ key="3"
+ >
+ 5
+ </Column>
+ </Row>
+ <Row
+ key="3"
+ >
+ <Column
+ key="0"
+ >
+ 6
+ </Column>
+ <Column
+ key="1"
+ >
+ 7
+ </Column>
+ </Row>
+ <Row
+ key="4"
+ >
+ <Column
+ key="0"
+ >
+ 8
+ </Column>
+ <Column
+ key="1"
+ >
+ 9
+ 10
+ </Column>
+ </Row>
+ <Row
+ key="5"
+ >
+ <Column
+ key="0"
+ >
+ 11
+ 12
+ </Column>
+ <Column
+ key="1"
+ >
+ 13
+ </Column>
+ </Row>
+</Gallery>
+`;
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/ratios.js.snap b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/ratios.js.snap
new file mode 100644
index 00000000..df02118c
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/ratios.js.snap
@@ -0,0 +1,30 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ratiosToMosaicRows transforms as expected 1`] = `
+Array [
+ Array [
+ 1,
+ ],
+ Array [
+ 1,
+ ],
+ Array [
+ 1,
+ 1,
+ 1,
+ 1,
+ ],
+ Array [
+ 1,
+ 1,
+ ],
+ Array [
+ 1,
+ 2,
+ ],
+ Array [
+ 2,
+ 1,
+ ],
+]
+`;
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/fixtures/ratios.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/fixtures/ratios.js
new file mode 100644
index 00000000..77db288c
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/fixtures/ratios.js
@@ -0,0 +1,16 @@
+export const ratios = [
+ 4,
+ 2.26056338028169,
+ 0.6676143094053542,
+ 0.75,
+ 0.7444409646100846,
+ 0.6666666666666666,
+ 0.8000588062334607,
+ 3.6392174704276616,
+ 1.335559265442404,
+ 1.509433962264151,
+ 1.6,
+ 1.3208430913348945,
+ 1.3553937789543349,
+ 1.499531396438613,
+];
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/index.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/index.js
new file mode 100644
index 00000000..72e49ba6
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/index.js
@@ -0,0 +1,21 @@
+/**
+ * External dependencies
+ */
+import React from 'react';
+import { range } from 'lodash';
+import { shallow } from 'enzyme';
+
+/**
+ * Internal dependencies
+ */
+import Mosaic from '..';
+import * as imageSets from '../../test/fixtures/image-sets';
+
+test( 'renders as expected', () => {
+ Object.keys( imageSets ).forEach( k => {
+ const images = imageSets[ k ];
+ expect(
+ shallow( <Mosaic images={ images } renderedImages={ range( images.length ) } /> )
+ ).toMatchSnapshot();
+ } );
+} );
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/ratios.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/ratios.js
new file mode 100644
index 00000000..3756b971
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/mosaic/test/ratios.js
@@ -0,0 +1,11 @@
+/**
+ * Internal dependencies
+ */
+import { ratiosToMosaicRows } from '../ratios';
+import { ratios } from './fixtures/ratios';
+
+describe( 'ratiosToMosaicRows', () => {
+ test( 'transforms as expected', () => {
+ expect( ratiosToMosaicRows( ratios ) ).toMatchSnapshot();
+ } );
+} );
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/row.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/row.js
new file mode 100644
index 00000000..200a58c2
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/row.js
@@ -0,0 +1,8 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+
+export default function Row( { children, className } ) {
+ return <div className={ classnames( 'tiled-gallery__row', className ) }>{ children }</div>;
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/square.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/square.js
new file mode 100644
index 00000000..2a1ab888
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/square.js
@@ -0,0 +1,33 @@
+/**
+ * External dependencies
+ */
+import { chunk, drop, take } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+import Row from './row';
+import Column from './column';
+import Gallery from './gallery';
+import { MAX_COLUMNS } from '../constants';
+
+export default function Square( { columns, renderedImages } ) {
+ const columnCount = Math.min( MAX_COLUMNS, columns );
+
+ const remainder = renderedImages.length % columnCount;
+
+ return (
+ <Gallery>
+ { [
+ ...( remainder ? [ take( renderedImages, remainder ) ] : [] ),
+ ...chunk( drop( renderedImages, remainder ), columnCount ),
+ ].map( ( imagesInRow, rowIndex ) => (
+ <Row key={ rowIndex } className={ `columns-${ imagesInRow.length }` }>
+ { imagesInRow.map( ( image, colIndex ) => (
+ <Column key={ colIndex }>{ image }</Column>
+ ) ) }
+ </Row>
+ ) ) }
+ </Gallery>
+ );
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/layout/test/fixtures/image-sets.js b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/test/fixtures/image-sets.js
new file mode 100644
index 00000000..fd477f5a
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/layout/test/fixtures/image-sets.js
@@ -0,0 +1,103 @@
+export const imageSet1 = [
+ {
+ alt: '',
+ id: 163,
+ url: 'https://example.files.wordpress.com/2018/12/architecture-bay-bridge-356830.jpg',
+ height: 2048,
+ width: 8192,
+ },
+ {
+ alt: '',
+ id: 162,
+ url: 'https://example.files.wordpress.com/2018/12/bloom-blossom-flora-40797-1.jpg',
+ height: 1562,
+ width: 3531,
+ },
+ {
+ alt: '',
+ id: 161,
+ url: 'https://example.files.wordpress.com/2018/12/architecture-building-city-597049.jpg',
+ height: 4221,
+ width: 2818,
+ },
+ {
+ alt: '',
+ id: 160,
+ url: 'https://example.files.wordpress.com/2018/12/architecture-art-blue-699466.jpg',
+ height: 4032,
+ width: 3024,
+ },
+ {
+ alt: '',
+ id: 159,
+ url:
+ 'https://example.files.wordpress.com/2018/12/black-and-white-construction-ladder-54335.jpg',
+ height: 3193,
+ width: 2377,
+ },
+ {
+ alt: '',
+ id: 158,
+ url: 'https://example.files.wordpress.com/2018/12/architecture-buildings-city-1672110.jpg',
+ height: 6000,
+ width: 4000,
+ },
+ {
+ alt: '',
+ id: 157,
+ url:
+ 'https://example.files.wordpress.com/2018/12/architectural-design-architecture-black-and-white-1672122-1.jpg',
+ height: 3401,
+ width: 2721,
+ },
+ {
+ alt: '',
+ id: 156,
+ url: 'https://example.files.wordpress.com/2018/12/grass-hd-wallpaper-lake-127753.jpg',
+ height: 2198,
+ width: 7999,
+ },
+ {
+ alt: '',
+ id: 122,
+ url: 'https://example.files.wordpress.com/2018/12/texaco-car-1.jpg',
+ height: 599,
+ width: 800,
+ },
+ {
+ alt: '',
+ id: 92,
+ url: 'https://example.files.wordpress.com/2018/12/43824553435_ea38cbc92a_m.jpg',
+ height: 159,
+ width: 240,
+ },
+ {
+ alt: '',
+ id: 90,
+ url: 'https://example.files.wordpress.com/2018/12/42924685680_7b5632e58e_m.jpg',
+ height: 150,
+ width: 240,
+ },
+ {
+ alt: '',
+ id: 89,
+ url:
+ 'https://example.files.wordpress.com/2018/12/31962299833_1e106f7f7a_z-1-e1545262352979.jpg',
+ height: 427,
+ width: 564,
+ },
+ {
+ alt: '',
+ id: 88,
+ url: 'https://example.files.wordpress.com/2018/12/29797558147_3c72afa8f4_k.jpg',
+ height: 1511,
+ width: 2048,
+ },
+ {
+ alt: '',
+ id: 8,
+ url: 'https://example.files.wordpress.com/2018/11/person-smartphone-office-table.jpeg',
+ height: 1067,
+ width: 1600,
+ },
+];
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/save.js b/plugins/jetpack/extensions/blocks/tiled-gallery/save.js
new file mode 100644
index 00000000..e21e56e1
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/save.js
@@ -0,0 +1,30 @@
+/**
+ * Internal dependencies
+ */
+import Layout from './layout';
+import { defaultColumnsNumber } from './edit';
+import { getActiveStyleName } from '../../shared/block-styles';
+import { LAYOUT_STYLES } from './constants';
+
+export default function TiledGallerySave( { attributes } ) {
+ const { imageFilter, images } = attributes;
+
+ if ( ! images.length ) {
+ return null;
+ }
+
+ const { align, className, columns = defaultColumnsNumber( attributes ), linkTo } = attributes;
+
+ return (
+ <Layout
+ align={ align }
+ className={ className }
+ columns={ columns }
+ imageFilter={ imageFilter }
+ images={ images }
+ isSave
+ layoutStyle={ getActiveStyleName( LAYOUT_STYLES, className ) }
+ linkTo={ linkTo }
+ />
+ );
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/tiled-gallery.php b/plugins/jetpack/extensions/blocks/tiled-gallery/tiled-gallery.php
new file mode 100644
index 00000000..bcfdaf1f
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/tiled-gallery.php
@@ -0,0 +1,171 @@
+<?php //phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
+
+/**
+ * Tiled Gallery block. Depends on the Photon module.
+ *
+ * @since 6.9.0
+ *
+ * @package Jetpack
+ */
+
+/**
+ * Jetpack Tiled Gallery Block class
+ *
+ * @since 7.3
+ */
+class Jetpack_Tiled_Gallery_Block {
+ /* Values for building srcsets */
+ const IMG_SRCSET_WIDTH_MAX = 2000;
+ const IMG_SRCSET_WIDTH_MIN = 600;
+ const IMG_SRCSET_WIDTH_STEP = 300;
+
+ /**
+ * Register the block
+ */
+ public static function register() {
+ jetpack_register_block(
+ 'jetpack/tiled-gallery',
+ array(
+ 'render_callback' => array( __CLASS__, 'render' ),
+ )
+ );
+ }
+
+ /**
+ * Tiled gallery block registration
+ *
+ * @param array $attr Array containing the block attributes.
+ * @param string $content String containing the block content.
+ *
+ * @return string
+ */
+ public static function render( $attr, $content ) {
+ Jetpack_Gutenberg::load_assets_as_required( 'tiled-gallery' );
+
+ $is_squareish_layout = self::is_squareish_layout( $attr );
+
+ if ( preg_match_all( '/<img [^>]+>/', $content, $images ) ) {
+ /**
+ * This block processes all of the images that are found and builds $find and $replace.
+ *
+ * The original img is added to the $find array and the replacement is made and added
+ * to the $replace array. This is so that the same find and replace operations can be
+ * made on the entire $content.
+ */
+ $find = array();
+ $replace = array();
+
+ foreach ( $images[0] as $image_html ) {
+ if (
+ preg_match( '/data-width="([0-9]+)"/', $image_html, $img_height )
+ && preg_match( '/data-height="([0-9]+)"/', $image_html, $img_width )
+ && preg_match( '/src="([^"]+)"/', $image_html, $img_src )
+ ) {
+ // Drop img src query string so it can be used as a base to add photon params
+ // for the srcset.
+ $src_parts = explode( '?', $img_src[1], 2 );
+ $orig_src = $src_parts[0];
+ $orig_height = absint( $img_height[1] );
+ $orig_width = absint( $img_width[1] );
+
+ // Because URLs are already "photon", the photon function used short-circuits
+ // before ssl is added. Detect ssl and add is if necessary.
+ $is_ssl = ! empty( $src_parts[1] ) && false !== strpos( $src_parts[1], 'ssl=1' );
+
+ if ( ! $orig_width || ! $orig_height || ! $orig_src ) {
+ continue;
+ }
+
+ $srcset_parts = array();
+ if ( $is_squareish_layout ) {
+ $min_width = min( self::IMG_SRCSET_WIDTH_MIN, $orig_width, $orig_height );
+ $max_width = min( self::IMG_SRCSET_WIDTH_MAX, $orig_width, $orig_height );
+
+ for ( $w = $min_width; $w <= $max_width; $w = min( $max_width, $w + self::IMG_SRCSET_WIDTH_STEP ) ) {
+ $srcset_src = add_query_arg(
+ array(
+ 'resize' => $w . ',' . $w,
+ 'strip' => 'all',
+ ),
+ $orig_src
+ );
+ if ( $is_ssl ) {
+ $srcset_src = add_query_arg( 'ssl', '1', $srcset_src );
+ }
+ $srcset_parts[] = esc_url( $srcset_src ) . ' ' . $w . 'w';
+ if ( $w >= $max_width ) {
+ break;
+ }
+ }
+ } else {
+ $min_width = min( self::IMG_SRCSET_WIDTH_MIN, $orig_width );
+ $max_width = min( self::IMG_SRCSET_WIDTH_MAX, $orig_width );
+
+ for ( $w = $min_width; $w <= $max_width; $w = min( $max_width, $w + self::IMG_SRCSET_WIDTH_STEP ) ) {
+ $srcset_src = add_query_arg(
+ array(
+ 'strip' => 'all',
+ 'w' => $w,
+ ),
+ $orig_src
+ );
+ if ( $is_ssl ) {
+ $srcset_src = add_query_arg( 'ssl', '1', $srcset_src );
+ }
+ $srcset_parts[] = esc_url( $srcset_src ) . ' ' . $w . 'w';
+ if ( $w >= $max_width ) {
+ break;
+ }
+ }
+ }
+
+ if ( ! empty( $srcset_parts ) ) {
+ $srcset = 'srcset="' . esc_attr( implode( ',', $srcset_parts ) ) . '"';
+
+ $find[] = $image_html;
+ $replace[] = str_replace( '<img', '<img ' . $srcset, $image_html );
+ }
+ }
+ }
+
+ if ( ! empty( $find ) ) {
+ $content = str_replace( $find, $replace, $content );
+ }
+ }
+
+ /**
+ * Filter the output of the Tiled Galleries content.
+ *
+ * @module tiled-gallery
+ *
+ * @since 6.9.0
+ *
+ * @param string $content Tiled Gallery block content.
+ */
+ return apply_filters( 'jetpack_tiled_galleries_block_content', $content );
+ }
+
+ /**
+ * Determines whether a Tiled Gallery block uses square or circle images (1:1 ratio)
+ *
+ * Layouts are block styles and will be available as `is-style-[LAYOUT]` in the className
+ * attribute. The default (rectangular) will be omitted.
+ *
+ * @param {Array} $attr Attributes key/value array.
+ * @return {boolean} True if layout is squareish, otherwise false.
+ */
+ private static function is_squareish_layout( $attr ) {
+ return isset( $attr['className'] )
+ && (
+ 'is-style-square' === $attr['className']
+ || 'is-style-circle' === $attr['className']
+ );
+ }
+}
+
+if (
+ ( defined( 'IS_WPCOM' ) && IS_WPCOM )
+ || class_exists( 'Jetpack_Photon' ) && Jetpack::is_module_active( 'photon' )
+) {
+ Jetpack_Tiled_Gallery_Block::register();
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/variables.scss b/plugins/jetpack/extensions/blocks/tiled-gallery/variables.scss
new file mode 100644
index 00000000..f0054531
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/variables.scss
@@ -0,0 +1,2 @@
+$tiled-gallery-gutter: 4px; // Fixed in JS, see `LayoutStyles` from `edit.jsx`
+$tiled-gallery-selection: #0085ba; // Gutenberg primary theme color (https://github.com/WordPress/gutenberg/blob/6928e41c8afd7daa3a709afdda7eee48218473b7/bin/packages/post-css-config.js#L4)
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/view.js b/plugins/jetpack/extensions/blocks/tiled-gallery/view.js
new file mode 100644
index 00000000..1f45b13d
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/view.js
@@ -0,0 +1,64 @@
+/**
+ * Internal dependencies
+ */
+import './view.scss';
+import ResizeObserver from 'resize-observer-polyfill';
+import { handleRowResize } from './layout/mosaic/resize';
+
+/**
+ * Handler for Gallery ResizeObserver
+ *
+ * @param {Array<ResizeObserverEntry>} galleries Resized galleries
+ */
+function handleObservedResize( galleries ) {
+ if ( handleObservedResize.pendingRaf ) {
+ cancelAnimationFrame( handleObservedResize.pendingRaf );
+ }
+ handleObservedResize.pendingRaf = requestAnimationFrame( () => {
+ handleObservedResize.pendingRaf = null;
+ for ( const gallery of galleries ) {
+ const { width: galleryWidth } = gallery.contentRect;
+ // We can't use childNodes becuase post content may contain unexpected text nodes
+ const rows = Array.from( gallery.target.querySelectorAll( '.tiled-gallery__row' ) );
+ rows.forEach( row => handleRowResize( row, galleryWidth ) );
+ }
+ } );
+}
+
+/**
+ * Get all the galleries on the document
+ *
+ * @return {Array} List of gallery nodes
+ */
+function getGalleries() {
+ return Array.from(
+ document.querySelectorAll(
+ '.wp-block-jetpack-tiled-gallery.is-style-rectangular > .tiled-gallery__gallery,' +
+ '.wp-block-jetpack-tiled-gallery.is-style-columns > .tiled-gallery__gallery'
+ )
+ );
+}
+
+/**
+ * Setup ResizeObserver to follow each gallery on the page
+ */
+const observeGalleries = () => {
+ const galleries = getGalleries();
+
+ if ( galleries.length === 0 ) {
+ return;
+ }
+
+ const observer = new ResizeObserver( handleObservedResize );
+
+ galleries.forEach( gallery => observer.observe( gallery ) );
+};
+
+if ( typeof window !== 'undefined' && typeof document !== 'undefined' ) {
+ // `DOMContentLoaded` may fire before the script has a chance to run
+ if ( document.readyState === 'loading' ) {
+ document.addEventListener( 'DOMContentLoaded', observeGalleries );
+ } else {
+ observeGalleries();
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/tiled-gallery/view.scss b/plugins/jetpack/extensions/blocks/tiled-gallery/view.scss
new file mode 100644
index 00000000..456f8acb
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/tiled-gallery/view.scss
@@ -0,0 +1,115 @@
+@import '../../shared/styles/jetpack-variables.scss';
+@import './variables.scss';
+@import './css-gram.scss';
+
+$tiled-gallery-max-column-count: 20;
+
+.wp-block-jetpack-tiled-gallery {
+ margin: 0 auto $jetpack-block-margin-bottom;
+
+ &.is-style-circle .tiled-gallery__item img {
+ border-radius: 50%;
+ }
+
+ &.is-style-square,
+ &.is-style-circle {
+ .tiled-gallery__row {
+ flex-grow: 1;
+ width: 100%;
+
+ @for $cols from 1 through $tiled-gallery-max-column-count {
+ &.columns-#{$cols} {
+ .tiled-gallery__col {
+ width: calc( ( 100% - #{ $tiled-gallery-gutter * ( $cols - 1 ) } ) / #{$cols} );
+ }
+ }
+ }
+ }
+ }
+
+ &.is-style-columns,
+ &.is-style-rectangular {
+ .tiled-gallery__item {
+ display: flex;
+ }
+ }
+}
+
+.tiled-gallery__gallery {
+ width: 100%;
+ display: flex;
+ padding: 0;
+ flex-wrap: wrap;
+}
+
+.tiled-gallery__row {
+ width: 100%;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ margin: 0;
+
+ & + & {
+ margin-top: $tiled-gallery-gutter;
+ }
+}
+
+.tiled-gallery__col {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ margin: 0;
+
+ & + & {
+ margin-left: $tiled-gallery-gutter;
+ }
+}
+
+.tiled-gallery__item {
+ justify-content: center;
+ margin: 0;
+ overflow: hidden;
+ padding: 0;
+ position: relative;
+
+ &.filter__black-and-white {
+ filter: grayscale( 100% );
+ }
+
+ &.filter__sepia {
+ filter: sepia( 100% );
+ }
+
+ &.filter__1977 {
+ @include _1977;
+ }
+
+ &.filter__clarendon {
+ @include clarendon;
+ }
+
+ &.filter__gingham {
+ @include gingham;
+ }
+
+ & + & {
+ margin-top: $tiled-gallery-gutter;
+ }
+
+ > img {
+ background-color: rgba( 0, 0, 0, 0.1 );
+ }
+
+ > a,
+ > a > img,
+ > img {
+ display: block;
+ height: auto;
+ margin: 0;
+ max-width: 100%;
+ object-fit: cover;
+ object-position: center;
+ padding: 0;
+ width: 100%;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/videopress/edit.js b/plugins/jetpack/extensions/blocks/videopress/edit.js
new file mode 100644
index 00000000..cd73e564
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/videopress/edit.js
@@ -0,0 +1,185 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+import classnames from 'classnames';
+import { __ } from '@wordpress/i18n';
+import { BlockControls, RichText } from '@wordpress/editor';
+import { Component, createRef, Fragment } from '@wordpress/element';
+import { compose, createHigherOrderComponent } from '@wordpress/compose';
+import { Disabled, IconButton, SandBox, Toolbar } from '@wordpress/components';
+import { get } from 'lodash';
+import { isBlobURL } from '@wordpress/blob';
+import { withSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import Loading from './loading';
+
+const VideoPressEdit = CoreVideoEdit =>
+ class extends Component {
+ constructor() {
+ super( ...arguments );
+ this.state = {
+ media: null,
+ isFetchingMedia: false,
+ fallback: false,
+ };
+ this.posterImageButton = createRef();
+ }
+
+ componentDidMount() {
+ const { guid } = this.props.attributes;
+ if ( ! guid ) {
+ this.setGuid();
+ }
+ }
+
+ componentDidUpdate( prevProps ) {
+ const { attributes } = this.props;
+
+ if ( attributes.id !== prevProps.attributes.id ) {
+ this.setGuid();
+ }
+ }
+
+ fallbackToCore = () => {
+ this.props.setAttributes( { guid: undefined } );
+ this.setState( { fallback: true } );
+ };
+
+ setGuid = async () => {
+ const { attributes, setAttributes } = this.props;
+ const { id } = attributes;
+
+ if ( ! id ) {
+ setAttributes( { guid: undefined } );
+ return;
+ }
+
+ try {
+ this.setState( { isFetchingMedia: true } );
+ const media = await apiFetch( { path: `/wp/v2/media/${ id }` } );
+ this.setState( { isFetchingMedia: false } );
+
+ const { id: currentId } = this.props.attributes;
+ if ( id !== currentId ) {
+ // Video was changed in the editor while fetching data for the previous video;
+ return;
+ }
+
+ this.setState( { media } );
+ const guid = get( media, 'jetpack_videopress_guid' );
+ if ( guid ) {
+ setAttributes( { guid } );
+ } else {
+ this.fallbackToCore();
+ }
+ } catch ( e ) {
+ this.setState( { isFetchingMedia: false } );
+ this.fallbackToCore();
+ }
+ };
+
+ switchToEditing = () => {
+ this.props.setAttributes( {
+ id: undefined,
+ guid: undefined,
+ src: undefined,
+ } );
+ };
+
+ onRemovePoster = () => {
+ this.props.setAttributes( { poster: '' } );
+
+ // Move focus back to the Media Upload button.
+ this.posterImageButton.current.focus();
+ };
+
+ render() {
+ const {
+ attributes,
+ className,
+ isFetchingPreview,
+ isSelected,
+ isUploading,
+ preview,
+ setAttributes,
+ } = this.props;
+ const { fallback, isFetchingMedia } = this.state;
+
+ if ( isUploading ) {
+ return <Loading text={ __( 'Uploading…', 'jetpack' ) } />;
+ }
+
+ if ( isFetchingMedia || isFetchingPreview ) {
+ return <Loading text={ __( 'Embedding…', 'jetpack' ) } />;
+ }
+
+ if ( fallback || ! preview ) {
+ return <CoreVideoEdit { ...this.props } />;
+ }
+
+ const { html, scripts } = preview;
+ const { caption } = attributes;
+
+ return (
+ <Fragment>
+ <BlockControls>
+ <Toolbar>
+ <IconButton
+ className="components-icon-button components-toolbar__control"
+ label={ __( 'Edit video', 'jetpack' ) }
+ onClick={ this.switchToEditing }
+ icon="edit"
+ />
+ </Toolbar>
+ </BlockControls>
+ <figure className={ classnames( className, 'wp-block-embed', 'is-type-video' ) }>
+ { /*
+ Disable the video player so the user clicking on it won't play the
+ video when the controls are enabled.
+ */ }
+ <Disabled>
+ <div className="wp-block-embed__wrapper">
+ <SandBox html={ html } scripts={ scripts } />
+ </div>
+ </Disabled>
+ { ( ! RichText.isEmpty( caption ) || isSelected ) && (
+ <RichText
+ tagName="figcaption"
+ placeholder={ __( 'Write caption…', 'jetpack' ) }
+ value={ caption }
+ onChange={ value => setAttributes( { caption: value } ) }
+ inlineToolbar
+ />
+ ) }
+ </figure>
+ </Fragment>
+ );
+ }
+ };
+
+export default createHigherOrderComponent(
+ compose( [
+ withSelect( ( select, ownProps ) => {
+ const { guid, src } = ownProps.attributes;
+ const { getEmbedPreview, isRequestingEmbedPreview } = select( 'core' );
+
+ const url = !! guid && `https://videopress.com/v/${ guid }`;
+ const preview = !! url && getEmbedPreview( url );
+
+ const isFetchingEmbedPreview = !! url && isRequestingEmbedPreview( url );
+ const isUploading = isBlobURL( src );
+
+ return {
+ isFetchingPreview: isFetchingEmbedPreview,
+ isUploading,
+ preview,
+ };
+ } ),
+ VideoPressEdit,
+ ] ),
+ 'withVideoPressEdit'
+);
diff --git a/plugins/jetpack/extensions/blocks/videopress/editor.js b/plugins/jetpack/extensions/blocks/videopress/editor.js
new file mode 100644
index 00000000..0cb7f476
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/videopress/editor.js
@@ -0,0 +1,119 @@
+/**
+ * External dependencies
+ */
+import { createBlobURL } from '@wordpress/blob';
+import { createBlock } from '@wordpress/blocks';
+import { mediaUpload } from '@wordpress/editor';
+import { addFilter } from '@wordpress/hooks';
+import { every } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+import withVideoPressEdit from './edit';
+import withVideoPressSave from './save';
+import getJetpackExtensionAvailability from '../../shared/get-jetpack-extension-availability';
+
+const addVideoPressSupport = ( settings, name ) => {
+ if ( 'core/video' !== name ) {
+ return settings;
+ }
+
+ const { available, unavailableReason } = getJetpackExtensionAvailability( 'videopress' );
+
+ // We customize the video block even if VideoPress it not available so we can support videos that were uploaded to
+ // VideoPress if it was available in the past (i.e. before a plan downgrade).
+ if ( available || [ 'missing_plan', 'missing_module' ].includes( unavailableReason ) ) {
+ return {
+ ...settings,
+
+ attributes: {
+ autoplay: {
+ type: 'boolean',
+ },
+ caption: {
+ type: 'string',
+ source: 'html',
+ selector: 'figcaption',
+ },
+ controls: {
+ type: 'boolean',
+ default: true,
+ },
+ guid: {
+ type: 'string',
+ },
+ id: {
+ type: 'number',
+ },
+ loop: {
+ type: 'boolean',
+ },
+ muted: {
+ type: 'boolean',
+ },
+ poster: {
+ type: 'string',
+ },
+ preload: {
+ type: 'string',
+ default: 'metadata',
+ },
+ src: {
+ type: 'string',
+ },
+ },
+
+ transforms: {
+ ...settings.transforms,
+ from: [
+ {
+ type: 'files',
+ isMatch: files => every( files, file => file.type.indexOf( 'video/' ) === 0 ),
+ // We define a higher priority (lower number) than the default of 10. This ensures that this
+ // transformation prevails over the core video block default transformations.
+ priority: 9,
+ transform: ( files, onChange ) => {
+ const blocks = [];
+ files.forEach( file => {
+ const block = createBlock( 'core/video', {
+ src: createBlobURL( file ),
+ } );
+ mediaUpload( {
+ filesList: [ file ],
+ onFileChange: ( [ { id, url } ] ) => {
+ onChange( block.clientId, { id, src: url } );
+ },
+ allowedTypes: [ 'video' ],
+ } );
+ blocks.push( block );
+ } );
+ return blocks;
+ },
+ },
+ ],
+ },
+
+ supports: {
+ ...settings.supports,
+ reusable: false,
+ },
+
+ edit: withVideoPressEdit( settings.edit ),
+
+ save: withVideoPressSave( settings.save ),
+
+ deprecated: [
+ {
+ attributes: settings.attributes,
+ save: settings.save,
+ isEligible: attrs => ! attrs.guid,
+ },
+ ],
+ };
+ }
+
+ return settings;
+};
+
+addFilter( 'blocks.registerBlockType', 'jetpack/videopress', addVideoPressSupport );
diff --git a/plugins/jetpack/extensions/blocks/videopress/index.js b/plugins/jetpack/extensions/blocks/videopress/index.js
new file mode 100644
index 00000000..60d3531f
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/videopress/index.js
@@ -0,0 +1,9 @@
+/**
+ * Internal dependencies
+ */
+// Register the hook that customize the core video block
+import './editor';
+
+// This is exporting deliberately an empty object so we don't break `getExtensions`
+// at the same time we don't register any new plugin or block
+export const settings = {};
diff --git a/plugins/jetpack/extensions/blocks/videopress/loading.js b/plugins/jetpack/extensions/blocks/videopress/loading.js
new file mode 100644
index 00000000..76c25d4c
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/videopress/loading.js
@@ -0,0 +1,13 @@
+/**
+ * External dependencies
+ */
+import { Spinner } from '@wordpress/components';
+
+const Loading = ( { text } ) => (
+ <div className="wp-block-embed is-loading">
+ <Spinner />
+ <p>{ text }</p>
+ </div>
+);
+
+export default Loading;
diff --git a/plugins/jetpack/extensions/blocks/videopress/save.js b/plugins/jetpack/extensions/blocks/videopress/save.js
new file mode 100644
index 00000000..52790480
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/videopress/save.js
@@ -0,0 +1,37 @@
+/**
+ * External dependencies
+ */
+import { createHigherOrderComponent } from '@wordpress/compose';
+import { RichText } from '@wordpress/editor';
+
+const VideoPressSave = CoreVideoSave => props => {
+ const { attributes: { caption, guid } = {} } = props;
+
+ if ( ! guid ) {
+ /**
+ * We return the element produced by the render so Gutenberg can add the block class when cloning the element.
+ * This is due to the fact that `React.cloneElement` ignores the class name when we clone a component to be
+ * rendered (i.e. `React.cloneElement( <CoreVideoSave { ...props } />, { className: 'wp-block-video' } )`).
+ *
+ * @see https://github.com/WordPress/gutenberg/blob/3f1324b53cc8bb45d08d12d5321d6f88510bed09/packages/blocks/src/api/serializer.js#L78-L96
+ * @see https://github.com/WordPress/gutenberg/blob/c5f9bd88125282a0c35f887cc8d835f065893112/packages/editor/src/hooks/generated-class-name.js#L42
+ * @see https://github.com/Automattic/wp-calypso/pull/30546#issuecomment-463637946
+ */
+ return CoreVideoSave( props );
+ }
+
+ const url = `https://videopress.com/v/${ guid }`;
+
+ return (
+ <figure className="wp-block-embed is-type-video is-provider-videopress">
+ <div className="wp-block-embed__wrapper">
+ { `\n${ url }\n` /* URL needs to be on its own line. */ }
+ </div>
+ { ! RichText.isEmpty( caption ) && (
+ <RichText.Content tagName="figcaption" value={ caption } />
+ ) }
+ </figure>
+ );
+};
+
+export default createHigherOrderComponent( VideoPressSave, 'withVideoPressSave' );
diff --git a/plugins/jetpack/extensions/blocks/wordads/constants.js b/plugins/jetpack/extensions/blocks/wordads/constants.js
new file mode 100644
index 00000000..6cf647af
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/wordads/constants.js
@@ -0,0 +1,61 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Path, SVG } from '@wordpress/components';
+
+export const DEFAULT_FORMAT = 'mrec';
+export const AD_FORMATS = [
+ {
+ height: 250,
+ icon: (
+ <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <Path fill="none" d="M0 0h24v24H0V0z" />
+ <Path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-7-2h2V7h-4v2h2z" />
+ </SVG>
+ ),
+ name: __( 'Rectangle 300x250', 'jetpack' ),
+ tag: DEFAULT_FORMAT,
+ width: 300,
+ editorPadding: 30,
+ },
+ {
+ height: 90,
+ icon: (
+ <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <Path fill="none" d="M0 0h24v24H0V0z" />
+ <Path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-4-4h-4v-2h2c1.1 0 2-.89 2-2V9c0-1.11-.9-2-2-2H9v2h4v2h-2c-1.1 0-2 .89-2 2v4h6v-2z" />
+ </SVG>
+ ),
+ name: __( 'Leaderboard 728x90', 'jetpack' ),
+ tag: 'leaderboard',
+ width: 728,
+ editorPadding: 60,
+ },
+ {
+ height: 50,
+ icon: (
+ <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <Path fill="none" d="M0 0h24v24H0V0z" />
+ <Path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-4-4v-1.5c0-.83-.67-1.5-1.5-1.5.83 0 1.5-.67 1.5-1.5V9c0-1.11-.9-2-2-2H9v2h4v2h-2v2h2v2H9v2h4c1.1 0 2-.89 2-2z" />
+ </SVG>
+ ),
+ name: __( 'Mobile Leaderboard 320x50', 'jetpack' ),
+ tag: 'mobile_leaderboard',
+ width: 320,
+ editorPadding: 100,
+ },
+ {
+ height: 600,
+ icon: (
+ <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <Path fill="none" d="M.04 0h24v24h-24V0z" />
+ <Path d="M19.04 3h-14c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16h-14V5h14v14zm-6-2h2V7h-2v4h-2V7h-2v6h4z" />
+ </SVG>
+ ),
+ name: __( 'Wide Skyscraper 160x600', 'jetpack' ),
+ tag: 'wideskyscraper',
+ width: 160,
+ editorPadding: 30,
+ },
+];
diff --git a/plugins/jetpack/extensions/blocks/wordads/edit.js b/plugins/jetpack/extensions/blocks/wordads/edit.js
new file mode 100644
index 00000000..2067d0cd
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/wordads/edit.js
@@ -0,0 +1,56 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { BlockControls } from '@wordpress/editor';
+import { Component, Fragment } from '@wordpress/element';
+import { Placeholder, ToggleControl } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import FormatPicker from './format-picker';
+import { AD_FORMATS } from './constants';
+import { icon, title } from './';
+
+import './editor.scss';
+
+class WordAdsEdit extends Component {
+ handleHideMobileChange = hideMobile => {
+ this.props.setAttributes( { hideMobile: !! hideMobile } );
+ };
+
+ render() {
+ const { attributes, setAttributes } = this.props;
+ const { format, hideMobile } = attributes;
+ const selectedFormatObject = AD_FORMATS.filter( ( { tag } ) => tag === format )[ 0 ];
+
+ return (
+ <Fragment>
+ <BlockControls>
+ <FormatPicker
+ value={ format }
+ onChange={ nextFormat => setAttributes( { format: nextFormat } ) }
+ />
+ </BlockControls>
+ <div className={ `wp-block-jetpack-wordads jetpack-wordads-${ format }` }>
+ <div
+ className="jetpack-wordads__ad"
+ style={ {
+ width: selectedFormatObject.width,
+ height: selectedFormatObject.height + selectedFormatObject.editorPadding,
+ } }
+ >
+ <Placeholder icon={ icon } label={ title } />
+ <ToggleControl
+ checked={ Boolean( hideMobile ) }
+ label={ __( 'Hide ad on mobile views', 'jetpack' ) }
+ onChange={ this.handleHideMobileChange }
+ />
+ </div>
+ </div>
+ </Fragment>
+ );
+ }
+}
+export default WordAdsEdit;
diff --git a/plugins/jetpack/extensions/blocks/wordads/editor.js b/plugins/jetpack/extensions/blocks/wordads/editor.js
new file mode 100644
index 00000000..d05f4039
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/wordads/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/plugins/jetpack/extensions/blocks/wordads/editor.scss b/plugins/jetpack/extensions/blocks/wordads/editor.scss
new file mode 100644
index 00000000..b7be4878
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/wordads/editor.scss
@@ -0,0 +1,52 @@
+@import '../../shared/styles/gutenberg-colors.scss';
+
+.wp-block-jetpack-wordads {
+ background: $white;
+}
+
+[data-type='jetpack/wordads'][data-align='center'] .jetpack-wordads__ad {
+ margin: 0 auto;
+}
+
+.jetpack-wordads__ad {
+ display: flex;
+ overflow: hidden;
+ flex-direction: column;
+ max-width: 100%;
+
+ .components-placeholder {
+ flex-grow: 2;
+ }
+
+ .components-toggle-control__label {
+ line-height: 1.4em;
+ }
+
+ .components-base-control__field {
+ padding: 7px;
+ }
+}
+
+.jetpack-wordads-leaderboard .components-placeholder {
+ min-height: 90px;
+}
+
+.jetpack-wordads-mobile_leaderboard .components-placeholder {
+ min-height: 72px;
+}
+
+.wp-block-jetpack-wordads__format-picker {
+ $active-item-outline-width: 2px;
+
+ padding: 7px;
+
+ // Leave space between elements for active state styling
+ .components-menu-item__button + .components-menu-item__button {
+ margin-top: $active-item-outline-width;
+ }
+
+ .components-menu-item__button.is-active {
+ color: $dark-gray-900;
+ box-shadow: 0 0 0 $active-item-outline-width $dark-gray-500 !important;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/wordads/format-picker.js b/plugins/jetpack/extensions/blocks/wordads/format-picker.js
new file mode 100644
index 00000000..b058ab03
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/wordads/format-picker.js
@@ -0,0 +1,59 @@
+/**
+ * External Dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Dropdown, MenuItem, NavigableMenu, Path, SVG, Toolbar } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import { AD_FORMATS } from './constants';
+
+const label = __( 'Pick an ad format', 'jetpack' );
+
+export default function FormatPicker( { value, onChange } ) {
+ return (
+ <Dropdown
+ position="bottom right"
+ renderToggle={ ( { onToggle, isOpen } ) => {
+ return (
+ <Toolbar
+ controls={ [
+ {
+ icon: (
+ <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+ <Path fill="none" d="M0 0h24v24H0V0z" />
+ <Path d="M1 9h2V7H1v2zm0 4h2v-2H1v2zm0-8h2V3c-1.1 0-2 .9-2 2zm8 16h2v-2H9v2zm-8-4h2v-2H1v2zm2 4v-2H1c0 1.1.9 2 2 2zM21 3h-8v6h10V5c0-1.1-.9-2-2-2zm0 14h2v-2h-2v2zM9 5h2V3H9v2zM5 21h2v-2H5v2zM5 5h2V3H5v2zm16 16c1.1 0 2-.9 2-2h-2v2zm0-8h2v-2h-2v2zm-8 8h2v-2h-2v2zm4 0h2v-2h-2v2z" />
+ </SVG>
+ ),
+ title: label,
+ onClick: onToggle,
+ extraProps: { 'aria-expanded': isOpen },
+ className: 'wp-block-jetpack-wordads__format-picker-icon',
+ },
+ ] }
+ />
+ );
+ } }
+ renderContent={ ( { onClose } ) => (
+ <NavigableMenu className="wp-block-jetpack-wordads__format-picker">
+ { AD_FORMATS.map( ( { tag, name, icon } ) => (
+ <MenuItem
+ className={ tag === value ? 'is-active' : undefined }
+ icon={ icon }
+ isSelected={ tag === value }
+ key={ tag }
+ onClick={ () => {
+ onChange( tag );
+ onClose();
+ } }
+ role="menuitemcheckbox"
+ >
+ { name }
+ </MenuItem>
+ ) ) }
+ </NavigableMenu>
+ ) }
+ />
+ );
+}
diff --git a/plugins/jetpack/extensions/blocks/wordads/index.js b/plugins/jetpack/extensions/blocks/wordads/index.js
new file mode 100644
index 00000000..7a6a0238
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/wordads/index.js
@@ -0,0 +1,66 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { ExternalLink, Path, SVG } from '@wordpress/components';
+import { Fragment } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import edit from './edit';
+import { DEFAULT_FORMAT } from './constants';
+
+export const name = 'wordads';
+export const title = __( 'Ad', 'jetpack' );
+
+export const icon = (
+ <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <Path fill="none" d="M0 0h24v24H0V0z" />
+ <Path d="M12,8H4A2,2 0 0,0 2,10V14A2,2 0 0,0 4,16H5V20A1,1 0 0,0 6,21H8A1,1 0 0,0 9,20V16H12L17,20V4L12,8M15,15.6L13,14H4V10H13L15,8.4V15.6M21.5,12C21.5,13.71 20.54,15.26 19,16V8C20.53,8.75 21.5,10.3 21.5,12Z" />
+ </SVG>
+);
+
+export const settings = {
+ title,
+
+ description: (
+ <Fragment>
+ <p>{ __( 'Earn income by adding high quality ads to your post', 'jetpack' ) }</p>
+ <ExternalLink href="https://wordads.co/">
+ { __( 'Learn all about WordAds', 'jetpack' ) }
+ </ExternalLink>
+ </Fragment>
+ ),
+
+ icon,
+ attributes: {
+ align: {
+ type: 'string',
+ default: 'center',
+ },
+ format: {
+ type: 'string',
+ default: DEFAULT_FORMAT,
+ },
+ hideMobile: {
+ type: 'boolean',
+ default: false,
+ },
+ },
+
+ category: 'jetpack',
+
+ keywords: [ __( 'ads', 'jetpack' ), 'WordAds', __( 'Advertisement', 'jetpack' ) ],
+
+ supports: {
+ align: [ 'left', 'center', 'right' ],
+ alignWide: false,
+ className: false,
+ customClassName: false,
+ html: false,
+ reusable: false,
+ },
+ edit,
+ save: () => null,
+};
diff --git a/plugins/jetpack/extensions/blocks/wordads/wordads.php b/plugins/jetpack/extensions/blocks/wordads/wordads.php
new file mode 100644
index 00000000..8cb8ea2a
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/wordads/wordads.php
@@ -0,0 +1,121 @@
+<?php //phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
+/**
+ * Ads Block.
+ *
+ * @since 7.1.0
+ *
+ * @package Jetpack
+ */
+class Jetpack_WordAds_Gutenblock {
+ const BLOCK_NAME = 'jetpack/wordads';
+
+ /**
+ * Check if site is on WP.com Simple.
+ *
+ * @return bool
+ */
+ private static function is_wpcom() {
+ return defined( 'IS_WPCOM' ) && IS_WPCOM;
+ }
+ /**
+ * Check if the WordAds module is active.
+ *
+ * @return bool
+ */
+ private static function is_jetpack_module_active() {
+ return method_exists( 'Jetpack', 'is_module_active' ) && Jetpack::is_module_active( 'wordads' );
+ }
+
+ /**
+ * Check if the site is approved for ads for WP.com Simple sites.
+ *
+ * @return bool
+ */
+ private static function is_available() {
+ if ( self::is_wpcom() ) {
+ return has_any_blog_stickers( array( 'wordads', 'wordads-approved', 'wordads-approved-misfits' ), get_current_blog_id() );
+ }
+
+ return self::is_jetpack_module_active();
+ }
+
+ /**
+ * Register the WordAds block.
+ */
+ public static function register() {
+ if ( self::is_available() ) {
+ jetpack_register_block(
+ self::BLOCK_NAME,
+ array(
+ 'render_callback' => array( 'Jetpack_WordAds_Gutenblock', 'gutenblock_render' ),
+ )
+ );
+ }
+ }
+
+ /**
+ * Set if the WordAds block is available.
+ */
+ public static function set_availability() {
+ if ( ! self::is_available() ) {
+ Jetpack_Gutenberg::set_extension_unavailable( self::BLOCK_NAME, 'WordAds unavailable' );
+ return;
+ }
+ // Make the block available. Just in case it wasn't registed before.
+ Jetpack_Gutenberg::set_extension_available( self::BLOCK_NAME );
+ }
+
+ /**
+ * Renders the WordAds block.
+ *
+ * @param array $attr Block attributes.
+ *
+ * @return string Block HTML.
+ */
+ public static function gutenblock_render( $attr ) {
+ global $wordads;
+
+ /** This filter is already documented in modules/wordads/wordads.php `insert_ad()` */
+ if ( empty( $wordads ) || is_feed() || apply_filters( 'wordads_inpost_disable', false ) ) {
+ return '';
+ }
+
+ if ( ! empty( $attr['hideMobile'] ) && $wordads->params->is_mobile() ) {
+ return '';
+ }
+
+ if ( ! self::is_wpcom() && $wordads->option( 'wordads_house' ) ) {
+ return $wordads->get_ad( 'inline', 'house' );
+ }
+
+ // section_id is mostly depricated at this point, but it helps us (devs) keep track of which ads end up where
+ // 6 is to keep track of gutenblock ads.
+ $section_id = $wordads->params->blog_id . '6';
+ $align = 'center';
+ if ( isset( $attr['align'] ) && in_array( $attr['align'], array( 'left', 'center', 'right' ), true ) ) {
+ $align = $attr['align'];
+ }
+ $align = 'align' . $align;
+
+ $ad_tag_ids = $wordads->get_ad_tags();
+ $format = 'mrec';
+ if ( isset( $attr['format'] ) && in_array( $attr['format'], array_keys( $ad_tag_ids ), true ) ) {
+ $format = $attr['format'];
+ }
+
+ $height = $ad_tag_ids[ $format ]['height'];
+ $width = $ad_tag_ids[ $format ]['width'];
+ $snippet = $wordads->get_ad_snippet( $section_id, $height, $width, 'gutenberg', $wordads->get_solo_unit_css() );
+ return $wordads->get_ad_div( 'inline', $snippet, array( $align ) );
+ }
+}
+
+add_action(
+ 'init',
+ array( 'Jetpack_WordAds_Gutenblock', 'register' )
+);
+
+add_action(
+ 'jetpack_register_gutenberg_extensions',
+ array( 'Jetpack_WordAds_Gutenblock', 'set_availability' )
+);
diff --git a/plugins/jetpack/extensions/editor.js b/plugins/jetpack/extensions/editor.js
new file mode 100644
index 00000000..34458e4c
--- /dev/null
+++ b/plugins/jetpack/extensions/editor.js
@@ -0,0 +1,5 @@
+/**
+ * Internal dependencies
+ */
+import './shared/public-path';
+import './shared/block-category';
diff --git a/plugins/jetpack/extensions/index.json b/plugins/jetpack/extensions/index.json
new file mode 100644
index 00000000..e8cdcfed
--- /dev/null
+++ b/plugins/jetpack/extensions/index.json
@@ -0,0 +1,24 @@
+{
+ "production": [
+ "business-hours",
+ "contact-form",
+ "contact-info",
+ "gif",
+ "likes",
+ "mailchimp",
+ "map",
+ "markdown",
+ "publicize",
+ "related-posts",
+ "repeat-visitor",
+ "sharing",
+ "shortlinks",
+ "simple-payments",
+ "slideshow",
+ "subscriptions",
+ "tiled-gallery",
+ "videopress",
+ "wordads"
+ ],
+ "beta": [ "seo", "membership-button" ]
+}
diff --git a/plugins/jetpack/extensions/shared/styles/gutenberg-colors.scss b/plugins/jetpack/extensions/shared/styles/gutenberg-colors.scss
new file mode 100644
index 00000000..78bf62c0
--- /dev/null
+++ b/plugins/jetpack/extensions/shared/styles/gutenberg-colors.scss
@@ -0,0 +1,93 @@
+/**
+ * Gutenberg colors
+ *
+ * Unfortunately these are not currently (04/2019) published to NPM.
+ *
+ * https://github.com/WordPress/gutenberg/blob/df6a17c8eb07e1355527b01e99ee22cf4c2338d7/assets/stylesheets/_colors.scss
+ */
+
+// Hugo's new WordPress shades of gray, from http://codepen.io/hugobaeta/pen/grJjVp.
+$black: #000;
+$dark-gray-900: #191e23;
+$dark-gray-800: #23282d;
+$dark-gray-700: #32373c;
+$dark-gray-600: #40464d;
+$dark-gray-500: #555d66; // Use this most of the time for dark items.
+$dark-gray-400: #606a73;
+$dark-gray-300: #6c7781; // Lightest gray that can be used for AA text contrast.
+$dark-gray-200: #7e8993;
+$dark-gray-150: #8d96a0; // Lightest gray that can be used for AA non-text contrast.
+$dark-gray-100: #8f98a1;
+$light-gray-900: #a2aab2;
+$light-gray-800: #b5bcc2;
+$light-gray-700: #ccd0d4;
+$light-gray-600: #d7dade;
+$light-gray-500: #e2e4e7; // Good for "grayed" items and borders.
+$light-gray-400: #e8eaeb; // Good for "readonly" input fields and special text selection.
+$light-gray-300: #edeff0;
+$light-gray-200: #f3f4f5;
+$light-gray-100: #f8f9f9;
+$white: #fff;
+
+
+// Dark opacities, for use with light themes.
+$dark-opacity-900: rgba(#000510, 0.9);
+$dark-opacity-800: rgba(#00000a, 0.85);
+$dark-opacity-700: rgba(#06060b, 0.8);
+$dark-opacity-600: rgba(#000913, 0.75);
+$dark-opacity-500: rgba(#0a1829, 0.7);
+$dark-opacity-400: rgba(#0a1829, 0.65);
+$dark-opacity-300: rgba(#0e1c2e, 0.62);
+$dark-opacity-200: rgba(#162435, 0.55);
+$dark-opacity-100: rgba(#223443, 0.5);
+$dark-opacity-light-900: rgba(#304455, 0.45);
+$dark-opacity-light-800: rgba(#425863, 0.4);
+$dark-opacity-light-700: rgba(#667886, 0.35);
+$dark-opacity-light-600: rgba(#7b86a2, 0.3);
+$dark-opacity-light-500: rgba(#9197a2, 0.25);
+$dark-opacity-light-400: rgba(#95959c, 0.2);
+$dark-opacity-light-300: rgba(#829493, 0.15);
+$dark-opacity-light-200: rgba(#8b8b96, 0.1);
+$dark-opacity-light-100: rgba(#747474, 0.05);
+
+// Light opacities, for use with dark themes.
+$light-opacity-900: rgba($white, 1);
+$light-opacity-800: rgba($white, 0.9);
+$light-opacity-700: rgba($white, 0.85);
+$light-opacity-600: rgba($white, 0.8);
+$light-opacity-500: rgba($white, 0.75);
+$light-opacity-400: rgba($white, 0.7);
+$light-opacity-300: rgba($white, 0.65);
+$light-opacity-200: rgba($white, 0.6);
+$light-opacity-100: rgba($white, 0.55);
+$light-opacity-light-900: rgba($white, 0.5);
+$light-opacity-light-800: rgba($white, 0.45);
+$light-opacity-light-700: rgba($white, 0.4);
+$light-opacity-light-600: rgba($white, 0.35);
+$light-opacity-light-500: rgba($white, 0.3);
+$light-opacity-light-400: rgba($white, 0.25);
+$light-opacity-light-300: rgba($white, 0.2);
+$light-opacity-light-200: rgba($white, 0.15);
+$light-opacity-light-100: rgba($white, 0.1);
+
+// Additional colors.
+// Some are from https://make.wordpress.org/design/handbook/foundations/colors/.
+$blue-wordpress-700: #00669b;
+$blue-dark-900: #0071a1;
+
+$blue-medium-900: #006589;
+$blue-medium-800: #00739c;
+$blue-medium-700: #007fac;
+$blue-medium-600: #008dbe;
+$blue-medium-500: #00a0d2;
+$blue-medium-400: #33b3db;
+$blue-medium-300: #66c6e4;
+$blue-medium-200: #bfe7f3;
+$blue-medium-100: #e5f5fa;
+$blue-medium-highlight: #b3e7fe;
+$blue-medium-focus: #007cba;
+
+// Alert colors.
+$alert-yellow: #f0b849;
+$alert-red: #d94f4f;
+$alert-green: #4ab866;
diff --git a/plugins/jetpack/extensions/shared/styles/gutenberg-variables.scss b/plugins/jetpack/extensions/shared/styles/gutenberg-variables.scss
new file mode 100644
index 00000000..20911c4a
--- /dev/null
+++ b/plugins/jetpack/extensions/shared/styles/gutenberg-variables.scss
@@ -0,0 +1,22 @@
+/**
+ * Gutenberg variables
+ *
+ * Unfortunately these are not currently (04/2019) published to NPM.
+ *
+ * https://github.com/WordPress/gutenberg/blob/df6a17c8eb07e1355527b01e99ee22cf4c2338d7/assets/stylesheets/_variables.scss
+ */
+
+$default-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+$default-font-size: 13px;
+$default-line-height: 1.4;
+$editor-font: "Noto Serif", serif;
+$editor-html-font: Menlo, Consolas, monaco, monospace;
+$editor-font-size: 16px;
+$text-editor-font-size: 14px;
+
+// @TODO: From which file in Gutenberg are these coming from?
+// Used in business-hours
+$break-xlarge: 1080px;
+//$break-large: 960px; // admin sidebar auto folds
+//$break-medium: 782px; // editor sidebar auto folds
+$break-small: 600px;
diff --git a/plugins/jetpack/extensions/shared/styles/jetpack-variables.scss b/plugins/jetpack/extensions/shared/styles/jetpack-variables.scss
new file mode 100644
index 00000000..51aecc74
--- /dev/null
+++ b/plugins/jetpack/extensions/shared/styles/jetpack-variables.scss
@@ -0,0 +1 @@
+$jetpack-block-margin-bottom: 1.5em;
diff --git a/plugins/jetpack/extensions/view.js b/plugins/jetpack/extensions/view.js
new file mode 100644
index 00000000..42ea5a34
--- /dev/null
+++ b/plugins/jetpack/extensions/view.js
@@ -0,0 +1,4 @@
+/**
+ * Internal dependencies
+ */
+import './shared/public-path';