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