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