Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
21.89% covered (danger)
21.89%
65 / 297
5.00% covered (danger)
5.00%
1 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
21.89% covered (danger)
21.89%
65 / 297
5.00% covered (danger)
5.00%
1 / 20
4038.09
0.00% covered (danger)
0.00%
0 / 1
 onBeforePageDisplay
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 addSXPublishingFollowupModule
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 addMobileNewByTranslationInvitation
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
56
 isMobileView
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 isSXEnabled
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isPotentialTranslator
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
 addModules
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
306
 onGetBetaFeaturePreferences
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 onSpecialContributionsBeforeMainOutput
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 onResourceLoaderRegisterModules
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 onListDefinedTags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onChangeTagsListActive
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 registerTags
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 onEditPage__showEditForm_initial
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
210
 onSaveUserOptions
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
56
 onBeforeCreateEchoEvent
100.00% covered (success)
100.00%
59 / 59
100.00% covered (success)
100.00%
1 / 1
1
 onEchoGetBundleRules
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 onGetPreferences
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 devModeCallback
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 onContributeCards
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3/**
4 * Hooks for ContentTranslation extension.
5 *
6 * @copyright See AUTHORS.txt
7 * @license GPL-2.0-or-later
8 */
9
10namespace ContentTranslation;
11
12// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName
13
14use ContentTranslation\Service\TranslatorService;
15use MediaWiki\ChangeTags\Hook\ChangeTagsListActiveHook;
16use MediaWiki\ChangeTags\Hook\ListDefinedTagsHook;
17use MediaWiki\Config\Config;
18use MediaWiki\Context\RequestContext;
19use MediaWiki\EditPage\EditPage;
20use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
21use MediaWiki\Extension\Notifications\AttributeManager;
22use MediaWiki\Extension\Notifications\Model\Event;
23use MediaWiki\Extension\Notifications\UserLocator;
24use MediaWiki\Hook\EditPage__showEditForm_initialHook;
25use MediaWiki\Hook\SpecialContributionsBeforeMainOutputHook;
26use MediaWiki\MediaWikiServices;
27use MediaWiki\Output\Hook\BeforePageDisplayHook;
28use MediaWiki\Output\OutputPage;
29use MediaWiki\Permissions\PermissionManager;
30use MediaWiki\Preferences\Hook\GetPreferencesHook;
31use MediaWiki\Registration\ExtensionRegistry;
32use MediaWiki\ResourceLoader\Context as ResourceLoaderContext;
33use MediaWiki\ResourceLoader\FilePath as ResourceLoaderFilePath;
34use MediaWiki\ResourceLoader\Hook\ResourceLoaderRegisterModulesHook;
35use MediaWiki\ResourceLoader\ResourceLoader;
36use MediaWiki\Skin\Skin;
37use MediaWiki\SpecialPage\SpecialPage;
38use MediaWiki\Specials\Contribute\Card\ContributeCard;
39use MediaWiki\Specials\Contribute\Card\ContributeCardActionLink;
40use MediaWiki\Specials\Contribute\ContributeFactory;
41use MediaWiki\Specials\Contribute\Hook\ContributeCardsHook;
42use MediaWiki\User\Options\Hook\SaveUserOptionsHook;
43use MediaWiki\User\User;
44use MediaWiki\User\UserIdentity;
45use MediaWiki\WikiMap\WikiMap;
46use MobileContext;
47
48class Hooks implements
49    BeforePageDisplayHook,
50    GetPreferencesHook,
51    ResourceLoaderRegisterModulesHook,
52    SpecialContributionsBeforeMainOutputHook,
53    ListDefinedTagsHook,
54    ChangeTagsListActiveHook,
55    SaveUserOptionsHook,
56    EditPage__showEditForm_initialHook,
57    ContributeCardsHook
58{
59    /**
60     * @param OutputPage $out
61     * @param Skin $skin
62     */
63    public function onBeforePageDisplay( $out, $skin ): void {
64        self::addModules( $out, $skin );
65        self::addSXPublishingFollowupModule( $out, $skin );
66        self::addMobileNewByTranslationInvitation( $out, $skin );
67    }
68
69    /**
70     * Add 'sx.publishing.followup' module when output page is an article page
71     * or a page in user namespace(sandbox), and "sx-published-section" query
72     * params exists. This is the case for redirections to target article page
73     * after Section Translation successful publishing
74     * @param OutputPage $out
75     * @param Skin $skin
76     */
77    public static function addSXPublishingFollowupModule( OutputPage $out, Skin $skin ): void {
78        $sxPublishedQueryParam = $out->getRequest()->getVal( "sx-published-section" );
79        $isContentPage = $out->getTitle()->isContentPage();
80        $isSandboxPage = $out->getTitle()->inNamespace( NS_USER );
81        if ( ( $isContentPage || $isSandboxPage ) && $sxPublishedQueryParam !== null ) {
82            $out->addModules( 'sx.publishing.followup' );
83        }
84    }
85
86    public static function addMobileNewByTranslationInvitation( OutputPage $out, Skin $skin ): void {
87        // This entrypoint should only be enabled for mobile web version
88        if ( !self::isMobileView() ) {
89            return;
90        }
91
92        if ( !self::isSXEnabled() ) {
93            return;
94        }
95
96        // This entrypoint should only be enabled for logged-in users or wikis that
97        // have section translation enabled for anonymous users
98        $user = $out->getUser();
99        $isSxEnabledForAnon = $out->getConfig()->get( 'ContentTranslationEnableAnonSectionTranslation' );
100        if ( !$user->isNamed() && !$isSxEnabledForAnon ) {
101            return;
102        }
103
104        $isValidContext = !$out->getTitle()->exists() && $out->getTitle()->inNamespace( NS_MAIN );
105
106        if ( !$isValidContext ) {
107            return;
108        }
109
110        $out->addModules( 'ext.cx.entrypoints.newbytranslation.mobile' );
111    }
112
113    /**
114     * Check whether the current context is in a mobile interface
115     *
116     * @return bool
117     */
118    private static function isMobileView() {
119        $isMobileView = false;
120
121        if ( ExtensionRegistry::getInstance()->isLoaded( 'MobileFrontend' ) ) {
122            /** @var MobileContext $mobileContext */
123            $mobileContext = MediaWikiServices::getInstance()->getService( 'MobileFrontend.Context' );
124            $isMobileView = $mobileContext->shouldDisplayMobileView();
125        }
126        return $isMobileView;
127    }
128
129    /**
130     * Check whether SectionTranslation is enabled in current wiki
131     *
132     * @return bool
133     */
134    private static function isSXEnabled() {
135        $out = RequestContext::getMain()->getOutput();
136        $currentLanguageCode = SiteMapper::getCurrentLanguageCode();
137        $enabledLanguages = $out->getConfig()->get( 'SectionTranslationTargetLanguages' );
138        return is_array( $enabledLanguages ) && in_array( $currentLanguageCode, $enabledLanguages );
139    }
140
141    /**
142     * Check whether the current user is a potential translator
143     *
144     * @param User $user
145     * @return bool
146     */
147    private static function isPotentialTranslator( User $user ) {
148        /** @var TranslatorService $translatorService */
149        $translatorService = MediaWikiServices::getInstance()->get( 'ContentTranslation.TranslatorService' );
150
151        if ( $translatorService->isTranslator( $user ) ) {
152            // Already a translator
153            return true;
154        }
155
156        if ( ExtensionRegistry::getInstance()->isLoaded( 'CentralAuth' ) ) {
157            $centralUser = CentralAuthUser::getInstance( $user );
158
159            // Check if the user has edited in more than one wiki.
160            $editedWikiCount = 0;
161            $attachedAccounts = $centralUser->queryAttached();
162            foreach ( $attachedAccounts as $wikiId => $account ) {
163                $wikiRef = WikiMap::getWiki( $wikiId ); // Get WikiReference instance
164                $url = '';
165                if ( $wikiRef ) {
166                    $url = $wikiRef->getCanonicalServer();
167                }
168                if (
169                    // Ignore non-wikipedia wikis such as commons, mediawiki, meta etc
170                    // url property example "https://commons.wikimedia.org",
171                    strpos( $url, 'wikipedia' ) !== false &&
172                    intval( $account['editCount'] ?? 0 ) > 0
173                ) {
174                    $editedWikiCount++;
175                }
176            }
177
178            return $editedWikiCount > 1;
179        }
180
181        return false;
182    }
183
184    /**
185     * Hook: BeforePageDisplay
186     * @param OutputPage $out
187     * @param Skin $skin
188     */
189    public static function addModules( OutputPage $out, Skin $skin ) {
190        global $wgContentTranslationAsBetaFeature, $wgContentTranslationCampaigns;
191
192        $title = $out->getTitle();
193        $user = $out->getUser();
194
195        /** @var PreferenceHelper $preferenceHelper */
196        $preferenceHelper = MediaWikiServices::getInstance()->getService( 'ContentTranslation.PreferenceHelper' );
197        if ( $preferenceHelper->isCXEntrypointDisabled( $user ) ) {
198            return;
199        }
200
201        $out->addModules( 'ext.cx.eventlogging.campaigns' );
202
203        if ( !$title ||
204            $title->isSpecial( 'ContentTranslation' )
205        ) {
206            // Entry point modules need not be shown in CX special pages
207            return;
208        }
209
210        $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
211
212        // Load the new article campaign for VisualEditor if it's relevant.
213        // Done separately from loading the newarticle campaign for the
214        // wiki syntax editor because of the different actions with which
215        // the editing page is loaded.
216        if ( !$preferenceHelper->isEnabledForUser( $user ) ) {
217            if (
218                !$title->exists() &&
219                $wgContentTranslationCampaigns['newarticle'] &&
220                !$out->getRequest()->getCookie( 'cx_campaign_newarticle_hide', '' ) &&
221                $title->inNamespace( NS_MAIN ) &&
222                $user->isRegistered() &&
223                $permissionManager->userCan( 'edit', $user, $title, PermissionManager::RIGOR_QUICK )
224            ) {
225                $out->addModules( 'ext.cx.entrypoints.newarticle.veloader' );
226            }
227
228            return;
229        }
230
231        if ( $title->inNamespace( NS_MAIN ) &&
232            $out->getActionName() === 'view' &&
233            $title->exists() &&
234            in_array( $skin->getSkinName(), [ 'vector', 'vector-2022' ] )
235        ) {
236            $out->addJsConfigVars( [
237                'wgContentTranslationAsBetaFeature' =>
238                    $wgContentTranslationAsBetaFeature,
239            ] );
240            $out->addModules( 'ext.cx.interlanguagelink.init' );
241        }
242
243        // Add translations entry for the personal menu
244        if ( !self::isMobileView() && in_array( $skin->getSkinName(), [ 'vector-2022' ] ) ) {
245            $out->addModules( 'ext.cx.entrypoints.contributionsmenu' );
246        }
247    }
248
249    /**
250     * Hook: GetBetaFeaturePreferences
251     * @param User $user
252     * @param array[] &$prefs
253     */
254    public static function onGetBetaFeaturePreferences( User $user, array &$prefs ) {
255        global $wgExtensionAssetsPath, $wgContentTranslationAsBetaFeature;
256
257        if ( !$wgContentTranslationAsBetaFeature ) {
258            return;
259        }
260
261        $imageDir = "$wgExtensionAssetsPath/ContentTranslation/images";
262
263        $prefs['cx'] = [
264            'label-message' => 'cx-beta',
265            'desc-message' => 'cx-beta-desc',
266            'screenshot' => [
267                'ltr' => "$imageDir/cx-icon-ltr.svg",
268                'rtl' => "$imageDir/cx-icon-rtl.svg",
269            ],
270            'info-link' => 'https://www.mediawiki.org/wiki/Special:MyLanguage/Content_translation',
271            'discussion-link' => 'https://www.mediawiki.org/wiki/Talk:Content_translation',
272            'requirements' => [
273                'javascript' => true,
274            ]
275        ];
276    }
277
278    /**
279     * Hook: SpecialContributionsBeforeMainOutput
280     * @param int $id
281     * @param UserIdentity $user
282     * @param SpecialPage $page
283     */
284    public function onSpecialContributionsBeforeMainOutput( $id, $user, $page ) {
285        $user = MediaWikiServices::getInstance()->getUserFactory()->newFromUserIdentity( $user );
286        if ( !$user->isNamed() ) {
287            return;
288        }
289
290        /** @var PreferenceHelper $preferenceHelper */
291        $preferenceHelper = MediaWikiServices::getInstance()->getService( 'ContentTranslation.PreferenceHelper' );
292
293        if ( $user->getId() !== $page->getUser()->getId() || !$preferenceHelper->isEnabledForUser( $user ) ) {
294            return;
295        }
296
297        if ( $preferenceHelper->isCXEntrypointDisabled( $user ) ) {
298            return;
299        }
300
301        if ( self::isMobileView() ) {
302            // Contribution buttons should be shown only in desktop
303            return;
304        }
305
306        $modules = [ 'ext.cx.eventlogging.campaigns' ];
307
308        $isSpecialContributeEnabled = ContributeFactory::isEnabledOnCurrentSkin(
309            $page->getSkin(),
310            $page->getConfig()->get( 'SpecialContributeSkinsEnabled' )
311        );
312
313        if ( !$isSpecialContributeEnabled ) {
314            $modules[] = 'ext.cx.contributions';
315        }
316        $page->getOutput()->addModules( $modules );
317    }
318
319    /**
320     * Hook: ResourceLoaderRegisterModules
321     *
322     * @param ResourceLoader $resourceLoader Client-side code and assets to be loaded.
323     */
324    public function onResourceLoaderRegisterModules( ResourceLoader $resourceLoader ): void {
325        $cxResourceTemplate = [
326            'localBasePath' => dirname( __DIR__ ),
327            'remoteExtPath' => 'ContentTranslation',
328        ];
329
330        $externalMessages = [];
331        $extReg = ExtensionRegistry::getInstance();
332        if ( $extReg->isLoaded( 'ConfirmEdit' ) ) {
333            $externalMessages[] = 'captcha-create';
334            $externalMessages[] = 'captcha-label';
335
336            if ( $extReg->isLoaded( 'QuestyCaptcha' ) ) {
337                $externalMessages[] = 'questycaptcha-create';
338            }
339
340            if ( $extReg->isLoaded( 'FancyCaptcha' ) ) {
341                $externalMessages[] = 'fancycaptcha-create';
342                $externalMessages[] = 'fancycaptcha-reload-text';
343            }
344        }
345
346        $resourceLoader->register( [
347            'mw.cx.externalmessages' => $cxResourceTemplate + [
348                'messages' => $externalMessages,
349            ]
350        ] );
351    }
352
353    /**
354     * Hooks: ListDefinedTags
355     * Define the content translation change tag
356     * @param array &$tags
357     */
358    public function onListDefinedTags( &$tags ) {
359        self::registerTags( $tags );
360    }
361
362    /**
363     * Hooks: ChangeTagsListActive
364     * Mart the content translation change tag as active
365     * @param array &$tags
366     */
367    public function onChangeTagsListActive( &$tags ) {
368        self::registerTags( $tags );
369    }
370
371    public static function registerTags( array &$tags ) {
372        global $wgContentTranslationCampaigns;
373        $tags[] = 'contenttranslation';
374        $tags[] = 'contenttranslation-v2'; // CX2 distinct tag. Used since 2018-09
375        $tags[] = 'sectiontranslation';
376        $tags[] = 'contenttranslation-high-unmodified-mt-text';
377        foreach ( $wgContentTranslationCampaigns as $tagName => $tag ) {
378            if ( isset( $tag['edittag'] ) ) {
379                $tags[] = $tag['edittag'];
380            }
381        }
382    }
383
384    /**
385     * Hook: EditPage::showEditForm:initial
386     * @param EditPage $newPage
387     * @param OutputPage $out
388     */
389    public function onEditPage__showEditForm_initial( $newPage, $out ) {
390        global $wgContentTranslationAsBetaFeature, $wgContentTranslationCampaigns;
391
392        $user = $out->getUser();
393        /** @var PreferenceHelper $preferenceHelper */
394        $preferenceHelper = MediaWikiServices::getInstance()->getService( 'ContentTranslation.PreferenceHelper' );
395        if ( $preferenceHelper->isCXEntrypointDisabled( $user ) ) {
396            return;
397        }
398
399        $isValidEditContext = $user->isRegistered() &&
400            !$newPage->getTitle()->exists() &&
401            $newPage->getTitle()->inNamespace( NS_MAIN );
402
403        if ( !$isValidEditContext ) {
404            return;
405        }
406
407        $veConfig = MediaWikiServices::getInstance()->getConfigFactory()
408            ->makeConfig( 'visualeditor' );
409        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
410        if ( $veConfig->get( 'VisualEditorShowBetaWelcome' ) &&
411            !$userOptionsLookup->getOption( $user, 'visualeditor-hidebetawelcome' )
412        ) {
413            // VisualEditorShowBetaWelcome is enabled and user has not
414            // seen the visualeditor yet. So when edit page is loaded
415            // VE user guiding dialogs will appear. We don't want to mix
416            // that with our invitations.
417            return;
418        }
419
420        if ( $wgContentTranslationAsBetaFeature === false &&
421            // CX is enabled for everybody. Not a beta feature.
422            self::isPotentialTranslator( $user )
423        ) {
424            $modules = [ 'ext.cx.eventlogging.campaigns' ];
425
426            // "firsttime" property is true the first time the edit form is rendered,
427            // and it's false after re-rendering with preview, diff, save prompts, etc.
428            // Here we only want to display the invitation when not in "preview" or "diff" mode.
429            if ( $newPage->firsttime ) {
430                $modules[] = 'ext.cx.entrypoints.newbytranslation';
431            }
432
433            $out->addModules( $modules );
434            $invitationShown = $preferenceHelper->getGlobalPreference(
435                $user,
436                'cx_campaign_newarticle_shown'
437            );
438            /** @var TranslatorService $translatorService */
439            $translatorService = MediaWikiServices::getInstance()->get( 'ContentTranslation.TranslatorService' );
440            $existingTranslator = $translatorService->isTranslator( $user );
441            $out->addJsConfigVars( [
442                'wgContentTranslationNewByTranslationShown' => $invitationShown,
443                'wgContentTranslationExistingTranslator' => $existingTranslator,
444            ] );
445            return;
446        }
447
448        if ( $wgContentTranslationAsBetaFeature &&
449            // CX is a beta feature
450            !$preferenceHelper->isBetaFeatureEnabled( $user ) &&
451            $wgContentTranslationCampaigns['newarticle'] &&
452            // The below cookie reading does not use default cookie prefix for historical reasons
453            !$out->getRequest()->getCookie( 'cx_campaign_newarticle_hide', '' )
454        ) {
455            // CX is a beta feature in this wiki and user has not enabled it.
456            $out->addModules( [
457                'ext.cx.entrypoints.newarticle',
458                'ext.cx.eventlogging.campaigns'
459            ] );
460        }
461    }
462
463    /**
464     * Hook: User::SaveUserOptions
465     * @see https://www.mediawiki.org/wiki/Manual:Hooks/SaveUserOptions
466     *
467     * @param UserIdentity $user
468     * @param array &$modifiedOptions
469     * @param array $originalOptions
470     */
471    public function onSaveUserOptions( UserIdentity $user, array &$modifiedOptions, array $originalOptions ) {
472        $out = RequestContext::getMain()->getOutput();
473
474        $mergedOptions = array_merge( $originalOptions, $modifiedOptions );
475
476        if ( !isset( $mergedOptions['cx'] ) || $mergedOptions['cx'] !== 1 ) {
477            // Not using ContentTranslation; bail.
478            return;
479        }
480
481        if ( isset( $mergedOptions['cx-know'] ) ) {
482            // The auto-open contribution menu has already been shown; bail.
483            return;
484        }
485
486        $title = $out->getTitle();
487        if ( $title && $title->isSpecial( 'ContentTranslation' ) ) {
488            // Don't show the menu on Special:ContentTranslation.
489            return;
490        }
491
492        // Show the auto-open contribution menu and set the cx-know preference
493        // as true to prevent it from being automatically shown in the future.
494        if ( !self::isMobileView() ) {
495            $out->addModules( [
496                'ext.cx.betafeature.init',
497                'ext.cx.entrypoints.contributionsmenu',
498            ] );
499        }
500        $modifiedOptions['cx-know'] = true;
501    }
502
503    /**
504     * Add notification events to Echo
505     *
506     * @param array &$notifications array of Echo notifications
507     * @param array &$notificationCategories array of Echo notification categories
508     * @param array &$icons array of icon details
509     */
510    public static function onBeforeCreateEchoEvent(
511        &$notifications,
512        &$notificationCategories,
513        &$icons
514    ) {
515        $notificationCategories['cx'] = [
516            'priority' => 3,
517            'tooltip' => 'echo-pref-tooltip-cx',
518        ];
519
520        $userLocator = [
521            AttributeManager::ATTR_LOCATORS => [
522                [
523                    [ UserLocator::class, 'locateFromEventExtra' ],
524                    [ 'recipient' ]
525                ],
526            ],
527        ];
528
529        $notifications['cx-first-translation'] = [
530            'category' => 'cx',
531            'group' => 'positive',
532            'section' => 'message',
533            'presentation-model' => EchoNotificationPresentationModel::class,
534        ] + $userLocator;
535
536        $notifications['cx-tenth-translation'] = [
537            'category' => 'cx',
538            'group' => 'positive',
539            'section' => 'message',
540            'presentation-model' => EchoNotificationPresentationModel::class,
541        ] + $userLocator;
542
543        $notifications['cx-hundredth-translation'] = [
544            'category' => 'cx',
545            'group' => 'positive',
546            'section' => 'message',
547            'presentation-model' => EchoNotificationPresentationModel::class,
548        ] + $userLocator;
549
550        $notifications['cx-suggestions-available'] = [
551            'category' => 'cx',
552            'group' => 'positive',
553            'section' => 'message',
554            'presentation-model' => EchoNotificationPresentationModel::class,
555        ] + $userLocator;
556
557        $notifications['cx-deleted-draft'] = [
558            'category' => 'cx',
559            'group' => 'negative',
560            'section' => 'message',
561            'presentation-model' => DraftNotificationPresentationModel::class,
562            'bundle' => [ 'web' => true, 'expandable' => true ]
563        ] + $userLocator;
564
565        $notifications['cx-continue-translation'] = [
566            'category' => 'cx',
567            'group' => 'positive',
568            'section' => 'message',
569            'presentation-model' => DraftNotificationPresentationModel::class,
570            'bundle' => [ 'web' => true, 'expandable' => true ]
571        ] + $userLocator;
572
573        $icons['cx'] = [
574            'path' => 'ContentTranslation/images/cx-notification-green.svg',
575        ];
576        $icons['cx-blue'] = [
577            'path' => 'ContentTranslation/images/cx-notification-blue.svg'
578        ];
579        $icons['outdated'] = [
580            'path' => 'ContentTranslation/images/cx-notification-gray.svg'
581        ];
582    }
583
584    /**
585     * Set bundle for message
586     *
587     * @param Event $event
588     * @param string &$bundleString
589     */
590    public static function onEchoGetBundleRules( $event, &$bundleString ) {
591        $recipient = $event->getExtraParam( 'recipient' );
592        if ( !$recipient ) {
593            return;
594        }
595
596        if ( $event->getType() === 'cx-deleted-draft' ) {
597            $bundleString = 'cx-deleted-draft-' . $recipient;
598        }
599
600        if ( $event->getType() === 'cx-continue-translation' ) {
601            $bundleString = 'cx-continue-translation-' . $recipient;
602        }
603    }
604
605    /**
606     * Hook: Preferences::GetPreferences
607     * @param User $user
608     * @param array &$preferences
609     */
610    public function onGetPreferences( $user, &$preferences ) {
611        global $wgContentTranslationAsBetaFeature;
612
613        if ( $wgContentTranslationAsBetaFeature === false ) {
614            $preferences['cx-enable-entrypoints'] = [
615                'type' => 'check',
616                'section' => 'rendering/languages',
617                'label-message' => [
618                    'cx-preference-enable-entrypoints',
619                    'mediawikiwiki:Special:MyLanguage/Help:Content_translation/Starting'
620                ]
621            ];
622        }
623
624        $preferences['cx-entrypoint-fd-status'] = [
625            'type' => 'api',
626        ];
627        $preferences['cx_campaign_newarticle_shown'] = [
628            'type' => 'api',
629        ];
630
631        $preferences['cx-dashboard'] = [
632            'type' => 'api',
633        ];
634    }
635
636    /**
637     * Integrate Vite's HMR based development workflow if enabled by configuration.
638     *
639     * @param ResourceLoaderContext $context
640     * @param Config $config
641     * @param array $paths
642     * @return ResourceLoaderFilePath
643     */
644    public static function devModeCallback( ResourceLoaderContext $context, Config $config, array $paths ) {
645        [ $buildPath, $devPath ] = $paths;
646        $file = $buildPath;
647        if ( $config->get( 'ContentTranslationDevMode' ) ) {
648            $file = $devPath;
649        }
650        return new ResourceLoaderFilePath( $file );
651    }
652
653    /**
654     * Add a persistent contribution entry point for creating translations
655     * Hook: ContributeCards
656     * @param array &$cards List of contribute cards data
657     */
658    public function onContributeCards( array &$cards ): void {
659        $context = RequestContext::getMain();
660
661        if ( self::isMobileView() && !self::isSXEnabled() ) {
662            // This entrypoint should only be enabled for wikis that have SectionTranslation enabled
663            return;
664        }
665
666        $cards[] = ( new ContributeCard(
667            $context->msg( 'cx-contributecard-entrypoint-title' )->text(),
668            $context->msg( 'cx-contributecard-entrypoint-desc' )->text(),
669            'language', // icon
670            new ContributeCardActionLink(
671                // The CX beta feature is automatically enabled, when a valid campaign param exists.
672                // This enablement is done by a call to "SpecialContentTranslation::enableCXBetaFeature" method
673                SpecialPage::getTitleFor( 'ContentTranslation' )
674                    ->getLocalUrl( [ 'campaign' => 'specialcontribute' ] ),
675                $context->msg( 'cx-contributecard-entrypoint-cta' )->text(),
676            )
677        ) )->toArray();
678        // 'language' icon is in oojs-ui.styles.icons-editing-advanced RL module. Load that.
679        $out = $context->getOutput();
680        $out->addModuleStyles( [ 'oojs-ui.styles.icons-editing-advanced' ] );
681    }
682
683}