Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 299
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 / 299
0.00% covered (danger)
0.00%
0 / 20
8556
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 / 33
0.00% covered (danger)
0.00%
0 / 1
342
 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 ContentTranslation\Service\TranslatorService;
13use ExtensionRegistry;
14use MediaWiki\ChangeTags\Hook\ChangeTagsListActiveHook;
15use MediaWiki\ChangeTags\Hook\ListDefinedTagsHook;
16use MediaWiki\Config\Config;
17use MediaWiki\Context\RequestContext;
18use MediaWiki\EditPage\EditPage;
19use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
20use MediaWiki\Extension\Notifications\AttributeManager;
21use MediaWiki\Extension\Notifications\Model\Event;
22use MediaWiki\Extension\Notifications\UserLocator;
23use MediaWiki\Hook\EditPage__showEditForm_initialHook;
24use MediaWiki\Hook\SpecialContributionsBeforeMainOutputHook;
25use MediaWiki\MediaWikiServices;
26use MediaWiki\Output\Hook\BeforePageDisplayHook;
27use MediaWiki\Output\OutputPage;
28use MediaWiki\Permissions\PermissionManager;
29use MediaWiki\Preferences\Hook\GetPreferencesHook;
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            if ( !$centralUser ) {
158                return false;
159            }
160
161            // Check if the user has edited in more than one wiki.
162            $editedWikiCount = 0;
163            $attachedAccounts = $centralUser->queryAttached();
164            foreach ( $attachedAccounts as $wikiId => $account ) {
165                $wikiRef = WikiMap::getWiki( $wikiId ); // Get WikiReference instance
166                $url = '';
167                if ( $wikiRef ) {
168                    $url = $wikiRef->getCanonicalServer();
169                }
170                if (
171                    // Ignore non-wikipedia wikis such as commons, mediawiki, meta etc
172                    // url property example "https://commons.wikimedia.org",
173                    strpos( $url, 'wikipedia' ) !== false &&
174                    intval( $account['editCount'] ?? 0 ) > 0
175                ) {
176                    $editedWikiCount++;
177                }
178            }
179
180            return $editedWikiCount > 1;
181        }
182
183        return false;
184    }
185
186    /**
187     * Hook: BeforePageDisplay
188     * @param OutputPage $out
189     * @param Skin $skin
190     */
191    public static function addModules( OutputPage $out, Skin $skin ) {
192        global $wgContentTranslationAsBetaFeature, $wgContentTranslationCampaigns;
193
194        $title = $out->getTitle();
195        $user = $out->getUser();
196
197        /** @var PreferenceHelper $preferenceHelper */
198        $preferenceHelper = MediaWikiServices::getInstance()->getService( 'ContentTranslation.PreferenceHelper' );
199        if ( $preferenceHelper->isCXEntrypointDisabled( $user ) ) {
200            return;
201        }
202
203        $out->addModules( 'ext.cx.eventlogging.campaigns' );
204
205        if ( !$title ||
206            $title->isSpecial( 'ContentTranslation' ) || $title->isSpecial( 'ContentTranslationStats' )
207        ) {
208            // Entry point modules need not be shown in CX special pages
209            return;
210        }
211
212        $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
213
214        // Load the new article campaign for VisualEditor if it's relevant.
215        // Done separately from loading the newarticle campaign for the
216        // wiki syntax editor because of the different actions with which
217        // the editing page is loaded.
218        if ( !$preferenceHelper->isEnabledForUser( $user ) ) {
219            if (
220                !$title->exists() &&
221                $wgContentTranslationCampaigns['newarticle'] &&
222                !$out->getRequest()->getCookie( 'cx_campaign_newarticle_hide', '' ) &&
223                $title->inNamespace( NS_MAIN ) &&
224                $user->isRegistered() &&
225                $permissionManager->userCan( 'edit', $user, $title, PermissionManager::RIGOR_QUICK )
226            ) {
227                $out->addModules( 'ext.cx.entrypoints.newarticle.veloader' );
228            }
229
230            return;
231        }
232
233        if ( $title->inNamespace( NS_MAIN ) &&
234            $out->getActionName() === 'view' &&
235            $title->exists() &&
236            in_array( $skin->getSkinName(), [ 'vector', 'vector-2022' ] )
237        ) {
238            $out->addJsConfigVars( [
239                'wgContentTranslationAsBetaFeature' =>
240                    $wgContentTranslationAsBetaFeature,
241            ] );
242            $out->addModules( 'ext.cx.interlanguagelink.init' );
243        }
244
245        // Add a hover menu for the contributions link in personal toolbar
246        if ( !self::isMobileView() ) {
247            $out->addModules( 'ext.cx.entrypoints.contributionsmenu' );
248        }
249
250        if ( $preferenceHelper->getGlobalPreference( $user, 'cx-entrypoint-fd-status' ) === 'pending' ) {
251            // A translation was initialized based on a campaign. Show the feature discovery
252            $out->addJsConfigVars( 'wgContentTranslationEntryPointFD', true );
253        }
254    }
255
256    /**
257     * Hook: GetBetaFeaturePreferences
258     * @param User $user
259     * @param array[] &$prefs
260     */
261    public static function onGetBetaFeaturePreferences( User $user, array &$prefs ) {
262        global $wgExtensionAssetsPath, $wgContentTranslationAsBetaFeature;
263
264        if ( !$wgContentTranslationAsBetaFeature ) {
265            return;
266        }
267
268        $imageDir = "$wgExtensionAssetsPath/ContentTranslation/images";
269
270        $prefs['cx'] = [
271            'label-message' => 'cx-beta',
272            'desc-message' => 'cx-beta-desc',
273            'screenshot' => [
274                'ltr' => "$imageDir/cx-icon-ltr.svg",
275                'rtl' => "$imageDir/cx-icon-rtl.svg",
276            ],
277            'info-link' => 'https://www.mediawiki.org/wiki/Special:MyLanguage/Content_translation',
278            'discussion-link' => 'https://www.mediawiki.org/wiki/Talk:Content_translation',
279            'requirements' => [
280                'javascript' => true,
281            ]
282        ];
283    }
284
285    /**
286     * Hook: SpecialContributionsBeforeMainOutput
287     * @param int $id
288     * @param UserIdentity $user
289     * @param SpecialPage $page
290     */
291    public function onSpecialContributionsBeforeMainOutput( $id, $user, $page ) {
292        /** @var PreferenceHelper $preferenceHelper */
293        $preferenceHelper = MediaWikiServices::getInstance()->getService( 'ContentTranslation.PreferenceHelper' );
294        if ( $preferenceHelper->isCXEntrypointDisabled( $user ) ) {
295            return;
296        }
297
298        if ( self::isMobileView() ) {
299            // Contribution buttons should be shown only in desktop
300            return;
301        }
302
303        if ( $user->getId() !== $page->getUser()->getId() || !$preferenceHelper->isEnabledForUser( $user ) ) {
304            return;
305        }
306
307        $modules = [ 'ext.cx.eventlogging.campaigns' ];
308
309        $isSpecialContributeEnabled = ContributeFactory::isEnabledOnCurrentSkin(
310            $page->getSkin(),
311            $page->getConfig()->get( 'SpecialContributeSkinsEnabled' )
312        );
313
314        if ( !$isSpecialContributeEnabled ) {
315            $modules[] = 'ext.cx.contributions';
316        }
317        $page->getOutput()->addModules( $modules );
318    }
319
320    /**
321     * Hook: ResourceLoaderRegisterModules
322     *
323     * @param ResourceLoader $resourceLoader Client-side code and assets to be loaded.
324     */
325    public function onResourceLoaderRegisterModules( ResourceLoader $resourceLoader ): void {
326        $cxResourceTemplate = [
327            'localBasePath' => dirname( __DIR__ ),
328            'remoteExtPath' => 'ContentTranslation',
329        ];
330
331        $externalMessages = [];
332        $extReg = ExtensionRegistry::getInstance();
333        if ( $extReg->isLoaded( 'ConfirmEdit' ) ) {
334            $externalMessages[] = 'captcha-create';
335            $externalMessages[] = 'captcha-label';
336
337            if ( $extReg->isLoaded( 'QuestyCaptcha' ) ) {
338                $externalMessages[] = 'questycaptcha-create';
339            }
340
341            if ( $extReg->isLoaded( 'FancyCaptcha' ) ) {
342                $externalMessages[] = 'fancycaptcha-create';
343                $externalMessages[] = 'fancycaptcha-reload-text';
344            }
345        }
346
347        $resourceLoader->register( [
348            'mw.cx.externalmessages' => $cxResourceTemplate + [
349                'messages' => $externalMessages,
350            ]
351        ] );
352    }
353
354    /**
355     * Hooks: ListDefinedTags
356     * Define the content translation change tag
357     * @param array &$tags
358     */
359    public function onListDefinedTags( &$tags ) {
360        self::registerTags( $tags );
361    }
362
363    /**
364     * Hooks: ChangeTagsListActive
365     * Mart the content translation change tag as active
366     * @param array &$tags
367     */
368    public function onChangeTagsListActive( &$tags ) {
369        self::registerTags( $tags );
370    }
371
372    public static function registerTags( array &$tags ) {
373        global $wgContentTranslationCampaigns;
374        $tags[] = 'contenttranslation';
375        $tags[] = 'contenttranslation-v2'; // CX2 distinct tag. Used since 2018-09
376        $tags[] = 'sectiontranslation';
377        $tags[] = 'contenttranslation-high-unmodified-mt-text';
378        foreach ( $wgContentTranslationCampaigns as $tagName => $tag ) {
379            if ( isset( $tag['edittag'] ) ) {
380                $tags[] = $tag['edittag'];
381            }
382        }
383    }
384
385    /**
386     * Hook: EditPage::showEditForm:initial
387     * @param EditPage $newPage
388     * @param OutputPage $out
389     */
390    public function onEditPage__showEditForm_initial( $newPage, $out ) {
391        global $wgContentTranslationAsBetaFeature, $wgContentTranslationCampaigns;
392
393        $user = $out->getUser();
394        /** @var PreferenceHelper $preferenceHelper */
395        $preferenceHelper = MediaWikiServices::getInstance()->getService( 'ContentTranslation.PreferenceHelper' );
396        if ( $preferenceHelper->isCXEntrypointDisabled( $user ) ) {
397            return;
398        }
399
400        $isValidEditContext = $user->isRegistered() &&
401            !$newPage->getTitle()->exists() &&
402            $newPage->getTitle()->inNamespace( NS_MAIN );
403
404        if ( !$isValidEditContext ) {
405            return;
406        }
407
408        $veConfig = MediaWikiServices::getInstance()->getConfigFactory()
409            ->makeConfig( 'visualeditor' );
410        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
411        if ( $veConfig->get( 'VisualEditorShowBetaWelcome' ) &&
412            !$userOptionsLookup->getOption( $user, 'visualeditor-hidebetawelcome' )
413        ) {
414            // VisualEditorShowBetaWelcome is enabled and user has not
415            // seen the visualeditor yet. So when edit page is loaded
416            // VE user guiding dialogs will appear. We don't want to mix
417            // that with our invitations.
418            return;
419        }
420
421        if ( $wgContentTranslationAsBetaFeature === false &&
422            // CX is enabled for everybody. Not a beta feature.
423            self::isPotentialTranslator( $user )
424        ) {
425            $modules = [ 'ext.cx.eventlogging.campaigns' ];
426
427            // "firsttime" property is true the first time the edit form is rendered,
428            // and it's false after re-rendering with preview, diff, save prompts, etc.
429            // Here we only want to display the invitation when not in "preview" or "diff" mode.
430            if ( $newPage->firsttime ) {
431                $modules[] = 'ext.cx.entrypoints.newbytranslation';
432            }
433
434            $out->addModules( $modules );
435            $invitationShown = $preferenceHelper->getGlobalPreference(
436                $user, '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, &$notificationCategories, &$icons
512    ) {
513        $notificationCategories['cx'] = [
514            'priority' => 3,
515            'tooltip' => 'echo-pref-tooltip-cx',
516        ];
517
518        $userLocator = [
519            AttributeManager::ATTR_LOCATORS => [
520                [
521                    [ UserLocator::class, 'locateFromEventExtra' ],
522                    [ 'recipient' ]
523                ],
524            ],
525        ];
526
527        $notifications['cx-first-translation'] = [
528            'category' => 'cx',
529            'group' => 'positive',
530            'section' => 'message',
531            'presentation-model' => EchoNotificationPresentationModel::class,
532        ] + $userLocator;
533
534        $notifications['cx-tenth-translation'] = [
535            'category' => 'cx',
536            'group' => 'positive',
537            'section' => 'message',
538            'presentation-model' => EchoNotificationPresentationModel::class,
539        ] + $userLocator;
540
541        $notifications['cx-hundredth-translation'] = [
542            'category' => 'cx',
543            'group' => 'positive',
544            'section' => 'message',
545            'presentation-model' => EchoNotificationPresentationModel::class,
546        ] + $userLocator;
547
548        $notifications['cx-suggestions-available'] = [
549            'category' => 'cx',
550            'group' => 'positive',
551            'section' => 'message',
552            'presentation-model' => EchoNotificationPresentationModel::class,
553        ] + $userLocator;
554
555        $notifications['cx-deleted-draft'] = [
556            'category' => 'cx',
557            'group' => 'negative',
558            'section' => 'message',
559            'presentation-model' => DraftNotificationPresentationModel::class,
560            'bundle' => [ 'web' => true, 'expandable' => true ]
561        ] + $userLocator;
562
563        $notifications['cx-continue-translation'] = [
564            'category' => 'cx',
565            'group' => 'positive',
566            'section' => 'message',
567            'presentation-model' => DraftNotificationPresentationModel::class,
568            'bundle' => [ 'web' => true, 'expandable' => true ]
569        ] + $userLocator;
570
571        $icons['cx'] = [
572            'path' => 'ContentTranslation/images/cx-notification-green.svg',
573        ];
574        $icons['cx-blue'] = [
575            'path' => 'ContentTranslation/images/cx-notification-blue.svg'
576        ];
577        $icons['outdated'] = [
578            'path' => 'ContentTranslation/images/cx-notification-gray.svg'
579        ];
580    }
581
582    /**
583     * Set bundle for message
584     *
585     * @param Event $event
586     * @param string &$bundleString
587     */
588    public static function onEchoGetBundleRules( $event, &$bundleString ) {
589        $recipient = $event->getExtraParam( 'recipient' );
590        if ( !$recipient ) {
591            return;
592        }
593
594        if ( $event->getType() === 'cx-deleted-draft' ) {
595            $bundleString = 'cx-deleted-draft-' . $recipient;
596        }
597
598        if ( $event->getType() === 'cx-continue-translation' ) {
599            $bundleString = 'cx-continue-translation-' . $recipient;
600        }
601    }
602
603    /**
604     * Hook: Preferences::GetPreferences
605     * @param User $user
606     * @param array &$preferences
607     */
608    public function onGetPreferences( $user, &$preferences ) {
609        global $wgContentTranslationAsBetaFeature;
610
611        if ( $wgContentTranslationAsBetaFeature === false ) {
612            $preferences['cx-enable-entrypoints'] = [
613                'type' => 'check',
614                'section' => 'rendering/languages',
615                'label-message' => [
616                    'cx-preference-enable-entrypoints',
617                    'mediawikiwiki:Special:MyLanguage/Help:Content_translation/Starting'
618                ]
619            ];
620        }
621
622        $preferences['cx-entrypoint-fd-status'] = [
623            'type' => 'api',
624        ];
625        $preferences['cx_campaign_newarticle_shown'] = [
626            'type' => 'api',
627        ];
628    }
629
630    /**
631     * Integrate Vite's HMR based development workflow if enabled by configuration.
632     *
633     * @param ResourceLoaderContext $context
634     * @param Config $config
635     * @param array $paths
636     * @return ResourceLoaderFilePath
637     */
638    public static function devModeCallback( ResourceLoaderContext $context, Config $config, array $paths ) {
639        [ $buildPath, $devPath ] = $paths;
640        $file = $buildPath;
641        if ( $config->get( 'ContentTranslationDevMode' ) ) {
642            $file = $devPath;
643        }
644        return new ResourceLoaderFilePath( $file );
645    }
646
647    /**
648     * Add a persistent contribution entry point for creating translations
649     * Hook: ContributeCards
650     * @param array &$cards List of contribute cards data
651     */
652    public function onContributeCards( array &$cards ): void {
653        $context = RequestContext::getMain();
654
655        if ( self::isMobileView() && !self::isSXEnabled() ) {
656            // This entrypoint should only be enabled for wikis that have SectionTranslation enabled
657            return;
658        }
659
660        $cards[] = ( new ContributeCard(
661            $context->msg( 'cx-contributecard-entrypoint-title' )->text(),
662            $context->msg( 'cx-contributecard-entrypoint-desc' )->text(),
663            'language', // icon
664            new ContributeCardActionLink(
665                // The CX beta feature is automatically enabled, when a valid campaign param exists.
666                // This enablement is done by a call to "SpecialContentTranslation::enableCXBetaFeature" method
667                SpecialPage::getTitleFor( 'ContentTranslation' )
668                    ->getLocalUrl( [ 'campaign' => 'specialcontribute' ] ),
669                $context->msg( 'cx-contributecard-entrypoint-cta' )->text(),
670            )
671        ) )->toArray();
672        // 'language' icon is in oojs-ui.styles.icons-editing-advanced RL module. Load that.
673        $out = $context->getOutput();
674        $out->addModuleStyles( [ 'oojs-ui.styles.icons-editing-advanced' ] );
675    }
676
677}