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