Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 304
0.00% covered (danger)
0.00%
0 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
0.00% covered (danger)
0.00%
0 / 304
0.00% covered (danger)
0.00%
0 / 20
8930
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 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 addModules
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
420
 onGetBetaFeaturePreferences
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 onSpecialContributionsBeforeMainOutput
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
 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 / 8
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
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
2
 onEchoGetBundleRules
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 onGetPreferences
0.00% covered (danger)
0.00%
0 / 16
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 * Hooks for ContentTranslation extension.
4 *
5 * @copyright See AUTHORS.txt
6 * @license GPL-2.0-or-later
7 */
8namespace ContentTranslation;
9
10// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName
11
12use Config;
13use ContentTranslation\Service\TranslatorService;
14use EchoAttributeManager;
15use EchoEvent;
16use EchoUserLocator;
17use ExtensionRegistry;
18use MediaWiki\ChangeTags\Hook\ChangeTagsListActiveHook;
19use MediaWiki\ChangeTags\Hook\ListDefinedTagsHook;
20use MediaWiki\EditPage\EditPage;
21use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
22use MediaWiki\Hook\BeforePageDisplayHook;
23use MediaWiki\Hook\EditPage__showEditForm_initialHook;
24use MediaWiki\Hook\SpecialContributionsBeforeMainOutputHook;
25use MediaWiki\MediaWikiServices;
26use MediaWiki\Permissions\PermissionManager;
27use MediaWiki\Preferences\Hook\GetPreferencesHook;
28use MediaWiki\ResourceLoader\Context as ResourceLoaderContext;
29use MediaWiki\ResourceLoader\FilePath as ResourceLoaderFilePath;
30use MediaWiki\ResourceLoader\Hook\ResourceLoaderRegisterModulesHook;
31use MediaWiki\ResourceLoader\ResourceLoader;
32use MediaWiki\Specials\Contribute\Card\ContributeCard;
33use MediaWiki\Specials\Contribute\Card\ContributeCardActionLink;
34use MediaWiki\Specials\Contribute\ContributeFactory;
35use MediaWiki\Specials\Contribute\Hook\ContributeCardsHook;
36use MediaWiki\User\Options\Hook\SaveUserOptionsHook;
37use MediaWiki\User\UserIdentity;
38use MediaWiki\WikiMap\WikiMap;
39use OutputPage;
40use RequestContext;
41use Skin;
42use SpecialPage;
43use User;
44
45class Hooks implements
46    BeforePageDisplayHook,
47    GetPreferencesHook,
48    ResourceLoaderRegisterModulesHook,
49    SpecialContributionsBeforeMainOutputHook,
50    ListDefinedTagsHook,
51    ChangeTagsListActiveHook,
52    SaveUserOptionsHook,
53    EditPage__showEditForm_initialHook,
54    ContributeCardsHook
55{
56
57    /**
58     * @param OutputPage $out
59     * @param Skin $skin
60     */
61    public function onBeforePageDisplay( $out, $skin ): void {
62        self::addModules( $out, $skin );
63        self::addSXPublishingFollowupModule( $out, $skin );
64        self::addMobileNewByTranslationInvitation( $out, $skin );
65    }
66
67    /**
68     * Add 'sx.publishing.followup' module when output page is an article page
69     * or a page in user namespace(sandbox), and "sx-published-section" query
70     * params exists. This is the case for redirections to target article page
71     * after Section Translation successful publishing
72     * @param OutputPage $out
73     * @param Skin $skin
74     */
75    public static function addSXPublishingFollowupModule( OutputPage $out, Skin $skin ): void {
76        $sxPublishedQueryParam = $out->getRequest()->getVal( "sx-published-section" );
77        $isContentPage = $out->getTitle()->isContentPage();
78        $isSandboxPage = $out->getTitle()->inNamespace( NS_USER );
79        if ( ( $isContentPage || $isSandboxPage ) && $sxPublishedQueryParam !== null ) {
80            $out->addModules( 'sx.publishing.followup' );
81        }
82    }
83
84    public static function addMobileNewByTranslationInvitation( OutputPage $out, Skin $skin ): void {
85        // This entrypoint should only be enabled for mobile web version
86        if ( !self::isMobileView() ) {
87            return;
88        }
89
90        if ( !self::isSXEnabled() ) {
91            return;
92        }
93
94        // This entrypoint should only be enabled for logged-in users or wikis that
95        // have section translation enabled for anonymous users
96        $user = $out->getUser();
97        $isSxEnabledForAnon = $out->getConfig()->get( 'ContentTranslationEnableAnonSectionTranslation' );
98        if ( !$user->isNamed() && !$isSxEnabledForAnon ) {
99            return;
100        }
101
102        $isValidContext = !$out->getTitle()->exists() && $out->getTitle()->inNamespace( NS_MAIN );
103
104        if ( !$isValidContext ) {
105            return;
106        }
107
108        $out->addModules( 'ext.cx.entrypoints.newbytranslation.mobile' );
109    }
110
111    /**
112     * Check whether the current context is in a mobile interface
113     *
114     * @return bool
115     */
116    private static function isMobileView() {
117        $isMobileView = false;
118
119        if ( ExtensionRegistry::getInstance()->isLoaded( 'MobileFrontend' ) ) {
120            $mobileContext = MediaWikiServices::getInstance()->getService( 'MobileFrontend.Context' );
121            $isMobileView = $mobileContext->shouldDisplayMobileView();
122        }
123        return $isMobileView;
124    }
125
126    /**
127     * Check whether SectionTranslation is enabled in current wiki
128     *
129     * @return bool
130     */
131    private static function isSXEnabled() {
132        $out = RequestContext::getMain()->getOutput();
133        $currentLanguageCode = SiteMapper::getCurrentLanguageCode();
134        $enabledLanguages = $out->getConfig()->get( 'SectionTranslationTargetLanguages' );
135        return is_array( $enabledLanguages ) && in_array( $currentLanguageCode, $enabledLanguages );
136    }
137
138    /**
139     * Check whether the current user is a potential translator
140     *
141     * @param User $user
142     * @return bool
143     */
144    private static function isPotentialTranslator( User $user ) {
145        /** @var TranslatorService $translatorService */
146        $translatorService = MediaWikiServices::getInstance()->get( 'ContentTranslation.TranslatorService' );
147
148        if ( $translatorService->isTranslator( $user ) ) {
149            // Already a translator
150            return true;
151        }
152
153        if ( ExtensionRegistry::getInstance()->isLoaded( 'CentralAuth' ) ) {
154            $centralUser = CentralAuthUser::getInstance( $user );
155            if ( !$centralUser ) {
156                return false;
157            }
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            $wgSectionTranslationTargetLanguages;
192
193        $title = $out->getTitle();
194        $user = $out->getUser();
195
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' ) || $title->isSpecial( 'ContentTranslationStats' )
205        ) {
206            // Entry point modules need not be shown in CX special pages
207            return;
208        }
209
210        if ( self::isMobileView() && $wgSectionTranslationTargetLanguages ) {
211                $out->addModules( 'ext.cx.entrypoints.languagesearcher.init' );
212                $out->addJsConfigVars( 'wgSectionTranslationTargetLanguages',
213                    $wgSectionTranslationTargetLanguages );
214        }
215
216        $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
217
218        // Load the new article campaign for VisualEditor if it's relevant.
219        // Done separately from loading the newarticle campaign for the
220        // wiki syntax editor because of the different actions with which
221        // the editing page is loaded.
222        if ( !$preferenceHelper->isEnabledForUser( $user ) ) {
223            if (
224                !$title->exists() &&
225                $wgContentTranslationCampaigns['newarticle'] &&
226                !$out->getRequest()->getCookie( 'cx_campaign_newarticle_hide', '' ) &&
227                $title->inNamespace( NS_MAIN ) &&
228                $user->isRegistered() &&
229                $permissionManager->userCan( 'edit', $user, $title, PermissionManager::RIGOR_QUICK )
230            ) {
231                $out->addModules( 'ext.cx.entrypoints.newarticle.veloader' );
232            }
233
234            return;
235        }
236
237        if ( $title->inNamespace( NS_MAIN ) &&
238            $out->getActionName() === 'view' &&
239            $title->exists() &&
240            in_array( $skin->getSkinName(), [ 'vector', 'vector-2022' ] )
241        ) {
242            $out->addJsConfigVars( [
243                'wgContentTranslationAsBetaFeature' =>
244                    $wgContentTranslationAsBetaFeature,
245            ] );
246            $out->addModules( 'ext.cx.interlanguagelink.init' );
247        }
248
249        // Add a hover menu for the contributions link in personal toolbar
250        if ( !self::isMobileView() ) {
251            $out->addModules( 'ext.cx.entrypoints.contributionsmenu' );
252        }
253
254        if ( $preferenceHelper->getGlobalPreference( $user, 'cx-entrypoint-fd-status' ) === 'pending' ) {
255            // A translation was initialized based on a campaign. Show the feature discovery
256            $out->addJsConfigVars( 'wgContentTranslationEntryPointFD', true );
257        }
258    }
259
260    /**
261     * Hook: GetBetaFeaturePreferences
262     * @param User $user
263     * @param array[] &$prefs
264     */
265    public static function onGetBetaFeaturePreferences( User $user, array &$prefs ) {
266        global $wgExtensionAssetsPath, $wgContentTranslationAsBetaFeature;
267
268        if ( !$wgContentTranslationAsBetaFeature ) {
269            return;
270        }
271
272        $imageDir = "$wgExtensionAssetsPath/ContentTranslation/images";
273
274        $prefs['cx'] = [
275            'label-message' => 'cx-beta',
276            'desc-message' => 'cx-beta-desc',
277            'screenshot' => [
278                'ltr' => "$imageDir/cx-icon-ltr.svg",
279                'rtl' => "$imageDir/cx-icon-rtl.svg",
280            ],
281            'info-link' => 'https://www.mediawiki.org/wiki/Special:MyLanguage/Content_translation',
282            'discussion-link' => 'https://www.mediawiki.org/wiki/Talk:Content_translation',
283            'requirements' => [
284                'javascript' => true,
285            ]
286        ];
287    }
288
289    /**
290     * Hook: SpecialContributionsBeforeMainOutput
291     * @param int $id
292     * @param UserIdentity $user
293     * @param SpecialPage $page
294     */
295    public function onSpecialContributionsBeforeMainOutput( $id, $user, $page ) {
296        $preferenceHelper = MediaWikiServices::getInstance()->getService( 'ContentTranslation.PreferenceHelper' );
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        if ( $user->getId() !== $page->getUser()->getId() || !$preferenceHelper->isEnabledForUser( $user ) ) {
307            return;
308        }
309
310        $modules = [ 'ext.cx.eventlogging.campaigns' ];
311
312        $isSpecialContributeEnabled = ContributeFactory::isEnabledOnCurrentSkin(
313            $page->getSkin(),
314            $page->getConfig()->get( 'SpecialContributeSkinsEnabled' )
315        );
316
317        if ( !$isSpecialContributeEnabled ) {
318            $modules[] = 'ext.cx.contributions';
319        }
320        $page->getOutput()->addModules( $modules );
321    }
322
323    /**
324     * Hook: ResourceLoaderRegisterModules
325     *
326     * @param ResourceLoader $resourceLoader Client-side code and assets to be loaded.
327     */
328    public function onResourceLoaderRegisterModules( ResourceLoader $resourceLoader ): void {
329        $cxResourceTemplate = [
330            'localBasePath' => dirname( __DIR__ ),
331            'remoteExtPath' => 'ContentTranslation',
332        ];
333
334        $externalMessages = [];
335        $extReg = ExtensionRegistry::getInstance();
336        if ( $extReg->isLoaded( 'ConfirmEdit' ) ) {
337            $externalMessages[] = 'captcha-create';
338            $externalMessages[] = 'captcha-label';
339
340            if ( $extReg->isLoaded( 'QuestyCaptcha' ) ) {
341                $externalMessages[] = 'questycaptcha-create';
342            }
343
344            if ( $extReg->isLoaded( 'FancyCaptcha' ) ) {
345                $externalMessages[] = 'fancycaptcha-create';
346                $externalMessages[] = 'fancycaptcha-reload-text';
347            }
348        }
349
350        $resourceLoader->register( [
351            'mw.cx.externalmessages' => $cxResourceTemplate + [
352                'messages' => $externalMessages,
353            ]
354        ] );
355    }
356
357    /**
358     * Hooks: ListDefinedTags
359     * Define the content translation change tag
360     * @param array &$tags
361     */
362    public function onListDefinedTags( &$tags ) {
363        self::registerTags( $tags );
364    }
365
366    /**
367     * Hooks: ChangeTagsListActive
368     * Mart the content translation change tag as active
369     * @param array &$tags
370     */
371    public function onChangeTagsListActive( &$tags ) {
372        self::registerTags( $tags );
373    }
374
375    public static function registerTags( array &$tags ) {
376        global $wgContentTranslationCampaigns;
377        $tags[] = 'contenttranslation';
378        $tags[] = 'contenttranslation-v2'; // CX2 distinct tag. Used since 2018-09
379        $tags[] = 'sectiontranslation';
380        $tags[] = 'contenttranslation-high-unmodified-mt-text';
381        foreach ( $wgContentTranslationCampaigns as $tagName => $tag ) {
382            if ( isset( $tag['edittag'] ) ) {
383                $tags[] = $tag['edittag'];
384            }
385        }
386    }
387
388    /**
389     * Hook: EditPage::showEditForm:initial
390     * @param EditPage $newPage
391     * @param OutputPage $out
392     */
393    public function onEditPage__showEditForm_initial( $newPage, $out ) {
394        global $wgContentTranslationAsBetaFeature, $wgContentTranslationCampaigns;
395
396        $user = $out->getUser();
397        $preferenceHelper = MediaWikiServices::getInstance()->getService( 'ContentTranslation.PreferenceHelper' );
398        if ( $preferenceHelper->isCXEntrypointDisabled( $user ) ) {
399            return;
400        }
401
402        $isValidEditContext = $user->isRegistered() &&
403            !$newPage->getTitle()->exists() &&
404            $newPage->getTitle()->inNamespace( NS_MAIN );
405
406        if ( !$isValidEditContext ) {
407            return;
408        }
409
410        $veConfig = MediaWikiServices::getInstance()->getConfigFactory()
411            ->makeConfig( 'visualeditor' );
412        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
413        if ( $veConfig->get( 'VisualEditorShowBetaWelcome' ) &&
414            !$userOptionsLookup->getOption( $user, 'visualeditor-hidebetawelcome' )
415        ) {
416            // VisualEditorShowBetaWelcome is enabled and user has not
417            // seen the visualeditor yet. So when edit page is loaded
418            // VE user guiding dialogs will appear. We don't want to mix
419            // that with our invitations.
420            return;
421        }
422
423        if ( $wgContentTranslationAsBetaFeature === false &&
424            // CX is enabled for everybody. Not a beta feature.
425            self::isPotentialTranslator( $user )
426        ) {
427            $modules = [ 'ext.cx.eventlogging.campaigns' ];
428
429            // "firsttime" property is true the first time the edit form is rendered,
430            // and it's false after re-rendering with preview, diff, save prompts, etc.
431            // Here we only want to display the invitation when not in "preview" or "diff" mode.
432            if ( $newPage->firsttime ) {
433                $modules[] = 'ext.cx.entrypoints.newbytranslation';
434            }
435
436            $out->addModules( $modules );
437            $invitationShown = $preferenceHelper->getGlobalPreference(
438                $user, 'cx_campaign_newarticle_shown'
439            );
440            /** @var TranslatorService $translatorService */
441            $translatorService = MediaWikiServices::getInstance()->get( 'ContentTranslation.TranslatorService' );
442            $existingTranslator = $translatorService->isTranslator( $user );
443            $out->addJsConfigVars( [
444                'wgContentTranslationNewByTranslationShown' => $invitationShown,
445                'wgContentTranslationExistingTranslator' => $existingTranslator,
446            ] );
447            return;
448        }
449
450        if ( $wgContentTranslationAsBetaFeature &&
451            // CX is a beta feature
452            !$preferenceHelper->isBetaFeatureEnabled( $user ) &&
453            $wgContentTranslationCampaigns['newarticle'] &&
454            // The below cookie reading does not use default cookie prefix for historical reasons
455            !$out->getRequest()->getCookie( 'cx_campaign_newarticle_hide', '' )
456        ) {
457            // CX is a beta feature in this wiki and user has not enabled it.
458            $out->addModules( [
459                'ext.cx.entrypoints.newarticle',
460                'ext.cx.eventlogging.campaigns'
461            ] );
462        }
463    }
464
465    /**
466     * Hook: User::SaveUserOptions
467     * @see https://www.mediawiki.org/wiki/Manual:Hooks/SaveUserOptions
468     *
469     * @param UserIdentity $user
470     * @param array &$modifiedOptions
471     * @param array $originalOptions
472     */
473    public function onSaveUserOptions( UserIdentity $user, array &$modifiedOptions, array $originalOptions ) {
474        $out = RequestContext::getMain()->getOutput();
475
476        $mergedOptions = array_merge( $originalOptions, $modifiedOptions );
477
478        if ( !isset( $mergedOptions['cx'] ) || $mergedOptions['cx'] !== 1 ) {
479            // Not using ContentTranslation; bail.
480            return;
481        }
482
483        if ( isset( $mergedOptions['cx-know'] ) ) {
484            // The auto-open contribution menu has already been shown; bail.
485            return;
486        }
487
488        $title = $out->getTitle();
489        if ( $title && $title->isSpecial( 'ContentTranslation' ) ) {
490            // Don't show the menu on Special:ContentTranslation.
491            return;
492        }
493
494        // Show the auto-open contribution menu and set the cx-know preference
495        // as true to prevent it from being automatically shown in the future.
496        if ( !self::isMobileView() ) {
497            $out->addModules( [
498                'ext.cx.betafeature.init',
499                'ext.cx.entrypoints.contributionsmenu',
500            ] );
501        }
502        $modifiedOptions['cx-know'] = true;
503    }
504
505    /**
506     * Add notification events to Echo
507     *
508     * @param array &$notifications array of Echo notifications
509     * @param array &$notificationCategories array of Echo notification categories
510     * @param array &$icons array of icon details
511     */
512    public static function onBeforeCreateEchoEvent(
513        &$notifications, &$notificationCategories, &$icons
514    ) {
515        $notificationCategories['cx'] = [
516            'priority' => 3,
517            'tooltip' => 'echo-pref-tooltip-cx',
518        ];
519
520        $userLocator = [
521            EchoAttributeManager::ATTR_LOCATORS => [
522                [
523                    [ EchoUserLocator::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 EchoEvent $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
632    /**
633     * Integrate Vite's HMR based development workflow if enabled by configuration.
634     *
635     * @param ResourceLoaderContext $context
636     * @param Config $config
637     * @param array $paths
638     * @return ResourceLoaderFilePath
639     */
640    public static function devModeCallback( ResourceLoaderContext $context, Config $config, array $paths ) {
641        [ $buildPath, $devPath ] = $paths;
642        $file = $buildPath;
643        if ( $config->get( 'ContentTranslationDevMode' ) ) {
644            $file = $devPath;
645        }
646        return new ResourceLoaderFilePath( $file );
647    }
648
649    /**
650     * Add a persistent contribution entry point for creating translations
651     * Hook: ContributeCards
652     * @param array &$cards List of contribute cards data
653     */
654    public function onContributeCards( array &$cards ): void {
655        $context = RequestContext::getMain();
656
657        if ( self::isMobileView() && !self::isSXEnabled() ) {
658            // This entrypoint should only be enabled for wikis that have SectionTranslation enabled
659            return;
660        }
661
662        $cards[] = ( new ContributeCard(
663            $context->msg( 'cx-contributecard-entrypoint-title' )->text(),
664            $context->msg( 'cx-contributecard-entrypoint-desc' )->text(),
665            'language', // icon
666            new ContributeCardActionLink(
667                // The CX beta feature is automatically enabled, when a valid campaign param exists.
668                // This enablement is done by a call to "SpecialContentTranslation::enableCXBetaFeature" method
669                SpecialPage::getTitleFor( 'ContentTranslation' )
670                    ->getLocalUrl( [ 'campaign' => 'specialcontribute' ] ),
671                $context->msg( 'cx-contributecard-entrypoint-cta' )->text(),
672            )
673        ) )->toArray();
674        // 'language' icon is in oojs-ui.styles.icons-editing-advanced RL module. Load that.
675        $out = $context->getOutput();
676        $out->addModuleStyles( [ 'oojs-ui.styles.icons-editing-advanced' ] );
677    }
678
679}