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