Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 189 |
|
0.00% |
0 / 37 |
CRAP | |
0.00% |
0 / 1 |
EchoEventPresentationModel | |
0.00% |
0 / 188 |
|
0.00% |
0 / 37 |
4692 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
supportsPresentationModel | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
factory | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getType | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getUser | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getCategory | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getDistributionType | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
msg | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getBundledEvents | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
getBundledIds | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
isBundled | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getBundleCount | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
getNotificationCountForOutput | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getIconType | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getTimestamp | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
userCan | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getAgentForOutput | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
getMessageWithAgent | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getViewingUserForGender | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getAgentLink | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
canRender | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getHeaderMessageKey | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getHeaderMessage | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getCompactHeaderMessageKey | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getCompactHeaderMessage | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getSubjectMessageKey | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getSubjectMessage | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
getBodyMessage | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getPrimaryLink | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getPrimaryLinkWithMarkAsRead | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
getSecondaryLinks | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getEventId | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
jsonSerialize | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
12 | |||
getTruncatedUsername | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getTruncatedTitleText | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getUserLink | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
42 | |||
getPageLink | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
12 | |||
getDynamicActionLink | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
42 | |||
getWatchActionLink | |
0.00% |
0 / 43 |
|
0.00% |
0 / 1 |
20 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\Notifications\Formatters; |
4 | |
5 | use InvalidArgumentException; |
6 | use JsonSerializable; |
7 | use Language; |
8 | use MediaWiki\Extension\Notifications\Controller\NotificationController; |
9 | use MediaWiki\Extension\Notifications\Model\Event; |
10 | use MediaWiki\MediaWikiServices; |
11 | use MediaWiki\Revision\RevisionRecord; |
12 | use MediaWiki\SpecialPage\SpecialPage; |
13 | use MediaWiki\Title\Title; |
14 | use MediaWiki\User\User; |
15 | use MediaWiki\WikiMap\WikiMap; |
16 | use Message; |
17 | use MessageLocalizer; |
18 | use MessageSpecifier; |
19 | use Wikimedia\Timestamp\TimestampException; |
20 | |
21 | /** |
22 | * Class that returns structured data based |
23 | * on the provided event. |
24 | */ |
25 | abstract class EchoEventPresentationModel implements JsonSerializable, MessageLocalizer { |
26 | |
27 | /** |
28 | * Recommended length of usernames included in messages, in |
29 | * characters (not bytes). |
30 | */ |
31 | private const USERNAME_RECOMMENDED_LENGTH = 20; |
32 | |
33 | /** |
34 | * Recommended length of usernames used as link label, in |
35 | * characters (not bytes). |
36 | */ |
37 | private const USERNAME_AS_LABEL_RECOMMENDED_LENGTH = 15; |
38 | |
39 | /** |
40 | * Recommended length of page names included in messages, in |
41 | * characters (not bytes). |
42 | */ |
43 | protected const PAGE_NAME_RECOMMENDED_LENGTH = 50; |
44 | |
45 | /** |
46 | * Recommended length of page names used as link label, in |
47 | * characters (not bytes). |
48 | */ |
49 | private const PAGE_NAME_AS_LABEL_RECOMMENDED_LENGTH = 15; |
50 | |
51 | /** |
52 | * Recommended length of section titles included in messages, in |
53 | * characters (not bytes). |
54 | */ |
55 | public const SECTION_TITLE_RECOMMENDED_LENGTH = 50; |
56 | |
57 | /** |
58 | * @var Event |
59 | */ |
60 | protected $event; |
61 | |
62 | /** |
63 | * @var Language |
64 | */ |
65 | protected $language; |
66 | |
67 | /** |
68 | * @var string |
69 | */ |
70 | protected $type; |
71 | |
72 | /** |
73 | * @var User for permissions checking |
74 | */ |
75 | private $user; |
76 | |
77 | /** |
78 | * @var string 'web' or 'email' |
79 | */ |
80 | private $distributionType; |
81 | |
82 | /** |
83 | * @param Event $event |
84 | * @param Language $language |
85 | * @param User $user Only used for permissions checking and GENDER |
86 | * @param string $distributionType |
87 | */ |
88 | protected function __construct( |
89 | Event $event, |
90 | Language $language, |
91 | User $user, |
92 | $distributionType |
93 | ) { |
94 | $this->event = $event; |
95 | $this->type = $event->getType(); |
96 | $this->language = $language; |
97 | $this->user = $user; |
98 | $this->distributionType = $distributionType; |
99 | } |
100 | |
101 | /** |
102 | * Convenience function to detect whether the event type |
103 | * has a presentation model available for rendering |
104 | * |
105 | * @param string $type event type |
106 | * @return bool |
107 | */ |
108 | public static function supportsPresentationModel( $type ) { |
109 | global $wgEchoNotifications; |
110 | return isset( $wgEchoNotifications[$type]['presentation-model'] ) |
111 | && class_exists( $wgEchoNotifications[$type]['presentation-model'] ); |
112 | } |
113 | |
114 | /** |
115 | * @param Event $event |
116 | * @param Language $language |
117 | * @param User $user |
118 | * @param string $distributionType 'web' or 'email' |
119 | * @return EchoEventPresentationModel |
120 | */ |
121 | public static function factory( |
122 | Event $event, |
123 | Language $language, |
124 | User $user, |
125 | $distributionType = 'web' |
126 | ) { |
127 | global $wgEchoNotifications; |
128 | // @todo don't depend upon globals |
129 | |
130 | $class = $wgEchoNotifications[$event->getType()]['presentation-model']; |
131 | return new $class( $event, $language, $user, $distributionType ); |
132 | } |
133 | |
134 | /** |
135 | * Get the type of event |
136 | * |
137 | * @return string |
138 | */ |
139 | final public function getType() { |
140 | return $this->type; |
141 | } |
142 | |
143 | /** |
144 | * Get the user receiving the notification |
145 | * |
146 | * @return User |
147 | */ |
148 | final public function getUser() { |
149 | return $this->user; |
150 | } |
151 | |
152 | /** |
153 | * Get the category of event |
154 | * |
155 | * @return string |
156 | */ |
157 | final public function getCategory() { |
158 | return $this->event->getCategory(); |
159 | } |
160 | |
161 | /** |
162 | * @return string 'web' or 'email' |
163 | */ |
164 | final public function getDistributionType() { |
165 | return $this->distributionType; |
166 | } |
167 | |
168 | /** |
169 | * Equivalent to IContextSource::msg for the current |
170 | * language |
171 | * |
172 | * @param string|string[]|MessageSpecifier $key Message key, or array of keys, |
173 | * or a MessageSpecifier. |
174 | * @param mixed ...$params Normal message parameters |
175 | * @return Message |
176 | */ |
177 | public function msg( $key, ...$params ) { |
178 | /** |
179 | * @var Message $msg |
180 | */ |
181 | $msg = wfMessage( $key, ...$params ); |
182 | $msg->inLanguage( $this->language ); |
183 | |
184 | // Notifications are considered UI (and should be in UI language, not |
185 | // content), and this flag is set false by inLanguage. |
186 | $msg->setInterfaceMessageFlag( true ); |
187 | |
188 | return $msg; |
189 | } |
190 | |
191 | /** |
192 | * @return Event[] |
193 | */ |
194 | final protected function getBundledEvents() { |
195 | return $this->event->getBundledEvents() ?: []; |
196 | } |
197 | |
198 | /** |
199 | * Get the ids of the bundled notifications or false if it's not bundled |
200 | * |
201 | * @return int[]|false |
202 | */ |
203 | public function getBundledIds() { |
204 | if ( $this->isBundled() ) { |
205 | return array_map( static function ( Event $event ) { |
206 | return $event->getId(); |
207 | }, $this->getBundledEvents() ); |
208 | } |
209 | return false; |
210 | } |
211 | |
212 | /** |
213 | * This method returns true when there are bundled notifications, even if they are all |
214 | * in the same group according to getBundleGrouping(). For presentation purposes, you may |
215 | * want to check if getBundleCount( true, $yourCallback ) > 1 instead. |
216 | * |
217 | * @return bool Whether there are other notifications bundled with this one. |
218 | */ |
219 | final protected function isBundled() { |
220 | return $this->getBundleCount() > 1; |
221 | } |
222 | |
223 | /** |
224 | * Count the number of event groups in this bundle. |
225 | * |
226 | * By default, each event is in its own group, and this method returns the number of events. |
227 | * To group events differently, pass $groupCallback. For example, to group events with the |
228 | * same title together, use $callback = function ( $event ) { return $event->getTitle()->getPrefixedText(); } |
229 | * |
230 | * If $includeCurrent is false, all events in the same group as the current one will be ignored. |
231 | * |
232 | * @param bool $includeCurrent Include the current event (and its group) |
233 | * @param callable|null $groupCallback Callback that takes an Event and returns a grouping value |
234 | * @return int Number of bundled events or groups |
235 | * @throws InvalidArgumentException |
236 | */ |
237 | final protected function getBundleCount( $includeCurrent = true, $groupCallback = null ) { |
238 | $events = array_merge( $this->getBundledEvents(), [ $this->event ] ); |
239 | if ( $groupCallback ) { |
240 | if ( !is_callable( $groupCallback ) ) { |
241 | // If we pass an invalid callback to array_map(), it'll just throw a warning |
242 | // and return NULL, so $count ends up being 0 or -1. Instead of doing that, |
243 | // throw an exception. |
244 | throw new InvalidArgumentException( 'Invalid callback passed to getBundleCount' ); |
245 | } |
246 | $events = array_unique( array_map( $groupCallback, $events ) ); |
247 | } |
248 | $count = count( $events ); |
249 | |
250 | if ( !$includeCurrent ) { |
251 | $count--; |
252 | } |
253 | return $count; |
254 | } |
255 | |
256 | /** |
257 | * Return the count of notifications bundled together. |
258 | * |
259 | * For parameters, see {@see EchoEventPresentationModel::getBundleCount}. |
260 | * |
261 | * @param bool $includeCurrent |
262 | * @param callable|null $groupCallback |
263 | * @return int count |
264 | */ |
265 | final protected function getNotificationCountForOutput( $includeCurrent = true, $groupCallback = null ) { |
266 | $count = $this->getBundleCount( $includeCurrent, $groupCallback ); |
267 | return NotificationController::getCappedNotificationCount( $count ); |
268 | } |
269 | |
270 | /** |
271 | * @return string The symbolic icon name as defined in $wgEchoNotificationIcons |
272 | */ |
273 | abstract public function getIconType(); |
274 | |
275 | /** |
276 | * @return string Timestamp the event occurred at |
277 | */ |
278 | final public function getTimestamp() { |
279 | return $this->event->getTimestamp(); |
280 | } |
281 | |
282 | /** |
283 | * Helper for Event::userCan |
284 | * |
285 | * @param int $type RevisionRecord::DELETED_* constant |
286 | * @return bool |
287 | */ |
288 | final protected function userCan( $type ) { |
289 | return $this->event->userCan( $type, $this->user ); |
290 | } |
291 | |
292 | /** |
293 | * @return string[]|false ['wikitext to display', 'username for GENDER'], false if no agent |
294 | * |
295 | * We have to display wikitext so we can add CSS classes for revision deleted user. |
296 | * The goal of this function is for callers not to worry about whether |
297 | * the user is visible or not. |
298 | * @par Example: |
299 | * @code |
300 | * [ $formattedName, $genderName ] = $this->getAgentForOutput(); |
301 | * $msg->params( $formattedName, $genderName ); |
302 | * @endcode |
303 | */ |
304 | final protected function getAgentForOutput() { |
305 | $agent = $this->event->getAgent(); |
306 | if ( !$agent ) { |
307 | return false; |
308 | } |
309 | |
310 | if ( $this->userCan( RevisionRecord::DELETED_USER ) ) { |
311 | // Not deleted |
312 | return [ |
313 | $this->getTruncatedUsername( $agent ), |
314 | $agent->getName() |
315 | ]; |
316 | } else { |
317 | // Deleted/hidden |
318 | $msg = $this->msg( 'rev-deleted-user' )->plain(); |
319 | // HACK: Pass an invalid username to GENDER to force the default |
320 | return [ '<span class="history-deleted">' . $msg . '</span>', '[]' ]; |
321 | } |
322 | } |
323 | |
324 | /** |
325 | * Return a message with the given key and the agent's |
326 | * formatted name and name for GENDER as 1st and |
327 | * 2nd parameters. |
328 | * @param string $key |
329 | * @return Message |
330 | */ |
331 | final protected function getMessageWithAgent( $key ) { |
332 | $msg = $this->msg( $key ); |
333 | [ $formattedName, $genderName ] = $this->getAgentForOutput(); |
334 | $msg->params( $formattedName, $genderName ); |
335 | return $msg; |
336 | } |
337 | |
338 | /** |
339 | * Get the viewing user's name for usage in GENDER |
340 | * |
341 | * @return string |
342 | */ |
343 | final protected function getViewingUserForGender() { |
344 | return $this->user->getName(); |
345 | } |
346 | |
347 | /** |
348 | * @return array|null Link object to the user's page or Special:Contributions for anon users. |
349 | * Can be used for primary or secondary links. |
350 | * Same format as secondary link. |
351 | * Returns null if the current user cannot see the agent. |
352 | */ |
353 | final protected function getAgentLink() { |
354 | return $this->getUserLink( $this->event->getAgent() ); |
355 | } |
356 | |
357 | /** |
358 | * To be overridden by subclasses if they are unable to render the |
359 | * notification, for example when a page is deleted. |
360 | * If this function returns false, no other methods will be called |
361 | * on the object. |
362 | * |
363 | * @return bool |
364 | */ |
365 | public function canRender() { |
366 | return true; |
367 | } |
368 | |
369 | /** |
370 | * @return string Message key that will be used in getHeaderMessage |
371 | */ |
372 | protected function getHeaderMessageKey() { |
373 | return "notification-header-{$this->type}"; |
374 | } |
375 | |
376 | /** |
377 | * Get a message object and add the performer's name as |
378 | * a parameter. It is expected that subclasses will override |
379 | * this. |
380 | * |
381 | * @return Message |
382 | */ |
383 | public function getHeaderMessage() { |
384 | return $this->getMessageWithAgent( $this->getHeaderMessageKey() ); |
385 | } |
386 | |
387 | /** |
388 | * @return string Message key that will be used in getCompactHeaderMessage |
389 | */ |
390 | public function getCompactHeaderMessageKey() { |
391 | return "notification-compact-header-{$this->type}"; |
392 | } |
393 | |
394 | /** |
395 | * Get a message object and add the performer's name as |
396 | * a parameter. It is expected that subclasses will override |
397 | * this. |
398 | * |
399 | * This message should be more compact than the header message |
400 | * ( getHeaderMessage() ). It is displayed when a |
401 | * notification is part of an expanded bundle. |
402 | * |
403 | * @return Message |
404 | */ |
405 | public function getCompactHeaderMessage() { |
406 | $msg = $this->getMessageWithAgent( $this->getCompactHeaderMessageKey() ); |
407 | if ( $msg->isDisabled() ) { |
408 | // Back-compat for models that haven't been updated yet |
409 | $msg = $this->getHeaderMessage(); |
410 | } |
411 | |
412 | return $msg; |
413 | } |
414 | |
415 | /** |
416 | * @return string Message key that will be used in getSubjectMessage |
417 | */ |
418 | protected function getSubjectMessageKey() { |
419 | return "notification-subject-{$this->type}"; |
420 | } |
421 | |
422 | /** |
423 | * Get a message object and add the performer's name as |
424 | * a parameter. It is expected that subclasses will override |
425 | * this. The output of the message should be plaintext. |
426 | * |
427 | * This message is used as the subject line in |
428 | * single-notification emails. |
429 | * |
430 | * For backward compatibility, if this is not defined, |
431 | * the header message ( getHeaderMessage() ) is used instead. |
432 | * |
433 | * @return Message |
434 | */ |
435 | public function getSubjectMessage() { |
436 | $msg = $this->getMessageWithAgent( $this->getSubjectMessageKey() ); |
437 | $msg->params( $this->getViewingUserForGender() ); |
438 | if ( $msg->isDisabled() ) { |
439 | // Back-compat for models that haven't been updated yet |
440 | $msg = $this->getHeaderMessage(); |
441 | } |
442 | |
443 | return $msg; |
444 | } |
445 | |
446 | /** |
447 | * Get a message for the notification's body, false if it has no body |
448 | * |
449 | * @return bool|Message |
450 | */ |
451 | public function getBodyMessage() { |
452 | return false; |
453 | } |
454 | |
455 | /** |
456 | * Array of primary link details, with possibly-relative URL & label. |
457 | * |
458 | * @return array|false Array of link data, or false for no link: |
459 | * ['url' => (string) url, 'label' => (string) link text (non-escaped)] |
460 | */ |
461 | abstract public function getPrimaryLink(); |
462 | |
463 | /** |
464 | * Like getPrimaryLink(), but with the URL altered to add ?markasread=XYZ. When this link is followed, |
465 | * the notification is marked as read. |
466 | * |
467 | * If the notification is a bundle, the notification IDs are added to the parameter value |
468 | * separated by a "|". If cross-wiki notifications are enabled, a markasreadwiki parameter is |
469 | * added. |
470 | * |
471 | * @return array|false |
472 | */ |
473 | final public function getPrimaryLinkWithMarkAsRead() { |
474 | global $wgEchoCrossWikiNotifications; |
475 | $primaryLink = $this->getPrimaryLink(); |
476 | if ( $primaryLink ) { |
477 | $eventIds = [ $this->event->getId() ]; |
478 | if ( $this->getBundledIds() ) { |
479 | $eventIds = array_merge( $eventIds, $this->getBundledIds() ); |
480 | } |
481 | |
482 | $queryParams = [ 'markasread' => implode( '|', $eventIds ) ]; |
483 | if ( $wgEchoCrossWikiNotifications ) { |
484 | $queryParams['markasreadwiki'] = WikiMap::getCurrentWikiId(); |
485 | } |
486 | |
487 | $primaryLink['url'] = wfAppendQuery( $primaryLink['url'], $queryParams ); |
488 | } |
489 | return $primaryLink; |
490 | } |
491 | |
492 | /** |
493 | * Array of secondary link details, including possibly-relative URLs, label, |
494 | * description & icon name. |
495 | * |
496 | * @return (null|array)[] Array of links in the format of: |
497 | * [['url' => (string) url, |
498 | * 'label' => (string) link text (non-escaped), |
499 | * 'description' => (string) descriptive text (optional, non-escaped), |
500 | * 'icon' => (bool|string) symbolic ooui icon name (or false if there is none), |
501 | * 'type' => (string) optional action type. Used to note a dynamic action, |
502 | * by setting it to 'dynamic-action' |
503 | * 'data' => (array) optional array containing information about the dynamic |
504 | * action. It must include 'tokenType' (string), 'messages' (array) |
505 | * with messages supplied for the item and the confirmation dialog |
506 | * and 'params' (array) for the API operation needed to complete the |
507 | * action. For example: |
508 | * 'data' => [ |
509 | * 'tokenType' => 'watch', |
510 | * 'params' => [ |
511 | * 'action' => 'watch', |
512 | * 'titles' => 'Namespace:SomeTitle' |
513 | * ], |
514 | * 'messages' => [ |
515 | * 'confirmation' => [ |
516 | * 'title' => 'message (parsed as HTML)', |
517 | * 'description' => 'optional message (parsed as HTML)' |
518 | * ] |
519 | * ] |
520 | * ] |
521 | * 'prioritized' => (bool) true to request the link be placed outside the action menu. |
522 | * false or omitted for the default behavior. By default, a link will |
523 | * be placed inside the menu, unless there are maxPrioritizedActions |
524 | * or fewer secondary links. If there are maxPrioritizedActions or |
525 | * fewer secondary links, they will all appear outside the action menu. |
526 | * At most maxPrioritizedActions links will be placed outside the action menu. |
527 | * maxPrioritizedActions is 2 on desktop and 1 on mobile. |
528 | * ...] |
529 | * |
530 | * Note that you should call array_values(array_filter()) on the |
531 | * result of this function (FIXME). |
532 | */ |
533 | public function getSecondaryLinks() { |
534 | return []; |
535 | } |
536 | |
537 | /** |
538 | * Get the ID of the associated event |
539 | * @return int Event id |
540 | */ |
541 | public function getEventId() { |
542 | return $this->event->getId(); |
543 | } |
544 | |
545 | /** |
546 | * @return array |
547 | * @throws TimestampException |
548 | */ |
549 | public function jsonSerialize(): array { |
550 | $body = $this->getBodyMessage(); |
551 | |
552 | return [ |
553 | 'header' => $this->getHeaderMessage()->parse(), |
554 | 'compactHeader' => $this->getCompactHeaderMessage()->parse(), |
555 | 'body' => $body ? $body->escaped() : '', |
556 | 'icon' => $this->getIconType(), |
557 | 'links' => [ |
558 | 'primary' => $this->getPrimaryLinkWithMarkAsRead() ?: [], |
559 | 'secondary' => array_values( array_filter( $this->getSecondaryLinks() ) ), |
560 | ], |
561 | ]; |
562 | } |
563 | |
564 | /** |
565 | * @param User $user |
566 | * @return string |
567 | */ |
568 | protected function getTruncatedUsername( User $user ) { |
569 | return $this->language->embedBidi( $this->language->truncateForVisual( |
570 | $user->getName(), self::USERNAME_RECOMMENDED_LENGTH, '...', false ) ); |
571 | } |
572 | |
573 | /** |
574 | * @param Title $title |
575 | * @param bool $includeNamespace |
576 | * @return string |
577 | */ |
578 | protected function getTruncatedTitleText( Title $title, $includeNamespace = false ) { |
579 | $text = $includeNamespace ? $title->getPrefixedText() : $title->getText(); |
580 | return $this->language->embedBidi( $this->language->truncateForVisual( |
581 | $text, self::PAGE_NAME_RECOMMENDED_LENGTH, '...', false ) ); |
582 | } |
583 | |
584 | /** |
585 | * @param User|null $user |
586 | * @return array|null |
587 | */ |
588 | final protected function getUserLink( $user ) { |
589 | if ( !$user ) { |
590 | return null; |
591 | } |
592 | |
593 | if ( !$this->userCan( RevisionRecord::DELETED_USER ) ) { |
594 | return null; |
595 | } |
596 | |
597 | $url = !$user->isRegistered() |
598 | ? SpecialPage::getTitleFor( 'Contributions', $user->getName() )->getFullURL() |
599 | : $user->getUserPage()->getFullURL(); |
600 | |
601 | $label = $user->getName(); |
602 | $truncatedLabel = $this->language->truncateForVisual( |
603 | $label, self::USERNAME_AS_LABEL_RECOMMENDED_LENGTH, '...', false ); |
604 | $isTruncated = $label !== $truncatedLabel; |
605 | |
606 | return [ |
607 | 'url' => $url, |
608 | 'label' => $this->language->embedBidi( $truncatedLabel ), |
609 | 'tooltip' => $isTruncated ? $label : '', |
610 | 'description' => '', |
611 | 'icon' => $user->isTemp() ? 'userTemporary' : 'userAvatar', |
612 | 'prioritized' => true, |
613 | ]; |
614 | } |
615 | |
616 | /** |
617 | * @param Title $title |
618 | * @param string $description |
619 | * @param bool $prioritized |
620 | * @param array $query |
621 | * @return array |
622 | */ |
623 | final protected function getPageLink( Title $title, $description, $prioritized, $query = [] ) { |
624 | if ( $title->getNamespace() === NS_USER_TALK ) { |
625 | $icon = 'userSpeechBubble'; |
626 | } elseif ( $title->isTalkPage() ) { |
627 | $icon = 'speechBubbles'; |
628 | } else { |
629 | $icon = 'article'; |
630 | } |
631 | |
632 | return [ |
633 | 'url' => $title->getFullURL( $query ), |
634 | 'label' => $this->language->embedBidi( |
635 | $this->language->truncateForVisual( |
636 | $title->getText(), self::PAGE_NAME_AS_LABEL_RECOMMENDED_LENGTH, '...', false ) |
637 | ), |
638 | 'tooltip' => $title->getPrefixedText(), |
639 | 'description' => $description, |
640 | 'icon' => $icon, |
641 | 'prioritized' => $prioritized, |
642 | ]; |
643 | } |
644 | |
645 | /** |
646 | * Get a dynamic action link |
647 | * |
648 | * @param Title $title Title relating to this action |
649 | * @param string|false $icon Optional. Symbolic name of the OOUI icon to use |
650 | * @param string $label link text (non-escaped) |
651 | * @param string|null $description descriptive text (optional, non-escaped) |
652 | * @param array $data Action data |
653 | * @param array $query |
654 | * @return array Array compatible with the structure of |
655 | * secondary links |
656 | */ |
657 | final protected function getDynamicActionLink( |
658 | Title $title, |
659 | $icon, |
660 | $label, |
661 | $description = null, |
662 | $data = [], |
663 | $query = [] |
664 | ) { |
665 | if ( !$icon && $title->getNamespace() === NS_USER_TALK ) { |
666 | $icon = 'userSpeechBubble'; |
667 | } elseif ( !$icon && $title->isTalkPage() ) { |
668 | $icon = 'speechBubbles'; |
669 | } elseif ( !$icon ) { |
670 | $icon = 'article'; |
671 | } |
672 | |
673 | return [ |
674 | 'type' => 'dynamic-action', |
675 | 'label' => $label, |
676 | 'description' => $description, |
677 | 'data' => $data, |
678 | 'url' => $title->getFullURL( $query ), |
679 | 'icon' => $icon, |
680 | ]; |
681 | } |
682 | |
683 | /** |
684 | * Get an 'watch' or 'unwatch' dynamic action link |
685 | * |
686 | * @param Title $title Title to watch or unwatch |
687 | * @return array Array compatible with dynamic action link |
688 | */ |
689 | final protected function getWatchActionLink( Title $title ) { |
690 | $isTitleWatched = MediaWikiServices::getInstance()->getWatchlistManager() |
691 | ->isWatched( $this->getUser(), $title ); |
692 | $availableAction = $isTitleWatched ? 'unwatch' : 'watch'; |
693 | |
694 | $data = [ |
695 | 'tokenType' => 'watch', |
696 | 'params' => [ |
697 | 'action' => 'watch', |
698 | 'titles' => $title->getPrefixedText(), |
699 | ], |
700 | 'messages' => [ |
701 | 'confirmation' => [ |
702 | // notification-dynamic-actions-watch-confirmation |
703 | // notification-dynamic-actions-unwatch-confirmation |
704 | 'title' => $this |
705 | ->msg( 'notification-dynamic-actions-' . $availableAction . '-confirmation' ) |
706 | ->params( |
707 | $this->getTruncatedTitleText( $title ), |
708 | $title->getFullURL(), |
709 | $this->getUser()->getName() |
710 | ), |
711 | // notification-dynamic-actions-watch-confirmation-description |
712 | // notification-dynamic-actions-unwatch-confirmation-description |
713 | 'description' => $this |
714 | ->msg( 'notification-dynamic-actions-' . $availableAction . '-confirmation-description' ) |
715 | ->params( |
716 | $this->getTruncatedTitleText( $title ), |
717 | $title->getFullURL(), |
718 | $this->getUser()->getName() |
719 | ), |
720 | ], |
721 | ], |
722 | ]; |
723 | |
724 | // "Unwatching" action requires another parameter |
725 | if ( $isTitleWatched ) { |
726 | $data[ 'params' ][ 'unwatch' ] = 1; |
727 | } |
728 | |
729 | return $this->getDynamicActionLink( |
730 | $title, |
731 | // Design requirements are to flip the star icons |
732 | // in their meaning; that is, for the 'unwatch' action |
733 | // we should display an empty star, and for the 'watch' |
734 | // action a full star. In OOUI icons, their names |
735 | // are reversed. |
736 | $isTitleWatched ? 'star' : 'unStar', |
737 | // notification-dynamic-actions-watch |
738 | // notification-dynamic-actions-unwatch |
739 | $this->msg( 'notification-dynamic-actions-' . $availableAction ) |
740 | ->params( |
741 | $this->getTruncatedTitleText( $title ), |
742 | $title->getFullURL( [ 'action' => $availableAction ] ), |
743 | $this->getUser()->getName() |
744 | )->text(), |
745 | null, |
746 | $data, |
747 | [ 'action' => $availableAction ] |
748 | ); |
749 | } |
750 | } |
751 | |
752 | class_alias( EchoEventPresentationModel::class, 'EchoEventPresentationModel' ); |