Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
6.11% covered (danger)
6.11%
11 / 180
7.69% covered (danger)
7.69%
1 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialMobileWatchlist
6.11% covered (danger)
6.11%
11 / 180
7.69% covered (danger)
7.69%
1 / 13
826.36
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 executeWhenAvailable
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
12
 addWatchlistHTML
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 getNSConditions
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 showRecentChangesHeader
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
12
 doFeedQuery
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
12
 showFeedResults
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 showResults
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 showEmptyList
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getEmptyListHtml
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 showFeedResultRow
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
20
 getShortDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAssociatedNavigationLinks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3use MediaWiki\Html\Html;
4use MediaWiki\MediaWikiServices;
5use MediaWiki\SpecialPage\SpecialPage;
6use MediaWiki\Title\Title;
7use MediaWiki\User\UserIdentity;
8use MediaWiki\Utils\MWTimestamp;
9use Wikimedia\IPUtils;
10use Wikimedia\Rdbms\IConnectionProvider;
11use Wikimedia\Rdbms\IResultWrapper;
12
13/**
14 * Implements the Watchlist special page
15 * @deprecated in future this should use the core Watchlist page (T109277)
16 */
17class SpecialMobileWatchlist extends MobileSpecialPageFeed {
18    // Performance-safe value with PageImages. Try to keep in sync with
19    // WatchListGateway.
20    public const LIMIT = 50;
21
22    public const VIEW_OPTION_NAME = 'mfWatchlistView';
23    public const FILTER_OPTION_NAME = 'mfWatchlistFilter';
24    public const VIEW_LIST = 'a-z';
25    public const VIEW_FEED = 'feed';
26
27    public const WATCHLIST_TAB_PATHS = [
28        'Special:Watchlist',
29        'Special:EditWatchlist'
30    ];
31
32    private IConnectionProvider $connectionProvider;
33
34    /** @var string Saves, how the watchlist is sorted: a-z or as a feed */
35    private $view;
36
37    public function __construct( IConnectionProvider $connectionProvider ) {
38        parent::__construct( 'Watchlist' );
39
40        $this->connectionProvider = $connectionProvider;
41    }
42
43    /** @var string Saves the actual used filter in feed view */
44    private $filter;
45    /** @var bool Saves whether display images or not */
46    private $usePageImages;
47
48    /**
49     * Render the special page
50     * @param string|null $par parameter submitted as subpage
51     */
52    public function executeWhenAvailable( $par ) {
53        // Anons don't get a watchlist
54        $this->requireLogin( 'mobile-frontend-watchlist-purpose' );
55        $this->usePageImages = ExtensionRegistry::getInstance()->isLoaded( 'PageImages' );
56
57        $user = $this->getUser();
58        $output = $this->getOutput();
59        $output->addBodyClasses( 'mw-mf-special-page' );
60        $output->addModules( 'mobile.special.watchlist.scripts' );
61        $output->addModuleStyles( [
62            'mobile.pagelist.styles',
63            'mobile.pagesummary.styles',
64        ] );
65        $req = $this->getRequest();
66
67        # Show watchlist feed if that person is an editor
68        $watchlistEditCountThreshold = $this->getConfig()->get( 'MFWatchlistEditCountThreshold' );
69        $defaultView = $this->getUser()->getEditCount() > $watchlistEditCountThreshold ?
70            self::VIEW_FEED : self::VIEW_LIST;
71        $this->view = $req->getVal( 'watchlistview', $defaultView );
72
73        $userOption = $this->getUserOptionsLookup()->getOption(
74            $user,
75            self::FILTER_OPTION_NAME,
76            'all'
77        );
78        $this->filter = $req->getVal( 'filter', $userOption );
79
80        $output->setPageTitleMsg( $this->msg( 'watchlist' ) );
81
82        if ( $this->view === self::VIEW_FEED ) {
83            $res = $this->doFeedQuery();
84            $this->addWatchlistHTML( $res, $user );
85        } else {
86            $output->redirect( SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL() );
87        }
88    }
89
90    /**
91     * Builds the watchlist HTML inside the associated OutputPage
92     * @param IResultWrapper $res
93     * @param UserIdentity $user
94     */
95    public function addWatchlistHTML( IResultWrapper $res, UserIdentity $user ) {
96        $output = $this->getOutput();
97        $output->addHTML(
98            Html::openElement( 'div', [ 'class' => 'content-unstyled' ] )
99        );
100        $this->showRecentChangesHeader();
101
102        if ( $res->numRows() ) {
103            $this->showFeedResults( $res );
104        } else {
105            $this->showEmptyList( true );
106        }
107        $output->addHTML(
108            Html::closeElement( 'div' )
109        );
110    }
111
112    /**
113     * Returns an array of conditions restricting namespace in queries
114     * @param string $column Namespace db key
115     *
116     * @return array
117     */
118    protected function getNSConditions( $column ) {
119        $conds = [];
120        switch ( $this->filter ) {
121            case 'all':
122                // no-op
123                break;
124            case 'articles':
125                // @fixme content namespaces
126                // Has to be unquoted or MySQL will filesort for wl_namespace
127                $conds[] = "$column = 0";
128                break;
129            case 'talk':
130                // check project talk, user talk and talk pages
131                $conds[] = "$column IN (1, 3, 5)";
132                break;
133            case 'other':
134                // @fixme
135                $conds[] = "$column NOT IN (0, 1, 3, 5)";
136                break;
137        }
138        return $conds;
139    }
140
141    /**
142     * Render "second" header for filter in feed view of watchlist
143     */
144    private function showRecentChangesHeader() {
145        $filters = [
146            'all' => 'mobile-frontend-watchlist-filter-all',
147            'articles' => 'mobile-frontend-watchlist-filter-articles',
148            'talk' => 'mobile-frontend-watchlist-filter-talk',
149            'other' => 'mobile-frontend-watchlist-filter-other',
150        ];
151        $output = $this->getOutput();
152
153        $output->addHTML(
154            Html::openElement( 'ul', [ 'class' => 'mw-mf-watchlist-selector page-header-bar' ] )
155        );
156
157        foreach ( $filters as $filter => $msg ) {
158            $itemAttrs = [];
159            if ( $filter === $this->filter ) {
160                $itemAttrs['class'] = 'selected';
161            }
162            $linkAttrs = [
163                'data-filter' => $filter,
164                'href' => $this->getPageTitle()->getLocalURL(
165                    [
166                        'filter' => $filter,
167                        'watchlistview' => self::VIEW_FEED,
168                    ]
169                )
170            ];
171            $output->addHTML(
172                Html::openElement( 'li', $itemAttrs ) .
173                Html::element( 'a', $linkAttrs, $this->msg( $msg )->plain() ) .
174                Html::closeElement( 'li' )
175            );
176        }
177
178        $output->addHTML(
179            Html::closeElement( 'ul' )
180        );
181    }
182
183    /**
184     * Get watchlist items for feed view
185     * @return IResultWrapper
186     *
187     * @see getNSConditions()
188     * @see doPageImages()
189     */
190    protected function doFeedQuery() {
191        $user = $this->getUser();
192        $dbr = $this->connectionProvider->getReplicaDatabase( false, 'watchlist' );
193
194        // Possible where conditions
195        $conds = $this->getNSConditions( 'rc_namespace' );
196
197        // snip....
198
199        // @todo This should be changed to use WatchedItemQuerySerivce
200
201        $rcQuery = RecentChange::getQueryInfo();
202        $tables = array_merge( $rcQuery['tables'], [ 'watchlist' ] );
203        $fields = $rcQuery['fields'];
204        $innerConds = [
205            'wl_user' => $user->getId(),
206            'wl_namespace=rc_namespace',
207            'wl_title=rc_title',
208            // FIXME: Filter out wikidata changes which currently show as anonymous (see T51315)
209            'rc_type!=' . $dbr->addQuotes( RC_EXTERNAL ),
210        ];
211        // Filter out category membership changes if configured
212        $userOption = $this->userOptionsLookup->getBoolOption( $user, 'hidecategorization' );
213        if ( $userOption ) {
214            $innerConds[] = 'rc_type!=' . $dbr->addQuotes( RC_CATEGORIZE );
215        }
216        $join_conds = [
217            'watchlist' => [
218                'INNER JOIN',
219                $innerConds,
220            ],
221        ] + $rcQuery['joins'];
222        $query_options = [
223            'ORDER BY' => 'rc_timestamp DESC',
224            'LIMIT' => self::LIMIT
225        ];
226
227        $rollbacker = MediaWikiServices::getInstance()->getPermissionManager()
228            ->userHasRight( $user, 'rollback' );
229        if ( $rollbacker ) {
230            $tables[] = 'page';
231            $join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
232            $fields[] = 'page_latest';
233        }
234
235        ChangeTags::modifyDisplayQuery( $tables, $fields, $conds, $join_conds, $query_options, '' );
236
237        return $dbr->select( $tables, $fields, $conds, __METHOD__, $query_options, $join_conds );
238    }
239
240    /**
241     * Show results of doFeedQuery
242     * @param IResultWrapper $res Result wrapper returned from db
243     *
244     * @see showResults()
245     */
246    protected function showFeedResults( IResultWrapper $res ) {
247        $this->showResults( $res, true );
248    }
249
250    /**
251     * Render the Watchlist items.
252     * When ?from not set, adds a link "more" to see the other watchlist items.
253     * @param IResultWrapper $res Result wrapper from db
254     * @param bool $feed Render as feed (true) or list (false) view?
255     */
256    protected function showResults( IResultWrapper $res, $feed ) {
257        $output = $this->getOutput();
258
259        if ( $feed ) {
260            foreach ( $res as $row ) {
261                $this->showFeedResultRow( $row );
262            }
263        }
264        // Close .side-list element opened in renderListHeaderWhereNeeded
265        // inside showFeedResultRow function
266        $output->addHTML( '</ul>' );
267    }
268
269    /**
270     * If the user doesn't watch any page, show information how to watch some.
271     * @param bool $feed Render as feed (true) or list (false) view?
272     */
273    private function showEmptyList( $feed ) {
274        $this->getOutput()->addHTML( self::getEmptyListHtml( $feed, $this->getLanguage() ) );
275    }
276
277    /**
278     * Get the HTML needed to show if a user doesn't watch any page, show information
279     * how to watch pages where no pages have been watched.
280     * @param bool $feed Render as feed (true) or list (false) view?
281     * @param Language $lang The language of the current mode
282     * @return string
283     */
284    public static function getEmptyListHtml( $feed, $lang ) {
285        $dir = $lang->isRTL() ? 'rtl' : 'ltr';
286
287        $config = MediaWikiServices::getInstance()->getService( 'MobileFrontend.Config' );
288        $imgUrl = $config->get( 'ExtensionAssetsPath' ) .
289            "/MobileFrontend/images/emptywatchlist-page-actions-$dir.png";
290
291        if ( $feed ) {
292            $msg = Html::element( 'p', [], wfMessage( 'mobile-frontend-watchlist-feed-empty' )->plain() );
293        } else {
294            $msg = Html::element( 'p', [],
295                wfMessage( 'mobile-frontend-watchlist-a-z-empty-howto' )->plain()
296            );
297            $msg .=    Html::element( 'img', [
298                'src' => $imgUrl,
299                'alt' => wfMessage( 'mobile-frontend-watchlist-a-z-empty-howto-alt' )->plain(),
300            ] );
301        }
302
303        return Html::openElement( 'div', [ 'class' => 'info empty-page' ] ) .
304                $msg .
305                Html::element( 'a',
306                    [ 'class' => 'button', 'href' => Title::newMainPage()->getLocalURL() ],
307                    wfMessage( 'mobile-frontend-watchlist-back-home' )->plain()
308                ) .
309                Html::closeElement( 'div' );
310    }
311
312    /**
313     * Render a result row in feed view
314     * @param \stdClass $row a row of db result
315     */
316    protected function showFeedResultRow( $row ) {
317        if ( $row->rc_deleted ) {
318            return;
319        }
320
321        $user = $this->getUser();
322        $lang = $this->getLanguage();
323
324        $date = $lang->userDate( $row->rc_timestamp, $user );
325        $this->renderListHeaderWhereNeeded( $date );
326
327        $title = Title::makeTitle( $row->rc_namespace, $row->rc_title );
328        $store = MediaWikiServices::getInstance()->getCommentStore();
329        $comment = $this->formatComment(
330            $store->getComment( 'rc_comment', $row )->text, $title
331        );
332        $ts = new MWTimestamp( $row->rc_timestamp );
333        $username = $row->rc_user != 0
334            ? $row->rc_user_text
335            : IPUtils::prettifyIP( $row->rc_user_text );
336        $revId = $row->rc_this_oldid;
337        $bytes = $row->rc_new_len - $row->rc_old_len;
338        $isAnon = $row->rc_user == 0;
339        $isMinor = $row->rc_minor != 0;
340
341        if ( $revId ) {
342            $diffTitle = SpecialPage::getTitleFor( 'MobileDiff', (string)$revId );
343            $diffLink = $diffTitle->getLocalURL();
344        } else {
345            // hack -- use full log entry display
346            $diffLink = Title::makeTitle( $row->rc_namespace, $row->rc_title )->getLocalURL();
347        }
348
349        $options = [
350            'ts' => $ts,
351            'diffLink' => $diffLink,
352            'username' => $username,
353            'comment' => $comment,
354            'title' => $title,
355            'isAnon' => $isAnon,
356            'bytes' => $bytes,
357            'isMinor' => $isMinor,
358        ];
359        $this->renderFeedItemHtml( $options );
360    }
361
362    /**
363     * @inheritDoc
364     */
365    public function getShortDescription( string $path = '' ): string {
366        return $this->msg( 'watchlisttools-view' )->text();
367    }
368
369    /**
370     * @inheritDoc
371     */
372    public function getAssociatedNavigationLinks() {
373        return self::WATCHLIST_TAB_PATHS;
374    }
375}