Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
6.52% covered (danger)
6.52%
29 / 445
0.00% covered (danger)
0.00%
0 / 37
CRAP
0.00% covered (danger)
0.00%
0 / 1
SuggestedEdits
6.52% covered (danger)
6.52%
29 / 445
0.00% covered (danger)
0.00%
0 / 37
8940.22
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 getHeaderTextElement
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 getTasksPaginationText
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 isEnabledForAnyone
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isEnabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCssClasses
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 isTopicMatchingEnabled
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 isTopicMatchModeEnabled
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getTopicFiltersPref
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isGuidanceEnabledForAnyone
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isGuidanceEnabled
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getHtml
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 trackQueueStatus
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 getJsData
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
90
 isActivated
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getState
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getTaskSet
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 resetTaskCache
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 canRender
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getHeaderText
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getHeaderIconName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getBody
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
6
 getFooter
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 getMobileSummaryBody
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
12
 getFiltersButtonGroupWidget
30.21% covered (danger)
30.21%
29 / 96
0.00% covered (danger)
0.00%
0 / 1
202.83
 getTaskCard
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
12
 getSubheader
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getSubheaderTag
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getModuleStyles
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getModules
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getSiteViews
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 formatSiteViews
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getActionData
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 getJsConfigVars
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getPager
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getRedirectParams
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getNavigationWidgetFactory
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace GrowthExperiments\HomepageModules;
4
5use GrowthExperiments\ExperimentUserManager;
6use GrowthExperiments\HomepageModules\SuggestedEditsComponents\CardWrapper;
7use GrowthExperiments\HomepageModules\SuggestedEditsComponents\NavigationWidgetFactory;
8use GrowthExperiments\HomepageModules\SuggestedEditsComponents\TaskExplanationWidget;
9use GrowthExperiments\NewcomerTasks\CampaignConfig;
10use GrowthExperiments\NewcomerTasks\ConfigurationLoader\ConfigurationLoader;
11use GrowthExperiments\NewcomerTasks\ConfigurationLoader\PageConfigurationLoader;
12use GrowthExperiments\NewcomerTasks\ImageRecommendationFilter;
13use GrowthExperiments\NewcomerTasks\LinkRecommendationFilter;
14use GrowthExperiments\NewcomerTasks\NewcomerTasksUserOptionsLookup;
15use GrowthExperiments\NewcomerTasks\ProtectionFilter;
16use GrowthExperiments\NewcomerTasks\Task\TaskSet;
17use GrowthExperiments\NewcomerTasks\Task\TaskSetFilters;
18use GrowthExperiments\NewcomerTasks\TaskSuggester\SearchStrategy\SearchStrategy;
19use GrowthExperiments\NewcomerTasks\TaskSuggester\TaskSuggester;
20use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationBaseTaskType;
21use GrowthExperiments\NewcomerTasks\TaskType\TaskType;
22use GrowthExperiments\NewcomerTasks\Topic\Topic;
23use GrowthExperiments\Util;
24use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
25use MediaWiki\Config\Config;
26use MediaWiki\Context\IContextSource;
27use MediaWiki\Deferred\DeferredUpdates;
28use MediaWiki\Extension\PageViewInfo\PageViewService;
29use MediaWiki\Html\Html;
30use MediaWiki\Logger\LoggerFactory;
31use MediaWiki\MediaWikiServices;
32use MediaWiki\Message\Message;
33use MediaWiki\Status\Status;
34use MediaWiki\Title\TitleFactory;
35use MediaWiki\User\Options\UserOptionsLookup;
36use MediaWiki\User\UserIdentity;
37use OOUI\ButtonGroupWidget;
38use OOUI\ButtonWidget;
39use OOUI\Exception;
40use OOUI\HtmlSnippet;
41use OOUI\IconWidget;
42use OOUI\Tag;
43use RuntimeException;
44use StatusValue;
45use Wikimedia\Message\MessageParam;
46
47/**
48 * Homepage module that displays a list of recommended tasks.
49 * This is JS-only functionality; most of the logic is in the
50 * ext.growthExperiments.Homepage.SuggestedEdits module.
51 */
52class SuggestedEdits extends BaseModule {
53
54    /**
55     * User preference to track that suggested edits should be shown to the user (instead of an
56     * onboarding dialog). Might be ignored in some situations.
57     */
58    public const ACTIVATED_PREF = 'growthexperiments-homepage-suggestededits-activated';
59    /** User preference to track that suggested edits were enabled this user automatically on signup. Not used. */
60    public const PREACTIVATED_PREF = 'growthexperiments-homepage-suggestededits-preactivated';
61    /** User preference used to remember the user's topic selection, when using morelike topics. */
62    public const TOPICS_PREF = 'growthexperiments-homepage-se-topic-filters';
63    /** User preference used to remember the user's topic selection, when using ORES topics. */
64    public const TOPICS_ORES_PREF = 'growthexperiments-homepage-se-ores-topic-filters';
65    /** User preference used to remember the user's topic mode selection, when using any type of topics. */
66    public const TOPICS_MATCH_MODE_PREF = 'growthexperiments-homepage-se-topic-filters-mode';
67    /** User preference used to remember the user's task type selection. */
68    public const TASKTYPES_PREF = 'growthexperiments-homepage-se-filters';
69    /** User preference for opting into guidance, when $wgGENewcomerTasksGuidanceRequiresOptIn is true. */
70    public const GUIDANCE_ENABLED_PREF = 'growthexperiments-guidance-enabled';
71    /**
72     * Default value for TASKTYPES_PREF.
73     *
74     * Depending on whether link recommendations are available for the wiki, either 'links' or 'link-recommendation'
75     * will be shown, see NewcomerTasksUserOptionsLookup::getTaskTypeFilter().
76     */
77    public const DEFAULT_TASK_TYPES = [ 'copyedit', 'links', 'link-recommendation' ];
78
79    /**
80     * Used to keep track of the state of user interactions with suggested edits per type per skin.
81     * See also HomepageHooks::onLocalUserCreated
82     */
83    public const GUIDANCE_BLUE_DOT_PREF =
84        'growthexperiments-homepage-suggestededits-guidance-blue-dot';
85
86    /**
87     * Used to keep track of the whether the user has opted out of seeing Add a Link onboarding
88     */
89    public const ADD_LINK_ONBOARDING_PREF = 'growthexperiments-addlink-onboarding';
90
91    /**
92     * Used to keep track of the whether the user has opted out of seeing Add an Image onboarding
93     */
94    public const ADD_IMAGE_ONBOARDING_PREF = 'growthexperiments-addimage-onboarding';
95
96    /**
97     * Used to keep track of the whether the user has opted out of seeing onboarding for
98     * the caption step of Add Image
99     */
100    public const ADD_IMAGE_CAPTION_ONBOARDING_PREF = 'growthexperiments-addimage-caption-onboarding';
101
102    /**
103     * Used to keep track of the whether the user has opted out of seeing "Add a section image" onboarding
104     */
105    public const ADD_SECTION_IMAGE_ONBOARDING_PREF = 'growthexperiments-addsectionimage-onboarding';
106
107    /**
108     * Used to keep track of the whether the user has opted out of seeing onboarding for
109     * the caption step of Add Section Image
110     */
111    public const ADD_SECTION_IMAGE_CAPTION_ONBOARDING_PREF = 'growthexperiments-addsectionimage-caption-onboarding';
112
113    private ?PageViewService $pageViewService;
114
115    private ConfigurationLoader $configurationLoader;
116
117    private NewcomerTasksUserOptionsLookup $newcomerTasksUserOptionsLookup;
118
119    private TaskSuggester $taskSuggester;
120
121    private TitleFactory $titleFactory;
122
123    private ProtectionFilter $protectionFilter;
124
125    /** @var string[] cache key => HTML */
126    private array $htmlCache = [];
127
128    /** @var TaskSet|StatusValue */
129    private $tasks;
130
131    private ButtonGroupWidget $buttonGroupWidget;
132
133    private UserOptionsLookup $userOptionsLookup;
134
135    private ?NavigationWidgetFactory $navigationWidgetFactory = null;
136
137    private LinkRecommendationFilter $linkRecommendationFilter;
138
139    private ImageRecommendationFilter $imageRecommendationFilter;
140
141    private CampaignConfig $campaignConfig;
142
143    private StatsdDataFactoryInterface $perDbNameStatsdDataFactory;
144
145    /**
146     * @param IContextSource $context
147     * @param Config $wikiConfig
148     * @param CampaignConfig $campaignConfig
149     * @param ExperimentUserManager $experimentUserManager
150     * @param PageViewService|null $pageViewService
151     * @param ConfigurationLoader $configurationLoader
152     * @param NewcomerTasksUserOptionsLookup $newcomerTasksUserOptionsLookup
153     * @param TaskSuggester $taskSuggester
154     * @param TitleFactory $titleFactory
155     * @param ProtectionFilter $protectionFilter
156     * @param UserOptionsLookup $userOptionsLookup
157     * @param LinkRecommendationFilter $linkRecommendationFilter
158     * @param ImageRecommendationFilter $imageRecommendationFilter
159     */
160    public function __construct(
161        IContextSource $context,
162        Config $wikiConfig,
163        CampaignConfig $campaignConfig,
164        ExperimentUserManager $experimentUserManager,
165        ?PageViewService $pageViewService,
166        ConfigurationLoader $configurationLoader,
167        NewcomerTasksUserOptionsLookup $newcomerTasksUserOptionsLookup,
168        TaskSuggester $taskSuggester,
169        TitleFactory $titleFactory,
170        ProtectionFilter $protectionFilter,
171        UserOptionsLookup $userOptionsLookup,
172        LinkRecommendationFilter $linkRecommendationFilter,
173        ImageRecommendationFilter $imageRecommendationFilter,
174        StatsdDataFactoryInterface $perDbNameStatsdDataFactory
175    ) {
176        parent::__construct( 'suggested-edits', $context, $wikiConfig, $experimentUserManager );
177        $this->pageViewService = $pageViewService;
178        $this->configurationLoader = $configurationLoader;
179        $this->newcomerTasksUserOptionsLookup = $newcomerTasksUserOptionsLookup;
180        $this->taskSuggester = $taskSuggester;
181        $this->titleFactory = $titleFactory;
182        $this->protectionFilter = $protectionFilter;
183        $this->userOptionsLookup = $userOptionsLookup;
184        $this->linkRecommendationFilter = $linkRecommendationFilter;
185        $this->imageRecommendationFilter = $imageRecommendationFilter;
186        $this->campaignConfig = $campaignConfig;
187        $this->perDbNameStatsdDataFactory = $perDbNameStatsdDataFactory;
188    }
189
190    /** @inheritDoc */
191    protected function getHeaderTextElement() {
192        $context = $this->getContext();
193        if ( $this->getMode() === self::RENDER_DESKTOP ) {
194            return Html::element(
195                    'div',
196                    [ 'class' => self::BASE_CSS_CLASS . '-header-text' ],
197                    $this->getHeaderText() ) .
198                new ButtonWidget( [
199                    'id' => 'mw-ge-homepage-suggestededits-info',
200                    'icon' => 'info-unpadded',
201                    'framed' => false,
202                    'title' => $context->msg( 'growthexperiments-homepage-suggestededits-more-info' )->text(),
203                    'label' => $context->msg( 'growthexperiments-homepage-suggestededits-more-info' )->text(),
204                    'invisibleLabel' => true,
205                    'infusable' => true,
206                ] );
207        } else {
208            return parent::getHeaderTextElement();
209        }
210    }
211
212    /**
213     * Return the pagination text in the form "1 of 30" being 30 the total number of tasks shown
214     * @return string
215     */
216    protected function getTasksPaginationText() {
217        $tasks = $this->getTaskSet();
218
219        return $this->getContext()->msg( 'growthexperiments-homepage-suggestededits-pager' )
220            ->numParams( 1, $tasks->getTotalCount() )
221            ->parse();
222    }
223
224    /**
225     * Check whether the suggested edits feature is (or could be) enabled for anyone
226     * on the wiki.
227     * @param Config $config
228     * @return bool
229     */
230    public static function isEnabledForAnyone( Config $config ) {
231        return $config->get( 'GEHomepageSuggestedEditsEnabled' );
232    }
233
234    /**
235     * Check whether the suggested edits feature is enabled according to the configuration.
236     * @param Config $config
237     * @return bool
238     */
239    public static function isEnabled( Config $config ): bool {
240        return self::isEnabledForAnyone( $config );
241    }
242
243    /** @inheritDoc */
244    public function getCssClasses() {
245        return array_merge( parent::getCssClasses(),
246            $this->userOptionsLookup->getOption( $this->getContext()->getUser(), self::ACTIVATED_PREF ) ?
247                [ 'activated' ] :
248                [ 'unactivated' ]
249        );
250    }
251
252    /**
253     * Check whether topic matching has been enabled for the context user.
254     * Note that even with topic matching disabled, all the relevant backend functionality
255     * should still work (but logging and UI will be different).
256     * @param IContextSource $context
257     * @param UserOptionsLookup $userOptionsLookup
258     * @return bool
259     */
260    public static function isTopicMatchingEnabled(
261        IContextSource $context,
262        UserOptionsLookup $userOptionsLookup
263    ) {
264        return self::isEnabled( $context->getConfig() ) &&
265            $context->getConfig()->get( 'GEHomepageSuggestedEditsEnableTopics' );
266    }
267
268    /**
269     * Check whether topic match mode has been enabled for the context user.
270     * Note that even with topic match mode is disabled, all the relevant backend functionality
271     * should still work (but logging and UI will be different).
272     * @param IContextSource $context
273     * @param UserOptionsLookup $userOptionsLookup
274     * @return bool
275     */
276    private function isTopicMatchModeEnabled(
277        IContextSource $context,
278        UserOptionsLookup $userOptionsLookup
279    ) {
280        return self::isTopicMatchingEnabled( $context, $userOptionsLookup ) &&
281            $this->campaignConfig->isUserInCampaign(
282                $context->getUser(),
283                'growth-glam-2022'
284            );
285    }
286
287    /**
288     * Get the name of the preference to use for storing topic filters.
289     * @param Config $config
290     * @return string
291     */
292    public static function getTopicFiltersPref( Config $config ) {
293        $topicType = $config->get( 'GENewcomerTasksTopicType' );
294        if ( $topicType === PageConfigurationLoader::CONFIGURATION_TYPE_ORES ) {
295            return self::TOPICS_ORES_PREF;
296        }
297        return self::TOPICS_PREF;
298    }
299
300    /**
301     * Check if guidance feature is enabled for suggested edits.
302     *
303     * @param IContextSource $context
304     * @return bool
305     */
306    public static function isGuidanceEnabledForAnyone( IContextSource $context ): bool {
307        return $context->getConfig()->get( 'GENewcomerTasksGuidanceEnabled' );
308    }
309
310    /**
311     * Check if guidance feature is enabled for suggested edits.
312     *
313     * @param IContextSource $context
314     * @return bool
315     */
316    public static function isGuidanceEnabled( IContextSource $context ): bool {
317        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
318        return self::isGuidanceEnabledForAnyone( $context ) && (
319            !$context->getConfig()->get( 'GENewcomerTasksGuidanceRequiresOptIn' ) ||
320            $userOptionsLookup->getBoolOption( $context->getUser(), self::GUIDANCE_ENABLED_PREF ) );
321    }
322
323    /** @inheritDoc */
324    public function getHtml() {
325        // This method will be called both directly by the homepage and by getJsData() in
326        // some cases, so use some lightweight caching.
327        $key = $this->getMode() . ':' . $this->getContext()->getLanguage()->getCode();
328        if ( !array_key_exists( $key, $this->htmlCache ) ) {
329            $this->htmlCache[$key] = parent::getHtml();
330        }
331        return $this->htmlCache[$key];
332    }
333
334    /**
335     * @param string $mode one of the self::RENDER_* constants
336     * @param string $status one of ['ok', 'empty', 'error', 'total']
337     * @return void
338     */
339    private function trackQueueStatus( string $mode, string $status ): void {
340        if ( !in_array( $status, [ 'ok', 'empty', 'error', 'total' ] ) ) {
341            // Should never happen
342            LoggerFactory::getInstance( 'GrowthExperiments' )->warning(
343                __METHOD__ . ' called with unexpected status: {status}',
344                [
345                    'status' => $status,
346                    'exception' => new \RuntimeException
347                ]
348            );
349
350            return;
351        }
352        $this->perDbNameStatsdDataFactory->increment( implode( '.', [
353            'growthExperiments.suggestedEdits',
354            ( $mode === self::RENDER_DESKTOP ? 'desktop' : 'mobile' ),
355            'queue',
356            $status,
357        ] ) );
358    }
359
360    /** @inheritDoc */
361    public function getJsData( $mode ) {
362        $data = parent::getJsData( $mode );
363        $data['task-preview'] = [ 'noresults' => true ];
364
365        // Preload task card and queue for users who have the module activated
366        if ( $this->canRender() ) {
367            $tasks = $this->getTaskSet();
368            $this->trackQueueStatus( $mode, 'total' );
369            if ( $tasks instanceof StatusValue ) {
370                $data['task-preview'] = [
371                    'error' => Status::wrap( $tasks )->getMessage( false, false, 'en' )->parse(),
372                ];
373                $this->trackQueueStatus( $mode, 'error' );
374            } elseif ( $tasks->count() === 0 ) {
375                $data['task-preview'] = [ 'noresults' => true ];
376                $this->trackQueueStatus( $mode, 'empty' );
377            } else {
378                $formattedTasks = [];
379                foreach ( $tasks as $task ) {
380                    $title = $this->titleFactory->newFromLinkTarget( $task->getTitle() );
381                    $taskData = [
382                        'tasktype' => $task->getTaskType()->getId(),
383                        'difficulty' => $task->getTaskType()->getDifficulty(),
384                        'qualityGateIds' => $task->getTaskType()->getQualityGateIds(),
385                        'qualityGateConfig' => $tasks->getQualityGateConfig(),
386                        'title' => $title->getPrefixedText(),
387                        'topics' => $task->getTopicScores(),
388                        // The front-end code for constructing SuggestedEditCardWidget checks
389                        // to see if pageId is set in order to construct a tracking URL.
390                        'pageId' => $title->getArticleID(),
391                        'token' => $task->getToken(),
392                    ];
393                    if ( $task->getTaskType() instanceof ImageRecommendationBaseTaskType ) {
394                        // Prevent loading of thumbnail for image recommendation tasks.
395                        // TODO: Maybe there should be a property on the task type to check
396                        // rather than special casing image recommendation here
397                        $taskData['thumbnailSource'] = null;
398                    }
399                    $formattedTasks[] = $taskData;
400                }
401                $data['task-queue'] = $formattedTasks;
402                $data['task-preview'] = current( $formattedTasks );
403                $this->trackQueueStatus( $mode, 'ok' );
404            }
405        }
406
407        // When the module is not activated yet, but can be, include module HTML in the
408        // data, for dynamic loading on activation.
409        if ( $this->canRender() &&
410            !self::isActivated( $this->getContext()->getUser(), $this->userOptionsLookup ) &&
411            $this->getMode() !== self::RENDER_MOBILE_DETAILS
412        ) {
413            $data += [
414                'html' => $this->getHtml(),
415                'rlModules' => $this->getModules(),
416            ];
417        }
418
419        return $data;
420    }
421
422    /**
423     * Check whether suggested edits have been activated for the given user.
424     * Before activation, suggested edits are exposed via the StartEditing module;
425     * after activation (which happens by interacting with that module) via this one.
426     * @param UserIdentity $user
427     * @param UserOptionsLookup $userOptionsLookup
428     * @return bool
429     */
430    public static function isActivated(
431        UserIdentity $user,
432        UserOptionsLookup $userOptionsLookup
433    ) {
434        return $userOptionsLookup->getBoolOption( $user, self::ACTIVATED_PREF );
435    }
436
437    /** @inheritDoc */
438    public function getState() {
439        return self::isActivated( $this->getContext()->getUser(), $this->userOptionsLookup ) ?
440            self::MODULE_STATE_ACTIVATED :
441            self::MODULE_STATE_UNACTIVATED;
442    }
443
444    /**
445     * Get a suggested task set, with in-process caching.
446     * @return TaskSet|StatusValue
447     */
448    private function getTaskSet() {
449        if ( $this->tasks ) {
450            return $this->tasks;
451        }
452        $user = $this->getContext()->getUser();
453        $suggesterOptions = [ 'revalidateCache' => false ];
454        if ( $this->getContext()->getRequest()->getCheck( 'resetTaskCache' ) ) {
455            $suggesterOptions = [ 'resetCache' => true ];
456            // TODO also reset cache in ImageRecommendationFilter
457        }
458        $taskTypes = $this->newcomerTasksUserOptionsLookup->getTaskTypeFilter( $user );
459        $topics = $this->newcomerTasksUserOptionsLookup->getTopics( $user );
460        $topicsMatchMode = $this->newcomerTasksUserOptionsLookup->getTopicsMatchMode( $user );
461        $taskSetFilters = new TaskSetFilters( $taskTypes, $topics, $topicsMatchMode );
462        $tasks = $this->taskSuggester->suggest( $user, $taskSetFilters, null, null,
463            $suggesterOptions );
464        if ( $tasks instanceof TaskSet ) {
465            // If there are link recommendation tasks without corresponding DB entries, these will be removed
466            // from the TaskSet.
467            $tasks = $this->linkRecommendationFilter->filter( $tasks );
468            $tasks = $this->imageRecommendationFilter->filter( $tasks );
469            $tasks = $this->protectionFilter->filter( $tasks );
470        }
471        $this->tasks = $tasks;
472        $this->resetTaskCache( $user, $taskSetFilters, $suggesterOptions );
473        return $this->tasks;
474    }
475
476    /**
477     * Refresh the user's task cache in a deferred update.
478     *
479     * @param UserIdentity $user
480     * @param TaskSetFilters $taskSetFilters
481     * @param array $suggesterOptions
482     * @return void
483     */
484    public function resetTaskCache( UserIdentity $user, TaskSetFilters $taskSetFilters, array $suggesterOptions ) {
485        DeferredUpdates::addCallableUpdate( function () use ( $user, $taskSetFilters, $suggesterOptions ) {
486            $suggesterOptions['resetCache'] = true;
487            $this->taskSuggester->suggest( $user, $taskSetFilters, null, null, $suggesterOptions );
488        } );
489    }
490
491    /** @inheritDoc */
492    protected function canRender() {
493        return self::isEnabled( $this->getContext()->getConfig() )
494            && !$this->configurationLoader->loadTaskTypes() instanceof StatusValue;
495    }
496
497    /** @inheritDoc */
498    protected function getHeaderText() {
499        return $this->getContext()
500            ->msg( 'growthexperiments-homepage-suggested-edits-header' )
501            ->text();
502    }
503
504    /** @inheritDoc */
505    protected function getHeaderIconName() {
506        return 'lightbulb';
507    }
508
509    /** @inheritDoc */
510    protected function getBody() {
511        $isDesktop = $this->getMode() === self::RENDER_DESKTOP;
512        $topicMatchMode = $this->newcomerTasksUserOptionsLookup->getTopicsMatchMode( $this->getUser() );
513        return Html::rawElement(
514            'div', [ 'class' => 'suggested-edits-module-wrapper' ],
515            ( new Tag( 'div' ) )
516                ->addClasses( [ 'suggested-edits-filters' ] )
517                ->appendContent( $isDesktop ? $this->getFiltersButtonGroupWidget() : '' ) .
518            ( new Tag( 'div' ) )
519                ->addClasses( [ 'suggested-edits-pager' ] )
520                ->appendContent( $this->getPager() ) .
521            ( new CardWrapper(
522                $this->getContext(),
523                self::isTopicMatchingEnabled( $this->getContext(), $this->userOptionsLookup ),
524                $topicMatchMode === SearchStrategy::TOPIC_MATCH_MODE_AND,
525                $this->getContext()->getLanguage()->getDir(),
526                $this->getTaskSet(),
527                $this->getNavigationWidgetFactory(),
528                $isDesktop
529            ) )->render() .
530            ( new Tag( 'div' ) )->addClasses( [ 'suggested-edits-task-explanation' ] )
531                ->appendContent( ( new TaskExplanationWidget( [
532                    'taskSet' => $this->getTaskSet(),
533                    'localizer' => $this->getContext()
534                ] ) ) )
535        );
536    }
537
538    /** @inheritDoc */
539    protected function getFooter() {
540        if ( $this->getMode() === self::RENDER_DESKTOP ) {
541            $siteViewsCount = $this->getSiteViews();
542            $siteViewsMessage = $siteViewsCount ?
543                $this->getContext()->msg( 'growthexperiments-homepage-suggestededits-footer' )
544                    ->params( $this->formatSiteViews( $siteViewsCount ) ) :
545                $this->getContext()->msg( 'growthexperiments-homepage-suggestededits-footer-noviews' );
546            return $siteViewsMessage->parse();
547        }
548        return ( new Tag( 'div' ) )->addClasses( [ 'suggested-edits-footer-navigation' ] )
549            ->appendContent( [
550                $this->getNavigationWidgetFactory()->getPreviousNextButtonHtml( 'Previous' ),
551                $this->getNavigationWidgetFactory()->getEditButton(),
552                $this->getNavigationWidgetFactory()->getPreviousNextButtonHtml( 'Next' )
553            ] );
554    }
555
556    /**
557     * @inheritDoc
558     */
559    protected function getMobileSummaryBody() {
560        $tasks = $this->getTaskSet();
561        // If the task cannot be loaded, fall back to the old summary style for now.
562        $showTaskPreview = $tasks instanceof TaskSet && $tasks->count() > 0;
563
564        if ( $showTaskPreview ) {
565            $button = new ButtonWidget( [
566                'label' => $this->getContext()->msg(
567                    'growthexperiments-homepage-suggestededits-mobilesummary-footer-button' )->text(),
568                'classes' => [ 'suggested-edits-preview-cta-button' ],
569                'flags' => [ 'primary', 'progressive' ],
570                // Avoid nesting links, browsers will break markup
571                'button' => new Tag( 'span' ),
572            ] );
573            $centeredButton = Html::rawElement( 'div', [ 'class' => 'suggested-edits-preview-footer' ], $button );
574            $subheader = Html::rawElement(
575                'div',
576                [ 'class' => 'suggested-edits-preview-pager' ],
577                $this->getTasksPaginationText()
578            );
579
580            return Html::rawElement( 'div', [ 'class' => [ 'growthexperiments-task-preview-widget' ] ],
581                $subheader . $this->getTaskCard() . $centeredButton );
582        } else {
583            $baseClass = 'growthexperiments-suggestededits-mobilesummary-notasks-widget';
584
585            $previewTitle = $this->getContext()
586                ->msg( 'growthexperiments-homepage-suggestededits-mobilesummary-notasks-title' )
587                ->text();
588            $subtitle = $this->getContext()
589                ->msg( 'growthexperiments-homepage-suggestededits-mobilesummary-notasks-subtitle' )
590                ->text();
591            $footerText = $this->getContext()
592                ->msg( 'growthexperiments-homepage-suggestededits-mobilesummary-footer' )
593                ->text();
594            $noTaskPreviewContent = Html::rawElement( 'div', [ 'class' => $baseClass . '__main' ],
595                Html::rawElement( 'div', [ 'class' => $baseClass . '__icon' ] ) .
596                Html::rawElement( 'div', [],
597                    Html::element( 'div', [ 'class' => $baseClass . '__title' ], $previewTitle ) .
598                    Html::element( 'div', [ 'class' => $baseClass . '__subtitle' ], $subtitle )
599                )
600            ) . Html::element( 'div', [
601                'class' => $baseClass . '__footer'
602            ], $footerText );
603            return Html::rawElement( 'div', [
604                'class' => [ $baseClass ]
605            ], $noTaskPreviewContent );
606        }
607    }
608
609    /**
610     * Generate a button group widget with task and topic filters.
611     *
612     * This function should be kept in sync with
613     * SuggestedEditsFiltersWidget.prototype.updateButtonLabelAndIcon
614     * @return ButtonGroupWidget
615     */
616    private function getFiltersButtonGroupWidget(): ButtonGroupWidget {
617        $buttons = [];
618        $user = $this->getContext()->getUser();
619        if ( self::isTopicMatchingEnabled( $this->getContext(), $this->userOptionsLookup ) ) {
620            // topicPreferences will be an empty array if the user had saved topics
621            // in the past, or null if they have never saved topics
622            $topicPreferences = $this->newcomerTasksUserOptionsLookup
623                ->getTopicFilterWithoutFallback( $user );
624            $excludedTopics = $this->campaignConfig->getTopicsToExcludeForUser( $user );
625            // Filter out campaign-specific topics that are no longer available
626            if ( $topicPreferences && count( $excludedTopics ) ) {
627                $topicPreferences = array_diff( $topicPreferences, $excludedTopics );
628            }
629            $topicData = $this->configurationLoader->getTopics();
630            $topicLabel = '';
631            $addPulsatingDot = false;
632            $topicFilterMode = $this->newcomerTasksUserOptionsLookup->getTopicsMatchMode( $user );
633            $flags = [];
634            if ( !$topicPreferences ) {
635                if ( $topicPreferences === null ) {
636                    $flags = [ 'progressive' ];
637                    $addPulsatingDot = true;
638                }
639                $topicLabel =
640                    $this->getContext()
641                        ->msg( 'growthexperiments-homepage-suggestededits-topic-filter-select-interests' )
642                        ->text();
643            } else {
644                $topicMessages = [];
645                foreach ( $topicPreferences as $topicPreference ) {
646                    $topic = $topicData[$topicPreference] ?? null;
647                    if ( $topic instanceof Topic ) {
648                        $topicMessages[] = $topic->getName( $this->getContext() );
649                    }
650                }
651                $topicMessages = array_filter( $topicMessages );
652                if ( count( $topicMessages ) ) {
653                    if ( count( $topicMessages ) < 3 ) {
654                        $separator = $topicFilterMode === SearchStrategy::TOPIC_MATCH_MODE_OR ?
655                            $this->getContext()->msg( 'comma-separator' ) : ' + ';
656                        $topicLabel = implode( $separator, $topicMessages );
657                    } else {
658                        $topicLabel =
659                            $this->getContext()
660                                ->msg( 'growthexperiments-homepage-suggestededits-topics-button-topic-count' )
661                                ->numParams( count( $topicMessages ) )
662                                ->text();
663                    }
664                }
665            }
666
667            $topicFilterButtonWidget = new ButtonWidget( [
668                'label' => $topicLabel,
669                'flags' => $flags,
670                'classes' => [ 'topic-matching', 'topic-filter-button' ],
671                'indicator' => $this->getMode() === self::RENDER_DESKTOP ? null : 'down',
672                'icon' => $topicFilterMode === SearchStrategy::TOPIC_MATCH_MODE_OR ? 'funnel' : 'funnel-add'
673            ] );
674            if ( $addPulsatingDot ) {
675                $topicFilterButtonWidget->appendContent(
676                    ( new Tag( 'div' ) )->addClasses( [ 'mw-pulsating-dot' ] )
677                );
678            }
679            $buttons[] = $topicFilterButtonWidget;
680        }
681        $difficultyFilterButtonWidget = new ButtonWidget( [
682            'icon' => 'difficulty-outline',
683            'classes' => self::isTopicMatchingEnabled( $this->getContext(), $this->userOptionsLookup ) ?
684                [ 'topic-matching' ] : [ '' ],
685            'label' => $this->getContext()->msg(
686                'growthexperiments-homepage-suggestededits-difficulty-filters-title'
687            )->text(),
688            'indicator' => $this->getMode() === self::RENDER_DESKTOP ? null : 'down'
689        ] );
690
691        $levels = [];
692        $taskTypeData = $this->configurationLoader->getTaskTypes();
693        foreach ( $this->newcomerTasksUserOptionsLookup->getTaskTypeFilter( $user ) as $taskTypeId ) {
694            /** @var TaskType $taskType */
695            $taskType = $taskTypeData[$taskTypeId] ?? null;
696            if ( $taskType ) {
697                // Sometimes the default task types don't exist on a wiki (T268012)
698                $levels[ $taskType->getDifficulty() ] = true;
699            }
700        }
701        $taskTypeMessages = [];
702        $messageKey = $this->getMode() === self::RENDER_DESKTOP ?
703            'growthexperiments-homepage-suggestededits-difficulty-filter-label' :
704            'growthexperiments-homepage-suggestededits-difficulty-filter-label-mobile';
705
706        foreach ( [ 'easy', 'medium', 'hard' ] as $level ) {
707            if ( !isset( $levels[$level] ) ) {
708                continue;
709            }
710            // The following messages are used here:
711            // * growthexperiments-homepage-suggestededits-difficulty-filter-label-easy
712            // * growthexperiments-homepage-suggestededits-difficulty-filter-label-medium
713            // * growthexperiments-homepage-suggestededits-difficulty-filter-label-hard
714            $label = $this->getContext()->msg(
715                'growthexperiments-homepage-suggestededits-difficulty-filter-label-' . $level
716            );
717            $message = $this->getContext()->msg( $messageKey )
718                ->params( $label )
719                ->text();
720            $difficultyFilterButtonWidget->setLabel( $message );
721            // Icons: difficulty-easy, difficulty-medium, difficulty-hard
722            $difficultyFilterButtonWidget->setIcon( 'difficulty-' . $level );
723            $taskTypeMessages[] = $label;
724        }
725        if ( count( $taskTypeMessages ) > 1 ) {
726            $difficultyFilterButtonWidget->setIcon( 'difficulty-outline' );
727            $messageKey = $this->getMode() === self::RENDER_DESKTOP ?
728                'growthexperiments-homepage-suggestededits-difficulty-filter-label' :
729                'growthexperiments-homepage-suggestededits-difficulty-filter-label-mobile';
730            $message = $this->getContext()->msg( $messageKey )
731                ->params( implode( $this->getContext()->msg( 'comma-separator' ),
732                    $taskTypeMessages ) )
733                ->text();
734            $difficultyFilterButtonWidget->setLabel( $message );
735        }
736
737        $buttons[] = $difficultyFilterButtonWidget;
738        $this->buttonGroupWidget = new ButtonGroupWidget( [
739            'class' => 'suggested-edits-filters',
740            'items' => $buttons,
741            'infusable' => true,
742        ] );
743        return $this->buttonGroupWidget;
744    }
745
746    /**
747     * Generate HTML identical to that of mw.libs.ge.SmallTaskCard
748     * @return string
749     */
750    private function getTaskCard() {
751        $tasks = $this->getTaskSet();
752        if ( !$tasks instanceof TaskSet ) {
753            throw new RuntimeException( 'Expected to have tasks.' );
754        }
755        $task = $tasks[0];
756        $taskTypeId = $task->getTaskType()->getId();
757        $title = $this->titleFactory->newFromLinkTarget( $task->getTitle() );
758
759        $imageClasses = array_merge(
760            [ 'mw-ge-small-task-card-image' ],
761            $task->getTaskType()->getSmallTaskCardImageCssClasses()
762        );
763        $image = Html::element( 'div', [ 'class' =>
764            implode( " ", $imageClasses ) ] );
765        $title = Html::element( 'span',
766            [ 'class' => 'mw-ge-small-task-card-title' ],
767            $title->getPrefixedText() );
768        $description = Html::element( 'div',
769            [ 'class' => 'mw-ge-small-task-card-description skeleton' ] );
770        $taskIcon = new IconWidget( [ 'icon' => 'difficulty-' . $task->getTaskType()->getDifficulty() ] );
771        $iconData = $task->getTaskType()->getIconData();
772        $taskTypeIcon = array_key_exists( 'icon', $iconData )
773            ? new IconWidget( [ 'icon' => $iconData['icon'] ] )
774            : '';
775        $taskType = Html::rawElement( 'span',
776            [ 'class' => 'mw-ge-small-task-card-tasktype '
777                 // The following classes are used here:
778                 // * mw-ge-small-task-card-tasktype-difficulty-easy
779                 // * mw-ge-small-task-card-tasktype-difficulty-medium
780                 // * mw-ge-small-task-card-tasktype-difficulty-hard
781                . 'mw-ge-small-task-card-tasktype-difficulty-'
782                . $task->getTaskType()->getDifficulty() ],
783            $taskTypeIcon . $taskIcon . Html::element( 'span',
784                [ 'class' => 'mw-ge-small-task-card-tasktype-taskname' ],
785                $task->getTaskType()->getName( $this->getContext() )
786            ) );
787
788        $glue = Html::element( 'div',
789            [ 'class' => 'mw-ge-small-task-card-glue' ] );
790        $cardMetadataContainer = Html::rawElement( 'div',
791            [ 'class' => 'mw-ge-small-task-card-metadata-container' ],
792            // Unlike SmallTaskCard, this version does not have pageviews.
793            $taskType );
794        $cardTextContainer = Html::rawElement( 'div',
795            [ 'class' => 'mw-ge-small-task-card-text-container' ],
796            $title . $description . $glue . $cardMetadataContainer );
797        return Html::rawElement( 'div',
798            // only called for mobile views
799            [ 'class' => 'mw-ge-small-task-card mw-ge-small-task-card-mobile '
800                . "mw-ge-small-task-card mw-ge-tasktype-$taskTypeId"
801            ],
802        $image . $cardTextContainer );
803    }
804
805    /** @inheritDoc */
806    protected function getSubheader() {
807        // Ugly hack to get the filters positioned outside of the module wrapper on mobile.
808        $mobileDetails = [ self::RENDER_MOBILE_DETAILS, self::RENDER_MOBILE_DETAILS_OVERLAY ];
809        if ( !in_array( $this->getMode(), $mobileDetails, true ) ) {
810            return '';
811        }
812        return Html::rawElement( 'div', [ 'class' => 'suggested-edits-filters' ] );
813    }
814
815    /** @inheritDoc */
816    protected function getSubheaderTag() {
817        return 'div';
818    }
819
820    /** @inheritDoc */
821    protected function getModuleStyles() {
822        return array_merge(
823            parent::getModuleStyles(),
824            [
825                'mediawiki.pulsatingdot',
826                'oojs-ui.styles.icons-editing-core'
827            ]
828        );
829    }
830
831    /** @inheritDoc */
832    protected function getModules() {
833        return array_merge(
834            parent::getModules(),
835            [ 'ext.growthExperiments.Homepage.SuggestedEdits' ]
836        );
837    }
838
839    /**
840     * Returns daily unique site views, averaged over the last 30 days.
841     * @return int|null
842     */
843    protected function getSiteViews() {
844        if ( !$this->pageViewService ||
845             !$this->pageViewService->supports( PageViewService::METRIC_UNIQUE, PageViewService::SCOPE_SITE )
846        ) {
847            return null;
848        }
849        // When PageViewService is a WikimediaPageViewService, the pageviews for the last two days
850        // or so will be missing due to AQS processing lag. Get some more days and discard the
851        // newest ones.
852        $status = $this->pageViewService->getSiteData( 32, PageViewService::METRIC_UNIQUE );
853        if ( !$status->isOK() ) {
854            return null;
855        }
856        $data = $status->getValue();
857        ksort( $data );
858        return (int)( array_sum( array_slice( $data, 0, 30 ) ) / 30 );
859    }
860
861    /**
862     * Format site views count in a human-readable way.
863     * @param int $siteViewsCount
864     * @return string|MessageParam A Message::params() parameter
865     */
866    protected function formatSiteViews( int $siteViewsCount ) {
867        // We only get here when $siteViewsCount is not 0 so log is safe.
868        $siteViewsCount = (int)round( $siteViewsCount, (int)-floor( log10( $siteViewsCount ) ) );
869        $language = $this->getContext()->getLanguage();
870        if ( $this->getContext()->msg( 'growthexperiments-homepage-suggestededits-footer-suffix' )
871            ->isDisabled()
872        ) {
873            // This language does not use suffixes, just output the rounded number
874            return Message::numParam( $siteViewsCount );
875        }
876        // Abuse Language::formatComputingNumbers into displaying large numbers in a human-readable way
877        return $language->formatComputingNumbers( $siteViewsCount, 1000,
878            'growthexperiments-homepage-suggestededits-footer-$1suffix' );
879    }
880
881    /** @inheritDoc */
882    protected function getActionData() {
883        $user = $this->getContext()->getUser();
884        $taskSet = $this->getTaskSet();
885        $taskTypes = $topics = null;
886        if ( $taskSet instanceof TaskSet ) {
887            $taskTypes = $taskSet->getFilters()->getTaskTypeFilters();
888            $topics = $taskSet->getFilters()->getTopicFilters();
889            $topicsMatchMode = $taskSet->getFilters()->getTopicFiltersMode();
890        }
891
892        $isMobile = Util::isMobile( $this->getContext()->getOutput()->getSkin() );
893
894        // these will be updated on the client side as needed
895        $data = [
896            'taskTypes' => $taskTypes ?? $this->newcomerTasksUserOptionsLookup->getTaskTypeFilter( $user ),
897            'taskCount' => ( $taskSet instanceof TaskSet ) ? $taskSet->getTotalCount() : 0,
898        ];
899        if ( self::isTopicMatchingEnabled( $this->getContext(), $this->userOptionsLookup ) ) {
900            $data['topics'] = $topics ?? $this->newcomerTasksUserOptionsLookup->getTopics( $user );
901            if ( $this->isTopicMatchModeEnabled( $this->getContext(), $this->userOptionsLookup ) ) {
902                $data['topicsMatchMode'] = $topicsMatchMode ??
903                    $this->newcomerTasksUserOptionsLookup->getTopicsMatchMode( $user );
904            }
905
906        }
907        return array_merge( parent::getActionData(), $data );
908    }
909
910    /**
911     * @inheritDoc
912     */
913    protected function getJsConfigVars() {
914        return [
915            'GEHomepageSuggestedEditsEnableTopics' => self::isTopicMatchingEnabled(
916                $this->getContext(),
917                $this->userOptionsLookup
918            )
919        ];
920    }
921
922    /**
923     * Get the pager text (1 of X) to show on server side render.
924     *
925     * This code roughly corresponds to SuggestedEditPagerWidget.prototype.setMessage
926     *
927     * @return string
928     * @throws Exception
929     */
930    private function getPager() {
931        $taskSet = $this->getTaskSet();
932        if ( !$taskSet instanceof TaskSet || !$taskSet->count() ) {
933            return '';
934        }
935        return new HtmlSnippet( $this->getContext()->msg( 'growthexperiments-homepage-suggestededits-pager' )
936                ->numParams( [ 1, $taskSet->getTotalCount() ] )
937            ->parse() );
938    }
939
940    /**
941     * Get the query params for the redirect URL for the specified task type ID
942     *
943     * @param string|null $taskTypeId
944     * @return array
945     */
946    public function getRedirectParams( ?string $taskTypeId = null ): array {
947        $taskType = $this->configurationLoader->getTaskTypes()[ $taskTypeId ] ?? null;
948        if ( !$taskType ) {
949            return [];
950        }
951
952        $redirectParams = [];
953        if ( $taskType->shouldOpenInEditMode() ) {
954            $redirectParams[ 'veaction' ] = 'edit';
955        }
956        if ( (bool)$taskType->getDefaultEditSection() ) {
957            $redirectParams[ 'section' ] = $taskType->getDefaultEditSection();
958        }
959        return $redirectParams;
960    }
961
962    /**
963     * @return NavigationWidgetFactory
964     */
965    private function getNavigationWidgetFactory(): NavigationWidgetFactory {
966        if ( !$this->navigationWidgetFactory ) {
967            $this->navigationWidgetFactory = new NavigationWidgetFactory(
968                $this->getContext(),
969                $this->getTaskSet()
970            );
971        }
972        return $this->navigationWidgetFactory;
973    }
974}