Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 189
0.00% covered (danger)
0.00%
0 / 37
CRAP
0.00% covered (danger)
0.00%
0 / 1
EchoEventPresentationModel
0.00% covered (danger)
0.00%
0 / 188
0.00% covered (danger)
0.00%
0 / 37
4692
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 supportsPresentationModel
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 factory
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCategory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDistributionType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 msg
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getBundledEvents
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getBundledIds
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 isBundled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getBundleCount
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getNotificationCountForOutput
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getIconType
n/a
0 / 0
n/a
0 / 0
0
 getTimestamp
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 userCan
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAgentForOutput
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 getMessageWithAgent
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getViewingUserForGender
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAgentLink
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 canRender
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHeaderMessageKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHeaderMessage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCompactHeaderMessageKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCompactHeaderMessage
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getSubjectMessageKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSubjectMessage
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getBodyMessage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPrimaryLink
n/a
0 / 0
n/a
0 / 0
0
 getPrimaryLinkWithMarkAsRead
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 getSecondaryLinks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getEventId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 jsonSerialize
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 getTruncatedUsername
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getTruncatedTitleText
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getUserLink
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
42
 getPageLink
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 getDynamicActionLink
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 getWatchActionLink
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace MediaWiki\Extension\Notifications\Formatters;
4
5use InvalidArgumentException;
6use JsonSerializable;
7use Language;
8use MediaWiki\Extension\Notifications\Controller\NotificationController;
9use MediaWiki\Extension\Notifications\Model\Event;
10use MediaWiki\MediaWikiServices;
11use MediaWiki\Revision\RevisionRecord;
12use MediaWiki\SpecialPage\SpecialPage;
13use MediaWiki\Title\Title;
14use MediaWiki\User\User;
15use MediaWiki\WikiMap\WikiMap;
16use Message;
17use MessageLocalizer;
18use MessageSpecifier;
19use Wikimedia\Timestamp\TimestampException;
20
21/**
22 * Class that returns structured data based
23 * on the provided event.
24 */
25abstract 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
752class_alias( EchoEventPresentationModel::class, 'EchoEventPresentationModel' );