Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 181
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
HookUtils
0.00% covered (danger)
0.00%
0 / 181
0.00% covered (danger)
0.00%
0 / 13
11342
0.00% covered (danger)
0.00%
0 / 1
 hasPagePropCached
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 parseRevisionParsoidHtml
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 featureConflictsWithGadget
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 isFeatureAvailableToUser
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
156
 isFeatureEnabledForUser
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
56
 determineUserABTestBucket
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
72
 isAvailableForTitle
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 isFeatureEnabledForOutput
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
702
 shouldShowNewSectionTab
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 shouldOpenNewTopicTool
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
90
 shouldDisplayEmptyState
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
110
 pageSubjectExists
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 shouldAddAutoSubscription
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2/**
3 * DiscussionTools extension hooks
4 *
5 * @file
6 * @ingroup Extensions
7 * @license MIT
8 */
9
10namespace MediaWiki\Extension\DiscussionTools\Hooks;
11
12use ExtensionRegistry;
13use IContextSource;
14use IDBAccessObject;
15use MediaWiki\Extension\DiscussionTools\CommentUtils;
16use MediaWiki\Extension\DiscussionTools\ContentThreadItemSet;
17use MediaWiki\Extension\Gadgets\GadgetRepo;
18use MediaWiki\Linker\LinkTarget;
19use MediaWiki\MediaWikiServices;
20use MediaWiki\Revision\RevisionRecord;
21use MediaWiki\User\UserIdentity;
22use MWException;
23use OutputPage;
24use ParserOptions;
25use RequestContext;
26use Title;
27use TitleValue;
28use Wikimedia\Assert\Assert;
29use Wikimedia\Parsoid\Utils\DOMCompat;
30use Wikimedia\Parsoid\Utils\DOMUtils;
31
32class HookUtils {
33
34    public const REPLYTOOL = 'replytool';
35    public const NEWTOPICTOOL = 'newtopictool';
36    public const SOURCEMODETOOLBAR = 'sourcemodetoolbar';
37    public const TOPICSUBSCRIPTION = 'topicsubscription';
38    public const AUTOTOPICSUB = 'autotopicsub';
39    public const VISUALENHANCEMENTS = 'visualenhancements';
40    public const VISUALENHANCEMENTS_REPLY = 'visualenhancements_reply';
41    public const VISUALENHANCEMENTS_PAGEFRAME = 'visualenhancements_pageframe';
42
43    /**
44     * @var string[] List of all sub-features. Will be used to generate:
45     *  - Feature override global: $wgDiscussionTools_FEATURE
46     *  - Body class: ext-discussiontools-FEATURE-enabled
47     *  - User option: discussiontools-FEATURE
48     */
49    public const FEATURES = [
50        // Can't use static:: in compile-time constants
51        self::REPLYTOOL,
52        self::NEWTOPICTOOL,
53        self::SOURCEMODETOOLBAR,
54        self::TOPICSUBSCRIPTION,
55        self::AUTOTOPICSUB,
56        self::VISUALENHANCEMENTS,
57        self::VISUALENHANCEMENTS_REPLY,
58        self::VISUALENHANCEMENTS_PAGEFRAME,
59    ];
60
61    public const FEATURES_CONFLICT_WITH_GADGET = [
62        self::REPLYTOOL,
63    ];
64
65    protected static array $propCache = [];
66
67    /**
68     * Check if a title has a page prop, and use an in-memory cache to avoid extra queries
69     *
70     * @param Title $title Title
71     * @param string $prop Page property
72     * @return bool Title has page property
73     */
74    public static function hasPagePropCached( Title $title, string $prop ): bool {
75        $id = $title->getArticleId();
76        if ( !isset( static::$propCache[ $id ] ) ) {
77            static::$propCache[ $id ] = [];
78        }
79        if ( !isset( static::$propCache[ $id ][ $prop ] ) ) {
80            $services = MediaWikiServices::getInstance();
81            $props = $services->getPageProps()->getProperties( $title, $prop );
82            static::$propCache[ $id ][ $prop ] = isset( $props[ $id ] );
83        }
84        return static::$propCache[ $id ][ $prop ];
85    }
86
87    /**
88     * Parse a revision by using the discussion parser on the HTML provided by Parsoid.
89     *
90     * @param RevisionRecord $revRecord
91     * @return ContentThreadItemSet
92     */
93    public static function parseRevisionParsoidHtml( RevisionRecord $revRecord ): ContentThreadItemSet {
94        $services = MediaWikiServices::getInstance();
95        $mainConfig = $services->getMainConfig();
96        $parsoidOutputAccess = $services->getParsoidOutputAccess();
97
98        // Look up the page by ID in master. If we just used $revRecord->getPage(),
99        // ParsoidOutputAccess would look it up by namespace+title in replica.
100        $pageRecord = $services->getPageStore()->getPageById( $revRecord->getPageId() ) ?:
101            $services->getPageStore()->getPageById( $revRecord->getPageId(), IDBAccessObject::READ_LATEST );
102        Assert::postcondition( $pageRecord !== null, 'Revision had no page' );
103
104        $parserOptions = ParserOptions::newFromAnon();
105        $parserOptions->setRenderReason( __METHOD__ );
106
107        $status = $parsoidOutputAccess->getParserOutput(
108            $pageRecord,
109            $parserOptions,
110            $revRecord
111        );
112
113        if ( !$status->isOK() ) {
114            [ 'message' => $key, 'params' => $params ] = $status->getErrors()[0];
115            $message = wfMessage( $key, ...$params );
116            throw new MWException( $message->inLanguage( 'en' )->useDatabase( false )->text() );
117        }
118
119        $parserOutput = $status->getValue();
120        $html = $parserOutput->getText();
121
122        // Run the discussion parser on it
123        $doc = DOMUtils::parseHTML( $html );
124        $container = DOMCompat::getBody( $doc );
125
126        CommentUtils::unwrapParsoidSections( $container );
127
128        $parser = $services->getService( 'DiscussionTools.CommentParser' );
129        $title = TitleValue::newFromPage( $revRecord->getPage() );
130        return $parser->parse( $container, $title );
131    }
132
133    /**
134     * @param UserIdentity $user
135     * @param string $feature Feature to check for
136     * @return bool
137     */
138    public static function featureConflictsWithGadget( UserIdentity $user, string $feature ) {
139        $dtConfig = MediaWikiServices::getInstance()->getConfigFactory()
140            ->makeConfig( 'discussiontools' );
141        $gadgetName = $dtConfig->get( 'DiscussionToolsConflictingGadgetName' );
142        if ( !$gadgetName ) {
143            return false;
144        }
145
146        if ( !in_array( $feature, static::FEATURES_CONFLICT_WITH_GADGET ) ) {
147            return false;
148        }
149
150        $extensionRegistry = ExtensionRegistry::getInstance();
151        if ( $extensionRegistry->isLoaded( 'Gadgets' ) ) {
152            $gadgetsRepo = GadgetRepo::singleton();
153            $match = array_search( $gadgetName, $gadgetsRepo->getGadgetIds() );
154            if ( $match !== false ) {
155                try {
156                    return $gadgetsRepo->getGadget( $gadgetName )
157                        ->isEnabled( $user );
158                } catch ( \InvalidArgumentException $e ) {
159                    return false;
160                }
161            }
162        }
163        return false;
164    }
165
166    /**
167     * Check if a DiscussionTools feature is available to this user
168     *
169     * @param UserIdentity $user
170     * @param string|null $feature Feature to check for (one of static::FEATURES)
171     *  Null will check for any DT feature.
172     * @return bool
173     */
174    public static function isFeatureAvailableToUser( UserIdentity $user, ?string $feature = null ): bool {
175        $services = MediaWikiServices::getInstance();
176        $dtConfig = $services->getConfigFactory()->makeConfig( 'discussiontools' );
177
178        if ( !$dtConfig->get( 'DiscussionToolsEnable' ) ) {
179            return false;
180        }
181
182        if (
183            ( $feature === static::TOPICSUBSCRIPTION || $feature === static::AUTOTOPICSUB ) &&
184            // Users must be logged in to use topic subscription, and Echo must be installed (T322498)
185            ( !$user->isRegistered() || !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) )
186        ) {
187            return false;
188        }
189
190        $optionsLookup = $services->getUserOptionsLookup();
191
192        if ( $feature ) {
193            // Feature-specific override
194            if ( $dtConfig->get( 'DiscussionTools_' . $feature ) !== 'default' ) {
195                // Feature setting can be 'available' or 'unavailable', overriding any BetaFeatures settings
196                return $dtConfig->get( 'DiscussionTools_' . $feature ) === 'available';
197            }
198        } else {
199            // Non-feature-specific override, check for any feature
200            foreach ( static::FEATURES as $feat ) {
201                if ( $dtConfig->get( 'DiscussionTools_' . $feat ) === 'available' ) {
202                    return true;
203                }
204            }
205        }
206
207        // Being in the "test" group for this feature means it's enabled. This
208        // overrules the wiki's beta feature setting. (However, a user who's
209        // in the control group can still bypass this and enable the feature
210        // normally.)
211        $abtest = static::determineUserABTestBucket( $user, $feature );
212        if ( $abtest === 'test' ) {
213            return true;
214        }
215
216        // No feature-specific override found.
217
218        if ( $dtConfig->get( 'DiscussionToolsBeta' ) ) {
219            $betaenabled = $optionsLookup->getOption( $user, 'discussiontools-betaenable', 0 );
220            return (bool)$betaenabled;
221        }
222
223        // Assume that if BetaFeature is turned off, or user has it enabled, that
224        // some features are available.
225        // If this isn't the case, then DiscussionToolsEnable should have been set to false.
226        return true;
227    }
228
229    /**
230     * Check if a DiscussionTools feature is enabled by this user
231     *
232     * @param UserIdentity $user
233     * @param string|null $feature Feature to check for (one of static::FEATURES)
234     *  Null will check for any DT feature.
235     * @return bool
236     */
237    public static function isFeatureEnabledForUser( UserIdentity $user, ?string $feature = null ): bool {
238        if ( !static::isFeatureAvailableToUser( $user, $feature ) ) {
239            return false;
240        }
241        $services = MediaWikiServices::getInstance();
242        $optionsLookup = $services->getUserOptionsLookup();
243        if ( $feature ) {
244            if ( static::featureConflictsWithGadget( $user, $feature ) ) {
245                return false;
246            }
247            // Check for a specific feature
248            $enabled = $optionsLookup->getOption( $user, 'discussiontools-' . $feature );
249            // `null` means there is no user option for this feature, so it must be enabled
250            return $enabled === null ? true : $enabled;
251        } else {
252            // Check for any feature
253            foreach ( static::FEATURES as $feat ) {
254                if ( $optionsLookup->getOption( $user, 'discussiontools-' . $feat ) ) {
255                    return true;
256                }
257            }
258            return false;
259        }
260    }
261
262    /**
263     * Work out the A/B test bucket for the current user
264     *
265     * Currently this just checks whether the user is logged in, and assigns
266     * them to a consistent bucket based on their ID.
267     *
268     * @param UserIdentity $user
269     * @param string|null $feature Feature to check for (one of static::FEATURES)
270     *  Null will check for any DT feature.
271     * @return string 'test' if in the test group, 'control' if in the control group, or '' if
272     *     they're not in the test
273     */
274    public static function determineUserABTestBucket( UserIdentity $user, ?string $feature = null ): string {
275        $services = MediaWikiServices::getInstance();
276        $optionsManager = $services->getUserOptionsManager();
277        $dtConfig = $services->getConfigFactory()->makeConfig( 'discussiontools' );
278
279        $abtest = $dtConfig->get( 'DiscussionToolsABTest' );
280
281        if ( $feature ? ( $abtest == $feature ) : (bool)$abtest ) {
282            if ( $user->isRegistered() ) {
283                return $user->getId() % 2 == 0 ? 'test' : 'control';
284            }
285            // logged out
286            $req = RequestContext::getMain()->getRequest();
287            $cookie = $req->getCookie( 'DTAB', '' );
288            if ( $cookie ) {
289                return $cookie;
290            }
291            // we just want to remember this across all calls in this request
292            static $bucket = false;
293            if ( !$bucket ) {
294                $bucket = rand( 0, 1 ) <= 0.5 ? 'test' : 'control';
295            }
296            return $bucket;
297        }
298        return '';
299    }
300
301    /**
302     * Check if the tools are available for a given title
303     *
304     * @param Title $title
305     * @param string|null $feature Feature to check for (one of static::FEATURES)
306     *  Null will check for any DT feature.
307     * @return bool
308     */
309    public static function isAvailableForTitle( Title $title, ?string $feature = null ): bool {
310        // Only wikitext pages (e.g. not Flow boards, special pages)
311        if ( $title->getContentModel() !== CONTENT_MODEL_WIKITEXT ) {
312            return false;
313        }
314        if ( !$title->canExist() ) {
315            return false;
316        }
317
318        $services = MediaWikiServices::getInstance();
319
320        if ( $feature === static::VISUALENHANCEMENTS ) {
321            $dtConfig = $services->getConfigFactory()->makeConfig( 'discussiontools' );
322            $namespaces = $dtConfig->get( 'DiscussionTools_visualenhancements_namespaces' );
323            if ( is_array( $namespaces ) ) {
324                // Only allow visual enhancements in specified namespaces
325                return in_array( $title->getNamespace(), $namespaces, true );
326            }
327        }
328
329        $hasNewSectionLink = static::hasPagePropCached( $title, 'newsectionlink' );
330
331        // Check that the page supports discussions.
332        // Treat pages with __NEWSECTIONLINK__ as talk pages (T245890)
333        return $hasNewSectionLink ||
334            // `wantSignatures` includes talk pages
335            $services->getNamespaceInfo()->wantSignatures( $title->getNamespace() );
336            // TODO: Consider not loading if forceHideNewSectionLink is true.
337    }
338
339    /**
340     * Check if the tool is available on a given page
341     *
342     * @param OutputPage $output
343     * @param string|null $feature Feature to check for (one of static::FEATURES)
344     *  Null will check for any DT feature.
345     * @return bool
346     */
347    public static function isFeatureEnabledForOutput( OutputPage $output, ?string $feature = null ): bool {
348        // Only show on normal page views (not history etc.), and in edit mode for previews
349        if (
350            // Don't try to call $output->getActionName if testing for NEWTOPICTOOL as we use
351            // the hook onGetActionName to override the action for the tool on empty pages.
352            // If we tried to call it here it would set up infinite recursion (T312689)
353            $feature !== static::NEWTOPICTOOL &&
354            !in_array( $output->getActionName(), [ 'view', 'edit', 'submit' ] )
355        ) {
356            return false;
357        }
358
359        $title = $output->getTitle();
360        // Don't show on pages without a Title
361        if ( !$title ) {
362            return false;
363        }
364
365        // Topic subscription is not available on your own talk page, as you will
366        // get 'edit-user-talk' notifications already. (T276996)
367        if (
368            ( $feature === static::TOPICSUBSCRIPTION || $feature === static::AUTOTOPICSUB ) &&
369            $title->equals( $output->getUser()->getTalkPage() )
370        ) {
371            return false;
372        }
373
374        // Subfeatures are disabled if the main feature is disabled
375        if ( (
376            $feature === static::VISUALENHANCEMENTS_REPLY ||
377            $feature === static::VISUALENHANCEMENTS_PAGEFRAME
378        ) && !self::isFeatureEnabledForOutput( $output, static::VISUALENHANCEMENTS ) ) {
379            return false;
380        }
381
382        // ?dtenable=1 overrides all user and title checks
383        $queryEnable = $output->getRequest()->getRawVal( 'dtenable' ) ?:
384            // Extra hack for parses from API, where this parameter isn't passed to derivative requests
385            RequestContext::getMain()->getRequest()->getRawVal( 'dtenable' );
386
387        if ( $queryEnable ) {
388            return true;
389        }
390
391        if ( $queryEnable === '0' ) {
392            // ?dtenable=0 forcibly disables the feature regardless of any other checks (T285578)
393            return false;
394        }
395
396        if ( !static::isAvailableForTitle( $title, $feature ) ) {
397            return false;
398        }
399
400        $isMobile = false;
401        if ( ExtensionRegistry::getInstance()->isLoaded( 'MobileFrontend' ) ) {
402            $mobFrontContext = MediaWikiServices::getInstance()->getService( 'MobileFrontend.Context' );
403            $isMobile = $mobFrontContext->shouldDisplayMobileView();
404        }
405        $dtConfig = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'discussiontools' );
406
407        if ( $isMobile ) {
408            // Enabling mobile removes MobileFrontend's reply and new topic tools, so always
409            // enable these tools as a replacement.
410            return (
411                $dtConfig->get( 'DiscussionToolsEnableMobile' ) ||
412                static::determineUserABTestBucket( $output->getUser(), 'mobile' ) === 'test'
413            ) && (
414                $feature === null ||
415                $feature === static::REPLYTOOL ||
416                $feature === static::NEWTOPICTOOL ||
417                $feature === static::SOURCEMODETOOLBAR ||
418                // Even though mobile ignores user preferences, TOPICSUBSCRIPTION must
419                // still be disabled if the user isn't registered.
420                ( $feature === static::TOPICSUBSCRIPTION && $output->getUser()->isRegistered() ) ||
421                $feature === static::VISUALENHANCEMENTS ||
422                $feature === static::VISUALENHANCEMENTS_REPLY ||
423                $feature === static::VISUALENHANCEMENTS_PAGEFRAME
424            );
425        }
426
427        return static::isFeatureEnabledForUser( $output->getUser(), $feature );
428    }
429
430    /**
431     * Check if the "New section" tab would be shown in a normal skin.
432     *
433     * @param IContextSource $context
434     * @return bool
435     */
436    public static function shouldShowNewSectionTab( IContextSource $context ): bool {
437        $title = $context->getTitle();
438        $output = $context->getOutput();
439
440        // Match the logic in MediaWiki core (as defined in SkinTemplate::buildContentNavigationUrlsInternal):
441        // https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/core/+/add6d0a0e38167a710fb47fac97ff3004451494c/includes/skins/SkinTemplate.php#1317
442        // * __NONEWSECTIONLINK__ is not present (OutputPage::forceHideNewSectionLink) and...
443        //   - This is the current revision of a non-redirect in a talk namespace or...
444        //   - __NEWSECTIONLINK__ is present (OutputPage::showNewSectionLink)
445        return (
446            !static::hasPagePropCached( $title, 'nonewsectionlink' ) &&
447            ( ( $title->isTalkPage() && !$title->isRedirect() && $output->isRevisionCurrent() ) ||
448                static::hasPagePropCached( $title, 'newsectionlink' ) )
449        );
450    }
451
452    /**
453     * Check if this page view should open the new topic tool on page load.
454     *
455     * @param IContextSource $context
456     * @return bool
457     */
458    public static function shouldOpenNewTopicTool( IContextSource $context ): bool {
459        $req = $context->getRequest();
460        $out = $context->getOutput();
461
462        return (
463            // ?title=...&action=edit&section=new
464            // ?title=...&veaction=editsource&section=new
465            ( $req->getRawVal( 'action' ) === 'edit' || $req->getRawVal( 'veaction' ) === 'editsource' ) &&
466            $req->getRawVal( 'section' ) === 'new' &&
467            // Adding a new topic with preloaded text is not supported yet (T269310)
468            !(
469                $req->getCheck( 'editintro' ) || $req->getCheck( 'preload' ) ||
470                $req->getCheck( 'preloadparams' ) || $req->getCheck( 'preloadtitle' ) ||
471                // Switching or previewing from an external tool (T316333)
472                $req->getCheck( 'wpTextbox1' )
473            ) &&
474            // User has new topic tool enabled (and not using &dtenable=0)
475            static::isFeatureEnabledForOutput( $out, static::NEWTOPICTOOL )
476        );
477    }
478
479    /**
480     * Check if this page view should display the "empty state" message for empty talk pages.
481     *
482     * @param IContextSource $context
483     * @return bool
484     */
485    public static function shouldDisplayEmptyState( IContextSource $context ): bool {
486        $req = $context->getRequest();
487        $out = $context->getOutput();
488        $user = $context->getUser();
489        $title = $context->getTitle();
490
491        $optionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
492
493        return (
494            (
495                // When following a red link from another page (but not when clicking the 'Edit' tab)
496                (
497                    $req->getRawVal( 'action' ) === 'edit' && $req->getRawVal( 'redlink' ) === '1' &&
498                    // …if not disabled by the user
499                    $optionsLookup->getOption( $user, 'discussiontools-newtopictool-createpage' )
500                ) ||
501                // When the new topic tool will be opened (usually when clicking the 'Add topic' tab)
502                static::shouldOpenNewTopicTool( $context ) ||
503                // In read mode (accessible for non-existent pages by clicking 'Cancel' in editor)
504                $req->getRawVal( 'action', 'view' ) === 'view'
505            ) &&
506            // Only in talk namespaces, not including other namespaces that isAvailableForTitle() allows
507            $title->isTalkPage() &&
508            // Only if the subject page or the user exists (T288319, T312560)
509            static::pageSubjectExists( $title ) &&
510            // The default display will probably be more useful for links to old revisions of deleted
511            // pages (existing pages are already excluded in shouldShowNewSectionTab())
512            $req->getIntOrNull( 'oldid' ) === null &&
513            // Only if "New section" tab would be shown by the skin.
514            // If the page doesn't exist, this only happens in talk namespaces.
515            // If the page exists, it also considers magic words on the page.
516            static::shouldShowNewSectionTab( $context ) &&
517            // User has new topic tool enabled (and not using &dtenable=0)
518            static::isFeatureEnabledForOutput( $out, static::NEWTOPICTOOL )
519        );
520    }
521
522    /**
523     * Return whether the corresponding subject page exists, or (if the page is a user talk page,
524     * excluding subpages) whether the user is registered or a valid IP address.
525     *
526     * @param LinkTarget $talkPage
527     * @return bool
528     */
529    private static function pageSubjectExists( LinkTarget $talkPage ): bool {
530        $services = MediaWikiServices::getInstance();
531        $namespaceInfo = $services->getNamespaceInfo();
532        Assert::precondition( $namespaceInfo->isTalk( $talkPage->getNamespace() ), "Page is a talk page" );
533
534        if ( $talkPage->getNamespace() === NS_USER_TALK && strpos( $talkPage->getText(), '/' ) === false ) {
535            if ( $services->getUserNameUtils()->isIP( $talkPage->getText() ) ) {
536                return true;
537            }
538            $subjectUser = $services->getUserFactory()->newFromName( $talkPage->getText() );
539            if ( $subjectUser && $subjectUser->isRegistered() ) {
540                return true;
541            }
542            return false;
543        } else {
544            $subjectPage = $namespaceInfo->getSubjectPage( $talkPage );
545            return $services->getPageStore()->getPageForLink( $subjectPage )->exists();
546        }
547    }
548
549    /**
550     * Check if we should be adding automatic topic subscriptions for this user on this page.
551     *
552     * @param UserIdentity $user
553     * @param Title $title
554     * @return bool
555     */
556    public static function shouldAddAutoSubscription( UserIdentity $user, Title $title ): bool {
557        // This duplicates the logic from isFeatureEnabledForOutput(),
558        // because we don't have access to the request or the output here.
559
560        // Topic subscription is not available on your own talk page, as you will
561        // get 'edit-user-talk' notifications already. (T276996)
562        // (can't use User::getTalkPage() to check because this is a UserIdentity)
563        if ( $title->inNamespace( NS_USER_TALK ) && $title->getText() === $user->getName() ) {
564            return false;
565        }
566
567        // Users flagged as bots shouldn't be autosubscribed. They can
568        // manually subscribe if it becomes relevant. (T301933)
569        $user = MediaWikiServices::getInstance()
570            ->getUserFactory()
571            ->newFromUserIdentity( $user );
572        if ( $user->isBot() ) {
573            return false;
574        }
575
576        // Check if the user has automatic subscriptions enabled, and the tools are enabled on the page.
577        return static::isAvailableForTitle( $title ) &&
578            static::isFeatureEnabledForUser( $user, static::AUTOTOPICSUB );
579    }
580}