Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.09% covered (success)
99.09%
109 / 110
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Playlist_Block_Email_Renderer
99.09% covered (success)
99.09%
109 / 110
83.33% covered (warning)
83.33%
5 / 6
44
0.00% covered (danger)
0.00%
0 / 1
 render
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
14
 render_latest_videos
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
 video_details
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
12
 render_first_video
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 render_grid
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
4
 layout_width
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
1<?php
2/**
3 * VideoPress playlist block email renderer class.
4 *
5 * @package automattic/jetpack-videopress
6 */
7
8namespace Automattic\Jetpack\VideoPress;
9
10/**
11 * Handles rendering VideoPress playlist blocks for email contexts.
12 *
13 * Emails cannot play a playlist, so the first video is shown as a poster with a
14 * play button and the next ones as a grid of thumbnails, all linking to the post.
15 */
16class Playlist_Block_Email_Renderer {
17
18    /**
19     * Thumbnails per grid row.
20     *
21     * @var int
22     */
23    const GRID_COLUMNS = 3;
24
25    /**
26     * Thumbnails shown under the first video before the "more videos" link.
27     *
28     * @var int
29     */
30    const GRID_MAX_VIDEOS = 6;
31
32    /**
33     * Padding around each grid thumbnail, in pixels.
34     *
35     * @var int
36     */
37    const GRID_CELL_PADDING = 4;
38
39    /**
40     * Render the Video Playlist block for email.
41     *
42     * @param string $block_content     The block's front-end HTML, unused: email builds its own markup.
43     * @param array  $parsed_block      The parsed block data including attributes.
44     * @param object $rendering_context Email rendering context.
45     *
46     * @return string
47     */
48    public static function render( $block_content, $parsed_block, $rendering_context ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
49        if ( ! isset( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) ||
50            ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Video' ) ||
51            ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper' ) ) {
52            return '';
53        }
54
55        $attributes = $parsed_block['attrs'];
56        $entries    = Initializer::sanitize_playlist_entries( $attributes['videos'] ?? null );
57        $post_url   = get_permalink();
58
59        if ( ! $entries || empty( $post_url ) ) {
60            return '';
61        }
62
63        $videos = array();
64        foreach ( array_slice( $entries, 0, 1 + self::GRID_MAX_VIDEOS ) as $index => $entry ) {
65            $videos[] = self::video_details( $entry['guid'], $index + 1 );
66        }
67
68        $width = self::layout_width( $parsed_block, $rendering_context );
69
70        $title = isset( $attributes['playlistTitle'] ) && is_string( $attributes['playlistTitle'] ) ? trim( $attributes['playlistTitle'] ) : '';
71        $html  = (bool) ( $attributes['showPlaylistTitle'] ?? true ) && '' !== $title
72            ? sprintf( '<h2 style="margin: 0 0 16px;">%s</h2>', esc_html( $title ) )
73            : '';
74        $html .= self::render_first_video( $videos[0], $post_url, $rendering_context );
75
76        if ( count( $videos ) > 1 ) {
77            $html .= self::render_grid( array_slice( $videos, 1 ), $post_url, $width );
78        }
79
80        $hidden = count( $entries ) - count( $videos );
81        if ( $hidden > 0 ) {
82            $html .= sprintf(
83                '<p style="margin: 8px 0 0; font-size: 14px;"><a href="%s" target="_blank" rel="noopener noreferrer">%s</a></p>',
84                esc_url( $post_url ),
85                /* translators: %d: number of playlist videos not shown in the email. */
86                esc_html( sprintf( _n( '+ %d more video', '+ %d more videos', $hidden, 'jetpack-videopress-pkg' ), $hidden ) )
87            );
88        }
89
90        $email_attrs  = $parsed_block['email_attrs'] ?? array();
91        $margin_style = (string) \WP_Style_Engine::compile_css( array_intersect_key( $email_attrs, array_flip( array( 'margin', 'margin-top' ) ) ), '' );
92
93        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
94        return \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
95            $html,
96            array(
97                'class' => 'videopress-playlist-email',
98                'style' => ltrim( sprintf( '%s width: 100%%; max-width: %dpx; border-collapse: collapse;', $margin_style, $width ) ),
99            )
100        );
101    }
102
103    /**
104     * Render the Latest Videos Playlist block for email: the site's videos, in the block's order, as a playlist.
105     *
106     * @param string $block_content     The block's front-end HTML, unused: email builds its own markup.
107     * @param array  $parsed_block      The parsed block data including attributes.
108     * @param object $rendering_context Email rendering context.
109     *
110     * @return string
111     */
112    public static function render_latest_videos( $block_content, $parsed_block, $rendering_context ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
113        $attributes = isset( $parsed_block['attrs'] ) && is_array( $parsed_block['attrs'] ) ? $parsed_block['attrs'] : array();
114        $query      = Initializer::latest_videos_playlist_query( $attributes );
115
116        // The same source as the front end, which has no current video to exclude on a post.
117        $is_browsed = 'newest' !== $query['order'] || $query['paginated'] || ! empty( $attributes['showBrowseControls'] ) || ! empty( $attributes['showMeta'] );
118
119        // Readers land on the post's first page with the block's own order, so ignore $_GET.
120        $entries = $is_browsed
121            ? Channel_Data::browse( Channel_Data::browse_request( false, false, $query['order'] ), $query['count'] )['entries']
122            : Data::get_latest_videopress_playlist_entries( $query['count'] );
123
124        $parsed_block['attrs']           = $attributes;
125        $parsed_block['attrs']['videos'] = $entries;
126
127        return self::render( '', $parsed_block, $rendering_context );
128    }
129
130    /**
131     * A video's title and poster, as anyone may see them.
132     *
133     * Private videos get no poster and keep their positional title, as on the
134     * front end for a viewer who is not allowed to watch them.
135     *
136     * @param string $guid     Video GUID.
137     * @param int    $position Position of the video in the playlist, from 1.
138     *
139     * @return array{title: string, poster: string}
140     */
141    private static function video_details( $guid, $position ) {
142        $details = function_exists( 'videopress_get_video_details' ) ? videopress_get_video_details( $guid ) : null;
143        $details = is_object( $details ) && ! is_wp_error( $details ) && empty( $details->is_private ) ? $details : null;
144
145        $title  = $details && ! empty( $details->title ) && is_string( $details->title ) ? $details->title : '';
146        $poster = $details && ! empty( $details->poster ) && is_string( $details->poster ) ? esc_url_raw( $details->poster ) : '';
147
148        return array(
149            /* translators: %d: position of the video in the playlist. */
150            'title'  => '' !== $title ? $title : sprintf( __( 'Video %d', 'jetpack-videopress-pkg' ), $position ),
151            'poster' => $poster,
152        );
153    }
154
155    /**
156     * The first video as a poster with a play button, or a link when it has no poster.
157     *
158     * @param array  $video             Video details.
159     * @param string $post_url          Post URL.
160     * @param object $rendering_context Email rendering context.
161     *
162     * @return string
163     */
164    private static function render_first_video( $video, $post_url, $rendering_context ) {
165        if ( '' !== $video['poster'] ) {
166            $video_block = array(
167                'blockName' => 'core/video',
168                'attrs'     => array(
169                    'poster'   => $video['poster'],
170                    'videoUrl' => $post_url,
171                ),
172                'innerHTML' => '<figure class="wp-block-video"></figure>',
173            );
174
175            // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() in render().
176            $video_renderer = new \Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Video();
177
178            // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() in render().
179            return $video_renderer->render( $video_block['innerHTML'], $video_block, $rendering_context );
180        }
181
182        return sprintf(
183            '<p><a href="%s" target="_blank" rel="noopener noreferrer">%s</a></p>',
184            esc_url( $post_url ),
185            esc_html__( 'Visit the post to watch the video', 'jetpack-videopress-pkg' )
186        );
187    }
188
189    /**
190     * The videos after the first as a table of linked thumbnails and titles.
191     *
192     * @param array  $videos   Video details.
193     * @param string $post_url Post URL.
194     * @param int    $width    Block width in pixels.
195     *
196     * @return string
197     */
198    private static function render_grid( $videos, $post_url, $width ) {
199        $image_width = (int) floor( $width / self::GRID_COLUMNS ) - 2 * self::GRID_CELL_PADDING;
200        $cell_style  = sprintf(
201            'width: %.2F%%; padding: %dpx; vertical-align: top;',
202            100 / self::GRID_COLUMNS,
203            self::GRID_CELL_PADDING
204        );
205
206        $rows = '';
207        foreach ( array_chunk( $videos, self::GRID_COLUMNS ) as $row_videos ) {
208            $cells = '';
209            foreach ( $row_videos as $video ) {
210                $thumbnail = '' !== $video['poster']
211                    ? sprintf(
212                        '<img src="%1$s" alt="" width="%2$d" style="display: block; width: 100%%; max-width: %2$dpx; height: auto; margin-bottom: 4px; border: 0; border-radius: 4px;" />',
213                        esc_url( $video['poster'] ),
214                        $image_width
215                    )
216                    : '';
217
218                $cells .= sprintf(
219                    '<td style="%1$s"><a href="%2$s" target="_blank" rel="noopener noreferrer" style="display: block; font-size: 14px; line-height: 1.4;">%3$s%4$s</a></td>',
220                    esc_attr( $cell_style ),
221                    esc_url( $post_url ),
222                    $thumbnail,
223                    esc_html( $video['title'] )
224                );
225            }
226
227            // Pad the last row so its cells keep the same width as the others.
228            $cells .= str_repeat(
229                sprintf( '<td style="%s"></td>', esc_attr( $cell_style ) ),
230                self::GRID_COLUMNS - count( $row_videos )
231            );
232
233            $rows .= '<tr>' . $cells . '</tr>';
234        }
235
236        return sprintf(
237            '<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="width: 100%%; margin-top: 16px; border-collapse: collapse; table-layout: fixed;">%s</table>',
238            $rows
239        );
240    }
241
242    /**
243     * The block's width in the email, e.g. narrower inside a column; else the content width, else 600px.
244     *
245     * @param array  $parsed_block      The parsed block data, with the email editor's email_attrs.
246     * @param object $rendering_context Email rendering context.
247     *
248     * @return int Width in pixels.
249     */
250    private static function layout_width( $parsed_block, $rendering_context ) {
251        $block_width = (int) ( $parsed_block['email_attrs']['width'] ?? 0 );
252        if ( $block_width > 0 ) {
253            return $block_width;
254        }
255
256        if ( is_object( $rendering_context ) && method_exists( $rendering_context, 'get_layout_width_without_padding' ) ) {
257            $width = (int) $rendering_context->get_layout_width_without_padding();
258            if ( $width > 0 ) {
259                return $width;
260            }
261        }
262
263        return 600;
264    }
265}