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