Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
6.11% |
11 / 180 |
|
7.69% |
1 / 13 |
CRAP | |
0.00% |
0 / 1 |
SpecialMobileWatchlist | |
6.11% |
11 / 180 |
|
7.69% |
1 / 13 |
826.36 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
executeWhenAvailable | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
12 | |||
addWatchlistHTML | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
getNSConditions | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
30 | |||
showRecentChangesHeader | |
0.00% |
0 / 31 |
|
0.00% |
0 / 1 |
12 | |||
doFeedQuery | |
0.00% |
0 / 33 |
|
0.00% |
0 / 1 |
12 | |||
showFeedResults | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
showResults | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
showEmptyList | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getEmptyListHtml | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
12 | |||
showFeedResultRow | |
0.00% |
0 / 34 |
|
0.00% |
0 / 1 |
20 | |||
getShortDescription | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getAssociatedNavigationLinks | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | use MediaWiki\Html\Html; |
4 | use MediaWiki\MediaWikiServices; |
5 | use MediaWiki\SpecialPage\SpecialPage; |
6 | use MediaWiki\Title\Title; |
7 | use MediaWiki\User\UserIdentity; |
8 | use MediaWiki\Utils\MWTimestamp; |
9 | use Wikimedia\IPUtils; |
10 | use Wikimedia\Rdbms\IConnectionProvider; |
11 | use Wikimedia\Rdbms\IResultWrapper; |
12 | |
13 | /** |
14 | * Implements the Watchlist special page |
15 | * @deprecated in future this should use the core Watchlist page (T109277) |
16 | */ |
17 | class 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 | } |