From fe3b7aca78d448ea605927672bf09394aee37066 Mon Sep 17 00:00:00 2001 From: frantorres Date: Wed, 14 Oct 2026 11:58:28 +0200 Subject: [PATCH 01/13] Freescout: My Reviews screen. --- .../Http/Controllers/MyReviewsController.php | 77 +++ .../Modules/WPOrgPluginReview/Http/routes.php | 4 + .../WPOrgPluginReviewServiceProvider.php | 79 ++- .../WPOrgPluginReview/Public/css/review.css | 172 ++++++- .../WPOrgPluginReview/Public/js/review.js | 8 +- .../Modules/WPOrgPluginReview/README.md | 16 +- .../Resources/views/my-reviews.blade.php | 100 ++++ .../WPOrgPluginReview/Services/MyReviews.php | 230 +++++++++ .../WPOrgPluginReview/Services/Reviewers.php | 59 ++- .../Modules/WPOrgPluginReview/module.json | 2 +- .../WPOrgPluginReview/tests/MyReviewsTest.php | 466 ++++++++++++++++++ 11 files changed, 1203 insertions(+), 10 deletions(-) create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/MyReviewsController.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/my-reviews.blade.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Services/MyReviews.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MyReviewsTest.php diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/MyReviewsController.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/MyReviewsController.php new file mode 100644 index 0000000000..2af576f401 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/MyReviewsController.php @@ -0,0 +1,77 @@ +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 = MyReviews::reviewers( $mailbox ); + $user = $reviewers->firstWhere( 'id', (int) $request->query( 'user', $viewer->id ) ) ?? $viewer; + $sections = MyReviews::sections( $mailbox, $user, $viewer ); + $folders = $mailbox->getAssesibleFolders(); + + // The folder list shows the folder without a count, which would take these queries on every page. + $folder = clone ( $folders->first( array( MyReviews::class, 'is_folder' ) ) ?? MyReviews::folder( $mailbox ) ); + + $folder->active_count = count( + array_filter( + array_merge( ...array_values( $sections ) ), + static function ( $conversation ): bool { + return $conversation->isActive(); + } + ) + ); + + return view( + 'wporgpluginreview::my-reviews', + array( + 'mailbox' => $mailbox, + 'folders' => $folders, + 'folder' => $folder, + 'user' => $user, + 'is_viewer' => (int) $user->id === (int) $viewer->id, + 'reviewers' => $reviewers->contains( 'id', $viewer->id ) ? $reviewers : $reviewers->prepend( $viewer ), + 'sections' => $sections, + ) + ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/routes.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/routes.php index f6350a52a3..2249a14294 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/routes.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/routes.php @@ -17,5 +17,9 @@ 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}/my-reviews', 'MyReviewsController@show' ) + ->where( 'mailbox_id', '[0-9]+' ) + ->name( 'wporgpluginreview.my_reviews' ); } ); diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php index 56012353d9..2ce18b4c9e 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php @@ -10,16 +10,20 @@ namespace Modules\WPOrgPluginReview\Providers; use App\Conversation; +use App\Mailbox; use App\Thread; +use Illuminate\Support\Collection; use Illuminate\Support\ServiceProvider; use Modules\WPOrgPluginReview\Console\IndexReviews; use Modules\WPOrgPluginReview\Services\FlagReplies; +use Modules\WPOrgPluginReview\Services\MyReviews; use Modules\WPOrgPluginReview\Services\Review; use Modules\WPOrgPluginReview\Services\Reviewers; use Modules\WPOrgPluginReview\Services\Subject; /** - * 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; + * adds the My Reviews folder to the plugins team's mailbox. */ final class WPOrgPluginReviewServiceProvider extends ServiceProvider { @@ -37,6 +41,13 @@ final class WPOrgPluginReviewServiceProvider extends ServiceProvider { */ private const PANEL_PRIORITY = 15; + /** + * Priority of the My Reviews folder's hooks: after the Teams and Custom Folders modules', which sort their folders. + * + * @var int + */ + private const FOLDERS_PRIORITY = 30; + /** * Boots the module. * @@ -158,6 +169,72 @@ static function ( mixed $conversation = null ): void { }, self::PANEL_PRIORITY ); + + self::register_folder_hooks(); + } + + /** + * Adds the My Reviews folder to the plugins team's mailbox, for reviewers. + * + * It isn't stored: it's added to the mailbox's folders as they're listed, after the Teams and Custom Folders + * modules have sorted theirs, and links to its own page. + * + * @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 { + if ( ! Review::is_plugins_mailbox( $mailbox ) || ! app( Reviewers::class )->includes( auth()->user() ) ) { + return $folders; + } + + return MyReviews::add_folder( $folders, $mailbox ); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not add the My Reviews folder 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 { + return MyReviews::is_folder( $folder ) ? __( 'My Reviews' ) : $name; + }, + self::FOLDERS_PRIORITY, + 2 + ); + + \Eventy::addFilter( + 'folder.type_icon', + static function ( mixed $icon = '', mixed $folder = null ): mixed { + return MyReviews::is_folder( $folder ) ? 'tasks' : $icon; + }, + self::FOLDERS_PRIORITY, + 2 + ); + + \Eventy::addFilter( + 'folder.url', + static function ( mixed $url = '', mixed $mailbox_id = null, mixed $folder = null ): mixed { + if ( ! MyReviews::is_folder( $folder ) || ! is_numeric( $mailbox_id ) ) { + return $url; + } + + return route( 'wporgpluginreview.my_reviews', array( 'mailbox_id' => (int) $mailbox_id ) ); + }, + self::FOLDERS_PRIORITY, + 3 + ); } /** diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css index 7ff2687c77..d24bafb0e7 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css @@ -1,5 +1,5 @@ /** - * Styles the Plugin Review panel in the conversation sidebar. + * Styles the Plugin Review panel in the conversation sidebar, and the My Reviews folder. * * Text colors meet WCAG AAA (7:1) on the panel background. */ @@ -186,3 +186,173 @@ .wporg-review-email.is-match { color: #175a28; } + +/* + * The My Reviews folder: its own table, like the conversation lists, rather than theirs, which core's scripts reload + * with a folder's conversations. + */ +.wporg-my-reviews-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 12px 14px; + border-bottom: 1px solid #e3e8eb; +} + +.wporg-my-reviews-toolbar label { + margin: 0; +} + +.wporg-my-reviews-toolbar .wporg-my-reviews-user { + width: auto; + max-width: 100%; +} + +.wporg-my-reviews-scope { + font-size: 13px; +} + +.wporg-my-reviews-section + .wporg-my-reviews-section { + border-top: 1px solid #d6dde3; +} + +.wporg-my-reviews-heading { + margin: 0; + padding: 18px 14px 2px; + color: #2a3b47; + font-size: 16px; + font-weight: 700; +} + +.wporg-my-reviews-count { + margin-left: 4px; + padding: 1px 7px; + border-radius: 9999px; + background-color: #eef1f4; + color: #3e4a55; + font-size: 12px; + vertical-align: 2px; +} + +.wporg-my-reviews-description { + margin: 0; + padding: 0 14px 10px; + font-size: 13px; +} + +.wporg-my-reviews-empty { + margin: 0; + padding: 0 14px 18px; + color: #4a5561; +} + +.table.wporg-my-reviews-table { + margin: 0; + table-layout: fixed; +} + +.wporg-my-reviews-table thead { + background-color: #deecf9; +} + +.table.wporg-my-reviews-table th { + padding: 4px 14px 1px; + border-bottom: 1px solid #e3e8eb; + color: #2a3b47; + white-space: nowrap; +} + +.table.wporg-my-reviews-table td { + padding: 0; + border-top: 0; + border-bottom: 1px solid #e3e8eb; + vertical-align: middle; +} + +.wporg-my-reviews-table td a { + display: block; + padding: 12px 14px; + color: #2a3b47; + font-size: 14px; +} + +.wporg-my-reviews-table td a:hover, +.wporg-my-reviews-table td a:focus { + text-decoration: none; +} + +.wporg-my-reviews-table tr:hover td { + background-color: #fbfcfd; +} + +/* Active conversations stand out, like in core's lists. */ +.wporg-my-reviews-table tr.is-active td { + background-color: #f4f8fd; + font-weight: 700; +} + +.wporg-my-reviews-sender { + width: 190px; +} + +.wporg-my-reviews-number { + width: 96px; +} + +.wporg-my-reviews-date { + width: 138px; +} + +.wporg-my-reviews-name, +.wporg-my-reviews-email, +.wporg-my-reviews-title, +.wporg-my-reviews-preview { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wporg-my-reviews-email, +.wporg-my-reviews-preview { + color: #4a5561; + font-size: 12px; + font-weight: 400; +} + +.wporg-my-reviews-preview { + margin-top: 2px; + font-size: 13px; +} + +.wporg-my-reviews-number i { + margin-right: 2px; + color: #4a5561; + font-size: 10px; +} + +.wporg-my-reviews-status { + margin-right: 4px; + padding: 0 6px; + border-radius: 9px; + background-color: #eef1f4; + color: #3e4a55; + font-size: 11px; + font-weight: 600; +} + +@media (max-width: 767px) { + .wporg-my-reviews-table thead, + .wporg-my-reviews-table .wporg-my-reviews-number { + display: none; + } + + .wporg-my-reviews-sender { + width: 35%; + } + + .wporg-my-reviews-date { + width: 90px; + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js index 839e3149a5..03e6d2a2d0 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js @@ -1,6 +1,7 @@ /** * 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. Also shows the reviewer + * chosen in the My Reviews folder. * * @param {jQuery} $ jQuery. */ @@ -668,6 +669,11 @@ return check; } + // The My Reviews folder shows a reviewer's conversations as soon as they're chosen. + $( document ).on( 'change', '.wporg-my-reviews-user', function () { + this.form.submit(); + } ); + $( function () { const $panel = $( '.wporg-review' ); if ( ! $panel.length ) { diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md index bf8025e50f..afb7a8cc43 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 a [My Reviews](#my-reviews) folder of the reviews each reviewer is working on. ## Who sees it @@ -25,6 +25,20 @@ The tab's title also shortens the plugin directory's subjects, like "R: My Plugi 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 + +The plugins team's mailbox gets a **My Reviews** folder, after the Plugin Reviews team's, for the same agents as the panel. It lists the open and pending conversations assigned to the Plugin Reviews team, in three sections: + +- **Conversations I'm participating in**: ones the agent sent a reply to. Notes and drafts don't count. +- **Conversations 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. +- **Conversations that need a reviewer**: ones with `PRTRN` in a note. + +Each conversation shows only in the first section it belongs to, open ones first, then by their latest reply. The reviewer at the top switches to another member of the plugins team's teams, to see theirs. Agents who may only see the conversations assigned to them see only those here too. + +Without the Teams module, the sections are of the mailbox's open and pending conversations, whoever they're assigned to. With it, but without a team named Plugin Reviews, they're empty. + +The folder isn't stored: it's added to the mailbox's folders as they're listed, and shows no count, which would take a query on every page. Its page takes one query for which sections every conversation belongs to, and one to load the ones that do; with a thousand open conversations, that's a few tens of milliseconds. + ## 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/my-reviews.blade.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/my-reviews.blade.php new file mode 100644 index 0000000000..bc718d9fa8 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/my-reviews.blade.php @@ -0,0 +1,100 @@ +@extends('layouts.app') + +@php + $name = $user->getFullName(); + $headings = $is_viewer + ? array( + 'participating' => __( 'Conversations I’m participating in' ), + 'mentioned' => __( 'Conversations I’m mentioned in' ), + 'needs_reviewer' => __( 'Conversations that need a reviewer' ), + ) + : array( + 'participating' => __( 'Conversations :name is participating in', array( 'name' => $name ) ), + 'mentioned' => __( 'Conversations :name is mentioned in', array( 'name' => $name ) ), + 'needs_reviewer' => __( 'Conversations that need a reviewer' ), + ); + $descriptions = array( + 'participating' => __( 'With a reply from :name.', array( 'name' => $name ) ), + 'mentioned' => __( 'With a note that mentions :name.', array( 'name' => $name ) ), + 'needs_reviewer' => __( 'With :marker in a note.', array( 'marker' => \Modules\WPOrgPluginReview\Services\MyReviews::NEEDS_REVIEWER ) ), + ); +@endphp + +@section('title', $folder->getTypeName() . ' - ' . $mailbox->name) + +@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') +
+
+ + + + {{ __( 'Open and pending conversations assigned to :team.', array( 'team' => \Modules\WPOrgPluginReview\Services\Reviewers::REVIEW_TEAM ) ) }} +
+ + @foreach ( $sections as $section => $conversations ) +
+

+ {{ $headings[ $section ] }} + {{ count( $conversations ) }} +

+

{{ $descriptions[ $section ] }}

+ + @if ( $conversations ) + + + + + + + + + + + @foreach ( $conversations as $conversation ) + @php + $url = $conversation->url(); + @endphp + + + + + + + @endforeach + +
{{ __( 'Sender' ) }}{{ __( 'Conversation' ) }}{{ __( 'Number' ) }}{{ __( 'Waiting Since' ) }}
+ + @if ( $conversation->customer ){{ $conversation->customer->getFullName( true ) }}@endif + {{ $conversation->customer_email }} + + + + + @if ( ! $conversation->isActive() ){{ $conversation->getStatusName() }}@endif + {{ $conversation->getSubject() }} + + {{ $conversation->preview }} + + + #{{ $conversation->number }} + + {{ $conversation->getWaitingSince( $folder ) }} +
+ @else +

{{ __( 'No conversations.' ) }}

+ @endif +
+ @endforeach +
+@endsection diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/MyReviews.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/MyReviews.php new file mode 100644 index 0000000000..97a368b8c1 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/MyReviews.php @@ -0,0 +1,230 @@ +id = self::FOLDER_ID; + $folder->mailbox_id = (int) $mailbox->id; + $folder->type = self::FOLDER_TYPE; + $folder->active_count = 0; + $folder->total_count = 0; + + return $folder; + } + + /** + * Adds the folder to a mailbox's folder list, after the Plugin Reviews team's folder. + * + * @param Collection $folders The mailbox's folders. + * @param Mailbox $mailbox Mailbox. + * @return Collection + */ + public static function add_folder( Collection $folders, Mailbox $mailbox ): Collection { + $team_id = app( Reviewers::class )->review_team_id(); + $folder = self::folder( $mailbox ); + $list = array(); + $added = false; + + foreach ( $folders as $item ) { + $list[] = $item; + + if ( ! $added && $team_id && Reviewers::TEAM_FOLDER_TYPE === (int) $item->type && $team_id === (int) $item->user_id ) { + $list[] = $folder; + $added = true; + } + } + + if ( ! $added ) { + $list[] = $folder; + } + + return $folders->make( $list ); + } + + /** + * Whether a folder is the My Reviews folder. + * + * @param mixed $folder Folder. + * @return bool + */ + public static function is_folder( mixed $folder ): bool { + return $folder instanceof Folder && self::FOLDER_TYPE === (int) $folder->type; + } + + /** + * The reviewers whose reviews 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 reviewer's conversations, by section. + * + * @param Mailbox $mailbox Mailbox. + * @param User $user The reviewer. + * @param User $viewer Who's looking: they only see the conversations they may. + * @return array Conversations by section, open ones first, then by their latest reply. + */ + public static function sections( Mailbox $mailbox, User $user, User $viewer ): array { + $rows = self::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_REVIEWER . '%' ), 'needs_reviewer' ) + ->orderBy( 'conversations.status' ) + ->orderBy( 'conversations.last_reply_at', 'desc' ) + ->orderBy( 'conversations.id', 'desc' ) + ->toBase() + ->get(); + + $ids = array_fill_keys( self::SECTIONS, array() ); + foreach ( $rows as $row ) { + foreach ( self::SECTIONS as $section ) { + if ( $row->$section ) { + $ids[ $section ][] = (int) $row->id; + break; + } + } + } + + $all = array_merge( ...array_values( $ids ) ); + if ( ! $all ) { + return $ids; + } + + $conversations = 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 + ); + } + + /** + * The Plugin Reviews team's open and pending conversations in the mailbox, or, without the Teams module, the + * mailbox's. + * + * @param Mailbox $mailbox Mailbox. + * @param User $viewer Who's looking. + * @return Builder + */ + private static function conversations( Mailbox $mailbox, User $viewer ): 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 )->review_team_id(); + 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->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/Services/Reviewers.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Reviewers.php index f2d3101923..cbf97f1611 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 which team reviews are assigned to, 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,20 @@ 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 Teams module's folder type: one folder per team in each of its mailboxes, of the conversations assigned to it. + * + * @var int + */ + public const TEAM_FOLDER_TYPE = 185; + /** * The Teams module's alias. * @@ -68,6 +82,28 @@ public function includes( ?User $user ): bool { return false; } + /** + * The Plugin Reviews team's ID: the Teams module assigns conversations to a team as if it were a user. + * + * @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 review_team_id(): ?int { + if ( ! \App\Module::isActive( self::TEAMS_MODULE ) ) { + return null; + } + + try { + $id = array_search( self::REVIEW_TEAM, $this->teams(), true ); + + return false === $id ? 0 : (int) $id; + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not find the ' . self::REVIEW_TEAM . ' team: ' . $e->getMessage() ); + + return 0; + } + } + /** * The names of the teams a user is a member of, as the Teams module tells them. * @@ -81,11 +117,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/module.json b/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json index fec79c243e..c966d74f9a 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json @@ -2,7 +2,7 @@ "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.", + "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 a My Reviews folder of the reviews an agent replied to, is mentioned in, or that need a reviewer.", "version": "1.0.0", "detailsUrl": "https://github.com/WordPress/wordpress.org/tree/trunk/freescout.wordpress.net/Modules/WPOrgPluginReview", "author": "WordPress.org", diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MyReviewsTest.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MyReviewsTest.php new file mode 100644 index 0000000000..884c792944 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MyReviewsTest.php @@ -0,0 +1,466 @@ +app->register( WPOrgPluginReviewServiceProvider::class ); + + // Routes registered after boot aren't in the name index yet. + $this->app['router']->getRoutes()->refreshNameLookups(); + + $this->mailbox = $this->create_mailbox( 'Plugins', 'plugins@wordpress.test' ); + $this->reviewer = $this->create_user(); + $this->colleague = $this->create_user(); + $this->team = $this->create_user(); + } + + /** + * Clears the modules' cache, which outlives the test's database. + * + * @return void + */ + protected function tearDown(): void { + \App\Module::clearModulesCache(); + + parent::tearDown(); + } + + /** + * Each conversation shows in the first section it belongs to: replied to, then mentioned in a note, then with + * PRTRN in a note. + * + * @return void + */ + public function test_sections_show_each_conversation_once(): void { + $replied = $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Review sent.' ) ) ); + $replied_mentioned = $this->conversation( + array( + array( Thread::TYPE_MESSAGE, $this->reviewer, 'Review sent.' ), + array( Thread::TYPE_NOTE, $this->colleague, $this->mention( $this->reviewer ) . ' yours. PRTRN' ), + ) + ); + $mentioned = $this->conversation( array( array( Thread::TYPE_NOTE, $this->colleague, $this->mention( $this->reviewer ) . ' could you look?' ) ) ); + $mentioned_needs = $this->conversation( + array( + array( Thread::TYPE_NOTE, $this->colleague, 'PRTRN' ), + array( Thread::TYPE_NOTE, $this->colleague, $this->mention( $this->reviewer ) ), + ) + ); + $needs = $this->conversation( array( array( Thread::TYPE_NOTE, $this->colleague, '

PRTRN, someone please take over.

' ) ) ); + + // Their latest replies are at the same time, so the newest conversation comes first. + $this->assertSections( + array( + 'participating' => array( $replied_mentioned, $replied ), + 'mentioned' => array( $mentioned_needs, $mentioned ), + 'needs_reviewer' => array( $needs ), + ), + $this->sections() + ); + } + + /** + * What doesn't count: notes as replies, drafts, mentions and PRTRN in replies, mentions of someone else, and + * conversations that are closed, spam, or deleted. + * + * @return void + */ + public function test_sections_leave_out_what_doesnt_count(): void { + $this->conversation( array( array( Thread::TYPE_NOTE, $this->reviewer, 'Looked at it.' ) ) ); + $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->colleague, $this->mention( $this->reviewer ) . ' PRTRN' ) ) ); + $this->conversation( array( array( Thread::TYPE_NOTE, $this->colleague, $this->mention( $this->colleague ) . ' @Someone' ) ) ); + + $draft = $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Not sent. PRTRN' ) ) ); + Thread::where( 'conversation_id', $draft->id )->update( array( 'state' => Thread::STATE_DRAFT ) ); + + foreach ( array( Conversation::STATUS_CLOSED, Conversation::STATUS_SPAM ) as $status ) { + $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Approved.' ) ), $status ); + } + + $deleted = $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Approved.' ) ) ); + $deleted->state = Conversation::STATE_DELETED; + $deleted->save(); + + $this->assertSections( array(), $this->sections() ); + } + + /** + * Open conversations come first, then by their latest reply; pending ones are shown too. + * + * @return void + */ + public function test_sections_list_open_conversations_first(): void { + $reply = array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Review sent.' ) ); + $pending = $this->conversation( $reply, Conversation::STATUS_PENDING, '2026-08-09 10:00:00' ); + $older = $this->conversation( $reply, Conversation::STATUS_ACTIVE, '2026-08-01 10:00:00' ); + $newer = $this->conversation( $reply, Conversation::STATUS_ACTIVE, '2026-08-05 10:00:00' ); + + $this->assertSections( array( 'participating' => array( $newer, $older, $pending ) ), $this->sections() ); + } + + /** + * The sections are someone else's when they're chosen. + * + * @return void + */ + public function test_sections_are_for_the_chosen_reviewer(): void { + $mine = $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Review sent.' ) ) ); + $theirs = $this->conversation( + array( + array( Thread::TYPE_MESSAGE, $this->colleague, 'Review sent.' ), + array( Thread::TYPE_NOTE, $this->reviewer, $this->mention( $this->reviewer ) ), + ) + ); + + $this->assertSections( + array( 'participating' => array( $theirs ) ), + MyReviews::sections( $this->mailbox, $this->colleague, $this->reviewer ) + ); + $this->assertSections( + array( + 'participating' => array( $mine ), + 'mentioned' => array( $theirs ), + ), + $this->sections() + ); + } + + /** + * With the Teams module, only the Plugin Reviews team's conversations are in the sections, and none if Teams has + * no such team; without it, the mailbox's open conversations are, whoever they're assigned to. + * + * @return void + */ + public function test_sections_are_the_plugin_reviews_teams(): void { + $reply = array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Review sent.' ) ); + $teams = $this->conversation( $reply, Conversation::STATUS_ACTIVE, '2026-08-02 10:00:00' ); + $colleagues = $this->conversation( $reply, Conversation::STATUS_ACTIVE, '2026-08-01 10:00:00', $this->colleague ); + $this->conversation( $reply, Conversation::STATUS_ACTIVE, '2026-08-03 10:00:00', $this->team, $this->create_mailbox( 'Themes', 'themes@wordpress.test' ) ); + + $this->answer_teams( array( (int) $this->team->id => 'Plugin Reviews' ) ); + $this->assertSections( array( 'participating' => array( $teams ) ), $this->sections() ); + + $this->answer_teams( array( (int) $this->team->id => 'Plugin Security' ) ); + $this->assertSections( array(), $this->sections() ); + + \App\Module::clearModulesCache(); + \App\Module::setActive( Reviewers::TEAMS_MODULE, false ); + \App\Module::clearModulesCache(); + $this->assertSections( array( 'participating' => array( $teams, $colleagues ) ), $this->sections() ); + } + + /** + * Agents who may only see the conversations assigned to them only see those here too. + * + * @return void + */ + public function test_sections_respect_only_assigned_conversations(): void { + $viewer = $this->create_user( User::ROLE_USER ); + $viewer->permissions = array( User::PERM_ONLY_ASSIGNED_TICKETS => true ); + $viewer->save(); + + $reply = array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Review sent.' ) ); + $this->conversation( $reply ); + $assigned = $this->conversation( $reply, Conversation::STATUS_ACTIVE, '2026-08-01 10:00:00', $viewer ); + + $this->assertSections( array( 'participating' => array( $assigned ) ), MyReviews::sections( $this->mailbox, $this->reviewer, $viewer ) ); + } + + /** + * Reviewers get the folder in the plugins team's mailbox, after the Plugin Reviews team's folder, linked to its + * page; nobody gets it in other mailboxes. + * + * @return void + */ + public function test_folder_is_added_after_the_teams_folder(): void { + $this->answer_teams( array( (int) $this->team->id => 'Plugin Reviews' ) ); + $team_folder = new Folder(); + $team_folder->id = 999999; + $team_folder->type = Reviewers::TEAM_FOLDER_TYPE; + $team_folder->user_id = $this->team->id; + $other_folder = new Folder(); + $other_folder->type = Folder::TYPE_CLOSED; + + $this->actingAs( $this->reviewer ); + $folders = \Eventy::filter( 'mailbox.folders', collect( array( $team_folder, $other_folder ) ), $this->mailbox ); + + $this->assertSame( array( Reviewers::TEAM_FOLDER_TYPE, MyReviews::FOLDER_TYPE, Folder::TYPE_CLOSED ), $folders->pluck( 'type' )->map( 'intval' )->all() ); + + $folder = $folders->get( 1 ); + $this->assertSame( 'My Reviews', $folder->getTypeName() ); + $this->assertSame( 'tasks', $folder->getTypeIcon() ); + $this->assertSame( route( 'wporgpluginreview.my_reviews', array( 'mailbox_id' => $this->mailbox->id ) ), $folder->url( $this->mailbox->id ) ); + + $themes = $this->create_mailbox( 'Themes', 'themes@wordpress.test' ); + $this->assertCount( 1, \Eventy::filter( 'mailbox.folders', collect( array( $other_folder ) ), $themes ) ); + } + + /** + * Members of other teams don't get the folder, or its page. + * + * @return void + */ + public function test_folder_is_only_for_reviewers(): void { + $this->answer_teams( array(), array( (int) $this->reviewer->id => array( 'Themes' ) ) ); + + $this->actingAs( $this->reviewer ); + $this->assertCount( 0, \Eventy::filter( 'mailbox.folders', collect(), $this->mailbox ) ); + + $this->get( $this->url() )->assertStatus( 403 ); + } + + /** + * The page shows the agent's own reviews by default, with the mailbox's folders. + * + * @return void + */ + public function test_page_shows_the_agents_reviews(): void { + $mine = $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Review sent.' ) ), Conversation::STATUS_ACTIVE, '2026-08-01 10:00:00', null, null, 'Review of ' ); + $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->colleague, 'Review sent.' ) ), Conversation::STATUS_ACTIVE, '2026-08-01 10:00:00', null, null, 'Review of Their Plugin' ); + + $response = $this->actingAs( $this->reviewer )->get( $this->url() ); + $response->assertStatus( 200 ); + $html = $response->getContent(); + + $this->assertStringContainsString( 'Conversations I’m participating in', $html ); + $this->assertStringContainsString( 'Review of <My Plugin>', $html ); + $this->assertStringContainsString( $mine->url(), $html ); + $this->assertStringNotContainsString( 'Review of Their Plugin', $html ); + $this->assertStringContainsString( 'data-folder_id="' . MyReviews::FOLDER_ID . '"', $html ); + } + + /** + * Choosing another reviewer shows theirs; someone who isn't a reviewer can't be chosen. + * + * @return void + */ + public function test_page_shows_the_chosen_reviewers_reviews(): void { + $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->colleague, 'Review sent.' ) ), Conversation::STATUS_ACTIVE, '2026-08-01 10:00:00', null, null, 'Review of Their Plugin' ); + $outsider = $this->create_user(); + $this->answer_teams( array( (int) $this->team->id => 'Plugin Reviews' ), array( (int) $outsider->id => array( 'Themes' ) ) ); + + $response = $this->actingAs( $this->reviewer )->get( $this->url() . '?user=' . $this->colleague->id ); + $response->assertStatus( 200 ); + $html = $response->getContent(); + + $this->assertStringContainsString( e( 'Conversations ' . $this->colleague->getFullName() . ' is participating in' ), $html ); + $this->assertStringContainsString( 'Review of Their Plugin', $html ); + + $html = $this->actingAs( $this->reviewer )->get( $this->url() . '?user=' . $outsider->id )->getContent(); + + $this->assertStringContainsString( 'Conversations I’m participating in', $html ); + $this->assertStringNotContainsString( 'Review of Their Plugin', $html ); + } + + /** + * Other mailboxes have no My Reviews page. + * + * @return void + */ + public function test_page_is_only_for_the_plugins_mailbox(): void { + $themes = $this->create_mailbox( 'Themes', 'themes@wordpress.test' ); + + $this->actingAs( $this->reviewer ) + ->get( route( 'wporgpluginreview.my_reviews', array( 'mailbox_id' => $themes->id ) ) ) + ->assertStatus( 404 ); + } + + /** + * Creates an open conversation in the plugins team's mailbox, assigned to the team, with a sender's first message. + * + * @param array $threads Type, author, and body of each thread after the sender's. + * @param int $status Conversation status. + * @param string $replied_at The latest reply's time. + * @param User|null $assignee Assignee; the team if null. + * @param Mailbox|null $mailbox Mailbox; the plugins team's if null. + * @param string $subject Subject. + * @return Conversation + */ + private function conversation( array $threads, int $status = Conversation::STATUS_ACTIVE, string $replied_at = '2026-08-01 10:00:00', ?User $assignee = null, ?Mailbox $mailbox = null, string $subject = 'Review in Progress' ): Conversation { + $conversation = $this->create_conversation( $mailbox ?? $this->mailbox, $this->create_sender() ); + $this->create_thread( $conversation, Thread::TYPE_CUSTOMER, '

My plugin.

', null, '2026-07-30 10:00:00' ); + + foreach ( $threads as list( $type, $user, $body ) ) { + $this->create_thread( $conversation, $type, $body, $user, '2026-07-31 10:00:00' ); + } + + // Set after the threads, which core's observer would update them from. + $conversation->status = $status; + $conversation->user_id = ( $assignee ?? $this->team )->id; + $conversation->subject = $subject; + $conversation->last_reply_at = $replied_at; + $conversation->save(); + + return $conversation; + } + + /** + * How the Mentions module mentions a user in a note. + * + * @param User $user User. + * @return string + */ + private function mention( User $user ): string { + return '@' . e( $user->first_name ) . ' '; + } + + /** + * The reviewer's own sections. + * + * @return array + */ + private function sections(): array { + return MyReviews::sections( $this->mailbox, $this->reviewer, $this->reviewer ); + } + + /** + * The My Reviews page of the plugins team's mailbox. + * + * @return string + */ + private function url(): string { + return route( 'wporgpluginreview.my_reviews', array( 'mailbox_id' => $this->mailbox->id ) ); + } + + /** + * Asserts which conversations each section has, in order. + * + * @param array $expected Conversations by section; sections left out are empty. + * @param array $sections Sections, from MyReviews::sections(). + * @return void + */ + private function assertSections( array $expected, array $sections ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- Like PHPUnit's own assertions. + $ids = static function ( array $conversations ): array { + return array_map( + static function ( Conversation $conversation ): int { + return (int) $conversation->id; + }, + $conversations + ); + }; + + $this->assertSame( MyReviews::SECTIONS, array_keys( $sections ) ); + foreach ( MyReviews::SECTIONS as $section ) { + $this->assertSame( $ids( $expected[ $section ] ?? array() ), $ids( $sections[ $section ] ), $section ); + } + } + + /** + * Switches the Teams module on, and answers for it. + * + * @param array $teams The teams' names, by ID. + * @param array $members The names of users' teams, by user ID; others are members of Plugin Reviews. + * @return void + */ + private function answer_teams( array $teams, array $members = array() ): void { + \App\Module::clearModulesCache(); + \App\Module::setActive( Reviewers::TEAMS_MODULE, true ); + \App\Module::clearModulesCache(); + + $this->app->instance( + Reviewers::class, + new class( $teams, $members ) extends Reviewers { + /** + * The teams' names, by ID. + * + * @var array + */ + private $teams; + + /** + * The names of users' teams, by user ID. + * + * @var array + */ + private $members; + + /** + * Constructor. + * + * @param array $teams The teams' names, by ID. + * @param array $members The names of users' teams, by user ID. + */ + public function __construct( array $teams, array $members ) { + $this->teams = $teams; + $this->members = $members; + } + + /** + * 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 $this->members[ (int) $user->id ] ?? array( Reviewers::REVIEW_TEAM ); + } + + /** + * The teams' names, by ID. + * + * @return string[] + */ + protected function teams(): array { + return $this->teams; + } + } + ); + } +} From 085d367c5d8cd3541f40e34faf327c0ae653d4c3 Mon Sep 17 00:00:00 2001 From: frantorres Date: Wed, 14 Oct 2026 12:42:04 +0200 Subject: [PATCH 02/13] Freescout: My Reviews screen tweaks. --- .../Http/Controllers/MyReviewsController.php | 16 +- .../WPOrgPluginReviewServiceProvider.php | 69 ++++++++- .../WPOrgPluginReview/Public/css/review.css | 58 +++++-- .../WPOrgPluginReview/Public/js/review.js | 10 ++ .../Modules/WPOrgPluginReview/README.md | 14 +- .../Resources/views/my-reviews.blade.php | 51 ++++--- .../WPOrgPluginReview/Services/MyReviews.php | 142 ++++++++++++++--- .../WPOrgPluginReview/tests/MyReviewsTest.php | 144 ++++++++++++++---- 8 files changed, 413 insertions(+), 91 deletions(-) diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/MyReviewsController.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/MyReviewsController.php index 2af576f401..70825a6a9a 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/MyReviewsController.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/MyReviewsController.php @@ -48,18 +48,12 @@ public function show( Request $request, int $mailbox_id ): View { $user = $reviewers->firstWhere( 'id', (int) $request->query( 'user', $viewer->id ) ) ?? $viewer; $sections = MyReviews::sections( $mailbox, $user, $viewer ); $folders = $mailbox->getAssesibleFolders(); + $folder = $folders->first( array( MyReviews::class, 'is_folder' ) ) ?? MyReviews::folder( $mailbox ); - // The folder list shows the folder without a count, which would take these queries on every page. - $folder = clone ( $folders->first( array( MyReviews::class, 'is_folder' ) ) ?? MyReviews::folder( $mailbox ) ); - - $folder->active_count = count( - array_filter( - array_merge( ...array_values( $sections ) ), - static function ( $conversation ): bool { - return $conversation->isActive(); - } - ) - ); + // The folder list counts the agent's own reviews, which may have changed since they were counted. + if ( (int) $user->id === (int) $viewer->id ) { + $folder->active_count = count( $sections['participating'] ); + } return view( 'wporgpluginreview::my-reviews', diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php index 2ce18b4c9e..8bcb159b3e 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php @@ -177,7 +177,7 @@ static function ( mixed $conversation = null ): void { * Adds the My Reviews folder to the plugins team's mailbox, for reviewers. * * It isn't stored: it's added to the mailbox's folders as they're listed, after the Teams and Custom Folders - * modules have sorted theirs, and links to its own page. + * modules have sorted theirs, and links to its own page. Its count is kept in the cache until a change may affect it. * * @return void */ @@ -190,11 +190,12 @@ static function ( mixed $folders = null, mixed $mailbox = null ): mixed { } try { - if ( ! Review::is_plugins_mailbox( $mailbox ) || ! app( Reviewers::class )->includes( auth()->user() ) ) { + $user = auth()->user(); + if ( ! Review::is_plugins_mailbox( $mailbox ) || ! app( Reviewers::class )->includes( $user ) ) { return $folders; } - return MyReviews::add_folder( $folders, $mailbox ); + return MyReviews::add_folder( $folders, $mailbox, $user ); } catch ( \Throwable $e ) { \Log::error( '[WPOrgPluginReview] Could not add the My Reviews folder to mailbox ' . $mailbox->id . ': ' . $e->getMessage() ); @@ -235,6 +236,68 @@ static function ( mixed $url = '', mixed $mailbox_id = null, mixed $folder = nul self::FOLDERS_PRIORITY, 3 ); + + // The folder's 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. + $hooks = array( + '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 ) { + MyReviews::forget_counts( (int) $prev_mailbox->id ); + } + }, + 20, + 3 + ); + } + + /** + * Forgets the My Reviews 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 ) { + MyReviews::forget_counts( (int) $conversation->mailbox_id ); + } } /** diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css index d24bafb0e7..513082c1da 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css @@ -191,6 +191,14 @@ * The My Reviews folder: its own table, like the conversation lists, rather than theirs, which core's scripts reload * with a folder's conversations. */ + +/* In the folder list, it's with Unassigned and Mine, apart from the rest. */ +#folders > li[data-folder_id="-229"] + li { + margin-bottom: 6px; + padding-bottom: 6px; + border-bottom: 1px solid #e3e8eb; +} + .wporg-my-reviews-toolbar { display: flex; flex-wrap: wrap; @@ -286,6 +294,13 @@ background-color: #fbfcfd; } +/* conv-subject is for the Tags module's scripts; core's lists move it around on phones. */ +.wporg-my-reviews .wporg-my-reviews-table .conv-subject { + position: static !important; + padding-right: 0 !important; + padding-left: 0 !important; +} + /* Active conversations stand out, like in core's lists. */ .wporg-my-reviews-table tr.is-active td { background-color: #f4f8fd; @@ -296,8 +311,33 @@ width: 190px; } -.wporg-my-reviews-number { - width: 96px; +.wporg-my-reviews-cb { + width: 40px; + text-align: center; +} + +.table.wporg-my-reviews-table .wporg-my-reviews-cb { + padding: 4px 0; +} + +/* Core's checkboxes draw their box at the left of their label, which its own lists move into place. */ +.wporg-my-reviews-table .conv-cb label { + left: 10px; +} + +.wporg-my-reviews-threads { + width: 84px; + text-align: center; +} + +.wporg-my-reviews-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-my-reviews-date { @@ -309,6 +349,7 @@ .wporg-my-reviews-title, .wporg-my-reviews-preview { display: block; + margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -326,12 +367,6 @@ font-size: 13px; } -.wporg-my-reviews-number i { - margin-right: 2px; - color: #4a5561; - font-size: 10px; -} - .wporg-my-reviews-status { margin-right: 4px; padding: 0 6px; @@ -344,10 +379,15 @@ @media (max-width: 767px) { .wporg-my-reviews-table thead, - .wporg-my-reviews-table .wporg-my-reviews-number { + .wporg-my-reviews-table .wporg-my-reviews-threads { display: none; } + /* Core hides the checkboxes of its lists on phones, which select by a long press instead. */ + .wporg-my-reviews .wporg-my-reviews-table .wporg-my-reviews-cb { + display: table-cell !important; + } + .wporg-my-reviews-sender { width: 35%; } diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js index 03e6d2a2d0..8bb9d2ea1c 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js @@ -674,6 +674,16 @@ this.form.submit(); } ); + // Its reviews someone is participating in get FreeScout's own bulk actions, which modules like Tags add to. + $( function () { + if ( + $( '.wporg-my-reviews .conv-checkbox' ).length && + 'function' === typeof window.converstationBulkActionsInit + ) { + window.converstationBulkActionsInit(); + } + } ); + $( function () { const $panel = $( '.wporg-review' ); if ( ! $panel.length ) { diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md index afb7a8cc43..4dc746233a 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md @@ -27,17 +27,19 @@ While an agent writes a reply, a warning shows above it when the reply has a Rev ## My Reviews -The plugins team's mailbox gets a **My Reviews** folder, after the Plugin Reviews team's, for the same agents as the panel. It lists the open and pending conversations assigned to the Plugin Reviews team, in three sections: +The plugins team's mailbox gets a **My Reviews** folder, right after Unassigned and before Mine, set apart from the folders after them, for the same agents as the panel. It lists the open and pending conversations assigned to the Plugin Reviews team, in three sections: -- **Conversations I'm participating in**: ones the agent sent a reply to. Notes and drafts don't count. -- **Conversations 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. -- **Conversations that need a reviewer**: ones with `PRTRN` in a note. +- **Reviews I'm participating in**: ones the agent sent a reply to. Notes and drafts don't count. +- **Reviews 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**: ones with `PRTRN` in a note. -Each conversation shows only in the first section it belongs to, open ones first, then by their latest reply. The reviewer at the top switches to another member of the plugins team's teams, to see theirs. Agents who may only see the conversations assigned to them see only those here too. +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 at the top switches to another member of the plugins team's teams, to see theirs. Agents who may only see the conversations assigned to them see only those here too. + +Reviews 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, the sections are of the mailbox's open and pending conversations, whoever they're assigned to. With it, but without a team named Plugin Reviews, they're empty. -The folder isn't stored: it's added to the mailbox's folders as they're listed, and shows no count, which would take a query on every page. Its page takes one query for which sections every conversation belongs to, and one to load the ones that do; with a thousand open conversations, that's a few tens of milliseconds. +The folder isn't stored: it's added to the mailbox's folders as they're listed. Its count is how many reviews the agent is participating in. Every reviewer's count comes from one query, kept in FreeScout's cache for up to ten minutes, and forgotten sooner when an agent's reply is published or removed, 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 every page. The 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. ## WordPress.org diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/my-reviews.blade.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/my-reviews.blade.php index bc718d9fa8..901e1b09fd 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/my-reviews.blade.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/my-reviews.blade.php @@ -4,23 +4,24 @@ $name = $user->getFullName(); $headings = $is_viewer ? array( - 'participating' => __( 'Conversations I’m participating in' ), - 'mentioned' => __( 'Conversations I’m mentioned in' ), - 'needs_reviewer' => __( 'Conversations that need a reviewer' ), + 'participating' => __( 'Reviews I’m participating in' ), + 'mentioned' => __( 'Reviews I’m mentioned in' ), + 'needs_reviewer' => __( 'Reviews that need a reviewer' ), ) : array( - 'participating' => __( 'Conversations :name is participating in', array( 'name' => $name ) ), - 'mentioned' => __( 'Conversations :name is mentioned in', array( 'name' => $name ) ), - 'needs_reviewer' => __( 'Conversations that need a reviewer' ), + 'participating' => __( 'Reviews :name is participating in', array( 'name' => $name ) ), + 'mentioned' => __( 'Reviews :name is mentioned in', array( 'name' => $name ) ), + 'needs_reviewer' => __( 'Reviews that need a reviewer' ), ); $descriptions = array( 'participating' => __( 'With a reply from :name.', array( 'name' => $name ) ), 'mentioned' => __( 'With a note that mentions :name.', array( 'name' => $name ) ), 'needs_reviewer' => __( 'With :marker in a note.', array( 'marker' => \Modules\WPOrgPluginReview\Services\MyReviews::NEEDS_REVIEWER ) ), ); + $title = $folder->getTypeName() . ' - ' . $mailbox->name; @endphp -@section('title', $folder->getTypeName() . ' - ' . $mailbox->name) +@section('title', $folder->active_count ? '(' . (int) $folder->active_count . ') ' . $title : $title) @section('body_attrs')@parent data-mailbox_id="{{ $mailbox->id }}"@endsection @@ -43,6 +44,10 @@ @foreach ( $sections as $section => $conversations ) + @php + // Reviews someone is participating in can be changed together, with FreeScout's own bulk actions. + $selectable = 'participating' === $section && $conversations; + @endphp

{{ $headings[ $section ] }} @@ -51,12 +56,18 @@

{{ $descriptions[ $section ] }}

@if ( $conversations ) + @if ( $selectable ) + @include('conversations/partials/bulk_actions', array( 'mailbox' => $mailbox )) + @endif + @if ( $selectable ) + + @endif - + @@ -65,24 +76,30 @@ @php $url = $conversation->url(); @endphp - + {{-- conv-row and conv-subject let the Tags module show tags added in bulk. --}} + + @if ( $selectable ) + + @endif - -
{{ __( 'Sender' ) }} {{ __( 'Conversation' ) }}{{ __( 'Number' ) }}{{ __( 'Threads' ) }} {{ __( 'Waiting Since' ) }}
+ + @if ( $conversation->customer ){{ $conversation->customer->getFullName( true ) }}@endif {{ $conversation->customer_email }} + - +

@if ( ! $conversation->isActive() ){{ $conversation->getStatusName() }}@endif - {{ $conversation->getSubject() }} - - {{ $conversation->preview }} + @action('conversations_table.before_subject', $conversation){{ $conversation->getSubject() }}@action('conversations_table.after_subject', $conversation) +

+

{{ $conversation->preview }}

- #{{ $conversation->number }} + + {{ (int) $conversation->threads_count }} {{ $conversation->getWaitingSince( $folder ) }} @@ -92,7 +109,7 @@
@else -

{{ __( 'No conversations.' ) }}

+

{{ __( 'No reviews.' ) }}

@endif

@endforeach diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/MyReviews.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/MyReviews.php index 97a368b8c1..32ec468e50 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/MyReviews.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/MyReviews.php @@ -20,7 +20,8 @@ /** * Sorts the open and pending conversations of the Plugin Reviews team into the ones a reviewer replied to, the ones - * they're mentioned in, and the ones that need a reviewer, for the mailbox's My Reviews folder. + * they're mentioned in, and the ones that need a reviewer, for the mailbox's My Reviews folder, and counts the ones + * each reviewer replied to for the folder list. * * Each conversation is only in the first section it belongs to. One query tells, for every conversation, which * sections it belongs to, through the threads' index on conversation and type. @@ -36,7 +37,8 @@ final class MyReviews { public const FOLDER_TYPE = 229; /** - * The folder's ID, which no stored folder has, so the folder list highlights it on its own page only. + * The folder's ID, which no stored folder has, so the folder list highlights it on its own page only; review.css + * finds it in the list by it. * * @var int */ @@ -56,6 +58,13 @@ final class MyReviews { */ public const SECTIONS = array( 'participating', 'mentioned', 'needs_reviewer' ); + /** + * How long the reviewers' counts are kept at most, in minutes; changes to conversations forget them before that. + * + * @var int + */ + private const COUNTS_MINUTES = 10; + /** * The folder, as the mailbox's folder list shows it. * @@ -74,34 +83,111 @@ public static function folder( Mailbox $mailbox ): Folder { } /** - * Adds the folder to a mailbox's folder list, after the Plugin Reviews team's folder. + * Adds the folder to a mailbox's folder list, after Unassigned and before Mine, with how many reviews 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_folder( Collection $folders, Mailbox $mailbox ): Collection { - $team_id = app( Reviewers::class )->review_team_id(); - $folder = self::folder( $mailbox ); - $list = array(); - $added = false; + public static function add_folder( Collection $folders, Mailbox $mailbox, User $user ): Collection { + $folder = self::folder( $mailbox ); + try { + $folder->active_count = self::participating_count( $mailbox, $user ); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not count the My Reviews of user ' . $user->id . ': ' . $e->getMessage() ); + } + $list = array(); + $added = false; foreach ( $folders as $item ) { + if ( ! $added && Folder::TYPE_MINE === (int) $item->type ) { + $list[] = $folder; + $added = true; + } + $list[] = $item; - if ( ! $added && $team_id && Reviewers::TEAM_FOLDER_TYPE === (int) $item->type && $team_id === (int) $item->user_id ) { + if ( ! $added && Folder::TYPE_UNASSIGNED === (int) $item->type ) { $list[] = $folder; $added = true; } } if ( ! $added ) { - $list[] = $folder; + array_unshift( $list, $folder ); } return $folders->make( $list ); } + /** + * How many reviews an agent is participating in, as the folder list shows it. + * + * Every reviewer's count comes from one query, kept until something changes them; agents who may only see what's + * assigned to them get theirs counted on its own. + * + * @param Mailbox $mailbox Mailbox. + * @param User $user The agent. + * @return int + */ + public static function participating_count( Mailbox $mailbox, User $user ): int { + if ( $user->canSeeOnlyAssignedConversations() ) { + return self::conversations( $mailbox, $user ) + ->addWhereExistsQuery( self::threads( Thread::TYPE_MESSAGE )->where( 'threads.created_by_user_id', $user->id ) ) + ->count(); + } + + $counts = \Cache::remember( + self::counts_key( (int) $mailbox->id ), + now()->addMinutes( self::COUNTS_MINUTES ), + static function () use ( $mailbox ): array { + return self::conversations( $mailbox ) + ->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 reviewers' 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::forget( self::counts_key( $mailbox_id ) ); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not forget the My Reviews counts of mailbox ' . $mailbox_id . ': ' . $e->getMessage() ); + } + } + + /** + * Where the reviewers' counts of a mailbox are kept. + * + * @param int $mailbox_id Mailbox ID. + * @return string + */ + private static function counts_key( int $mailbox_id ): string { + return 'wporgpluginreview.my_reviews.counts.' . $mailbox_id; + } + /** * Whether a folder is the My Reviews folder. * @@ -137,7 +223,7 @@ static function ( mixed $user ) use ( $reviewers ): bool { * @param Mailbox $mailbox Mailbox. * @param User $user The reviewer. * @param User $viewer Who's looking: they only see the conversations they may. - * @return array Conversations by section, open ones first, then by their latest reply. + * @return array Conversations by section, the longest waiting first. */ public static function sections( Mailbox $mailbox, User $user, User $viewer ): array { $rows = self::conversations( $mailbox, $viewer ) @@ -145,9 +231,8 @@ public static function sections( Mailbox $mailbox, User $user, User $viewer ): a ->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_REVIEWER . '%' ), 'needs_reviewer' ) - ->orderBy( 'conversations.status' ) - ->orderBy( 'conversations.last_reply_at', 'desc' ) - ->orderBy( 'conversations.id', 'desc' ) + ->orderBy( 'conversations.last_reply_at' ) + ->orderBy( 'conversations.id' ) ->toBase() ->get(); @@ -166,7 +251,7 @@ public static function sections( Mailbox $mailbox, User $user, User $viewer ): a return $ids; } - $conversations = Conversation::whereIn( 'id', $all )->with( 'customer' )->get()->keyBy( 'id' ); + $conversations = self::preload( Conversation::whereIn( 'id', $all )->with( 'customer' )->get() )->keyBy( 'id' ); return array_map( static function ( array $section ) use ( $conversations ): array { @@ -176,15 +261,34 @@ static function ( array $section ) use ( $conversations ): array { ); } + /** + * 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 My Reviews conversations: ' . $e->getMessage() ); + + return $conversations; + } + } + /** * The Plugin Reviews team's open and pending conversations in the mailbox, or, without the Teams module, the * mailbox's. * - * @param Mailbox $mailbox Mailbox. - * @param User $viewer Who's looking. + * @param Mailbox $mailbox Mailbox. + * @param User|null $viewer Who's looking, if they only see the conversations they may. * @return Builder */ - private static function conversations( Mailbox $mailbox, User $viewer ): Builder { + private static function conversations( Mailbox $mailbox, ?User $viewer = null ): Builder { $query = Conversation::query() ->where( 'conversations.mailbox_id', $mailbox->id ) ->where( 'conversations.state', Conversation::STATE_PUBLISHED ) @@ -196,7 +300,7 @@ private static function conversations( Mailbox $mailbox, User $viewer ): Builder } // Like core's folders, for users who may only see what's assigned to them; Teams adds their teams' conversations. - if ( $viewer->canSeeOnlyAssignedConversations() && ! \Eventy::filter( 'folder.only_assigned_condition', false, $query, (int) $viewer->id ) ) { + if ( $viewer && $viewer->canSeeOnlyAssignedConversations() && ! \Eventy::filter( 'folder.only_assigned_condition', false, $query, (int) $viewer->id ) ) { $query->where( 'conversations.user_id', $viewer->id ); } diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MyReviewsTest.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MyReviewsTest.php index 884c792944..362c51bdc5 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MyReviewsTest.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MyReviewsTest.php @@ -105,11 +105,11 @@ public function test_sections_show_each_conversation_once(): void { ); $needs = $this->conversation( array( array( Thread::TYPE_NOTE, $this->colleague, '

PRTRN, someone please take over.

' ) ) ); - // Their latest replies are at the same time, so the newest conversation comes first. + // Their latest replies are at the same time, so the oldest conversation comes first. $this->assertSections( array( - 'participating' => array( $replied_mentioned, $replied ), - 'mentioned' => array( $mentioned_needs, $mentioned ), + 'participating' => array( $replied, $replied_mentioned ), + 'mentioned' => array( $mentioned, $mentioned_needs ), 'needs_reviewer' => array( $needs ), ), $this->sections() @@ -142,17 +142,17 @@ public function test_sections_leave_out_what_doesnt_count(): void { } /** - * Open conversations come first, then by their latest reply; pending ones are shown too. + * The conversations waiting the longest come first, open or pending. * * @return void */ - public function test_sections_list_open_conversations_first(): void { + public function test_sections_list_the_longest_waiting_first(): void { $reply = array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Review sent.' ) ); - $pending = $this->conversation( $reply, Conversation::STATUS_PENDING, '2026-08-09 10:00:00' ); - $older = $this->conversation( $reply, Conversation::STATUS_ACTIVE, '2026-08-01 10:00:00' ); + $pending = $this->conversation( $reply, Conversation::STATUS_PENDING, '2026-08-03 10:00:00' ); $newer = $this->conversation( $reply, Conversation::STATUS_ACTIVE, '2026-08-05 10:00:00' ); + $older = $this->conversation( $reply, Conversation::STATUS_ACTIVE, '2026-08-01 10:00:00' ); - $this->assertSections( array( 'participating' => array( $newer, $older, $pending ) ), $this->sections() ); + $this->assertSections( array( 'participating' => array( $older, $pending, $newer ) ), $this->sections() ); } /** @@ -203,7 +203,7 @@ public function test_sections_are_the_plugin_reviews_teams(): void { \App\Module::clearModulesCache(); \App\Module::setActive( Reviewers::TEAMS_MODULE, false ); \App\Module::clearModulesCache(); - $this->assertSections( array( 'participating' => array( $teams, $colleagues ) ), $this->sections() ); + $this->assertSections( array( 'participating' => array( $colleagues, $teams ) ), $this->sections() ); } /** @@ -216,40 +216,84 @@ public function test_sections_respect_only_assigned_conversations(): void { $viewer->permissions = array( User::PERM_ONLY_ASSIGNED_TICKETS => true ); $viewer->save(); - $reply = array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Review sent.' ) ); + $reply = array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Review sent.' ), array( Thread::TYPE_MESSAGE, $viewer, 'Review sent.' ) ); $this->conversation( $reply ); $assigned = $this->conversation( $reply, Conversation::STATUS_ACTIVE, '2026-08-01 10:00:00', $viewer ); $this->assertSections( array( 'participating' => array( $assigned ) ), MyReviews::sections( $this->mailbox, $this->reviewer, $viewer ) ); + $this->assertSame( 1, MyReviews::participating_count( $this->mailbox, $viewer ) ); } /** - * Reviewers get the folder in the plugins team's mailbox, after the Plugin Reviews team's folder, linked to its - * page; nobody gets it in other mailboxes. + * Reviewers get the folder in the plugins team's mailbox, after Unassigned and before Mine, with how many reviews + * they're participating in, linked to its page; nobody gets it in other mailboxes. * * @return void */ - public function test_folder_is_added_after_the_teams_folder(): void { - $this->answer_teams( array( (int) $this->team->id => 'Plugin Reviews' ) ); - $team_folder = new Folder(); - $team_folder->id = 999999; - $team_folder->type = Reviewers::TEAM_FOLDER_TYPE; - $team_folder->user_id = $this->team->id; - $other_folder = new Folder(); - $other_folder->type = Folder::TYPE_CLOSED; + public function test_folder_is_added_after_unassigned(): void { + $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Review sent.' ) ) ); + $unassigned = $this->folder( Folder::TYPE_UNASSIGNED ); + $mine = $this->folder( Folder::TYPE_MINE ); + $closed = $this->folder( Folder::TYPE_CLOSED ); $this->actingAs( $this->reviewer ); - $folders = \Eventy::filter( 'mailbox.folders', collect( array( $team_folder, $other_folder ) ), $this->mailbox ); + $folders = \Eventy::filter( 'mailbox.folders', collect( array( $unassigned, $mine, $closed ) ), $this->mailbox ); - $this->assertSame( array( Reviewers::TEAM_FOLDER_TYPE, MyReviews::FOLDER_TYPE, Folder::TYPE_CLOSED ), $folders->pluck( 'type' )->map( 'intval' )->all() ); + $this->assertSame( array( Folder::TYPE_UNASSIGNED, MyReviews::FOLDER_TYPE, Folder::TYPE_MINE, Folder::TYPE_CLOSED ), $this->types( $folders ) ); $folder = $folders->get( 1 ); $this->assertSame( 'My Reviews', $folder->getTypeName() ); $this->assertSame( 'tasks', $folder->getTypeIcon() ); + $this->assertSame( 1, (int) $folder->getCount( $folders ) ); $this->assertSame( route( 'wporgpluginreview.my_reviews', array( 'mailbox_id' => $this->mailbox->id ) ), $folder->url( $this->mailbox->id ) ); + // Without Unassigned, it's before Mine; without either, first. + $this->assertSame( array( MyReviews::FOLDER_TYPE, Folder::TYPE_MINE, Folder::TYPE_CLOSED ), $this->types( \Eventy::filter( 'mailbox.folders', collect( array( $mine, $closed ) ), $this->mailbox ) ) ); + $this->assertSame( array( MyReviews::FOLDER_TYPE, Folder::TYPE_CLOSED ), $this->types( \Eventy::filter( 'mailbox.folders', collect( array( $closed ) ), $this->mailbox ) ) ); + $themes = $this->create_mailbox( 'Themes', 'themes@wordpress.test' ); - $this->assertCount( 1, \Eventy::filter( 'mailbox.folders', collect( array( $other_folder ) ), $themes ) ); + $this->assertSame( array( Folder::TYPE_CLOSED ), $this->types( \Eventy::filter( 'mailbox.folders', collect( array( $closed ) ), $themes ) ) ); + } + + /** + * The folder's counts are every reviewer's, kept until a reply, or a change to a conversation, may change them. + * + * @return void + */ + public function test_folder_counts_are_kept_until_they_change(): void { + $reply = array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Review sent.' ) ); + $first = $this->conversation( $reply ); + $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->colleague, 'Review sent.' ) ) ); + + $this->assertSame( 1, MyReviews::participating_count( $this->mailbox, $this->reviewer ) ); + $this->assertSame( 1, MyReviews::participating_count( $this->mailbox, $this->colleague ) ); + $this->assertSame( 0, MyReviews::participating_count( $this->mailbox, $this->team ) ); + + // A reply written without the hooks isn't counted until the counts are forgotten. + $second = $this->conversation( array() ); + Thread::query()->insert( + array( + 'conversation_id' => $second->id, + 'type' => Thread::TYPE_MESSAGE, + 'state' => Thread::STATE_PUBLISHED, + 'source_via' => Thread::PERSON_USER, + 'source_type' => Thread::SOURCE_TYPE_WEB, + 'created_by_user_id' => $this->reviewer->id, + 'body' => 'Review sent.', + ) + ); + $this->assertSame( 1, MyReviews::participating_count( $this->mailbox, $this->reviewer ) ); + + MyReviews::forget_counts( (int) $this->mailbox->id ); + $this->assertSame( 2, MyReviews::participating_count( $this->mailbox, $this->reviewer ) ); + + // An agent's reply forgets them. + $this->conversation( $reply ); + $this->assertSame( 3, MyReviews::participating_count( $this->mailbox, $this->reviewer ) ); + + // So does closing a conversation. + $first->changeStatus( Conversation::STATUS_CLOSED, $this->colleague ); + $this->assertSame( 2, MyReviews::participating_count( $this->mailbox, $this->reviewer ) ); } /** @@ -279,11 +323,31 @@ public function test_page_shows_the_agents_reviews(): void { $response->assertStatus( 200 ); $html = $response->getContent(); - $this->assertStringContainsString( 'Conversations I’m participating in', $html ); + $this->assertStringContainsString( 'Reviews I’m participating in', $html ); $this->assertStringContainsString( 'Review of <My Plugin>', $html ); $this->assertStringContainsString( $mine->url(), $html ); $this->assertStringNotContainsString( 'Review of Their Plugin', $html ); $this->assertStringContainsString( 'data-folder_id="' . MyReviews::FOLDER_ID . '"', $html ); + $this->assertStringContainsString( '(1) My Reviews - Plugins', $html ); + $this->assertStringContainsString( '>Threads</th>', $html ); + $this->assertStringNotContainsString( '>Number</th>', $html ); + } + + /** + * Reviews someone is participating in can be selected, for FreeScout's bulk actions; the others can't. + * + * @return void + */ + public function test_page_lets_participating_reviews_be_changed_together(): void { + $mine = $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Review sent.' ) ) ); + $mentioned = $this->conversation( array( array( Thread::TYPE_NOTE, $this->colleague, $this->mention( $this->reviewer ) ) ) ); + + $html = $this->actingAs( $this->reviewer )->get( $this->url() )->getContent(); + + $this->assertStringContainsString( 'id="conversations-bulk-actions"', $html ); + $this->assertStringContainsString( 'id="cb-' . $mine->id . '"', $html ); + $this->assertStringNotContainsString( 'id="cb-' . $mentioned->id . '"', $html ); + $this->assertSame( 1, substr_count( $html, 'class="toggle-all' ) ); } /** @@ -300,12 +364,12 @@ public function test_page_shows_the_chosen_reviewers_reviews(): void { $response->assertStatus( 200 ); $html = $response->getContent(); - $this->assertStringContainsString( e( 'Conversations ' . $this->colleague->getFullName() . ' is participating in' ), $html ); + $this->assertStringContainsString( e( 'Reviews ' . $this->colleague->getFullName() . ' is participating in' ), $html ); $this->assertStringContainsString( 'Review of Their Plugin', $html ); $html = $this->actingAs( $this->reviewer )->get( $this->url() . '?user=' . $outsider->id )->getContent(); - $this->assertStringContainsString( 'Conversations I’m participating in', $html ); + $this->assertStringContainsString( 'Reviews I’m participating in', $html ); $this->assertStringNotContainsString( 'Review of Their Plugin', $html ); } @@ -351,6 +415,34 @@ private function conversation( array $threads, int $status = Conversation::STATU return $conversation; } + /** + * A folder of a type, as the mailbox's folder list has them. + * + * @param int $type Folder type. + * @return Folder + */ + private function folder( int $type ): Folder { + $folder = new Folder(); + $folder->type = $type; + + return $folder; + } + + /** + * The types of folders, in order. + * + * @param mixed $folders Folders. + * @return int[] + */ + private function types( mixed $folders ): array { + return array_map( + static function ( Folder $folder ): int { + return (int) $folder->type; + }, + array_values( $folders->all() ) + ); + } + /** * How the Mentions module mentions a user in a note. * From aa4650ae44daf3d636a54fd03c1cc19dc04d799c Mon Sep 17 00:00:00 2001 From: frantorres <git@frantorres.es> Date: Wed, 14 Oct 2026 13:06:30 +0200 Subject: [PATCH 03/13] Freescout: My Reviews screen tweaks. --- .../Http/Controllers/MyReviewsController.php | 11 ++- .../WPOrgPluginReviewServiceProvider.php | 6 +- .../Modules/WPOrgPluginReview/README.md | 4 +- .../Resources/views/my-reviews.blade.php | 2 +- .../WPOrgPluginReview/Services/MyReviews.php | 27 +++++--- .../WPOrgPluginReview/tests/MyReviewsTest.php | 68 ++++++++++++++----- 6 files changed, 86 insertions(+), 32 deletions(-) diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/MyReviewsController.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/MyReviewsController.php index 70825a6a9a..9d30674c9c 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/MyReviewsController.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/MyReviewsController.php @@ -50,9 +50,16 @@ public function show( Request $request, int $mailbox_id ): View { $folders = $mailbox->getAssesibleFolders(); $folder = $folders->first( array( MyReviews::class, 'is_folder' ) ) ?? MyReviews::folder( $mailbox ); - // The folder list counts the agent's own reviews, which may have changed since they were counted. + // The folder list counts the agent's own active reviews, which may have changed since they were counted. if ( (int) $user->id === (int) $viewer->id ) { - $folder->active_count = count( $sections['participating'] ); + $folder->active_count = count( + array_filter( + $sections['participating'], + static function ( $conversation ): bool { + return $conversation->isActive(); + } + ) + ); } return view( diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php index 8bcb159b3e..d3eec6e8fd 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php @@ -256,8 +256,12 @@ static function ( mixed $thread = null ) use ( $hook ): void { ); } - // And with conversations that open, close, change hands, or are deleted, merged, or imported. + /* + * 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', diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md index 4dc746233a..924f05e2e7 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md @@ -31,7 +31,7 @@ The plugins team's mailbox gets a **My Reviews** folder, right after Unassigned - **Reviews I'm participating in**: ones the agent sent a reply to. Notes and drafts don't count. - **Reviews 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**: ones with `PRTRN` in a note. +- **Reviews that need a reviewer**: ones with `PRTRN` in a note, and ones no reviewer 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 at the top switches to another member of the plugins team's teams, to see theirs. Agents who may only see the conversations assigned to them see only those here too. @@ -39,7 +39,7 @@ Reviews someone is participating in can be selected, and changed together with F Without the Teams module, the sections are of the mailbox's open and pending conversations, whoever they're assigned to. With it, but without a team named Plugin Reviews, they're empty. -The folder isn't stored: it's added to the mailbox's folders as they're listed. Its count is how many reviews the agent is participating in. Every reviewer's count comes from one query, kept in FreeScout's cache for up to ten minutes, and forgotten sooner when an agent's reply is published or removed, 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 every page. The 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. +The folder isn't stored: it's added to the mailbox's folders as they're listed. Its count is how many active reviews the agent is participating in: the ones whose author wrote last, like core's folders count. Every reviewer's count comes from one query, kept in FreeScout's cache for up to ten minutes, and forgotten sooner when an agent's reply is published or removed, an author 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 every page. The 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. ## WordPress.org diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/my-reviews.blade.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/my-reviews.blade.php index 901e1b09fd..fc79b7a4ca 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/my-reviews.blade.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/my-reviews.blade.php @@ -16,7 +16,7 @@ $descriptions = array( 'participating' => __( 'With a reply from :name.', array( 'name' => $name ) ), 'mentioned' => __( 'With a note that mentions :name.', array( 'name' => $name ) ), - 'needs_reviewer' => __( 'With :marker in a note.', array( 'marker' => \Modules\WPOrgPluginReview\Services\MyReviews::NEEDS_REVIEWER ) ), + 'needs_reviewer' => __( 'With :marker in a note, or without a reply from a reviewer yet.', array( 'marker' => \Modules\WPOrgPluginReview\Services\MyReviews::NEEDS_REVIEWER ) ), ); $title = $folder->getTypeName() . ' - ' . $mailbox->name; @endphp diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/MyReviews.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/MyReviews.php index 32ec468e50..4195127503 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/MyReviews.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/MyReviews.php @@ -20,8 +20,8 @@ /** * Sorts the open and pending conversations of the Plugin Reviews team into the ones a reviewer replied to, the ones - * they're mentioned in, and the ones that need a reviewer, for the mailbox's My Reviews folder, and counts the ones - * each reviewer replied to for the folder list. + * they're mentioned in, and the ones that need a reviewer, for the mailbox's My Reviews folder, and counts the active + * ones each reviewer replied to for the folder list. * * Each conversation is only in the first section it belongs to. One query tells, for every conversation, which * sections it belongs to, through the threads' index on conversation and type. @@ -83,8 +83,8 @@ public static function folder( Mailbox $mailbox ): Folder { } /** - * Adds the folder to a mailbox's folder list, after Unassigned and before Mine, with how many reviews the agent is - * participating in. + * Adds the folder to a mailbox's folder list, after Unassigned and before Mine, with how many active reviews the + * agent is participating in. * * @param Collection $folders The mailbox's folders. * @param Mailbox $mailbox Mailbox. @@ -94,7 +94,7 @@ public static function folder( Mailbox $mailbox ): Folder { public static function add_folder( Collection $folders, Mailbox $mailbox, User $user ): Collection { $folder = self::folder( $mailbox ); try { - $folder->active_count = self::participating_count( $mailbox, $user ); + $folder->active_count = self::active_count( $mailbox, $user ); } catch ( \Throwable $e ) { \Log::error( '[WPOrgPluginReview] Could not count the My Reviews of user ' . $user->id . ': ' . $e->getMessage() ); } @@ -123,7 +123,8 @@ public static function add_folder( Collection $folders, Mailbox $mailbox, User $ } /** - * How many reviews an agent is participating in, as the folder list shows it. + * How many active reviews an agent is participating in, as the folder list shows it: the ones whose author wrote + * last, like core's folders count. * * Every reviewer's count comes from one query, kept until something changes them; agents who may only see what's * assigned to them get theirs counted on its own. @@ -132,9 +133,10 @@ public static function add_folder( Collection $folders, Mailbox $mailbox, User $ * @param User $user The agent. * @return int */ - public static function participating_count( Mailbox $mailbox, User $user ): int { + public static function active_count( Mailbox $mailbox, User $user ): int { if ( $user->canSeeOnlyAssignedConversations() ) { return self::conversations( $mailbox, $user ) + ->where( 'conversations.status', Conversation::STATUS_ACTIVE ) ->addWhereExistsQuery( self::threads( Thread::TYPE_MESSAGE )->where( 'threads.created_by_user_id', $user->id ) ) ->count(); } @@ -144,6 +146,7 @@ public static function participating_count( Mailbox $mailbox, User $user ): int now()->addMinutes( self::COUNTS_MINUTES ), static function () use ( $mailbox ): array { return self::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 ) @@ -185,7 +188,7 @@ public static function forget_counts( int $mailbox_id ): void { * @return string */ private static function counts_key( int $mailbox_id ): string { - return 'wporgpluginreview.my_reviews.counts.' . $mailbox_id; + return 'wporgpluginreview.my_reviews.active_counts.' . $mailbox_id; } /** @@ -220,17 +223,23 @@ static function ( mixed $user ) use ( $reviewers ): bool { /** * A reviewer's conversations, by section. * + * A conversation needs a reviewer when a note says so, or when no reviewer 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 reviewer. * @param User $viewer Who's looking: they only see the conversations they may. * @return array<string, Conversation[]> Conversations by section, the longest waiting first. */ public static function sections( Mailbox $mailbox, User $user, User $viewer ): array { + $reviewer_ids = self::reviewers( $mailbox )->pluck( 'id' )->all(); + $rows = self::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_REVIEWER . '%' ), 'needs_reviewer' ) + ->selectSub( self::threads( Thread::TYPE_MESSAGE )->whereIn( 'threads.created_by_user_id', $reviewer_ids ), 'reviewed' ) ->orderBy( 'conversations.last_reply_at' ) ->orderBy( 'conversations.id' ) ->toBase() @@ -238,6 +247,8 @@ public static function sections( Mailbox $mailbox, User $user, User $viewer ): a $ids = array_fill_keys( self::SECTIONS, array() ); foreach ( $rows as $row ) { + $row->needs_reviewer = $row->needs_reviewer || ! $row->reviewed; + foreach ( self::SECTIONS as $section ) { if ( $row->$section ) { $ids[ $section ][] = (int) $row->id; diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MyReviewsTest.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MyReviewsTest.php index 362c51bdc5..9827a55f15 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MyReviewsTest.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MyReviewsTest.php @@ -118,17 +118,19 @@ public function test_sections_show_each_conversation_once(): void { /** * What doesn't count: notes as replies, drafts, mentions and PRTRN in replies, mentions of someone else, and - * conversations that are closed, spam, or deleted. + * conversations that are closed, spam, or deleted. All have a reviewer's reply, so they don't need a reviewer. * * @return void */ public function test_sections_leave_out_what_doesnt_count(): void { - $this->conversation( array( array( Thread::TYPE_NOTE, $this->reviewer, 'Looked at it.' ) ) ); + $reviewed = array( Thread::TYPE_MESSAGE, $this->colleague, 'Review sent.' ); + $this->conversation( array( $reviewed, array( Thread::TYPE_NOTE, $this->reviewer, 'Looked at it.' ) ) ); $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->colleague, $this->mention( $this->reviewer ) . ' PRTRN' ) ) ); - $this->conversation( array( array( Thread::TYPE_NOTE, $this->colleague, $this->mention( $this->colleague ) . ' <b data-mentioned-id="' . $this->reviewer->id . '0">@Someone</b>' ) ) ); + $this->conversation( array( $reviewed, array( Thread::TYPE_NOTE, $this->colleague, $this->mention( $this->colleague ) . ' <b data-mentioned-id="' . $this->reviewer->id . '0">@Someone</b>' ) ) ); - $draft = $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Not sent. PRTRN' ) ) ); - Thread::where( 'conversation_id', $draft->id )->update( array( 'state' => Thread::STATE_DRAFT ) ); + $draft = $this->conversation( array( $reviewed ) ); + $unsent = $this->create_thread( $draft, Thread::TYPE_MESSAGE, 'Not sent. PRTRN', $this->reviewer, '2026-07-31 11:00:00' ); + Thread::where( 'id', $unsent->id )->update( array( 'state' => Thread::STATE_DRAFT ) ); foreach ( array( Conversation::STATUS_CLOSED, Conversation::STATUS_SPAM ) as $status ) { $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Approved.' ) ), $status ); @@ -141,6 +143,27 @@ public function test_sections_leave_out_what_doesnt_count(): void { $this->assertSections( array(), $this->sections() ); } + /** + * Conversations without a reply from any reviewer need one too: whether nobody replied, or only agents who aren't, + * or aren't anymore, members of the plugins team's teams. + * + * @return void + */ + public function test_sections_need_a_reviewer_without_a_reviewers_reply(): void { + $outsider = $this->create_user(); + $former = $this->create_user(); + $former->status = User::STATUS_DISABLED; + $former->save(); + $this->answer_teams( array( (int) $this->team->id => 'Plugin Reviews' ), array( (int) $outsider->id => array( 'Themes' ) ) ); + + $unreplied = $this->conversation( array() ); + $outsiders = $this->conversation( array( array( Thread::TYPE_MESSAGE, $outsider, 'We got your plugin.' ) ) ); + $formers = $this->conversation( array( array( Thread::TYPE_MESSAGE, $former, 'Review sent.' ) ) ); + $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->colleague, 'Review sent.' ) ) ); + + $this->assertSections( array( 'needs_reviewer' => array( $unreplied, $outsiders, $formers ) ), $this->sections() ); + } + /** * The conversations waiting the longest come first, open or pending. * @@ -221,7 +244,7 @@ public function test_sections_respect_only_assigned_conversations(): void { $assigned = $this->conversation( $reply, Conversation::STATUS_ACTIVE, '2026-08-01 10:00:00', $viewer ); $this->assertSections( array( 'participating' => array( $assigned ) ), MyReviews::sections( $this->mailbox, $this->reviewer, $viewer ) ); - $this->assertSame( 1, MyReviews::participating_count( $this->mailbox, $viewer ) ); + $this->assertSame( 1, MyReviews::active_count( $this->mailbox, $viewer ) ); } /** @@ -256,18 +279,20 @@ public function test_folder_is_added_after_unassigned(): void { } /** - * The folder's counts are every reviewer's, kept until a reply, or a change to a conversation, may change them. + * The folder counts the active reviews each reviewer is participating in, kept until a reply, or a change to a + * conversation, may change them. * * @return void */ - public function test_folder_counts_are_kept_until_they_change(): void { - $reply = array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Review sent.' ) ); - $first = $this->conversation( $reply ); + public function test_folder_counts_active_reviews_until_they_change(): void { + $reply = array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Review sent.' ) ); + $first = $this->conversation( $reply ); + $pending = $this->conversation( $reply, Conversation::STATUS_PENDING ); $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->colleague, 'Review sent.' ) ) ); - $this->assertSame( 1, MyReviews::participating_count( $this->mailbox, $this->reviewer ) ); - $this->assertSame( 1, MyReviews::participating_count( $this->mailbox, $this->colleague ) ); - $this->assertSame( 0, MyReviews::participating_count( $this->mailbox, $this->team ) ); + $this->assertSame( 1, MyReviews::active_count( $this->mailbox, $this->reviewer ) ); + $this->assertSame( 1, MyReviews::active_count( $this->mailbox, $this->colleague ) ); + $this->assertSame( 0, MyReviews::active_count( $this->mailbox, $this->team ) ); // A reply written without the hooks isn't counted until the counts are forgotten. $second = $this->conversation( array() ); @@ -282,18 +307,25 @@ public function test_folder_counts_are_kept_until_they_change(): void { 'body' => 'Review sent.', ) ); - $this->assertSame( 1, MyReviews::participating_count( $this->mailbox, $this->reviewer ) ); + $this->assertSame( 1, MyReviews::active_count( $this->mailbox, $this->reviewer ) ); MyReviews::forget_counts( (int) $this->mailbox->id ); - $this->assertSame( 2, MyReviews::participating_count( $this->mailbox, $this->reviewer ) ); + $this->assertSame( 2, MyReviews::active_count( $this->mailbox, $this->reviewer ) ); // An agent's reply forgets them. $this->conversation( $reply ); - $this->assertSame( 3, MyReviews::participating_count( $this->mailbox, $this->reviewer ) ); + $this->assertSame( 3, MyReviews::active_count( $this->mailbox, $this->reviewer ) ); + + // So does an author's reply, which makes a conversation active without core's status_changed. + $pending->status = Conversation::STATUS_ACTIVE; + $pending->save(); + $this->assertSame( 3, MyReviews::active_count( $this->mailbox, $this->reviewer ) ); + \Eventy::action( 'conversation.customer_replied', $pending, null, null ); + $this->assertSame( 4, MyReviews::active_count( $this->mailbox, $this->reviewer ) ); - // So does closing a conversation. + // And closing a conversation. $first->changeStatus( Conversation::STATUS_CLOSED, $this->colleague ); - $this->assertSame( 2, MyReviews::participating_count( $this->mailbox, $this->reviewer ) ); + $this->assertSame( 3, MyReviews::active_count( $this->mailbox, $this->reviewer ) ); } /** From ebf7d0e0abe3570a9b640012f28860818c80e23b Mon Sep 17 00:00:00 2001 From: frantorres <git@frantorres.es> Date: Wed, 14 Oct 2026 13:43:47 +0200 Subject: [PATCH 04/13] Freescout: My Cases screen. --- .../Http/Controllers/MyReviewsController.php | 78 -- .../Http/Controllers/WorklistController.php | 100 +++ .../Modules/WPOrgPluginReview/Http/routes.php | 11 +- .../WPOrgPluginReviewServiceProvider.php | 39 +- .../WPOrgPluginReview/Public/css/review.css | 89 +-- .../WPOrgPluginReview/Public/js/review.js | 12 +- .../Modules/WPOrgPluginReview/README.md | 22 +- .../Resources/views/my-reviews.blade.php | 117 --- .../Resources/views/worklist.blade.php | 112 +++ .../WPOrgPluginReview/Services/MyReviews.php | 345 --------- .../WPOrgPluginReview/Services/Reviewers.php | 18 +- .../WPOrgPluginReview/Services/Worklist.php | 670 ++++++++++++++++++ .../Modules/WPOrgPluginReview/module.json | 2 +- .../{MyReviewsTest.php => WorklistTest.php} | 205 ++++-- 14 files changed, 1150 insertions(+), 670 deletions(-) delete mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/MyReviewsController.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php delete mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/my-reviews.blade.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/worklist.blade.php delete mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Services/MyReviews.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php rename freescout.wordpress.net/Modules/WPOrgPluginReview/tests/{MyReviewsTest.php => WorklistTest.php} (68%) diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/MyReviewsController.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/MyReviewsController.php deleted file mode 100644 index 9d30674c9c..0000000000 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/MyReviewsController.php +++ /dev/null @@ -1,78 +0,0 @@ -<?php -/** - * My Reviews controller. - * - * @package WordPressdotorg\FreeScout\WPOrgPluginReview - */ - -declare( strict_types = 1 ); - -namespace Modules\WPOrgPluginReview\Http\Controllers; - -use App\Mailbox; -use Illuminate\Http\Request; -use Illuminate\Routing\Controller; -use Illuminate\View\View; -use Modules\WPOrgPluginReview\Services\MyReviews; -use Modules\WPOrgPluginReview\Services\Review; -use Modules\WPOrgPluginReview\Services\Reviewers; - -/** - * Shows the plugins team's mailbox's My Reviews folder: the Plugin Reviews team's open and pending conversations that - * a reviewer replied to, that they're mentioned in, and that need a reviewer. It's the agent's own reviews unless they - * choose another reviewer. - */ -final class MyReviewsController extends Controller { - - /** - * Shows a reviewer's conversations. - * - * @param Request $request Request; `user` chooses the reviewer. - * @param int $mailbox_id Mailbox ID. - * @return View - */ - public function show( Request $request, int $mailbox_id ): View { - $viewer = auth()->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 = MyReviews::reviewers( $mailbox ); - $user = $reviewers->firstWhere( 'id', (int) $request->query( 'user', $viewer->id ) ) ?? $viewer; - $sections = MyReviews::sections( $mailbox, $user, $viewer ); - $folders = $mailbox->getAssesibleFolders(); - $folder = $folders->first( array( MyReviews::class, 'is_folder' ) ) ?? MyReviews::folder( $mailbox ); - - // The folder list counts the agent's own active reviews, which may have changed since they were counted. - if ( (int) $user->id === (int) $viewer->id ) { - $folder->active_count = count( - array_filter( - $sections['participating'], - static function ( $conversation ): bool { - return $conversation->isActive(); - } - ) - ); - } - - return view( - 'wporgpluginreview::my-reviews', - array( - 'mailbox' => $mailbox, - 'folders' => $folders, - 'folder' => $folder, - 'user' => $user, - 'is_viewer' => (int) $user->id === (int) $viewer->id, - 'reviewers' => $reviewers->contains( 'id', $viewer->id ) ? $reviewers : $reviewers->prepend( $viewer ), - 'sections' => $sections, - ) - ); - } -} 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..fb191e3ce3 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php @@ -0,0 +1,100 @@ +<?php +/** + * Worklist controller. + * + * @package WordPressdotorg\FreeScout\WPOrgPluginReview + */ + +declare( strict_types = 1 ); + +namespace Modules\WPOrgPluginReview\Http\Controllers; + +use App\Mailbox; +use Illuminate\Http\Request; +use Illuminate\Routing\Controller; +use Illuminate\View\View; +use Modules\WPOrgPluginReview\Services\Review; +use Modules\WPOrgPluginReview\Services\Reviewers; +use Modules\WPOrgPluginReview\Services\Worklist; + +/** + * Shows the plugins team's mailbox's My Reviews and My Cases folders: a team's open and pending conversations that a + * member replied to, that they're mentioned in, and that need someone. They're the agent's own unless they choose + * another member; My Cases can also be filtered by tag. + */ +final class WorklistController extends Controller { + + /** + * Shows a member's conversations. + * + * @param Request $request Request; `user` chooses the member, and `tag` a tag to filter by. + * @param int $mailbox_id Mailbox ID. + * @param string $worklist The worklist's part of the URL. + * @return View + */ + public function show( Request $request, int $mailbox_id, string $worklist ): View { + $list = Worklist::by_slug( $worklist ); + if ( ! $list ) { + abort( 404 ); + } + + $viewer = auth()->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 ); + + $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 ); + $folders = $mailbox->getAssesibleFolders(); + $folder = $folders->first( array( $list, 'is_folder' ) ) ?? $list->folder( $mailbox ); + + // The folder list counts the agent's own active conversations, which may have changed since they were counted. + if ( (int) $user->id === (int) $viewer->id && ! $tag ) { + $folder->active_count = count( + array_filter( + $sections['participating'], + static function ( $conversation ): bool { + return $conversation->isActive(); + } + ) + ); + } + + return view( + 'wporgpluginreview::worklist', + array( + 'list' => $list, + 'mailbox' => $mailbox, + 'folders' => $folders, + 'folder' => $folder, + 'user' => $user, + 'is_viewer' => (int) $user->id === (int) $viewer->id, + 'reviewers' => $reviewers->contains( 'id', $viewer->id ) ? $reviewers : $reviewers->prepend( $viewer ), + 'sections' => $sections, + 'tags' => $tags, + 'tag' => $tag, + ) + ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/routes.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/routes.php index 2249a14294..743687a874 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/routes.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/routes.php @@ -18,8 +18,13 @@ static function (): void { ->where( 'conversation_id', '[0-9]+' ) ->name( 'wporgpluginreview.plugin' ); - Route::get( '/wporgpluginreview/mailbox/{mailbox_id}/my-reviews', 'MyReviewsController@show' ) - ->where( 'mailbox_id', '[0-9]+' ) - ->name( 'wporgpluginreview.my_reviews' ); + 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 d3eec6e8fd..d28ca27b84 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php @@ -16,14 +16,14 @@ use Illuminate\Support\ServiceProvider; use Modules\WPOrgPluginReview\Console\IndexReviews; use Modules\WPOrgPluginReview\Services\FlagReplies; -use Modules\WPOrgPluginReview\Services\MyReviews; 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; - * adds the My Reviews folder to the plugins team's mailbox. + * adds the My Reviews and My Cases folders to the plugins team's mailbox. */ final class WPOrgPluginReviewServiceProvider extends ServiceProvider { @@ -42,7 +42,7 @@ final class WPOrgPluginReviewServiceProvider extends ServiceProvider { private const PANEL_PRIORITY = 15; /** - * Priority of the My Reviews folder's hooks: after the Teams and Custom Folders modules', which sort their folders. + * Priority of the worklist folders' hooks: after the Teams and Custom Folders modules', which sort their folders. * * @var int */ @@ -174,10 +174,11 @@ static function ( mixed $conversation = null ): void { } /** - * Adds the My Reviews folder to the plugins team's mailbox, for reviewers. + * Adds the My Reviews and My Cases folders to the plugins team's mailbox, for its members. * - * It isn't stored: it's added to the mailbox's folders as they're listed, after the Teams and Custom Folders - * modules have sorted theirs, and links to its own page. Its count is kept in the cache until a change may affect it. + * 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 */ @@ -195,9 +196,9 @@ static function ( mixed $folders = null, mixed $mailbox = null ): mixed { return $folders; } - return MyReviews::add_folder( $folders, $mailbox, $user ); + return Worklist::add_folders( $folders, $mailbox, $user ); } catch ( \Throwable $e ) { - \Log::error( '[WPOrgPluginReview] Could not add the My Reviews folder to mailbox ' . $mailbox->id . ': ' . $e->getMessage() ); + \Log::error( '[WPOrgPluginReview] Could not add the worklist folders to mailbox ' . $mailbox->id . ': ' . $e->getMessage() ); return $folders; } @@ -209,7 +210,9 @@ static function ( mixed $folders = null, mixed $mailbox = null ): mixed { \Eventy::addFilter( 'folder.type_name', static function ( mixed $name = '', mixed $folder = null ): mixed { - return MyReviews::is_folder( $folder ) ? __( 'My Reviews' ) : $name; + $worklist = Worklist::of_folder( $folder ); + + return $worklist ? $worklist->name() : $name; }, self::FOLDERS_PRIORITY, 2 @@ -218,7 +221,9 @@ static function ( mixed $name = '', mixed $folder = null ): mixed { \Eventy::addFilter( 'folder.type_icon', static function ( mixed $icon = '', mixed $folder = null ): mixed { - return MyReviews::is_folder( $folder ) ? 'tasks' : $icon; + $worklist = Worklist::of_folder( $folder ); + + return $worklist ? $worklist->icon : $icon; }, self::FOLDERS_PRIORITY, 2 @@ -227,17 +232,15 @@ static function ( mixed $icon = '', mixed $folder = null ): mixed { \Eventy::addFilter( 'folder.url', static function ( mixed $url = '', mixed $mailbox_id = null, mixed $folder = null ): mixed { - if ( ! MyReviews::is_folder( $folder ) || ! is_numeric( $mailbox_id ) ) { - return $url; - } + $worklist = Worklist::of_folder( $folder ); - return route( 'wporgpluginreview.my_reviews', array( 'mailbox_id' => (int) $mailbox_id ) ); + return $worklist && is_numeric( $mailbox_id ) ? $worklist->url( (int) $mailbox_id ) : $url; }, self::FOLDERS_PRIORITY, 3 ); - // The folder's counts change with agents' replies; only those published, removed, or moved. + // 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, @@ -284,7 +287,7 @@ static function ( mixed $conversation = null, mixed $user = null, mixed $prev_ma self::forget_counts( $conversation ); if ( $prev_mailbox instanceof Mailbox ) { - MyReviews::forget_counts( (int) $prev_mailbox->id ); + Worklist::forget_counts( (int) $prev_mailbox->id ); } }, 20, @@ -293,14 +296,14 @@ static function ( mixed $conversation = null, mixed $user = null, mixed $prev_ma } /** - * Forgets the My Reviews counts of a conversation's mailbox. + * 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 ) { - MyReviews::forget_counts( (int) $conversation->mailbox_id ); + Worklist::forget_counts( (int) $conversation->mailbox_id ); } } diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css index 513082c1da..83666f6cfb 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css @@ -1,5 +1,5 @@ /** - * Styles the Plugin Review panel in the conversation sidebar, and the My Reviews folder. + * Styles the Plugin Review panel in the conversation sidebar, and the My Reviews and My Cases folders. * * Text colors meet WCAG AAA (7:1) on the panel background. */ @@ -188,18 +188,19 @@ } /* - * The My Reviews folder: its own table, like the conversation lists, rather than theirs, which core's scripts reload - * with a folder's conversations. + * 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, it's with Unassigned and Mine, apart from the rest. */ -#folders > li[data-folder_id="-229"] + li { +/* 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-my-reviews-toolbar { +.wporg-worklist-toolbar { display: flex; flex-wrap: wrap; align-items: center; @@ -208,24 +209,24 @@ border-bottom: 1px solid #e3e8eb; } -.wporg-my-reviews-toolbar label { +.wporg-worklist-toolbar label { margin: 0; } -.wporg-my-reviews-toolbar .wporg-my-reviews-user { +.wporg-worklist-toolbar .wporg-worklist-filter { width: auto; max-width: 100%; } -.wporg-my-reviews-scope { +.wporg-worklist-scope { font-size: 13px; } -.wporg-my-reviews-section + .wporg-my-reviews-section { +.wporg-worklist-section + .wporg-worklist-section { border-top: 1px solid #d6dde3; } -.wporg-my-reviews-heading { +.wporg-worklist-heading { margin: 0; padding: 18px 14px 2px; color: #2a3b47; @@ -233,7 +234,7 @@ font-weight: 700; } -.wporg-my-reviews-count { +.wporg-worklist-count { margin-left: 4px; padding: 1px 7px; border-radius: 9999px; @@ -243,94 +244,94 @@ vertical-align: 2px; } -.wporg-my-reviews-description { +.wporg-worklist-description { margin: 0; padding: 0 14px 10px; font-size: 13px; } -.wporg-my-reviews-empty { +.wporg-worklist-empty { margin: 0; padding: 0 14px 18px; color: #4a5561; } -.table.wporg-my-reviews-table { +.table.wporg-worklist-table { margin: 0; table-layout: fixed; } -.wporg-my-reviews-table thead { +.wporg-worklist-table thead { background-color: #deecf9; } -.table.wporg-my-reviews-table th { +.table.wporg-worklist-table th { padding: 4px 14px 1px; border-bottom: 1px solid #e3e8eb; color: #2a3b47; white-space: nowrap; } -.table.wporg-my-reviews-table td { +.table.wporg-worklist-table td { padding: 0; border-top: 0; border-bottom: 1px solid #e3e8eb; vertical-align: middle; } -.wporg-my-reviews-table td a { +.wporg-worklist-table td a { display: block; padding: 12px 14px; color: #2a3b47; font-size: 14px; } -.wporg-my-reviews-table td a:hover, -.wporg-my-reviews-table td a:focus { +.wporg-worklist-table td a:hover, +.wporg-worklist-table td a:focus { text-decoration: none; } -.wporg-my-reviews-table tr:hover td { +.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-my-reviews .wporg-my-reviews-table .conv-subject { +.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-my-reviews-table tr.is-active td { +.wporg-worklist-table tr.is-active td { background-color: #f4f8fd; font-weight: 700; } -.wporg-my-reviews-sender { +.wporg-worklist-sender { width: 190px; } -.wporg-my-reviews-cb { +.wporg-worklist-cb { width: 40px; text-align: center; } -.table.wporg-my-reviews-table .wporg-my-reviews-cb { +.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-my-reviews-table .conv-cb label { +.wporg-worklist-table .conv-cb label { left: 10px; } -.wporg-my-reviews-threads { +.wporg-worklist-threads { width: 84px; text-align: center; } -.wporg-my-reviews-threads-count { +.wporg-worklist-threads-count { padding: 0.1em 0.5em; border: 1px solid #d6dde3; border-radius: 4px; @@ -340,14 +341,14 @@ font-weight: 400; } -.wporg-my-reviews-date { +.wporg-worklist-date { width: 138px; } -.wporg-my-reviews-name, -.wporg-my-reviews-email, -.wporg-my-reviews-title, -.wporg-my-reviews-preview { +.wporg-worklist-name, +.wporg-worklist-email, +.wporg-worklist-title, +.wporg-worklist-preview { display: block; margin: 0; overflow: hidden; @@ -355,19 +356,19 @@ white-space: nowrap; } -.wporg-my-reviews-email, -.wporg-my-reviews-preview { +.wporg-worklist-email, +.wporg-worklist-preview { color: #4a5561; font-size: 12px; font-weight: 400; } -.wporg-my-reviews-preview { +.wporg-worklist-preview { margin-top: 2px; font-size: 13px; } -.wporg-my-reviews-status { +.wporg-worklist-status { margin-right: 4px; padding: 0 6px; border-radius: 9px; @@ -378,21 +379,21 @@ } @media (max-width: 767px) { - .wporg-my-reviews-table thead, - .wporg-my-reviews-table .wporg-my-reviews-threads { + .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-my-reviews .wporg-my-reviews-table .wporg-my-reviews-cb { + .wporg-worklist .wporg-worklist-table .wporg-worklist-cb { display: table-cell !important; } - .wporg-my-reviews-sender { + .wporg-worklist-sender { width: 35%; } - .wporg-my-reviews-date { + .wporg-worklist-date { width: 90px; } } diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js index 8bb9d2ea1c..88a9a8b002 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js @@ -1,7 +1,7 @@ /** * 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. Also shows the reviewer - * chosen in the My Reviews folder. + * shortening the tab's title, and warning about a reply with another author's Review ID. Also shows the member + * and tag chosen in the My Reviews and My Cases folders. * * @param {jQuery} $ jQuery. */ @@ -669,15 +669,15 @@ return check; } - // The My Reviews folder shows a reviewer's conversations as soon as they're chosen. - $( document ).on( 'change', '.wporg-my-reviews-user', function () { + // 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(); } ); - // Its reviews someone is participating in get FreeScout's own bulk actions, which modules like Tags add to. + // Their conversations someone is participating in get FreeScout's own bulk actions, which modules like Tags add to. $( function () { if ( - $( '.wporg-my-reviews .conv-checkbox' ).length && + $( '.wporg-worklist .conv-checkbox' ).length && 'function' === typeof window.converstationBulkActionsInit ) { window.converstationBulkActionsInit(); diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md index 924f05e2e7..1f44b5b199 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. Adds a [My Reviews](#my-reviews) folder of the reviews each reviewer is working on. +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 @@ -25,21 +25,23 @@ The tab's title also shortens the plugin directory's subjects, like "R: My Plugi 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 +## My Reviews and My Cases -The plugins team's mailbox gets a **My Reviews** folder, right after Unassigned and before Mine, set apart from the folders after them, for the same agents as the panel. It lists the open and pending conversations assigned to the Plugin Reviews team, in three sections: +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 Admin team's: questions, guideline reports, replies to rejections, sockpuppets, adoptions, and the like. Each has three sections: -- **Reviews I'm participating in**: ones the agent sent a reply to. Notes and drafts don't count. -- **Reviews 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**: ones with `PRTRN` in a note, and ones no reviewer 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. +- **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 at the top switches to another member of the plugins team's teams, to see theirs. Agents who may only see the conversations assigned to them see only those here too. +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. -Reviews 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. +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, the sections are of the mailbox's open and pending conversations, whoever they're assigned to. With it, but without a team named Plugin Reviews, they're empty. +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 folder isn't stored: it's added to the mailbox's folders as they're listed. Its count is how many active reviews the agent is participating in: the ones whose author wrote last, like core's folders count. Every reviewer's count comes from one query, kept in FreeScout's cache for up to ten minutes, and forgotten sooner when an agent's reply is published or removed, an author 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 every page. The 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. +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 every page. 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`. ## WordPress.org diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/my-reviews.blade.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/my-reviews.blade.php deleted file mode 100644 index fc79b7a4ca..0000000000 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/my-reviews.blade.php +++ /dev/null @@ -1,117 +0,0 @@ -@extends('layouts.app') - -@php - $name = $user->getFullName(); - $headings = $is_viewer - ? array( - 'participating' => __( 'Reviews I’m participating in' ), - 'mentioned' => __( 'Reviews I’m mentioned in' ), - 'needs_reviewer' => __( 'Reviews that need a reviewer' ), - ) - : array( - 'participating' => __( 'Reviews :name is participating in', array( 'name' => $name ) ), - 'mentioned' => __( 'Reviews :name is mentioned in', array( 'name' => $name ) ), - 'needs_reviewer' => __( 'Reviews that need a reviewer' ), - ); - $descriptions = array( - 'participating' => __( 'With a reply from :name.', array( 'name' => $name ) ), - 'mentioned' => __( 'With a note that mentions :name.', array( 'name' => $name ) ), - 'needs_reviewer' => __( 'With :marker in a note, or without a reply from a reviewer yet.', array( 'marker' => \Modules\WPOrgPluginReview\Services\MyReviews::NEEDS_REVIEWER ) ), - ); - $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') -<div class="wporg-my-reviews"> - <form class="wporg-my-reviews-toolbar" method="GET" action="{{ route( 'wporgpluginreview.my_reviews', array( 'mailbox_id' => $mailbox->id ) ) }}"> - <label for="wporg-my-reviews-user">{{ __( 'Reviewer' ) }}</label> - <select id="wporg-my-reviews-user" name="user" class="form-control input-sm wporg-my-reviews-user"> - @foreach ( $reviewers as $reviewer ) - <option value="{{ $reviewer->id }}" @if ( (int) $reviewer->id === (int) $user->id ) selected @endif>{{ $reviewer->getFullName() }}</option> - @endforeach - </select> - <noscript><button type="submit" class="btn btn-default btn-sm">{{ __( 'Show' ) }}</button></noscript> - <span class="wporg-my-reviews-scope text-help">{{ __( 'Open and pending conversations assigned to :team.', array( 'team' => \Modules\WPOrgPluginReview\Services\Reviewers::REVIEW_TEAM ) ) }}</span> - </form> - - @foreach ( $sections as $section => $conversations ) - @php - // Reviews someone is participating in can be changed together, with FreeScout's own bulk actions. - $selectable = 'participating' === $section && $conversations; - @endphp - <section class="wporg-my-reviews-section" data-section="{{ $section }}" aria-labelledby="wporg-my-reviews-{{ $section }}"> - <h2 class="wporg-my-reviews-heading" id="wporg-my-reviews-{{ $section }}"> - {{ $headings[ $section ] }} - <span class="wporg-my-reviews-count">{{ count( $conversations ) }}</span> - </h2> - <p class="wporg-my-reviews-description text-help">{{ $descriptions[ $section ] }}</p> - - @if ( $conversations ) - @if ( $selectable ) - @include('conversations/partials/bulk_actions', array( 'mailbox' => $mailbox )) - @endif - <table class="table wporg-my-reviews-table"> - <thead> - <tr> - @if ( $selectable ) - <th class="conv-cb wporg-my-reviews-cb"><input type="checkbox" class="toggle-all magic-checkbox" id="toggle-all"><label for="toggle-all"><span class="sr-only">{{ __( 'Select All Conversations' ) }}</span></label></th> - @endif - <th class="wporg-my-reviews-sender">{{ __( 'Sender' ) }}</th> - <th class="wporg-my-reviews-subject">{{ __( 'Conversation' ) }}</th> - <th class="wporg-my-reviews-threads">{{ __( 'Threads' ) }}</th> - <th class="wporg-my-reviews-date">{{ __( 'Waiting Since' ) }}</th> - </tr> - </thead> - <tbody> - @foreach ( $conversations as $conversation ) - @php - $url = $conversation->url(); - @endphp - {{-- conv-row and conv-subject let the Tags module show tags added in bulk. --}} - <tr class="@if ( $selectable ) conv-row @endif @if ( $conversation->isActive() ) is-active @endif" data-conversation_id="{{ $conversation->id }}"> - @if ( $selectable ) - <td class="conv-cb wporg-my-reviews-cb"> - <input type="checkbox" class="conv-checkbox magic-checkbox" id="cb-{{ $conversation->id }}" name="cb_{{ $conversation->id }}" value="{{ $conversation->id }}"><label for="cb-{{ $conversation->id }}"><span class="sr-only">{{ __( 'Select Conversation' ) }}: {{ $conversation->getSubject() }}</span></label> - </td> - @endif - <td class="wporg-my-reviews-sender"> - <a href="{{ $url }}" title="{{ $conversation->customer_email }}"> - <span class="wporg-my-reviews-name">@if ( $conversation->customer ){{ $conversation->customer->getFullName( true ) }}@endif</span> - <span class="wporg-my-reviews-email">{{ $conversation->customer_email }}</span> - </a> - </td> - <td class="conv-subject wporg-my-reviews-subject"> - <a href="{{ $url }}"> - <p class="wporg-my-reviews-title"> - @if ( ! $conversation->isActive() )<span class="wporg-my-reviews-status">{{ $conversation->getStatusName() }}</span>@endif - @action('conversations_table.before_subject', $conversation){{ $conversation->getSubject() }}@action('conversations_table.after_subject', $conversation) - </p> - <p class="wporg-my-reviews-preview">{{ $conversation->preview }}</p> - </a> - </td> - <td class="wporg-my-reviews-threads"> - <a href="{{ $url }}"><span class="wporg-my-reviews-threads-count">{{ (int) $conversation->threads_count }}</span></a> - </td> - <td class="wporg-my-reviews-date"> - <a href="{{ $url }}" data-toggle="tooltip" data-html="true" data-placement="left" title="{{ $conversation->getDateTitle() }}">{{ $conversation->getWaitingSince( $folder ) }}</a> - </td> - </tr> - @endforeach - </tbody> - </table> - @else - <p class="wporg-my-reviews-empty">{{ __( 'No reviews.' ) }}</p> - @endif - </section> - @endforeach -</div> -@endsection 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..3bae53c29e --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/worklist.blade.php @@ -0,0 +1,112 @@ +@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') +<div class="wporg-worklist" data-worklist="{{ $list->key }}"> + <form class="wporg-worklist-toolbar" method="GET" action="{{ $list->url( (int) $mailbox->id ) }}"> + <label for="wporg-worklist-user">{{ $list->member_label() }}</label> + <select id="wporg-worklist-user" name="user" class="form-control input-sm wporg-worklist-filter"> + @foreach ( $reviewers as $reviewer ) + <option value="{{ $reviewer->id }}" @if ( (int) $reviewer->id === (int) $user->id ) selected @endif>{{ $reviewer->getFullName() }}</option> + @endforeach + </select> + @if ( $tags ) + <label for="wporg-worklist-tag">{{ __( 'Tag' ) }}</label> + <select id="wporg-worklist-tag" name="tag" class="form-control input-sm wporg-worklist-filter"> + <option value="">{{ __( 'All tags' ) }}</option> + @foreach ( $tags as $tag_id => $tag_option ) + <option value="{{ $tag_id }}" @if ( (int) $tag_id === (int) $tag ) selected @endif>{{ $tag_option['name'] }} ({{ $tag_option['count'] }})</option> + @endforeach + </select> + @endif + <noscript><button type="submit" class="btn btn-default btn-sm">{{ __( 'Show' ) }}</button></noscript> + <span class="wporg-worklist-scope text-help">{{ __( 'Open and pending conversations assigned to :team.', array( 'team' => $list->team ) ) }}</span> + </form> + + @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 + <section class="wporg-worklist-section" data-section="{{ $section }}" aria-labelledby="wporg-worklist-{{ $section }}"> + <h2 class="wporg-worklist-heading" id="wporg-worklist-{{ $section }}"> + {{ $headings[ $section ] }} + <span class="wporg-worklist-count">{{ count( $conversations ) }}</span> + </h2> + <p class="wporg-worklist-description text-help">{{ $descriptions[ $section ] }}</p> + + @if ( $conversations ) + @if ( $selectable ) + @include('conversations/partials/bulk_actions', array( 'mailbox' => $mailbox )) + @endif + <table class="table wporg-worklist-table"> + <thead> + <tr> + @if ( $selectable ) + <th class="conv-cb wporg-worklist-cb"><input type="checkbox" class="toggle-all magic-checkbox" id="toggle-all"><label for="toggle-all"><span class="sr-only">{{ __( 'Select All Conversations' ) }}</span></label></th> + @endif + <th class="wporg-worklist-sender">{{ __( 'Sender' ) }}</th> + <th class="wporg-worklist-subject">{{ __( 'Conversation' ) }}</th> + <th class="wporg-worklist-threads">{{ __( 'Threads' ) }}</th> + <th class="wporg-worklist-date">{{ __( 'Waiting Since' ) }}</th> + </tr> + </thead> + <tbody> + @foreach ( $conversations as $conversation ) + @php + $url = $conversation->url(); + @endphp + {{-- conv-row and conv-subject let the Tags module show tags added in bulk. --}} + <tr class="@if ( $selectable ) conv-row @endif @if ( $conversation->isActive() ) is-active @endif" data-conversation_id="{{ $conversation->id }}"> + @if ( $selectable ) + <td class="conv-cb wporg-worklist-cb"> + <input type="checkbox" class="conv-checkbox magic-checkbox" id="cb-{{ $conversation->id }}" name="cb_{{ $conversation->id }}" value="{{ $conversation->id }}"><label for="cb-{{ $conversation->id }}"><span class="sr-only">{{ __( 'Select Conversation' ) }}: {{ $conversation->getSubject() }}</span></label> + </td> + @endif + <td class="wporg-worklist-sender"> + <a href="{{ $url }}" title="{{ $conversation->customer_email }}"> + <span class="wporg-worklist-name">@if ( $conversation->customer ){{ $conversation->customer->getFullName( true ) }}@endif</span> + <span class="wporg-worklist-email">{{ $conversation->customer_email }}</span> + </a> + </td> + <td class="conv-subject wporg-worklist-subject"> + <a href="{{ $url }}"> + <p class="wporg-worklist-title"> + @if ( ! $conversation->isActive() )<span class="wporg-worklist-status">{{ $conversation->getStatusName() }}</span>@endif + @action('conversations_table.before_subject', $conversation){{ $conversation->getSubject() }}@action('conversations_table.after_subject', $conversation) + </p> + <p class="wporg-worklist-preview">{{ $conversation->preview }}</p> + </a> + </td> + <td class="wporg-worklist-threads"> + <a href="{{ $url }}"><span class="wporg-worklist-threads-count">{{ (int) $conversation->threads_count }}</span></a> + </td> + <td class="wporg-worklist-date"> + <a href="{{ $url }}" data-toggle="tooltip" data-html="true" data-placement="left" title="{{ $conversation->getDateTitle() }}">{{ $conversation->getWaitingSince( $folder ) }}</a> + </td> + </tr> + @endforeach + </tbody> + </table> + @else + <p class="wporg-worklist-empty">{{ $list->empty_text() }}</p> + @endif + </section> + @endforeach +</div> +@endsection diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/MyReviews.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/MyReviews.php deleted file mode 100644 index 4195127503..0000000000 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/MyReviews.php +++ /dev/null @@ -1,345 +0,0 @@ -<?php -/** - * The reviews a reviewer is working on. - * - * @package WordPressdotorg\FreeScout\WPOrgPluginReview - */ - -declare( strict_types = 1 ); - -namespace Modules\WPOrgPluginReview\Services; - -use App\Conversation; -use App\Folder; -use App\Mailbox; -use App\Thread; -use App\User; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Query\Builder as QueryBuilder; -use Illuminate\Support\Collection; - -/** - * Sorts the open and pending conversations of the Plugin Reviews team into the ones a reviewer replied to, the ones - * they're mentioned in, and the ones that need a reviewer, for the mailbox's My Reviews folder, and counts the active - * ones each reviewer replied to for the folder list. - * - * Each conversation is only in the first section it belongs to. One query tells, for every conversation, which - * sections it belongs to, through the threads' index on conversation and type. - */ -final class MyReviews { - - /** - * The folder's type. The folder is added to the mailbox's list as it's shown, and never stored, so the type is - * only for telling it apart; no other module is known to use it. - * - * @var int - */ - public const FOLDER_TYPE = 229; - - /** - * The folder's ID, which no stored folder has, so the folder list highlights it on its own page only; review.css - * finds it in the list by it. - * - * @var int - */ - public const FOLDER_ID = -229; - - /** - * What the team writes in a note when a conversation needs a reviewer. - * - * @var string - */ - public const NEEDS_REVIEWER = 'PRTRN'; - - /** - * The sections, in the order a conversation is looked for in them. - * - * @var string[] - */ - public const SECTIONS = array( 'participating', 'mentioned', 'needs_reviewer' ); - - /** - * How long the reviewers' counts are kept at most, in minutes; changes to conversations forget them before that. - * - * @var int - */ - private const COUNTS_MINUTES = 10; - - /** - * The folder, as the mailbox's folder list shows it. - * - * @param Mailbox $mailbox Mailbox. - * @return Folder - */ - public static function folder( Mailbox $mailbox ): Folder { - $folder = new Folder(); - $folder->id = self::FOLDER_ID; - $folder->mailbox_id = (int) $mailbox->id; - $folder->type = self::FOLDER_TYPE; - $folder->active_count = 0; - $folder->total_count = 0; - - return $folder; - } - - /** - * Adds the folder to a mailbox's folder list, after Unassigned and before Mine, with how many active reviews 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_folder( Collection $folders, Mailbox $mailbox, User $user ): Collection { - $folder = self::folder( $mailbox ); - try { - $folder->active_count = self::active_count( $mailbox, $user ); - } catch ( \Throwable $e ) { - \Log::error( '[WPOrgPluginReview] Could not count the My Reviews of user ' . $user->id . ': ' . $e->getMessage() ); - } - - $list = array(); - $added = false; - foreach ( $folders as $item ) { - if ( ! $added && Folder::TYPE_MINE === (int) $item->type ) { - $list[] = $folder; - $added = true; - } - - $list[] = $item; - - if ( ! $added && Folder::TYPE_UNASSIGNED === (int) $item->type ) { - $list[] = $folder; - $added = true; - } - } - - if ( ! $added ) { - array_unshift( $list, $folder ); - } - - return $folders->make( $list ); - } - - /** - * How many active reviews an agent is participating in, as the folder list shows it: the ones whose author wrote - * last, like core's folders count. - * - * Every reviewer's count comes from one query, kept until something changes them; agents who may only see what's - * assigned to them get theirs counted on its own. - * - * @param Mailbox $mailbox Mailbox. - * @param User $user The agent. - * @return int - */ - public static function active_count( Mailbox $mailbox, User $user ): int { - if ( $user->canSeeOnlyAssignedConversations() ) { - return self::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( - self::counts_key( (int) $mailbox->id ), - now()->addMinutes( self::COUNTS_MINUTES ), - static function () use ( $mailbox ): array { - return self::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 reviewers' 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::forget( self::counts_key( $mailbox_id ) ); - } catch ( \Throwable $e ) { - \Log::error( '[WPOrgPluginReview] Could not forget the My Reviews counts of mailbox ' . $mailbox_id . ': ' . $e->getMessage() ); - } - } - - /** - * Where the reviewers' counts of a mailbox are kept. - * - * @param int $mailbox_id Mailbox ID. - * @return string - */ - private static function counts_key( int $mailbox_id ): string { - return 'wporgpluginreview.my_reviews.active_counts.' . $mailbox_id; - } - - /** - * Whether a folder is the My Reviews folder. - * - * @param mixed $folder Folder. - * @return bool - */ - public static function is_folder( mixed $folder ): bool { - return $folder instanceof Folder && self::FOLDER_TYPE === (int) $folder->type; - } - - /** - * The reviewers whose reviews 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 reviewer's conversations, by section. - * - * A conversation needs a reviewer when a note says so, or when no reviewer 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 reviewer. - * @param User $viewer Who's looking: they only see the conversations they may. - * @return array<string, Conversation[]> Conversations by section, the longest waiting first. - */ - public static function sections( Mailbox $mailbox, User $user, User $viewer ): array { - $reviewer_ids = self::reviewers( $mailbox )->pluck( 'id' )->all(); - - $rows = self::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_REVIEWER . '%' ), 'needs_reviewer' ) - ->selectSub( self::threads( Thread::TYPE_MESSAGE )->whereIn( 'threads.created_by_user_id', $reviewer_ids ), 'reviewed' ) - ->orderBy( 'conversations.last_reply_at' ) - ->orderBy( 'conversations.id' ) - ->toBase() - ->get(); - - $ids = array_fill_keys( self::SECTIONS, array() ); - foreach ( $rows as $row ) { - $row->needs_reviewer = $row->needs_reviewer || ! $row->reviewed; - - foreach ( self::SECTIONS as $section ) { - if ( $row->$section ) { - $ids[ $section ][] = (int) $row->id; - break; - } - } - } - - $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 - ); - } - - /** - * 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 My Reviews conversations: ' . $e->getMessage() ); - - return $conversations; - } - } - - /** - * The Plugin Reviews 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 static 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 )->review_team_id(); - 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/Services/Reviewers.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Reviewers.php index cbf97f1611..d5b384c756 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, and which team reviews are assigned to, 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. @@ -34,11 +34,12 @@ class Reviewers { public const REVIEW_TEAM = 'Plugin Reviews'; /** - * The Teams module's folder type: one folder per team in each of its mailboxes, of the conversations assigned to it. + * The name of the team that cases are assigned to: questions, guideline reports, replies to rejections, and the + * like. * - * @var int + * @var string */ - public const TEAM_FOLDER_TYPE = 185; + public const ADMIN_TEAM = 'Plugin Admin'; /** * The Teams module's alias. @@ -83,22 +84,23 @@ public function includes( ?User $user ): bool { } /** - * The Plugin Reviews team's ID: the Teams module assigns conversations to a team as if it were a user. + * 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 review_team_id(): ?int { + public function team_id( string $name ): ?int { if ( ! \App\Module::isActive( self::TEAMS_MODULE ) ) { return null; } try { - $id = array_search( self::REVIEW_TEAM, $this->teams(), true ); + $id = array_search( $name, $this->teams(), true ); return false === $id ? 0 : (int) $id; } catch ( \Throwable $e ) { - \Log::error( '[WPOrgPluginReview] Could not find the ' . self::REVIEW_TEAM . ' team: ' . $e->getMessage() ); + \Log::error( '[WPOrgPluginReview] Could not find the ' . $name . ' team: ' . $e->getMessage() ); return 0; } 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..134d8f9ffd --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php @@ -0,0 +1,670 @@ +<?php +/** + * The conversations a member of the plugins team is working on: My Reviews and My Cases. + * + * @package WordPressdotorg\FreeScout\WPOrgPluginReview + */ + +declare( strict_types = 1 ); + +namespace Modules\WPOrgPluginReview\Services; + +use App\Conversation; +use App\Folder; +use App\Mailbox; +use App\Thread; +use App\User; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Query\Builder as QueryBuilder; +use Illuminate\Support\Collection; + +/** + * A folder of the plugins team's mailbox for one of its teams: the team's open and pending conversations that a + * member replied to, that they're mentioned in, and that need someone, and how many active ones each member replied + * to, for the folder list. + * + * My Reviews is the Plugin Reviews team's, and My Cases the Plugin Admin team's, which can also be filtered by tag. + * Each conversation is only in the first section it belongs to. One query tells, for every conversation, which + * sections it belongs to, through the threads' index on conversation and type. + */ +final class Worklist { + + /** + * What the team writes in a note when a conversation needs someone to take it. + * + * @var string + */ + public const NEEDS_MARKER = 'PRTRN'; + + /** + * The sections, in the order a conversation is looked for in them. + * + * @var string[] + */ + public const SECTIONS = array( 'participating', 'mentioned', 'needs' ); + + /** + * How long the members' counts are kept at most, in minutes; changes to conversations forget them before that. + * + * @var int + */ + private const COUNTS_MINUTES = 10; + + /** + * The worklists, by key. + * + * @var self[]|null + */ + private static $all = null; + + /** + * Constructor. + * + * @param string $key Key: reviews or cases. + * @param string $slug The page's part of its URL. + * @param string $team The name of the team whose conversations it lists. + * @param int $folder_type The folder's type. The folder is added to the mailbox's list as it's shown, and never + * stored, so the type is only for telling it apart; no other module is known to use it. + * Its ID is the type's negative, which no stored folder has, so the folder list + * highlights it on its own page only; review.css finds it in the list by it. + * @param string $icon The folder's Glyphicon. + * @param bool $tag_filter Whether its conversations can be filtered by tag. + */ + private function __construct( + public readonly string $key, + public readonly string $slug, + public readonly string $team, + public readonly int $folder_type, + public readonly string $icon, + public readonly bool $tag_filter + ) {} + + /** + * The worklists, in the order the folder list shows them. + * + * @return self[] By key. + */ + public static function all(): array { + if ( null === self::$all ) { + self::$all = array( + 'reviews' => 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 Admin 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 { + if ( $folder instanceof Folder ) { + foreach ( self::all() as $worklist ) { + if ( $worklist->folder_type === (int) $folder->type ) { + 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 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 $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 { + foreach ( self::all() as $worklist ) { + \Cache::forget( $worklist->counts_key( $mailbox_id ) ); + } + } 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. + * + * @param int $mailbox_id Mailbox ID. + * @return string + */ + private function counts_key( int $mailbox_id ): string { + return 'wporgpluginreview.worklist.' . $this->key . '.active_counts.' . $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<string, Conversation[]> 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 ) ); + } + + /** + * 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. + * @return array<string, int[]> Conversation IDs by section, the longest waiting first. + */ + public function section_ids( Mailbox $mailbox, User $user, User $viewer ): array { + $member_ids = self::reviewers( $mailbox )->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<string, int[]> $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<int, array{name: string, count: int}> 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<string, int[]> $ids Conversation IDs by section. + * @param int $tag_id Tag ID. + * @return array<string, int[]> + */ + 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<string, int[]> $ids Conversation IDs by section. + * @return array<string, Conversation[]> + */ + 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 + */ + private 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 c966d74f9a..a7b9d46ffb 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json @@ -2,7 +2,7 @@ "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. Adds a My Reviews folder of the reviews an agent replied to, is mentioned in, or that need a reviewer.", + "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.", "version": "1.0.0", "detailsUrl": "https://github.com/WordPress/wordpress.org/tree/trunk/freescout.wordpress.net/Modules/WPOrgPluginReview", "author": "WordPress.org", diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MyReviewsTest.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/WorklistTest.php similarity index 68% rename from freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MyReviewsTest.php rename to freescout.wordpress.net/Modules/WPOrgPluginReview/tests/WorklistTest.php index 9827a55f15..04a760e5c5 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MyReviewsTest.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/WorklistTest.php @@ -1,6 +1,6 @@ <?php /** - * Tests for the My Reviews folder. + * Tests for the My Reviews and My Cases folders. * * @package WordPressdotorg\FreeScout\WPOrgPluginReview */ @@ -15,14 +15,15 @@ use App\Thread; use App\User; use Modules\WPOrgPluginReview\Providers\WPOrgPluginReviewServiceProvider; -use Modules\WPOrgPluginReview\Services\MyReviews; use Modules\WPOrgPluginReview\Services\Reviewers; +use Modules\WPOrgPluginReview\Services\Worklist; use WordPressdotorg\FreeScout\Tests\TestCase; /** - * Covers which conversations each section shows, the folder in the mailbox's list, and its page. + * Covers which conversations each section shows, the folders in the mailbox's list, and their pages. Most of it is + * My Reviews', which My Cases shares. */ -final class MyReviewsTest extends TestCase { +final class WorklistTest extends TestCase { /** * The plugins team's mailbox. @@ -108,9 +109,9 @@ public function test_sections_show_each_conversation_once(): void { // Their latest replies are at the same time, so the oldest conversation comes first. $this->assertSections( array( - 'participating' => array( $replied, $replied_mentioned ), - 'mentioned' => array( $mentioned, $mentioned_needs ), - 'needs_reviewer' => array( $needs ), + 'participating' => array( $replied, $replied_mentioned ), + 'mentioned' => array( $mentioned, $mentioned_needs ), + 'needs' => array( $needs ), ), $this->sections() ); @@ -161,7 +162,7 @@ public function test_sections_need_a_reviewer_without_a_reviewers_reply(): void $formers = $this->conversation( array( array( Thread::TYPE_MESSAGE, $former, 'Review sent.' ) ) ); $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->colleague, 'Review sent.' ) ) ); - $this->assertSections( array( 'needs_reviewer' => array( $unreplied, $outsiders, $formers ) ), $this->sections() ); + $this->assertSections( array( 'needs' => array( $unreplied, $outsiders, $formers ) ), $this->sections() ); } /** @@ -194,7 +195,7 @@ public function test_sections_are_for_the_chosen_reviewer(): void { $this->assertSections( array( 'participating' => array( $theirs ) ), - MyReviews::sections( $this->mailbox, $this->colleague, $this->reviewer ) + Worklist::reviews()->sections( $this->mailbox, $this->colleague, $this->reviewer ) ); $this->assertSections( array( @@ -243,36 +244,49 @@ public function test_sections_respect_only_assigned_conversations(): void { $this->conversation( $reply ); $assigned = $this->conversation( $reply, Conversation::STATUS_ACTIVE, '2026-08-01 10:00:00', $viewer ); - $this->assertSections( array( 'participating' => array( $assigned ) ), MyReviews::sections( $this->mailbox, $this->reviewer, $viewer ) ); - $this->assertSame( 1, MyReviews::active_count( $this->mailbox, $viewer ) ); + $this->assertSections( array( 'participating' => array( $assigned ) ), Worklist::reviews()->sections( $this->mailbox, $this->reviewer, $viewer ) ); + $this->assertSame( 1, Worklist::reviews()->active_count( $this->mailbox, $viewer ) ); } /** - * Reviewers get the folder in the plugins team's mailbox, after Unassigned and before Mine, with how many reviews - * they're participating in, linked to its page; nobody gets it in other mailboxes. + * Members get the folders in the plugins team's mailbox, My Reviews then My Cases, after Unassigned and before + * Mine, with how many active conversations they're participating in, linked to their pages; nobody gets them in + * other mailboxes. * * @return void */ - public function test_folder_is_added_after_unassigned(): void { + public function test_folders_are_added_after_unassigned(): void { $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Review sent.' ) ) ); $unassigned = $this->folder( Folder::TYPE_UNASSIGNED ); $mine = $this->folder( Folder::TYPE_MINE ); $closed = $this->folder( Folder::TYPE_CLOSED ); + $reviews = Worklist::reviews(); + $cases = Worklist::cases(); $this->actingAs( $this->reviewer ); $folders = \Eventy::filter( 'mailbox.folders', collect( array( $unassigned, $mine, $closed ) ), $this->mailbox ); - $this->assertSame( array( Folder::TYPE_UNASSIGNED, MyReviews::FOLDER_TYPE, Folder::TYPE_MINE, Folder::TYPE_CLOSED ), $this->types( $folders ) ); + $this->assertSame( array( Folder::TYPE_UNASSIGNED, $reviews->folder_type, $cases->folder_type, Folder::TYPE_MINE, Folder::TYPE_CLOSED ), $this->types( $folders ) ); - $folder = $folders->get( 1 ); - $this->assertSame( 'My Reviews', $folder->getTypeName() ); - $this->assertSame( 'tasks', $folder->getTypeIcon() ); - $this->assertSame( 1, (int) $folder->getCount( $folders ) ); - $this->assertSame( route( 'wporgpluginreview.my_reviews', array( 'mailbox_id' => $this->mailbox->id ) ), $folder->url( $this->mailbox->id ) ); + // Worklist, name, and icon, by position. + $expected = array( + 1 => array( $reviews, 'My Reviews', 'tasks' ), + 2 => array( $cases, 'My Cases', 'briefcase' ), + ); + foreach ( $expected as $position => list( $worklist, $name, $icon ) ) { + $folder = $folders->get( $position ); + $this->assertSame( $name, $folder->getTypeName() ); + $this->assertSame( $icon, $folder->getTypeIcon() ); + $this->assertSame( $worklist->folder_id(), (int) $folder->id ); + $this->assertSame( $worklist->url( (int) $this->mailbox->id ), $folder->url( $this->mailbox->id ) ); + + // Without the Teams module, both are of the whole mailbox. + $this->assertSame( 1, (int) $folder->getCount( $folders ) ); + } - // Without Unassigned, it's before Mine; without either, first. - $this->assertSame( array( MyReviews::FOLDER_TYPE, Folder::TYPE_MINE, Folder::TYPE_CLOSED ), $this->types( \Eventy::filter( 'mailbox.folders', collect( array( $mine, $closed ) ), $this->mailbox ) ) ); - $this->assertSame( array( MyReviews::FOLDER_TYPE, Folder::TYPE_CLOSED ), $this->types( \Eventy::filter( 'mailbox.folders', collect( array( $closed ) ), $this->mailbox ) ) ); + // Without Unassigned, they're before Mine; without either, first. + $this->assertSame( array( $reviews->folder_type, $cases->folder_type, Folder::TYPE_MINE, Folder::TYPE_CLOSED ), $this->types( \Eventy::filter( 'mailbox.folders', collect( array( $mine, $closed ) ), $this->mailbox ) ) ); + $this->assertSame( array( $reviews->folder_type, $cases->folder_type, Folder::TYPE_CLOSED ), $this->types( \Eventy::filter( 'mailbox.folders', collect( array( $closed ) ), $this->mailbox ) ) ); $themes = $this->create_mailbox( 'Themes', 'themes@wordpress.test' ); $this->assertSame( array( Folder::TYPE_CLOSED ), $this->types( \Eventy::filter( 'mailbox.folders', collect( array( $closed ) ), $themes ) ) ); @@ -290,9 +304,9 @@ public function test_folder_counts_active_reviews_until_they_change(): void { $pending = $this->conversation( $reply, Conversation::STATUS_PENDING ); $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->colleague, 'Review sent.' ) ) ); - $this->assertSame( 1, MyReviews::active_count( $this->mailbox, $this->reviewer ) ); - $this->assertSame( 1, MyReviews::active_count( $this->mailbox, $this->colleague ) ); - $this->assertSame( 0, MyReviews::active_count( $this->mailbox, $this->team ) ); + $this->assertSame( 1, Worklist::reviews()->active_count( $this->mailbox, $this->reviewer ) ); + $this->assertSame( 1, Worklist::reviews()->active_count( $this->mailbox, $this->colleague ) ); + $this->assertSame( 0, Worklist::reviews()->active_count( $this->mailbox, $this->team ) ); // A reply written without the hooks isn't counted until the counts are forgotten. $second = $this->conversation( array() ); @@ -307,25 +321,25 @@ public function test_folder_counts_active_reviews_until_they_change(): void { 'body' => 'Review sent.', ) ); - $this->assertSame( 1, MyReviews::active_count( $this->mailbox, $this->reviewer ) ); + $this->assertSame( 1, Worklist::reviews()->active_count( $this->mailbox, $this->reviewer ) ); - MyReviews::forget_counts( (int) $this->mailbox->id ); - $this->assertSame( 2, MyReviews::active_count( $this->mailbox, $this->reviewer ) ); + Worklist::forget_counts( (int) $this->mailbox->id ); + $this->assertSame( 2, Worklist::reviews()->active_count( $this->mailbox, $this->reviewer ) ); // An agent's reply forgets them. $this->conversation( $reply ); - $this->assertSame( 3, MyReviews::active_count( $this->mailbox, $this->reviewer ) ); + $this->assertSame( 3, Worklist::reviews()->active_count( $this->mailbox, $this->reviewer ) ); // So does an author's reply, which makes a conversation active without core's status_changed. $pending->status = Conversation::STATUS_ACTIVE; $pending->save(); - $this->assertSame( 3, MyReviews::active_count( $this->mailbox, $this->reviewer ) ); + $this->assertSame( 3, Worklist::reviews()->active_count( $this->mailbox, $this->reviewer ) ); \Eventy::action( 'conversation.customer_replied', $pending, null, null ); - $this->assertSame( 4, MyReviews::active_count( $this->mailbox, $this->reviewer ) ); + $this->assertSame( 4, Worklist::reviews()->active_count( $this->mailbox, $this->reviewer ) ); // And closing a conversation. $first->changeStatus( Conversation::STATUS_CLOSED, $this->colleague ); - $this->assertSame( 3, MyReviews::active_count( $this->mailbox, $this->reviewer ) ); + $this->assertSame( 3, Worklist::reviews()->active_count( $this->mailbox, $this->reviewer ) ); } /** @@ -359,7 +373,7 @@ public function test_page_shows_the_agents_reviews(): void { $this->assertStringContainsString( 'Review of <My Plugin>', $html ); $this->assertStringContainsString( $mine->url(), $html ); $this->assertStringNotContainsString( 'Review of Their Plugin', $html ); - $this->assertStringContainsString( 'data-folder_id="' . MyReviews::FOLDER_ID . '"', $html ); + $this->assertStringContainsString( 'data-folder_id="' . Worklist::reviews()->folder_id() . '"', $html ); $this->assertStringContainsString( '<title>(1) My Reviews - Plugins', $html ); $this->assertStringContainsString( '>Threads</th>', $html ); $this->assertStringNotContainsString( '>Number</th>', $html ); @@ -414,10 +428,100 @@ public function test_page_is_only_for_the_plugins_mailbox(): void { $themes = $this->create_mailbox( 'Themes', 'themes@wordpress.test' ); $this->actingAs( $this->reviewer ) - ->get( route( 'wporgpluginreview.my_reviews', array( 'mailbox_id' => $themes->id ) ) ) + ->get( Worklist::reviews()->url( (int) $themes->id ) ) ->assertStatus( 404 ); } + /** + * My Cases lists the Plugin Admin team's conversations, in the same sections as My Reviews the Plugin Reviews + * team's, and counts them on its own. + * + * @return void + */ + public function test_cases_are_the_plugin_admin_teams(): void { + $admin = $this->create_user(); + $this->answer_teams( + array( + (int) $this->team->id => Reviewers::REVIEW_TEAM, + (int) $admin->id => Reviewers::ADMIN_TEAM, + ) + ); + + $review = $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Review sent.' ) ) ); + $replied = $this->conversation( array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'We closed the plugin.' ) ), Conversation::STATUS_ACTIVE, '2026-08-01 10:00:00', $admin ); + $mentioned = $this->conversation( + array( + array( Thread::TYPE_MESSAGE, $this->colleague, 'Thanks for the report.' ), + array( Thread::TYPE_NOTE, $this->colleague, $this->mention( $this->reviewer ) . ' could you check the accounts?' ), + ), + Conversation::STATUS_ACTIVE, + '2026-08-01 10:00:00', + $admin + ); + $unanswered = $this->conversation( array(), Conversation::STATUS_ACTIVE, '2026-08-01 10:00:00', $admin ); + + $this->assertSections( array( 'participating' => array( $review ) ), $this->sections() ); + $this->assertSections( + array( + 'participating' => array( $replied ), + 'mentioned' => array( $mentioned ), + 'needs' => array( $unanswered ), + ), + Worklist::cases()->sections( $this->mailbox, $this->reviewer, $this->reviewer ) + ); + $this->assertSame( 1, Worklist::reviews()->active_count( $this->mailbox, $this->reviewer ) ); + $this->assertSame( 1, Worklist::cases()->active_count( $this->mailbox, $this->reviewer ) ); + $this->assertSame( 1, Worklist::cases()->active_count( $this->mailbox, $this->colleague ) ); + } + + /** + * My Cases' page reads as cases, and can be filtered by tag, with the Tags module; My Reviews' can't. + * + * @return void + */ + public function test_cases_page_can_be_filtered_by_tag(): void { + $reply = array( array( Thread::TYPE_MESSAGE, $this->reviewer, 'Thanks for the report.' ) ); + $guide = $this->conversation( $reply, Conversation::STATUS_ACTIVE, '2026-08-01 10:00:00', null, null, 'Report: fake reviews on Quick Cache' ); + $sock = $this->conversation( $reply, Conversation::STATUS_ACTIVE, '2026-08-01 10:00:00', null, null, 'Sockpuppets reviewing Smart Slider' ); + $this->conversation( $reply, Conversation::STATUS_ACTIVE, '2026-08-01 10:00:00', null, null, 'How do I transfer my plugin?' ); + + $guidelines = $this->tag( 'guidelines', array( $guide ) ); + $this->tag( 'sockpuppet', array( $sock ) ); + $adoption = $this->tag( 'adoption', array() ); + + \App\Module::clearModulesCache(); + \App\Module::setActive( 'tags', true ); + \App\Module::clearModulesCache(); + + $url = Worklist::cases()->url( (int) $this->mailbox->id ); + $html = $this->actingAs( $this->reviewer )->get( $url )->getContent(); + + $this->assertStringContainsString( 'Cases I’m participating in', $html ); + $this->assertStringContainsString( 'Cases that need a volunteer', $html ); + $this->assertStringContainsString( '>Volunteer</label>', $html ); + $this->assertStringContainsString( '<title>(3) My Cases - Plugins', $html ); + $this->assertStringContainsString( '>guidelines (1)</option>', $html ); + $this->assertStringContainsString( '>sockpuppet (1)</option>', $html ); + $this->assertStringNotContainsString( '>adoption', $html ); + $this->assertStringContainsString( 'How do I transfer my plugin?', $html ); + + $html = $this->actingAs( $this->reviewer )->get( $url . '?tag=' . $guidelines )->getContent(); + + $this->assertStringContainsString( 'Report: fake reviews on Quick Cache', $html ); + $this->assertStringNotContainsString( 'Sockpuppets reviewing Smart Slider', $html ); + $this->assertStringNotContainsString( 'How do I transfer my plugin?', $html ); + $this->assertMatchesRegularExpression( '#<option value="' . $guidelines . '"\s+selected\s*>guidelines \(1\)</option>#', $html ); + + // A tag that none of them has stays chosen, with nothing to show. + $html = $this->actingAs( $this->reviewer )->get( $url . '?tag=' . $adoption )->getContent(); + + $this->assertMatchesRegularExpression( '#<option value="' . $adoption . '"\s+selected\s*>adoption \(0\)</option>#', $html ); + $this->assertStringNotContainsString( 'Report: fake reviews on Quick Cache', $html ); + $this->assertStringContainsString( 'No cases.', $html ); + + $this->assertStringNotContainsString( 'name="tag"', $this->actingAs( $this->reviewer )->get( $this->url() )->getContent() ); + } + /** * Creates an open conversation in the plugins team's mailbox, assigned to the team, with a sender's first message. * @@ -475,6 +579,27 @@ static function ( Folder $folder ): int { ); } + /** + * Creates a tag, the way the Tags module stores them, on conversations. + * + * @param string $name Tag name. + * @param Conversation[] $conversations Conversations with the tag. + * @return int Tag ID. + */ + private function tag( string $name, array $conversations ): int { + $id = (int) \DB::table( 'tags' )->insertGetId( array( 'name' => $name ) ); + foreach ( $conversations as $conversation ) { + \DB::table( 'conversation_tag' )->insert( + array( + 'conversation_id' => $conversation->id, + 'tag_id' => $id, + ) + ); + } + + return $id; + } + /** * How the Mentions module mentions a user in a note. * @@ -491,7 +616,7 @@ private function mention( User $user ): string { * @return array */ private function sections(): array { - return MyReviews::sections( $this->mailbox, $this->reviewer, $this->reviewer ); + return Worklist::reviews()->sections( $this->mailbox, $this->reviewer, $this->reviewer ); } /** @@ -500,14 +625,14 @@ private function sections(): array { * @return string */ private function url(): string { - return route( 'wporgpluginreview.my_reviews', array( 'mailbox_id' => $this->mailbox->id ) ); + return Worklist::reviews()->url( (int) $this->mailbox->id ); } /** * Asserts which conversations each section has, in order. * * @param array $expected Conversations by section; sections left out are empty. - * @param array $sections Sections, from MyReviews::sections(). + * @param array $sections Sections, from Worklist::sections(). * @return void */ private function assertSections( array $expected, array $sections ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- Like PHPUnit's own assertions. @@ -520,8 +645,8 @@ static function ( Conversation $conversation ): int { ); }; - $this->assertSame( MyReviews::SECTIONS, array_keys( $sections ) ); - foreach ( MyReviews::SECTIONS as $section ) { + $this->assertSame( Worklist::SECTIONS, array_keys( $sections ) ); + foreach ( Worklist::SECTIONS as $section ) { $this->assertSame( $ids( $expected[ $section ] ?? array() ), $ids( $sections[ $section ] ), $section ); } } From d282f532fa0f48bda6456427eb8593b64683c848 Mon Sep 17 00:00:00 2001 From: frantorres <git@frantorres.es> Date: Wed, 14 Oct 2026 16:11:47 +0200 Subject: [PATCH 05/13] Freescout: Plugins Opening conversations in bulk. --- .../Http/Controllers/WorklistController.php | 11 +- .../WPOrgPluginReviewServiceProvider.php | 64 +++- .../WPOrgPluginReview/Public/css/review.css | 60 +++- .../WPOrgPluginReview/Public/js/review.js | 80 ++++- .../Modules/WPOrgPluginReview/README.md | 9 + .../Resources/views/profile.blade.php | 9 + .../Resources/views/worklist.blade.php | 22 +- .../WPOrgPluginReview/Services/OpenOrder.php | 295 ++++++++++++++++++ .../WPOrgPluginReview/Services/Worklist.php | 2 +- .../WPOrgPluginReview/tests/OpenOrderTest.php | 291 +++++++++++++++++ 10 files changed, 834 insertions(+), 9 deletions(-) create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/profile.blade.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Services/OpenOrder.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/tests/OpenOrderTest.php diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php index fb191e3ce3..daff5da6ba 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php @@ -13,6 +13,7 @@ use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\View\View; +use Modules\WPOrgPluginReview\Services\OpenOrder; use Modules\WPOrgPluginReview\Services\Review; use Modules\WPOrgPluginReview\Services\Reviewers; use Modules\WPOrgPluginReview\Services\Worklist; @@ -20,7 +21,8 @@ /** * Shows the plugins team's mailbox's My Reviews and My Cases folders: a team's open and pending conversations that a * member replied to, that they're mentioned in, and that need someone. They're the agent's own unless they choose - * another member; My Cases can also be filtered by tag. + * another member; My Cases can also be filtered by tag. The active ones they're participating in can be opened in new + * tabs, the longest waiting first or by progress. */ final class WorklistController extends Controller { @@ -66,6 +68,11 @@ public function show( Request $request, int $mailbox_id, string $worklist ): Vie } $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 ); @@ -94,6 +101,8 @@ static function ( $conversation ): bool { 'sections' => $sections, 'tags' => $tags, 'tag' => $tag, + 'open' => $open, + 'limit' => $limit, ) ); } diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php index d28ca27b84..a0898d6a6f 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php @@ -12,10 +12,13 @@ use App\Conversation; 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; @@ -23,7 +26,8 @@ /** * Shows a conversation's latest plugin review in its sidebar, and keeps the index of review emails as threads change; - * adds the My Reviews and My Cases folders to the plugins team's mailbox. + * 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. */ final class WPOrgPluginReviewServiceProvider extends ServiceProvider { @@ -171,6 +175,64 @@ static function ( mixed $conversation = null ): void { ); self::register_folder_hooks(); + self::register_profile_hooks(); + } + + /** + * 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() ); + } + } + ); } /** diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css index 83666f6cfb..dbe19717ab 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css @@ -226,9 +226,17 @@ 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; - padding: 18px 14px 2px; color: #2a3b47; font-size: 16px; font-weight: 700; @@ -308,6 +316,51 @@ 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 { + margin-left: 2px; + padding: 0 6px; + border-radius: 9px; + background-color: #eef1f4; + color: #3e4a55; + font-size: 11px; + font-weight: 600; +} + +.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; } @@ -397,3 +450,8 @@ width: 90px; } } + +/* 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/review.js b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js index 88a9a8b002..38b5150ee0 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js @@ -1,7 +1,7 @@ /** * 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. Also shows the member - * and tag chosen in the My Reviews and My Cases folders. + * and tag chosen in the My Reviews and My Cases folders, and opens their conversations in new tabs. * * @param {jQuery} $ jQuery. */ @@ -669,11 +669,89 @@ 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( + $( '<span class="wporg-worklist-open-order">' ).text( + index + 1 + ) + ); + } ); + } + ); + $( document ).on( + 'mouseleave blur', + '.wporg-worklist-open-button', + unmarkRowsToOpen + ); + + // And open them in new tabs, in that order; browsers block all but the first, unless the site may open pop-ups. + $( document ).on( 'click', '.wporg-worklist-open-button', function () { + const $button = $( this ); + let blocked = 0; + + rowsToOpen( $button ).forEach( ( $row ) => { + const url = $row.find( 'a[href]' ).first().attr( 'href' ); + if ( url && ! window.open( url, '_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 ( diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md index 1f44b5b199..9bef2cafca 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md @@ -43,6 +43,15 @@ The folders aren't stored: they're added to the mailbox's folders as they're lis 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. + ## 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/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. --}} +<div class="form-group"> + <label for="wporgpluginreview_open_limit" class="col-sm-2 control-label">{{ __( 'Conversations to open' ) }}</label> + + <div class="col-sm-6"> + <input id="wporgpluginreview_open_limit" type="number" class="form-control wporg-open-limit" name="wporgpluginreview_open_limit" value="{{ $limit }}" min="1" max="{{ $max }}" step="1"> + <p class="block-help">{{ __( '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 ) ) }}</p> + </div> +</div> diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/worklist.blade.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/worklist.blade.php index 3bae53c29e..7324867a77 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/worklist.blade.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/worklist.blade.php @@ -44,10 +44,24 @@ $selectable = 'participating' === $section && $conversations; @endphp <section class="wporg-worklist-section" data-section="{{ $section }}" aria-labelledby="wporg-worklist-{{ $section }}"> - <h2 class="wporg-worklist-heading" id="wporg-worklist-{{ $section }}"> - {{ $headings[ $section ] }} - <span class="wporg-worklist-count">{{ count( $conversations ) }}</span> - </h2> + <div class="wporg-worklist-section-header"> + <h2 class="wporg-worklist-heading" id="wporg-worklist-{{ $section }}"> + {{ $headings[ $section ] }} + <span class="wporg-worklist-count">{{ count( $conversations ) }}</span> + </h2> + @if ( 'participating' === $section && $conversations ) + {{-- Opens the active ones in new tabs; hovering or focusing a button marks which, in order. --}} + <div class="wporg-worklist-open" data-strings="{{ json_encode( array( 'blocked' => __( 'Your browser blocked :count of the tabs. Allow pop-ups for this site, then try again.' ) ) ) }}"> + <button type="button" class="btn btn-default btn-sm wporg-worklist-open-button" data-conversations="{{ implode( ',', $open['oldest'] ) }}" title="{{ __( 'Opens the active ones that waited :hours hours or more in new tabs, the longest waiting first.', array( 'hours' => \Modules\WPOrgPluginReview\Services\OpenOrder::MIN_AGE_HOURS ) ) }}" @if ( ! $open['oldest'] ) disabled @endif> + {{ __( 'Open Oldest First' ) }} <span class="wporg-worklist-open-count">{{ count( $open['oldest'] ) }}</span> + </button> + <button type="button" class="btn btn-default btn-sm wporg-worklist-open-button" data-conversations="{{ implode( ',', $open['progress'] ) }}" title="{{ __( 'Opens the active ones in new tabs, the longest waiting first, after waiting longer the more the team replied to them.' ) }}" @if ( ! $open['progress'] ) disabled @endif> + {{ __( 'Open Progress Based' ) }} <span class="wporg-worklist-open-count">{{ count( $open['progress'] ) }}</span> + </button> + <a class="wporg-worklist-open-limit" href="{{ route( 'users.profile', array( 'id' => auth()->id() ) ) }}" title="{{ __( 'Change it in your profile' ) }}">{{ __( 'Up to :limit at a time', array( 'limit' => $limit ) ) }}</a> + </div> + @endif + </div> <p class="wporg-worklist-description text-help">{{ $descriptions[ $section ] }}</p> @if ( $conversations ) 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..aec46c98b3 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/OpenOrder.php @@ -0,0 +1,295 @@ +<?php +/** + * Which active conversations the worklists' Open buttons open, and in what order. + * + * @package WordPressdotorg\FreeScout\WPOrgPluginReview + */ + +declare( strict_types = 1 ); + +namespace Modules\WPOrgPluginReview\Services; + +use App\Conversation; +use App\Thread; +use App\User; +use Carbon\Carbon; + +/** + * Picks the active conversations a member is participating in to open in new tabs, up to how many they open at a + * time: the longest waiting first, or by progress, which makes conversations wait longer the more the team replied to + * them, the ones that need the most attention. + * + * A conversation's age is how long its sender has been waiting: since its latest reply, as Waiting Since shows. + */ +final class OpenOrder { + + /** + * How many conversations are opened at a time, unless the agent chooses otherwise in their profile. + * + * @var int + */ + public const DEFAULT_LIMIT = 25; + + /** + * The most conversations an agent can open at a time. + * + * @var int + */ + public const MAX_LIMIT = 100; + + /** + * Conversations whose sender has waited less than this, in hours, are never opened. + * + * @var int + */ + public const MIN_AGE_HOURS = 12; + + /** + * How many days a conversation waits at least before it's opened by progress, by how many replies the team sent + * to it: the most replies each wait is for. Replies are the team's emails, including the first if the team wrote + * it, and not notes. + * + * @var array<int, int> + */ + 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<string, int> + */ + 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 ( null === $limit || '' === $limit || ! 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<int, int> 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<int, int> 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<int, string[]> 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/Worklist.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php index 134d8f9ffd..07554f4393 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php @@ -590,7 +590,7 @@ static function ( array $section ) use ( $conversations ): array { * * @return bool */ - private static function has_tags(): bool { + public static function has_tags(): bool { try { return \App\Module::isActive( 'tags' ) && \Schema::hasTable( 'tags' ) && \Schema::hasTable( 'conversation_tag' ); } catch ( \Throwable $e ) { 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 @@ +<?php +/** + * Tests for the worklists' Open buttons. + * + * @package WordPressdotorg\FreeScout\WPOrgPluginReview + */ + +declare( strict_types = 1 ); + +namespace Modules\WPOrgPluginReview\Tests; + +use App\Conversation; +use App\Mailbox; +use App\Thread; +use App\User; +use Carbon\Carbon; +use Illuminate\Http\Request; +use Modules\WPOrgPluginReview\Providers\WPOrgPluginReviewServiceProvider; +use Modules\WPOrgPluginReview\Services\OpenOrder; +use Modules\WPOrgPluginReview\Services\Worklist; +use WordPressdotorg\FreeScout\Tests\TestCase; + +/** + * Covers which conversations the buttons open and in what order, how many, and the profile field that sets it. + */ +final class OpenOrderTest extends TestCase { + + /** + * The plugins team's mailbox. + * + * @var Mailbox + */ + private $mailbox; + + /** + * The reviewer whose conversations are opened. + * + * @var User + */ + private $reviewer; + + /** + * Registers the module. + * + * Times are from now: FreeScout's Carbon can't stop the clock. The time a test takes only makes conversations wait + * a little longer, and none is that close to a wait. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->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, '<p>My plugin.</p>', null, $at ); + } + for ( $n = 0; $n < $replies; $n++ ) { + $this->create_thread( $conversation, Thread::TYPE_MESSAGE, '<p>Review.</p>', $this->reviewer, $at ); + $this->create_thread( $conversation, Thread::TYPE_CUSTOMER, '<p>Updated.</p>', null, $at ); + } + for ( $n = 0; $n < $notes; $n++ ) { + $this->create_thread( $conversation, Thread::TYPE_NOTE, '<p>Note.</p>', $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 + ); + } +} From 981c37bbb7968fee25c8ce4626a5d50f0948b913 Mon Sep 17 00:00:00 2001 From: frantorres <git@frantorres.es> Date: Wed, 14 Oct 2026 17:11:53 +0200 Subject: [PATCH 06/13] Freescout: Plugins copy plugin URLs button. --- .../Http/Controllers/PluginController.php | 2 +- .../WPOrgPluginReviewServiceProvider.php | 18 +- .../WPOrgPluginReview/Public/js/downloads.js | 361 ++++++++++++++++++ .../WPOrgPluginReview/Public/js/review.js | 60 ++- .../Modules/WPOrgPluginReview/README.md | 10 +- .../Resources/views/downloads.blade.php | 7 + .../Modules/WPOrgPluginReview/module.json | 2 +- .../WPOrgPluginReview/tests/DownloadsTest.php | 103 +++++ 8 files changed, 554 insertions(+), 9 deletions(-) create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/downloads.js create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/downloads.blade.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/tests/DownloadsTest.php diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/PluginController.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/PluginController.php index ecb73b04b6..444d2a9241 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. diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php index a0898d6a6f..490c423473 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php @@ -27,7 +27,8 @@ /** * Shows a conversation's latest plugin review in its sidebar, and keeps the index of review emails as threads change; * 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. + * 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 { @@ -93,6 +94,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; @@ -176,6 +178,20 @@ static function ( mixed $conversation = null ): void { 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() ); + } + } + ); } /** 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..86e9e85b1d --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/downloads.js @@ -0,0 +1,361 @@ +/** + * 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. + * + * @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 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<string, string>} + */ + 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; + + /** + * 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; + + /** + * 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<Object[]>} 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 ( 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 ); + } ); + } + + /** + * Keeps this page's URL, while it holds its lock. + */ + function keep() { + const local = storage( 'localStorage' ); + const store = () => { + if ( entry && local ) { + try { + local.setItem( PAGE_KEY, JSON.stringify( entry ) ); + } catch { + // Then it isn't in the list. + } + } + refresh(); + }; + const manager = locks(); + + if ( ! manager || locked ) { + store(); + return; + } + + // Held until the page ends: the promise never settles. + manager + .request( PAGE_KEY, () => { + locked = true; + store(); + return new Promise( () => {} ); + } ) + .catch( store ); + } + + /** + * 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 ) ) { + refresh(); + } + } ); + + // 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( () => {} ); + } ); + + // 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 38b5150ee0..abf3a2c6f5 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js @@ -66,6 +66,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<string, number>} + */ + 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<string, string>} + */ + const OPEN_PARAMS = { + batch: 'wporg_open_batch', + order: 'wporg_open_order', + }; + /** * Copies text, and HTML if there is any, to the clipboard. * @@ -389,8 +413,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; @@ -409,6 +434,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, [ $( '<span class="wporg-review-error">' ).text( 429 === xhr.status ? strings.throttled : strings.failed @@ -723,14 +759,25 @@ unmarkRowsToOpen ); - // And open them in new tabs, in that order; browsers block all but the first, unless the site may open pop-ups. + /* + * 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 ) => { - const url = $row.find( 'a[href]' ).first().attr( 'href' ); - if ( url && ! window.open( url, '_blank' ) ) { + 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++; } } ); @@ -797,6 +844,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() diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md index 9bef2cafca..f55b1903fe 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md @@ -50,7 +50,15 @@ Next to the participating section's heading, two buttons open its active convers - **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. +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 `"<URL1>|<URL2>|…"`: 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. + +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 60 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 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..39a93aca0a --- /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. --}} +<li class="wporg-downloads hidden" data-strings="{{ json_encode( array( 'one' => __( 'Copy 1 URL' ), 'many' => __( 'Copy :count URLs' ), 'copied' => __( 'Copied' ) ) ) }}"> + <a href="#" class="wporg-downloads-copy" role="button" title="{{ __( 'Copy the download URLs of the plugins in review you have open, in the order you opened them' ) }}"><i class="glyphicon glyphicon-download-alt"></i> <span class="wporg-downloads-label"></span></a> +</li> +<li class="wporg-downloads hidden"> + <a href="#" class="wporg-downloads-clear" role="button" title="{{ __( 'Remove all the URLs' ) }}" aria-label="{{ __( 'Remove all the URLs' ) }}"><i class="glyphicon glyphicon-trash"></i></a> +</li> diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json b/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json index a7b9d46ffb..bc28da6a52 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json @@ -2,7 +2,7 @@ "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. Adds My Reviews and My Cases folders of the reviews and cases an agent replied to, is mentioned in, or that need someone.", + "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.0", "detailsUrl": "https://github.com/WordPress/wordpress.org/tree/trunk/freescout.wordpress.net/Modules/WPOrgPluginReview", "author": "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 @@ +<?php +/** + * Tests for the header's button that copies the download URLs of the plugins open. + * + * @package WordPressdotorg\FreeScout\WPOrgPluginReview + */ + +declare( strict_types = 1 ); + +namespace Modules\WPOrgPluginReview\Tests; + +use App\User; +use Modules\WPOrgPluginReview\Providers\WPOrgPluginReviewServiceProvider; +use Modules\WPOrgPluginReview\Services\Reviewers; +use WordPressdotorg\FreeScout\Tests\TestCase; + +/** + * Covers who gets the button, which downloads.js shows and fills in the browser. + */ +final class DownloadsTest extends TestCase { + + /** + * Registers the module. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->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( '<li class="wporg-downloads hidden"', $html ); + $this->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() ); + } +} From bf74357826aced370f5619bbdff2cee6fbaf7a65 Mon Sep 17 00:00:00 2001 From: frantorres <git@frantorres.es> Date: Fri, 16 Oct 2026 14:54:15 +0200 Subject: [PATCH 07/13] Freescout: Plugin Review, subjects simplification and visual changes to the sidebar element. --- .../WPOrgPluginReviewServiceProvider.php | 118 ++++++++- .../WPOrgPluginReview/Public/css/review.css | 87 +++++-- .../WPOrgPluginReview/Public/js/review.js | 229 ++++++++++++++---- .../Modules/WPOrgPluginReview/README.md | 19 +- .../Resources/views/panel.blade.php | 111 +++++---- .../Resources/views/subjects.blade.php | 2 + .../WPOrgPluginReview/Services/IssueNames.php | 7 + .../WPOrgPluginReview/Services/Subject.php | 77 ++++-- .../tests/MarkedSubjectsTest.php | 161 ++++++++++++ .../WPOrgPluginReview/tests/PanelTest.php | 34 ++- .../WPOrgPluginReview/tests/SubjectTest.php | 53 +++- 11 files changed, 751 insertions(+), 147 deletions(-) create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/subjects.blade.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/tests/MarkedSubjectsTest.php diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php index 490c423473..bc1db77372 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php @@ -26,7 +26,7 @@ /** * Shows a conversation's latest plugin review in its sidebar, and keeps the index of review emails as threads change; - * adds the My Reviews and My Cases folders to the plugins team's mailbox, and to its members' profiles how many + * 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. */ @@ -53,6 +53,13 @@ final class WPOrgPluginReviewServiceProvider extends ServiceProvider { */ private const FOLDERS_PRIORITY = 30; + /** + * How many of the sender's other conversations get their subjects marked in the sidebar: core shows the latest five. + * + * @var int + */ + private const PREVIOUS_LIMIT = 40; + /** * Boots the module. * @@ -176,6 +183,7 @@ static function ( mixed $conversation = null ): void { self::PANEL_PRIORITY ); + self::register_subject_hooks(); self::register_folder_hooks(); self::register_profile_hooks(); @@ -194,6 +202,112 @@ static function (): void { ); } + /** + * 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: it's what the row is read as, and what FreeScout's scripts find there. + \Eventy::addAction( + 'conversations_table.before_subject', + static function ( mixed $conversation = null ) use ( $marks ): void { + try { + $marked = $marks( $conversation ) ? Subject::marked( (string) $conversation->subject ) : ''; + if ( '' !== $marked ) { + $subject = e( $conversation->getSubject() ); + echo '<span class="wporg-subject" data-wporg-subject="' . $subject . '" title="' . $subject . '">' . e( $marked ) . '</span><span hidden>'; + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not mark the subject of conversation ' . ( $conversation->id ?? '' ) . ': ' . $e->getMessage() ); + } + } + ); + + \Eventy::addAction( + 'conversations_table.after_subject', + static function ( mixed $conversation = null ) use ( $marks ): void { + try { + if ( $marks( $conversation ) && '' !== Subject::marked( (string) $conversation->subject ) ) { + echo '</span>'; + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not mark the subject of conversation ' . ( $conversation->id ?? '' ) . ': ' . $e->getMessage() ); + } + } + ); + } + + /** + * 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 ) + ->orderBy( 'created_at', 'desc' ) + ->limit( self::PREVIOUS_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. @@ -409,8 +523,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 dbe19717ab..95882387de 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css @@ -66,29 +66,56 @@ 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"] { + background-color: #1d4ed8; +} + +.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 +141,8 @@ padding: 0; border: 0; background: none; - color: #005ba3; + color: #4a5561; + font-weight: 400; text-align: left; } @@ -128,7 +156,7 @@ } .wporg-review-issue.is-priority-0 { - color: #4a5561; + opacity: 0.6; } /* Where an issue the panel jumped to starts, for a moment. */ @@ -143,14 +171,41 @@ } /* 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 { diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js index abf3a2c6f5..6d8e6805e6 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js @@ -1,7 +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. Also shows the member - * and tag chosen in the My Reviews and My Cases folders, and opens their conversations in new tabs. + * 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. */ @@ -269,7 +270,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. @@ -349,18 +351,24 @@ 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' ) ); - $names - .toggleClass( 'is-suggested', suggested ) - .toggleClass( - 'is-original', - ! suggested && - current === - normalizeName( $names.attr( 'data-original' ) ) - ); - addRow( $names, strings.current, [ + 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'; + } + markCheck( $names, state, strings[ 'names_' + state ] ); + + addRow( $names.find( '.wporg-review-details' ), strings.current, [ String( plugin.name ), $( '<br>' ), $( '<code>' ).text( String( plugin.slug ) ), @@ -370,14 +378,35 @@ } /** - * Shows whether the plugin's domains carry the author's record, and whether the submitter's address is at one. + * Colors a check in the panel by whether the author followed the review. + * + * @param {jQuery} $check Check. + * @param {string} state followed, ignored, or to-review. + * @param {string} label What it means. + */ + function markCheck( $check, state, label ) { + $check.addClass( 'is-' + state ).attr( 'title', label ); + } + + /** + * 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 neither 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 ) { + // Either is how the review asks them to prove it. + if ( + ! [ 'author', 'plugin', 'email' ].some( + ( key ) => true === owner[ key ] + ) + ) { + markCheck( $owner, 'ignored', strings.owner_ignored ); + } + [ 'author', 'plugin' ].forEach( ( key ) => { if ( true === owner[ key ] ) { $owner @@ -391,7 +420,7 @@ } ); if ( plugin && plugin.submitter ) { - addRow( $owner, strings.submitter, [ + addRow( $owner.find( '.wporg-review-details' ), strings.submitter, [ String( plugin.submitter.username ), $( '<br>' ), $( '<span class="wporg-review-email">' ) @@ -484,31 +513,31 @@ } /** - * 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. + * 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 {jQuery} $panel Panel. + * @param {jQuery} $data What the page says about the conversation's subject. * @param {string[]} flags Flags. * @param {Object} strings Labels. */ - function manageTitle( $panel, flags, strings ) { + function manageTitle( $data, flags, strings ) { + const subject = String( $data.attr( 'data-subject' ) || '' ); + const number = String( $data.attr( 'data-number' ) || '' ); + if ( ! subject || ! number ) { + return; + } + 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; - } + // 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; - const $toggle = $panel.find( '.wporg-review-title-toggle' ); + const $toggle = $( '.wporg-review-title-toggle' ); let full = prefersFullTitle(); // FreeScout's count of new replies goes first, then the marks. @@ -522,14 +551,15 @@ 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 ); + // Each way only replaces what the other way leaves, so another pass changes nothing. + if ( ! full && rest.startsWith( long ) ) { + rest = short + rest.slice( long.length ); + } else if ( + full && + ! rest.startsWith( long ) && + rest.startsWith( short ) + ) { + rest = long + rest.slice( short.length ); } const title = @@ -543,6 +573,7 @@ update(); label(); + $toggle.removeAttr( 'hidden' ); $toggle.on( 'click', () => { full = ! full; @@ -561,6 +592,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 = $( '<span>' ); + 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. * @@ -854,13 +960,50 @@ 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; + } + + const $panel = $( '.wporg-review' ); + markSubjects( $data ); manageTitle( - $panel, + $data, String( $panel.attr( 'data-flags' ) || '' ) .split( ' ' ) .filter( Boolean ), - strings + $panel.data( 'strings' ) || {} ); } ); + + // 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 copied = selection.toString(); + let text = copied; + $( '[data-wporg-subject]' ).each( function () { + if ( selection.containsNode( this, true ) ) { + text = text.replace( + $( this ).text(), + String( $( this ).attr( 'data-wporg-subject' ) ) + ); + } + } ); + + 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 f55b1903fe..fbd94f6d1d 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md @@ -14,14 +14,25 @@ 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"; a link at the bottom of the panel switches back to the full title, which the browser remembers. 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. 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..0891d68a38 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,32 @@ ); @endphp {{-- FreeScout's own sidebar block, like Previous Conversations. --}} -<div class="conv-sidebar-block wporg-review" data-flags="{{ implode( ' ', $review['flags'] ) }}" data-username="{{ $review_id['username'] }}" data-subject="{{ $subject }}" data-short-subject="{{ $short_subject }}" data-url="{{ route( 'wporgpluginreview.plugin', array( 'conversation_id' => $conversation_id ) ) }}" data-strings="{{ json_encode( +<div class="conv-sidebar-block wporg-review" data-flags="{{ implode( ' ', $review['flags'] ) }}" data-username="{{ $review_id['username'] }}" data-url="{{ route( 'wporgpluginreview.plugin', array( 'conversation_id' => $conversation_id ) ) }}" data-strings="{{ json_encode( array( - 'copied' => __( 'Copied' ), - 'verified' => __( 'DNS verified' ), - 'status' => __( 'Status' ), - 'assigned' => __( 'Assigned to' ), - 'current' => __( 'Current' ), - 'submitter' => __( 'Submitter' ), - 'download' => __( 'Copy the download URL' ), - 'page' => __( 'Open the plugin’s page' ), - 'scans' => __( 'Open the plugin’s security scans' ), - 'slug' => __( 'Slug' ), - 'copy_slug' => __( 'Copy the slug' ), - 'email_match' => __( 'At one of the plugin’s domains' ), - 'email_other' => __( 'Not at the plugin’s domains' ), - 'edit' => __( 'Edit the plugin on WordPress.org' ), - 'failed' => __( 'Couldn’t load the plugin from WordPress.org.' ), - 'throttled' => __( 'Couldn’t load the plugin from WordPress.org: too many requests. Reload the page in a minute.' ), - 'short_title' => __( 'Shorten the tab’s title' ), - 'full_title' => __( 'Show the tab’s full title' ), - 'mismatch' => __( 'The Review ID in this reply is for :username, who isn’t the author of the plugin this conversation is about. Check that it’s the right review before you send it.' ), - 'acknowledge' => __( 'It’s the right one' ), + 'copied' => __( 'Copied' ), + 'verified' => __( 'DNS verified' ), + 'status' => __( 'Status' ), + 'assigned' => __( 'Assigned to' ), + 'current' => __( 'Current' ), + 'submitter' => __( 'Submitter' ), + 'download' => __( 'Copy the download URL' ), + 'page' => __( 'Open the plugin’s page' ), + 'scans' => __( 'Open the plugin’s security scans' ), + 'slug' => __( 'Slug' ), + 'copy_slug' => __( 'Copy the slug' ), + 'email_match' => __( 'At one of the plugin’s domains' ), + 'email_other' => __( 'Not at the plugin’s domains' ), + 'edit' => __( 'Edit the plugin on WordPress.org' ), + 'failed' => __( 'Couldn’t load the plugin from WordPress.org.' ), + 'throttled' => __( 'Couldn’t load the plugin from WordPress.org: too many requests. Reload the page in a minute.' ), + 'short_title' => __( 'Shorten the tab’s title' ), + 'full_title' => __( 'Show the tab’s full title' ), + 'mismatch' => __( 'The Review ID in this reply is for :username, who isn’t the author of the plugin this conversation is about. Check that it’s the right review before you send it.' ), + 'acknowledge' => __( 'It’s the right one' ), + 'names_followed' => __( 'The plugin has the suggested name.' ), + 'names_ignored' => __( 'The plugin still has its original name.' ), + 'names_to-review' => __( 'The plugin has another name: check it.' ), + 'owner_ignored' => __( 'Neither the plugin’s domains nor the submitter’s email address show they own it.' ), ) ) }}"> <div class="panel-group accordion accordion-empty"> @@ -88,32 +92,45 @@ @endif @if ( $review['names'] ) - <h5 class="wporg-review-heading">{{ __( 'Names' ) }}</h5> - <dl class="wporg-review-details wporg-review-names" data-original="{{ $review['names']['original'] }}" data-suggested="{{ $review['names']['suggested'] }}"> - <dt>{{ __( 'Original' ) }}</dt> - <dd>{{ '' !== $review['names']['original'] ? $review['names']['original'] : '—' }}</dd> - <dt>{{ __( 'Suggested' ) }}</dt> - <dd>{{ '' !== $review['names']['suggested'] ? $review['names']['suggested'] : '—' }}</dd> - @if ( '' !== $review['names']['suggested_slug'] ) - <dt>{{ __( 'Suggested slug' ) }}</dt> - <dd> - <code>{{ $review['names']['suggested_slug'] }}</code> - <button type="button" class="wporg-review-copy" data-copy="{{ $review['names']['suggested_slug'] }}" title="{{ __( 'Copy the slug' ) }}" aria-label="{{ __( 'Copy the slug' ) }}"><i class="glyphicon glyphicon-copy"></i></button> - </dd> - @endif - </dl> + @php + $names = $review['names']; + @endphp + {{-- The script colors it once it knows the plugin's current name. --}} + <div class="wporg-review-check wporg-review-names" data-original="{{ $names['original'] }}" data-suggested="{{ $names['suggested'] }}" data-suggested-slug="{{ $names['suggested_slug'] }}"> + <h5 class="wporg-review-heading">{{ __( 'Names' ) }}</h5> + <dl class="wporg-review-details"> + <dt>{{ __( 'Original' ) }}</dt> + <dd>{{ '' !== $names['original'] ? $names['original'] : '—' }}</dd> + @if ( '' !== $names['suggested'] || '' !== $names['suggested_slug'] ) + <dt>{{ __( 'Suggested' ) }}</dt> + <dd> + {{ $names['suggested'] }} + @if ( '' !== $names['suggested_slug'] ) + @if ( '' !== $names['suggested'] ) + <br> + @endif + <code>{{ $names['suggested_slug'] }}</code> + <button type="button" class="wporg-review-copy" data-copy="{{ $names['suggested_slug'] }}" title="{{ __( 'Copy the slug' ) }}" aria-label="{{ __( 'Copy the slug' ) }}"><i class="glyphicon glyphicon-copy"></i></button> + @endif + </dd> + @endif + </dl> + </div> @endif @if ( $review['owner'] ) - <h5 class="wporg-review-heading">{{ __( 'Owner' ) }}</h5> - <dl class="wporg-review-details wporg-review-owner"> - @foreach ( array( 'author' => __( 'Author host' ), 'plugin' => __( 'Plugin host' ) ) as $key => $label ) - @if ( '' !== $review['owner'][ $key . '_host' ] ) - <dt>{{ $label }}</dt> - <dd data-host="{{ $key }}">{{ $review['owner'][ $key . '_host' ] }}</dd> - @endif - @endforeach - </dl> + {{-- The script colors it once it knows whether the author proved they own the plugin. --}} + <div class="wporg-review-check wporg-review-owner"> + <h5 class="wporg-review-heading">{{ __( 'Owner' ) }}</h5> + <dl class="wporg-review-details"> + @foreach ( array( 'author' => __( 'Author host' ), 'plugin' => __( 'Plugin host' ) ) as $key => $label ) + @if ( '' !== $review['owner'][ $key . '_host' ] ) + <dt>{{ $label }}</dt> + <dd data-host="{{ $key }}">{{ $review['owner'][ $key . '_host' ] }}</dd> + @endif + @endforeach + </dl> + </div> @endif @if ( $review['issues'] ) @@ -127,10 +144,8 @@ </ul> @endif - @if ( '' !== $short_subject ) - {{-- Its label says what a click does, once the script knows which title the tab has. --}} - <button type="button" class="wporg-review-title-toggle"></button> - @endif + {{-- The script shows it, labeled with what a click does, when it changes the tab's title. --}} + <button type="button" class="wporg-review-title-toggle" hidden></button> </div> </div> </div> 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. --}} +<span class="wporg-subjects" hidden data-conversation-id="{{ $conversation_id }}" data-number="{{ $number }}" data-subject="{{ $subject }}" data-short-subject="{{ $short_subject }}" data-subjects="{{ json_encode( (object) $subjects ) }}"></span> 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/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 @@ <?php /** - * Shortens the subjects of the plugin directory's emails, for the browser tab. + * Shortens the subjects of the plugin directory's emails. * * @package WordPressdotorg\FreeScout\WPOrgPluginReview */ @@ -10,8 +10,8 @@ namespace Modules\WPOrgPluginReview\Services; /** - * Turns a subject like "[WordPress Plugin Directory] Review in Progress: My Plugin" into "R: My Plugin", so a reviewer - * with many conversations open can tell their tabs apart. + * Turns a subject like "[WordPress Plugin Directory] Review in Progress: My Plugin" into "🔌 My Plugin", so a reviewer + * can tell conversations, and tabs, apart at a glance. */ final class Subject { @@ -23,32 +23,65 @@ final class Subject { private const PREFIX = '[WordPress Plugin Directory]'; /** - * Short names of the kinds of email, by how their subjects name them; others keep their own name. + * What goes before the plugin's name, by how the subjects of the most common kinds of email name them. + * + * @var string[] + */ + private const MARKS = array( + 'Review in Progress' => '🔌', + '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/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 @@ +<?php +/** + * Tests for marking the plugin directory's subjects in conversations and lists. + * + * @package WordPressdotorg\FreeScout\WPOrgPluginReview + */ + +declare( strict_types = 1 ); + +namespace Modules\WPOrgPluginReview\Tests; + +use App\Conversation; +use App\Customer; +use App\Mailbox; +use Modules\WPOrgPluginReview\Providers\WPOrgPluginReviewServiceProvider; +use WordPressdotorg\FreeScout\Tests\TestCase; + +/** + * Covers what the conversation tells its script, and what lists of conversations show, in the plugins team's mailbox + * and in others. + */ +final class MarkedSubjectsTest extends TestCase { + + /** + * The plugins team's mailbox. + * + * @var Mailbox + */ + private $mailbox; + + /** + * Sender. + * + * @var Customer + */ + private $sender; + + /** + * Registers the module, and acts as an agent. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->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 <Plugin>' ); + $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( '<Plugin>', $html ); + + $this->assertTrue( (bool) preg_match( '/data-subjects="([^"]*)"/', $html, $matches ) ); + $this->assertSame( + array( + $conversation->id => array( + 'subject' => '[WordPress Plugin Directory] Review in Progress: My <Plugin>', + 'marked' => '🔌 My <Plugin>', + ), + $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( + '<span class="wporg-subject" data-wporg-subject="Re: [WordPress Plugin Directory] Security Notice: My <Plugin>" title="Re: [WordPress Plugin Directory] Security Notice: My <Plugin>">🔌🛡️ My <Plugin></span><span hidden>Re: [WordPress Plugin Directory] Security Notice: My <Plugin></span>', + $this->row( $this->conversation( 'Re: [WordPress Plugin Directory] Security Notice: My <Plugin>' ) ) + ); + $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/PanelTest.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php index 469f8dc58f..9f947aeba3 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 <Plugin>' ) ); $this->review( '<ul><li>Plugin URI: https://example.org/p</li></ul><h3>🔴 Use wp_enqueue <script> commands</h3><p>Review ID: R ❗OWN my-plugin/jane/1Aug26/T2 8Aug26/4.3 (P0TDX42HGN)</p>' ); $html = $this->panel(); @@ -86,8 +85,7 @@ 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( '<button type="button" class="wporg-review-title-toggle" hidden>', $html ); $this->assertStringContainsString( 'data-title="Use wp_enqueue <script> commands"', $html ); $this->assertStringNotContainsString( '<script>', $html ); $this->assertStringContainsString( '/wporgpluginreview/' . $this->conversation->id . '/plugin', $html ); @@ -95,20 +93,40 @@ public function test_panel_shows_the_latest_review(): void { } /** - * Without a plugin ID, the slug waits for WordPress.org to link it; a subject that isn't the plugin directory's isn't - * shortened. + * Without a plugin ID, the slug waits for WordPress.org to link it. * * @return void */ public function test_panel_links_the_slug_once_the_plugin_is_found(): void { - $this->conversation->update( array( 'subject' => 'Question about my plugin' ) ); $this->review( '<p>Review ID: R my-plugin/jane 8Aug26/4.3</p>' ); $html = $this->panel(); $this->assertStringContainsString( '<span class="wporg-review-slug-name">my-plugin</span>', $html ); - $this->assertStringContainsString( 'data-short-subject=""', $html ); - $this->assertStringNotContainsString( 'wporg-review-title-toggle', $html ); + } + + /** + * A naming review shows the name and slug it suggests together, for the script to compare with the plugin's; and no + * suggestion when it makes none. + * + * @return void + */ + public function test_panel_shows_the_suggestions_together(): void { + $this->conversation->update( array( 'subject' => '[WordPress Plugin Directory] Review in Progress: My Plugin' ) ); + $this->review( '<ul><li>Suggested alternative name: <code>Example Kit</code></li><li>Suggested alternative slug: <code>example-kit</code></li></ul><p>Review ID: R ❗TRM my-plugin/jane 8Aug26/4.3</p>' ); + + $html = $this->panel(); + + $this->assertStringContainsString( 'data-original="My Plugin" data-suggested="Example Kit" data-suggested-slug="example-kit"', $html ); + $this->assertMatchesRegularExpression( '#<dt>Suggested</dt>\s*<dd>\s*Example Kit\s*<br>\s*<code>example-kit</code>#', $html ); + $this->assertStringNotContainsString( 'Suggested slug', $html ); + + $this->review( '<p>Review ID: R ❗TRM my-plugin/jane 9Aug26/4.4</p>', '2026-08-09 10:00:00' ); + + $html = $this->panel(); + + $this->assertStringContainsString( 'wporg-review-names', $html ); + $this->assertStringNotContainsString( '<dt>Suggested</dt>', $html ); } /** diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/SubjectTest.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/SubjectTest.php index 1c13f50f55..827227a937 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/SubjectTest.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/SubjectTest.php @@ -18,18 +18,50 @@ final class SubjectTest extends TestCase { /** - * Subjects are shortened by their kind, or by their prefix alone for kinds without a short name; a short subject never - * holds the full one, which the tab's title relies on to switch between them. + * The most common kinds of email get their mark in place of everything before the plugin's name, replies and + * forwards included. * - * @dataProvider data_subjects + * @dataProvider data_marked_subjects + * + * @param string $subject Subject. + * @param string $marked Its marked version. + * @return void + */ + public function test_marks_the_most_common_subjects( string $subject, string $marked ): void { + $this->assertSame( $marked, Subject::marked( $subject ) ); + $this->assertSame( $marked, Subject::short( $subject ) ); + } + + /** + * Subjects, and their marked versions. + * + * @return array[] + */ + public static function data_marked_subjects(): array { + return array( + 'review in progress' => array( '[WordPress Plugin Directory] Review in Progress: Acme Forms', '🔌 Acme Forms' ), + 'forwarded reply' => array( 'Fwd: Re: RE: [WordPress Plugin Directory] Review in Progress: Acme: Forms', '🔌 Acme: Forms' ), + 'trademarks' => array( '[WordPress Plugin Directory] Closure Notice - Licensing/Trademark Violation: Acme Forms', '🔌™️ Acme Forms' ), + 'guidelines' => array( '[WordPress Plugin Directory] Closure Notice - Guideline Violation: Acme Forms', '🔌📜 Acme Forms' ), + 'notice' => array( '[WordPress Plugin Directory] Notice: Acme Forms', '🔌ℹ️ Acme Forms' ), + 'security' => array( '[WordPress Plugin Directory] Security Notice: Acme Forms', '🔌🛡️ Acme Forms' ), + 'rejection' => array( '[WordPress Plugin Directory] Rejection Explanation: Acme Forms', '🔌🚫 Acme Forms' ), + ); + } + + /** + * Other kinds have no mark, and the tab's title shortens them by their kind, or by their prefix alone for kinds + * without a short name. + * + * @dataProvider data_other_subjects * * @param string $subject Subject. * @param string $short Its short version. * @return void */ - public function test_shortens_the_plugin_directorys_subjects( string $subject, string $short ): void { + public function test_shortens_other_plugin_directory_subjects( string $subject, string $short ): void { + $this->assertSame( '', Subject::marked( $subject ) ); $this->assertSame( $short, Subject::short( $subject ) ); - $this->assertStringNotContainsString( $subject, $short ); } /** @@ -37,13 +69,11 @@ public function test_shortens_the_plugin_directorys_subjects( string $subject, s * * @return array[] */ - public static function data_subjects(): array { + public static function data_other_subjects(): array { return array( - 'review in progress' => array( '[WordPress Plugin Directory] Review in Progress: Acme Forms', 'R: Acme Forms' ), - 'closure with reason' => array( '[WordPress Plugin Directory] Closure Notice - Trademarks: Acme: Forms', 'Closed (Trademarks): Acme: Forms' ), - 'reply' => array( 'Re: RE: [WordPress Plugin Directory] Rejection Explanation: Acme Forms', 'Rejected: Acme Forms' ), - 'unknown kind' => array( '[WordPress Plugin Directory] Notice: Acme Forms', 'Notice: Acme Forms' ), - 'unknown, no colon' => array( '[WordPress Plugin Directory] Added to Block Directory - Acme Forms', 'Added to Block Directory - Acme Forms' ), + 'other closure' => array( '[WordPress Plugin Directory] Closure Notice - Author Request: Acme: Forms', 'Closed (Author Request): Acme: Forms' ), + 'unknown kind' => array( 'Re: [WordPress Plugin Directory] Approved: Acme Forms', 'Approved: Acme Forms' ), + 'unknown, no colon' => array( '[WordPress Plugin Directory] Added to Block Directory - Acme Forms', 'Added to Block Directory - Acme Forms' ), ); } @@ -54,6 +84,7 @@ public static function data_subjects(): array { */ public function test_leaves_other_subjects_alone(): void { $this->assertSame( '', Subject::short( 'Question about Acme Forms' ) ); + $this->assertSame( '', Subject::marked( 'Review in Progress: Acme Forms' ) ); $this->assertSame( '', Subject::short( '' ) ); } } From 48b51f889c36d636114987c8b974be8e43fea4d2 Mon Sep 17 00:00:00 2001 From: frantorres <git@frantorres.es> Date: Fri, 16 Oct 2026 15:02:21 +0200 Subject: [PATCH 08/13] Freescout: Plugin Review, include the new subject as title. --- .../WPOrgPluginReview/Public/css/review.css | 17 ----- .../WPOrgPluginReview/Public/js/review.js | 73 ++----------------- .../Modules/WPOrgPluginReview/README.md | 2 +- .../Resources/views/panel.blade.php | 5 -- .../WPOrgPluginReview/tests/PanelTest.php | 1 - 5 files changed, 7 insertions(+), 91 deletions(-) diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css index 95882387de..6eb3e12091 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css @@ -216,23 +216,6 @@ color: #8c1616; } -/* Switches the tab between its short and full title. */ -.wporg-review-title-toggle { - display: block; - margin-top: 12px; - padding: 0; - border: 0; - background: none; - color: #4a5561; - font-size: 11px; -} - -.wporg-review-title-toggle:hover, -.wporg-review-title-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; diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js index 6d8e6805e6..0f58974c4b 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js @@ -25,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. * @@ -482,45 +475,14 @@ } ); } - /** - * 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. - * - * @param {boolean} full Whether they want the full title. - */ - 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. - } - } - /** * 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 {jQuery} $data What the page says about the conversation's subject. - * @param {string[]} flags Flags. - * @param {Object} strings Labels. + * @param {jQuery} $data What the page says about the conversation's subject. + * @param {string[]} flags Flags. */ - function manageTitle( $data, flags, strings ) { + function manageTitle( $data, flags ) { const subject = String( $data.attr( 'data-subject' ) || '' ); const number = String( $data.attr( 'data-number' ) || '' ); if ( ! subject || ! number ) { @@ -537,9 +499,6 @@ const short = String( $data.attr( 'data-short-subject' ) || '' ) || subject; - const $toggle = $( '.wporg-review-title-toggle' ); - let full = prefersFullTitle(); - // FreeScout's count of new replies goes first, then the marks. const prefix = new RegExp( '^(\\(\\d+\\) )?(?:[' + @@ -551,15 +510,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. - if ( ! full && rest.startsWith( long ) ) { + if ( rest.startsWith( long ) ) { rest = short + rest.slice( long.length ); - } else if ( - full && - ! rest.startsWith( long ) && - rest.startsWith( short ) - ) { - rest = long + rest.slice( short.length ); } const title = @@ -568,19 +520,8 @@ document.title = title; } }; - const label = () => - $toggle.text( full ? strings.short_title : strings.full_title ); update(); - label(); - $toggle.removeAttr( 'hidden' ); - - $toggle.on( 'click', () => { - full = ! full; - preferFullTitle( full ); - update(); - label(); - } ); const element = document.querySelector( 'title' ); if ( element ) { @@ -969,14 +910,12 @@ return; } - const $panel = $( '.wporg-review' ); markSubjects( $data ); manageTitle( $data, - String( $panel.attr( 'data-flags' ) || '' ) + String( $( '.wporg-review' ).attr( 'data-flags' ) || '' ) .split( ' ' ) - .filter( Boolean ), - $panel.data( 'strings' ) || {} + .filter( Boolean ) ); } ); diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md index fbd94f6d1d..71241d0aee 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md @@ -32,7 +32,7 @@ The plugin directory's most common subjects show with a mark in place of everyth | [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"; a link at the bottom of the panel switches back to the full title, which the browser remembers. Marks are in `Services/Subject.php`. +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. 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 0891d68a38..ba7fe457c6 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/panel.blade.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/panel.blade.php @@ -30,8 +30,6 @@ 'edit' => __( 'Edit the plugin on WordPress.org' ), 'failed' => __( 'Couldn’t load the plugin from WordPress.org.' ), 'throttled' => __( 'Couldn’t load the plugin from WordPress.org: too many requests. Reload the page in a minute.' ), - 'short_title' => __( 'Shorten the tab’s title' ), - 'full_title' => __( 'Show the tab’s full title' ), 'mismatch' => __( 'The Review ID in this reply is for :username, who isn’t the author of the plugin this conversation is about. Check that it’s the right review before you send it.' ), 'acknowledge' => __( 'It’s the right one' ), 'names_followed' => __( 'The plugin has the suggested name.' ), @@ -143,9 +141,6 @@ @endforeach </ul> @endif - - {{-- The script shows it, labeled with what a click does, when it changes the tab's title. --}} - <button type="button" class="wporg-review-title-toggle" hidden></button> </div> </div> </div> diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php index 9f947aeba3..cac026aea8 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php @@ -85,7 +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( '<button type="button" class="wporg-review-title-toggle" hidden>', $html ); $this->assertStringContainsString( 'data-title="Use wp_enqueue <script> commands"', $html ); $this->assertStringNotContainsString( '<script>', $html ); $this->assertStringContainsString( '/wporgpluginreview/' . $this->conversation->id . '/plugin', $html ); From 958d9c50cd206fc8105982454e13d3e7f0413da1 Mon Sep 17 00:00:00 2001 From: frantorres <git@frantorres.es> Date: Fri, 16 Oct 2026 17:04:04 +0200 Subject: [PATCH 09/13] Freescout: Plugins & Themes sidebar, show the last update as a short relative time. "Updated 1 day ago" becomes "(1d)", with the full date in the tooltip, to fit the narrow sidebar. The local environment's recorded panel uses the same format. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../public_html/dotorg/freescout/common.php | 29 +++++ .../dotorg/freescout/plugins-themes.php | 4 +- .../dotorg/freescout/tests/bootstrap.php | 5 + .../tests/class-short-time-diff-test.php | 53 +++++++++ .../mock-api/panels/plugins-themes.json | 112 +++++++++--------- 5 files changed, 145 insertions(+), 58 deletions(-) create mode 100644 api.wordpress.org/public_html/dotorg/freescout/tests/class-short-time-diff-test.php diff --git a/api.wordpress.org/public_html/dotorg/freescout/common.php b/api.wordpress.org/public_html/dotorg/freescout/common.php index b2fb762bbb..8046f916b2 100644 --- a/api.wordpress.org/public_html/dotorg/freescout/common.php +++ b/api.wordpress.org/public_html/dotorg/freescout/common.php @@ -286,6 +286,35 @@ 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 to the largest unit that fits, like human_time_diff(). + * + * @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, + ); + + foreach ( $units as $unit => $seconds ) { + if ( $diff >= $seconds ) { + return (int) round( $diff / $seconds ) . $unit; + } + } + + return max( 1, $diff ) . 's'; +} + /** * Determines the mailbox slug, e.g. "Plugins" => plugins. * 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..4e67c88a6a 100644 --- a/api.wordpress.org/public_html/dotorg/freescout/plugins-themes.php +++ b/api.wordpress.org/public_html/dotorg/freescout/plugins-themes.php @@ -234,8 +234,8 @@ function render_items( array $post_ids, string $mailbox_email ): array { $meta = array( array( 'text' => $post->post_name ), array( - 'text' => 'Updated ' . human_time_diff( $last_updated, time() ) . ' ago', - 'tooltip' => gmdate( 'Y-m-d', $last_updated ), + 'text' => '(' . short_time_diff( $last_updated, time() ) . ')', + 'tooltip' => 'Updated ' . gmdate( 'Y-m-d', $last_updated ), ), ); 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-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..869151f8ac --- /dev/null +++ b/api.wordpress.org/public_html/dotorg/freescout/tests/class-short-time-diff-test.php @@ -0,0 +1,53 @@ +<?php +/** + * Tests for the short times the sidebar shows, e.g. when a plugin was last updated. + * + * @package WordPressdotorg\API\FreeScout + */ + +declare( strict_types = 1 ); + +namespace WordPressdotorg\API\FreeScout; + +use PHPUnit\Framework\TestCase; + +/** + * Tests for short_time_diff(). + */ +class Short_Time_Diff_Test extends TestCase { + + /** + * The time is rounded to the largest unit that fits, like human_time_diff(). + * + * @dataProvider data_short_time_diff + * + * @param int $seconds Seconds between the timestamps. + * @param string $expected Short time. + * @return void + */ + public function test_short_time_diff( int $seconds, string $expected ): void { + $now = 1760000000; + + $this->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' ), + 'in the future' => array( -2 * DAY_IN_SECONDS, '2d' ), + ); + } +} diff --git a/environments/freescout/mock-api/panels/plugins-themes.json b/environments/freescout/mock-api/panels/plugins-themes.json index 48ebd11b1a..03c5321abf 100644 --- a/environments/freescout/mock-api/panels/plugins-themes.json +++ b/environments/freescout/mock-api/panels/plugins-themes.json @@ -17,8 +17,8 @@ "text": "the-events-calendar" }, { - "text": "Updated 5 days ago", - "tooltip": "2026-09-30" + "text": "(5d)", + "tooltip": "Updated 2026-09-30" } ], "links": [ @@ -54,8 +54,8 @@ "text": "block-for-strava" }, { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" + "text": "(2mo)", + "tooltip": "Updated 2026-08-21" } ], "links": [ @@ -80,8 +80,8 @@ "text": "change-from-address" }, { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" + "text": "(2mo)", + "tooltip": "Updated 2026-08-21" } ], "links": [ @@ -106,8 +106,8 @@ "text": "clicky-popular-posts-widget" }, { - "text": "Updated 12 years ago", - "tooltip": "2014-09-04" + "text": "(12y)", + "tooltip": "Updated 2014-09-04" } ], "links": [ @@ -132,8 +132,8 @@ "text": "jetpack-twitter-via" }, { - "text": "Updated 14 years ago", - "tooltip": "2012-11-26" + "text": "(14y)", + "tooltip": "Updated 2012-11-26" } ], "links": [ @@ -158,8 +158,8 @@ "text": "shiny-updates" }, { - "text": "Updated 10 years ago", - "tooltip": "2016-09-27" + "text": "(10y)", + "tooltip": "Updated 2016-09-27" } ], "links": [ @@ -184,8 +184,8 @@ "text": "twenty-eleven-showcase-slider" }, { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" + "text": "(2mo)", + "tooltip": "Updated 2026-08-21" } ], "links": [ @@ -210,8 +210,8 @@ "text": "uploads-unleashed" }, { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" + "text": "(2mo)", + "tooltip": "Updated 2026-08-21" } ], "links": [ @@ -236,8 +236,8 @@ "text": "wp-approve-user" }, { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" + "text": "(2mo)", + "tooltip": "Updated 2026-08-21" } ], "links": [ @@ -262,8 +262,8 @@ "text": "wp-author-slug" }, { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" + "text": "(2mo)", + "tooltip": "Updated 2026-08-21" } ], "links": [ @@ -288,8 +288,8 @@ "text": "wp-display-header" }, { - "text": "Updated 1 month ago", - "tooltip": "2026-09-02" + "text": "(1mo)", + "tooltip": "Updated 2026-09-02" } ], "links": [ @@ -314,8 +314,8 @@ "text": "wp-last-login" }, { - "text": "Updated 3 weeks ago", - "tooltip": "2026-09-16" + "text": "(3w)", + "tooltip": "Updated 2026-09-16" } ], "links": [ @@ -340,8 +340,8 @@ "text": "wp-search-suggest" }, { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" + "text": "(2mo)", + "tooltip": "Updated 2026-08-21" } ], "links": [ @@ -366,8 +366,8 @@ "text": "wpmu-author-description" }, { - "text": "Updated 14 years ago", - "tooltip": "2012-07-12" + "text": "(14y)", + "tooltip": "Updated 2012-07-12" } ], "links": [ @@ -392,8 +392,8 @@ "text": "wpmu-network-site-users-dropdown" }, { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" + "text": "(2mo)", + "tooltip": "Updated 2026-08-21" } ], "links": [ @@ -423,8 +423,8 @@ "text": "arya-stark" }, { - "text": "Updated 10 years ago", - "tooltip": "2016-04-27" + "text": "(10y)", + "tooltip": "Updated 2016-04-27" } ], "links": [ @@ -454,8 +454,8 @@ "text": "wp-google-suggest" }, { - "text": "Updated 6 years ago", - "tooltip": "2020-11-23" + "text": "(6y)", + "tooltip": "Updated 2020-11-23" } ], "links": [ @@ -485,8 +485,8 @@ "text": "wp-save-custom-header" }, { - "text": "Updated 15 years ago", - "tooltip": "2011-05-11" + "text": "(15y)", + "tooltip": "Updated 2011-05-11" } ], "links": [ @@ -516,8 +516,8 @@ "text": "wp-time-background-color" }, { - "text": "Updated 12 years ago", - "tooltip": "2014-12-15" + "text": "(12y)", + "tooltip": "Updated 2014-12-15" } ], "links": [ @@ -547,8 +547,8 @@ "text": "wporg-site-icon" }, { - "text": "Updated 11 years ago", - "tooltip": "2015-06-29" + "text": "(11y)", + "tooltip": "Updated 2015-06-29" } ], "links": [ @@ -578,8 +578,8 @@ "text": "rejected-hello-world-greeting-rejected-2" }, { - "text": "Updated 2 months ago", - "tooltip": "2026-07-30" + "text": "(2mo)", + "tooltip": "Updated 2026-07-30" } ], "links": [ @@ -604,8 +604,8 @@ "text": "rejected-hello-world-greeting-rejected" }, { - "text": "Updated 4 months ago", - "tooltip": "2026-06-01" + "text": "(4mo)", + "tooltip": "Updated 2026-06-01" } ], "links": [ @@ -636,8 +636,8 @@ "text": "testr-2__trashed" }, { - "text": "Updated 6 years ago", - "tooltip": "2020-07-07" + "text": "(6y)", + "tooltip": "Updated 2020-07-07" } ], "links": [ @@ -662,8 +662,8 @@ "text": "testr-3__trashed" }, { - "text": "Updated 6 years ago", - "tooltip": "2020-07-07" + "text": "(6y)", + "tooltip": "Updated 2020-07-07" } ], "links": [ @@ -688,8 +688,8 @@ "text": "the-bootstrap" }, { - "text": "Updated 1 year ago", - "tooltip": "2025-07-24" + "text": "(1y)", + "tooltip": "Updated 2025-07-24" } ], "links": [ @@ -719,8 +719,8 @@ "text": "testr" }, { - "text": "Updated 6 years ago", - "tooltip": "2020-07-07" + "text": "(6y)", + "tooltip": "Updated 2020-07-07" } ], "links": [ @@ -750,8 +750,8 @@ "text": "arya-stark-testing" }, { - "text": "Updated 6 months ago", - "tooltip": "2026-03-28" + "text": "(6mo)", + "tooltip": "Updated 2026-03-28" } ], "links": [ @@ -776,8 +776,8 @@ "text": "canary-build" }, { - "text": "Updated 3 months ago", - "tooltip": "2026-07-02" + "text": "(3mo)", + "tooltip": "Updated 2026-07-02" } ], "links": [ From 3644eeef0f2171e279404d5b88b7aabb402f72bd Mon Sep 17 00:00:00 2001 From: frantorres <git@frantorres.es> Date: Fri, 16 Oct 2026 18:18:32 +0200 Subject: [PATCH 10/13] Freescout: Plugins & Themes sidebar, a status line, a Gandalf link, and aligned links. Each plugin or theme now shows its name across the full width, then, unless it's published, a status line with an icon and the status in words (the close reason, and the reviewer while in review), instead of a badge; delisted themes get no icon. The slug line ends with the last update and the links: Gandalf, for released plugins and published themes, the page on WordPress.org, once approved, and the download, which most have, at the right edge. The Gandalf URL and what makes a plugin released move to common.php, shared with the Plugin Review panel. Lists show those in review first and rejected ones last. The local mock panel uses the new format, with a sample plugin in each review status. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../public_html/dotorg/freescout/common.php | 105 +++ .../dotorg/freescout/plugin-review.php | 12 +- .../dotorg/freescout/plugins-themes.php | 135 +-- .../tests/class-item-status-test.php | 138 ++++ environments/freescout/mock-api/index.php | 5 +- .../mock-api/panels/plugins-themes.json | 772 ++++++++++++------ .../WPOrgSidebar/Public/css/sidebar.css | 74 +- .../WPOrgSidebar/Public/img/shield.svg | 1 + .../Modules/WPOrgSidebar/Public/js/sidebar.js | 175 +++- .../Modules/WPOrgSidebar/module.json | 2 +- 10 files changed, 1040 insertions(+), 379 deletions(-) create mode 100644 api.wordpress.org/public_html/dotorg/freescout/tests/class-item-status-test.php create mode 100644 freescout.wordpress.net/Modules/WPOrgSidebar/Public/img/shield.svg diff --git a/api.wordpress.org/public_html/dotorg/freescout/common.php b/api.wordpress.org/public_html/dotorg/freescout/common.php index 8046f916b2..6923e5bdd6 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. * @@ -315,6 +334,92 @@ function short_time_diff( int $from, int $to ): string { return max( 1, $diff ) . 's'; } +/** + * Icons and labels of the reasons plugins are closed or disabled for, by reason. + * + * @var array[] + */ +const CLOSE_REASONS = array( + 'security-issue' => array( '🛡️', 'Security issue' ), + 'author-request' => array( '👤', 'Author request' ), + 'guideline-violation' => array( '📜', 'Guideline violation' ), + 'licensing-trademark-violation' => array( '™️', 'Trademark or licensing' ), + 'merged-into-core' => array( '🧩', 'Merged into core' ), + 'unused' => array( '💤', 'Unused' ), +); + +/** + * 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. + * @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 { + switch ( $post_status ) { + // Plugins. + case 'new': + $status = array( '⏳', 'Pending initial review', 'warning' ); + break; + case 'pending': + $status = array( '🔍', 'In review', 'warning' ); + break; + case 'approved': + $status = array( '✅', 'Approved', 'warning' ); + break; + case 'closed': + case 'disabled': + $text = ucfirst( $post_status ); + if ( isset( CLOSE_REASONS[ $close_reason ] ) ) { + $status = array( CLOSE_REASONS[ $close_reason ][0], $text . ': ' . CLOSE_REASONS[ $close_reason ][1], 'error' ); + } else { + $status = array( '🔒', $close_reason ? $text . ': ' . ucfirst( str_replace( '-', ' ', $close_reason ) ) : $text, 'error' ); + } + break; + case 'rejected': + $status = array( '🚫', 'Rejected', 'muted' ); + break; + + // Themes. + case 'draft': + $status = array( '⏳', 'In review or rejected', 'warning' ); + break; + case 'suspend': + case 'suspended': + $status = array( '⛔', 'Suspended', 'error' ); + break; + case 'delist': + case 'delisted': + $status = array( '', 'Delisted', 'muted' ); + break; + + default: + return array(); + } + + list( $icon, $text, $tone ) = $status; + + if ( $reviewer && in_array( $post_status, array( 'new', 'pending' ), true ) ) { + $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..d2a46fe56c 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( @@ -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 4e67c88a6a..cb7b452632 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,16 @@ require __DIR__ . '/common.php'; /** - * Badge tones of plugins' statuses. + * Orders plugins and themes by status, those in review first and rejected ones last, then by name. * - * @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 +136,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 +152,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 +167,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 ) ) ) ); @@ -177,6 +175,22 @@ function get_items_by_slug( array $slugs ): array { return array_map( 'intval', $ids ); } +/** + * Gets why a plugin was rejected, in the words reviewers chose it from. + * + * @param string $reason Rejection reason, e.g. duplicate-copy. + * @return string + */ +function get_rejection_reason( string $reason ): string { + if ( ! $reason ) { + return ''; + } + + $reasons = class_exists( '\WordPressdotorg\Plugin_Directory\Template' ) ? \WordPressdotorg\Plugin_Directory\Template::get_rejection_reasons() : array(); + + return $reasons[ $reason ] ?? ucfirst( str_replace( '-', ' ', $reason ) ); +} + /** * Gets a list of plugins or themes, with their status. * @@ -193,9 +207,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 +218,36 @@ 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 = get_rejection_reason( (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' => '(' . short_time_diff( $last_updated, time() ) . ')', - 'tooltip' => 'Updated ' . gmdate( 'Y-m-d', $last_updated ), - ), - ); - - if ( $reviewer ) { - $meta[] = array( 'text' => 'Assigned to ' . $reviewer ); - } + $last_updated = (int) strtotime( $last_modified ); // Edit and permalinks are built by hand, as the post types aren't registered on this site. - $links = array( - array( + // Rarest first, so the download link, which most have, lines up at the end. + $links = array(); + if ( 'plugin' === $type ? is_plugin_released( $post ) : 'publish' === $post->post_status ) { + $links[] = array( + 'text' => 'Scans in Gandalf', + 'url' => sprintf( SCANS_URL, $type, rawurlencode( $post->post_name ) ), + 'icon' => 'shield', + ); + } + // Plugins and themes have no public page until they're approved. + if ( ! in_array( $post->post_status, array( 'new', 'pending', 'rejected', 'draft' ), 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 +256,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/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..3bab89d86a --- /dev/null +++ b/api.wordpress.org/public_html/dotorg/freescout/tests/class-item-status-test.php @@ -0,0 +1,138 @@ +<?php +/** + * Tests for the status lines of plugins and themes in the sidebar. + * + * @package WordPressdotorg\API\FreeScout + */ + +declare( strict_types = 1 ); + +namespace WordPressdotorg\API\FreeScout; + +use PHPUnit\Framework\TestCase; + +/** + * Tests for get_item_status(). + */ +class Item_Status_Test extends TestCase { + + /** + * Published plugins and themes have no status line. + * + * @return void + */ + public function test_published_has_no_status(): void { + $this->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: Trademark or licensing', + ), + 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'] ); + } + + /** + * Only reviews have a reviewer: the assignment stays after they're done. + * + * @return void + */ + public function test_no_reviewer_after_review(): void { + $this->assertSame( 'Approved', get_item_status( 'approved', '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/environments/freescout/mock-api/index.php b/environments/freescout/mock-api/index.php index bb14f1fcbf..fc21fb58a8 100644 --- a/environments/freescout/mock-api/index.php +++ b/environments/freescout/mock-api/index.php @@ -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 03c5321abf..b34a2a0f6a 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": "(5d)", - "tooltip": "Updated 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: Trademark or licensing" + } + }, + { + "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": "(2mo)", - "tooltip": "Updated 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": "(2mo)", - "tooltip": "Updated 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": "(12y)", - "tooltip": "Updated 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": "(14y)", - "tooltip": "Updated 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": "(10y)", - "tooltip": "Updated 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": "(2mo)", - "tooltip": "Updated 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": "(2mo)", - "tooltip": "Updated 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": "(2mo)", - "tooltip": "Updated 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": "(2mo)", - "tooltip": "Updated 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": "(1mo)", - "tooltip": "Updated 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": "(3w)", - "tooltip": "Updated 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": "(2mo)", - "tooltip": "Updated 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": "(14y)", - "tooltip": "Updated 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": "(2mo)", - "tooltip": "Updated 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": "(10y)", - "tooltip": "Updated 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": "(6y)", - "tooltip": "Updated 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": "(15y)", - "tooltip": "Updated 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": "(12y)", - "tooltip": "Updated 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": "(11y)", - "tooltip": "Updated 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": "(2mo)", - "tooltip": "Updated 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": "(4mo)", - "tooltip": "Updated 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": "(6y)", - "tooltip": "Updated 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": "(6y)", - "tooltip": "Updated 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": "(1y)", - "tooltip": "Updated 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": "(6y)", - "tooltip": "Updated 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": "(6mo)", - "tooltip": "Updated 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": "(3mo)", - "tooltip": "Updated 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/WPOrgSidebar/Public/css/sidebar.css b/freescout.wordpress.net/Modules/WPOrgSidebar/Public/css/sidebar.css index 719eb08934..4617ae465b 100644 --- a/freescout.wordpress.net/Modules/WPOrgSidebar/Public/css/sidebar.css +++ b/freescout.wordpress.net/Modules/WPOrgSidebar/Public/css/sidebar.css @@ -90,23 +90,81 @@ font-size: 12px; } -/* Next to the name, so the meta line below only wraps its text. */ -.wporg-sidebar-item-links { - float: right; - margin-left: 8px; +/* Statuses and their names: in progress, closed, and rejected or delisted. */ +.wporg-sidebar-item-status { + margin-top: 1px; + font-size: 11.5px; +} + +.wporg-sidebar-item-status-icon { + margin-right: 4px; +} + +.wporg-sidebar-item.is-warning :is(.wporg-sidebar-item-title, .wporg-sidebar-item-status) { + color: #634200; +} + +.wporg-sidebar-item.is-error :is(.wporg-sidebar-item-title, .wporg-sidebar-item-status) { + color: #8c1616; +} + +.wporg-sidebar-item.is-muted :is(.wporg-sidebar-item-title, .wporg-sidebar-item-status) { + color: #4a5561; +} + +.wporg-sidebar-item.is-muted .wporg-sidebar-item-title { + font-weight: 400; +} + +/* The age and links end the meta line, which wraps beside them, so they line up down the list. */ +.wporg-sidebar-item-footer { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 1px; +} + +.wporg-sidebar-item-footer > .wporg-sidebar-item-meta { + flex: 1; + min-width: 0; + margin-top: 0; +} + +.wporg-sidebar-item-actions { + display: flex; + flex: none; + align-items: center; + gap: 6px; + color: #4a5561; + font-size: 12px; +} + +.wporg-sidebar-item-age { + font-variant-numeric: tabular-nums; + white-space: nowrap; } -.wporg-sidebar-item-links a { - margin-left: 4px; +.wporg-sidebar-item-actions a { color: #4a5561; } -.wporg-sidebar-item-links a:hover, -.wporg-sidebar-item-links a:focus { +.wporg-sidebar-item-actions a:hover, +.wporg-sidebar-item-actions a:focus { color: #0078d7; text-decoration: none; } +/* Like a glyphicon, which has no shield. The path starts at the root, since production combines stylesheets into /css/builds/. */ +.wporg-sidebar-icon-shield { + position: relative; + top: 1px; + display: inline-block; + width: 1em; + height: 1em; + background-color: currentColor; + mask: url(/modules/wporgsidebar/img/shield.svg) no-repeat center / contain; +} + .wporg-sidebar-note { margin: 0; } diff --git a/freescout.wordpress.net/Modules/WPOrgSidebar/Public/img/shield.svg b/freescout.wordpress.net/Modules/WPOrgSidebar/Public/img/shield.svg new file mode 100644 index 0000000000..d4cb43d063 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgSidebar/Public/img/shield.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M8 .6 1.8 3v4.5c0 3.8 2.6 6.8 6.2 7.9 3.6-1.1 6.2-4.1 6.2-7.9V3L8 .6zm0 1.7 4.7 1.8v3.4c0 3-2 5.4-4.7 6.4V2.3z"/></svg> diff --git a/freescout.wordpress.net/Modules/WPOrgSidebar/Public/js/sidebar.js b/freescout.wordpress.net/Modules/WPOrgSidebar/Public/js/sidebar.js index 4688426a2a..d17b05438a 100644 --- a/freescout.wordpress.net/Modules/WPOrgSidebar/Public/js/sidebar.js +++ b/freescout.wordpress.net/Modules/WPOrgSidebar/Public/js/sidebar.js @@ -22,11 +22,18 @@ const TONES = [ 'success', 'warning', 'error', 'neutral' ]; /** - * Glyphicons an item's links may use. + * Item tones the stylesheet colors titles and status lines in. * * @type {string[]} */ - const ICONS = [ 'link', 'download-alt' ]; + const ITEM_TONES = [ 'warning', 'error', 'muted' ]; + + /** + * Icons an item's links may use: glyphicons, and a shield the stylesheet draws, as there's no glyphicon for one. + * + * @type {string[]} + */ + const ICONS = [ 'link', 'download-alt', 'shield' ]; /** * Profile panel actions, by the class of the link that does them. @@ -118,10 +125,121 @@ return nodes; } + /** + * Builds a link's icon. + * + * @param {string} icon One of ICONS. + * @return {jQuery} Element. + */ + function renderIcon( icon ) { + return 'shield' === icon + ? $( '<i class="wporg-sidebar-icon-shield">' ) + : $( '<i class="glyphicon">' ).addClass( 'glyphicon-' + icon ); + } + + /** + * Builds an item's status line: an icon, and the status in words. + * + * @param {Object} status Status: icon, text, and tooltip; text required. + * @return {jQuery|null} Element, or null without text. + */ + function renderStatus( status ) { + if ( ! status || ! status.text ) { + return null; + } + + const $status = $( '<div class="wporg-sidebar-item-status">' ); + + if ( status.icon ) { + $status.append( + $( + '<span class="wporg-sidebar-item-status-icon" aria-hidden="true">' + ).text( String( status.icon ) ) + ); + } + + if ( status.tooltip ) { + $status.attr( 'title', String( status.tooltip ) ); + } + + return $status.append( + document.createTextNode( String( status.text ) ) + ); + } + + /** + * Builds an item's meta line, its parts separated by dots. + * + * @param {Array} meta Parts: text, url, and tooltip. + * @return {jQuery|null} Element, or null without parts. + */ + function renderMeta( meta ) { + if ( ! meta || ! meta.length ) { + return null; + } + + const $meta = $( '<div class="wporg-sidebar-item-meta">' ); + + meta.forEach( function ( part, index ) { + const $part = textLink( part.text, part.url ); + + if ( part.tooltip ) { + $part.attr( 'title', String( part.tooltip ) ); + } + $meta.append( index ? ' · ' : '', $part ); + } ); + + return $meta; + } + + /** + * Builds the age and links that end an item's meta line. + * + * @param {Object} item Item: age, and links; both optional. + * @return {jQuery|null} Element, or null with neither. + */ + function renderActions( item ) { + const $actions = $( '<span class="wporg-sidebar-item-actions">' ); + + if ( item.age && item.age.text ) { + const $age = $( '<span class="wporg-sidebar-item-age">' ).text( + String( item.age.text ) + ); + + if ( item.age.tooltip ) { + $age.attr( 'title', String( item.age.tooltip ) ); + } + $actions.append( $age ); + } + + ( item.links || [] ).forEach( function ( link ) { + const href = webUrl( link.url ); + const icon = ICONS.includes( link.icon ) ? link.icon : 'link'; + + if ( ! href ) { + return; + } + + $actions.append( + $( '<a>' ) + .attr( { + href, + title: String( link.text ), + 'aria-label': String( link.text ), + } ) + .append( renderIcon( icon ) ) + ); + } ); + + return $actions.children().length ? $actions : null; + } + /** * Builds a list item: a plugin, a signup, a note, and the like. * - * @param {Object} item Item: title, url, tooltip, badges, note, meta, and links; all optional. + * The title takes the full width; the age and links end the meta line, so they line up down the list. + * + * @param {Object} item Item: title, url, tooltip, tone, badges, status, note, meta, age, and links; all optional. * @return {jQuery} Element. */ function renderItem( item ) { @@ -131,33 +249,8 @@ $item.attr( 'title', String( item.tooltip ) ); } - if ( item.links && item.links.length ) { - const $links = $( '<span class="wporg-sidebar-item-links">' ); - - item.links.forEach( function ( link, index ) { - const href = webUrl( link.url ); - const icon = ICONS.includes( link.icon ) ? link.icon : 'link'; - - if ( ! href ) { - return; - } - - $links.append( - index ? ' ' : '', - $( '<a>' ) - .attr( { - href, - title: String( link.text ), - 'aria-label': String( link.text ), - } ) - .append( - $( '<i class="glyphicon">' ).addClass( - 'glyphicon-' + icon - ) - ) - ); - } ); - $item.append( $links ); + if ( ITEM_TONES.includes( item.tone ) ) { + $item.addClass( 'is-' + item.tone ); } if ( item.title ) { @@ -167,6 +260,8 @@ ); } + $item.append( renderStatus( item.status ) ); + if ( item.note ) { $item.append( $( '<p class="wporg-sidebar-note">' ).text( @@ -175,17 +270,17 @@ ); } - if ( item.meta && item.meta.length ) { - const $meta = $( '<div class="wporg-sidebar-item-meta">' ); + const $meta = renderMeta( item.meta ); + const $actions = renderActions( item ); - item.meta.forEach( function ( part, index ) { - const $part = textLink( part.text, part.url ); - - if ( part.tooltip ) { - $part.attr( 'title', String( part.tooltip ) ); - } - $meta.append( index ? ' · ' : '', $part ); - } ); + if ( $actions ) { + $item.append( + $( '<div class="wporg-sidebar-item-footer">' ).append( + $meta || $( '<div class="wporg-sidebar-item-meta">' ), + $actions + ) + ); + } else { $item.append( $meta ); } diff --git a/freescout.wordpress.net/Modules/WPOrgSidebar/module.json b/freescout.wordpress.net/Modules/WPOrgSidebar/module.json index aa531aaa18..c93c7d629d 100644 --- a/freescout.wordpress.net/Modules/WPOrgSidebar/module.json +++ b/freescout.wordpress.net/Modules/WPOrgSidebar/module.json @@ -3,7 +3,7 @@ "alias": "wporgsidebar", "img": "/modules/wporgsidebar/img/icon.svg", "description": "Shows the sender's WordPress.org profile, forum notes, plugins and themes, and privacy requests in the conversation sidebar.", - "version": "1.0.1", + "version": "1.0.2", "detailsUrl": "https://github.com/WordPress/wordpress.org/tree/trunk/freescout.wordpress.net/Modules/WPOrgSidebar", "author": "WordPress.org", "authorUrl": "https://wordpress.org/", From ad1f008d08766252a7e98606f20841bda5c269f8 Mon Sep 17 00:00:00 2001 From: frantorres <git@frantorres.es> Date: Fri, 16 Oct 2026 19:10:21 +0200 Subject: [PATCH 11/13] Freescout: Plugins & Themes sidebar, add or remove a plugin's download URL from the ones to copy. A button after each plugin's download link adds its URL to the header's Copy X URLs, or removes it once it's there. URLs added this way stay until removed. WPOrgSidebar triggers wporgsidebar:panel once a panel loads, for the buttons to be added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../WPOrgPluginReview/Public/css/review.css | 19 ++- .../WPOrgPluginReview/Public/js/downloads.js | 157 +++++++++++++++++- .../Modules/WPOrgPluginReview/README.md | 2 + .../Resources/views/downloads.blade.php | 2 +- .../Modules/WPOrgPluginReview/module.json | 2 +- .../Modules/WPOrgSidebar/Public/js/sidebar.js | 3 + .../Modules/WPOrgSidebar/module.json | 2 +- 7 files changed, 182 insertions(+), 5 deletions(-) diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css index 6eb3e12091..5090fbb2d3 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, and the My Reviews and My Cases folders. + * 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. */ @@ -489,6 +490,22 @@ } } +/* 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: inherit; + line-height: 1; +} + +.wporg-downloads-toggle[aria-pressed="true"], +.wporg-downloads-toggle:hover, +.wporg-downloads-toggle:focus { + color: #0078d7; +} + /* 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 index 86e9e85b1d..1697d38ed2 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/downloads.js +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/downloads.js @@ -8,6 +8,9 @@ * 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 ( $ ) { @@ -18,6 +21,13 @@ */ 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. @@ -199,7 +209,7 @@ const local = storage( 'localStorage' ); return found.filter( ( item ) => { - if ( held.has( item.key ) ) { + if ( item.added || held.has( item.key ) ) { return true; } if ( local ) { @@ -239,9 +249,116 @@ $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. */ @@ -332,6 +449,44 @@ .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 $( + '<button type="button" class="wporg-downloads-toggle">' + ) + .attr( 'data-url', this.href ) + .append( '<i class="glyphicon glyphicon-plus"></i>' ); + } ); + + 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(); diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md index 71241d0aee..67bf87d5c9 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md @@ -67,6 +67,8 @@ The waits are `OpenOrder::WAIT_DAYS` and `OpenOrder::TAG_WAIT_DAYS`, in `Service 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 `"<URL1>|<URL2>|…"`: 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 60 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. diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/downloads.blade.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/downloads.blade.php index 39a93aca0a..fc34f56831 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/downloads.blade.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/downloads.blade.php @@ -1,5 +1,5 @@ {{-- 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. --}} -<li class="wporg-downloads hidden" data-strings="{{ json_encode( array( 'one' => __( 'Copy 1 URL' ), 'many' => __( 'Copy :count URLs' ), 'copied' => __( 'Copied' ) ) ) }}"> +<li class="wporg-downloads hidden" data-strings="{{ json_encode( array( 'one' => __( 'Copy 1 URL' ), 'many' => __( 'Copy :count URLs' ), 'copied' => __( 'Copied' ), 'add' => __( 'Add to the URLs to copy' ), 'remove' => __( 'Remove from the URLs to copy' ) ) ) }}"> <a href="#" class="wporg-downloads-copy" role="button" title="{{ __( 'Copy the download URLs of the plugins in review you have open, in the order you opened them' ) }}"><i class="glyphicon glyphicon-download-alt"></i> <span class="wporg-downloads-label"></span></a> </li> <li class="wporg-downloads hidden"> diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json b/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json index bc28da6a52..22911f8fab 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json @@ -3,7 +3,7 @@ "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. 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.0", + "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/WPOrgSidebar/Public/js/sidebar.js b/freescout.wordpress.net/Modules/WPOrgSidebar/Public/js/sidebar.js index d17b05438a..9df6b4ca31 100644 --- a/freescout.wordpress.net/Modules/WPOrgSidebar/Public/js/sidebar.js +++ b/freescout.wordpress.net/Modules/WPOrgSidebar/Public/js/sidebar.js @@ -505,6 +505,9 @@ $panel.empty().append( blocks ); shortenLists( $panel ); $block.show(); + + // For other modules to add to, like WPOrgPluginReview's buttons for its download URLs. + $panel.trigger( 'wporgsidebar:panel' ); } else { // Not removed: the other account may have something to show. $block.hide(); diff --git a/freescout.wordpress.net/Modules/WPOrgSidebar/module.json b/freescout.wordpress.net/Modules/WPOrgSidebar/module.json index c93c7d629d..31a1136609 100644 --- a/freescout.wordpress.net/Modules/WPOrgSidebar/module.json +++ b/freescout.wordpress.net/Modules/WPOrgSidebar/module.json @@ -3,7 +3,7 @@ "alias": "wporgsidebar", "img": "/modules/wporgsidebar/img/icon.svg", "description": "Shows the sender's WordPress.org profile, forum notes, plugins and themes, and privacy requests in the conversation sidebar.", - "version": "1.0.2", + "version": "1.0.3", "detailsUrl": "https://github.com/WordPress/wordpress.org/tree/trunk/freescout.wordpress.net/Modules/WPOrgSidebar", "author": "WordPress.org", "authorUrl": "https://wordpress.org/", From 990ee627b37530b7dee2852f2668d51ad748406c Mon Sep 17 00:00:00 2001 From: Konstantin Obenland <obenland@gmx.de> Date: Fri, 16 Oct 2026 16:16:13 -0500 Subject: [PATCH 12/13] FreeScout: Plugin Review, fixes from code review. - Match the Plugin Admins team's name, and log when a team isn't found. - Keep other modules' tags and snooze icons out of the hidden subject. - Mark only the previous conversations core shows in the sidebar. - Only flag the ownership check when every check ran and none passed. - Copy each marked subject at its own place in the selection. - Keep a page's download URL when it can't get a lock, and let tabs settle before refreshing. - Link the WordPress.org page only for plugins and themes that have one. - Round short ages up to the next unit, like 1y instead of 12mo. - Cache the worklist counts of agents who only see assigned conversations. - Read the worklist members once per page. - Name close reasons as the plugin directory does, and share the status labels between the panel and the sidebar. - Update the README's rate limit, and tidy comments and types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../public_html/dotorg/freescout/common.php | 70 +++++++------------ .../dotorg/freescout/plugin-review.php | 2 +- .../dotorg/freescout/plugins-themes.php | 9 ++- .../tests/class-item-status-test.php | 11 +-- .../tests/class-short-time-diff-test.php | 2 + environments/freescout/mock-api/index.php | 2 +- .../mock-api/panels/plugins-themes.json | 8 +-- .../Http/Controllers/PluginController.php | 7 +- .../Http/Controllers/WorklistController.php | 5 +- .../WPOrgPluginReviewServiceProvider.php | 20 +++--- .../WPOrgPluginReview/Public/js/downloads.js | 37 +++++++--- .../WPOrgPluginReview/Public/js/review.js | 49 ++++++++++--- .../Modules/WPOrgPluginReview/README.md | 6 +- .../WPOrgPluginReview/Services/Reviewers.php | 9 ++- .../WPOrgPluginReview/Services/Worklist.php | 56 ++++++++++----- .../WPOrgPluginReview/tests/PanelTest.php | 19 ++++- .../WPOrgPluginReview/tests/WorklistTest.php | 6 +- 17 files changed, 192 insertions(+), 126 deletions(-) diff --git a/api.wordpress.org/public_html/dotorg/freescout/common.php b/api.wordpress.org/public_html/dotorg/freescout/common.php index 6923e5bdd6..e48b70b26f 100644 --- a/api.wordpress.org/public_html/dotorg/freescout/common.php +++ b/api.wordpress.org/public_html/dotorg/freescout/common.php @@ -230,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. * @@ -308,7 +280,7 @@ 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 to the largest unit that fits, like human_time_diff(). + * 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. @@ -325,27 +297,35 @@ function short_time_diff( int $from, int $to ): string { 'm' => MINUTE_IN_SECONDS, ); + $larger = null; foreach ( $units as $unit => $seconds ) { if ( $diff >= $seconds ) { - return (int) round( $diff / $seconds ) . $unit; + $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 and labels of the reasons plugins are closed or disabled for, by reason. + * Icons of the reasons plugins are closed or disabled for, by reason; the plugin directory names them. * - * @var array[] + * @var string[] */ -const CLOSE_REASONS = array( - 'security-issue' => array( '🛡️', 'Security issue' ), - 'author-request' => array( '👤', 'Author request' ), - 'guideline-violation' => array( '📜', 'Guideline violation' ), - 'licensing-trademark-violation' => array( '™️', 'Trademark or licensing' ), - 'merged-into-core' => array( '🧩', 'Merged into core' ), - 'unused' => array( '💤', 'Unused' ), +const CLOSE_REASON_ICONS = array( + 'security-issue' => '🛡️', + 'author-request' => '👤', + 'guideline-violation' => '📜', + 'licensing-trademark-violation' => '™️', + 'merged-into-core' => '🧩', + 'unused' => '💤', ); /** @@ -354,7 +334,7 @@ function short_time_diff( int $from, int $to ): string { * 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. + * @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. @@ -374,11 +354,11 @@ function get_item_status( string $post_status, string $reviewer = '', string $cl case 'closed': case 'disabled': $text = ucfirst( $post_status ); - if ( isset( CLOSE_REASONS[ $close_reason ] ) ) { - $status = array( CLOSE_REASONS[ $close_reason ][0], $text . ': ' . CLOSE_REASONS[ $close_reason ][1], 'error' ); - } else { - $status = array( '🔒', $close_reason ? $text . ': ' . ucfirst( str_replace( '-', ' ', $close_reason ) ) : $text, 'error' ); + if ( $close_reason ) { + $reasons = class_exists( '\WordPressdotorg\Plugin_Directory\Template' ) ? \WordPressdotorg\Plugin_Directory\Template::get_close_reasons() : array(); + $text .= ': ' . ( $reasons[ $close_reason ] ?? ucfirst( str_replace( '-', ' ', $close_reason ) ) ); } + $status = array( CLOSE_REASON_ICONS[ $close_reason ] ?? '🔒', $text, 'error' ); break; case 'rejected': $status = array( '🚫', 'Rejected', 'muted' ); @@ -403,7 +383,7 @@ function get_item_status( string $post_status, string $reviewer = '', string $cl list( $icon, $text, $tone ) = $status; - if ( $reviewer && in_array( $post_status, array( 'new', 'pending' ), true ) ) { + if ( $reviewer ) { $text .= ' · Assigned to ' . $reviewer; } 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 d2a46fe56c..9fc85a17b5 100644 --- a/api.wordpress.org/public_html/dotorg/freescout/plugin-review.php +++ b/api.wordpress.org/public_html/dotorg/freescout/plugin-review.php @@ -42,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( $post->post_status, '', (string) $post->_close_reason )['status']['text'] ?? 'Published', // 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. 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 cb7b452632..d4c4576c2c 100644 --- a/api.wordpress.org/public_html/dotorg/freescout/plugins-themes.php +++ b/api.wordpress.org/public_html/dotorg/freescout/plugins-themes.php @@ -14,7 +14,8 @@ require __DIR__ . '/common.php'; /** - * Orders plugins and themes by status, those in review first and rejected ones last, then by name. + * 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 */ @@ -230,8 +231,7 @@ function render_items( array $post_ids, string $mailbox_email ): array { $last_updated = (int) strtotime( $last_modified ); - // Edit and permalinks are built by hand, as the post types aren't registered on this site. - // Rarest first, so the download link, which most have, lines up at the end. + // 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 ( 'plugin' === $type ? is_plugin_released( $post ) : 'publish' === $post->post_status ) { $links[] = array( @@ -240,8 +240,7 @@ function render_items( array $post_ids, string $mailbox_email ): array { 'icon' => 'shield', ); } - // Plugins and themes have no public page until they're approved. - if ( ! in_array( $post->post_status, array( 'new', 'pending', 'rejected', 'draft' ), true ) ) { + if ( 'plugin' === $type ? is_plugin_released( $post ) : in_array( $post->post_status, array( 'publish', 'delist' ), true ) ) { $links[] = array( 'text' => 'View on WordPress.org', 'url' => home_url( "/{$post->post_name}/" ), 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 index 3bab89d86a..34cea5b5c0 100644 --- 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 @@ -87,7 +87,7 @@ public function test_close_reason(): void { $this->assertSame( array( 'icon' => '™️', - 'text' => 'Disabled: Trademark or licensing', + 'text' => 'Disabled: Licensing trademark violation', ), get_item_status( 'disabled', '', 'licensing-trademark-violation' )['status'] ); @@ -118,15 +118,6 @@ public function test_reviewer(): void { $this->assertSame( 'Pending initial review · Assigned to Priya Shah', get_item_status( 'new', 'Priya Shah' )['status']['text'] ); } - /** - * Only reviews have a reviewer: the assignment stays after they're done. - * - * @return void - */ - public function test_no_reviewer_after_review(): void { - $this->assertSame( 'Approved', get_item_status( 'approved', 'Priya Shah' )['status']['text'] ); - } - /** * Why a plugin was rejected is in the 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 index 869151f8ac..3431052a46 100644 --- 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 @@ -47,6 +47,8 @@ public static function data_short_time_diff(): array { '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 fc21fb58a8..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, diff --git a/environments/freescout/mock-api/panels/plugins-themes.json b/environments/freescout/mock-api/panels/plugins-themes.json index b34a2a0f6a..bec3cd2c6a 100644 --- a/environments/freescout/mock-api/panels/plugins-themes.json +++ b/environments/freescout/mock-api/panels/plugins-themes.json @@ -150,7 +150,7 @@ "tone": "error", "status": { "icon": "🛡️", - "text": "Closed: Security issue" + "text": "Closed: Security Issue" } }, { @@ -185,7 +185,7 @@ "tone": "error", "status": { "icon": "📜", - "text": "Closed: Guideline violation" + "text": "Closed: Guideline Violation" } }, { @@ -220,7 +220,7 @@ "tone": "error", "status": { "icon": "™️", - "text": "Disabled: Trademark or licensing" + "text": "Disabled: Licensing/Trademark Violation" } }, { @@ -741,7 +741,7 @@ "tone": "error", "status": { "icon": "👤", - "text": "Closed: Author request" + "text": "Closed: Author Request" } }, { diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/PluginController.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/PluginController.php index 444d2a9241..3d893b6322 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/PluginController.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/PluginController.php @@ -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 index daff5da6ba..d1422411e5 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php @@ -9,6 +9,7 @@ namespace Modules\WPOrgPluginReview\Http\Controllers; +use App\Conversation; use App\Mailbox; use Illuminate\Http\Request; use Illuminate\Routing\Controller; @@ -54,7 +55,7 @@ public function show( Request $request, int $mailbox_id, string $worklist ): Vie $reviewers = Worklist::reviewers( $mailbox ); $user = $reviewers->firstWhere( 'id', (int) $request->query( 'user', $viewer->id ) ) ?? $viewer; - $ids = $list->section_ids( $mailbox, $user, $viewer ); + $ids = $list->section_ids( $mailbox, $user, $viewer, $reviewers ); $tags = array(); $tag = 0; @@ -81,7 +82,7 @@ public function show( Request $request, int $mailbox_id, string $worklist ): Vie $folder->active_count = count( array_filter( $sections['participating'], - static function ( $conversation ): bool { + static function ( Conversation $conversation ): bool { return $conversation->isActive(); } ) diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php index bc1db77372..1c95b15062 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php @@ -10,6 +10,7 @@ namespace Modules\WPOrgPluginReview\Providers; use App\Conversation; +use App\Http\Controllers\ConversationsController; use App\Mailbox; use App\Thread; use App\User; @@ -53,13 +54,6 @@ final class WPOrgPluginReviewServiceProvider extends ServiceProvider { */ private const FOLDERS_PRIORITY = 30; - /** - * How many of the sender's other conversations get their subjects marked in the sidebar: core shows the latest five. - * - * @var int - */ - private const PREVIOUS_LIMIT = 40; - /** * Boots the module. * @@ -246,7 +240,7 @@ static function ( mixed $conversation = null ) use ( $marks ): void { } ); - // The subject goes in between, hidden: it's what the row is read as, and what FreeScout's scripts find there. + // The subject goes in between, hidden, for the row and FreeScout's scripts; last, so other modules' tags stay out of it. \Eventy::addAction( 'conversations_table.before_subject', static function ( mixed $conversation = null ) use ( $marks ): void { @@ -259,7 +253,8 @@ static function ( mixed $conversation = null ) use ( $marks ): void { } catch ( \Throwable $e ) { \Log::error( '[WPOrgPluginReview] Could not mark the subject of conversation ' . ( $conversation->id ?? '' ) . ': ' . $e->getMessage() ); } - } + }, + PHP_INT_MAX ); \Eventy::addAction( @@ -272,7 +267,8 @@ static function ( mixed $conversation = null ) use ( $marks ): void { } catch ( \Throwable $e ) { \Log::error( '[WPOrgPluginReview] Could not mark the subject of conversation ' . ( $conversation->id ?? '' ) . ': ' . $e->getMessage() ); } - } + }, + PHP_INT_MIN ); } @@ -288,8 +284,10 @@ private static function marked_subjects( Conversation $conversation ): array { $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( self::PREVIOUS_LIMIT ) + ->limit( ConversationsController::PREV_CONVERSATIONS_LIMIT ) ->get(); $conversations = array_merge( $conversations, $previous->all() ); } diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/downloads.js b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/downloads.js index 1697d38ed2..6ecfca4e59 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/downloads.js +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/downloads.js @@ -60,6 +60,14 @@ */ 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. * @@ -98,6 +106,13 @@ */ 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. * @@ -209,7 +224,7 @@ const local = storage( 'localStorage' ); return found.filter( ( item ) => { - if ( item.added || held.has( item.key ) ) { + if ( item.added || item.unlocked || held.has( item.key ) ) { return true; } if ( local ) { @@ -360,14 +375,19 @@ } /** - * Keeps this page's URL, while it holds its lock. + * 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 = () => { + const store = ( held ) => { if ( entry && local ) { try { - local.setItem( PAGE_KEY, JSON.stringify( entry ) ); + local.setItem( + PAGE_KEY, + JSON.stringify( + held ? entry : { ...entry, unlocked: true } + ) + ); } catch { // Then it isn't in the list. } @@ -377,7 +397,7 @@ const manager = locks(); if ( ! manager || locked ) { - store(); + store( locked ); return; } @@ -385,10 +405,10 @@ manager .request( PAGE_KEY, () => { locked = true; - store(); + store( true ); return new Promise( () => {} ); } ) - .catch( store ); + .catch( () => store( false ) ); } /** @@ -426,7 +446,8 @@ // Other tabs' changes. window.addEventListener( 'storage', ( event ) => { if ( null === event.key || String( event.key ).startsWith( PREFIX ) ) { - refresh(); + clearTimeout( settling ); + settling = setTimeout( refresh, SETTLE_FOR ); } } ); diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js index 0f58974c4b..b56434ac08 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js @@ -383,7 +383,7 @@ /** * 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 neither proves they own it. + * marks the check when all were checked and none proves they own it. * * @param {jQuery} $owner The owner check. * @param {Object} owner The check. @@ -391,10 +391,10 @@ * @param {Object} strings Labels. */ function showOwner( $owner, owner, plugin, strings ) { - // Either is how the review asks them to prove it. + // Any of them proves it; unknown ones, null, weren't checked. if ( - ! [ 'author', 'plugin', 'email' ].some( - ( key ) => true === owner[ key ] + [ 'author', 'plugin', 'email' ].every( + ( key ) => false === owner[ key ] ) ) { markCheck( $owner, 'ignored', strings.owner_ignored ); @@ -929,17 +929,46 @@ 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; - $( '[data-wporg-subject]' ).each( function () { - if ( selection.containsNode( this, true ) ) { - text = text.replace( - $( this ).text(), - String( $( this ).attr( 'data-wporg-subject' ) ) - ); + 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(); diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md index 67bf87d5c9..6b0bacbf06 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/README.md @@ -38,7 +38,7 @@ While an agent writes a reply, a warning shows above it when the reply has a Rev ## 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 Admin team's: questions, guideline reports, replies to rejections, sockpuppets, adoptions, and the like. Each has three sections: +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. @@ -50,7 +50,7 @@ Conversations someone is participating in can be selected, and changed together 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 every page. 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. +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`. @@ -71,7 +71,7 @@ In the sidebar's **Plugins & Themes** panel, each plugin's download link has a b 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 60 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. +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 diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Reviewers.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Reviewers.php index d5b384c756..0c60cf82f6 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Reviewers.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Reviewers.php @@ -39,7 +39,7 @@ class Reviewers { * * @var string */ - public const ADMIN_TEAM = 'Plugin Admin'; + public const ADMIN_TEAM = 'Plugin Admins'; /** * The Teams module's alias. @@ -97,8 +97,13 @@ public function team_id( string $name ): ?int { try { $id = array_search( $name, $this->teams(), true ); + if ( false === $id ) { + \Log::error( '[WPOrgPluginReview] There is no ' . $name . ' team.' ); - return false === $id ? 0 : (int) $id; + return 0; + } + + return (int) $id; } catch ( \Throwable $e ) { \Log::error( '[WPOrgPluginReview] Could not find the ' . $name . ' team: ' . $e->getMessage() ); diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php index 07554f4393..8b135b3866 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php @@ -23,7 +23,7 @@ * member replied to, that they're mentioned in, and that need someone, and how many active ones each member replied * to, for the folder list. * - * My Reviews is the Plugin Reviews team's, and My Cases the Plugin Admin team's, which can also be filtered by tag. + * My Reviews is the Plugin Reviews team's, and My Cases the Plugin Admins team's, which can also be filtered by tag. * Each conversation is only in the first section it belongs to. One query tells, for every conversation, which * sections it belongs to, through the threads' index on conversation and type. */ @@ -105,7 +105,7 @@ public static function reviews(): self { } /** - * My Cases: the Plugin Admin team's. + * My Cases: the Plugin Admins team's. * * @return self */ @@ -329,7 +329,7 @@ public static function add_folders( Collection $folders, Mailbox $mailbox, User * 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 on its own. + * assigned to them get theirs counted, and kept, on its own. * * @param Mailbox $mailbox Mailbox. * @param User $user The agent. @@ -337,10 +337,16 @@ public static function add_folders( Collection $folders, Mailbox $mailbox, User */ public function active_count( Mailbox $mailbox, User $user ): int { if ( $user->canSeeOnlyAssignedConversations() ) { - 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(); + 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( @@ -377,22 +383,33 @@ static function ( mixed $total ): int { */ public static function forget_counts( int $mailbox_id ): void { try { - foreach ( self::all() as $worklist ) { - \Cache::forget( $worklist->counts_key( $mailbox_id ) ); - } + \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. + * 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 function counts_key( int $mailbox_id ): string { - return 'wporgpluginreview.worklist.' . $this->key . '.active_counts.' . $mailbox_id; + private static function version_key( int $mailbox_id ): string { + return 'wporgpluginreview.worklist.counts_version.' . $mailbox_id; } /** @@ -423,7 +440,7 @@ static function ( mixed $user ) use ( $reviewers ): bool { * @return array<string, Conversation[]> 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 ) ); + return self::load( $this->section_ids( $mailbox, $user, $viewer, self::reviewers( $mailbox ) ) ); } /** @@ -432,13 +449,14 @@ public function sections( Mailbox $mailbox, User $user, User $viewer ): array { * 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 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<string, int[]> Conversation IDs by section, the longest waiting first. */ - public function section_ids( Mailbox $mailbox, User $user, User $viewer ): array { - $member_ids = self::reviewers( $mailbox )->pluck( 'id' )->all(); + 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' ) diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php index cac026aea8..7573951eda 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php @@ -190,7 +190,8 @@ public function test_plugin_endpoint_returns_the_plugin_and_ownership(): void { } /** - * Without an answer from WordPress.org, the ownership check still runs, with the Review ID's username. + * Without an answer from WordPress.org, the ownership check still runs, with the Review ID's username; without that + * either, nothing can be checked. * * @return void */ @@ -212,6 +213,22 @@ public function test_plugin_endpoint_works_without_wordpress_org(): void { ), ) ); + + $this->review( '<ul><li>Plugin URI: <a href="https://plugin.example">p</a></li></ul><p>Review ID: R ❗OWN my-plugin 9Aug26/4.3 (P0TDX42HGN)</p>', '2026-08-09 10:00:00' ); + + $this->actingAs( $this->reviewer ) + ->get( '/wporgpluginreview/' . $this->conversation->id . '/plugin' ) + ->assertStatus( 200 ) + ->assertExactJson( + array( + 'plugin' => null, + 'owner' => array( + 'author' => null, + 'plugin' => null, + 'email' => null, + ), + ) + ); } /** diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/WorklistTest.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/WorklistTest.php index 04a760e5c5..7bf4bd73bb 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/WorklistTest.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/WorklistTest.php @@ -246,6 +246,10 @@ public function test_sections_respect_only_assigned_conversations(): void { $this->assertSections( array( 'participating' => array( $assigned ) ), Worklist::reviews()->sections( $this->mailbox, $this->reviewer, $viewer ) ); $this->assertSame( 1, Worklist::reviews()->active_count( $this->mailbox, $viewer ) ); + + // Their count is kept, and forgotten with everyone's. + $this->conversation( $reply, Conversation::STATUS_ACTIVE, '2026-08-01 10:00:00', $viewer ); + $this->assertSame( 2, Worklist::reviews()->active_count( $this->mailbox, $viewer ) ); } /** @@ -433,7 +437,7 @@ public function test_page_is_only_for_the_plugins_mailbox(): void { } /** - * My Cases lists the Plugin Admin team's conversations, in the same sections as My Reviews the Plugin Reviews + * My Cases lists the Plugin Admins team's conversations, in the same sections as My Reviews the Plugin Reviews * team's, and counts them on its own. * * @return void From e7b339ecbe6a98abb0f1776bfc21819a4ca55c7a Mon Sep 17 00:00:00 2001 From: Konstantin Obenland <obenland@gmx.de> Date: Fri, 16 Oct 2026 16:24:10 -0500 Subject: [PATCH 13/13] FreeScout: Plugin Review, simplify. - Close the hidden subject span only when it was opened, parsing the subject once per row. - Keep the statuses of plugins and themes in one table, and label any status in the panel. - Look up close and rejection reasons through one helper. - Check whether a plugin is released once per item. - Reuse is_folder(), inline markCheck(), and merge duplicate CSS rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../public_html/dotorg/freescout/common.php | 107 +++++++++++------- .../dotorg/freescout/plugin-review.php | 2 +- .../dotorg/freescout/plugins-themes.php | 23 +--- .../Http/Controllers/WorklistController.php | 15 +-- .../WPOrgPluginReviewServiceProvider.php | 15 ++- .../WPOrgPluginReview/Public/css/review.css | 20 ++-- .../WPOrgPluginReview/Public/js/review.js | 19 +--- .../WPOrgPluginReview/Services/OpenOrder.php | 2 +- .../WPOrgPluginReview/Services/Worklist.php | 8 +- 9 files changed, 102 insertions(+), 109 deletions(-) diff --git a/api.wordpress.org/public_html/dotorg/freescout/common.php b/api.wordpress.org/public_html/dotorg/freescout/common.php index e48b70b26f..b4d5fd651a 100644 --- a/api.wordpress.org/public_html/dotorg/freescout/common.php +++ b/api.wordpress.org/public_html/dotorg/freescout/common.php @@ -328,6 +328,62 @@ function short_time_diff( int $from, int $to ): string { '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. * @@ -340,49 +396,16 @@ function short_time_diff( int $from, int $to ): string { * @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 { - switch ( $post_status ) { - // Plugins. - case 'new': - $status = array( '⏳', 'Pending initial review', 'warning' ); - break; - case 'pending': - $status = array( '🔍', 'In review', 'warning' ); - break; - case 'approved': - $status = array( '✅', 'Approved', 'warning' ); - break; - case 'closed': - case 'disabled': - $text = ucfirst( $post_status ); - if ( $close_reason ) { - $reasons = class_exists( '\WordPressdotorg\Plugin_Directory\Template' ) ? \WordPressdotorg\Plugin_Directory\Template::get_close_reasons() : array(); - $text .= ': ' . ( $reasons[ $close_reason ] ?? ucfirst( str_replace( '-', ' ', $close_reason ) ) ); - } - $status = array( CLOSE_REASON_ICONS[ $close_reason ] ?? '🔒', $text, 'error' ); - break; - case 'rejected': - $status = array( '🚫', 'Rejected', 'muted' ); - break; - - // Themes. - case 'draft': - $status = array( '⏳', 'In review or rejected', 'warning' ); - break; - case 'suspend': - case 'suspended': - $status = array( '⛔', 'Suspended', 'error' ); - break; - case 'delist': - case 'delisted': - $status = array( '', 'Delisted', 'muted' ); - break; - - default: - return array(); - } - - list( $icon, $text, $tone ) = $status; + 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; } 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 9fc85a17b5..792d725972 100644 --- a/api.wordpress.org/public_html/dotorg/freescout/plugin-review.php +++ b/api.wordpress.org/public_html/dotorg/freescout/plugin-review.php @@ -42,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_item_status( $post->post_status, '', (string) $post->_close_reason )['status']['text'] ?? 'Published', + '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. 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 d4c4576c2c..449533a0c0 100644 --- a/api.wordpress.org/public_html/dotorg/freescout/plugins-themes.php +++ b/api.wordpress.org/public_html/dotorg/freescout/plugins-themes.php @@ -176,22 +176,6 @@ function get_items_by_slug( array $slugs ): array { return array_map( 'intval', $ids ); } -/** - * Gets why a plugin was rejected, in the words reviewers chose it from. - * - * @param string $reason Rejection reason, e.g. duplicate-copy. - * @return string - */ -function get_rejection_reason( string $reason ): string { - if ( ! $reason ) { - return ''; - } - - $reasons = class_exists( '\WordPressdotorg\Plugin_Directory\Template' ) ? \WordPressdotorg\Plugin_Directory\Template::get_rejection_reasons() : array(); - - return $reasons[ $reason ] ?? ucfirst( str_replace( '-', ' ', $reason ) ); -} - /** * Gets a list of plugins or themes, with their status. * @@ -223,24 +207,25 @@ function render_items( array $post_ids, string $mailbox_email ): array { // Why a plugin was closed or rejected is the plugins team's, like reviews. if ( $show_review ) { $close_reason = (string) $post->_close_reason; - $rejection = get_rejection_reason( (string) $post->_rejection_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. } $last_updated = (int) strtotime( $last_modified ); + $released = 'plugin' === $type && is_plugin_released( $post ); // 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 ( 'plugin' === $type ? is_plugin_released( $post ) : 'publish' === $post->post_status ) { + 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', ); } - if ( 'plugin' === $type ? is_plugin_released( $post ) : in_array( $post->post_status, array( 'publish', 'delist' ), true ) ) { + 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}/" ), diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php index d1422411e5..c3fe0066ce 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php @@ -68,17 +68,18 @@ public function show( Request $request, int $mailbox_id, string $worklist ): Vie } } - $sections = Worklist::load( $ids ); - $limit = OpenOrder::limit( $viewer ); - $open = array( + $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 ); + $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 ( (int) $user->id === (int) $viewer->id && ! $tag ) { + if ( $is_viewer && ! $tag ) { $folder->active_count = count( array_filter( $sections['participating'], @@ -97,7 +98,7 @@ static function ( Conversation $conversation ): bool { 'folders' => $folders, 'folder' => $folder, 'user' => $user, - 'is_viewer' => (int) $user->id === (int) $viewer->id, + 'is_viewer' => $is_viewer, 'reviewers' => $reviewers->contains( 'id', $viewer->id ) ? $reviewers : $reviewers->prepend( $viewer ), 'sections' => $sections, 'tags' => $tags, diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php index 1c95b15062..0b3a40b2af 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php @@ -241,14 +241,16 @@ static function ( mixed $conversation = null ) use ( $marks ): void { ); // 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 ): void { + 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 '<span class="wporg-subject" data-wporg-subject="' . $subject . '" title="' . $subject . '">' . e( $marked ) . '</span><span hidden>'; + $hidden = true; } } catch ( \Throwable $e ) { \Log::error( '[WPOrgPluginReview] Could not mark the subject of conversation ' . ( $conversation->id ?? '' ) . ': ' . $e->getMessage() ); @@ -259,13 +261,10 @@ static function ( mixed $conversation = null ) use ( $marks ): void { \Eventy::addAction( 'conversations_table.after_subject', - static function ( mixed $conversation = null ) use ( $marks ): void { - try { - if ( $marks( $conversation ) && '' !== Subject::marked( (string) $conversation->subject ) ) { - echo '</span>'; - } - } catch ( \Throwable $e ) { - \Log::error( '[WPOrgPluginReview] Could not mark the subject of conversation ' . ( $conversation->id ?? '' ) . ': ' . $e->getMessage() ); + static function () use ( &$hidden ): void { + if ( $hidden ) { + echo '</span>'; + $hidden = false; } }, PHP_INT_MIN diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css index 5090fbb2d3..94d6a70d38 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css @@ -102,10 +102,7 @@ background-color: #0c4f4d; } -.wporg-review-flag[data-flag="FUN"] { - background-color: #1d4ed8; -} - +.wporg-review-flag[data-flag="FUN"], .wporg-review-flag[data-flag="ACT"] { background-color: #1d4ed8; } @@ -171,7 +168,6 @@ color: #4a5561; } -/* The current name is the one the review suggested, or still the one it asked to change. */ /* Names and Owner: a section of their own, colored by whether the author followed the review. */ .wporg-review-check { margin-top: 12px; @@ -363,8 +359,8 @@ gap: 6px; } -.wporg-worklist-open-count { - margin-left: 2px; +.wporg-worklist-open-count, +.wporg-worklist-status { padding: 0 6px; border-radius: 9px; background-color: #eef1f4; @@ -373,6 +369,10 @@ font-weight: 600; } +.wporg-worklist-open-count { + margin-left: 2px; +} + .wporg-worklist-open-limit { color: #4a5561; font-size: 12px; @@ -462,12 +462,6 @@ .wporg-worklist-status { margin-right: 4px; - padding: 0 6px; - border-radius: 9px; - background-color: #eef1f4; - color: #3e4a55; - font-size: 11px; - font-weight: 600; } @media (max-width: 767px) { diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js index b56434ac08..2a87f881f1 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js @@ -359,7 +359,9 @@ ) { state = 'ignored'; } - markCheck( $names, state, strings[ 'names_' + state ] ); + $names + .addClass( 'is-' + state ) + .attr( 'title', strings[ 'names_' + state ] ); addRow( $names.find( '.wporg-review-details' ), strings.current, [ String( plugin.name ), @@ -370,17 +372,6 @@ } } - /** - * Colors a check in the panel by whether the author followed the review. - * - * @param {jQuery} $check Check. - * @param {string} state followed, ignored, or to-review. - * @param {string} label What it means. - */ - function markCheck( $check, state, label ) { - $check.addClass( 'is-' + state ).attr( 'title', label ); - } - /** * 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. @@ -397,7 +388,9 @@ ( key ) => false === owner[ key ] ) ) { - markCheck( $owner, 'ignored', strings.owner_ignored ); + $owner + .addClass( 'is-ignored' ) + .attr( 'title', strings.owner_ignored ); } [ 'author', 'plugin' ].forEach( ( key ) => { diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/OpenOrder.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/OpenOrder.php index aec46c98b3..57ffb85f85 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/OpenOrder.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/OpenOrder.php @@ -183,7 +183,7 @@ public static function limit( User $user ): int { * @return void */ public static function set_limit( User $user, mixed $limit ): void { - if ( null === $limit || '' === $limit || ! is_numeric( $limit ) || self::DEFAULT_LIMIT === (int) $limit ) { + if ( ! is_numeric( $limit ) || self::DEFAULT_LIMIT === (int) $limit ) { \Option::remove( self::LIMIT_OPTION . $user->id ); return; diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php index 8b135b3866..4c0d3a0c29 100644 --- a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php @@ -152,11 +152,9 @@ static function ( self $worklist ): string { * @return self|null Null if it isn't one of theirs. */ public static function of_folder( mixed $folder ): ?self { - if ( $folder instanceof Folder ) { - foreach ( self::all() as $worklist ) { - if ( $worklist->folder_type === (int) $folder->type ) { - return $worklist; - } + foreach ( self::all() as $worklist ) { + if ( $worklist->is_folder( $folder ) ) { + return $worklist; } }