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