diff --git a/api.wordpress.org/public_html/dotorg/freescout/common.php b/api.wordpress.org/public_html/dotorg/freescout/common.php index b2fb762bbb..b4d5fd651a 100644 --- a/api.wordpress.org/public_html/dotorg/freescout/common.php +++ b/api.wordpress.org/public_html/dotorg/freescout/common.php @@ -173,6 +173,25 @@ function send_panel( array $blocks, array $extra = array() ): never { exit; } +/** + * A plugin's or theme's security scans; %1$s is plugin or theme, and %2$s its slug. + * + * @var string + */ +const SCANS_URL = 'https://gandalf.wordpress.org/admin/subjects/%1$s/%2$s?tab=runs'; + +/** + * Whether a plugin has been released: its code is in SVN, so it can be checked out and scanned. + * + * An approved plugin isn't in SVN until its author commits it. + * + * @param \WP_Post $post Plugin. + * @return bool + */ +function is_plugin_released( \WP_Post $post ): bool { + return ! in_array( $post->post_status, array( 'new', 'pending', 'approved', 'rejected' ), true ); +} + /** * The ZIP of a plugin: the latest uploaded for review until it's released, and the latest release otherwise. * @@ -211,34 +230,6 @@ function get_plugin_download_url( \WP_Post $post, bool $review ): string { return $url; } -/** - * A plugin's status, as reviewers say it. - * - * @param \WP_Post $post Plugin. - * @param bool $review Whether it's for a reviewer, who also sees why a plugin was closed. - * @return string - */ -function get_plugin_status_label( \WP_Post $post, bool $review ): string { - switch ( $post->post_status ) { - case 'new': - case 'pending': - return 'In Review'; - case 'closed': - case 'disabled': - $label = ucwords( $post->post_status ); - // This is not perfect, but close enough. - if ( $review && $post->_close_reason ) { - $label .= ': ' . ucwords( str_replace( '-', ' ', $post->_close_reason ) ); - } - - return $label; - case 'publish': - return 'Published'; - default: - return ucwords( $post->post_status ); - } -} - /** * Who's reviewing a plugin: only a review in progress has someone on it, as the assignment stays after it's done. * @@ -286,6 +277,152 @@ function panel_link( string $text, string $url ): array { ); } +/** + * Gets the time between two timestamps as a number and a unit letter, e.g. 3d, for the narrow sidebar. + * + * Rounded in the largest unit that fits, like human_time_diff(), or up to the next one when it rounds to a whole one. + * + * @param int $from Unix timestamp. + * @param int $to Unix timestamp. + * @return string + */ +function short_time_diff( int $from, int $to ): string { + $diff = abs( $to - $from ); + $units = array( + 'y' => YEAR_IN_SECONDS, + 'mo' => MONTH_IN_SECONDS, + 'w' => WEEK_IN_SECONDS, + 'd' => DAY_IN_SECONDS, + 'h' => HOUR_IN_SECONDS, + 'm' => MINUTE_IN_SECONDS, + ); + + $larger = null; + foreach ( $units as $unit => $seconds ) { + if ( $diff >= $seconds ) { + $count = (int) round( $diff / $seconds ); + if ( $larger && $count >= round( $units[ $larger ] / $seconds ) ) { + return '1' . $larger; + } + + return $count . $unit; + } + + $larger = $unit; + } + + return max( 1, $diff ) . 's'; +} + +/** + * Icons of the reasons plugins are closed or disabled for, by reason; the plugin directory names them. + * + * @var string[] + */ +const CLOSE_REASON_ICONS = array( + 'security-issue' => '๐ก๏ธ', + 'author-request' => '๐ค', + 'guideline-violation' => '๐', + 'licensing-trademark-violation' => 'โข๏ธ', + 'merged-into-core' => '๐งฉ', + 'unused' => '๐ค', +); + +/** + * Icons, text, and tones of the statuses of plugins and themes in the sidebar, by status; published ones have none. + * + * @var array[] + */ +const ITEM_STATUSES = array( + // Plugins. + 'new' => array( 'โณ', 'Pending initial review', 'warning' ), + 'pending' => array( '๐', 'In review', 'warning' ), + 'approved' => array( 'โ ', 'Approved', 'warning' ), + 'closed' => array( '๐', 'Closed', 'error' ), + 'disabled' => array( '๐', 'Disabled', 'error' ), + 'rejected' => array( '๐ซ', 'Rejected', 'muted' ), + + // Themes. + 'draft' => array( 'โณ', 'In review or rejected', 'warning' ), + 'suspend' => array( 'โ', 'Suspended', 'error' ), + 'suspended' => array( 'โ', 'Suspended', 'error' ), + 'delist' => array( '', 'Delisted', 'muted' ), + 'delisted' => array( '', 'Delisted', 'muted' ), +); + +/** + * Gets a reason a plugin was closed, disabled, or rejected for, in the plugin directory's words. + * + * @param string $reasons_method The plugin directory's method that lists them: get_close_reasons or get_rejection_reasons. + * @param string $reason Reason, e.g. security-issue. + * @return string + */ +function get_plugin_reason( string $reasons_method, string $reason ): string { + $template = '\WordPressdotorg\Plugin_Directory\Template'; + $reasons = class_exists( $template ) ? $template::$reasons_method() : array(); + + return $reasons[ $reason ] ?? ucfirst( str_replace( '-', ' ', $reason ) ); +} + +/** + * Gets the status of a plugin or theme in words. + * + * @param string $post_status Post status. + * @param string $close_reason Why a plugin was closed or disabled, e.g. security-issue. + * @return string + */ +function get_item_status_label( string $post_status, string $close_reason = '' ): string { + if ( ! isset( ITEM_STATUSES[ $post_status ] ) ) { + return 'publish' === $post_status ? 'Published' : ucfirst( $post_status ); + } + + $label = ITEM_STATUSES[ $post_status ][1]; + if ( $close_reason && in_array( $post_status, array( 'closed', 'disabled' ), true ) ) { + $label .= ': ' . get_plugin_reason( 'get_close_reasons', $close_reason ); + } + + return $label; +} + +/** + * Gets the status line of a plugin or theme in the sidebar, which published ones don't have. + * + * Reasons and reviewers are the plugins team's, so only reviews pass them. + * + * @param string $post_status Post status. + * @param string $reviewer Reviewer assigned to a plugin in review, see get_assigned_reviewer(). + * @param string $close_reason Why a plugin was closed or disabled, e.g. security-issue. + * @param string $rejection Why a plugin was rejected, in words. + * @return array The item's tone, and its status: icon, text, and tooltip. Empty if published. + */ +function get_item_status( string $post_status, string $reviewer = '', string $close_reason = '', string $rejection = '' ): array { + if ( ! isset( ITEM_STATUSES[ $post_status ] ) ) { + return array(); + } + + list( $icon, , $tone ) = ITEM_STATUSES[ $post_status ]; + if ( in_array( $post_status, array( 'closed', 'disabled' ), true ) ) { + $icon = CLOSE_REASON_ICONS[ $close_reason ] ?? $icon; + } + + $text = get_item_status_label( $post_status, $close_reason ); + if ( $reviewer ) { + $text .= ' ยท Assigned to ' . $reviewer; + } + + return array( + 'tone' => $tone, + // Without an icon or tooltip where there's none. + 'status' => array_filter( + array( + 'icon' => $icon, + 'text' => $text, + 'tooltip' => 'rejected' === $post_status ? $rejection : '', + ) + ), + ); +} + /** * Determines the mailbox slug, e.g. "Plugins" => plugins. * diff --git a/api.wordpress.org/public_html/dotorg/freescout/plugin-review.php b/api.wordpress.org/public_html/dotorg/freescout/plugin-review.php index d1185f121a..792d725972 100644 --- a/api.wordpress.org/public_html/dotorg/freescout/plugin-review.php +++ b/api.wordpress.org/public_html/dotorg/freescout/plugin-review.php @@ -13,13 +13,6 @@ $wp_init_host = 'https://wordpress.org/plugins/'; require __DIR__ . '/common.php'; -/** - * A plugin's security scans; %s is its slug. - * - * @var string - */ -const SCANS_URL = 'https://gandalf.wordpress.org/admin/subjects/plugin/%s?tab=runs'; - /** * Gets a plugin as the Plugin Review panel shows it: its current name, slug, and status, its ZIP, and who submitted it. * @@ -39,8 +32,7 @@ function render_plugin_review( object $request ): array { return array( 'plugin' => null ); } - // An approved plugin isn't in SVN until its author commits it. - $released = ! in_array( $post->post_status, array( 'new', 'pending', 'approved', 'rejected' ), true ); + $released = is_plugin_released( $post ); $submitter = get_user_by( 'id', (int) $post->post_author ); return array( @@ -50,7 +42,7 @@ function render_plugin_review( object $request ): array { 'name' => html_entity_decode( $post->post_title, ENT_QUOTES | ENT_HTML5, 'UTF-8' ), 'slug' => $post->post_name, 'status' => $post->post_status, - 'status_label' => get_plugin_status_label( $post, true ), + 'status_label' => get_item_status_label( $post->post_status, (string) $post->_close_reason ), // Its code is in SVN, so it can be checked out and scanned. 'released' => $released, // Edit and permalinks are built by hand, as the post types aren't registered on this site. @@ -64,7 +56,7 @@ function render_plugin_review( object $request ): array { 'page_url' => home_url( "/{$post->post_name}/" ), 'download_url' => get_plugin_download_url( $post, true ), // Only releases are scanned. - 'scans_url' => $released ? sprintf( SCANS_URL, rawurlencode( $post->post_name ) ) : '', + 'scans_url' => $released ? sprintf( SCANS_URL, 'plugin', rawurlencode( $post->post_name ) ) : '', 'submitter' => $submitter ? array( 'username' => $submitter->user_login, 'email' => $submitter->user_email, diff --git a/api.wordpress.org/public_html/dotorg/freescout/plugins-themes.php b/api.wordpress.org/public_html/dotorg/freescout/plugins-themes.php index 30a1ca23da..449533a0c0 100644 --- a/api.wordpress.org/public_html/dotorg/freescout/plugins-themes.php +++ b/api.wordpress.org/public_html/dotorg/freescout/plugins-themes.php @@ -14,18 +14,17 @@ require __DIR__ . '/common.php'; /** - * Badge tones of plugins' statuses. + * Orders plugins and themes by status, then by name: in review or approved first, with theme drafts, which can also be + * rejected; then published, then the others, and rejected plugins last. * - * @var string[] + * @var string */ -const PLUGIN_STATUS_TONES = array( - 'rejected' => 'error', - 'closed' => 'error', - 'disabled' => 'error', - 'pending' => 'warning', - 'new' => 'warning', - 'approved' => 'success', -); +const ITEM_ORDER = "CASE + WHEN post_status IN( 'new', 'pending', 'approved', 'draft' ) THEN 1 + WHEN post_status = 'publish' THEN 2 + WHEN post_status = 'rejected' THEN 4 + ELSE 3 +END, post_title"; /** * Gets the plugins and themes panel. @@ -138,8 +137,8 @@ function get_user_items( \WP_User $user ): array { $wpdb->prepare( "SELECT ID FROM %i - WHERE post_type IN( 'plugin', 'repopackage' ) AND ( post_author = %d" . ( $slugs ? ' OR post_name IN( ' . implode( ', ', array_fill( 0, count( $slugs ), '%s' ) ) . ' )' : '' ) . " ) - ORDER BY FIELD( post_status, 'new', 'pending', 'publish', 'disabled', 'delisted', 'delist', 'closed', 'approved', 'suspended', 'suspend', 'rejected', 'draft' ), post_title", + WHERE post_type IN( 'plugin', 'repopackage' ) AND ( post_author = %d" . ( $slugs ? ' OR post_name IN( ' . implode( ', ', array_fill( 0, count( $slugs ), '%s' ) ) . ' )' : '' ) . ' ) + ORDER BY ' . ITEM_ORDER, array_merge( array( $wpdb->posts, $user->ID ), $slugs ) ) ); @@ -154,7 +153,7 @@ function get_user_items( \WP_User $user ): array { * Queried directly, as WP_Query's 'any' skips statuses this request doesn't register, like suspended themes. * * @param array $slugs Plugin or theme slugs. - * @return int[] Post IDs, ordered by title. + * @return int[] Post IDs, ordered by status, then title. */ function get_items_by_slug( array $slugs ): array { global $wpdb; @@ -169,7 +168,7 @@ function get_items_by_slug( array $slugs ): array { FROM %i WHERE post_type IN( 'plugin', 'repopackage' ) AND post_status NOT IN( 'trash', 'auto-draft' ) AND post_name IN( " . implode( ', ', array_fill( 0, count( $slugs ), '%s' ) ) . ' ) - ORDER BY post_title', + ORDER BY ' . ITEM_ORDER, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- A constant, with no input. array_merge( array( $wpdb->posts ), array_values( $slugs ) ) ) ); @@ -193,9 +192,9 @@ function render_items( array $post_ids, string $mailbox_email ): array { foreach ( $post_ids as $post_id ) { $post = get_post( (int) $post_id ); $type = ( 'plugin' === $post->post_type ) ? 'plugin' : 'theme'; - $status = ''; - $tone = 'neutral'; $reviewer = ''; + $close_reason = ''; + $rejection = ''; $last_modified = $post->post_modified_gmt; $download_link = 'plugin' === $type ? get_plugin_download_url( $post, $show_review ) : "https://downloads.wordpress.org/theme/{$post->post_name}.latest-stable.zip"; @@ -204,53 +203,35 @@ function render_items( array $post_ids, string $mailbox_email ): array { // Prefer the last_updated post meta. $last_modified = $post->last_updated ? $post->last_updated : $last_modified; - } - - $last_updated = (int) strtotime( $last_modified ); - // Published plugins and themes get no badge. - if ( 'plugin' === $type && isset( PLUGIN_STATUS_TONES[ $post->post_status ] ) ) { - $status = get_plugin_status_label( $post, $show_review ); - $tone = PLUGIN_STATUS_TONES[ $post->post_status ]; - } - - switch ( $post->post_status ) { - // Themes. - case 'draft': - $status = 'In Review or Rejected'; - $tone = 'warning'; - $download_link = ''; // No zips exist for drafts. - break; - case 'suspend': - $status = 'Suspended'; - $tone = 'error'; - break; - case 'delist': - $status = 'Delisted'; - $tone = 'error'; - break; + // Why a plugin was closed or rejected is the plugins team's, like reviews. + if ( $show_review ) { + $close_reason = (string) $post->_close_reason; + $rejection = $post->_rejection_reason ? get_plugin_reason( 'get_rejection_reasons', (string) $post->_rejection_reason ) : ''; + } + } elseif ( 'draft' === $post->post_status ) { + $download_link = ''; // No zips exist for themes in review. } - $meta = array( - array( 'text' => $post->post_name ), - array( - 'text' => 'Updated ' . human_time_diff( $last_updated, time() ) . ' ago', - 'tooltip' => gmdate( 'Y-m-d', $last_updated ), - ), - ); + $last_updated = (int) strtotime( $last_modified ); + $released = 'plugin' === $type && is_plugin_released( $post ); - if ( $reviewer ) { - $meta[] = array( 'text' => 'Assigned to ' . $reviewer ); + // Built by hand, as the post types aren't registered on this site; rarest first, so Download lines up at the end. + $links = array(); + if ( $released || ( 'theme' === $type && 'publish' === $post->post_status ) ) { + $links[] = array( + 'text' => 'Scans in Gandalf', + 'url' => sprintf( SCANS_URL, $type, rawurlencode( $post->post_name ) ), + 'icon' => 'shield', + ); } - - // Edit and permalinks are built by hand, as the post types aren't registered on this site. - $links = array( - array( + if ( $released || ( 'theme' === $type && in_array( $post->post_status, array( 'publish', 'delist' ), true ) ) ) { + $links[] = array( 'text' => 'View on WordPress.org', 'url' => home_url( "/{$post->post_name}/" ), 'icon' => 'link', - ), - ); + ); + } if ( $download_link ) { $links[] = array( 'text' => 'Download', @@ -259,19 +240,25 @@ function render_items( array $post_ids, string $mailbox_email ): array { ); } - $items[] = array( - // Stored escaped, and shown as text. - 'title' => html_entity_decode( $post->post_title, ENT_QUOTES | ENT_HTML5, 'UTF-8' ), - 'url' => add_query_arg( - array( - 'action' => 'edit', - 'post' => $post->ID, + $items[] = array_merge( + array( + // Stored escaped, and shown as text. + 'title' => html_entity_decode( $post->post_title, ENT_QUOTES | ENT_HTML5, 'UTF-8' ), + 'url' => add_query_arg( + array( + 'action' => 'edit', + 'post' => $post->ID, + ), + admin_url( 'post.php' ) + ), + 'meta' => array( array( 'text' => $post->post_name ) ), + 'age' => array( + 'text' => short_time_diff( $last_updated, time() ), + 'tooltip' => 'Updated ' . gmdate( 'Y-m-d', $last_updated ), ), - admin_url( 'post.php' ) + 'links' => $links, ), - 'badges' => $status ? array( badge( $status, $tone ) ) : array(), - 'meta' => $meta, - 'links' => $links, + get_item_status( $post->post_status, $reviewer, $close_reason, $rejection ) ); } diff --git a/api.wordpress.org/public_html/dotorg/freescout/tests/bootstrap.php b/api.wordpress.org/public_html/dotorg/freescout/tests/bootstrap.php index ad27668857..2bfac8fe13 100644 --- a/api.wordpress.org/public_html/dotorg/freescout/tests/bootstrap.php +++ b/api.wordpress.org/public_html/dotorg/freescout/tests/bootstrap.php @@ -16,6 +16,11 @@ define( 'ABSPATH', __DIR__ . '/' ); define( 'KB_IN_BYTES', 1024 ); define( 'MINUTE_IN_SECONDS', 60 ); +define( 'HOUR_IN_SECONDS', 60 * MINUTE_IN_SECONDS ); +define( 'DAY_IN_SECONDS', 24 * HOUR_IN_SECONDS ); +define( 'WEEK_IN_SECONDS', 7 * DAY_IN_SECONDS ); +define( 'MONTH_IN_SECONDS', 30 * DAY_IN_SECONDS ); +define( 'YEAR_IN_SECONDS', 365 * DAY_IN_SECONDS ); define( 'FREESCOUT_SECRET', 'test-secret' ); require_once __DIR__ . '/stubs/class-wp-user.php'; diff --git a/api.wordpress.org/public_html/dotorg/freescout/tests/class-item-status-test.php b/api.wordpress.org/public_html/dotorg/freescout/tests/class-item-status-test.php new file mode 100644 index 0000000000..34cea5b5c0 --- /dev/null +++ b/api.wordpress.org/public_html/dotorg/freescout/tests/class-item-status-test.php @@ -0,0 +1,129 @@ +assertSame( array(), get_item_status( 'publish' ) ); + } + + /** + * Each status has words, a tone, and but for delisted themes, an icon. + * + * @dataProvider data_statuses + * + * @param string $post_status Post status. + * @param string $icon Icon. + * @param string $text Text. + * @param string $tone Tone. + * @return void + */ + public function test_status( string $post_status, string $icon, string $text, string $tone ): void { + $this->assertSame( + array( + 'tone' => $tone, + 'status' => array_filter( + array( + 'icon' => $icon, + 'text' => $text, + ) + ), + ), + get_item_status( $post_status ) + ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_statuses(): array { + return array( + 'new' => array( 'new', 'โณ', 'Pending initial review', 'warning' ), + 'pending' => array( 'pending', '๐', 'In review', 'warning' ), + 'approved' => array( 'approved', 'โ ', 'Approved', 'warning' ), + 'closed' => array( 'closed', '๐', 'Closed', 'error' ), + 'disabled' => array( 'disabled', '๐', 'Disabled', 'error' ), + 'rejected' => array( 'rejected', '๐ซ', 'Rejected', 'muted' ), + 'draft' => array( 'draft', 'โณ', 'In review or rejected', 'warning' ), + 'suspended' => array( 'suspend', 'โ', 'Suspended', 'error' ), + 'delisted' => array( 'delist', '', 'Delisted', 'muted' ), + ); + } + + /** + * A closed or disabled plugin's reason picks its icon. + * + * @return void + */ + public function test_close_reason(): void { + $this->assertSame( + array( + 'icon' => '๐ก๏ธ', + 'text' => 'Closed: Security issue', + ), + get_item_status( 'closed', '', 'security-issue' )['status'] + ); + + $this->assertSame( + array( + 'icon' => 'โข๏ธ', + 'text' => 'Disabled: Licensing trademark violation', + ), + get_item_status( 'disabled', '', 'licensing-trademark-violation' )['status'] + ); + } + + /** + * A reason without an icon is still named. + * + * @return void + */ + public function test_unknown_close_reason(): void { + $this->assertSame( + array( + 'icon' => '๐', + 'text' => 'Closed: Some new reason', + ), + get_item_status( 'closed', '', 'some-new-reason' )['status'] + ); + } + + /** + * The reviewer of a plugin in review follows its status. + * + * @return void + */ + public function test_reviewer(): void { + $this->assertSame( 'In review ยท Assigned to Priya Shah', get_item_status( 'pending', 'Priya Shah' )['status']['text'] ); + $this->assertSame( 'Pending initial review ยท Assigned to Priya Shah', get_item_status( 'new', 'Priya Shah' )['status']['text'] ); + } + + /** + * Why a plugin was rejected is in the tooltip. + * + * @return void + */ + public function test_rejection_reason(): void { + $this->assertSame( 'Duplicate (copy) of another Plugin', get_item_status( 'rejected', '', '', 'Duplicate (copy) of another Plugin' )['status']['tooltip'] ); + } +} diff --git a/api.wordpress.org/public_html/dotorg/freescout/tests/class-short-time-diff-test.php b/api.wordpress.org/public_html/dotorg/freescout/tests/class-short-time-diff-test.php new file mode 100644 index 0000000000..3431052a46 --- /dev/null +++ b/api.wordpress.org/public_html/dotorg/freescout/tests/class-short-time-diff-test.php @@ -0,0 +1,55 @@ +assertSame( $expected, short_time_diff( $now - $seconds, $now ) ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_short_time_diff(): array { + return array( + 'no time' => array( 0, '1s' ), + 'seconds' => array( 45, '45s' ), + 'a minute' => array( MINUTE_IN_SECONDS, '1m' ), + 'hours' => array( 3 * HOUR_IN_SECONDS, '3h' ), + 'a day' => array( DAY_IN_SECONDS, '1d' ), + 'rounded up' => array( DAY_IN_SECONDS + 13 * HOUR_IN_SECONDS, '2d' ), + 'weeks' => array( 2 * WEEK_IN_SECONDS, '2w' ), + 'months' => array( 2 * MONTH_IN_SECONDS + DAY_IN_SECONDS, '2mo' ), + 'years' => array( 12 * YEAR_IN_SECONDS, '12y' ), + 'up to a week' => array( 6 * DAY_IN_SECONDS + 15 * HOUR_IN_SECONDS, '1w' ), + 'up to a year' => array( 350 * DAY_IN_SECONDS, '1y' ), + 'in the future' => array( -2 * DAY_IN_SECONDS, '2d' ), + ); + } +} diff --git a/environments/freescout/mock-api/index.php b/environments/freescout/mock-api/index.php index bb14f1fcbf..afea615da5 100644 --- a/environments/freescout/mock-api/index.php +++ b/environments/freescout/mock-api/index.php @@ -109,7 +109,7 @@ function respond( int $status, array|object $data ): void { 'name' => 'Mock Plugin Renamed', 'slug' => 'mock-plugin-renamed', 'status' => 'pending', - 'status_label' => 'In Review', + 'status_label' => 'In review', 'released' => false, 'scans_url' => '', 'edit_url' => 'https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=' . $plugin_id, @@ -127,8 +127,9 @@ function respond( int $status, array|object $data ): void { /* * What the real endpoints sent for obenland's WordPress.org account, whatever the sender, though searches are for the - * sender's email, as they are there. Download links lose their review info, and the forum note and privacy request - * are samples, as the account has neither. + * sender's email, as they are there. Download links lose their review info, and the forum note, the privacy request, + * and the plugins mentioned besides The Events Calendar, one in each review status, are samples, as the account has + * none of them. */ $panel = __DIR__ . '/panels/' . basename( $endpoint, '.php' ) . '.json'; $email = rawurlencode( (string) ( $request->sender->email ?? '' ) ); diff --git a/environments/freescout/mock-api/panels/plugins-themes.json b/environments/freescout/mock-api/panels/plugins-themes.json index 48ebd11b1a..bec3cd2c6a 100644 --- a/environments/freescout/mock-api/panels/plugins-themes.json +++ b/environments/freescout/mock-api/panels/plugins-themes.json @@ -3,25 +3,109 @@ { "type": "heading", "text": "Plugins mentioned", - "count": 1 + "count": 8 }, { "type": "items", "items": [ + { + "title": "Booking Widget for Salons", + "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900003", + "meta": [ + { + "text": "booking-widget-for-salons" + } + ], + "age": { + "text": "1d", + "tooltip": "Updated 2026-10-04" + }, + "links": [ + { + "text": "View on WordPress.org", + "url": "https://wordpress.org/plugins/booking-widget-for-salons/", + "icon": "link" + }, + { + "text": "Download", + "url": "https://wordpress.org/plugins/wp-content/uploads/2026/10/booking-widget-for-salons.zip", + "icon": "download-alt" + } + ], + "tone": "warning", + "status": { + "icon": "โ ", + "text": "Approved" + } + }, + { + "title": "Contact Form Builder Pro", + "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900002", + "meta": [ + { + "text": "contact-form-builder-pro" + } + ], + "age": { + "text": "5d", + "tooltip": "Updated 2026-09-30" + }, + "links": [ + { + "text": "Download", + "url": "https://wordpress.org/plugins/wp-content/uploads/2026/10/contact-form-builder-pro.zip", + "icon": "download-alt" + } + ], + "tone": "warning", + "status": { + "icon": "๐", + "text": "In review ยท Assigned to Priya Shah" + } + }, + { + "title": "Custom Checkout Fields, Order Notes and Delivery Date Picker for WooCommerce", + "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900001", + "meta": [ + { + "text": "custom-checkout-fields-order-notes-delivery-date-picker" + } + ], + "age": { + "text": "3d", + "tooltip": "Updated 2026-10-02" + }, + "links": [ + { + "text": "Download", + "url": "https://wordpress.org/plugins/wp-content/uploads/2026/10/custom-checkout-fields-order-notes-delivery-date-picker.zip", + "icon": "download-alt" + } + ], + "tone": "warning", + "status": { + "icon": "โณ", + "text": "Pending initial review ยท Assigned to Marco Rossi" + } + }, { "title": "The Events Calendar", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=203", - "badges": [], "meta": [ { "text": "the-events-calendar" - }, - { - "text": "Updated 5 days ago", - "tooltip": "2026-09-30" } ], + "age": { + "text": "5d", + "tooltip": "Updated 2026-09-30" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/the-events-calendar?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/the-events-calendar/", @@ -33,6 +117,131 @@ "icon": "download-alt" } ] + }, + { + "title": "Easy Gallery Slider", + "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900004", + "meta": [ + { + "text": "easy-gallery-slider" + } + ], + "age": { + "text": "3w", + "tooltip": "Updated 2026-09-14" + }, + "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/easy-gallery-slider?tab=runs", + "icon": "shield" + }, + { + "text": "View on WordPress.org", + "url": "https://wordpress.org/plugins/easy-gallery-slider/", + "icon": "link" + }, + { + "text": "Download", + "url": "https://downloads.wordpress.org/plugin/easy-gallery-slider.latest-stable.zip", + "icon": "download-alt" + } + ], + "tone": "error", + "status": { + "icon": "๐ก๏ธ", + "text": "Closed: Security Issue" + } + }, + { + "title": "SEO Booster Ultimate", + "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900005", + "meta": [ + { + "text": "seo-booster-ultimate" + } + ], + "age": { + "text": "6mo", + "tooltip": "Updated 2026-04-02" + }, + "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/seo-booster-ultimate?tab=runs", + "icon": "shield" + }, + { + "text": "View on WordPress.org", + "url": "https://wordpress.org/plugins/seo-booster-ultimate/", + "icon": "link" + }, + { + "text": "Download", + "url": "https://downloads.wordpress.org/plugin/seo-booster-ultimate.latest-stable.zip", + "icon": "download-alt" + } + ], + "tone": "error", + "status": { + "icon": "๐", + "text": "Closed: Guideline Violation" + } + }, + { + "title": "Turbo Checkout for WooCommerce", + "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900006", + "meta": [ + { + "text": "turbo-checkout-for-woocommerce" + } + ], + "age": { + "text": "1y", + "tooltip": "Updated 2025-10-20" + }, + "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/turbo-checkout-for-woocommerce?tab=runs", + "icon": "shield" + }, + { + "text": "View on WordPress.org", + "url": "https://wordpress.org/plugins/turbo-checkout-for-woocommerce/", + "icon": "link" + }, + { + "text": "Download", + "url": "https://downloads.wordpress.org/plugin/turbo-checkout-for-woocommerce.latest-stable.zip", + "icon": "download-alt" + } + ], + "tone": "error", + "status": { + "icon": "โข๏ธ", + "text": "Disabled: Licensing/Trademark Violation" + } + }, + { + "title": "Smart AI Page Builder", + "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900007", + "meta": [ + { + "text": "rejected-smart-ai-page-builder-rejected" + } + ], + "age": { + "text": "2d", + "tooltip": "Updated 2026-10-03" + }, + "links": [], + "tone": "muted", + "status": { + "icon": "๐ซ", + "text": "Rejected", + "tooltip": "Duplicate (copy) of another Plugin" + } } ] }, @@ -48,17 +257,21 @@ { "title": "Block for Strava", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=308916", - "badges": [], "meta": [ { "text": "block-for-strava" - }, - { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" } ], + "age": { + "text": "2mo", + "tooltip": "Updated 2026-08-21" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/block-for-strava?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/block-for-strava/", @@ -74,17 +287,21 @@ { "title": "Change From Address", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=183755", - "badges": [], "meta": [ { "text": "change-from-address" - }, - { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" } ], + "age": { + "text": "2mo", + "tooltip": "Updated 2026-08-21" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/change-from-address?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/change-from-address/", @@ -100,17 +317,21 @@ { "title": "Clicky Popular Posts Widget", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=15101", - "badges": [], "meta": [ { "text": "clicky-popular-posts-widget" - }, - { - "text": "Updated 12 years ago", - "tooltip": "2014-09-04" } ], + "age": { + "text": "12y", + "tooltip": "Updated 2014-09-04" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/clicky-popular-posts-widget?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/clicky-popular-posts-widget/", @@ -126,17 +347,21 @@ { "title": "Jetpack Twitter Via", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=20510", - "badges": [], "meta": [ { "text": "jetpack-twitter-via" - }, - { - "text": "Updated 14 years ago", - "tooltip": "2012-11-26" } ], + "age": { + "text": "14y", + "tooltip": "Updated 2012-11-26" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/jetpack-twitter-via?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/jetpack-twitter-via/", @@ -152,17 +377,21 @@ { "title": "Shiny Updates", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=40", - "badges": [], "meta": [ { "text": "shiny-updates" - }, - { - "text": "Updated 10 years ago", - "tooltip": "2016-09-27" } ], + "age": { + "text": "10y", + "tooltip": "Updated 2016-09-27" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/shiny-updates?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/shiny-updates/", @@ -178,17 +407,21 @@ { "title": "Twenty Eleven Showcase Slider", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=18855", - "badges": [], "meta": [ { "text": "twenty-eleven-showcase-slider" - }, - { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" } ], + "age": { + "text": "2mo", + "tooltip": "Updated 2026-08-21" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/twenty-eleven-showcase-slider?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/twenty-eleven-showcase-slider/", @@ -204,17 +437,21 @@ { "title": "Uploads Unleashed", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=278625", - "badges": [], "meta": [ { "text": "uploads-unleashed" - }, - { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" } ], + "age": { + "text": "2mo", + "tooltip": "Updated 2026-08-21" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/uploads-unleashed?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/uploads-unleashed/", @@ -230,17 +467,21 @@ { "title": "WP Approve User", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=16677", - "badges": [], "meta": [ { "text": "wp-approve-user" - }, - { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" } ], + "age": { + "text": "2mo", + "tooltip": "Updated 2026-08-21" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-approve-user?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/wp-approve-user/", @@ -256,17 +497,21 @@ { "title": "WP Author Slug", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=149", - "badges": [], "meta": [ { "text": "wp-author-slug" - }, - { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" } ], + "age": { + "text": "2mo", + "tooltip": "Updated 2026-08-21" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-author-slug?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/wp-author-slug/", @@ -282,17 +527,21 @@ { "title": "WP Display Header", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=12688", - "badges": [], "meta": [ { "text": "wp-display-header" - }, - { - "text": "Updated 1 month ago", - "tooltip": "2026-09-02" } ], + "age": { + "text": "1mo", + "tooltip": "Updated 2026-09-02" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-display-header?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/wp-display-header/", @@ -308,17 +557,21 @@ { "title": "WP Last Login", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=13408", - "badges": [], "meta": [ { "text": "wp-last-login" - }, - { - "text": "Updated 3 weeks ago", - "tooltip": "2026-09-16" } ], + "age": { + "text": "3w", + "tooltip": "Updated 2026-09-16" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-last-login?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/wp-last-login/", @@ -334,17 +587,21 @@ { "title": "WP Search Suggest", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=13050", - "badges": [], "meta": [ { "text": "wp-search-suggest" - }, - { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" } ], + "age": { + "text": "2mo", + "tooltip": "Updated 2026-08-21" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-search-suggest?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/wp-search-suggest/", @@ -360,17 +617,21 @@ { "title": "WPMU Author Description", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=17940", - "badges": [], "meta": [ { "text": "wpmu-author-description" - }, - { - "text": "Updated 14 years ago", - "tooltip": "2012-07-12" } ], + "age": { + "text": "14y", + "tooltip": "Updated 2012-07-12" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wpmu-author-description?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/wpmu-author-description/", @@ -386,17 +647,21 @@ { "title": "WPMU Network Site Users Dropdown", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=12552", - "badges": [], "meta": [ { "text": "wpmu-network-site-users-dropdown" - }, - { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" } ], + "age": { + "text": "2mo", + "tooltip": "Updated 2026-08-21" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wpmu-network-site-users-dropdown?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/wpmu-network-site-users-dropdown/", @@ -412,22 +677,21 @@ { "title": "Arya Stark", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=48935", - "badges": [ - { - "label": "Closed", - "tone": "error" - } - ], "meta": [ { "text": "arya-stark" - }, - { - "text": "Updated 10 years ago", - "tooltip": "2016-04-27" } ], + "age": { + "text": "10y", + "tooltip": "Updated 2016-04-27" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/arya-stark?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/arya-stark/", @@ -438,27 +702,31 @@ "url": "https://downloads.wordpress.org/plugin/arya-stark.latest-stable.zip", "icon": "download-alt" } - ] + ], + "tone": "error", + "status": { + "icon": "๐", + "text": "Closed" + } }, { "title": "WP Google Suggest", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=13214", - "badges": [ - { - "label": "Closed: Author Request", - "tone": "error" - } - ], "meta": [ { "text": "wp-google-suggest" - }, - { - "text": "Updated 6 years ago", - "tooltip": "2020-11-23" } ], + "age": { + "text": "6y", + "tooltip": "Updated 2020-11-23" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-google-suggest?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/wp-google-suggest/", @@ -469,27 +737,31 @@ "url": "https://downloads.wordpress.org/plugin/wp-google-suggest.latest-stable.zip", "icon": "download-alt" } - ] + ], + "tone": "error", + "status": { + "icon": "๐ค", + "text": "Closed: Author Request" + } }, { "title": "WP Save Custom Header", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=48065", - "badges": [ - { - "label": "Closed", - "tone": "error" - } - ], "meta": [ { "text": "wp-save-custom-header" - }, - { - "text": "Updated 15 years ago", - "tooltip": "2011-05-11" } ], + "age": { + "text": "15y", + "tooltip": "Updated 2011-05-11" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-save-custom-header?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/wp-save-custom-header/", @@ -500,121 +772,120 @@ "url": "https://downloads.wordpress.org/plugin/wp-save-custom-header.latest-stable.zip", "icon": "download-alt" } - ] + ], + "tone": "error", + "status": { + "icon": "๐", + "text": "Closed" + } }, { - "title": "wp-time-background-color", - "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=64541", - "badges": [ - { - "label": "Closed: Unused", - "tone": "error" - } - ], + "title": "WP.org Site Icon", + "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=37008", "meta": [ { - "text": "wp-time-background-color" - }, - { - "text": "Updated 12 years ago", - "tooltip": "2014-12-15" + "text": "wporg-site-icon" } ], + "age": { + "text": "11y", + "tooltip": "Updated 2015-06-29" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wporg-site-icon?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", - "url": "https://wordpress.org/plugins/wp-time-background-color/", + "url": "https://wordpress.org/plugins/wporg-site-icon/", "icon": "link" }, { "text": "Download", - "url": "https://downloads.wordpress.org/plugin/wp-time-background-color.latest-stable.zip", + "url": "https://downloads.wordpress.org/plugin/wporg-site-icon.latest-stable.zip", "icon": "download-alt" } - ] + ], + "tone": "error", + "status": { + "icon": "๐", + "text": "Closed" + } }, { - "title": "WP.org Site Icon", - "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=37008", - "badges": [ - { - "label": "Closed", - "tone": "error" - } - ], + "title": "wp-time-background-color", + "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=64541", "meta": [ { - "text": "wporg-site-icon" - }, - { - "text": "Updated 11 years ago", - "tooltip": "2015-06-29" + "text": "wp-time-background-color" } ], + "age": { + "text": "12y", + "tooltip": "Updated 2014-12-15" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-time-background-color?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", - "url": "https://wordpress.org/plugins/wporg-site-icon/", + "url": "https://wordpress.org/plugins/wp-time-background-color/", "icon": "link" }, { "text": "Download", - "url": "https://downloads.wordpress.org/plugin/wporg-site-icon.latest-stable.zip", + "url": "https://downloads.wordpress.org/plugin/wp-time-background-color.latest-stable.zip", "icon": "download-alt" } - ] + ], + "tone": "error", + "status": { + "icon": "๐ค", + "text": "Closed: Unused" + } }, { "title": "Hello World Greeting", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=342576", - "badges": [ - { - "label": "Rejected", - "tone": "error" - } - ], "meta": [ { "text": "rejected-hello-world-greeting-rejected-2" - }, - { - "text": "Updated 2 months ago", - "tooltip": "2026-07-30" } ], - "links": [ - { - "text": "View on WordPress.org", - "url": "https://wordpress.org/plugins/rejected-hello-world-greeting-rejected-2/", - "icon": "link" - } - ] + "age": { + "text": "2mo", + "tooltip": "Updated 2026-07-30" + }, + "links": [], + "tone": "muted", + "status": { + "icon": "๐ซ", + "text": "Rejected" + } }, { "title": "Hello World Greeting", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=321620", - "badges": [ - { - "label": "Rejected", - "tone": "error" - } - ], "meta": [ { "text": "rejected-hello-world-greeting-rejected" - }, - { - "text": "Updated 4 months ago", - "tooltip": "2026-06-01" } ], - "links": [ - { - "text": "View on WordPress.org", - "url": "https://wordpress.org/plugins/rejected-hello-world-greeting-rejected/", - "icon": "link" - } - ] + "age": { + "text": "4mo", + "tooltip": "Updated 2026-06-01" + }, + "links": [], + "tone": "muted", + "status": { + "icon": "๐ซ", + "text": "Rejected" + } } ] }, @@ -627,20 +898,62 @@ { "type": "items", "items": [ + { + "title": "Arya Stark Testing", + "url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=111841", + "meta": [ + { + "text": "arya-stark-testing" + } + ], + "age": { + "text": "6mo", + "tooltip": "Updated 2026-03-28" + }, + "links": [], + "tone": "warning", + "status": { + "icon": "โณ", + "text": "In review or rejected" + } + }, + { + "title": "Canary Build", + "url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=132216", + "meta": [ + { + "text": "canary-build" + } + ], + "age": { + "text": "3mo", + "tooltip": "Updated 2026-07-02" + }, + "links": [], + "tone": "warning", + "status": { + "icon": "โณ", + "text": "In review or rejected" + } + }, { "title": "Test theme", "url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=10234", - "badges": [], "meta": [ { "text": "testr-2__trashed" - }, - { - "text": "Updated 6 years ago", - "tooltip": "2020-07-07" } ], + "age": { + "text": "6y", + "tooltip": "Updated 2020-07-07" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/theme/testr-2__trashed?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/themes/testr-2__trashed/", @@ -656,17 +969,21 @@ { "title": "Test theme", "url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=10235", - "badges": [], "meta": [ { "text": "testr-3__trashed" - }, - { - "text": "Updated 6 years ago", - "tooltip": "2020-07-07" } ], + "age": { + "text": "6y", + "tooltip": "Updated 2020-07-07" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/theme/testr-3__trashed?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/themes/testr-3__trashed/", @@ -682,17 +999,21 @@ { "title": "The Bootstrap", "url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=6007", - "badges": [], "meta": [ { "text": "the-bootstrap" - }, - { - "text": "Updated 1 year ago", - "tooltip": "2025-07-24" } ], + "age": { + "text": "1y", + "tooltip": "Updated 2025-07-24" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/theme/the-bootstrap?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/themes/the-bootstrap/", @@ -708,21 +1029,15 @@ { "title": "Testr", "url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=12500", - "badges": [ - { - "label": "Suspended", - "tone": "error" - } - ], "meta": [ { "text": "testr" - }, - { - "text": "Updated 6 years ago", - "tooltip": "2020-07-07" } ], + "age": { + "text": "6y", + "tooltip": "Updated 2020-07-07" + }, "links": [ { "text": "View on WordPress.org", @@ -734,59 +1049,12 @@ "url": "https://downloads.wordpress.org/theme/testr.latest-stable.zip", "icon": "download-alt" } - ] - }, - { - "title": "Arya Stark Testing", - "url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=111841", - "badges": [ - { - "label": "In Review or Rejected", - "tone": "warning" - } - ], - "meta": [ - { - "text": "arya-stark-testing" - }, - { - "text": "Updated 6 months ago", - "tooltip": "2026-03-28" - } - ], - "links": [ - { - "text": "View on WordPress.org", - "url": "https://wordpress.org/themes/arya-stark-testing/", - "icon": "link" - } - ] - }, - { - "title": "Canary Build", - "url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=132216", - "badges": [ - { - "label": "In Review or Rejected", - "tone": "warning" - } - ], - "meta": [ - { - "text": "canary-build" - }, - { - "text": "Updated 3 months ago", - "tooltip": "2026-07-02" - } ], - "links": [ - { - "text": "View on WordPress.org", - "url": "https://wordpress.org/themes/canary-build/", - "icon": "link" - } - ] + "tone": "error", + "status": { + "icon": "โ", + "text": "Suspended" + } } ] } diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/PluginController.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/PluginController.php index ecb73b04b6..3d893b6322 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/PluginController.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/PluginController.php @@ -31,7 +31,7 @@ final class PluginController extends Controller { * * @var int */ - public const MAX_PER_MINUTE = 60; + public const MAX_PER_MINUTE = 120; /** * Returns the plugin of the conversation's latest review, and the ownership check. @@ -96,7 +96,8 @@ private static function plugin( int $plugin_id, string $slug, Conversation $conv * * @param array $owner What the review says about the owner, see Review::latest(). * @param array|null $plugin The plugin as it is now. - * @return array Author, plugin, and email: whether each matches; email is null without a submitter. + * @return array Author, plugin, and email: whether each matches; null when it can't be checked, the domains without a + * username, and email without a submitter. */ private static function owner( array $owner, ?array $plugin ): array { $submitter = is_array( $plugin['submitter'] ?? null ) ? $plugin['submitter'] : null; @@ -112,8 +113,8 @@ private static function owner( array $owner, ?array $plugin ): array { } return array( - 'author' => $dns->verifies( $owner['author_host'], $username ), - 'plugin' => $dns->verifies( $owner['plugin_host'], $username ), + 'author' => '' !== $username ? $dns->verifies( $owner['author_host'], $username ) : null, + 'plugin' => '' !== $username ? $dns->verifies( $owner['plugin_host'], $username ) : null, 'email' => $email, ); } diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php new file mode 100644 index 0000000000..c3fe0066ce --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php @@ -0,0 +1,111 @@ +user(); + + // With the agent's settings for the mailbox, which its folder list shows. + $mailbox = Mailbox::findOrFailWithSettings( $mailbox_id, $viewer->id ); + if ( ! Review::is_plugins_mailbox( $mailbox ) ) { + abort( 404 ); + } + + if ( ! $viewer->can( 'viewCached', $mailbox ) || ! app( Reviewers::class )->includes( $viewer ) ) { + abort( 403 ); + } + + $reviewers = Worklist::reviewers( $mailbox ); + $user = $reviewers->firstWhere( 'id', (int) $request->query( 'user', $viewer->id ) ) ?? $viewer; + $ids = $list->section_ids( $mailbox, $user, $viewer, $reviewers ); + + $tags = array(); + $tag = 0; + if ( $list->tag_filter ) { + $requested = (int) $request->query( 'tag', 0 ); + $tags = Worklist::tags( $ids, $requested ); + if ( isset( $tags[ $requested ] ) ) { + $tag = $requested; + $ids = Worklist::filter_by_tag( $ids, $tag ); + } + } + + $sections = Worklist::load( $ids ); + $limit = OpenOrder::limit( $viewer ); + $open = array( + 'oldest' => OpenOrder::oldest_first( $sections['participating'], $limit ), + 'progress' => OpenOrder::progress_based( $sections['participating'], $limit ), + ); + $folders = $mailbox->getAssesibleFolders(); + $folder = $folders->first( array( $list, 'is_folder' ) ) ?? $list->folder( $mailbox ); + $is_viewer = (int) $user->id === (int) $viewer->id; + + // The folder list counts the agent's own active conversations, which may have changed since they were counted. + if ( $is_viewer && ! $tag ) { + $folder->active_count = count( + array_filter( + $sections['participating'], + static function ( Conversation $conversation ): bool { + return $conversation->isActive(); + } + ) + ); + } + + return view( + 'wporgpluginreview::worklist', + array( + 'list' => $list, + 'mailbox' => $mailbox, + 'folders' => $folders, + 'folder' => $folder, + 'user' => $user, + 'is_viewer' => $is_viewer, + 'reviewers' => $reviewers->contains( 'id', $viewer->id ) ? $reviewers : $reviewers->prepend( $viewer ), + 'sections' => $sections, + 'tags' => $tags, + 'tag' => $tag, + 'open' => $open, + 'limit' => $limit, + ) + ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/routes.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/routes.php index f6350a52a3..743687a874 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/routes.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/routes.php @@ -17,5 +17,14 @@ static function (): void { Route::get( '/wporgpluginreview/{conversation_id}/plugin', 'PluginController@show' ) ->where( 'conversation_id', '[0-9]+' ) ->name( 'wporgpluginreview.plugin' ); + + Route::get( '/wporgpluginreview/mailbox/{mailbox_id}/{worklist}', 'WorklistController@show' ) + ->where( + array( + 'mailbox_id' => '[0-9]+', + 'worklist' => implode( '|', \Modules\WPOrgPluginReview\Services\Worklist::slugs() ), + ) + ) + ->name( 'wporgpluginreview.worklist' ); } ); diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php index 56012353d9..0b3a40b2af 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php @@ -10,16 +10,26 @@ namespace Modules\WPOrgPluginReview\Providers; use App\Conversation; +use App\Http\Controllers\ConversationsController; +use App\Mailbox; use App\Thread; +use App\User; +use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\ServiceProvider; use Modules\WPOrgPluginReview\Console\IndexReviews; use Modules\WPOrgPluginReview\Services\FlagReplies; +use Modules\WPOrgPluginReview\Services\OpenOrder; use Modules\WPOrgPluginReview\Services\Review; use Modules\WPOrgPluginReview\Services\Reviewers; use Modules\WPOrgPluginReview\Services\Subject; +use Modules\WPOrgPluginReview\Services\Worklist; /** - * Shows a conversation's latest plugin review in its sidebar, and keeps the index of review emails as threads change. + * Shows a conversation's latest plugin review in its sidebar, and keeps the index of review emails as threads change; + * marks the plugin directory's subjects; adds the My Reviews and My Cases folders to the plugins team's mailbox, and to its members' profiles how many + * conversations those open at a time; and to their header, a button that copies the download URLs of the plugins in + * review they have open. */ final class WPOrgPluginReviewServiceProvider extends ServiceProvider { @@ -37,6 +47,13 @@ final class WPOrgPluginReviewServiceProvider extends ServiceProvider { */ private const PANEL_PRIORITY = 15; + /** + * Priority of the worklist folders' hooks: after the Teams and Custom Folders modules', which sort their folders. + * + * @var int + */ + private const FOLDERS_PRIORITY = 30; + /** * Boots the module. * @@ -78,6 +95,7 @@ static function ( mixed $styles = array() ): mixed { static function ( mixed $javascripts = array() ): mixed { if ( is_array( $javascripts ) ) { $javascripts[] = \Module::getPublicPath( self::ALIAS ) . '/js/review.js'; + $javascripts[] = \Module::getPublicPath( self::ALIAS ) . '/js/downloads.js'; } return $javascripts; @@ -158,6 +176,324 @@ static function ( mixed $conversation = null ): void { }, self::PANEL_PRIORITY ); + + self::register_subject_hooks(); + self::register_folder_hooks(); + self::register_profile_hooks(); + + // The page's script shows it once there are URLs. + \Eventy::addAction( + 'menu.append', + static function (): void { + try { + if ( app( Reviewers::class )->includes( auth()->user() ) ) { + echo \View::make( self::ALIAS . '::downloads' )->render(); + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not add the download URLs button to the header: ' . $e->getMessage() ); + } + } + ); + } + + /** + * Shows the plugin directory's subjects marked, like "๐ My Plugin", for the same agents as the panel: in the + * conversation, its tab's title, the sender's previous conversations, and lists of conversations. The subject itself + * shows on hover, in the subject's editor, and when copied. + * + * @return void + */ + private static function register_subject_hooks(): void { + // Lists ask for each row, and the answer doesn't change within a request. + $mailbox_ids = null; + $marks = static function ( mixed $conversation ) use ( &$mailbox_ids ): bool { + if ( ! $conversation instanceof Conversation ) { + return false; + } + + if ( null === $mailbox_ids ) { + $mailbox_ids = app( Reviewers::class )->includes( auth()->user() ) ? Review::plugins_mailbox_ids() : array(); + } + + return in_array( (int) $conversation->mailbox_id, $mailbox_ids, true ); + }; + + \Eventy::addAction( + 'conversation.after_subject', + static function ( mixed $conversation = null ) use ( $marks ): void { + try { + if ( $marks( $conversation ) ) { + echo \View::make( + self::ALIAS . '::subjects', + array( + 'conversation_id' => (int) $conversation->id, + 'number' => (int) $conversation->number, + 'subject' => (string) $conversation->getSubject(), + 'short_subject' => Subject::short( (string) $conversation->subject ), + 'subjects' => self::marked_subjects( $conversation ), + ) + )->render(); + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not mark the subjects of conversation ' . ( $conversation->id ?? '' ) . ': ' . $e->getMessage() ); + } + } + ); + + // The subject goes in between, hidden, for the row and FreeScout's scripts; last, so other modules' tags stay out of it. + $hidden = false; + \Eventy::addAction( + 'conversations_table.before_subject', + static function ( mixed $conversation = null ) use ( $marks, &$hidden ): void { + try { + $marked = $marks( $conversation ) ? Subject::marked( (string) $conversation->subject ) : ''; + if ( '' !== $marked ) { + $subject = e( $conversation->getSubject() ); + echo '' . e( $marked ) . ''; + $hidden = true; + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not mark the subject of conversation ' . ( $conversation->id ?? '' ) . ': ' . $e->getMessage() ); + } + }, + PHP_INT_MAX + ); + + \Eventy::addAction( + 'conversations_table.after_subject', + static function () use ( &$hidden ): void { + if ( $hidden ) { + echo ''; + $hidden = false; + } + }, + PHP_INT_MIN + ); + } + + /** + * The marked subjects of a conversation and of its sender's latest others in its mailbox, which its sidebar lists. + * + * @param Conversation $conversation Conversation. + * @return array[] Subject, and marked subject, by conversation ID; only those with a mark. + */ + private static function marked_subjects( Conversation $conversation ): array { + $conversations = array( $conversation ); + if ( $conversation->customer_id ) { + $previous = Conversation::where( 'mailbox_id', $conversation->mailbox_id ) + ->where( 'customer_id', $conversation->customer_id ) + ->where( 'id', '<>', $conversation->id ) + ->where( 'status', '!=', Conversation::STATUS_SPAM ) + ->where( 'state', Conversation::STATE_PUBLISHED ) + ->orderBy( 'created_at', 'desc' ) + ->limit( ConversationsController::PREV_CONVERSATIONS_LIMIT ) + ->get(); + $conversations = array_merge( $conversations, $previous->all() ); + } + + $subjects = array(); + foreach ( $conversations as $item ) { + $marked = Subject::marked( (string) $item->subject ); + if ( '' !== $marked ) { + $subjects[ (int) $item->id ] = array( + 'subject' => (string) $item->getSubject(), + 'marked' => $marked, + ); + } + } + + return $subjects; + } + + /** + * Lets members of the plugins team's teams choose in their profile how many conversations the worklists' Open + * buttons open at a time. + * + * @return void + */ + private static function register_profile_hooks(): void { + \Eventy::addAction( + 'user.edit.before_photo', + static function ( mixed $user = null ): void { + try { + if ( $user instanceof User && app( Reviewers::class )->includes( $user ) ) { + echo \View::make( + self::ALIAS . '::profile', + array( + 'limit' => OpenOrder::limit( $user ), + 'max' => OpenOrder::MAX_LIMIT, + ) + )->render(); + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not show how many conversations to open in a profile: ' . $e->getMessage() ); + } + } + ); + + \Eventy::addFilter( + 'user.save_profile', + static function ( mixed $user = null, mixed $request = null ): mixed { + try { + if ( $user instanceof User && $request instanceof Request && $request->has( 'wporgpluginreview_open_limit' ) && app( Reviewers::class )->includes( $user ) ) { + OpenOrder::set_limit( $user, $request->input( 'wporgpluginreview_open_limit' ) ); + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not save how many conversations to open in a profile: ' . $e->getMessage() ); + } + + return $user; + }, + 20, + 2 + ); + + \Eventy::addAction( + 'user.deleted', + static function ( mixed $user = null ): void { + try { + if ( $user instanceof User ) { + OpenOrder::forget_limit( (int) $user->id ); + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not forget how many conversations a deleted user opens: ' . $e->getMessage() ); + } + } + ); + } + + /** + * Adds the My Reviews and My Cases folders to the plugins team's mailbox, for its members. + * + * They aren't stored: they're added to the mailbox's folders as they're listed, after the Teams and Custom Folders + * modules have sorted theirs, and link to their own pages. Their counts are kept in the cache until a change may + * affect them. + * + * @return void + */ + private static function register_folder_hooks(): void { + \Eventy::addFilter( + 'mailbox.folders', + static function ( mixed $folders = null, mixed $mailbox = null ): mixed { + if ( ! $folders instanceof Collection || ! $mailbox instanceof Mailbox ) { + return $folders; + } + + try { + $user = auth()->user(); + if ( ! Review::is_plugins_mailbox( $mailbox ) || ! app( Reviewers::class )->includes( $user ) ) { + return $folders; + } + + return Worklist::add_folders( $folders, $mailbox, $user ); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not add the worklist folders to mailbox ' . $mailbox->id . ': ' . $e->getMessage() ); + + return $folders; + } + }, + self::FOLDERS_PRIORITY, + 2 + ); + + \Eventy::addFilter( + 'folder.type_name', + static function ( mixed $name = '', mixed $folder = null ): mixed { + $worklist = Worklist::of_folder( $folder ); + + return $worklist ? $worklist->name() : $name; + }, + self::FOLDERS_PRIORITY, + 2 + ); + + \Eventy::addFilter( + 'folder.type_icon', + static function ( mixed $icon = '', mixed $folder = null ): mixed { + $worklist = Worklist::of_folder( $folder ); + + return $worklist ? $worklist->icon : $icon; + }, + self::FOLDERS_PRIORITY, + 2 + ); + + \Eventy::addFilter( + 'folder.url', + static function ( mixed $url = '', mixed $mailbox_id = null, mixed $folder = null ): mixed { + $worklist = Worklist::of_folder( $folder ); + + return $worklist && is_numeric( $mailbox_id ) ? $worklist->url( (int) $mailbox_id ) : $url; + }, + self::FOLDERS_PRIORITY, + 3 + ); + + // The folders' counts change with agents' replies; only those published, removed, or moved. + foreach ( array( 'thread.created', 'thread.updated', 'thread.deleting' ) as $hook ) { + \Eventy::addAction( + $hook, + static function ( mixed $thread = null ) use ( $hook ): void { + if ( ! $thread instanceof Thread || Thread::TYPE_MESSAGE !== (int) $thread->type || ! $thread->created_by_user_id ) { + return; + } + + // Core fires it before the model takes its changes in, while they're still dirty. + if ( 'thread.updated' === $hook && ! $thread->isDirty( array( 'state', 'type', 'conversation_id' ) ) ) { + return; + } + + self::forget_counts( $thread->conversation ); + } + ); + } + + /* + * And with conversations that open, close, change hands, or are deleted, merged, or imported. An author's reply + * makes a conversation active without core's status_changed. + */ + $hooks = array( + 'conversation.customer_replied', + 'conversation.status_changed', + 'conversation.user_changed', + 'conversation.state_changed', + 'conversation.merged', + 'conversation.deleting', + 'wporghelpscoutimport.conversation_imported', + ); + foreach ( $hooks as $hook ) { + \Eventy::addAction( + $hook, + static function ( mixed $conversation = null ): void { + self::forget_counts( $conversation ); + } + ); + } + + \Eventy::addAction( + 'conversation.moved', + static function ( mixed $conversation = null, mixed $user = null, mixed $prev_mailbox = null ): void { + self::forget_counts( $conversation ); + + if ( $prev_mailbox instanceof Mailbox ) { + Worklist::forget_counts( (int) $prev_mailbox->id ); + } + }, + 20, + 3 + ); + } + + /** + * Forgets the worklists' counts of a conversation's mailbox. + * + * @param mixed $conversation Conversation. + * @return void + */ + private static function forget_counts( mixed $conversation ): void { + if ( $conversation instanceof Conversation && $conversation->mailbox_id ) { + Worklist::forget_counts( (int) $conversation->mailbox_id ); + } } /** @@ -184,8 +520,6 @@ private static function render_panel( Conversation $conversation ): void { 'review' => $review, 'replies' => FlagReplies::REPLIES, 'conversation_id' => (int) $conversation->id, - 'subject' => (string) $conversation->getSubject(), - 'short_subject' => Subject::short( (string) $conversation->subject ), ) )->render(); } catch ( \Throwable $e ) { diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css index 7ff2687c77..94d6a70d38 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css @@ -1,5 +1,6 @@ /** - * Styles the Plugin Review panel in the conversation sidebar. + * Styles the Plugin Review panel in the conversation sidebar, the buttons it adds to the Plugins & Themes panel, and the + * My Reviews and My Cases folders. * * Text colors meet WCAG AAA (7:1) on the panel background. */ @@ -66,29 +67,53 @@ list-style: none; } +/* Flags without a color of their own are orange. */ .wporg-review-flag { padding: 0 8px; border-radius: 9px; - background-color: #eef1f4; - color: #3e4a55; + background-color: #ff8918; + color: #fff; font-size: 11px; font-weight: 600; line-height: 20px; } .wporg-review-flag[data-flag="UPD"] { - background-color: #fce4e4; - color: #8c1616; + background-color: #dc2626; } .wporg-review-flag[data-flag="TRM"] { - background-color: #fdf1d4; - color: #634200; + background-color: #f59e0b; } .wporg-review-flag[data-flag="OWN"] { - background-color: #f3eadb; - color: #5a4410; + background-color: #8c6e1e; +} + +.wporg-review-flag[data-flag="DESC"] { + background-color: #4f3d0c; +} + +.wporg-review-flag[data-flag="LIC"] { + background-color: #a72e9e; +} + +.wporg-review-flag[data-flag="COM"] { + background-color: #0c4f4d; +} + +.wporg-review-flag[data-flag="FUN"], +.wporg-review-flag[data-flag="ACT"] { + background-color: #1d4ed8; +} + +.wporg-review-flag[data-flag="RMT"] { + background-color: #b91c1c; +} + +.wporg-review-flag[data-flag="SOCK"] { + background-color: #ffcafe; + color: #444; } .wporg-review-flag .wporg-review-copy { @@ -114,7 +139,8 @@ padding: 0; border: 0; background: none; - color: #005ba3; + color: #4a5561; + font-weight: 400; text-align: left; } @@ -128,7 +154,7 @@ } .wporg-review-issue.is-priority-0 { - color: #4a5561; + opacity: 0.6; } /* Where an issue the panel jumped to starts, for a moment. */ @@ -142,15 +168,41 @@ color: #4a5561; } -/* The current name is the one the review suggested, or still the one it asked to change. */ -.wporg-review-names.is-suggested dd:last-child { - color: #175a28; - font-weight: 600; +/* Names and Owner: a section of their own, colored by whether the author followed the review. */ +.wporg-review-check { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #d6dde3; } -.wporg-review-names.is-original dd:last-child { - color: #8c1616; - font-weight: 600; +.wporg-review-check .wporg-review-heading { + margin-top: 0; + padding-top: 0; + border-top: 0; +} + +.wporg-review-check.is-followed, +.wporg-review-check.is-ignored, +.wporg-review-check.is-to-review { + padding: 8px 10px; + border: 1px solid; + border-left-width: 4px; + border-radius: 4px; +} + +.wporg-review-check.is-followed { + border-color: #00a32a; + background-color: #edfaef; +} + +.wporg-review-check.is-ignored { + border-color: #d63638; + background-color: #fcf0f1; +} + +.wporg-review-check.is-to-review { + border-color: #dba617; + background-color: #fcf9e8; } .wporg-review-email { @@ -161,28 +213,294 @@ color: #8c1616; } -/* Switches the tab between its short and full title. */ -.wporg-review-title-toggle { +/* The warning above the reply editor, for a Review ID of another author. */ +.wporg-review-mismatch .btn { + margin-left: 4px; +} + +.wporg-review-email.is-match { + color: #175a28; +} + +/* + * The My Reviews and My Cases folders: their own table, like the conversation lists, rather than theirs, which core's + * scripts reload with a folder's conversations. + */ + +/* In the folder list, they're with Unassigned and Mine, apart from the rest: Mine comes right after them. */ +#folders > li[data-folder_id="-229"] + li:not([data-folder_id="-230"]), +#folders > li[data-folder_id="-230"] + li { + margin-bottom: 6px; + padding-bottom: 6px; + border-bottom: 1px solid #e3e8eb; +} + +.wporg-worklist-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 12px 14px; + border-bottom: 1px solid #e3e8eb; +} + +.wporg-worklist-toolbar label { + margin: 0; +} + +.wporg-worklist-toolbar .wporg-worklist-filter { + width: auto; + max-width: 100%; +} + +.wporg-worklist-scope { + font-size: 13px; +} + +.wporg-worklist-section + .wporg-worklist-section { + border-top: 1px solid #d6dde3; +} + +.wporg-worklist-section-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 6px 12px; + padding: 18px 14px 2px; +} + +.wporg-worklist-heading { + margin: 0; + color: #2a3b47; + font-size: 16px; + font-weight: 700; +} + +.wporg-worklist-count { + margin-left: 4px; + padding: 1px 7px; + border-radius: 9999px; + background-color: #eef1f4; + color: #3e4a55; + font-size: 12px; + vertical-align: 2px; +} + +.wporg-worklist-description { + margin: 0; + padding: 0 14px 10px; + font-size: 13px; +} + +.wporg-worklist-empty { + margin: 0; + padding: 0 14px 18px; + color: #4a5561; +} + +.table.wporg-worklist-table { + margin: 0; + table-layout: fixed; +} + +.wporg-worklist-table thead { + background-color: #deecf9; +} + +.table.wporg-worklist-table th { + padding: 4px 14px 1px; + border-bottom: 1px solid #e3e8eb; + color: #2a3b47; + white-space: nowrap; +} + +.table.wporg-worklist-table td { + padding: 0; + border-top: 0; + border-bottom: 1px solid #e3e8eb; + vertical-align: middle; +} + +.wporg-worklist-table td a { display: block; - margin-top: 12px; + padding: 12px 14px; + color: #2a3b47; + font-size: 14px; +} + +.wporg-worklist-table td a:hover, +.wporg-worklist-table td a:focus { + text-decoration: none; +} + +.wporg-worklist-table tr:hover td { + background-color: #fbfcfd; +} + +/* conv-subject is for the Tags module's scripts; core's lists move it around on phones. */ +.wporg-worklist .wporg-worklist-table .conv-subject { + position: static !important; + padding-right: 0 !important; + padding-left: 0 !important; +} + +/* Active conversations stand out, like in core's lists. */ +.wporg-worklist-table tr.is-active td { + background-color: #f4f8fd; + font-weight: 700; +} + +/* The Open buttons, and the conversations one would open while it's hovered or focused, numbered in order. */ +.wporg-worklist-open { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.wporg-worklist-open-count, +.wporg-worklist-status { + padding: 0 6px; + border-radius: 9px; + background-color: #eef1f4; + color: #3e4a55; + font-size: 11px; + font-weight: 600; +} + +.wporg-worklist-open-count { + margin-left: 2px; +} + +.wporg-worklist-open-limit { + color: #4a5561; + font-size: 12px; +} + +.wporg-worklist-table tr.is-to-open td { + background-color: #fff6bf; +} + +.wporg-worklist-table tr.is-to-open td:first-child { + box-shadow: inset 4px 0 0 #b38a00; +} + +.wporg-worklist-open-order { + display: inline-block; + min-width: 20px; + margin-right: 6px; + padding: 0 6px; + border-radius: 10px; + background-color: #2a3b47; + color: #fff; + font-size: 11px; + font-weight: 700; + line-height: 18px; + text-align: center; +} + +.wporg-worklist-sender { + width: 190px; +} + +.wporg-worklist-cb { + width: 40px; + text-align: center; +} + +.table.wporg-worklist-table .wporg-worklist-cb { + padding: 4px 0; +} + +/* Core's checkboxes draw their box at the left of their label, which its own lists move into place. */ +.wporg-worklist-table .conv-cb label { + left: 10px; +} + +.wporg-worklist-threads { + width: 84px; + text-align: center; +} + +.wporg-worklist-threads-count { + padding: 0.1em 0.5em; + border: 1px solid #d6dde3; + border-radius: 4px; + background: #f8f9f9; + color: #4a5561; + font-size: 12px; + font-weight: 400; +} + +.wporg-worklist-date { + width: 138px; +} + +.wporg-worklist-name, +.wporg-worklist-email, +.wporg-worklist-title, +.wporg-worklist-preview { + display: block; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wporg-worklist-email, +.wporg-worklist-preview { + color: #4a5561; + font-size: 12px; + font-weight: 400; +} + +.wporg-worklist-preview { + margin-top: 2px; + font-size: 13px; +} + +.wporg-worklist-status { + margin-right: 4px; +} + +@media (max-width: 767px) { + .wporg-worklist-table thead, + .wporg-worklist-table .wporg-worklist-threads { + display: none; + } + + /* Core hides the checkboxes of its lists on phones, which select by a long press instead. */ + .wporg-worklist .wporg-worklist-table .wporg-worklist-cb { + display: table-cell !important; + } + + .wporg-worklist-sender { + width: 35%; + } + + .wporg-worklist-date { + width: 90px; + } +} + +/* In the sidebar's Plugins & Themes panel, after a plugin's download link: adds its URL to the ones the header copies, or removes it. */ +.wporg-downloads-toggle { padding: 0; border: 0; background: none; color: #4a5561; - font-size: 11px; + font-size: inherit; + line-height: 1; } -.wporg-review-title-toggle:hover, -.wporg-review-title-toggle:focus { +.wporg-downloads-toggle[aria-pressed="true"], +.wporg-downloads-toggle:hover, +.wporg-downloads-toggle:focus { color: #0078d7; - text-decoration: underline; } -/* The warning above the reply editor, for a Review ID of another author. */ -.wporg-review-mismatch .btn { - margin-left: 4px; -} - -.wporg-review-email.is-match { - color: #175a28; +/* In the profile: how many conversations the Open buttons open at a time. */ +.wporg-open-limit { + max-width: 100px; } diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/downloads.js b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/downloads.js new file mode 100644 index 0000000000..6ecfca4e59 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/downloads.js @@ -0,0 +1,537 @@ +/** + * Collects the download URLs of the plugins in review that the agent has open, in the order they opened them, for the + * header's button that copies them all, to review the plugins in bulk with a local tool. + * + * Each page keeps its plugin's URL in the browser's storage, under a key of its own, so that pages opening at once + * don't write over each other's, and removes it as it closes. It also holds a lock of the same name for as long as it's + * open, which the browser lets go of however the page ends, even when it crashes, or closes without saying so: URLs + * whose page doesn't hold its lock anymore are left out, and removed. Pages the worklists' Open buttons opened go in + * the order they were opened, which their URL tells; the others in the order they loaded. + * + * The sidebar's Plugins & Themes panel gets a button next to each plugin's download link, which adds its URL, or + * removes it when it's there. Added URLs stay, after the others, until they're removed: no page holds them. + * + * @param {jQuery} $ jQuery. + */ +( function ( $ ) { + /** + * What the keys of the pages' URLs start with, in the browser's storage; their locks are named the same. + * + * @type {string} + */ + const PREFIX = 'wporgpluginreview.download.'; + + /** + * What the keys of the URLs added from the sidebar start with, followed by the URL, so each is there once. + * + * @type {string} + */ + const ADDED = PREFIX + 'added.'; + + /** + * What the keys of the conversations' places in the order start with, in the tab's storage, followed by their path, + * so a page keeps its place when it's reloaded. + * + * @type {string} + */ + const PLACES = 'wporgpluginreview.download-place.'; + + /** + * The parameters the worklists' Open buttons add to the conversations they open, as review.js's OPEN_PARAMS. + * + * @type {Object} + */ + const OPEN_PARAMS = { + batch: 'wporg_open_batch', + order: 'wporg_open_order', + }; + + /** + * How long "Copied" shows, in milliseconds. + * + * @type {number} + */ + const COPIED_FOR = 1200; + + /** + * How often a visible page checks for pages that closed without saying so, in milliseconds. + * + * @type {number} + */ + const CHECK_EVERY = 5000; + + /** + * How long a page waits for other pages' changes to settle before it shows them, in milliseconds, so that many + * pages opening at once don't each refresh for every other. + * + * @type {number} + */ + const SETTLE_FOR = 250; + + /** + * This page's key, different for every page that opens, even of the same conversation. + * + * @type {string} + */ + const PAGE_KEY = + PREFIX + + Date.now().toString( 36 ) + + Math.random().toString( 36 ).slice( 2 ); + + /** + * This page's URL and place, once its plugin is known. + * + * @type {Object|null} + */ + let entry = null; + + /** + * Whether this page holds its lock. + * + * @type {boolean} + */ + let locked = false; + + /** + * The URLs the header shows, which are the ones it copies. + * + * @type {string[]} + */ + let shown = []; + + /** + * Until when the header says they were copied, as a time in milliseconds. + * + * @type {number} + */ + let copiedUntil = 0; + + /** + * The refresh waiting for other pages' changes to settle. + * + * @type {number} + */ + let settling = 0; + + /** + * One of the browser's storages, if the page may use it. + * + * @param {string} name localStorage or sessionStorage. + * @return {Storage|null} Storage. + */ + function storage( name ) { + try { + return window[ name ] || null; + } catch { + return null; + } + } + + /** + * The browser's locks, which only secure pages have. + * + * @return {LockManager|null} Locks. + */ + function locks() { + const manager = window.navigator.locks; + + return manager && manager.request && manager.query ? manager : null; + } + + /** + * Where this page goes in the order: when the Open button that opened it was clicked, and in which position, or + * else when it loaded; first time this conversation loaded in this tab, so a reload keeps its place. + * + * @return {Object} Batch, a time in milliseconds, and order in it; 0 for pages that weren't opened together. + */ + function place() { + const params = new URLSearchParams( window.location.search ); + const batch = Number( params.get( OPEN_PARAMS.batch ) ); + const order = Number( params.get( OPEN_PARAMS.order ) ); + const session = storage( 'sessionStorage' ); + const key = PLACES + window.location.pathname; + + let found = batch > 0 && order > 0 ? { batch, order } : null; + if ( ! found && session ) { + try { + found = JSON.parse( session.getItem( key ) || 'null' ); + } catch { + found = null; + } + } + if ( ! found || ! ( Number( found.batch ) > 0 ) ) { + found = { batch: Date.now(), order: 0 }; + } + + try { + session.setItem( key, JSON.stringify( found ) ); + } catch { + // Then a reload puts it last. + } + + return { batch: Number( found.batch ), order: Number( found.order ) }; + } + + /** + * The pages' URLs and places, as kept. + * + * @return {Object[]} Each with its key. + */ + function entries() { + const local = storage( 'localStorage' ); + const found = []; + if ( ! local ) { + return found; + } + + for ( let i = 0; i < local.length; i++ ) { + const key = local.key( i ); + if ( ! key || ! key.startsWith( PREFIX ) ) { + continue; + } + + try { + const item = JSON.parse( local.getItem( key ) || 'null' ); + if ( item && 'string' === typeof item.url && item.url ) { + found.push( { ...item, key } ); + } + } catch { + // Not one of ours. + } + } + + return found; + } + + /** + * The ones whose page is still open, removing the others. + * + * @param {Object[]} found Entries. + * @return {Promise} Entries. + */ + function open( found ) { + const manager = locks(); + if ( ! manager ) { + return Promise.resolve( found ); + } + + return manager + .query() + .then( ( state ) => { + const held = new Set( + ( state.held || [] ).map( ( lock ) => lock.name ) + ); + const local = storage( 'localStorage' ); + + return found.filter( ( item ) => { + if ( item.added || item.unlocked || held.has( item.key ) ) { + return true; + } + if ( local ) { + local.removeItem( item.key ); + } + return false; + } ); + } ) + .catch( () => found ); + } + + /** + * Updates the header's buttons: shown once there are URLs, with how many. + */ + function refresh() { + const $items = $( '.wporg-downloads' ); + if ( ! $items.length ) { + return; + } + + open( entries() ).then( ( found ) => { + found.sort( + ( a, b ) => + Number( a.batch ) - Number( b.batch ) || + Number( a.order ) - Number( b.order ) || + Number( a.at ) - Number( b.at ) + ); + shown = [ ...new Set( found.map( ( item ) => item.url ) ) ]; + + const strings = $items.first().data( 'strings' ) || {}; + let label = String( + ( 1 === shown.length ? strings.one : strings.many ) || '' + ).replace( ':count', String( shown.length ) ); + if ( Date.now() < copiedUntil ) { + label = String( strings.copied || label ); + } + + $items.toggleClass( 'hidden', ! shown.length ); + $items.find( '.wporg-downloads-label' ).text( label ); + updateToggles(); + } ); + } + + /** + * A URL as the browser writes it, to compare it with others. + * + * @param {string} url URL. + * @return {string} URL. + */ + function normalize( url ) { + try { + return new URL( String( url ) ).href; + } catch { + return String( url ); + } + } + + /** + * Whether a URL is a plugin's ZIP: a release, or an upload for review. + * + * @param {string} url URL. + * @return {boolean} Whether it is. + */ + function isPluginZip( url ) { + try { + const parsed = new URL( String( url ) ); + + return ( + ( 'downloads.wordpress.org' === parsed.hostname && + parsed.pathname.startsWith( '/plugin/' ) ) || + ( 'wordpress.org' === parsed.hostname && + parsed.pathname.startsWith( '/plugins/' ) ) + ); + } catch { + return false; + } + } + + /** + * Shows on each of the sidebar's buttons whether its URL is in the list: a minus to remove it, or a plus to add it. + */ + function updateToggles() { + const strings = $( '.wporg-downloads' ).first().data( 'strings' ) || {}; + const listed = new Set( shown.map( normalize ) ); + + $( '.wporg-downloads-toggle' ).each( function () { + const $toggle = $( this ); + const there = listed.has( normalize( $toggle.attr( 'data-url' ) ) ); + const label = String( + ( there ? strings.remove : strings.add ) || '' + ); + + $toggle + .attr( { + 'aria-pressed': String( there ), + title: label, + 'aria-label': label, + } ) + .find( '.glyphicon' ) + .toggleClass( 'glyphicon-minus', there ) + .toggleClass( 'glyphicon-plus', ! there ); + } ); + } + + /** + * Adds a URL from the sidebar, after the others. + * + * @param {string} url URL. + */ + function add( url ) { + const local = storage( 'localStorage' ); + if ( ! local ) { + return; + } + + try { + local.setItem( + ADDED + url, + JSON.stringify( { + url, + batch: Date.now(), + order: 0, + at: Date.now(), + added: true, + } ) + ); + } catch { + // Then it isn't in the list. + } + } + + /** + * Removes a URL, however it got in the list: added, or kept by a page, this one's or another's, which won't put it + * back until it loads again. + * + * @param {string} url URL. + */ + function remove( url ) { + const local = storage( 'localStorage' ); + if ( ! local ) { + return; + } + + const removing = normalize( url ); + entries() + .filter( ( item ) => normalize( item.url ) === removing ) + .forEach( ( item ) => local.removeItem( item.key ) ); + } + + /** + * Keeps this page's URL, while it holds its lock, or, if it can't get one, until it closes. + */ + function keep() { + const local = storage( 'localStorage' ); + const store = ( held ) => { + if ( entry && local ) { + try { + local.setItem( + PAGE_KEY, + JSON.stringify( + held ? entry : { ...entry, unlocked: true } + ) + ); + } catch { + // Then it isn't in the list. + } + } + refresh(); + }; + const manager = locks(); + + if ( ! manager || locked ) { + store( locked ); + return; + } + + // Held until the page ends: the promise never settles. + manager + .request( PAGE_KEY, () => { + locked = true; + store( true ); + return new Promise( () => {} ); + } ) + .catch( () => store( false ) ); + } + + /** + * Removes this page's URL, as it closes. + */ + function forget() { + const local = storage( 'localStorage' ); + if ( local ) { + local.removeItem( PAGE_KEY ); + } + } + + // The Plugin Review panel tells the plugin once WordPress.org did, which may take a while. + $( document ).on( 'wporgpluginreview:plugin', ( event, plugin ) => { + if ( ! plugin || ! plugin.download_url ) { + return; + } + + entry = { + url: String( plugin.download_url ), + ...place(), + at: Date.now(), + }; + keep(); + } ); + + // Closing the page, or leaving it, removes its URL; coming back to it, as with the Back button, puts it back. + window.addEventListener( 'pagehide', forget ); + window.addEventListener( 'pageshow', ( event ) => { + if ( event.persisted && entry ) { + keep(); + } + } ); + + // Other tabs' changes. + window.addEventListener( 'storage', ( event ) => { + if ( null === event.key || String( event.key ).startsWith( PREFIX ) ) { + clearTimeout( settling ); + settling = setTimeout( refresh, SETTLE_FOR ); + } + } ); + + // Copies them in quotes, separated by pipes, as the local tool reads them. + $( document ).on( 'click', '.wporg-downloads-copy', ( event ) => { + event.preventDefault(); + + const clipboard = window.navigator.clipboard; + if ( ! clipboard || ! shown.length ) { + return; + } + + clipboard + .writeText( '"' + shown.join( '|' ) + '"' ) + .then( () => { + copiedUntil = Date.now() + COPIED_FOR; + refresh(); + setTimeout( refresh, COPIED_FOR ); + } ) + .catch( () => {} ); + } ); + + // The sidebar's Plugins & Themes panel: a button after each plugin's download link, for the agents with the header's. + $( document ).on( 'wporgsidebar:panel', ( event ) => { + if ( ! $( '.wporg-downloads' ).length ) { + return; + } + + $( event.target ) + .find( '.wporg-sidebar-item-actions a' ) + .filter( function () { + return ( + $( this ).find( '.glyphicon-download-alt' ).length > 0 && + isPluginZip( this.href ) + ); + } ) + .after( function () { + return $( + '' + ) + .attr( 'data-url', this.href ) + .append( '' ); + } ); + + updateToggles(); + } ); + + // Adds the plugin's URL, or removes it if it's there. + $( document ).on( 'click', '.wporg-downloads-toggle', function ( event ) { + event.preventDefault(); + + const url = String( $( this ).attr( 'data-url' ) ); + if ( 'true' === $( this ).attr( 'aria-pressed' ) ) { + remove( url ); + } else { + add( url ); + } + refresh(); + } ); + + // Forgets them all, the open pages' too. + $( document ).on( 'click', '.wporg-downloads-clear', ( event ) => { + event.preventDefault(); + + const local = storage( 'localStorage' ); + if ( local ) { + Object.keys( local ) + .filter( ( key ) => key.startsWith( PREFIX ) ) + .forEach( ( key ) => local.removeItem( key ) ); + } + refresh(); + } ); + + $( () => { + if ( ! $( '.wporg-downloads' ).length ) { + return; + } + + refresh(); + setInterval( () => { + if ( 'visible' === document.visibilityState ) { + refresh(); + } + }, CHECK_EVERY ); + document.addEventListener( 'visibilitychange', refresh ); + } ); +} )( jQuery ); diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js index 839e3149a5..2a87f881f1 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js @@ -1,6 +1,8 @@ /** * Makes the Plugin Review panel work: copying, jumping to issues, loading the plugin as it is now, flagging and - * shortening the tab's title, and warning about a reply with another author's Review ID. + * shortening the tab's title, and warning about a reply with another author's Review ID. Marks the plugin directory's + * subjects, and copies the subjects themselves. Also shows the member and tag chosen in the My Reviews and My Cases + * folders, and opens their conversations in new tabs. * * @param {jQuery} $ jQuery. */ @@ -23,13 +25,6 @@ */ const COPIED_FOR = 1200; - /** - * Where the browser keeps whether the agent prefers the tab's full title. - * - * @type {string} - */ - const FULL_TITLE_KEY = 'wporgpluginreview.fullTitle'; - /** * A date, as Review IDs write them, like 29Jul26. * @@ -65,6 +60,30 @@ */ const FLAG_MARK = '\u2757'; + /** + * How many times the plugin is asked for again when the agent asked for too many in a minute, as when many tabs + * open at once, and how long to wait before each, in milliseconds: longer every time, and a little more at random, + * so the tabs don't all ask again at once. + * + * @type {Object} + */ + const RETRY = { + times: 3, + after: 20000, + jitter: 10000, + }; + + /** + * The parameters the worklists' Open buttons add to the conversations they open: when they were opened, and in + * which position, so their plugins' download URLs keep that order. + * + * @type {Object} + */ + const OPEN_PARAMS = { + batch: 'wporg_open_batch', + order: 'wporg_open_order', + }; + /** * Copies text, and HTML if there is any, to the clipboard. * @@ -244,7 +263,8 @@ } /** - * Shows the plugin as it is now: its status, ZIP, and page, its current name, and who submitted it. + * Shows the plugin as it is now: its status, ZIP, and page, and its current name, colored by whether it's the one the + * review suggested. * * @param {jQuery} $panel Panel. * @param {Object} plugin The plugin. @@ -324,18 +344,26 @@ const $names = $panel.find( '.wporg-review-names' ); if ( $names.length && plugin.name ) { const current = normalizeName( plugin.name ); - // The suggested name wins, should it be the original too. - const suggested = - current === normalizeName( $names.attr( 'data-suggested' ) ); + const suggested = normalizeName( $names.attr( 'data-suggested' ) ); + const slug = String( $names.attr( 'data-suggested-slug' ) || '' ); + + // The suggested name, or slug when there's no name, wins should it be the original too. + const followed = suggested + ? current === suggested + : '' !== slug && slug === String( plugin.slug ); + let state = 'to-review'; + if ( followed ) { + state = 'followed'; + } else if ( + current === normalizeName( $names.attr( 'data-original' ) ) + ) { + state = 'ignored'; + } $names - .toggleClass( 'is-suggested', suggested ) - .toggleClass( - 'is-original', - ! suggested && - current === - normalizeName( $names.attr( 'data-original' ) ) - ); - addRow( $names, strings.current, [ + .addClass( 'is-' + state ) + .attr( 'title', strings[ 'names_' + state ] ); + + addRow( $names.find( '.wporg-review-details' ), strings.current, [ String( plugin.name ), $( '' ), $( '' ).text( String( plugin.slug ) ), @@ -345,14 +373,26 @@ } /** - * Shows whether the plugin's domains carry the author's record, and whether the submitter's address is at one. + * Shows whether the plugin's domains carry the author's record, and whether the submitter's address is at one, and + * marks the check when all were checked and none proves they own it. * - * @param {jQuery} $owner The owner details. + * @param {jQuery} $owner The owner check. * @param {Object} owner The check. * @param {Object|null} plugin The plugin, if known. * @param {Object} strings Labels. */ function showOwner( $owner, owner, plugin, strings ) { + // Any of them proves it; unknown ones, null, weren't checked. + if ( + [ 'author', 'plugin', 'email' ].every( + ( key ) => false === owner[ key ] + ) + ) { + $owner + .addClass( 'is-ignored' ) + .attr( 'title', strings.owner_ignored ); + } + [ 'author', 'plugin' ].forEach( ( key ) => { if ( true === owner[ key ] ) { $owner @@ -366,7 +406,7 @@ } ); if ( plugin && plugin.submitter ) { - addRow( $owner, strings.submitter, [ + addRow( $owner.find( '.wporg-review-details' ), strings.submitter, [ String( plugin.submitter.username ), $( '' ), $( '' ) @@ -388,8 +428,9 @@ * @param {jQuery} $panel Panel. * @param {Object} strings Labels. * @param {function(Object): void} onPlugin Called with the plugin, once it's known. + * @param {number} attempt How many times it was asked for already. */ - function loadPlugin( $panel, strings, onPlugin ) { + function loadPlugin( $panel, strings, onPlugin, attempt = 0 ) { $.getJSON( String( $panel.data( 'url' ) ) ) .done( ( result ) => { const plugin = result && result.plugin ? result.plugin : null; @@ -408,6 +449,17 @@ } } ) .fail( ( xhr ) => { + if ( 429 === xhr.status && attempt < RETRY.times ) { + const next = attempt + 1; + const wait = + next * RETRY.after + Math.random() * RETRY.jitter; + + setTimeout( () => { + loadPlugin( $panel, strings, onPlugin, next ); + }, wait ); + return; + } + addRow( $panel.find( '.wporg-review-plugin' ), strings.status, [ $( '' ).text( 429 === xhr.status ? strings.throttled : strings.failed @@ -417,62 +469,28 @@ } /** - * Reads the agent's preference for the tab's title. - * - * @return {boolean} Whether they want the full title. - */ - function prefersFullTitle() { - try { - return '1' === window.localStorage.getItem( FULL_TITLE_KEY ); - } catch { - return false; - } - } - - /** - * Keeps the agent's preference for the tab's title. + * Puts the marks of the review's flags before the tab's title, its subject's short version in it without the + * conversation's number, and keeps it that way as FreeScout changes it, like when it counts new replies. * - * @param {boolean} full Whether they want the full title. + * @param {jQuery} $data What the page says about the conversation's subject. + * @param {string[]} flags Flags. */ - function preferFullTitle( full ) { - try { - if ( full ) { - window.localStorage.setItem( FULL_TITLE_KEY, '1' ); - } else { - window.localStorage.removeItem( FULL_TITLE_KEY ); - } - } catch { - // Then it's only for this page. + function manageTitle( $data, flags ) { + const subject = String( $data.attr( 'data-subject' ) || '' ); + const number = String( $data.attr( 'data-number' ) || '' ); + if ( ! subject || ! number ) { + return; } - } - /** - * Puts the marks of the review's flags before the tab's title, shortens the subject in it, and keeps it that way as - * FreeScout changes it, like when it counts new replies. - * - * @param {jQuery} $panel Panel. - * @param {string[]} flags Flags. - * @param {Object} strings Labels. - */ - function manageTitle( $panel, flags, strings ) { const marks = Object.keys( TITLE_MARKS ) .filter( ( flag ) => flags.includes( flag ) ) .map( ( flag ) => TITLE_MARKS[ flag ] ) .join( '' ); - const subject = String( $panel.attr( 'data-subject' ) || '' ); - let shortSubject = String( $panel.attr( 'data-short-subject' ) || '' ); - - // Shortening only goes one way: a short subject that holds the full one could never be told from it. - if ( ! subject || shortSubject.includes( subject ) ) { - shortSubject = ''; - } - if ( ! marks && ! shortSubject ) { - return; - } - - const $toggle = $panel.find( '.wporg-review-title-toggle' ); - let full = prefersFullTitle(); + // FreeScout's title starts with the number and the subject; the short one, with the short subject alone. + const long = '#' + number + ' ' + subject; + const short = + String( $data.attr( 'data-short-subject' ) || '' ) || subject; // FreeScout's count of new replies goes first, then the marks. const prefix = new RegExp( @@ -485,14 +503,8 @@ const match = document.title.match( prefix ); let rest = document.title.slice( match[ 0 ].length ); - /* - * Each way only replaces what the other way leaves, so another pass changes nothing: the short subject may be - * part of the full one, but not the other way around. - */ - if ( shortSubject && full && ! rest.includes( subject ) ) { - rest = rest.replace( shortSubject, subject ); - } else if ( shortSubject && ! full ) { - rest = rest.replace( subject, shortSubject ); + if ( rest.startsWith( long ) ) { + rest = short + rest.slice( long.length ); } const title = @@ -501,18 +513,8 @@ document.title = title; } }; - const label = () => - $toggle.text( full ? strings.short_title : strings.full_title ); update(); - label(); - - $toggle.on( 'click', () => { - full = ! full; - preferFullTitle( full ); - update(); - label(); - } ); const element = document.querySelector( 'title' ); if ( element ) { @@ -524,6 +526,81 @@ } } + /** + * Shows a marked subject in an element, which keeps the subject itself for hovering and copying. + * + * @param {jQuery} $element Element. + * @param {Object} subjects Subject, and marked subject. + */ + function markSubject( $element, subjects ) { + $element + .addClass( 'wporg-subject' ) + .attr( { + title: subjects.subject, + 'data-wporg-subject': subjects.subject, + } ) + .text( subjects.marked ); + } + + /** + * Marks the subjects of the conversation and of its sender's previous conversations, as the page says; lists of + * conversations come marked already. + * + * @param {jQuery} $data What the page says about the conversation's subject. + */ + function markSubjects( $data ) { + let subjects; + try { + subjects = JSON.parse( String( $data.attr( 'data-subjects' ) ) ); + } catch { + return; + } + + $( '.sidebar-block-list a.help-link' ).each( function () { + const id = String( $( this ).attr( 'href' ) || '' ).match( + /\/conversation\/(\d+)/ + ); + const item = id && subjects[ id[ 1 ] ]; + const node = item && this.lastChild; + if ( + node && + window.Node.TEXT_NODE === node.nodeType && + node.nodeValue.trim() === item.subject + ) { + const $subject = $( '' ); + markSubject( $subject, item ); + $( node ).replaceWith( $subject ); + } + } ); + + const current = + subjects[ String( $data.attr( 'data-conversation-id' ) ) ]; + const $heading = $( '.conv-subjtext > span' ).first(); + if ( ! current || ! $heading.length ) { + return; + } + + /* + * FreeScout's editor keeps the subject itself, and puts what the agent saves in the heading, which shows as it is + * until the page loads again. + */ + const update = () => { + const text = $heading.text(); + if ( text === current.subject ) { + markSubject( $heading, current ); + } else if ( text !== current.marked ) { + $heading.removeAttr( 'title data-wporg-subject' ); + } + }; + + update(); + new window.MutationObserver( update ).observe( $heading[ 0 ], { + childList: true, + characterData: true, + subtree: true, + } ); + } + /** * The username in the last Review ID line of a text, read the way the module's ReviewId class reads it. * @@ -668,6 +745,110 @@ return check; } + /** + * The rows of the conversations a worklist's Open button opens, in the order it opens them. + * + * @param {jQuery} $button The button. + * @return {jQuery[]} Rows. + */ + function rowsToOpen( $button ) { + return String( $button.attr( 'data-conversations' ) || '' ) + .split( ',' ) + .filter( Boolean ) + .map( ( id ) => + $button + .closest( '.wporg-worklist-section' ) + .find( 'tr[data-conversation_id="' + id + '"]' ) + ) + .filter( ( $row ) => $row.length ); + } + + /** + * Unmarks the conversations an Open button would open. + */ + function unmarkRowsToOpen() { + $( '.wporg-worklist tr.is-to-open' ).removeClass( 'is-to-open' ); + $( '.wporg-worklist-open-order' ).remove(); + } + + // The My Reviews and My Cases folders show a member's conversations, or a tag's, as soon as they're chosen. + $( document ).on( 'change', '.wporg-worklist-filter', function () { + this.form.submit(); + } ); + + // Their Open buttons mark which conversations they'd open, and in what order. + $( document ).on( + 'mouseenter focus', + '.wporg-worklist-open-button', + function () { + unmarkRowsToOpen(); + rowsToOpen( $( this ) ).forEach( ( $row, index ) => { + $row.addClass( 'is-to-open' ) + .find( '.wporg-worklist-name' ) + .prepend( + $( '' ).text( + index + 1 + ) + ); + } ); + } + ); + $( document ).on( + 'mouseleave blur', + '.wporg-worklist-open-button', + unmarkRowsToOpen + ); + + /* + * And open them in new tabs, in that order, telling each when and in which position it was opened; browsers block + * all but the first, unless the site may open pop-ups. + */ + $( document ).on( 'click', '.wporg-worklist-open-button', function () { + const $button = $( this ); + const batch = String( Date.now() ); + let blocked = 0; + + rowsToOpen( $button ).forEach( ( $row, index ) => { + const href = $row.find( 'a[href]' ).first().attr( 'href' ); + if ( ! href ) { + return; + } + + const url = new URL( href, window.location.href ); + url.searchParams.set( OPEN_PARAMS.batch, batch ); + url.searchParams.set( OPEN_PARAMS.order, String( index + 1 ) ); + if ( ! window.open( url.href, '_blank' ) ) { + blocked++; + } + } ); + + if ( blocked ) { + const strings = + $button.closest( '.wporg-worklist-open' ).data( 'strings' ) || + {}; + const message = String( strings.blocked || '' ).replace( + ':count', + blocked + ); + + if ( 'function' === typeof window.showFloatingAlert ) { + window.showFloatingAlert( 'error', message ); + } else { + window.alert( message ); // eslint-disable-line no-alert + } + } + } ); + + // Their conversations someone is participating in get FreeScout's own bulk actions, which modules like Tags add to. + $( function () { + if ( + $( '.wporg-worklist .conv-checkbox' ).length && + 'function' === typeof window.converstationBulkActionsInit + ) { + window.converstationBulkActionsInit(); + } + } ); + $( function () { const $panel = $( '.wporg-review' ); if ( ! $panel.length ) { @@ -703,6 +884,9 @@ checkReply(); loadPlugin( $panel, strings, ( plugin ) => { + // For the header's Copy button, which collects the download URLs of the plugins open. + $( document ).trigger( 'wporgpluginreview:plugin', [ plugin ] ); + if ( plugin.submitter && plugin.submitter.username ) { authors.add( String( plugin.submitter.username ).toLowerCase() @@ -710,13 +894,77 @@ checkReply(); } } ); + } ); + + // The plugin directory's subjects, for the plugins team, with or without a review to show. + $( function () { + const $data = $( '.wporg-subjects' ).first(); + if ( ! $data.length ) { + return; + } + markSubjects( $data ); manageTitle( - $panel, - String( $panel.attr( 'data-flags' ) || '' ) + $data, + String( $( '.wporg-review' ).attr( 'data-flags' ) || '' ) .split( ' ' ) - .filter( Boolean ), - strings + .filter( Boolean ) ); } ); + + // Copying a marked subject copies the subject itself. + $( document ).on( 'copy', ( event ) => { + const view = ( event.target.ownerDocument || document ).defaultView; + const selection = view && view.getSelection(); + const clipboard = + event.originalEvent && event.originalEvent.clipboardData; + if ( ! selection || selection.isCollapsed || ! clipboard ) { + return; + } + + const marked = $( '[data-wporg-subject]' ) + .filter( function () { + return selection.containsNode( this ); + } ) + .get(); + if ( ! marked.length ) { + return; + } + + const copied = selection.toString(); + const ranges = []; + for ( let i = 0; i < selection.rangeCount; i++ ) { + ranges.push( selection.getRangeAt( i ).cloneRange() ); + } + + // Each is found after the text selected before it, as the selection reads it, last first so the others stay put. + let text = copied; + marked.reverse().forEach( ( element ) => { + const before = element.ownerDocument.createRange(); + before.setStart( + ranges[ 0 ].startContainer, + ranges[ 0 ].startOffset + ); + before.setEndBefore( element ); + selection.removeAllRanges(); + selection.addRange( before ); + + const shown = $( element ).text(); + const at = text.indexOf( shown, selection.toString().length ); + if ( -1 !== at ) { + text = + text.slice( 0, at ) + + String( $( element ).attr( 'data-wporg-subject' ) ) + + text.slice( at + shown.length ); + } + } ); + + selection.removeAllRanges(); + ranges.forEach( ( range ) => selection.addRange( range ) ); + + if ( text !== copied ) { + clipboard.setData( 'text/plain', text ); + event.preventDefault(); + } + } ); } )( jQuery ); diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md index bf8025e50f..6b0bacbf06 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md @@ -1,6 +1,6 @@ # WP.org Plugin Review -Shows the latest plugin review of a conversation in its sidebar, for the plugins team, read from the review emails in FreeScout's database. +Shows the latest plugin review of a conversation in its sidebar, for the plugins team, read from the review emails in FreeScout's database. Adds [My Reviews and My Cases](#my-reviews-and-my-cases) folders of the reviews and cases each member is working on. ## Who sees it @@ -14,17 +14,65 @@ A conversation gets the **Plugin Review** panel once the team has sent it a revi - **Details** from the latest Review ID: the slug, linked to the plugin's edit page on WordPress.org, the submitter's profile, and how many reviews there were and when. - **Flags**: the review's own (like `TRM` for naming, and `OWN` for ownership), and `UPD` while the author owes an update: a review asked for one, none approved the plugin, and since the latest review email, WordPress.org hasn't confirmed an upload, or the team wrote to the author again after the latest confirmation. The browser tab's title starts with ๐ด for `UPD`, ๐ for `TRM`, and ๐ค for `OWN`, in that order. -- **Names**, for `TRM`: the name the plugin was submitted with, and the name and slug the review suggests. -- **Owner**, for `OWN`: the domains of the plugin's Author URI and Plugin URI, marked "DNS verified" when they carry the `wordpressorg-{username}-verification` TXT record the ownership emails ask for. The username is the Review ID's, or the submitter's when it doesn't name one. -- **The plugin as it is now**, from WordPress.org by the Review ID's plugin ID, or by its slug for reviews that don't carry the ID: its status, who's assigned to it, a button that copies its ZIP's URL (with the review info the review tools read, while it's in review), and, once it's released, links to its page and its security scans. A naming review also shows its current name and slug, in green when it's the suggested name and in red when it's still the original; an ownership review shows the submitter, with their email address in green when it's at one of the plugin's domains. +- **Names**, for `TRM`: the name the plugin was submitted with, and the name and slug the review suggests, if it suggests any. Once the plugin's current name is known, the section is green when it's the suggested name (or slug, when the review suggests no name), red when it's still the original, and orange when it's another name that needs a look. +- **Owner**, for `OWN`: the domains of the plugin's Author URI and Plugin URI, marked "DNS verified" when they carry the `wordpressorg-{username}-verification` TXT record the ownership emails ask for. The username is the Review ID's, or the submitter's when it doesn't name one. The section is red when neither domain is verified and the submitter's email address isn't at either. +- **The plugin as it is now**, from WordPress.org by the Review ID's plugin ID, or by its slug for reviews that don't carry the ID: its status, who's assigned to it, a button that copies its ZIP's URL (with the review info the review tools read, while it's in review), and, once it's released, links to its page and its security scans. A naming review also shows its current name and slug; an ownership review shows the submitter, with their email address in green when it's at one of the plugin's domains. - **Issues** the review lists, under their headings' ๐ด (or the older `##`); warnings, under ๐ก, aren't listed. Clicking one scrolls to it in the email. Quoted text is left out, so an author's reply that quotes a review isn't read as one. What WordPress.org and DNS say is loaded after the page, so neither holds it up. -The tab's title also shortens the plugin directory's subjects, like "R: My Plugin" for "[WordPress Plugin Directory] Review in Progress: My Plugin"; a link at the bottom of the panel switches back to the full title, which the browser remembers. +The plugin directory's most common subjects show with a mark in place of everything before the plugin's name, replies and forwards included: in the conversation, its tab's title, the sender's previous conversations, and lists of conversations. The full subject shows on hover and in the subject's editor, and is what gets copied. + +| Subject | Shows as | +| --- | --- | +| [WordPress Plugin Directory] Review in Progress: My Plugin | ๐ My Plugin | +| [WordPress Plugin Directory] Closure Notice - Licensing/Trademark Violation: My Plugin | ๐โข๏ธ My Plugin | +| [WordPress Plugin Directory] Closure Notice - Guideline Violation: My Plugin | ๐๐ My Plugin | +| [WordPress Plugin Directory] Notice: My Plugin | ๐โน๏ธ My Plugin | +| [WordPress Plugin Directory] Security Notice: My Plugin | ๐๐ก๏ธ My Plugin | +| [WordPress Plugin Directory] Rejection Explanation: My Plugin | ๐๐ซ My Plugin | + +The tab's title leaves out the conversation's number, and shortens the plugin directory's other subjects too, like "Closed (Author Request): My Plugin". Marks are in `Services/Subject.php`. While an agent writes a reply, a warning shows above it when the reply has a Review ID for a username other than the latest review's and the plugin's submitter's, until they say it's the right one: it would send one author's review to another. +## My Reviews and My Cases + +The plugins team's mailbox gets two folders, right after Unassigned and before Mine, set apart from the folders after them, for the same agents as the panel: **My Reviews**, of the open and pending conversations assigned to the Plugin Reviews team, and **My Cases**, of the Plugin Admins team's: questions, guideline reports, replies to rejections, sockpuppets, adoptions, and the like. Each has three sections: + +- **Reviews (or Cases) I'm participating in**: ones the agent sent a reply to. Notes and drafts don't count. +- **Reviews (or Cases) I'm mentioned in**: ones with a note that mentions the agent, with the Mentions module's `@` mentions. Mentions of a team the agent is in don't count. +- **Reviews that need a reviewer**, or **Cases that need a volunteer**: ones with `PRTRN` in a note, and ones no member replied to yet: no member of the plugins team's teams, as they are now, so replies from other agents, workflows, and former members don't count. Without the Teams module, any agent's reply does. + +Each conversation shows only in the first section it belongs to, the longest waiting first, with its tags and how many threads it has, as FreeScout counts them: emails, not notes. Every one shows, without pages. The reviewer or volunteer at the top switches to another member of the plugins team's teams, to see theirs, and My Cases can be filtered by a tag its conversations have. Agents who may only see the conversations assigned to them see only those here too. + +Conversations someone is participating in can be selected, and changed together with FreeScout's bulk actions: status, assignee, and deleting, and the Tags and Workflows modules' tags and manual workflows. + +Without the Teams module, both folders are of the mailbox's open and pending conversations, whoever they're assigned to. With it, but without a team of that name, they're empty. + +The folders aren't stored: they're added to the mailbox's folders as they're listed. Their counts are how many active conversations the agent is participating in: the ones whose sender wrote last, like core's folders count. Every member's count comes from one query per folder, kept in FreeScout's cache for up to ten minutes, and forgotten sooner when an agent's reply is published or removed, a sender replies, or a conversation in the mailbox changes status, assignee, or mailbox, or is deleted, merged, or imported. Agents who may only see the conversations assigned to them have theirs counted on their own, and kept the same way. A page takes one query for which sections every conversation belongs to, and a few to load the ones that do and their tags; with a thousand open conversations, that's a few tens of milliseconds. + +Their definitions, like the team and what the sections are called, are in `Services/Worklist.php`. + +### Opening conversations + +Next to the participating section's heading, two buttons open its active conversations in new tabs: up to 25 at a time, or as many as the agent chooses under **Conversations to open** in their profile, up to 100. Neither opens a conversation whose sender has waited less than 12 hours, since its latest reply, as Waiting Since shows. Hovering or focusing a button marks the conversations it would open, numbered in order. + +- **Open Oldest First**: the longest waiting first. +- **Open Progress Based**: the longest waiting first, after each has waited as long as the team's replies to it make it: the more replies, the worse the review went, and the longer the others go first. The team's emails count, its first one included when the team wrote it, and notes don't. 1 or 2 replies wait at least a day, 3 or 4 two days, 5 or 6 four, 7 or 8 five, 9 or 10 six, and more, eight. The `sockpuppet` and `sockpuppeting` tags add four days. A conversation that has waited that long is ahead of one that waited longer but has to wait more: they go by how long they've waited beyond it. + +The waits are `OpenOrder::WAIT_DAYS` and `OpenOrder::TAG_WAIT_DAYS`, in `Services/OpenOrder.php`. Browsers open the first tab and block the rest unless the site may open pop-ups; the page says how many were blocked, so the agent can allow them once. Each conversation opens with `wporg_open_batch`, when the button was clicked, and `wporg_open_order`, its position, which keep their download URLs in that order. + +## Copying download URLs + +For reviewing plugins in bulk with a local tool, the header has a **Copy X URLs** button, for the same agents as the panel, once they have open conversations whose panel knows the plugin's download URL. It copies them as `"||โฆ"`: in quotes, separated by pipes, each once, in the order the agent opened them. The ones the Open buttons opened go by their batch and position, whichever tab loaded first; the others by when they loaded. The trash bin next to it forgets them all. + +In the sidebar's **Plugins & Themes** panel, each plugin's download link has a button after it: **+** adds its URL to them, after the others, and **โ**, once it's there, removes it, however it got there. URLs added this way stay until they're removed, as no page holds them. WPOrgSidebar triggers `wporgsidebar:panel` on a panel once it's loaded, which is when the buttons are added. + +The URLs are only in the agent's browser: each conversation's page keeps its own, in `localStorage`, as soon as WordPress.org tells the panel the plugin, and removes it as it closes. It also holds a [Web Lock](https://developer.mozilla.org/docs/Web/API/Web_Locks_API) of the same name while it's open, which the browser lets go of however the page ends, even when it crashes or closes without saying so; the URLs of pages that don't hold theirs anymore are left out, and removed. A reload keeps the page's place. + +So that many tabs opening at once don't lose their URLs to the 120 plugins an agent may ask WordPress.org for in a minute, the panel asks again for those it was refused, up to three times, after 20 seconds or more, and longer each time. + ## WordPress.org The plugin comes from `api.wordpress.org/dotorg/freescout/plugin-review.php`, which only answers for the plugins team's mailbox (its address starts with `plugins`). Requests are signed with `WPORG_API_URL` and `WPORG_API_SECRET`, like the sidebar's panels, through WPOrgSidebar's client. Without them, the panel shows what the review emails say. diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/downloads.blade.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/downloads.blade.php new file mode 100644 index 0000000000..fc34f56831 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/downloads.blade.php @@ -0,0 +1,7 @@ +{{-- In the header's menu: copies the download URLs of the plugins in review the agent has open, shown once there are any; downloads.js fills it. --}} + + + + + + diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/panel.blade.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/panel.blade.php index 3f4d90a704..ba7fe457c6 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/panel.blade.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/panel.blade.php @@ -12,28 +12,30 @@ ); @endphp {{-- FreeScout's own sidebar block, like Previous Conversations. --}} - @@ -88,32 +90,45 @@ @endif @if ( $review['names'] ) - {{ __( 'Names' ) }} - - {{ __( 'Original' ) }} - {{ '' !== $review['names']['original'] ? $review['names']['original'] : 'โ' }} - {{ __( 'Suggested' ) }} - {{ '' !== $review['names']['suggested'] ? $review['names']['suggested'] : 'โ' }} - @if ( '' !== $review['names']['suggested_slug'] ) - {{ __( 'Suggested slug' ) }} - - {{ $review['names']['suggested_slug'] }} - - - @endif - + @php + $names = $review['names']; + @endphp + {{-- The script colors it once it knows the plugin's current name. --}} + + {{ __( 'Names' ) }} + + {{ __( 'Original' ) }} + {{ '' !== $names['original'] ? $names['original'] : 'โ' }} + @if ( '' !== $names['suggested'] || '' !== $names['suggested_slug'] ) + {{ __( 'Suggested' ) }} + + {{ $names['suggested'] }} + @if ( '' !== $names['suggested_slug'] ) + @if ( '' !== $names['suggested'] ) + + @endif + {{ $names['suggested_slug'] }} + + @endif + + @endif + + @endif @if ( $review['owner'] ) - {{ __( 'Owner' ) }} - - @foreach ( array( 'author' => __( 'Author host' ), 'plugin' => __( 'Plugin host' ) ) as $key => $label ) - @if ( '' !== $review['owner'][ $key . '_host' ] ) - {{ $label }} - {{ $review['owner'][ $key . '_host' ] }} - @endif - @endforeach - + {{-- The script colors it once it knows whether the author proved they own the plugin. --}} + + {{ __( 'Owner' ) }} + + @foreach ( array( 'author' => __( 'Author host' ), 'plugin' => __( 'Plugin host' ) ) as $key => $label ) + @if ( '' !== $review['owner'][ $key . '_host' ] ) + {{ $label }} + {{ $review['owner'][ $key . '_host' ] }} + @endif + @endforeach + + @endif @if ( $review['issues'] ) @@ -126,11 +141,6 @@ @endforeach @endif - - @if ( '' !== $short_subject ) - {{-- Its label says what a click does, once the script knows which title the tab has. --}} - - @endif diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/profile.blade.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/profile.blade.php new file mode 100644 index 0000000000..2a278c8782 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/profile.blade.php @@ -0,0 +1,9 @@ +{{-- In the user's profile, like core's fields: how many conversations My Reviews and My Cases open at a time. --}} + + {{ __( 'Conversations to open' ) }} + + + + {{ __( 'How many conversations the Open buttons of My Reviews and My Cases open in new tabs at a time, up to :max.', array( 'max' => $max ) ) }} + + diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/subjects.blade.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/subjects.blade.php new file mode 100644 index 0000000000..65fae8beec --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/subjects.blade.php @@ -0,0 +1,2 @@ +{{-- What the page's script needs to mark the subjects of the conversation, its tab's title, and its previous conversations. --}} + diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/worklist.blade.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/worklist.blade.php new file mode 100644 index 0000000000..7324867a77 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/worklist.blade.php @@ -0,0 +1,126 @@ +@extends('layouts.app') + +@php + $name = $user->getFullName(); + $headings = $list->headings( $is_viewer ? null : $name ); + $descriptions = $list->descriptions( $name ); + $title = $folder->getTypeName() . ' - ' . $mailbox->name; +@endphp + +@section('title', $folder->active_count ? '(' . (int) $folder->active_count . ') ' . $title : $title) + +@section('body_attrs')@parent data-mailbox_id="{{ $mailbox->id }}"@endsection + +@section('sidebar') + @include('partials/sidebar_menu_toggle') + @include('mailboxes/sidebar_menu_view') +@endsection + +@section('content') + + + {{ $list->member_label() }} + + @foreach ( $reviewers as $reviewer ) + id === (int) $user->id ) selected @endif>{{ $reviewer->getFullName() }} + @endforeach + + @if ( $tags ) + {{ __( 'Tag' ) }} + + {{ __( 'All tags' ) }} + @foreach ( $tags as $tag_id => $tag_option ) + {{ $tag_option['name'] }} ({{ $tag_option['count'] }}) + @endforeach + + @endif + {{ __( 'Show' ) }} + {{ __( 'Open and pending conversations assigned to :team.', array( 'team' => $list->team ) ) }} + + + @foreach ( $sections as $section => $conversations ) + @php + // Conversations someone is participating in can be changed together, with FreeScout's own bulk actions. + $selectable = 'participating' === $section && $conversations; + @endphp + + + + {{ $headings[ $section ] }} + {{ count( $conversations ) }} + + @if ( 'participating' === $section && $conversations ) + {{-- Opens the active ones in new tabs; hovering or focusing a button marks which, in order. --}} + + + {{ __( 'Open Oldest First' ) }} {{ count( $open['oldest'] ) }} + + + {{ __( 'Open Progress Based' ) }} {{ count( $open['progress'] ) }} + + {{ __( 'Up to :limit at a time', array( 'limit' => $limit ) ) }} + + @endif + + {{ $descriptions[ $section ] }} + + @if ( $conversations ) + @if ( $selectable ) + @include('conversations/partials/bulk_actions', array( 'mailbox' => $mailbox )) + @endif + + + + @if ( $selectable ) + {{ __( 'Select All Conversations' ) }} + @endif + {{ __( 'Sender' ) }} + {{ __( 'Conversation' ) }} + {{ __( 'Threads' ) }} + {{ __( 'Waiting Since' ) }} + + + + @foreach ( $conversations as $conversation ) + @php + $url = $conversation->url(); + @endphp + {{-- conv-row and conv-subject let the Tags module show tags added in bulk. --}} + + @if ( $selectable ) + + {{ __( 'Select Conversation' ) }}: {{ $conversation->getSubject() }} + + @endif + + + @if ( $conversation->customer ){{ $conversation->customer->getFullName( true ) }}@endif + {{ $conversation->customer_email }} + + + + + + @if ( ! $conversation->isActive() ){{ $conversation->getStatusName() }}@endif + @action('conversations_table.before_subject', $conversation){{ $conversation->getSubject() }}@action('conversations_table.after_subject', $conversation) + + {{ $conversation->preview }} + + + + {{ (int) $conversation->threads_count }} + + + {{ $conversation->getWaitingSince( $folder ) }} + + + @endforeach + + + @else + {{ $list->empty_text() }} + @endif + + @endforeach + +@endsection diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/IssueNames.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/IssueNames.php index 380f239f12..88d0195ff3 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/IssueNames.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/IssueNames.php @@ -101,6 +101,13 @@ final class IssueNames { array( 'Phoning Home / Collecting User Data Without Opt-In Consent', 'Phoning Home / Collecting User Data', self::PRIORITY_HIGH ), array( 'The main file of the plugin has a name that does not follow the convention', 'Main plugin file naming', self::PRIORITY_NORMAL ), array( 'Arbitrary input in sensitive function parameters or calls', 'Arbitrary input in sensitive parameters/calls', self::PRIORITY_HIGH ), + array( 'The plugin has problems when it is activated', 'Activation issues', self::PRIORITY_NORMAL ), + array( 'Writing data to disallowed or incorrect locations, and/or asking users to edit plugin files', 'Writing data out of recommended directories', self::PRIORITY_LOW ), + array( 'There are unusual users in the "Contributors" list for this plugin', 'Unusual "Contributors"', self::PRIORITY_NORMAL ), + array( 'High position for your admin dashboard menu item', 'High position dashboard menu', self::PRIORITY_LOW ), + array( 'Improper handling of authentication cookies, keys, and salts.', 'Improper handling of authentication cookies, keys, and salts', self::PRIORITY_HIGH ), + array( 'Direct integration with a third-party AI provider detected.', 'Direct AI integration', self::PRIORITY_LOW ), + array( 'The functionality described is not in the plugin', 'Functionality not in the plugin', self::PRIORITY_HIGH ), ); /** diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/OpenOrder.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/OpenOrder.php new file mode 100644 index 0000000000..57ffb85f85 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/OpenOrder.php @@ -0,0 +1,295 @@ + + */ + public const WAIT_DAYS = array( + 0 => 0, + 2 => 1, + 4 => 2, + 6 => 4, + 8 => 5, + 10 => 6, + PHP_INT_MAX => 8, + ); + + /** + * The days conversations with these tags wait on top of that. + * + * @var array + */ + public const TAG_WAIT_DAYS = array( + 'sockpuppet' => 4, + 'sockpuppeting' => 4, + ); + + /** + * Seconds in an hour. + * + * @var int + */ + private const HOUR = 3600; + + /** + * Seconds in a day. + * + * @var int + */ + private const DAY = 86400; + + /** + * Where the agents' choices of how many to open are kept, followed by their user ID. + * + * @var string + */ + private const LIMIT_OPTION = 'wporgpluginreview.open_limit.'; + + /** + * The active conversations to open, the longest waiting first. + * + * @param Conversation[] $conversations Conversations. + * @param int $limit How many to open at most. + * @return int[] Conversation IDs, in the order to open them. + */ + public static function oldest_first( array $conversations, int $limit ): array { + $waits = self::waits( $conversations ); + arsort( $waits ); + + return array_slice( array_keys( $waits ), 0, max( 0, $limit ) ); + } + + /** + * The active conversations to open by progress: the longest waiting, after each waits as many days as WAIT_DAYS and + * TAG_WAIT_DAYS make it. A conversation that waited that long is ahead of one that waited longer but has to wait + * more. + * + * @param Conversation[] $conversations Conversations. + * @param int $limit How many to open at most. + * @return int[] Conversation IDs, in the order to open them. + */ + public static function progress_based( array $conversations, int $limit ): array { + $waits = self::waits( $conversations ); + $replies = self::team_replies( array_keys( $waits ) ); + $tags = self::tags( array_keys( $waits ) ); + + // How long each waited beyond what it has to; the ones that didn't wait enough yet are left out. + $ahead = array(); + foreach ( $waits as $id => $seconds ) { + $beyond = $seconds - self::wait_days( $replies[ $id ] ?? 0, $tags[ $id ] ?? array() ) * self::DAY; + if ( $beyond >= 0 ) { + $ahead[ $id ] = $beyond; + } + } + arsort( $ahead ); + + return array_slice( array_keys( $ahead ), 0, max( 0, $limit ) ); + } + + /** + * How many days a conversation waits at least before it's opened by progress. + * + * @param int $replies How many replies the team sent to it. + * @param string[] $tags Its tags. + * @return int + */ + public static function wait_days( int $replies, array $tags ): int { + $days = 0; + foreach ( self::WAIT_DAYS as $most => $wait ) { + if ( $replies <= $most ) { + $days = $wait; + break; + } + } + + foreach ( $tags as $tag ) { + $days += self::TAG_WAIT_DAYS[ strtolower( $tag ) ] ?? 0; + } + + return $days; + } + + /** + * How many conversations an agent opens at a time. + * + * @param User $user Agent. + * @return int + */ + public static function limit( User $user ): int { + try { + // Not from Option's cache, which setting and removing options leave as it was. + $limit = (int) \Option::get( self::LIMIT_OPTION . $user->id, self::DEFAULT_LIMIT, true, false ); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not read how many conversations user ' . $user->id . ' opens: ' . $e->getMessage() ); + + return self::DEFAULT_LIMIT; + } + + return $limit >= 1 ? min( $limit, self::MAX_LIMIT ) : self::DEFAULT_LIMIT; + } + + /** + * Sets how many conversations an agent opens at a time, between 1 and MAX_LIMIT; the default when it's empty. + * + * @param User $user Agent. + * @param mixed $limit How many, as sent. + * @return void + */ + public static function set_limit( User $user, mixed $limit ): void { + if ( ! is_numeric( $limit ) || self::DEFAULT_LIMIT === (int) $limit ) { + \Option::remove( self::LIMIT_OPTION . $user->id ); + + return; + } + + \Option::set( self::LIMIT_OPTION . $user->id, max( 1, min( (int) $limit, self::MAX_LIMIT ) ) ); + } + + /** + * Forgets an agent's choice, when they're deleted. + * + * @param int $user_id User ID. + * @return void + */ + public static function forget_limit( int $user_id ): void { + \Option::remove( self::LIMIT_OPTION . $user_id ); + } + + /** + * How long the senders of the active conversations that are old enough to open have waited. + * + * @param Conversation[] $conversations Conversations. + * @return array Seconds, by conversation ID. + */ + private static function waits( array $conversations ): array { + $now = Carbon::now(); + $waits = array(); + foreach ( $conversations as $conversation ) { + if ( Conversation::STATUS_ACTIVE !== (int) $conversation->status ) { + continue; + } + + $since = $conversation->last_reply_at ?? $conversation->created_at; + if ( ! $since ) { + continue; + } + + $since = $since instanceof \DateTimeInterface ? $since->getTimestamp() : Carbon::parse( (string) $since )->getTimestamp(); + $seconds = $now->getTimestamp() - $since; + if ( $seconds >= self::MIN_AGE_HOURS * self::HOUR ) { + $waits[ (int) $conversation->id ] = $seconds; + } + } + + // Ties go to the conversation that came in first. + ksort( $waits ); + + return $waits; + } + + /** + * How many replies the team sent to conversations: their published emails written by agents, the first one + * included, and not notes. + * + * @param int[] $ids Conversation IDs. + * @return array By conversation ID. + */ + private static function team_replies( array $ids ): array { + if ( ! $ids ) { + return array(); + } + + return \DB::table( 'threads' ) + ->whereIn( 'conversation_id', $ids ) + ->where( 'type', Thread::TYPE_MESSAGE ) + ->where( 'state', Thread::STATE_PUBLISHED ) + ->groupBy( 'conversation_id' ) + ->selectRaw( 'conversation_id, count(*) as total' ) + ->pluck( 'total', 'conversation_id' ) + ->map( + static function ( mixed $total ): int { + return (int) $total; + } + ) + ->all(); + } + + /** + * The tags of conversations that make them wait longer, with the Tags module. + * + * @param int[] $ids Conversation IDs. + * @return array Tag names, by conversation ID. + */ + private static function tags( array $ids ): array { + if ( ! $ids || ! Worklist::has_tags() ) { + return array(); + } + + try { + $tags = array(); + $rows = \DB::table( 'conversation_tag' ) + ->join( 'tags', 'tags.id', '=', 'conversation_tag.tag_id' ) + ->whereIn( 'conversation_tag.conversation_id', $ids ) + ->whereIn( 'tags.name', array_keys( self::TAG_WAIT_DAYS ) ) + ->select( 'conversation_tag.conversation_id', 'tags.name' ) + ->get(); + + foreach ( $rows as $row ) { + $tags[ (int) $row->conversation_id ][] = (string) $row->name; + } + + return $tags; + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not read the tags of the conversations to open: ' . $e->getMessage() ); + + return array(); + } + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Reviewers.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Reviewers.php index f2d3101923..0c60cf82f6 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Reviewers.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Reviewers.php @@ -12,7 +12,7 @@ use App\User; /** - * Tells members of the plugins team's teams apart, with the Teams module. + * Tells members of the plugins team's teams apart, and finds its teams, with the Teams module. * * Teams is a premium module, which most contributors don't have; without it, everyone who can see the plugins team's * mailbox is taken for a reviewer. Not final, so tests can answer for Teams. @@ -26,6 +26,21 @@ class Reviewers { */ private const TEAM_PREFIX = 'Plugin'; + /** + * The name of the team that plugin reviews are assigned to. + * + * @var string + */ + public const REVIEW_TEAM = 'Plugin Reviews'; + + /** + * The name of the team that cases are assigned to: questions, guideline reports, replies to rejections, and the + * like. + * + * @var string + */ + public const ADMIN_TEAM = 'Plugin Admins'; + /** * The Teams module's alias. * @@ -68,6 +83,34 @@ public function includes( ?User $user ): bool { return false; } + /** + * A team's ID: the Teams module assigns conversations to a team as if it were a user. + * + * @param string $name The team's name, like REVIEW_TEAM. + * @return int|null Null without the Teams module; 0 when Teams has no such team, or can't tell, which no + * conversation is assigned to. + */ + public function team_id( string $name ): ?int { + if ( ! \App\Module::isActive( self::TEAMS_MODULE ) ) { + return null; + } + + try { + $id = array_search( $name, $this->teams(), true ); + if ( false === $id ) { + \Log::error( '[WPOrgPluginReview] There is no ' . $name . ' team.' ); + + return 0; + } + + return (int) $id; + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not find the ' . $name . ' team: ' . $e->getMessage() ); + + return 0; + } + } + /** * The names of the teams a user is a member of, as the Teams module tells them. * @@ -81,11 +124,24 @@ protected function team_names( User $user ): array { } $team_ids = array_map( 'intval', (array) $teams::getUserTeamIds( (int) $user->id ) ); - $names = array(); + + return array_values( array_intersect_key( $this->teams(), array_flip( $team_ids ) ) ); + } + + /** + * The Teams module's teams. + * + * @return string[] Names, by team ID. + */ + protected function teams(): array { + $teams = self::TEAMS; + if ( ! class_exists( $teams ) ) { + return array(); + } + + $names = array(); foreach ( $teams::getTeams( true ) as $team ) { - if ( in_array( (int) $team->id, $team_ids, true ) ) { - $names[] = (string) $team->first_name; - } + $names[ (int) $team->id ] = (string) $team->first_name; } return $names; diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Subject.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Subject.php index 206e4ac9ea..3d9944a055 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Subject.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Subject.php @@ -1,6 +1,6 @@ '๐', + 'Closure Notice - Licensing/Trademark Violation' => '๐โข๏ธ', + 'Closure Notice - Guideline Violation' => '๐๐', + 'Notice' => '๐โน๏ธ', + 'Security Notice' => '๐๐ก๏ธ', + 'Rejection Explanation' => '๐๐ซ', + ); + + /** + * Short names of the other kinds of email, for the browser tab; others keep their own name. * * @var string[] */ private const KINDS = array( - 'Review in Progress' => 'R', - 'Rejection Explanation' => 'Rejected', - 'Security Notice' => 'Security', - 'Closure Notice' => 'Closed', + 'Closure Notice' => 'Closed', ); /** - * A subject's short version. + * A subject with its kind's mark in place of everything before the plugin's name, which the conversation, its tab, + * and lists of conversations show. + * + * @param string $subject Subject. + * @return string Empty if it isn't of one of the kinds with a mark. + */ + public static function marked( string $subject ): string { + $subject = self::without_prefix( $subject ); + $colon = null === $subject ? false : strpos( $subject, ': ' ); + if ( false === $colon ) { + return ''; + } + + $mark = self::MARKS[ trim( substr( $subject, 0, $colon ) ) ] ?? ''; + + return '' === $mark ? '' : $mark . ' ' . trim( substr( $subject, $colon + 2 ) ); + } + + /** + * A subject's short version, for the browser tab: the marked one, or one without the prefix for other kinds. * * @param string $subject Subject. * @return string Empty if it isn't one of the plugin directory's. */ public static function short( string $subject ): string { - // Replies and forwards keep the subject they answer. - $subject = trim( (string) preg_replace( '/^(?:(?:re|fwd?)\s*:\s*)+/i', '', trim( $subject ) ) ); - if ( 0 !== stripos( $subject, self::PREFIX ) ) { + $marked = self::marked( $subject ); + if ( '' !== $marked ) { + return $marked; + } + + $subject = self::without_prefix( $subject ); + if ( null === $subject ) { return ''; } - $subject = trim( substr( $subject, strlen( self::PREFIX ) ) ); - $colon = strpos( $subject, ': ' ); + $colon = strpos( $subject, ': ' ); if ( false === $colon ) { return $subject; } @@ -70,4 +103,20 @@ public static function short( string $subject ): string { return $short . ': ' . $name; } + + /** + * A plugin directory's subject without its prefix, nor those of replies and forwards. + * + * @param string $subject Subject. + * @return string|null Null if it isn't one of the plugin directory's. + */ + private static function without_prefix( string $subject ): ?string { + // Replies and forwards keep the subject they answer. + $subject = trim( (string) preg_replace( '/^(?:(?:re|fwd?)\s*:\s*)+/i', '', trim( $subject ) ) ); + if ( 0 !== stripos( $subject, self::PREFIX ) ) { + return null; + } + + return trim( substr( $subject, strlen( self::PREFIX ) ) ); + } } diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php new file mode 100644 index 0000000000..4c0d3a0c29 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php @@ -0,0 +1,686 @@ + new self( 'reviews', 'my-reviews', Reviewers::REVIEW_TEAM, 229, 'tasks', false ), + 'cases' => new self( 'cases', 'my-cases', Reviewers::ADMIN_TEAM, 230, 'briefcase', true ), + ); + } + + return self::$all; + } + + /** + * My Reviews: the Plugin Reviews team's. + * + * @return self + */ + public static function reviews(): self { + return self::all()['reviews']; + } + + /** + * My Cases: the Plugin Admins team's. + * + * @return self + */ + public static function cases(): self { + return self::all()['cases']; + } + + /** + * The worklist of a page. + * + * @param string $slug The page's part of its URL. + * @return self|null + */ + public static function by_slug( string $slug ): ?self { + foreach ( self::all() as $worklist ) { + if ( $worklist->slug === $slug ) { + return $worklist; + } + } + + return null; + } + + /** + * The pages' parts of their URLs, for the route. + * + * @return string[] + */ + public static function slugs(): array { + return array_values( + array_map( + static function ( self $worklist ): string { + return $worklist->slug; + }, + self::all() + ) + ); + } + + /** + * The worklist of a folder. + * + * @param mixed $folder Folder. + * @return self|null Null if it isn't one of theirs. + */ + public static function of_folder( mixed $folder ): ?self { + foreach ( self::all() as $worklist ) { + if ( $worklist->is_folder( $folder ) ) { + return $worklist; + } + } + + return null; + } + + /** + * The folder's name. + * + * @return string + */ + public function name(): string { + return 'cases' === $this->key ? __( 'My Cases' ) : __( 'My Reviews' ); + } + + /** + * What a member is called, for choosing whose conversations to show. + * + * @return string + */ + public function member_label(): string { + return 'cases' === $this->key ? __( 'Volunteer' ) : __( 'Reviewer' ); + } + + /** + * The sections' headings. + * + * @param string|null $name The member's name; null for the agent's own. + * @return string[] By section. + */ + public function headings( ?string $name ): array { + if ( 'cases' === $this->key ) { + return array( + 'participating' => null === $name ? __( 'Cases Iโm participating in' ) : __( 'Cases :name is participating in', array( 'name' => $name ) ), + 'mentioned' => null === $name ? __( 'Cases Iโm mentioned in' ) : __( 'Cases :name is mentioned in', array( 'name' => $name ) ), + 'needs' => __( 'Cases that need a volunteer' ), + ); + } + + return array( + 'participating' => null === $name ? __( 'Reviews Iโm participating in' ) : __( 'Reviews :name is participating in', array( 'name' => $name ) ), + 'mentioned' => null === $name ? __( 'Reviews Iโm mentioned in' ) : __( 'Reviews :name is mentioned in', array( 'name' => $name ) ), + 'needs' => __( 'Reviews that need a reviewer' ), + ); + } + + /** + * What's in the sections. + * + * @param string $name The member's name. + * @return string[] By section. + */ + public function descriptions( string $name ): array { + return array( + 'participating' => __( 'With a reply from :name.', array( 'name' => $name ) ), + 'mentioned' => __( 'With a note that mentions :name.', array( 'name' => $name ) ), + 'needs' => 'cases' === $this->key + ? __( 'With :marker in a note, or without a reply from a volunteer yet.', array( 'marker' => self::NEEDS_MARKER ) ) + : __( 'With :marker in a note, or without a reply from a reviewer yet.', array( 'marker' => self::NEEDS_MARKER ) ), + ); + } + + /** + * What an empty section says. + * + * @return string + */ + public function empty_text(): string { + return 'cases' === $this->key ? __( 'No cases.' ) : __( 'No reviews.' ); + } + + /** + * The page of the folder. + * + * @param int $mailbox_id Mailbox ID. + * @return string + */ + public function url( int $mailbox_id ): string { + return route( + 'wporgpluginreview.worklist', + array( + 'mailbox_id' => $mailbox_id, + 'worklist' => $this->slug, + ) + ); + } + + /** + * The folder's ID. + * + * @return int + */ + public function folder_id(): int { + return -$this->folder_type; + } + + /** + * Whether a folder is this worklist's. + * + * @param mixed $folder Folder. + * @return bool + */ + public function is_folder( mixed $folder ): bool { + return $folder instanceof Folder && $this->folder_type === (int) $folder->type; + } + + /** + * The folder, as the mailbox's folder list shows it. + * + * @param Mailbox $mailbox Mailbox. + * @return Folder + */ + public function folder( Mailbox $mailbox ): Folder { + $folder = new Folder(); + $folder->id = $this->folder_id(); + $folder->mailbox_id = (int) $mailbox->id; + $folder->type = $this->folder_type; + $folder->active_count = 0; + $folder->total_count = 0; + + return $folder; + } + + /** + * Adds the worklists' folders to a mailbox's folder list, after Unassigned and before Mine, with how many active + * conversations the agent is participating in. + * + * @param Collection $folders The mailbox's folders. + * @param Mailbox $mailbox Mailbox. + * @param User $user The agent. + * @return Collection + */ + public static function add_folders( Collection $folders, Mailbox $mailbox, User $user ): Collection { + $added = array(); + foreach ( self::all() as $worklist ) { + $folder = $worklist->folder( $mailbox ); + try { + $folder->active_count = $worklist->active_count( $mailbox, $user ); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not count the ' . $worklist->key . ' of user ' . $user->id . ': ' . $e->getMessage() ); + } + $added[] = $folder; + } + + $list = array(); + $placed = false; + foreach ( $folders as $item ) { + if ( ! $placed && Folder::TYPE_MINE === (int) $item->type ) { + array_push( $list, ...$added ); + $placed = true; + } + + $list[] = $item; + + if ( ! $placed && Folder::TYPE_UNASSIGNED === (int) $item->type ) { + array_push( $list, ...$added ); + $placed = true; + } + } + + if ( ! $placed ) { + array_unshift( $list, ...$added ); + } + + return $folders->make( $list ); + } + + /** + * How many active conversations an agent is participating in, as the folder list shows it: the ones whose sender + * wrote last, like core's folders count. + * + * Every member's count comes from one query, kept until something changes them; agents who may only see what's + * assigned to them get theirs counted, and kept, on its own. + * + * @param Mailbox $mailbox Mailbox. + * @param User $user The agent. + * @return int + */ + public function active_count( Mailbox $mailbox, User $user ): int { + if ( $user->canSeeOnlyAssignedConversations() ) { + return (int) \Cache::remember( + $this->counts_key( (int) $mailbox->id, (int) $user->id ), + now()->addMinutes( self::COUNTS_MINUTES ), + function () use ( $mailbox, $user ): int { + return $this->conversations( $mailbox, $user ) + ->where( 'conversations.status', Conversation::STATUS_ACTIVE ) + ->addWhereExistsQuery( self::threads( Thread::TYPE_MESSAGE )->where( 'threads.created_by_user_id', $user->id ) ) + ->count(); + } + ); + } + + $counts = \Cache::remember( + $this->counts_key( (int) $mailbox->id ), + now()->addMinutes( self::COUNTS_MINUTES ), + function () use ( $mailbox ): array { + return $this->conversations( $mailbox ) + ->where( 'conversations.status', Conversation::STATUS_ACTIVE ) + ->join( 'threads', 'threads.conversation_id', '=', 'conversations.id' ) + ->where( 'threads.type', Thread::TYPE_MESSAGE ) + ->where( 'threads.state', Thread::STATE_PUBLISHED ) + ->whereNotNull( 'threads.created_by_user_id' ) + ->groupBy( 'threads.created_by_user_id' ) + ->toBase() + ->selectRaw( 'threads.created_by_user_id as user_id, count(distinct conversations.id) as total' ) + ->pluck( 'total', 'user_id' ) + ->map( + static function ( mixed $total ): int { + return (int) $total; + } + ) + ->all(); + } + ); + + return (int) ( $counts[ (int) $user->id ] ?? 0 ); + } + + /** + * Forgets the worklists' counts of a mailbox, after something that may change them. + * + * @param int $mailbox_id Mailbox ID. + * @return void + */ + public static function forget_counts( int $mailbox_id ): void { + try { + \Cache::forever( self::version_key( $mailbox_id ), uniqid( '', true ) ); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not forget the worklists\' counts of mailbox ' . $mailbox_id . ': ' . $e->getMessage() ); + } + } + + /** + * Where the members' counts of a mailbox are kept, or an agent's own; forgetting them moves them to new keys. + * + * @param int $mailbox_id Mailbox ID. + * @param int|null $user_id The agent, if they're counted on their own. + * @return string + */ + private function counts_key( int $mailbox_id, ?int $user_id = null ): string { + $key = 'wporgpluginreview.worklist.' . $this->key . '.active_counts.' . $mailbox_id . '.' . \Cache::get( self::version_key( $mailbox_id ), '' ); + + return null === $user_id ? $key : $key . '.' . $user_id; + } + + /** + * Where the version of a mailbox's counts is kept. + * + * @param int $mailbox_id Mailbox ID. + * @return string + */ + private static function version_key( int $mailbox_id ): string { + return 'wporgpluginreview.worklist.counts_version.' . $mailbox_id; + } + + /** + * The members whose conversations can be shown: the mailbox's users who are members of the plugins team's teams. + * + * @param Mailbox $mailbox Mailbox. + * @return Collection Users, by name. + */ + public static function reviewers( Mailbox $mailbox ): Collection { + $reviewers = app( Reviewers::class ); + + // The Teams module adds its teams to the list, as users who aren't active. + return collect( $mailbox->usersHavingAccess( true ) ) + ->filter( + static function ( mixed $user ) use ( $reviewers ): bool { + return $user instanceof User && $user->isActive() && $reviewers->includes( $user ); + } + ) + ->values(); + } + + /** + * A member's conversations, by section. + * + * @param Mailbox $mailbox Mailbox. + * @param User $user The member. + * @param User $viewer Who's looking: they only see the conversations they may. + * @return array Conversations by section, the longest waiting first. + */ + public function sections( Mailbox $mailbox, User $user, User $viewer ): array { + return self::load( $this->section_ids( $mailbox, $user, $viewer, self::reviewers( $mailbox ) ) ); + } + + /** + * The IDs of a member's conversations, by section. + * + * A conversation needs someone when a note says so, or when no member replied to it yet: none of the mailbox's + * users who are members of the plugins team's teams, as they are now. + * + * @param Mailbox $mailbox Mailbox. + * @param User $user The member. + * @param User $viewer Who's looking: they only see the conversations they may. + * @param Collection $reviewers The members, as reviewers() tells them. + * @return array Conversation IDs by section, the longest waiting first. + */ + public function section_ids( Mailbox $mailbox, User $user, User $viewer, Collection $reviewers ): array { + $member_ids = $reviewers->pluck( 'id' )->all(); + + $rows = $this->conversations( $mailbox, $viewer ) + ->select( 'conversations.id' ) + ->selectSub( self::threads( Thread::TYPE_MESSAGE )->where( 'threads.created_by_user_id', $user->id ), 'participating' ) + ->selectSub( self::threads( Thread::TYPE_NOTE )->where( 'threads.body', 'like', '%' . self::mention( $user ) . '%' ), 'mentioned' ) + ->selectSub( self::threads( Thread::TYPE_NOTE )->where( 'threads.body', 'like', '%' . self::NEEDS_MARKER . '%' ), 'needs' ) + ->selectSub( self::threads( Thread::TYPE_MESSAGE )->whereIn( 'threads.created_by_user_id', $member_ids ), 'answered' ) + ->orderBy( 'conversations.last_reply_at' ) + ->orderBy( 'conversations.id' ) + ->toBase() + ->get(); + + $ids = array_fill_keys( self::SECTIONS, array() ); + foreach ( $rows as $row ) { + $row->needs = $row->needs || ! $row->answered; + + foreach ( self::SECTIONS as $section ) { + if ( $row->$section ) { + $ids[ $section ][] = (int) $row->id; + break; + } + } + } + + return $ids; + } + + /** + * The tags of conversations, to filter them by, with the Tags module. + * + * @param array $ids Conversation IDs by section. + * @param int $selected The tag filtered by, if any, which is listed even when none of the + * conversations have it anymore. + * @return array By tag ID, by name. + */ + public static function tags( array $ids, int $selected = 0 ): array { + $all = array_merge( ...array_values( $ids ) ); + if ( ! self::has_tags() ) { + return array(); + } + + try { + $tags = array(); + if ( $all ) { + $rows = \DB::table( 'conversation_tag' ) + ->join( 'tags', 'tags.id', '=', 'conversation_tag.tag_id' ) + ->whereIn( 'conversation_tag.conversation_id', $all ) + ->groupBy( 'tags.id', 'tags.name' ) + ->select( 'tags.id', 'tags.name' ) + ->selectRaw( 'count(*) as total' ) + ->get(); + + foreach ( $rows as $row ) { + $tags[ (int) $row->id ] = array( + 'name' => (string) $row->name, + 'count' => (int) $row->total, + ); + } + } + + if ( $selected && ! isset( $tags[ $selected ] ) ) { + $name = \DB::table( 'tags' )->where( 'id', $selected )->value( 'name' ); + if ( null !== $name ) { + $tags[ $selected ] = array( + 'name' => (string) $name, + 'count' => 0, + ); + } + } + + uasort( + $tags, + static function ( array $a, array $b ): int { + return strnatcasecmp( $a['name'], $b['name'] ); + } + ); + + return $tags; + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not read the tags to filter by: ' . $e->getMessage() ); + + return array(); + } + } + + /** + * Keeps the conversations with a tag. + * + * @param array $ids Conversation IDs by section. + * @param int $tag_id Tag ID. + * @return array + */ + public static function filter_by_tag( array $ids, int $tag_id ): array { + $all = array_merge( ...array_values( $ids ) ); + $tagged = $all + ? array_flip( + \DB::table( 'conversation_tag' ) + ->where( 'tag_id', $tag_id ) + ->whereIn( 'conversation_id', $all ) + ->pluck( 'conversation_id' ) + ->map( + static function ( mixed $id ): int { + return (int) $id; + } + ) + ->all() + ) + : array(); + + return array_map( + static function ( array $section ) use ( $tagged ): array { + return array_values( + array_filter( + $section, + static function ( int $id ) use ( $tagged ): bool { + return isset( $tagged[ $id ] ); + } + ) + ); + }, + $ids + ); + } + + /** + * Loads conversations by section, with what conversation lists show of them. + * + * @param array $ids Conversation IDs by section. + * @return array + */ + public static function load( array $ids ): array { + $all = array_merge( ...array_values( $ids ) ); + if ( ! $all ) { + return $ids; + } + + $conversations = self::preload( Conversation::whereIn( 'id', $all )->with( 'customer' )->get() )->keyBy( 'id' ); + + return array_map( + static function ( array $section ) use ( $conversations ): array { + return array_values( array_filter( array_map( array( $conversations, 'get' ), $section ) ) ); + }, + $ids + ); + } + + /** + * Whether there are tags to filter by: the Tags module is on, and its tables are there. + * + * @return bool + */ + public static function has_tags(): bool { + try { + return \App\Module::isActive( 'tags' ) && \Schema::hasTable( 'tags' ) && \Schema::hasTable( 'conversation_tag' ); + } catch ( \Throwable $e ) { + return false; + } + } + + /** + * Lets modules load what they show in conversation lists for all the conversations at once, like the Tags module + * their tags, as core's lists do. + * + * @param Collection $conversations Conversations. + * @return Collection + */ + private static function preload( Collection $conversations ): Collection { + try { + $preloaded = \Eventy::filter( 'conversations_table.preload_table_data', $conversations ); + + return $preloaded instanceof Collection ? $preloaded : $conversations; + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not preload the worklist\'s conversations: ' . $e->getMessage() ); + + return $conversations; + } + } + + /** + * The team's open and pending conversations in the mailbox, or, without the Teams module, the mailbox's. + * + * @param Mailbox $mailbox Mailbox. + * @param User|null $viewer Who's looking, if they only see the conversations they may. + * @return Builder + */ + private function conversations( Mailbox $mailbox, ?User $viewer = null ): Builder { + $query = Conversation::query() + ->where( 'conversations.mailbox_id', $mailbox->id ) + ->where( 'conversations.state', Conversation::STATE_PUBLISHED ) + ->whereIn( 'conversations.status', array( Conversation::STATUS_ACTIVE, Conversation::STATUS_PENDING ) ); + + $team_id = app( Reviewers::class )->team_id( $this->team ); + if ( null !== $team_id ) { + $query->where( 'conversations.user_id', $team_id ); + } + + // Like core's folders, for users who may only see what's assigned to them; Teams adds their teams' conversations. + if ( $viewer && $viewer->canSeeOnlyAssignedConversations() && ! \Eventy::filter( 'folder.only_assigned_condition', false, $query, (int) $viewer->id ) ) { + $query->where( 'conversations.user_id', $viewer->id ); + } + + return $query; + } + + /** + * Whether a conversation has a published thread of a type, to narrow down. + * + * @param int $type Thread type. + * @return QueryBuilder + */ + private static function threads( int $type ): QueryBuilder { + return \DB::table( 'threads' ) + ->selectRaw( '1' ) + ->whereColumn( 'threads.conversation_id', 'conversations.id' ) + ->where( 'threads.type', $type ) + ->where( 'threads.state', Thread::STATE_PUBLISHED ) + ->limit( 1 ); + } + + /** + * How the Mentions module marks a user up in a note. + * + * @param User $user User. + * @return string + */ + private static function mention( User $user ): string { + return 'data-mentioned-id="' . (int) $user->id . '"'; + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json b/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json index fec79c243e..22911f8fab 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json @@ -2,8 +2,8 @@ "name": "WP.org Plugin Review", "alias": "wporgpluginreview", "img": "/modules/wporgpluginreview/img/icon.svg", - "description": "Shows the latest plugin review of a conversation in its sidebar: its details, flags, and issues, from the review emails' Review ID lines.", - "version": "1.0.0", + "description": "Shows the latest plugin review of a conversation in its sidebar: its details, flags, and issues, from the review emails' Review ID lines. Adds My Reviews and My Cases folders of the reviews and cases an agent replied to, is mentioned in, or that need someone, and a header button that copies the download URLs of the plugins in review they have open.", + "version": "1.0.1", "detailsUrl": "https://github.com/WordPress/wordpress.org/tree/trunk/freescout.wordpress.net/Modules/WPOrgPluginReview", "author": "WordPress.org", "authorUrl": "https://wordpress.org/", diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/DownloadsTest.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/DownloadsTest.php new file mode 100644 index 0000000000..8c34a19391 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/DownloadsTest.php @@ -0,0 +1,103 @@ +app->register( WPOrgPluginReviewServiceProvider::class ); + } + + /** + * Clears the modules' cache, which outlives the test's database. + * + * @return void + */ + protected function tearDown(): void { + \App\Module::clearModulesCache(); + + parent::tearDown(); + } + + /** + * Members of the plugins team's teams get the button, hidden until there are URLs, and its script. + * + * @return void + */ + public function test_reviewers_get_the_button(): void { + $this->actingAs( $this->create_user() ); + + $html = $this->header(); + + $this->assertStringContainsString( 'assertStringContainsString( 'class="wporg-downloads-copy"', $html ); + $this->assertStringContainsString( 'class="wporg-downloads-clear"', $html ); + $this->assertStringContainsString( 'Copy :count URLs', html_entity_decode( $html ) ); + + $scripts = implode( ' ', (array) \Eventy::filter( 'javascripts', array() ) ); + $this->assertStringContainsString( '/js/downloads.js', $scripts ); + } + + /** + * Members of other teams don't. + * + * @return void + */ + public function test_others_dont(): void { + $this->actingAs( $this->create_user() ); + + \App\Module::clearModulesCache(); + \App\Module::setActive( Reviewers::TEAMS_MODULE, true ); + \App\Module::clearModulesCache(); + $this->app->instance( + Reviewers::class, + new class() extends Reviewers { + /** + * The names of the teams a user is a member of. + * + * @param User $user User. + * @return string[] + */ + protected function team_names( User $user ): array { + return array( 'Themes' ); + } + } + ); + + $this->assertSame( '', $this->header() ); + } + + /** + * What the module adds to the header's menu. + * + * @return string + */ + private function header(): string { + ob_start(); + \Eventy::action( 'menu.append' ); + + return trim( (string) ob_get_clean() ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MarkedSubjectsTest.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MarkedSubjectsTest.php new file mode 100644 index 0000000000..88760f0acb --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MarkedSubjectsTest.php @@ -0,0 +1,161 @@ +app->register( WPOrgPluginReviewServiceProvider::class ); + + $this->mailbox = $this->create_mailbox( 'Plugins', 'plugins@wordpress.test' ); + $this->sender = $this->create_sender(); + $this->actingAs( $this->create_user() ); + } + + /** + * The conversation tells its script its subjects, for its title, and the marked subjects of the sender's others in + * the mailbox, for the sidebar, escaped. + * + * @return void + */ + public function test_conversation_has_its_subjects_and_its_previous_ones(): void { + $conversation = $this->conversation( '[WordPress Plugin Directory] Review in Progress: My ' ); + $rejection = $this->conversation( 'Re: [WordPress Plugin Directory] Rejection Explanation: Old Plugin' ); + $this->conversation( 'Question about my plugin' ); + $elsewhere = $this->conversation( '[WordPress Plugin Directory] Notice: Other Plugin', $this->create_mailbox( 'Themes', 'themes@wordpress.test' ) ); + + $html = $this->subjects( $conversation ); + + $this->assertStringContainsString( 'data-number="' . $conversation->number . '"', $html ); + $this->assertStringContainsString( 'data-subject="[WordPress Plugin Directory] Review in Progress: My <Plugin>"', $html ); + $this->assertStringContainsString( 'data-short-subject="๐ My <Plugin>"', $html ); + $this->assertStringNotContainsString( '', $html ); + + $this->assertTrue( (bool) preg_match( '/data-subjects="([^"]*)"/', $html, $matches ) ); + $this->assertSame( + array( + $conversation->id => array( + 'subject' => '[WordPress Plugin Directory] Review in Progress: My ', + 'marked' => '๐ My ', + ), + $rejection->id => array( + 'subject' => 'Re: [WordPress Plugin Directory] Rejection Explanation: Old Plugin', + 'marked' => '๐๐ซ Old Plugin', + ), + ), + json_decode( html_entity_decode( $matches[1] ), true ) + ); + + $this->assertSame( '', $this->subjects( $elsewhere ) ); + } + + /** + * A conversation without a marked subject still gets its title shortened, with nothing for the sidebar. + * + * @return void + */ + public function test_conversation_without_a_marked_subject(): void { + $html = $this->subjects( $this->conversation( 'Question about my plugin' ) ); + + $this->assertStringContainsString( 'data-short-subject=""', $html ); + $this->assertStringContainsString( 'data-subjects="{}"', $html ); + } + + /** + * Lists show a marked subject, with the subject itself hidden after it, on hover, and for copying; other subjects, + * and other mailboxes', as they are. + * + * @return void + */ + public function test_lists_mark_subjects(): void { + $this->assertSame( + '๐๐ก๏ธ My <Plugin>Re: [WordPress Plugin Directory] Security Notice: My <Plugin>', + $this->row( $this->conversation( 'Re: [WordPress Plugin Directory] Security Notice: My ' ) ) + ); + $this->assertSame( 'Question about my plugin', $this->row( $this->conversation( 'Question about my plugin' ) ) ); + $this->assertSame( + '[WordPress Plugin Directory] Notice: My Plugin', + $this->row( $this->conversation( '[WordPress Plugin Directory] Notice: My Plugin', $this->create_mailbox( 'Themes', 'themes@wordpress.test' ) ) ) + ); + } + + /** + * Creates a conversation from the sender. + * + * @param string $subject Subject. + * @param Mailbox|null $mailbox Mailbox; the plugins team's if null. + * @return Conversation + */ + private function conversation( string $subject, ?Mailbox $mailbox = null ): Conversation { + $conversation = $this->create_conversation( $mailbox ?? $this->mailbox, $this->sender ); + $conversation->update( array( 'subject' => $subject ) ); + + return $conversation; + } + + /** + * Renders what the module adds after a conversation's subject. + * + * @param Conversation $conversation Conversation. + * @return string + */ + private function subjects( Conversation $conversation ): string { + ob_start(); + \Eventy::action( 'conversation.after_subject', $conversation, $conversation->mailbox ); + + return trim( (string) ob_get_clean() ); + } + + /** + * Renders a conversation's subject in a list, as core does between its hooks. + * + * @param Conversation $conversation Conversation. + * @return string + */ + private function row( Conversation $conversation ): string { + ob_start(); + \Eventy::action( 'conversations_table.before_subject', $conversation ); + echo e( $conversation->getSubject() ); + \Eventy::action( 'conversations_table.after_subject', $conversation ); + + return (string) ob_get_clean(); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/OpenOrderTest.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/OpenOrderTest.php new file mode 100644 index 0000000000..21637ba832 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/OpenOrderTest.php @@ -0,0 +1,291 @@ +app->register( WPOrgPluginReviewServiceProvider::class ); + $this->app['router']->getRoutes()->refreshNameLookups(); + + $this->mailbox = $this->create_mailbox( 'Plugins', 'plugins@wordpress.test' ); + $this->reviewer = $this->create_user(); + } + + /** + * Clears the modules' cache, which outlives the test's database. + * + * @return void + */ + protected function tearDown(): void { + \App\Module::clearModulesCache(); + + parent::tearDown(); + } + + /** + * Waits grow with the team's replies, as WAIT_DAYS has them, and with tags. + * + * @return void + */ + public function test_wait_days(): void { + $expected = array( + 0 => 0, + 1 => 1, + 2 => 1, + 3 => 2, + 4 => 2, + 5 => 4, + 6 => 4, + 7 => 5, + 8 => 5, + 9 => 6, + 10 => 6, + 11 => 8, + 40 => 8, + ); + foreach ( $expected as $replies => $days ) { + $this->assertSame( $days, OpenOrder::wait_days( $replies, array() ), $replies . ' replies' ); + } + + $this->assertSame( 5, OpenOrder::wait_days( 1, array( 'Sockpuppeting', 'guidelines' ) ) ); + $this->assertSame( 4, OpenOrder::wait_days( 0, array( 'sockpuppet' ) ) ); + } + + /** + * Oldest first opens the active conversations that waited at least 12 hours, the longest waiting first, up to the + * limit. + * + * @return void + */ + public function test_oldest_first(): void { + $day = $this->conversation( 1, 24 ); + $fresh = $this->conversation( 1, 11 ); + $half_day = $this->conversation( 1, 12 ); + $week = $this->conversation( 9, 168 ); + $pending = $this->conversation( 1, 300, Conversation::STATUS_PENDING ); + + $all = array( $day, $fresh, $half_day, $week, $pending ); + + $this->assertSame( $this->ids( $week, $day, $half_day ), OpenOrder::oldest_first( $all, 25 ) ); + $this->assertSame( $this->ids( $week, $day ), OpenOrder::oldest_first( $all, 2 ) ); + $this->assertNotContains( (int) $fresh->id, OpenOrder::oldest_first( $all, 25 ) ); + } + + /** + * By progress, each conversation waits as long as the team's replies make it, and those that waited longest beyond + * that go first; the first email counts when the team wrote it, and notes don't. + * + * @return void + */ + public function test_progress_based(): void { + $recipe = $this->conversation( 7, 216 ); + $badges = $this->conversation( 5, 148 ); + $table = $this->conversation( 2, 74 ); + $membership = $this->conversation( 11, 240 ); + $colors = $this->conversation( 6, 72 ); + $gallery = $this->conversation( 1, 38 ); + $contact = $this->conversation( 1, 20 ); + $started = $this->conversation( 0, 13, Conversation::STATUS_ACTIVE, true ); + $noted = $this->conversation( 2, 30, Conversation::STATUS_ACTIVE, false, 9 ); + + $all = array( $recipe, $badges, $table, $membership, $colors, $gallery, $contact, $started, $noted ); + + // 96, 52, 50, 48, 14, 13, and 6 hours beyond their waits; the colors wait 4 days yet, and the contact form one. + $this->assertSame( $this->ids( $recipe, $badges, $table, $membership, $gallery, $started, $noted ), OpenOrder::progress_based( $all, 25 ) ); + $this->assertSame( $this->ids( $recipe, $badges ), OpenOrder::progress_based( $all, 2 ) ); + } + + /** + * The team's first email counts as a reply when the team wrote it. + * + * @return void + */ + public function test_progress_based_counts_the_teams_first_email(): void { + $started = $this->conversation( 3, 60 ); + $answered = $this->conversation( 2, 59, Conversation::STATUS_ACTIVE, true ); + + // Three replies wait 2 days, 12 hours beyond; two wait a day, 35 hours beyond. + $this->assertSame( $this->ids( $answered, $started ), OpenOrder::progress_based( array( $started, $answered ), 25 ) ); + } + + /** + * Tags like sockpuppeting add to the wait, with the Tags module. + * + * @return void + */ + public function test_progress_based_waits_longer_for_tags(): void { + $sockpuppets = $this->conversation( 1, 145 ); + $other = $this->conversation( 1, 100 ); + + $tag_id = (int) \DB::table( 'tags' )->insertGetId( array( 'name' => 'sockpuppeting' ) ); + \DB::table( 'conversation_tag' )->insert( + array( + 'conversation_id' => $sockpuppets->id, + 'tag_id' => $tag_id, + ) + ); + + $this->assertSame( $this->ids( $sockpuppets, $other ), OpenOrder::progress_based( array( $sockpuppets, $other ), 25 ) ); + + \App\Module::clearModulesCache(); + \App\Module::setActive( 'tags', true ); + \App\Module::clearModulesCache(); + + // 145 - 120 hours is 25 beyond, and 100 - 24 is 76. + $this->assertSame( $this->ids( $other, $sockpuppets ), OpenOrder::progress_based( array( $sockpuppets, $other ), 25 ) ); + } + + /** + * How many open at a time is 25, unless the agent chose otherwise, from 1 to 100. + * + * @return void + */ + public function test_limit(): void { + $this->assertSame( OpenOrder::DEFAULT_LIMIT, OpenOrder::limit( $this->reviewer ) ); + + OpenOrder::set_limit( $this->reviewer, '40' ); + $this->assertSame( 40, OpenOrder::limit( $this->reviewer ) ); + + OpenOrder::set_limit( $this->reviewer, '500' ); + $this->assertSame( OpenOrder::MAX_LIMIT, OpenOrder::limit( $this->reviewer ) ); + + OpenOrder::set_limit( $this->reviewer, '0' ); + $this->assertSame( 1, OpenOrder::limit( $this->reviewer ) ); + + OpenOrder::set_limit( $this->reviewer, '' ); + $this->assertSame( OpenOrder::DEFAULT_LIMIT, OpenOrder::limit( $this->reviewer ) ); + $this->assertFalse( \Option::get( 'wporgpluginreview.open_limit.' . $this->reviewer->id, false, true, false ) ); + } + + /** + * Reviewers' profiles have the field, which saves with the profile. + * + * @return void + */ + public function test_profile_field(): void { + OpenOrder::set_limit( $this->reviewer, 30 ); + + ob_start(); + \Eventy::action( 'user.edit.before_photo', $this->reviewer ); + $html = (string) ob_get_clean(); + + $this->assertStringContainsString( 'name="wporgpluginreview_open_limit" value="30"', $html ); + + $request = Request::create( '/', 'POST', array( 'wporgpluginreview_open_limit' => '12' ) ); + $this->assertSame( $this->reviewer, \Eventy::filter( 'user.save_profile', $this->reviewer, $request ) ); + $this->assertSame( 12, OpenOrder::limit( $this->reviewer ) ); + } + + /** + * The participating section has the buttons, with the conversations each opens, in order, and how many. + * + * @return void + */ + public function test_page_has_the_buttons(): void { + $day = $this->conversation( 1, 24 ); + $week = $this->conversation( 9, 168 ); + OpenOrder::set_limit( $this->reviewer, 10 ); + + $html = $this->actingAs( $this->reviewer )->get( Worklist::reviews()->url( (int) $this->mailbox->id ) )->getContent(); + + // The week-old one has to wait 6 days for its 9 replies, and the day-old one 1 for its one. + $this->assertStringContainsString( 'data-conversations="' . $week->id . ',' . $day->id . '"', $html ); + $this->assertStringContainsString( 'Open Oldest First', $html ); + $this->assertStringContainsString( 'Open Progress Based', $html ); + $this->assertStringContainsString( 'Up to 10 at a time', $html ); + } + + /** + * Creates an active conversation the reviewer replied to, assigned to nobody, which without the Teams module is + * in their reviews: the team writes first, unless the sender did, and the sender answers every email. + * + * @param int $replies The team's replies. + * @param int $hours Hours since the sender's latest reply. + * @param int $status Status. + * @param bool $sender_first Whether the sender wrote first, so the team's first email is a reply to them. + * @param int $notes Notes the team added too, which don't count. + * @return Conversation + */ + private function conversation( int $replies, int $hours, int $status = Conversation::STATUS_ACTIVE, bool $sender_first = false, int $notes = 0 ): Conversation { + $conversation = $this->create_conversation( $this->mailbox, $this->create_sender() ); + $at = '2026-01-01 10:00:00'; + + if ( $sender_first || ! $replies ) { + $this->create_thread( $conversation, Thread::TYPE_CUSTOMER, 'My plugin.', null, $at ); + } + for ( $n = 0; $n < $replies; $n++ ) { + $this->create_thread( $conversation, Thread::TYPE_MESSAGE, 'Review.', $this->reviewer, $at ); + $this->create_thread( $conversation, Thread::TYPE_CUSTOMER, 'Updated.', null, $at ); + } + for ( $n = 0; $n < $notes; $n++ ) { + $this->create_thread( $conversation, Thread::TYPE_NOTE, 'Note.', $this->reviewer, $at ); + } + + $conversation->status = $status; + $conversation->last_reply_at = Carbon::now()->subHours( $hours ); + $conversation->save(); + + return $conversation; + } + + /** + * Conversations' IDs. + * + * @param Conversation ...$conversations Conversations. + * @return int[] + */ + private function ids( Conversation ...$conversations ): array { + return array_map( + static function ( Conversation $conversation ): int { + return (int) $conversation->id; + }, + $conversations + ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php index 469f8dc58f..7573951eda 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php @@ -76,7 +76,6 @@ public function test_no_panel_without_a_review(): void { * @return void */ public function test_panel_shows_the_latest_review(): void { - $this->conversation->update( array( 'subject' => '[WordPress Plugin Directory] Review in Progress: My ' ) ); $this->review( 'Plugin URI: https://example.org/p๐ด Use wp_enqueue <script> commandsReview ID: R โOWN my-plugin/jane/1Aug26/T2 8Aug26/4.3 (P0TDX42HGN)' ); $html = $this->panel(); @@ -86,8 +85,6 @@ public function test_panel_shows_the_latest_review(): void { $this->assertStringContainsString( 'https://profiles.wordpress.org/jane/', $html ); $this->assertStringContainsString( 'data-flags="UPD OWN"', $html ); $this->assertStringContainsString( 'data-username="jane"', $html ); - $this->assertStringContainsString( 'data-short-subject="R: My <Plugin>"', $html ); - $this->assertStringContainsString( 'wporg-review-title-toggle', $html ); $this->assertStringContainsString( 'data-title="Use wp_enqueue <script> commands"', $html ); $this->assertStringNotContainsString( '
' ).text( String( plugin.slug ) ), @@ -345,14 +373,26 @@ } /** - * Shows whether the plugin's domains carry the author's record, and whether the submitter's address is at one. + * Shows whether the plugin's domains carry the author's record, and whether the submitter's address is at one, and + * marks the check when all were checked and none proves they own it. * - * @param {jQuery} $owner The owner details. + * @param {jQuery} $owner The owner check. * @param {Object} owner The check. * @param {Object|null} plugin The plugin, if known. * @param {Object} strings Labels. */ function showOwner( $owner, owner, plugin, strings ) { + // Any of them proves it; unknown ones, null, weren't checked. + if ( + [ 'author', 'plugin', 'email' ].every( + ( key ) => false === owner[ key ] + ) + ) { + $owner + .addClass( 'is-ignored' ) + .attr( 'title', strings.owner_ignored ); + } + [ 'author', 'plugin' ].forEach( ( key ) => { if ( true === owner[ key ] ) { $owner @@ -366,7 +406,7 @@ } ); if ( plugin && plugin.submitter ) { - addRow( $owner, strings.submitter, [ + addRow( $owner.find( '.wporg-review-details' ), strings.submitter, [ String( plugin.submitter.username ), $( '' ), $( '' ) @@ -388,8 +428,9 @@ * @param {jQuery} $panel Panel. * @param {Object} strings Labels. * @param {function(Object): void} onPlugin Called with the plugin, once it's known. + * @param {number} attempt How many times it was asked for already. */ - function loadPlugin( $panel, strings, onPlugin ) { + function loadPlugin( $panel, strings, onPlugin, attempt = 0 ) { $.getJSON( String( $panel.data( 'url' ) ) ) .done( ( result ) => { const plugin = result && result.plugin ? result.plugin : null; @@ -408,6 +449,17 @@ } } ) .fail( ( xhr ) => { + if ( 429 === xhr.status && attempt < RETRY.times ) { + const next = attempt + 1; + const wait = + next * RETRY.after + Math.random() * RETRY.jitter; + + setTimeout( () => { + loadPlugin( $panel, strings, onPlugin, next ); + }, wait ); + return; + } + addRow( $panel.find( '.wporg-review-plugin' ), strings.status, [ $( '' ).text( 429 === xhr.status ? strings.throttled : strings.failed @@ -417,62 +469,28 @@ } /** - * Reads the agent's preference for the tab's title. - * - * @return {boolean} Whether they want the full title. - */ - function prefersFullTitle() { - try { - return '1' === window.localStorage.getItem( FULL_TITLE_KEY ); - } catch { - return false; - } - } - - /** - * Keeps the agent's preference for the tab's title. + * Puts the marks of the review's flags before the tab's title, its subject's short version in it without the + * conversation's number, and keeps it that way as FreeScout changes it, like when it counts new replies. * - * @param {boolean} full Whether they want the full title. + * @param {jQuery} $data What the page says about the conversation's subject. + * @param {string[]} flags Flags. */ - function preferFullTitle( full ) { - try { - if ( full ) { - window.localStorage.setItem( FULL_TITLE_KEY, '1' ); - } else { - window.localStorage.removeItem( FULL_TITLE_KEY ); - } - } catch { - // Then it's only for this page. + function manageTitle( $data, flags ) { + const subject = String( $data.attr( 'data-subject' ) || '' ); + const number = String( $data.attr( 'data-number' ) || '' ); + if ( ! subject || ! number ) { + return; } - } - /** - * Puts the marks of the review's flags before the tab's title, shortens the subject in it, and keeps it that way as - * FreeScout changes it, like when it counts new replies. - * - * @param {jQuery} $panel Panel. - * @param {string[]} flags Flags. - * @param {Object} strings Labels. - */ - function manageTitle( $panel, flags, strings ) { const marks = Object.keys( TITLE_MARKS ) .filter( ( flag ) => flags.includes( flag ) ) .map( ( flag ) => TITLE_MARKS[ flag ] ) .join( '' ); - const subject = String( $panel.attr( 'data-subject' ) || '' ); - let shortSubject = String( $panel.attr( 'data-short-subject' ) || '' ); - - // Shortening only goes one way: a short subject that holds the full one could never be told from it. - if ( ! subject || shortSubject.includes( subject ) ) { - shortSubject = ''; - } - if ( ! marks && ! shortSubject ) { - return; - } - - const $toggle = $panel.find( '.wporg-review-title-toggle' ); - let full = prefersFullTitle(); + // FreeScout's title starts with the number and the subject; the short one, with the short subject alone. + const long = '#' + number + ' ' + subject; + const short = + String( $data.attr( 'data-short-subject' ) || '' ) || subject; // FreeScout's count of new replies goes first, then the marks. const prefix = new RegExp( @@ -485,14 +503,8 @@ const match = document.title.match( prefix ); let rest = document.title.slice( match[ 0 ].length ); - /* - * Each way only replaces what the other way leaves, so another pass changes nothing: the short subject may be - * part of the full one, but not the other way around. - */ - if ( shortSubject && full && ! rest.includes( subject ) ) { - rest = rest.replace( shortSubject, subject ); - } else if ( shortSubject && ! full ) { - rest = rest.replace( subject, shortSubject ); + if ( rest.startsWith( long ) ) { + rest = short + rest.slice( long.length ); } const title = @@ -501,18 +513,8 @@ document.title = title; } }; - const label = () => - $toggle.text( full ? strings.short_title : strings.full_title ); update(); - label(); - - $toggle.on( 'click', () => { - full = ! full; - preferFullTitle( full ); - update(); - label(); - } ); const element = document.querySelector( 'title' ); if ( element ) { @@ -524,6 +526,81 @@ } } + /** + * Shows a marked subject in an element, which keeps the subject itself for hovering and copying. + * + * @param {jQuery} $element Element. + * @param {Object} subjects Subject, and marked subject. + */ + function markSubject( $element, subjects ) { + $element + .addClass( 'wporg-subject' ) + .attr( { + title: subjects.subject, + 'data-wporg-subject': subjects.subject, + } ) + .text( subjects.marked ); + } + + /** + * Marks the subjects of the conversation and of its sender's previous conversations, as the page says; lists of + * conversations come marked already. + * + * @param {jQuery} $data What the page says about the conversation's subject. + */ + function markSubjects( $data ) { + let subjects; + try { + subjects = JSON.parse( String( $data.attr( 'data-subjects' ) ) ); + } catch { + return; + } + + $( '.sidebar-block-list a.help-link' ).each( function () { + const id = String( $( this ).attr( 'href' ) || '' ).match( + /\/conversation\/(\d+)/ + ); + const item = id && subjects[ id[ 1 ] ]; + const node = item && this.lastChild; + if ( + node && + window.Node.TEXT_NODE === node.nodeType && + node.nodeValue.trim() === item.subject + ) { + const $subject = $( '' ); + markSubject( $subject, item ); + $( node ).replaceWith( $subject ); + } + } ); + + const current = + subjects[ String( $data.attr( 'data-conversation-id' ) ) ]; + const $heading = $( '.conv-subjtext > span' ).first(); + if ( ! current || ! $heading.length ) { + return; + } + + /* + * FreeScout's editor keeps the subject itself, and puts what the agent saves in the heading, which shows as it is + * until the page loads again. + */ + const update = () => { + const text = $heading.text(); + if ( text === current.subject ) { + markSubject( $heading, current ); + } else if ( text !== current.marked ) { + $heading.removeAttr( 'title data-wporg-subject' ); + } + }; + + update(); + new window.MutationObserver( update ).observe( $heading[ 0 ], { + childList: true, + characterData: true, + subtree: true, + } ); + } + /** * The username in the last Review ID line of a text, read the way the module's ReviewId class reads it. * @@ -668,6 +745,110 @@ return check; } + /** + * The rows of the conversations a worklist's Open button opens, in the order it opens them. + * + * @param {jQuery} $button The button. + * @return {jQuery[]} Rows. + */ + function rowsToOpen( $button ) { + return String( $button.attr( 'data-conversations' ) || '' ) + .split( ',' ) + .filter( Boolean ) + .map( ( id ) => + $button + .closest( '.wporg-worklist-section' ) + .find( 'tr[data-conversation_id="' + id + '"]' ) + ) + .filter( ( $row ) => $row.length ); + } + + /** + * Unmarks the conversations an Open button would open. + */ + function unmarkRowsToOpen() { + $( '.wporg-worklist tr.is-to-open' ).removeClass( 'is-to-open' ); + $( '.wporg-worklist-open-order' ).remove(); + } + + // The My Reviews and My Cases folders show a member's conversations, or a tag's, as soon as they're chosen. + $( document ).on( 'change', '.wporg-worklist-filter', function () { + this.form.submit(); + } ); + + // Their Open buttons mark which conversations they'd open, and in what order. + $( document ).on( + 'mouseenter focus', + '.wporg-worklist-open-button', + function () { + unmarkRowsToOpen(); + rowsToOpen( $( this ) ).forEach( ( $row, index ) => { + $row.addClass( 'is-to-open' ) + .find( '.wporg-worklist-name' ) + .prepend( + $( '' ).text( + index + 1 + ) + ); + } ); + } + ); + $( document ).on( + 'mouseleave blur', + '.wporg-worklist-open-button', + unmarkRowsToOpen + ); + + /* + * And open them in new tabs, in that order, telling each when and in which position it was opened; browsers block + * all but the first, unless the site may open pop-ups. + */ + $( document ).on( 'click', '.wporg-worklist-open-button', function () { + const $button = $( this ); + const batch = String( Date.now() ); + let blocked = 0; + + rowsToOpen( $button ).forEach( ( $row, index ) => { + const href = $row.find( 'a[href]' ).first().attr( 'href' ); + if ( ! href ) { + return; + } + + const url = new URL( href, window.location.href ); + url.searchParams.set( OPEN_PARAMS.batch, batch ); + url.searchParams.set( OPEN_PARAMS.order, String( index + 1 ) ); + if ( ! window.open( url.href, '_blank' ) ) { + blocked++; + } + } ); + + if ( blocked ) { + const strings = + $button.closest( '.wporg-worklist-open' ).data( 'strings' ) || + {}; + const message = String( strings.blocked || '' ).replace( + ':count', + blocked + ); + + if ( 'function' === typeof window.showFloatingAlert ) { + window.showFloatingAlert( 'error', message ); + } else { + window.alert( message ); // eslint-disable-line no-alert + } + } + } ); + + // Their conversations someone is participating in get FreeScout's own bulk actions, which modules like Tags add to. + $( function () { + if ( + $( '.wporg-worklist .conv-checkbox' ).length && + 'function' === typeof window.converstationBulkActionsInit + ) { + window.converstationBulkActionsInit(); + } + } ); + $( function () { const $panel = $( '.wporg-review' ); if ( ! $panel.length ) { @@ -703,6 +884,9 @@ checkReply(); loadPlugin( $panel, strings, ( plugin ) => { + // For the header's Copy button, which collects the download URLs of the plugins open. + $( document ).trigger( 'wporgpluginreview:plugin', [ plugin ] ); + if ( plugin.submitter && plugin.submitter.username ) { authors.add( String( plugin.submitter.username ).toLowerCase() @@ -710,13 +894,77 @@ checkReply(); } } ); + } ); + + // The plugin directory's subjects, for the plugins team, with or without a review to show. + $( function () { + const $data = $( '.wporg-subjects' ).first(); + if ( ! $data.length ) { + return; + } + markSubjects( $data ); manageTitle( - $panel, - String( $panel.attr( 'data-flags' ) || '' ) + $data, + String( $( '.wporg-review' ).attr( 'data-flags' ) || '' ) .split( ' ' ) - .filter( Boolean ), - strings + .filter( Boolean ) ); } ); + + // Copying a marked subject copies the subject itself. + $( document ).on( 'copy', ( event ) => { + const view = ( event.target.ownerDocument || document ).defaultView; + const selection = view && view.getSelection(); + const clipboard = + event.originalEvent && event.originalEvent.clipboardData; + if ( ! selection || selection.isCollapsed || ! clipboard ) { + return; + } + + const marked = $( '[data-wporg-subject]' ) + .filter( function () { + return selection.containsNode( this ); + } ) + .get(); + if ( ! marked.length ) { + return; + } + + const copied = selection.toString(); + const ranges = []; + for ( let i = 0; i < selection.rangeCount; i++ ) { + ranges.push( selection.getRangeAt( i ).cloneRange() ); + } + + // Each is found after the text selected before it, as the selection reads it, last first so the others stay put. + let text = copied; + marked.reverse().forEach( ( element ) => { + const before = element.ownerDocument.createRange(); + before.setStart( + ranges[ 0 ].startContainer, + ranges[ 0 ].startOffset + ); + before.setEndBefore( element ); + selection.removeAllRanges(); + selection.addRange( before ); + + const shown = $( element ).text(); + const at = text.indexOf( shown, selection.toString().length ); + if ( -1 !== at ) { + text = + text.slice( 0, at ) + + String( $( element ).attr( 'data-wporg-subject' ) ) + + text.slice( at + shown.length ); + } + } ); + + selection.removeAllRanges(); + ranges.forEach( ( range ) => selection.addRange( range ) ); + + if ( text !== copied ) { + clipboard.setData( 'text/plain', text ); + event.preventDefault(); + } + } ); } )( jQuery ); diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md index bf8025e50f..6b0bacbf06 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md @@ -1,6 +1,6 @@ # WP.org Plugin Review -Shows the latest plugin review of a conversation in its sidebar, for the plugins team, read from the review emails in FreeScout's database. +Shows the latest plugin review of a conversation in its sidebar, for the plugins team, read from the review emails in FreeScout's database. Adds [My Reviews and My Cases](#my-reviews-and-my-cases) folders of the reviews and cases each member is working on. ## Who sees it @@ -14,17 +14,65 @@ A conversation gets the **Plugin Review** panel once the team has sent it a revi - **Details** from the latest Review ID: the slug, linked to the plugin's edit page on WordPress.org, the submitter's profile, and how many reviews there were and when. - **Flags**: the review's own (like `TRM` for naming, and `OWN` for ownership), and `UPD` while the author owes an update: a review asked for one, none approved the plugin, and since the latest review email, WordPress.org hasn't confirmed an upload, or the team wrote to the author again after the latest confirmation. The browser tab's title starts with ๐ด for `UPD`, ๐ for `TRM`, and ๐ค for `OWN`, in that order. -- **Names**, for `TRM`: the name the plugin was submitted with, and the name and slug the review suggests. -- **Owner**, for `OWN`: the domains of the plugin's Author URI and Plugin URI, marked "DNS verified" when they carry the `wordpressorg-{username}-verification` TXT record the ownership emails ask for. The username is the Review ID's, or the submitter's when it doesn't name one. -- **The plugin as it is now**, from WordPress.org by the Review ID's plugin ID, or by its slug for reviews that don't carry the ID: its status, who's assigned to it, a button that copies its ZIP's URL (with the review info the review tools read, while it's in review), and, once it's released, links to its page and its security scans. A naming review also shows its current name and slug, in green when it's the suggested name and in red when it's still the original; an ownership review shows the submitter, with their email address in green when it's at one of the plugin's domains. +- **Names**, for `TRM`: the name the plugin was submitted with, and the name and slug the review suggests, if it suggests any. Once the plugin's current name is known, the section is green when it's the suggested name (or slug, when the review suggests no name), red when it's still the original, and orange when it's another name that needs a look. +- **Owner**, for `OWN`: the domains of the plugin's Author URI and Plugin URI, marked "DNS verified" when they carry the `wordpressorg-{username}-verification` TXT record the ownership emails ask for. The username is the Review ID's, or the submitter's when it doesn't name one. The section is red when neither domain is verified and the submitter's email address isn't at either. +- **The plugin as it is now**, from WordPress.org by the Review ID's plugin ID, or by its slug for reviews that don't carry the ID: its status, who's assigned to it, a button that copies its ZIP's URL (with the review info the review tools read, while it's in review), and, once it's released, links to its page and its security scans. A naming review also shows its current name and slug; an ownership review shows the submitter, with their email address in green when it's at one of the plugin's domains. - **Issues** the review lists, under their headings' ๐ด (or the older `##`); warnings, under ๐ก, aren't listed. Clicking one scrolls to it in the email. Quoted text is left out, so an author's reply that quotes a review isn't read as one. What WordPress.org and DNS say is loaded after the page, so neither holds it up. -The tab's title also shortens the plugin directory's subjects, like "R: My Plugin" for "[WordPress Plugin Directory] Review in Progress: My Plugin"; a link at the bottom of the panel switches back to the full title, which the browser remembers. +The plugin directory's most common subjects show with a mark in place of everything before the plugin's name, replies and forwards included: in the conversation, its tab's title, the sender's previous conversations, and lists of conversations. The full subject shows on hover and in the subject's editor, and is what gets copied. + +| Subject | Shows as | +| --- | --- | +| [WordPress Plugin Directory] Review in Progress: My Plugin | ๐ My Plugin | +| [WordPress Plugin Directory] Closure Notice - Licensing/Trademark Violation: My Plugin | ๐โข๏ธ My Plugin | +| [WordPress Plugin Directory] Closure Notice - Guideline Violation: My Plugin | ๐๐ My Plugin | +| [WordPress Plugin Directory] Notice: My Plugin | ๐โน๏ธ My Plugin | +| [WordPress Plugin Directory] Security Notice: My Plugin | ๐๐ก๏ธ My Plugin | +| [WordPress Plugin Directory] Rejection Explanation: My Plugin | ๐๐ซ My Plugin | + +The tab's title leaves out the conversation's number, and shortens the plugin directory's other subjects too, like "Closed (Author Request): My Plugin". Marks are in `Services/Subject.php`. While an agent writes a reply, a warning shows above it when the reply has a Review ID for a username other than the latest review's and the plugin's submitter's, until they say it's the right one: it would send one author's review to another. +## My Reviews and My Cases + +The plugins team's mailbox gets two folders, right after Unassigned and before Mine, set apart from the folders after them, for the same agents as the panel: **My Reviews**, of the open and pending conversations assigned to the Plugin Reviews team, and **My Cases**, of the Plugin Admins team's: questions, guideline reports, replies to rejections, sockpuppets, adoptions, and the like. Each has three sections: + +- **Reviews (or Cases) I'm participating in**: ones the agent sent a reply to. Notes and drafts don't count. +- **Reviews (or Cases) I'm mentioned in**: ones with a note that mentions the agent, with the Mentions module's `@` mentions. Mentions of a team the agent is in don't count. +- **Reviews that need a reviewer**, or **Cases that need a volunteer**: ones with `PRTRN` in a note, and ones no member replied to yet: no member of the plugins team's teams, as they are now, so replies from other agents, workflows, and former members don't count. Without the Teams module, any agent's reply does. + +Each conversation shows only in the first section it belongs to, the longest waiting first, with its tags and how many threads it has, as FreeScout counts them: emails, not notes. Every one shows, without pages. The reviewer or volunteer at the top switches to another member of the plugins team's teams, to see theirs, and My Cases can be filtered by a tag its conversations have. Agents who may only see the conversations assigned to them see only those here too. + +Conversations someone is participating in can be selected, and changed together with FreeScout's bulk actions: status, assignee, and deleting, and the Tags and Workflows modules' tags and manual workflows. + +Without the Teams module, both folders are of the mailbox's open and pending conversations, whoever they're assigned to. With it, but without a team of that name, they're empty. + +The folders aren't stored: they're added to the mailbox's folders as they're listed. Their counts are how many active conversations the agent is participating in: the ones whose sender wrote last, like core's folders count. Every member's count comes from one query per folder, kept in FreeScout's cache for up to ten minutes, and forgotten sooner when an agent's reply is published or removed, a sender replies, or a conversation in the mailbox changes status, assignee, or mailbox, or is deleted, merged, or imported. Agents who may only see the conversations assigned to them have theirs counted on their own, and kept the same way. A page takes one query for which sections every conversation belongs to, and a few to load the ones that do and their tags; with a thousand open conversations, that's a few tens of milliseconds. + +Their definitions, like the team and what the sections are called, are in `Services/Worklist.php`. + +### Opening conversations + +Next to the participating section's heading, two buttons open its active conversations in new tabs: up to 25 at a time, or as many as the agent chooses under **Conversations to open** in their profile, up to 100. Neither opens a conversation whose sender has waited less than 12 hours, since its latest reply, as Waiting Since shows. Hovering or focusing a button marks the conversations it would open, numbered in order. + +- **Open Oldest First**: the longest waiting first. +- **Open Progress Based**: the longest waiting first, after each has waited as long as the team's replies to it make it: the more replies, the worse the review went, and the longer the others go first. The team's emails count, its first one included when the team wrote it, and notes don't. 1 or 2 replies wait at least a day, 3 or 4 two days, 5 or 6 four, 7 or 8 five, 9 or 10 six, and more, eight. The `sockpuppet` and `sockpuppeting` tags add four days. A conversation that has waited that long is ahead of one that waited longer but has to wait more: they go by how long they've waited beyond it. + +The waits are `OpenOrder::WAIT_DAYS` and `OpenOrder::TAG_WAIT_DAYS`, in `Services/OpenOrder.php`. Browsers open the first tab and block the rest unless the site may open pop-ups; the page says how many were blocked, so the agent can allow them once. Each conversation opens with `wporg_open_batch`, when the button was clicked, and `wporg_open_order`, its position, which keep their download URLs in that order. + +## Copying download URLs + +For reviewing plugins in bulk with a local tool, the header has a **Copy X URLs** button, for the same agents as the panel, once they have open conversations whose panel knows the plugin's download URL. It copies them as `"||โฆ"`: in quotes, separated by pipes, each once, in the order the agent opened them. The ones the Open buttons opened go by their batch and position, whichever tab loaded first; the others by when they loaded. The trash bin next to it forgets them all. + +In the sidebar's **Plugins & Themes** panel, each plugin's download link has a button after it: **+** adds its URL to them, after the others, and **โ**, once it's there, removes it, however it got there. URLs added this way stay until they're removed, as no page holds them. WPOrgSidebar triggers `wporgsidebar:panel` on a panel once it's loaded, which is when the buttons are added. + +The URLs are only in the agent's browser: each conversation's page keeps its own, in `localStorage`, as soon as WordPress.org tells the panel the plugin, and removes it as it closes. It also holds a [Web Lock](https://developer.mozilla.org/docs/Web/API/Web_Locks_API) of the same name while it's open, which the browser lets go of however the page ends, even when it crashes or closes without saying so; the URLs of pages that don't hold theirs anymore are left out, and removed. A reload keeps the page's place. + +So that many tabs opening at once don't lose their URLs to the 120 plugins an agent may ask WordPress.org for in a minute, the panel asks again for those it was refused, up to three times, after 20 seconds or more, and longer each time. + ## WordPress.org The plugin comes from `api.wordpress.org/dotorg/freescout/plugin-review.php`, which only answers for the plugins team's mailbox (its address starts with `plugins`). Requests are signed with `WPORG_API_URL` and `WPORG_API_SECRET`, like the sidebar's panels, through WPOrgSidebar's client. Without them, the panel shows what the review emails say. diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/downloads.blade.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/downloads.blade.php new file mode 100644 index 0000000000..fc34f56831 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/downloads.blade.php @@ -0,0 +1,7 @@ +{{-- In the header's menu: copies the download URLs of the plugins in review the agent has open, shown once there are any; downloads.js fills it. --}} + + + + + + diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/panel.blade.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/panel.blade.php index 3f4d90a704..ba7fe457c6 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/panel.blade.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/panel.blade.php @@ -12,28 +12,30 @@ ); @endphp {{-- FreeScout's own sidebar block, like Previous Conversations. --}} - @@ -88,32 +90,45 @@ @endif @if ( $review['names'] ) - {{ __( 'Names' ) }} - - {{ __( 'Original' ) }} - {{ '' !== $review['names']['original'] ? $review['names']['original'] : 'โ' }} - {{ __( 'Suggested' ) }} - {{ '' !== $review['names']['suggested'] ? $review['names']['suggested'] : 'โ' }} - @if ( '' !== $review['names']['suggested_slug'] ) - {{ __( 'Suggested slug' ) }} - - {{ $review['names']['suggested_slug'] }} - - - @endif - + @php + $names = $review['names']; + @endphp + {{-- The script colors it once it knows the plugin's current name. --}} + + {{ __( 'Names' ) }} + + {{ __( 'Original' ) }} + {{ '' !== $names['original'] ? $names['original'] : 'โ' }} + @if ( '' !== $names['suggested'] || '' !== $names['suggested_slug'] ) + {{ __( 'Suggested' ) }} + + {{ $names['suggested'] }} + @if ( '' !== $names['suggested_slug'] ) + @if ( '' !== $names['suggested'] ) + + @endif + {{ $names['suggested_slug'] }} + + @endif + + @endif + + @endif @if ( $review['owner'] ) - {{ __( 'Owner' ) }} - - @foreach ( array( 'author' => __( 'Author host' ), 'plugin' => __( 'Plugin host' ) ) as $key => $label ) - @if ( '' !== $review['owner'][ $key . '_host' ] ) - {{ $label }} - {{ $review['owner'][ $key . '_host' ] }} - @endif - @endforeach - + {{-- The script colors it once it knows whether the author proved they own the plugin. --}} + + {{ __( 'Owner' ) }} + + @foreach ( array( 'author' => __( 'Author host' ), 'plugin' => __( 'Plugin host' ) ) as $key => $label ) + @if ( '' !== $review['owner'][ $key . '_host' ] ) + {{ $label }} + {{ $review['owner'][ $key . '_host' ] }} + @endif + @endforeach + + @endif @if ( $review['issues'] ) @@ -126,11 +141,6 @@ @endforeach @endif - - @if ( '' !== $short_subject ) - {{-- Its label says what a click does, once the script knows which title the tab has. --}} - - @endif diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/profile.blade.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/profile.blade.php new file mode 100644 index 0000000000..2a278c8782 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/profile.blade.php @@ -0,0 +1,9 @@ +{{-- In the user's profile, like core's fields: how many conversations My Reviews and My Cases open at a time. --}} + + {{ __( 'Conversations to open' ) }} + + + + {{ __( 'How many conversations the Open buttons of My Reviews and My Cases open in new tabs at a time, up to :max.', array( 'max' => $max ) ) }} + + diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/subjects.blade.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/subjects.blade.php new file mode 100644 index 0000000000..65fae8beec --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/subjects.blade.php @@ -0,0 +1,2 @@ +{{-- What the page's script needs to mark the subjects of the conversation, its tab's title, and its previous conversations. --}} + diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/worklist.blade.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/worklist.blade.php new file mode 100644 index 0000000000..7324867a77 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/worklist.blade.php @@ -0,0 +1,126 @@ +@extends('layouts.app') + +@php + $name = $user->getFullName(); + $headings = $list->headings( $is_viewer ? null : $name ); + $descriptions = $list->descriptions( $name ); + $title = $folder->getTypeName() . ' - ' . $mailbox->name; +@endphp + +@section('title', $folder->active_count ? '(' . (int) $folder->active_count . ') ' . $title : $title) + +@section('body_attrs')@parent data-mailbox_id="{{ $mailbox->id }}"@endsection + +@section('sidebar') + @include('partials/sidebar_menu_toggle') + @include('mailboxes/sidebar_menu_view') +@endsection + +@section('content') + + + {{ $list->member_label() }} + + @foreach ( $reviewers as $reviewer ) + id === (int) $user->id ) selected @endif>{{ $reviewer->getFullName() }} + @endforeach + + @if ( $tags ) + {{ __( 'Tag' ) }} + + {{ __( 'All tags' ) }} + @foreach ( $tags as $tag_id => $tag_option ) + {{ $tag_option['name'] }} ({{ $tag_option['count'] }}) + @endforeach + + @endif + {{ __( 'Show' ) }} + {{ __( 'Open and pending conversations assigned to :team.', array( 'team' => $list->team ) ) }} + + + @foreach ( $sections as $section => $conversations ) + @php + // Conversations someone is participating in can be changed together, with FreeScout's own bulk actions. + $selectable = 'participating' === $section && $conversations; + @endphp + + + + {{ $headings[ $section ] }} + {{ count( $conversations ) }} + + @if ( 'participating' === $section && $conversations ) + {{-- Opens the active ones in new tabs; hovering or focusing a button marks which, in order. --}} + + + {{ __( 'Open Oldest First' ) }} {{ count( $open['oldest'] ) }} + + + {{ __( 'Open Progress Based' ) }} {{ count( $open['progress'] ) }} + + {{ __( 'Up to :limit at a time', array( 'limit' => $limit ) ) }} + + @endif + + {{ $descriptions[ $section ] }} + + @if ( $conversations ) + @if ( $selectable ) + @include('conversations/partials/bulk_actions', array( 'mailbox' => $mailbox )) + @endif + + + + @if ( $selectable ) + {{ __( 'Select All Conversations' ) }} + @endif + {{ __( 'Sender' ) }} + {{ __( 'Conversation' ) }} + {{ __( 'Threads' ) }} + {{ __( 'Waiting Since' ) }} + + + + @foreach ( $conversations as $conversation ) + @php + $url = $conversation->url(); + @endphp + {{-- conv-row and conv-subject let the Tags module show tags added in bulk. --}} + + @if ( $selectable ) + + {{ __( 'Select Conversation' ) }}: {{ $conversation->getSubject() }} + + @endif + + + @if ( $conversation->customer ){{ $conversation->customer->getFullName( true ) }}@endif + {{ $conversation->customer_email }} + + + + + + @if ( ! $conversation->isActive() ){{ $conversation->getStatusName() }}@endif + @action('conversations_table.before_subject', $conversation){{ $conversation->getSubject() }}@action('conversations_table.after_subject', $conversation) + + {{ $conversation->preview }} + + + + {{ (int) $conversation->threads_count }} + + + {{ $conversation->getWaitingSince( $folder ) }} + + + @endforeach + + + @else + {{ $list->empty_text() }} + @endif + + @endforeach + +@endsection diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/IssueNames.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/IssueNames.php index 380f239f12..88d0195ff3 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/IssueNames.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/IssueNames.php @@ -101,6 +101,13 @@ final class IssueNames { array( 'Phoning Home / Collecting User Data Without Opt-In Consent', 'Phoning Home / Collecting User Data', self::PRIORITY_HIGH ), array( 'The main file of the plugin has a name that does not follow the convention', 'Main plugin file naming', self::PRIORITY_NORMAL ), array( 'Arbitrary input in sensitive function parameters or calls', 'Arbitrary input in sensitive parameters/calls', self::PRIORITY_HIGH ), + array( 'The plugin has problems when it is activated', 'Activation issues', self::PRIORITY_NORMAL ), + array( 'Writing data to disallowed or incorrect locations, and/or asking users to edit plugin files', 'Writing data out of recommended directories', self::PRIORITY_LOW ), + array( 'There are unusual users in the "Contributors" list for this plugin', 'Unusual "Contributors"', self::PRIORITY_NORMAL ), + array( 'High position for your admin dashboard menu item', 'High position dashboard menu', self::PRIORITY_LOW ), + array( 'Improper handling of authentication cookies, keys, and salts.', 'Improper handling of authentication cookies, keys, and salts', self::PRIORITY_HIGH ), + array( 'Direct integration with a third-party AI provider detected.', 'Direct AI integration', self::PRIORITY_LOW ), + array( 'The functionality described is not in the plugin', 'Functionality not in the plugin', self::PRIORITY_HIGH ), ); /** diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/OpenOrder.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/OpenOrder.php new file mode 100644 index 0000000000..57ffb85f85 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/OpenOrder.php @@ -0,0 +1,295 @@ + + */ + public const WAIT_DAYS = array( + 0 => 0, + 2 => 1, + 4 => 2, + 6 => 4, + 8 => 5, + 10 => 6, + PHP_INT_MAX => 8, + ); + + /** + * The days conversations with these tags wait on top of that. + * + * @var array + */ + public const TAG_WAIT_DAYS = array( + 'sockpuppet' => 4, + 'sockpuppeting' => 4, + ); + + /** + * Seconds in an hour. + * + * @var int + */ + private const HOUR = 3600; + + /** + * Seconds in a day. + * + * @var int + */ + private const DAY = 86400; + + /** + * Where the agents' choices of how many to open are kept, followed by their user ID. + * + * @var string + */ + private const LIMIT_OPTION = 'wporgpluginreview.open_limit.'; + + /** + * The active conversations to open, the longest waiting first. + * + * @param Conversation[] $conversations Conversations. + * @param int $limit How many to open at most. + * @return int[] Conversation IDs, in the order to open them. + */ + public static function oldest_first( array $conversations, int $limit ): array { + $waits = self::waits( $conversations ); + arsort( $waits ); + + return array_slice( array_keys( $waits ), 0, max( 0, $limit ) ); + } + + /** + * The active conversations to open by progress: the longest waiting, after each waits as many days as WAIT_DAYS and + * TAG_WAIT_DAYS make it. A conversation that waited that long is ahead of one that waited longer but has to wait + * more. + * + * @param Conversation[] $conversations Conversations. + * @param int $limit How many to open at most. + * @return int[] Conversation IDs, in the order to open them. + */ + public static function progress_based( array $conversations, int $limit ): array { + $waits = self::waits( $conversations ); + $replies = self::team_replies( array_keys( $waits ) ); + $tags = self::tags( array_keys( $waits ) ); + + // How long each waited beyond what it has to; the ones that didn't wait enough yet are left out. + $ahead = array(); + foreach ( $waits as $id => $seconds ) { + $beyond = $seconds - self::wait_days( $replies[ $id ] ?? 0, $tags[ $id ] ?? array() ) * self::DAY; + if ( $beyond >= 0 ) { + $ahead[ $id ] = $beyond; + } + } + arsort( $ahead ); + + return array_slice( array_keys( $ahead ), 0, max( 0, $limit ) ); + } + + /** + * How many days a conversation waits at least before it's opened by progress. + * + * @param int $replies How many replies the team sent to it. + * @param string[] $tags Its tags. + * @return int + */ + public static function wait_days( int $replies, array $tags ): int { + $days = 0; + foreach ( self::WAIT_DAYS as $most => $wait ) { + if ( $replies <= $most ) { + $days = $wait; + break; + } + } + + foreach ( $tags as $tag ) { + $days += self::TAG_WAIT_DAYS[ strtolower( $tag ) ] ?? 0; + } + + return $days; + } + + /** + * How many conversations an agent opens at a time. + * + * @param User $user Agent. + * @return int + */ + public static function limit( User $user ): int { + try { + // Not from Option's cache, which setting and removing options leave as it was. + $limit = (int) \Option::get( self::LIMIT_OPTION . $user->id, self::DEFAULT_LIMIT, true, false ); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not read how many conversations user ' . $user->id . ' opens: ' . $e->getMessage() ); + + return self::DEFAULT_LIMIT; + } + + return $limit >= 1 ? min( $limit, self::MAX_LIMIT ) : self::DEFAULT_LIMIT; + } + + /** + * Sets how many conversations an agent opens at a time, between 1 and MAX_LIMIT; the default when it's empty. + * + * @param User $user Agent. + * @param mixed $limit How many, as sent. + * @return void + */ + public static function set_limit( User $user, mixed $limit ): void { + if ( ! is_numeric( $limit ) || self::DEFAULT_LIMIT === (int) $limit ) { + \Option::remove( self::LIMIT_OPTION . $user->id ); + + return; + } + + \Option::set( self::LIMIT_OPTION . $user->id, max( 1, min( (int) $limit, self::MAX_LIMIT ) ) ); + } + + /** + * Forgets an agent's choice, when they're deleted. + * + * @param int $user_id User ID. + * @return void + */ + public static function forget_limit( int $user_id ): void { + \Option::remove( self::LIMIT_OPTION . $user_id ); + } + + /** + * How long the senders of the active conversations that are old enough to open have waited. + * + * @param Conversation[] $conversations Conversations. + * @return array Seconds, by conversation ID. + */ + private static function waits( array $conversations ): array { + $now = Carbon::now(); + $waits = array(); + foreach ( $conversations as $conversation ) { + if ( Conversation::STATUS_ACTIVE !== (int) $conversation->status ) { + continue; + } + + $since = $conversation->last_reply_at ?? $conversation->created_at; + if ( ! $since ) { + continue; + } + + $since = $since instanceof \DateTimeInterface ? $since->getTimestamp() : Carbon::parse( (string) $since )->getTimestamp(); + $seconds = $now->getTimestamp() - $since; + if ( $seconds >= self::MIN_AGE_HOURS * self::HOUR ) { + $waits[ (int) $conversation->id ] = $seconds; + } + } + + // Ties go to the conversation that came in first. + ksort( $waits ); + + return $waits; + } + + /** + * How many replies the team sent to conversations: their published emails written by agents, the first one + * included, and not notes. + * + * @param int[] $ids Conversation IDs. + * @return array By conversation ID. + */ + private static function team_replies( array $ids ): array { + if ( ! $ids ) { + return array(); + } + + return \DB::table( 'threads' ) + ->whereIn( 'conversation_id', $ids ) + ->where( 'type', Thread::TYPE_MESSAGE ) + ->where( 'state', Thread::STATE_PUBLISHED ) + ->groupBy( 'conversation_id' ) + ->selectRaw( 'conversation_id, count(*) as total' ) + ->pluck( 'total', 'conversation_id' ) + ->map( + static function ( mixed $total ): int { + return (int) $total; + } + ) + ->all(); + } + + /** + * The tags of conversations that make them wait longer, with the Tags module. + * + * @param int[] $ids Conversation IDs. + * @return array Tag names, by conversation ID. + */ + private static function tags( array $ids ): array { + if ( ! $ids || ! Worklist::has_tags() ) { + return array(); + } + + try { + $tags = array(); + $rows = \DB::table( 'conversation_tag' ) + ->join( 'tags', 'tags.id', '=', 'conversation_tag.tag_id' ) + ->whereIn( 'conversation_tag.conversation_id', $ids ) + ->whereIn( 'tags.name', array_keys( self::TAG_WAIT_DAYS ) ) + ->select( 'conversation_tag.conversation_id', 'tags.name' ) + ->get(); + + foreach ( $rows as $row ) { + $tags[ (int) $row->conversation_id ][] = (string) $row->name; + } + + return $tags; + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not read the tags of the conversations to open: ' . $e->getMessage() ); + + return array(); + } + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Reviewers.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Reviewers.php index f2d3101923..0c60cf82f6 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Reviewers.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Reviewers.php @@ -12,7 +12,7 @@ use App\User; /** - * Tells members of the plugins team's teams apart, with the Teams module. + * Tells members of the plugins team's teams apart, and finds its teams, with the Teams module. * * Teams is a premium module, which most contributors don't have; without it, everyone who can see the plugins team's * mailbox is taken for a reviewer. Not final, so tests can answer for Teams. @@ -26,6 +26,21 @@ class Reviewers { */ private const TEAM_PREFIX = 'Plugin'; + /** + * The name of the team that plugin reviews are assigned to. + * + * @var string + */ + public const REVIEW_TEAM = 'Plugin Reviews'; + + /** + * The name of the team that cases are assigned to: questions, guideline reports, replies to rejections, and the + * like. + * + * @var string + */ + public const ADMIN_TEAM = 'Plugin Admins'; + /** * The Teams module's alias. * @@ -68,6 +83,34 @@ public function includes( ?User $user ): bool { return false; } + /** + * A team's ID: the Teams module assigns conversations to a team as if it were a user. + * + * @param string $name The team's name, like REVIEW_TEAM. + * @return int|null Null without the Teams module; 0 when Teams has no such team, or can't tell, which no + * conversation is assigned to. + */ + public function team_id( string $name ): ?int { + if ( ! \App\Module::isActive( self::TEAMS_MODULE ) ) { + return null; + } + + try { + $id = array_search( $name, $this->teams(), true ); + if ( false === $id ) { + \Log::error( '[WPOrgPluginReview] There is no ' . $name . ' team.' ); + + return 0; + } + + return (int) $id; + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not find the ' . $name . ' team: ' . $e->getMessage() ); + + return 0; + } + } + /** * The names of the teams a user is a member of, as the Teams module tells them. * @@ -81,11 +124,24 @@ protected function team_names( User $user ): array { } $team_ids = array_map( 'intval', (array) $teams::getUserTeamIds( (int) $user->id ) ); - $names = array(); + + return array_values( array_intersect_key( $this->teams(), array_flip( $team_ids ) ) ); + } + + /** + * The Teams module's teams. + * + * @return string[] Names, by team ID. + */ + protected function teams(): array { + $teams = self::TEAMS; + if ( ! class_exists( $teams ) ) { + return array(); + } + + $names = array(); foreach ( $teams::getTeams( true ) as $team ) { - if ( in_array( (int) $team->id, $team_ids, true ) ) { - $names[] = (string) $team->first_name; - } + $names[ (int) $team->id ] = (string) $team->first_name; } return $names; diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Subject.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Subject.php index 206e4ac9ea..3d9944a055 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Subject.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Subject.php @@ -1,6 +1,6 @@ '๐', + 'Closure Notice - Licensing/Trademark Violation' => '๐โข๏ธ', + 'Closure Notice - Guideline Violation' => '๐๐', + 'Notice' => '๐โน๏ธ', + 'Security Notice' => '๐๐ก๏ธ', + 'Rejection Explanation' => '๐๐ซ', + ); + + /** + * Short names of the other kinds of email, for the browser tab; others keep their own name. * * @var string[] */ private const KINDS = array( - 'Review in Progress' => 'R', - 'Rejection Explanation' => 'Rejected', - 'Security Notice' => 'Security', - 'Closure Notice' => 'Closed', + 'Closure Notice' => 'Closed', ); /** - * A subject's short version. + * A subject with its kind's mark in place of everything before the plugin's name, which the conversation, its tab, + * and lists of conversations show. + * + * @param string $subject Subject. + * @return string Empty if it isn't of one of the kinds with a mark. + */ + public static function marked( string $subject ): string { + $subject = self::without_prefix( $subject ); + $colon = null === $subject ? false : strpos( $subject, ': ' ); + if ( false === $colon ) { + return ''; + } + + $mark = self::MARKS[ trim( substr( $subject, 0, $colon ) ) ] ?? ''; + + return '' === $mark ? '' : $mark . ' ' . trim( substr( $subject, $colon + 2 ) ); + } + + /** + * A subject's short version, for the browser tab: the marked one, or one without the prefix for other kinds. * * @param string $subject Subject. * @return string Empty if it isn't one of the plugin directory's. */ public static function short( string $subject ): string { - // Replies and forwards keep the subject they answer. - $subject = trim( (string) preg_replace( '/^(?:(?:re|fwd?)\s*:\s*)+/i', '', trim( $subject ) ) ); - if ( 0 !== stripos( $subject, self::PREFIX ) ) { + $marked = self::marked( $subject ); + if ( '' !== $marked ) { + return $marked; + } + + $subject = self::without_prefix( $subject ); + if ( null === $subject ) { return ''; } - $subject = trim( substr( $subject, strlen( self::PREFIX ) ) ); - $colon = strpos( $subject, ': ' ); + $colon = strpos( $subject, ': ' ); if ( false === $colon ) { return $subject; } @@ -70,4 +103,20 @@ public static function short( string $subject ): string { return $short . ': ' . $name; } + + /** + * A plugin directory's subject without its prefix, nor those of replies and forwards. + * + * @param string $subject Subject. + * @return string|null Null if it isn't one of the plugin directory's. + */ + private static function without_prefix( string $subject ): ?string { + // Replies and forwards keep the subject they answer. + $subject = trim( (string) preg_replace( '/^(?:(?:re|fwd?)\s*:\s*)+/i', '', trim( $subject ) ) ); + if ( 0 !== stripos( $subject, self::PREFIX ) ) { + return null; + } + + return trim( substr( $subject, strlen( self::PREFIX ) ) ); + } } diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php new file mode 100644 index 0000000000..4c0d3a0c29 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php @@ -0,0 +1,686 @@ + new self( 'reviews', 'my-reviews', Reviewers::REVIEW_TEAM, 229, 'tasks', false ), + 'cases' => new self( 'cases', 'my-cases', Reviewers::ADMIN_TEAM, 230, 'briefcase', true ), + ); + } + + return self::$all; + } + + /** + * My Reviews: the Plugin Reviews team's. + * + * @return self + */ + public static function reviews(): self { + return self::all()['reviews']; + } + + /** + * My Cases: the Plugin Admins team's. + * + * @return self + */ + public static function cases(): self { + return self::all()['cases']; + } + + /** + * The worklist of a page. + * + * @param string $slug The page's part of its URL. + * @return self|null + */ + public static function by_slug( string $slug ): ?self { + foreach ( self::all() as $worklist ) { + if ( $worklist->slug === $slug ) { + return $worklist; + } + } + + return null; + } + + /** + * The pages' parts of their URLs, for the route. + * + * @return string[] + */ + public static function slugs(): array { + return array_values( + array_map( + static function ( self $worklist ): string { + return $worklist->slug; + }, + self::all() + ) + ); + } + + /** + * The worklist of a folder. + * + * @param mixed $folder Folder. + * @return self|null Null if it isn't one of theirs. + */ + public static function of_folder( mixed $folder ): ?self { + foreach ( self::all() as $worklist ) { + if ( $worklist->is_folder( $folder ) ) { + return $worklist; + } + } + + return null; + } + + /** + * The folder's name. + * + * @return string + */ + public function name(): string { + return 'cases' === $this->key ? __( 'My Cases' ) : __( 'My Reviews' ); + } + + /** + * What a member is called, for choosing whose conversations to show. + * + * @return string + */ + public function member_label(): string { + return 'cases' === $this->key ? __( 'Volunteer' ) : __( 'Reviewer' ); + } + + /** + * The sections' headings. + * + * @param string|null $name The member's name; null for the agent's own. + * @return string[] By section. + */ + public function headings( ?string $name ): array { + if ( 'cases' === $this->key ) { + return array( + 'participating' => null === $name ? __( 'Cases Iโm participating in' ) : __( 'Cases :name is participating in', array( 'name' => $name ) ), + 'mentioned' => null === $name ? __( 'Cases Iโm mentioned in' ) : __( 'Cases :name is mentioned in', array( 'name' => $name ) ), + 'needs' => __( 'Cases that need a volunteer' ), + ); + } + + return array( + 'participating' => null === $name ? __( 'Reviews Iโm participating in' ) : __( 'Reviews :name is participating in', array( 'name' => $name ) ), + 'mentioned' => null === $name ? __( 'Reviews Iโm mentioned in' ) : __( 'Reviews :name is mentioned in', array( 'name' => $name ) ), + 'needs' => __( 'Reviews that need a reviewer' ), + ); + } + + /** + * What's in the sections. + * + * @param string $name The member's name. + * @return string[] By section. + */ + public function descriptions( string $name ): array { + return array( + 'participating' => __( 'With a reply from :name.', array( 'name' => $name ) ), + 'mentioned' => __( 'With a note that mentions :name.', array( 'name' => $name ) ), + 'needs' => 'cases' === $this->key + ? __( 'With :marker in a note, or without a reply from a volunteer yet.', array( 'marker' => self::NEEDS_MARKER ) ) + : __( 'With :marker in a note, or without a reply from a reviewer yet.', array( 'marker' => self::NEEDS_MARKER ) ), + ); + } + + /** + * What an empty section says. + * + * @return string + */ + public function empty_text(): string { + return 'cases' === $this->key ? __( 'No cases.' ) : __( 'No reviews.' ); + } + + /** + * The page of the folder. + * + * @param int $mailbox_id Mailbox ID. + * @return string + */ + public function url( int $mailbox_id ): string { + return route( + 'wporgpluginreview.worklist', + array( + 'mailbox_id' => $mailbox_id, + 'worklist' => $this->slug, + ) + ); + } + + /** + * The folder's ID. + * + * @return int + */ + public function folder_id(): int { + return -$this->folder_type; + } + + /** + * Whether a folder is this worklist's. + * + * @param mixed $folder Folder. + * @return bool + */ + public function is_folder( mixed $folder ): bool { + return $folder instanceof Folder && $this->folder_type === (int) $folder->type; + } + + /** + * The folder, as the mailbox's folder list shows it. + * + * @param Mailbox $mailbox Mailbox. + * @return Folder + */ + public function folder( Mailbox $mailbox ): Folder { + $folder = new Folder(); + $folder->id = $this->folder_id(); + $folder->mailbox_id = (int) $mailbox->id; + $folder->type = $this->folder_type; + $folder->active_count = 0; + $folder->total_count = 0; + + return $folder; + } + + /** + * Adds the worklists' folders to a mailbox's folder list, after Unassigned and before Mine, with how many active + * conversations the agent is participating in. + * + * @param Collection $folders The mailbox's folders. + * @param Mailbox $mailbox Mailbox. + * @param User $user The agent. + * @return Collection + */ + public static function add_folders( Collection $folders, Mailbox $mailbox, User $user ): Collection { + $added = array(); + foreach ( self::all() as $worklist ) { + $folder = $worklist->folder( $mailbox ); + try { + $folder->active_count = $worklist->active_count( $mailbox, $user ); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not count the ' . $worklist->key . ' of user ' . $user->id . ': ' . $e->getMessage() ); + } + $added[] = $folder; + } + + $list = array(); + $placed = false; + foreach ( $folders as $item ) { + if ( ! $placed && Folder::TYPE_MINE === (int) $item->type ) { + array_push( $list, ...$added ); + $placed = true; + } + + $list[] = $item; + + if ( ! $placed && Folder::TYPE_UNASSIGNED === (int) $item->type ) { + array_push( $list, ...$added ); + $placed = true; + } + } + + if ( ! $placed ) { + array_unshift( $list, ...$added ); + } + + return $folders->make( $list ); + } + + /** + * How many active conversations an agent is participating in, as the folder list shows it: the ones whose sender + * wrote last, like core's folders count. + * + * Every member's count comes from one query, kept until something changes them; agents who may only see what's + * assigned to them get theirs counted, and kept, on its own. + * + * @param Mailbox $mailbox Mailbox. + * @param User $user The agent. + * @return int + */ + public function active_count( Mailbox $mailbox, User $user ): int { + if ( $user->canSeeOnlyAssignedConversations() ) { + return (int) \Cache::remember( + $this->counts_key( (int) $mailbox->id, (int) $user->id ), + now()->addMinutes( self::COUNTS_MINUTES ), + function () use ( $mailbox, $user ): int { + return $this->conversations( $mailbox, $user ) + ->where( 'conversations.status', Conversation::STATUS_ACTIVE ) + ->addWhereExistsQuery( self::threads( Thread::TYPE_MESSAGE )->where( 'threads.created_by_user_id', $user->id ) ) + ->count(); + } + ); + } + + $counts = \Cache::remember( + $this->counts_key( (int) $mailbox->id ), + now()->addMinutes( self::COUNTS_MINUTES ), + function () use ( $mailbox ): array { + return $this->conversations( $mailbox ) + ->where( 'conversations.status', Conversation::STATUS_ACTIVE ) + ->join( 'threads', 'threads.conversation_id', '=', 'conversations.id' ) + ->where( 'threads.type', Thread::TYPE_MESSAGE ) + ->where( 'threads.state', Thread::STATE_PUBLISHED ) + ->whereNotNull( 'threads.created_by_user_id' ) + ->groupBy( 'threads.created_by_user_id' ) + ->toBase() + ->selectRaw( 'threads.created_by_user_id as user_id, count(distinct conversations.id) as total' ) + ->pluck( 'total', 'user_id' ) + ->map( + static function ( mixed $total ): int { + return (int) $total; + } + ) + ->all(); + } + ); + + return (int) ( $counts[ (int) $user->id ] ?? 0 ); + } + + /** + * Forgets the worklists' counts of a mailbox, after something that may change them. + * + * @param int $mailbox_id Mailbox ID. + * @return void + */ + public static function forget_counts( int $mailbox_id ): void { + try { + \Cache::forever( self::version_key( $mailbox_id ), uniqid( '', true ) ); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not forget the worklists\' counts of mailbox ' . $mailbox_id . ': ' . $e->getMessage() ); + } + } + + /** + * Where the members' counts of a mailbox are kept, or an agent's own; forgetting them moves them to new keys. + * + * @param int $mailbox_id Mailbox ID. + * @param int|null $user_id The agent, if they're counted on their own. + * @return string + */ + private function counts_key( int $mailbox_id, ?int $user_id = null ): string { + $key = 'wporgpluginreview.worklist.' . $this->key . '.active_counts.' . $mailbox_id . '.' . \Cache::get( self::version_key( $mailbox_id ), '' ); + + return null === $user_id ? $key : $key . '.' . $user_id; + } + + /** + * Where the version of a mailbox's counts is kept. + * + * @param int $mailbox_id Mailbox ID. + * @return string + */ + private static function version_key( int $mailbox_id ): string { + return 'wporgpluginreview.worklist.counts_version.' . $mailbox_id; + } + + /** + * The members whose conversations can be shown: the mailbox's users who are members of the plugins team's teams. + * + * @param Mailbox $mailbox Mailbox. + * @return Collection Users, by name. + */ + public static function reviewers( Mailbox $mailbox ): Collection { + $reviewers = app( Reviewers::class ); + + // The Teams module adds its teams to the list, as users who aren't active. + return collect( $mailbox->usersHavingAccess( true ) ) + ->filter( + static function ( mixed $user ) use ( $reviewers ): bool { + return $user instanceof User && $user->isActive() && $reviewers->includes( $user ); + } + ) + ->values(); + } + + /** + * A member's conversations, by section. + * + * @param Mailbox $mailbox Mailbox. + * @param User $user The member. + * @param User $viewer Who's looking: they only see the conversations they may. + * @return array Conversations by section, the longest waiting first. + */ + public function sections( Mailbox $mailbox, User $user, User $viewer ): array { + return self::load( $this->section_ids( $mailbox, $user, $viewer, self::reviewers( $mailbox ) ) ); + } + + /** + * The IDs of a member's conversations, by section. + * + * A conversation needs someone when a note says so, or when no member replied to it yet: none of the mailbox's + * users who are members of the plugins team's teams, as they are now. + * + * @param Mailbox $mailbox Mailbox. + * @param User $user The member. + * @param User $viewer Who's looking: they only see the conversations they may. + * @param Collection $reviewers The members, as reviewers() tells them. + * @return array Conversation IDs by section, the longest waiting first. + */ + public function section_ids( Mailbox $mailbox, User $user, User $viewer, Collection $reviewers ): array { + $member_ids = $reviewers->pluck( 'id' )->all(); + + $rows = $this->conversations( $mailbox, $viewer ) + ->select( 'conversations.id' ) + ->selectSub( self::threads( Thread::TYPE_MESSAGE )->where( 'threads.created_by_user_id', $user->id ), 'participating' ) + ->selectSub( self::threads( Thread::TYPE_NOTE )->where( 'threads.body', 'like', '%' . self::mention( $user ) . '%' ), 'mentioned' ) + ->selectSub( self::threads( Thread::TYPE_NOTE )->where( 'threads.body', 'like', '%' . self::NEEDS_MARKER . '%' ), 'needs' ) + ->selectSub( self::threads( Thread::TYPE_MESSAGE )->whereIn( 'threads.created_by_user_id', $member_ids ), 'answered' ) + ->orderBy( 'conversations.last_reply_at' ) + ->orderBy( 'conversations.id' ) + ->toBase() + ->get(); + + $ids = array_fill_keys( self::SECTIONS, array() ); + foreach ( $rows as $row ) { + $row->needs = $row->needs || ! $row->answered; + + foreach ( self::SECTIONS as $section ) { + if ( $row->$section ) { + $ids[ $section ][] = (int) $row->id; + break; + } + } + } + + return $ids; + } + + /** + * The tags of conversations, to filter them by, with the Tags module. + * + * @param array $ids Conversation IDs by section. + * @param int $selected The tag filtered by, if any, which is listed even when none of the + * conversations have it anymore. + * @return array By tag ID, by name. + */ + public static function tags( array $ids, int $selected = 0 ): array { + $all = array_merge( ...array_values( $ids ) ); + if ( ! self::has_tags() ) { + return array(); + } + + try { + $tags = array(); + if ( $all ) { + $rows = \DB::table( 'conversation_tag' ) + ->join( 'tags', 'tags.id', '=', 'conversation_tag.tag_id' ) + ->whereIn( 'conversation_tag.conversation_id', $all ) + ->groupBy( 'tags.id', 'tags.name' ) + ->select( 'tags.id', 'tags.name' ) + ->selectRaw( 'count(*) as total' ) + ->get(); + + foreach ( $rows as $row ) { + $tags[ (int) $row->id ] = array( + 'name' => (string) $row->name, + 'count' => (int) $row->total, + ); + } + } + + if ( $selected && ! isset( $tags[ $selected ] ) ) { + $name = \DB::table( 'tags' )->where( 'id', $selected )->value( 'name' ); + if ( null !== $name ) { + $tags[ $selected ] = array( + 'name' => (string) $name, + 'count' => 0, + ); + } + } + + uasort( + $tags, + static function ( array $a, array $b ): int { + return strnatcasecmp( $a['name'], $b['name'] ); + } + ); + + return $tags; + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not read the tags to filter by: ' . $e->getMessage() ); + + return array(); + } + } + + /** + * Keeps the conversations with a tag. + * + * @param array $ids Conversation IDs by section. + * @param int $tag_id Tag ID. + * @return array + */ + public static function filter_by_tag( array $ids, int $tag_id ): array { + $all = array_merge( ...array_values( $ids ) ); + $tagged = $all + ? array_flip( + \DB::table( 'conversation_tag' ) + ->where( 'tag_id', $tag_id ) + ->whereIn( 'conversation_id', $all ) + ->pluck( 'conversation_id' ) + ->map( + static function ( mixed $id ): int { + return (int) $id; + } + ) + ->all() + ) + : array(); + + return array_map( + static function ( array $section ) use ( $tagged ): array { + return array_values( + array_filter( + $section, + static function ( int $id ) use ( $tagged ): bool { + return isset( $tagged[ $id ] ); + } + ) + ); + }, + $ids + ); + } + + /** + * Loads conversations by section, with what conversation lists show of them. + * + * @param array $ids Conversation IDs by section. + * @return array + */ + public static function load( array $ids ): array { + $all = array_merge( ...array_values( $ids ) ); + if ( ! $all ) { + return $ids; + } + + $conversations = self::preload( Conversation::whereIn( 'id', $all )->with( 'customer' )->get() )->keyBy( 'id' ); + + return array_map( + static function ( array $section ) use ( $conversations ): array { + return array_values( array_filter( array_map( array( $conversations, 'get' ), $section ) ) ); + }, + $ids + ); + } + + /** + * Whether there are tags to filter by: the Tags module is on, and its tables are there. + * + * @return bool + */ + public static function has_tags(): bool { + try { + return \App\Module::isActive( 'tags' ) && \Schema::hasTable( 'tags' ) && \Schema::hasTable( 'conversation_tag' ); + } catch ( \Throwable $e ) { + return false; + } + } + + /** + * Lets modules load what they show in conversation lists for all the conversations at once, like the Tags module + * their tags, as core's lists do. + * + * @param Collection $conversations Conversations. + * @return Collection + */ + private static function preload( Collection $conversations ): Collection { + try { + $preloaded = \Eventy::filter( 'conversations_table.preload_table_data', $conversations ); + + return $preloaded instanceof Collection ? $preloaded : $conversations; + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not preload the worklist\'s conversations: ' . $e->getMessage() ); + + return $conversations; + } + } + + /** + * The team's open and pending conversations in the mailbox, or, without the Teams module, the mailbox's. + * + * @param Mailbox $mailbox Mailbox. + * @param User|null $viewer Who's looking, if they only see the conversations they may. + * @return Builder + */ + private function conversations( Mailbox $mailbox, ?User $viewer = null ): Builder { + $query = Conversation::query() + ->where( 'conversations.mailbox_id', $mailbox->id ) + ->where( 'conversations.state', Conversation::STATE_PUBLISHED ) + ->whereIn( 'conversations.status', array( Conversation::STATUS_ACTIVE, Conversation::STATUS_PENDING ) ); + + $team_id = app( Reviewers::class )->team_id( $this->team ); + if ( null !== $team_id ) { + $query->where( 'conversations.user_id', $team_id ); + } + + // Like core's folders, for users who may only see what's assigned to them; Teams adds their teams' conversations. + if ( $viewer && $viewer->canSeeOnlyAssignedConversations() && ! \Eventy::filter( 'folder.only_assigned_condition', false, $query, (int) $viewer->id ) ) { + $query->where( 'conversations.user_id', $viewer->id ); + } + + return $query; + } + + /** + * Whether a conversation has a published thread of a type, to narrow down. + * + * @param int $type Thread type. + * @return QueryBuilder + */ + private static function threads( int $type ): QueryBuilder { + return \DB::table( 'threads' ) + ->selectRaw( '1' ) + ->whereColumn( 'threads.conversation_id', 'conversations.id' ) + ->where( 'threads.type', $type ) + ->where( 'threads.state', Thread::STATE_PUBLISHED ) + ->limit( 1 ); + } + + /** + * How the Mentions module marks a user up in a note. + * + * @param User $user User. + * @return string + */ + private static function mention( User $user ): string { + return 'data-mentioned-id="' . (int) $user->id . '"'; + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json b/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json index fec79c243e..22911f8fab 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json @@ -2,8 +2,8 @@ "name": "WP.org Plugin Review", "alias": "wporgpluginreview", "img": "/modules/wporgpluginreview/img/icon.svg", - "description": "Shows the latest plugin review of a conversation in its sidebar: its details, flags, and issues, from the review emails' Review ID lines.", - "version": "1.0.0", + "description": "Shows the latest plugin review of a conversation in its sidebar: its details, flags, and issues, from the review emails' Review ID lines. Adds My Reviews and My Cases folders of the reviews and cases an agent replied to, is mentioned in, or that need someone, and a header button that copies the download URLs of the plugins in review they have open.", + "version": "1.0.1", "detailsUrl": "https://github.com/WordPress/wordpress.org/tree/trunk/freescout.wordpress.net/Modules/WPOrgPluginReview", "author": "WordPress.org", "authorUrl": "https://wordpress.org/", diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/DownloadsTest.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/DownloadsTest.php new file mode 100644 index 0000000000..8c34a19391 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/DownloadsTest.php @@ -0,0 +1,103 @@ +app->register( WPOrgPluginReviewServiceProvider::class ); + } + + /** + * Clears the modules' cache, which outlives the test's database. + * + * @return void + */ + protected function tearDown(): void { + \App\Module::clearModulesCache(); + + parent::tearDown(); + } + + /** + * Members of the plugins team's teams get the button, hidden until there are URLs, and its script. + * + * @return void + */ + public function test_reviewers_get_the_button(): void { + $this->actingAs( $this->create_user() ); + + $html = $this->header(); + + $this->assertStringContainsString( 'assertStringContainsString( 'class="wporg-downloads-copy"', $html ); + $this->assertStringContainsString( 'class="wporg-downloads-clear"', $html ); + $this->assertStringContainsString( 'Copy :count URLs', html_entity_decode( $html ) ); + + $scripts = implode( ' ', (array) \Eventy::filter( 'javascripts', array() ) ); + $this->assertStringContainsString( '/js/downloads.js', $scripts ); + } + + /** + * Members of other teams don't. + * + * @return void + */ + public function test_others_dont(): void { + $this->actingAs( $this->create_user() ); + + \App\Module::clearModulesCache(); + \App\Module::setActive( Reviewers::TEAMS_MODULE, true ); + \App\Module::clearModulesCache(); + $this->app->instance( + Reviewers::class, + new class() extends Reviewers { + /** + * The names of the teams a user is a member of. + * + * @param User $user User. + * @return string[] + */ + protected function team_names( User $user ): array { + return array( 'Themes' ); + } + } + ); + + $this->assertSame( '', $this->header() ); + } + + /** + * What the module adds to the header's menu. + * + * @return string + */ + private function header(): string { + ob_start(); + \Eventy::action( 'menu.append' ); + + return trim( (string) ob_get_clean() ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MarkedSubjectsTest.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MarkedSubjectsTest.php new file mode 100644 index 0000000000..88760f0acb --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MarkedSubjectsTest.php @@ -0,0 +1,161 @@ +app->register( WPOrgPluginReviewServiceProvider::class ); + + $this->mailbox = $this->create_mailbox( 'Plugins', 'plugins@wordpress.test' ); + $this->sender = $this->create_sender(); + $this->actingAs( $this->create_user() ); + } + + /** + * The conversation tells its script its subjects, for its title, and the marked subjects of the sender's others in + * the mailbox, for the sidebar, escaped. + * + * @return void + */ + public function test_conversation_has_its_subjects_and_its_previous_ones(): void { + $conversation = $this->conversation( '[WordPress Plugin Directory] Review in Progress: My ' ); + $rejection = $this->conversation( 'Re: [WordPress Plugin Directory] Rejection Explanation: Old Plugin' ); + $this->conversation( 'Question about my plugin' ); + $elsewhere = $this->conversation( '[WordPress Plugin Directory] Notice: Other Plugin', $this->create_mailbox( 'Themes', 'themes@wordpress.test' ) ); + + $html = $this->subjects( $conversation ); + + $this->assertStringContainsString( 'data-number="' . $conversation->number . '"', $html ); + $this->assertStringContainsString( 'data-subject="[WordPress Plugin Directory] Review in Progress: My <Plugin>"', $html ); + $this->assertStringContainsString( 'data-short-subject="๐ My <Plugin>"', $html ); + $this->assertStringNotContainsString( '', $html ); + + $this->assertTrue( (bool) preg_match( '/data-subjects="([^"]*)"/', $html, $matches ) ); + $this->assertSame( + array( + $conversation->id => array( + 'subject' => '[WordPress Plugin Directory] Review in Progress: My ', + 'marked' => '๐ My ', + ), + $rejection->id => array( + 'subject' => 'Re: [WordPress Plugin Directory] Rejection Explanation: Old Plugin', + 'marked' => '๐๐ซ Old Plugin', + ), + ), + json_decode( html_entity_decode( $matches[1] ), true ) + ); + + $this->assertSame( '', $this->subjects( $elsewhere ) ); + } + + /** + * A conversation without a marked subject still gets its title shortened, with nothing for the sidebar. + * + * @return void + */ + public function test_conversation_without_a_marked_subject(): void { + $html = $this->subjects( $this->conversation( 'Question about my plugin' ) ); + + $this->assertStringContainsString( 'data-short-subject=""', $html ); + $this->assertStringContainsString( 'data-subjects="{}"', $html ); + } + + /** + * Lists show a marked subject, with the subject itself hidden after it, on hover, and for copying; other subjects, + * and other mailboxes', as they are. + * + * @return void + */ + public function test_lists_mark_subjects(): void { + $this->assertSame( + '๐๐ก๏ธ My <Plugin>Re: [WordPress Plugin Directory] Security Notice: My <Plugin>', + $this->row( $this->conversation( 'Re: [WordPress Plugin Directory] Security Notice: My ' ) ) + ); + $this->assertSame( 'Question about my plugin', $this->row( $this->conversation( 'Question about my plugin' ) ) ); + $this->assertSame( + '[WordPress Plugin Directory] Notice: My Plugin', + $this->row( $this->conversation( '[WordPress Plugin Directory] Notice: My Plugin', $this->create_mailbox( 'Themes', 'themes@wordpress.test' ) ) ) + ); + } + + /** + * Creates a conversation from the sender. + * + * @param string $subject Subject. + * @param Mailbox|null $mailbox Mailbox; the plugins team's if null. + * @return Conversation + */ + private function conversation( string $subject, ?Mailbox $mailbox = null ): Conversation { + $conversation = $this->create_conversation( $mailbox ?? $this->mailbox, $this->sender ); + $conversation->update( array( 'subject' => $subject ) ); + + return $conversation; + } + + /** + * Renders what the module adds after a conversation's subject. + * + * @param Conversation $conversation Conversation. + * @return string + */ + private function subjects( Conversation $conversation ): string { + ob_start(); + \Eventy::action( 'conversation.after_subject', $conversation, $conversation->mailbox ); + + return trim( (string) ob_get_clean() ); + } + + /** + * Renders a conversation's subject in a list, as core does between its hooks. + * + * @param Conversation $conversation Conversation. + * @return string + */ + private function row( Conversation $conversation ): string { + ob_start(); + \Eventy::action( 'conversations_table.before_subject', $conversation ); + echo e( $conversation->getSubject() ); + \Eventy::action( 'conversations_table.after_subject', $conversation ); + + return (string) ob_get_clean(); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/OpenOrderTest.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/OpenOrderTest.php new file mode 100644 index 0000000000..21637ba832 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/OpenOrderTest.php @@ -0,0 +1,291 @@ +app->register( WPOrgPluginReviewServiceProvider::class ); + $this->app['router']->getRoutes()->refreshNameLookups(); + + $this->mailbox = $this->create_mailbox( 'Plugins', 'plugins@wordpress.test' ); + $this->reviewer = $this->create_user(); + } + + /** + * Clears the modules' cache, which outlives the test's database. + * + * @return void + */ + protected function tearDown(): void { + \App\Module::clearModulesCache(); + + parent::tearDown(); + } + + /** + * Waits grow with the team's replies, as WAIT_DAYS has them, and with tags. + * + * @return void + */ + public function test_wait_days(): void { + $expected = array( + 0 => 0, + 1 => 1, + 2 => 1, + 3 => 2, + 4 => 2, + 5 => 4, + 6 => 4, + 7 => 5, + 8 => 5, + 9 => 6, + 10 => 6, + 11 => 8, + 40 => 8, + ); + foreach ( $expected as $replies => $days ) { + $this->assertSame( $days, OpenOrder::wait_days( $replies, array() ), $replies . ' replies' ); + } + + $this->assertSame( 5, OpenOrder::wait_days( 1, array( 'Sockpuppeting', 'guidelines' ) ) ); + $this->assertSame( 4, OpenOrder::wait_days( 0, array( 'sockpuppet' ) ) ); + } + + /** + * Oldest first opens the active conversations that waited at least 12 hours, the longest waiting first, up to the + * limit. + * + * @return void + */ + public function test_oldest_first(): void { + $day = $this->conversation( 1, 24 ); + $fresh = $this->conversation( 1, 11 ); + $half_day = $this->conversation( 1, 12 ); + $week = $this->conversation( 9, 168 ); + $pending = $this->conversation( 1, 300, Conversation::STATUS_PENDING ); + + $all = array( $day, $fresh, $half_day, $week, $pending ); + + $this->assertSame( $this->ids( $week, $day, $half_day ), OpenOrder::oldest_first( $all, 25 ) ); + $this->assertSame( $this->ids( $week, $day ), OpenOrder::oldest_first( $all, 2 ) ); + $this->assertNotContains( (int) $fresh->id, OpenOrder::oldest_first( $all, 25 ) ); + } + + /** + * By progress, each conversation waits as long as the team's replies make it, and those that waited longest beyond + * that go first; the first email counts when the team wrote it, and notes don't. + * + * @return void + */ + public function test_progress_based(): void { + $recipe = $this->conversation( 7, 216 ); + $badges = $this->conversation( 5, 148 ); + $table = $this->conversation( 2, 74 ); + $membership = $this->conversation( 11, 240 ); + $colors = $this->conversation( 6, 72 ); + $gallery = $this->conversation( 1, 38 ); + $contact = $this->conversation( 1, 20 ); + $started = $this->conversation( 0, 13, Conversation::STATUS_ACTIVE, true ); + $noted = $this->conversation( 2, 30, Conversation::STATUS_ACTIVE, false, 9 ); + + $all = array( $recipe, $badges, $table, $membership, $colors, $gallery, $contact, $started, $noted ); + + // 96, 52, 50, 48, 14, 13, and 6 hours beyond their waits; the colors wait 4 days yet, and the contact form one. + $this->assertSame( $this->ids( $recipe, $badges, $table, $membership, $gallery, $started, $noted ), OpenOrder::progress_based( $all, 25 ) ); + $this->assertSame( $this->ids( $recipe, $badges ), OpenOrder::progress_based( $all, 2 ) ); + } + + /** + * The team's first email counts as a reply when the team wrote it. + * + * @return void + */ + public function test_progress_based_counts_the_teams_first_email(): void { + $started = $this->conversation( 3, 60 ); + $answered = $this->conversation( 2, 59, Conversation::STATUS_ACTIVE, true ); + + // Three replies wait 2 days, 12 hours beyond; two wait a day, 35 hours beyond. + $this->assertSame( $this->ids( $answered, $started ), OpenOrder::progress_based( array( $started, $answered ), 25 ) ); + } + + /** + * Tags like sockpuppeting add to the wait, with the Tags module. + * + * @return void + */ + public function test_progress_based_waits_longer_for_tags(): void { + $sockpuppets = $this->conversation( 1, 145 ); + $other = $this->conversation( 1, 100 ); + + $tag_id = (int) \DB::table( 'tags' )->insertGetId( array( 'name' => 'sockpuppeting' ) ); + \DB::table( 'conversation_tag' )->insert( + array( + 'conversation_id' => $sockpuppets->id, + 'tag_id' => $tag_id, + ) + ); + + $this->assertSame( $this->ids( $sockpuppets, $other ), OpenOrder::progress_based( array( $sockpuppets, $other ), 25 ) ); + + \App\Module::clearModulesCache(); + \App\Module::setActive( 'tags', true ); + \App\Module::clearModulesCache(); + + // 145 - 120 hours is 25 beyond, and 100 - 24 is 76. + $this->assertSame( $this->ids( $other, $sockpuppets ), OpenOrder::progress_based( array( $sockpuppets, $other ), 25 ) ); + } + + /** + * How many open at a time is 25, unless the agent chose otherwise, from 1 to 100. + * + * @return void + */ + public function test_limit(): void { + $this->assertSame( OpenOrder::DEFAULT_LIMIT, OpenOrder::limit( $this->reviewer ) ); + + OpenOrder::set_limit( $this->reviewer, '40' ); + $this->assertSame( 40, OpenOrder::limit( $this->reviewer ) ); + + OpenOrder::set_limit( $this->reviewer, '500' ); + $this->assertSame( OpenOrder::MAX_LIMIT, OpenOrder::limit( $this->reviewer ) ); + + OpenOrder::set_limit( $this->reviewer, '0' ); + $this->assertSame( 1, OpenOrder::limit( $this->reviewer ) ); + + OpenOrder::set_limit( $this->reviewer, '' ); + $this->assertSame( OpenOrder::DEFAULT_LIMIT, OpenOrder::limit( $this->reviewer ) ); + $this->assertFalse( \Option::get( 'wporgpluginreview.open_limit.' . $this->reviewer->id, false, true, false ) ); + } + + /** + * Reviewers' profiles have the field, which saves with the profile. + * + * @return void + */ + public function test_profile_field(): void { + OpenOrder::set_limit( $this->reviewer, 30 ); + + ob_start(); + \Eventy::action( 'user.edit.before_photo', $this->reviewer ); + $html = (string) ob_get_clean(); + + $this->assertStringContainsString( 'name="wporgpluginreview_open_limit" value="30"', $html ); + + $request = Request::create( '/', 'POST', array( 'wporgpluginreview_open_limit' => '12' ) ); + $this->assertSame( $this->reviewer, \Eventy::filter( 'user.save_profile', $this->reviewer, $request ) ); + $this->assertSame( 12, OpenOrder::limit( $this->reviewer ) ); + } + + /** + * The participating section has the buttons, with the conversations each opens, in order, and how many. + * + * @return void + */ + public function test_page_has_the_buttons(): void { + $day = $this->conversation( 1, 24 ); + $week = $this->conversation( 9, 168 ); + OpenOrder::set_limit( $this->reviewer, 10 ); + + $html = $this->actingAs( $this->reviewer )->get( Worklist::reviews()->url( (int) $this->mailbox->id ) )->getContent(); + + // The week-old one has to wait 6 days for its 9 replies, and the day-old one 1 for its one. + $this->assertStringContainsString( 'data-conversations="' . $week->id . ',' . $day->id . '"', $html ); + $this->assertStringContainsString( 'Open Oldest First', $html ); + $this->assertStringContainsString( 'Open Progress Based', $html ); + $this->assertStringContainsString( 'Up to 10 at a time', $html ); + } + + /** + * Creates an active conversation the reviewer replied to, assigned to nobody, which without the Teams module is + * in their reviews: the team writes first, unless the sender did, and the sender answers every email. + * + * @param int $replies The team's replies. + * @param int $hours Hours since the sender's latest reply. + * @param int $status Status. + * @param bool $sender_first Whether the sender wrote first, so the team's first email is a reply to them. + * @param int $notes Notes the team added too, which don't count. + * @return Conversation + */ + private function conversation( int $replies, int $hours, int $status = Conversation::STATUS_ACTIVE, bool $sender_first = false, int $notes = 0 ): Conversation { + $conversation = $this->create_conversation( $this->mailbox, $this->create_sender() ); + $at = '2026-01-01 10:00:00'; + + if ( $sender_first || ! $replies ) { + $this->create_thread( $conversation, Thread::TYPE_CUSTOMER, 'My plugin.', null, $at ); + } + for ( $n = 0; $n < $replies; $n++ ) { + $this->create_thread( $conversation, Thread::TYPE_MESSAGE, 'Review.', $this->reviewer, $at ); + $this->create_thread( $conversation, Thread::TYPE_CUSTOMER, 'Updated.', null, $at ); + } + for ( $n = 0; $n < $notes; $n++ ) { + $this->create_thread( $conversation, Thread::TYPE_NOTE, 'Note.', $this->reviewer, $at ); + } + + $conversation->status = $status; + $conversation->last_reply_at = Carbon::now()->subHours( $hours ); + $conversation->save(); + + return $conversation; + } + + /** + * Conversations' IDs. + * + * @param Conversation ...$conversations Conversations. + * @return int[] + */ + private function ids( Conversation ...$conversations ): array { + return array_map( + static function ( Conversation $conversation ): int { + return (int) $conversation->id; + }, + $conversations + ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php index 469f8dc58f..7573951eda 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php @@ -76,7 +76,6 @@ public function test_no_panel_without_a_review(): void { * @return void */ public function test_panel_shows_the_latest_review(): void { - $this->conversation->update( array( 'subject' => '[WordPress Plugin Directory] Review in Progress: My ' ) ); $this->review( 'Plugin URI: https://example.org/p๐ด Use wp_enqueue <script> commandsReview ID: R โOWN my-plugin/jane/1Aug26/T2 8Aug26/4.3 (P0TDX42HGN)' ); $html = $this->panel(); @@ -86,8 +85,6 @@ public function test_panel_shows_the_latest_review(): void { $this->assertStringContainsString( 'https://profiles.wordpress.org/jane/', $html ); $this->assertStringContainsString( 'data-flags="UPD OWN"', $html ); $this->assertStringContainsString( 'data-username="jane"', $html ); - $this->assertStringContainsString( 'data-short-subject="R: My <Plugin>"', $html ); - $this->assertStringContainsString( 'wporg-review-title-toggle', $html ); $this->assertStringContainsString( 'data-title="Use wp_enqueue <script> commands"', $html ); $this->assertStringNotContainsString( '
{{ $review['names']['suggested_slug'] }}
{{ $names['suggested_slug'] }}
{{ __( 'How many conversations the Open buttons of My Reviews and My Cases open in new tabs at a time, up to :max.', array( 'max' => $max ) ) }}
{{ $descriptions[ $section ] }}
+ @if ( ! $conversation->isActive() ){{ $conversation->getStatusName() }}@endif + @action('conversations_table.before_subject', $conversation){{ $conversation->getSubject() }}@action('conversations_table.after_subject', $conversation) +
{{ $conversation->preview }}
{{ $list->empty_text() }}
My plugin.
Review.
Updated.
Note.
Review ID: R โOWN my-plugin/jane/1Aug26/T2 8Aug26/4.3 (P0TDX42HGN)