Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.89% covered (warning)
78.89%
71 / 90
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
HookHandler
78.89% covered (warning)
78.89%
71 / 90
50.00% covered (danger)
50.00%
4 / 8
36.91
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setExperimentManager
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onGetPreferences
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 onSkinTemplateNavigation__Universal
82.98% covered (warning)
82.98%
39 / 47
0.00% covered (danger)
0.00%
0 / 1
16.11
 addSpecialPageLinkToUserMenu
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 isReadingListsEnabledForUser
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
6.01
 isSkinSupported
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 onAPIQuerySiteInfoGeneralInfo
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\ReadingLists;
4
5use MediaWiki\Api\ApiQuerySiteinfo;
6use MediaWiki\Api\Hook\APIQuerySiteInfoGeneralInfoHook;
7use MediaWiki\Config\Config;
8use MediaWiki\Deferred\DeferredUpdates;
9use MediaWiki\Extension\BetaFeatures\BetaFeatures;
10use MediaWiki\Extension\TestKitchen\Sdk\ExperimentManager;
11use MediaWiki\Hook\SkinTemplateNavigation__UniversalHook;
12use MediaWiki\Registration\ExtensionRegistry;
13use MediaWiki\Skin\SkinTemplate;
14use MediaWiki\SpecialPage\SpecialPage;
15use MediaWiki\User\CentralId\CentralIdLookupFactory;
16use MediaWiki\User\Options\UserOptionsLookup;
17use MediaWiki\User\User;
18use MediaWiki\User\UserIdentity;
19use MediaWiki\WikiMap\WikiMap;
20
21/**
22 * Static entry points for hooks.
23 */
24class HookHandler implements APIQuerySiteInfoGeneralInfoHook, SkinTemplateNavigation__UniversalHook {
25
26    public function __construct(
27        private readonly Config $config,
28        private readonly ReadingListRepositoryFactory $readingListRepositoryFactory,
29        private readonly UserOptionsLookup $userOptionsLookup,
30        private readonly CentralIdLookupFactory $centralIdLookupFactory,
31        private ?ExperimentManager $experimentManager = null
32    ) {
33    }
34
35    public function setExperimentManager( ExperimentManager $experimentManager ): void {
36        $this->experimentManager = $experimentManager;
37    }
38
39    /**
40     * Adds a hidden preference, accessed via api. The preference indicates user eligibility
41     * for showing the ReadingLists bookmark icon button in supported skins.
42     *
43     * @param User $user User whose preferences are being modified.
44     * @param array[] &$preferences Preferences description array, to be fed to a HTMLForm object.
45     * @return bool|void True or no return value to continue or false to abort
46     */
47    public function onGetPreferences( $user, &$preferences ) {
48        $preferences += [
49            'readinglists-web-ui-enabled' => [
50                'type' => 'api',
51            ],
52        ];
53    }
54
55    /**
56     * Handler for SkinTemplateNavigation::Universal hook.
57     * Adds "Notifications" items to the notifications content navigation.
58     * SkinTemplate automatically merges these into the personal tools for older skins.
59     * @see https://www.mediawiki.org/wiki/Manual:Hooks/SkinTemplateNavigation::Universal
60     * @param SkinTemplate $sktemplate
61     * @param array &$links Array of URLs to append to.
62     * @throws ReadingListRepositoryException
63     */
64    public function onSkinTemplateNavigation__Universal( $sktemplate, &$links ): void {
65        if ( !self::isSkinSupported( $sktemplate->getSkinName() ) ) {
66            return;
67        }
68
69        $user = $sktemplate->getUser();
70
71        if ( !$this->isReadingListsEnabledForUser( $user ) ) {
72            return;
73        }
74
75        $centralId = $this->centralIdLookupFactory->getLookup()
76            ->centralIdFromLocalUser( $user );
77
78        if ( !$centralId ) {
79            return;
80        }
81
82        $this->addSpecialPageLinkToUserMenu( $user, $sktemplate, $links );
83
84        $repository = $this->readingListRepositoryFactory->create( $centralId );
85        $defaultListId = $repository->getDefaultListIdForUser() ?: null;
86
87        if ( $defaultListId === null ) {
88            DeferredUpdates::addCallableUpdate(
89                static function () use ( $repository ) {
90                    $repository->setupForUser( true );
91                },
92                DeferredUpdates::POSTSEND
93            );
94        }
95
96        $output = $sktemplate->getOutput();
97        $output->addModuleStyles( 'ext.readingLists.bookmark.icons' );
98
99        if ( !$output->isArticle() ) {
100            return;
101        }
102
103        // NOTE: Non-existent pages still have a Title object.
104        // It should be rare that the Title is null here, but we should still check.
105        $title = $output->getTitle();
106        if ( !$title || $title->getNamespace() !== NS_MAIN ) {
107            return;
108        }
109
110        $list = null;
111        $entry = false;
112
113        if ( $defaultListId !== null ) {
114            $list = $repository->selectValidList( $defaultListId );
115            $entry = $repository->getListsByPage(
116                '@local',
117                $title->getPrefixedDBkey(),
118                1
119            )->fetchObject();
120        }
121
122        // If the list id is null, then list setup occurs async in bookmark.js.
123        // When a user saves their first page, these attributes are updated accordingly
124        // after list setup.
125        $links['views']['bookmark'] = [
126            'text' => $sktemplate->msg(
127                'readinglists-' . ( $entry === false ? 'add' : 'remove' ) . '-bookmark'
128            )->text(),
129            'icon' => $entry === false ? 'bookmarkOutline' : 'bookmark',
130            'href' => '#',
131            'data-mw-list-id' => $list ? $list->rl_id : null,
132            'data-mw-entry-id' => $entry === false ? null : $entry->rle_id,
133            'data-mw-list-page-count' => $list ? $list->rl_size : 0,
134            'link-class' => 'reading-lists-bookmark'
135        ];
136
137        $output->addModules( 'ext.readingLists.bookmark' );
138    }
139
140    private function addSpecialPageLinkToUserMenu(
141        UserIdentity $user,
142        SkinTemplate $sktemplate,
143        array &$links
144    ): void {
145        $userMenu = $links['user-menu'] ?? [];
146
147        // Insert readinglists after 'mytalk', or after 'sandbox' if present.
148        // Reference: T413413.
149        $insertAfter = 'mytalk';
150        if ( isset( $userMenu['sandbox'] ) ) {
151            $insertAfter = 'sandbox';
152        }
153
154        $userName = $user->getName();
155        $specialPageUrl = SpecialPage::getTitleFor( 'ReadingLists', $userName )->getLinkURL();
156
157        $links['user-menu'] = wfArrayInsertAfter( $userMenu, [
158            'readinglists' => [
159                'text' => $sktemplate->msg( 'readinglists-menu-item' )->text(),
160                'href' => $specialPageUrl,
161                'icon' => 'bookmarkList',
162            ],
163        ], $insertAfter );
164    }
165
166    private function isReadingListsEnabledForUser( UserIdentity $user ): bool {
167        $betaFeatureIsAvailable = $this->config->get( 'ReadingListBetaFeature' ) &&
168            ExtensionRegistry::getInstance()->isLoaded( 'BetaFeatures' );
169
170        if ( $betaFeatureIsAvailable ) {
171            return BetaFeatures::isFeatureEnabled( $user, Constants::PREF_KEY_BETA_FEATURES );
172        }
173
174        $hiddenPreferenceEnabled = $this->userOptionsLookup->getOption(
175            $user,
176            Constants::PREF_KEY_WEB_UI_ENABLED
177        ) === '1';
178
179        $inExperimentTreatment = false;
180        if ( $this->experimentManager ) {
181            $wikiId = WikiMap::getCurrentWikiId();
182            // NOTE: These need to be the same as the experiment names
183            // defined in WikimediaEvents, in readingListAB.js.
184            $experimentName = $wikiId === 'enwiki'
185                ? 'we-3-3-4-reading-list-test1-en'
186                : 'we-3-3-4-reading-list-test1';
187            $experiment = $this->experimentManager->getExperiment( $experimentName );
188            $inExperimentTreatment = $experiment->isAssignedGroup( 'treatment' );
189        }
190
191        return $hiddenPreferenceEnabled && $inExperimentTreatment;
192    }
193
194    /**
195     * Show the reading list and bookmark if the skin is Vector 2022 or Minerva.
196     * @see https://phabricator.wikimedia.org/T395332
197     * @param string $skinName
198     * @return bool
199     */
200    public static function isSkinSupported( $skinName ) {
201        return $skinName === 'vector-2022' || $skinName === 'minerva';
202    }
203
204    /**
205     * Add configuration data to the siteinfo API output.
206     * Used by the RESTBase proxy for help messages in the Swagger doc.
207     * @param ApiQuerySiteinfo $module
208     * @param array &$result
209     */
210    public function onAPIQuerySiteInfoGeneralInfo( $module, &$result ) {
211        $result['readinglists-config'] = [
212            'maxListsPerUser' => $this->config->get( 'ReadingListsMaxListsPerUser' ),
213            'maxEntriesPerList' => $this->config->get( 'ReadingListsMaxEntriesPerList' ),
214            'deletedRetentionDays' => $this->config->get( 'ReadingListsDeletedRetentionDays' ),
215        ];
216    }
217}