Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
5.38% covered (danger)
5.38%
24 / 446
5.26% covered (danger)
5.26%
1 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Controller
5.38% covered (danger)
5.38%
24 / 446
5.26% covered (danger)
5.26%
1 / 19
9266.19
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 onBeforeCreateEchoEvent
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
2
 notifyHeaderChange
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
42
 notifyPostChange
0.00% covered (danger)
0.00%
0 / 77
0.00% covered (danger)
0.00%
0 / 1
182
 notifySummaryChange
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
56
 notifyNewTopic
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
110
 notifyTopicLocked
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
42
 notifyFlowEnabledOnTalkpage
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 generateMentionEvents
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
72
 getMentionedUsersAndSkipState
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 filterMentionedUsers
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 getMentionedUsersFromWikitext
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 onEchoGetBundleRules
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
210
 isFirstPost
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getTopmostPostId
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 getFirstPreorderDepthFirst
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
5.15
 getDeepestCommonRoot
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
6.13
 moderateTopicNotifications
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 moderatePostNotifications
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace Flow\Notifications;
4
5use Flow\Conversion\Utils;
6use Flow\Exception\FlowException;
7use Flow\Model\AbstractRevision;
8use Flow\Model\Header;
9use Flow\Model\PostRevision;
10use Flow\Model\PostSummary;
11use Flow\Model\UUID;
12use Flow\Model\Workflow;
13use Flow\Repository\TreeRepository;
14use MediaWiki\Config\ServiceOptions;
15use MediaWiki\Deferred\DeferredUpdates;
16use MediaWiki\Extension\Notifications\Controller\ModerationController;
17use MediaWiki\Extension\Notifications\Mapper\EventMapper;
18use MediaWiki\Extension\Notifications\Model\Event;
19use MediaWiki\Language\Language;
20use MediaWiki\MediaWikiServices;
21use MediaWiki\Parser\ParserOptions;
22use MediaWiki\Registration\ExtensionRegistry;
23use MediaWiki\Title\Title;
24use MediaWiki\User\User;
25use Wikimedia\Rdbms\IDBAccessObject;
26
27class Controller {
28    /**
29     * @var Language
30     */
31    protected $language;
32
33    /**
34     * @var TreeRepository
35     */
36    protected $treeRepository;
37
38    public const CONSTRUCTOR_OPTIONS = [
39        'FlowNotificationTruncateLength'
40    ];
41    private ?int $truncateLength;
42
43    /**
44     * @param ServiceOptions $options
45     * @param Language $language
46     * @param TreeRepository $treeRepository
47     */
48    public function __construct( ServiceOptions $options, Language $language, TreeRepository $treeRepository ) {
49        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
50        $this->truncateLength = $options->get( 'FlowNotificationTruncateLength' );
51        $this->language = $language;
52        $this->treeRepository = $treeRepository;
53    }
54
55    public static function onBeforeCreateEchoEvent( &$notifs, &$categories, &$icons ) {
56        $notifs += require dirname( dirname( __DIR__ ) ) . "/Notifications.php";
57        $categories['flow-discussion'] = [
58            'priority' => 3,
59            'tooltip' => 'echo-pref-tooltip-flow-discussion',
60        ];
61        $icons['flow-new-topic'] = [
62            'path' => [
63                'ltr' => 'Flow/modules/notification/icon/flow-new-topic-ltr.svg',
64                'rtl' => 'Flow/modules/notification/icon/flow-new-topic-rtl.svg'
65            ]
66        ];
67        $icons['flowusertalk-new-topic'] = [
68            'path' => [
69                'ltr' => 'Flow/modules/notification/icon/flow-new-topic-ltr.svg',
70                'rtl' => 'Flow/modules/notification/icon/flow-new-topic-rtl.svg'
71            ]
72        ];
73        $icons['flow-post-edited'] = $icons['flowusertalk-post-edited'] = [
74            'path' => [
75                'ltr' => 'Flow/modules/notification/icon/flow-post-edited-ltr.svg',
76                'rtl' => 'Flow/modules/notification/icon/flow-post-edited-rtl.svg'
77            ]
78        ];
79        $icons['flow-topic-renamed'] = $icons['flowusertalk-topic-renamed'] = [
80            'path' => [
81                'ltr' => 'Flow/modules/notification/icon/flow-topic-renamed-ltr.svg',
82                'rtl' => 'Flow/modules/notification/icon/flow-topic-renamed-rtl.svg'
83            ]
84        ];
85        $icons['flow-topic-resolved'] = $icons['flowusertalk-topic-resolved'] = [
86            'path' => [
87                'ltr' => 'Flow/modules/notification/icon/flow-topic-resolved-ltr.svg',
88                'rtl' => 'Flow/modules/notification/icon/flow-topic-resolved-rtl.svg'
89            ]
90        ];
91        $icons['flow-topic-reopened'] = $icons['flowusertalk-topic-reopened'] = [
92            'path' => [
93                'ltr' => 'Flow/modules/notification/icon/flow-topic-reopened-ltr.svg',
94                'rtl' => 'Flow/modules/notification/icon/flow-topic-reopened-rtl.svg'
95            ]
96        ];
97    }
98
99    /**
100     * Causes notifications to be fired for a Header-related event.
101     * @param array $data Associative array of parameters.
102     * * revision: The PostRevision created by the action. Always required.
103     * * board-workflow: The Workflow object for the board. Always required.
104     * * timestamp: Original event timestamp, for imports. Optional.
105     * * extra-data: Additional data to pass along to Event extra.
106     * @return Event[]
107     * @throws FlowException When $data contains unexpected types/values
108     */
109    public function notifyHeaderChange( array $data ): array {
110        if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) {
111            return [];
112        }
113
114        $extraData = $data['extra-data'] ?? [];
115
116        $revision = $data['revision'];
117        if ( !$revision instanceof Header ) {
118            throw new FlowException( 'Expected Header but received ' . get_class( $revision ) );
119        }
120        $boardWorkflow = $data['board-workflow'];
121        if ( !$boardWorkflow instanceof Workflow ) {
122            throw new FlowException( 'Expected Workflow but received ' . get_class( $boardWorkflow ) );
123        }
124
125        $user = $revision->getUser();
126        [ $mentionedUsers, $mentionsSkipped ] = $this->getMentionedUsersAndSkipState( $revision );
127
128        $extraData['content'] = Utils::htmlToPlaintext( $revision->getContent(), $this->truncateLength, $this->language );
129        $extraData['revision-id'] = $revision->getRevisionId();
130        $extraData['collection-id'] = $revision->getCollectionId();
131        $extraData['target-page'] = $boardWorkflow->getArticleTitle()->getArticleID();
132        // pass along mentioned users to other notification, so it knows who to ignore
133        $extraData['mentioned-users'] = $mentionedUsers;
134        $title = $boardWorkflow->getOwnerTitle();
135
136        $info = [
137            'agent' => $user,
138            'title' => $title,
139            'extra' => $extraData,
140        ];
141
142        // Allow a specific timestamp to be set - useful when importing existing data
143        if ( isset( $data['timestamp'] ) ) {
144            $info['timestamp'] = $data['timestamp'];
145        }
146
147        $events = [ Event::create( [ 'type' => 'flow-description-edited' ] + $info ) ];
148        if ( $title->getNamespace() === NS_USER_TALK ) {
149            $events[] = Event::create( [ 'type' => 'flowusertalk-description-edited' ] + $info );
150        }
151        return [
152            ...$events,
153            ...$this->generateMentionEvents(
154                $revision,
155                null,
156                $boardWorkflow,
157                $user,
158                $mentionedUsers,
159                $mentionsSkipped
160            )
161        ];
162    }
163
164    /**
165     * Causes notifications to be fired for a Flow event.
166     * @param string $eventName The event that occurred. Choice of:
167     * * flow-post-reply
168     * * flow-topic-renamed
169     * * flow-post-edited
170     * @param array $data Associative array of parameters.
171     * * user: The user who made the change. Always required.
172     * * revision: The PostRevision created by the action. Always required.
173     * * title: The Title on which this Topic sits. Always required.
174     * * topic-workflow: The Workflow object for the topic. Always required.
175     * * topic-title: The Title of the Topic that the post belongs to. Required except for topic renames.
176     * * old-subject: The old subject of a Topic. Required for topic renames.
177     * * new-subject: The new subject of a Topic. Required for topic renames.
178     * @return Event[]
179     * @throws FlowException When $data contains unexpected types/values
180     */
181    public function notifyPostChange( $eventName, array $data ): array {
182        if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) {
183            return [];
184        }
185
186        $extraData = $data['extra-data'] ?? [];
187
188        $revision = $data['revision'];
189        if ( !$revision instanceof PostRevision ) {
190            throw new FlowException( 'Expected PostRevision but received ' . get_class( $revision ) );
191        }
192        $topicRevision = $data['topic-title'];
193        if ( !$topicRevision instanceof PostRevision ) {
194            throw new FlowException( 'Expected PostRevision but received ' . get_class( $topicRevision ) );
195        }
196        $topicWorkflow = $data['topic-workflow'];
197        if ( !$topicWorkflow instanceof Workflow ) {
198            throw new FlowException( 'Expected Workflow but received ' . get_class( $topicWorkflow ) );
199        }
200
201        $user = $revision->getUser();
202        [ $mentionedUsers, $mentionsSkipped ] = $this->getMentionedUsersAndSkipState( $revision );
203        $title = $topicWorkflow->getOwnerTitle();
204
205        $extraData['revision-id'] = $revision->getRevisionId();
206        $extraData['post-id'] = $revision->getPostId();
207        $extraData['topic-workflow'] = $topicWorkflow->getId();
208        $extraData['target-page'] = $topicWorkflow->getArticleTitle()->getArticleID();
209        // pass along mentioned users to other notification, so it knows who to ignore
210        $extraData['mentioned-users'] = $mentionedUsers;
211
212        switch ( $eventName ) {
213            case 'flow-post-reply':
214                $extraData += [
215                    'reply-to' => $revision->getReplyToId(),
216                    'content' => Utils::htmlToPlaintext( $revision->getContent(), $this->truncateLength, $this->language ),
217                    'topic-title' => $this->language->truncateForVisual( $topicRevision->getContent( 'topic-title-plaintext' ), 200 ),
218                ];
219
220                // if we're looking at the initial post (submitted along with the topic
221                // title), we don't want to send the flow-post-reply notification,
222                // because users will already receive flow-new-topic as well
223                if ( $this->isFirstPost( $revision, $topicWorkflow ) ) {
224                    // if users were mentioned here, we'll want to make sure
225                    // that they weren't also mentioned in the topic title (in
226                    // which case they would get 2 notifications...)
227                    if ( $mentionedUsers ) {
228                        [ $mentionedInTitle, $mentionsSkippedInTitle ] =
229                            $this->getMentionedUsersAndSkipState( $topicRevision );
230                        $mentionedUsers = array_diff_key( $mentionedUsers, $mentionedInTitle );
231                        $mentionsSkipped = $mentionsSkipped || $mentionsSkippedInTitle;
232                        $extraData['mentioned-users'] = $mentionedUsers;
233                    }
234
235                    return $this->generateMentionEvents(
236                        $revision,
237                        $topicRevision,
238                        $topicWorkflow,
239                        $user,
240                        $mentionedUsers,
241                        $mentionsSkipped
242                    );
243                }
244
245                break;
246            case 'flow-topic-renamed':
247                $previousRevision = $revision->getCollection()->getPrevRevision( $revision );
248                $extraData += [
249                    'old-subject' => $this->language->truncateForVisual( $previousRevision->getContent( 'topic-title-plaintext' ), 200 ),
250                    'new-subject' => $this->language->truncateForVisual( $revision->getContent( 'topic-title-plaintext' ), 200 ),
251                ];
252                break;
253            case 'flow-post-edited':
254                $extraData += [
255                    'content' => Utils::htmlToPlaintext( $revision->getContent(), $this->truncateLength, $this->language ),
256                    'topic-title' => $this->language->truncateForVisual( $topicRevision->getContent( 'topic-title-plaintext' ), 200 ),
257                ];
258                break;
259        }
260
261        $info = [
262            'agent' => $user,
263            'title' => $title,
264            'extra' => $extraData,
265        ];
266
267        // Allow a specific timestamp to be set - useful when importing existing data
268        if ( isset( $data['timestamp'] ) ) {
269            $info['timestamp'] = $data['timestamp'];
270        }
271
272        $events = [ Event::create( [ 'type' => $eventName ] + $info ) ];
273        if ( $title->getNamespace() === NS_USER_TALK ) {
274            $usertalkEvent = str_replace( 'flow-', 'flowusertalk-', $eventName );
275            $events[] = Event::create( [ 'type' => $usertalkEvent ] + $info );
276        }
277        return [
278            ...$events,
279            ...$this->generateMentionEvents(
280                $revision,
281                $topicRevision,
282                $topicWorkflow,
283                $user,
284                $mentionedUsers,
285                $mentionsSkipped
286            )
287        ];
288    }
289
290    /**
291     * Causes notifications to be fired for a Summary-related event.
292     * @param array $data Associative array of parameters.
293     * * revision: The PostRevision created by the action. Always required.
294     * * topic-title: The PostRevision object for the topic title. Always required.
295     * * topic-workflow: The Workflow object for the board. Always required.
296     * * extra-data: Additional data to pass along to Event extra.
297     * @return Event[]
298     * @throws FlowException When $data contains unexpected types/values
299     */
300    public function notifySummaryChange( array $data ): array {
301        if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) {
302            return [];
303        }
304
305        $revision = $data['revision'];
306        if ( !$revision instanceof PostSummary ) {
307            throw new FlowException( 'Expected PostSummary but received ' . get_class( $revision ) );
308        }
309        $topicRevision = $data['topic-title'];
310        if ( !$topicRevision instanceof PostRevision ) {
311            throw new FlowException( 'Expected PostRevision but received ' . get_class( $topicRevision ) );
312        }
313        $topicWorkflow = $data['topic-workflow'];
314        if ( !$topicWorkflow instanceof Workflow ) {
315            throw new FlowException( 'Expected Workflow but received ' . get_class( $topicWorkflow ) );
316        }
317
318        $user = $revision->getUser();
319        [ $mentionedUsers, $mentionsSkipped ] = $this->getMentionedUsersAndSkipState( $revision );
320
321        $extraData = [];
322        $extraData['content'] = Utils::htmlToPlaintext( $revision->getContent(), $this->truncateLength, $this->language );
323        $extraData['revision-id'] = $revision->getRevisionId();
324        $extraData['prev-revision-id'] = $revision->getPrevRevisionId();
325        $extraData['topic-workflow'] = $topicWorkflow->getId();
326        $extraData['topic-title'] = $this->language->truncateForVisual( $topicRevision->getContent( 'topic-title-plaintext' ), 200 );
327        $extraData['target-page'] = $topicWorkflow->getArticleTitle()->getArticleID();
328        // pass along mentioned users to other notification, so it knows who to ignore
329        $extraData['mentioned-users'] = $mentionedUsers;
330        $title = $topicWorkflow->getOwnerTitle();
331
332        $info = [
333            'agent' => $user,
334            'title' => $title,
335            'extra' => $extraData,
336        ];
337
338        // Allow a specific timestamp to be set - useful when importing existing data
339        if ( isset( $data['timestamp'] ) ) {
340            $info['timestamp'] = $data['timestamp'];
341        }
342
343        $events = [ Event::create( [ 'type' => 'flow-summary-edited' ] + $info ) ];
344        if ( $title->getNamespace() === NS_USER_TALK ) {
345            $events[] = Event::create( [ 'type' => 'flowusertalk-summary-edited' ] + $info );
346        }
347        return [
348            ...$events,
349            ...$this->generateMentionEvents(
350                $revision,
351                $topicRevision,
352                $topicWorkflow,
353                $user,
354                $mentionedUsers,
355                $mentionsSkipped
356            )
357        ];
358    }
359
360    /**
361     * Triggers notifications for a new topic.
362     * @param array $params Associative array of parameters, all required:
363     * * board-workflow: Workflow object for the Flow board.
364     * * topic-workflow: Workflow object for the new Topic.
365     * * topic-title: PostRevision object for the "topic post", containing the
366     *    title.
367     * * first-post: PostRevision object for the first post, or null when no first post.
368     * * user: The User who created the topic.
369     * @return Event[]
370     * @throws FlowException When $params contains unexpected types/values
371     */
372    public function notifyNewTopic( array $params ): array {
373        if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) {
374            // Nothing to do here.
375            return [];
376        }
377
378        $topicWorkflow = $params['topic-workflow'];
379        if ( !$topicWorkflow instanceof Workflow ) {
380            throw new FlowException( 'Expected Workflow but received ' . get_class( $topicWorkflow ) );
381        }
382        $topicTitle = $params['topic-title'];
383        if ( !$topicTitle instanceof PostRevision ) {
384            throw new FlowException( 'Expected PostRevision but received ' . get_class( $topicTitle ) );
385        }
386        $firstPost = $params['first-post'];
387        if ( $firstPost !== null && !$firstPost instanceof PostRevision ) {
388            throw new FlowException( 'Expected PostRevision but received ' . get_class( $firstPost ) );
389        }
390        $user = $topicTitle->getUser();
391        $boardWorkflow = $params['board-workflow'];
392        if ( !$boardWorkflow instanceof Workflow ) {
393            throw new FlowException( 'Expected Workflow but received ' . get_class( $boardWorkflow ) );
394        }
395
396        [ $mentionedUsers, $mentionsSkipped ] = $this->getMentionedUsersAndSkipState( $topicTitle );
397
398        $title = $boardWorkflow->getArticleTitle();
399        $events = [];
400        $eventData = [
401            'agent' => $user,
402            'title' => $title,
403            'extra' => [
404                'board-workflow' => $boardWorkflow->getId(),
405                'topic-workflow' => $topicWorkflow->getId(),
406                'post-id' => $firstPost ? $firstPost->getRevisionId() : null,
407                'topic-title' => $this->language->truncateForVisual( $topicTitle->getContent( 'topic-title-plaintext' ), 200 ),
408                'content' => $firstPost
409                    ? Utils::htmlToPlaintext( $firstPost->getContent(), $this->truncateLength, $this->language )
410                    : null,
411                // Force a read from primary database since this could be a new page
412                'target-page' => [
413                    $topicWorkflow->getOwnerTitle()->getArticleID( IDBAccessObject::READ_LATEST ),
414                    $topicWorkflow->getArticleTitle()->getArticleID( IDBAccessObject::READ_LATEST ),
415                ],
416                // pass along mentioned users to other notification, so it knows who to ignore
417                // also look at users mentioned in first post: if there are any, this
418                // (flow-new-topic) notification shouldn't go through (because they'll
419                // already receive the mention notification)
420                'mentioned-users' => $mentionedUsers,
421            ]
422        ];
423        $events[] = Event::create( [ 'type' => 'flow-new-topic' ] + $eventData );
424        if ( $title->getNamespace() === NS_USER_TALK ) {
425            $events[] = Event::create( [ 'type' => 'flowusertalk-new-topic' ] + $eventData );
426        }
427
428        return [
429            ...$events,
430            ...$this->generateMentionEvents(
431                $topicTitle,
432                $topicTitle,
433                $topicWorkflow,
434                $user,
435                $mentionedUsers,
436                $mentionsSkipped
437            )
438        ];
439    }
440
441    /**
442     * Triggers notifications when a topic is resolved or reopened.
443     *
444     * @param string $type flow-topic-resolved|flow-topic-reopened
445     * @param array $data
446     * @return Event[]
447     */
448    public function notifyTopicLocked( $type, array $data ): array {
449        if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) {
450            return [];
451        }
452
453        $revision = $data['revision'];
454        if ( !$revision instanceof PostRevision ) {
455            throw new FlowException( 'Expected PostSummary but received ' . get_class( $revision ) );
456        }
457        $topicWorkflow = $data['topic-workflow'];
458        if ( !$topicWorkflow instanceof Workflow ) {
459            throw new FlowException( 'Expected Workflow but received ' . get_class( $topicWorkflow ) );
460        }
461
462        $extraData = [];
463        $extraData['topic-workflow'] = $topicWorkflow->getId();
464        $extraData['topic-title'] = Utils::htmlToPlaintext( $revision->getContent( 'topic-title-html' ), 200, $this->language );
465        $extraData['target-page'] = $topicWorkflow->getArticleTitle()->getArticleID();
466        // I'll treat resolve & reopen as the same notification type, but pass the
467        // different type so presentation models can differentiate
468        $extraData['type'] = $type;
469        $title = $topicWorkflow->getOwnerTitle();
470
471        $info = [
472            'agent' => $revision->getUser(),
473            'title' => $title,
474            'extra' => $extraData,
475        ];
476
477        // Allow a specific timestamp to be set - useful when importing existing data
478        if ( isset( $data['timestamp'] ) ) {
479            $info['timestamp'] = $data['timestamp'];
480        }
481
482        $events = [ Event::create( [ 'type' => 'flow-topic-resolved' ] + $info ) ];
483        if ( $title->getNamespace() === NS_USER_TALK ) {
484            $events[] = Event::create( [ 'type' => 'flowusertalk-topic-resolved' ] + $info );
485        }
486        return $events;
487    }
488
489    public function notifyFlowEnabledOnTalkpage( User $user ) {
490        if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) {
491            // Nothing to do here.
492            return [];
493        }
494
495        $events = [];
496        $events[] = Event::create( [
497            'type' => 'flow-enabled-on-talkpage',
498            'agent' => $user,
499            'title' => $user->getTalkPage(),
500        ] );
501
502        return $events;
503    }
504
505    /**
506     * @param AbstractRevision $content The (post|topic|header) revision that contains the content of the mention
507     * @param PostRevision|null $topic Topic PostRevision object, if relevant (e.g. not for Header)
508     * @param Workflow $workflow
509     * @param User $user User who created the new post
510     * @param int[] $mentionedUsers
511     * @param bool $mentionsSkipped Were mentions skipped due to too many mentions being attempted?
512     * @return Event[]
513     * @throws \Flow\Exception\InvalidDataException
514     */
515    protected function generateMentionEvents(
516        AbstractRevision $content,
517        ?PostRevision $topic,
518        Workflow $workflow,
519        User $user,
520        array $mentionedUsers,
521        $mentionsSkipped
522    ): array {
523        global $wgEchoMentionStatusNotifications, $wgFlowMaxMentionCount;
524
525        if ( !$mentionedUsers ) {
526            return [];
527        }
528
529        $extraData = [];
530        $extraData['mentioned-users'] = $mentionedUsers;
531        $extraData['target-page'] = $workflow->getArticleTitle()->getArticleID();
532        // don't include topic content again if the notification IS in the title
533        $extraData['content'] = $content === $topic ? '' :
534            Utils::htmlToPlaintext( $content->getContent(), $this->truncateLength, $this->language );
535        // lets us differentiate between different revision types
536        $extraData['revision-type'] = $content->getRevisionType();
537
538        // additional data needed to render link to post
539        if ( $extraData['revision-type'] === 'post' ) {
540            $extraData['post-id'] = $content->getCollection()->getId();
541        }
542        // needed to render topic title text & link to topic
543        if ( $topic !== null ) {
544            $extraData['topic-workflow'] = $workflow->getId();
545            $extraData['topic-title'] = $this->language->truncateForVisual( $topic->getContent( 'topic-title-plaintext' ), 200 );
546        }
547
548        $events = [];
549        $events[] = Event::create( [
550            'type' => 'flow-mention',
551            'title' => $workflow->getOwnerTitle(),
552            'extra' => $extraData,
553            'agent' => $user,
554        ] );
555        if ( $wgEchoMentionStatusNotifications && $mentionsSkipped ) {
556            $extra = [
557                'topic-workflow' => $workflow->getId(),
558                'max-mentions' => $wgFlowMaxMentionCount,
559                // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
560                'section-title' => $extraData['topic-title'],
561                'failure-type' => 'too-many',
562            ];
563            if ( $content->getRevisionType() === 'post' ) {
564                $extra['post-id'] = $content->getCollection()->getId();
565            }
566            $events[] = Event::create( [
567                'type' => 'flow-mention-failure-too-many',
568                'title' => $workflow->getOwnerTitle(),
569                'extra' => $extra,
570                'agent' => $user,
571            ] );
572        }
573        return $events;
574    }
575
576    /**
577     * Analyses a PostRevision to determine which users are mentioned.
578     *
579     * @param AbstractRevision $revision The Post to analyse.
580     * @return array
581     *          0 => int[] Array of user IDs
582     *          1 => bool Were some mentions ignored due to $wgFlowMaxMentionCount?
583     * @phan-return array{0:int[],1:bool}
584     */
585    protected function getMentionedUsersAndSkipState( AbstractRevision $revision ) {
586        // At the moment, it is not possible to get a list of mentioned users from HTML
587        // unless that HTML comes from Parsoid. But VisualEditor (what is currently used
588        // to convert wikitext to HTML) does not currently use Parsoid.
589        $wikitext = $revision->getContentInWikitext();
590        $mentions = $this->getMentionedUsersFromWikitext( $wikitext );
591
592        // if this post had a previous revision (= this is an edit), we don't
593        // want to pick up on the same mentions as in the previous edit, only
594        // new mentions
595        $previousRevision = $revision->getCollection()->getPrevRevision( $revision );
596        if ( $previousRevision !== null ) {
597            $previousWikitext = $previousRevision->getContentInWikitext();
598            $previousMentions = $this->getMentionedUsersFromWikitext( $previousWikitext );
599            $mentions = array_diff( $mentions, $previousMentions );
600        }
601
602        return $this->filterMentionedUsers( $mentions, $revision );
603    }
604
605    /**
606     * Process an array of users linked to in a comment into a list of users
607     * who should actually be notified.
608     *
609     * Removes duplicates, anonymous users, self-mentions, and mentions of the
610     * owner of the talk page
611     * @param User[] $mentions
612     * @param AbstractRevision $revision The Post that is being examined.
613     * @return array
614     *          0 => int[] Array of user IDs
615     *          1 => bool Were some mentions ignored due to $wgFlowMaxMentionCount?
616     * @phan-return array{0:int[],1:bool}
617     */
618    protected function filterMentionedUsers( $mentions, AbstractRevision $revision ) {
619        global $wgFlowMaxMentionCount;
620
621        $outputMentions = [];
622        $mentionsSkipped = false;
623
624        foreach ( $mentions as $mentionedUser ) {
625            // Don't notify anonymous users
626            if ( !$mentionedUser->isRegistered() ) {
627                continue;
628            }
629
630            // Don't notify the user who made the post
631            if ( $mentionedUser->getId() == $revision->getUserId() ) {
632                continue;
633            }
634
635            if ( count( $outputMentions ) >= $wgFlowMaxMentionCount ) {
636                $mentionsSkipped = true;
637                break;
638            }
639
640            $outputMentions[$mentionedUser->getId()] = $mentionedUser->getId();
641        }
642
643        return [ $outputMentions, $mentionsSkipped ];
644    }
645
646    /**
647     * Examines a wikitext string and finds users that were mentioned
648     * @param string $wikitext
649     * @return User[]
650     */
651    protected function getMentionedUsersFromWikitext( $wikitext ) {
652        $title = Title::newMainPage(); // Bogus title used for parser
653
654        $options = ParserOptions::newFromAnon();
655
656        $output = MediaWikiServices::getInstance()->getParser()
657            ->parse( $wikitext, $title, $options );
658
659        $links = $output->getLinks();
660
661        if ( !isset( $links[NS_USER] ) || !is_array( $links[NS_USER] ) ) {
662            // Nothing
663            return [];
664        }
665
666        $users = [];
667        foreach ( $links[NS_USER] as $dbk => $page_id ) {
668            $user = User::newFromName( $dbk );
669            if ( !$user || !$user->isRegistered() ) {
670                continue;
671            }
672
673            $users[$user->getId()] = $user;
674        }
675
676        return $users;
677    }
678
679    /**
680     * Handler for EchoGetBundleRule hook, which defines the bundle rules for each notification
681     *
682     * @param Event $event
683     * @param string &$bundleString Determines how the notification should be bundled
684     * @return bool True for success
685     */
686    public static function onEchoGetBundleRules( $event, &$bundleString ) {
687        switch ( $event->getType() ) {
688            case 'flow-new-topic':
689            case 'flowusertalk-new-topic':
690                $board = $event->getExtraParam( 'board-workflow' );
691                if ( $board instanceof UUID ) {
692                    $bundleString = $event->getType() . '-' . $board->getAlphadecimal();
693                }
694                break;
695
696            case 'flow-post-reply':
697            case 'flowusertalk-post-reply':
698            case 'flow-post-edited':
699            case 'flowusertalk-post-edited':
700            case 'flow-summary-edited':
701            case 'flowusertalk-summary-edited':
702                $topic = $event->getExtraParam( 'topic-workflow' );
703                if ( $topic instanceof UUID ) {
704                    $bundleString = $event->getType() . '-' . $topic->getAlphadecimal();
705                }
706                break;
707
708            case 'flow-description-edited':
709            case 'flowusertalk-description-edited':
710                $headerId = $event->getExtraParam( 'collection-id' );
711                if ( $headerId instanceof UUID ) {
712                    $bundleString = $event->getType() . '-' . $headerId->getAlphadecimal();
713                }
714                break;
715        }
716        return true;
717    }
718
719    /**
720     * @param PostRevision $revision
721     * @param Workflow $workflow
722     * @return bool
723     */
724    protected function isFirstPost( PostRevision $revision, Workflow $workflow ) {
725        $postId = $revision->getPostId();
726        $workflowId = $workflow->getId();
727        $replyToId = $revision->getReplyToId();
728
729        // if the post is not a direct reply to the topic, it definitely can't be
730        // first post
731        if ( !$replyToId->equals( $workflowId ) ) {
732            return false;
733        }
734
735        /*
736         * We don't want to go fetch the entire topic tree, so we'll use a crude
737         * technique to figure out if we're dealing with the first post: check if
738         * they were posted at (almost) the exact same time.
739         * If they're more than 1 second apart, it's very likely a not-first-post
740         * (or a very slow server, upgrade your machine!). False positives on the
741         * other side are also very rare: who on earth can refresh the page, read
742         * the post and write a meaningful reply in just 1 second? :)
743         */
744        $diff = (int)$postId->getTimestamp( TS_UNIX ) - (int)$workflowId->getTimestamp( TS_UNIX );
745        return $diff <= 1;
746    }
747
748    /**
749     * Gets ID of topmost post
750     *
751     * This is the lowest-number post, numbering them using a pre-order depth-first
752     *  search
753     *
754     * @param Event[] $bundledEvents
755     * @return UUID|null Post ID, or null on failure
756     */
757    public function getTopmostPostId( array $bundledEvents ) {
758        $postIds = [];
759        foreach ( $bundledEvents as $event ) {
760            $postId = $event->getExtraParam( 'post-id' );
761            if ( $postId instanceof UUID ) {
762                $postIds[$postId->getAlphadecimal()] = $postId;
763            }
764        }
765
766        $rootPaths = $this->treeRepository->findRootPaths( $postIds );
767
768        // We do this so we don't have to walk the whole topic.
769        $deepestCommonRoot = $this->getDeepestCommonRoot( $rootPaths );
770
771        $subtree = $this->treeRepository->fetchSubtreeIdentityMap( $deepestCommonRoot );
772
773        $topmostPostId = $this->getFirstPreorderDepthFirst( $postIds, $deepestCommonRoot, $subtree );
774        return $topmostPostId;
775    }
776
777    /**
778     * Walks a (sub)tree in pre-order depth-first search order and return the first
779     *  post ID from a specified list
780     *
781     * @param array $relevantPostIds Associative array mapping alphadecimal post ID to
782     *  UUID post ID
783     * @param UUID $root Root node
784     * @param array $tree Tree structure
785     * @return UUID|null First post ID found, or null on failure
786     */
787    protected function getFirstPreorderDepthFirst( array $relevantPostIds, UUID $root, array $tree ) {
788        $rootAlpha = $root->getAlphadecimal();
789
790        if ( isset( $relevantPostIds[$rootAlpha] ) ) {
791            return $root;
792        }
793
794        if ( isset( $tree[$rootAlpha]['children'] ) ) {
795            $children = array_keys( $tree[$rootAlpha]['children'] );
796        } else {
797            $children = [];
798        }
799
800        foreach ( $children as $child ) {
801            $relevantPostId = $this->getFirstPreorderDepthFirst( $relevantPostIds, UUID::create( $child ), $tree );
802            if ( $relevantPostId !== null ) {
803                return $relevantPostId;
804            }
805        }
806
807        return null;
808    }
809
810    /**
811     * Gets the deepest common root post
812     *
813     * This is the root of the smallest subtree all the posts are in.
814     *
815     * @param array[] $rootPaths Associative array mapping post IDs to root paths
816     * @return UUID|null Common root, or null on failure
817     */
818    protected function getDeepestCommonRoot( array $rootPaths ) {
819        if ( count( $rootPaths ) == 0 ) {
820            return null;
821        }
822
823        $deepestRoot = null;
824        $possibleDeepestRoot = null;
825
826        $firstPath = reset( $rootPaths );
827        $pathLength = count( $firstPath );
828
829        for ( $i = 0; $i < $pathLength; $i++ ) {
830            $possibleDeepestRoot = $firstPath[$i];
831
832            foreach ( $rootPaths as $path ) {
833                if ( !isset( $path[$i] ) || !$path[$i]->equals( $possibleDeepestRoot ) ) {
834                    // Mismatch.  Return the last match we found
835                    return $deepestRoot;
836                }
837            }
838
839            $deepestRoot = $possibleDeepestRoot;
840        }
841
842        return $deepestRoot;
843    }
844
845    /**
846     * Moderate or unmoderate Flow notifications associated with a topic.
847     *
848     * @param UUID $topicId
849     * @param bool $moderated Whether the events need to be moderated or unmoderated
850     * @throws FlowException
851     */
852    public function moderateTopicNotifications( UUID $topicId, $moderated ) {
853        if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) {
854            // Nothing to do here.
855            return;
856        }
857
858        $title = Title::makeTitle( NS_TOPIC, ucfirst( $topicId->getAlphadecimal() ) );
859        $pageId = $title->getArticleID();
860        DeferredUpdates::addCallableUpdate( static function () use ( $pageId, $moderated ) {
861            $eventMapper = new EventMapper();
862            $eventIds = $eventMapper->fetchIdsByPage( $pageId );
863
864            ModerationController::moderate( $eventIds, $moderated );
865        } );
866    }
867
868    /**
869     * Moderate or unmoderate Flow notifications associated with a post within a topic.
870     *
871     * @param UUID $topicId
872     * @param UUID $postId
873     * @param bool $moderated Whether the events need to be moderated or unmoderated
874     * @throws FlowException
875     */
876    public function moderatePostNotifications( UUID $topicId, UUID $postId, $moderated ) {
877        if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) {
878            // Nothing to do here.
879            return;
880        }
881
882        $title = Title::makeTitle( NS_TOPIC, ucfirst( $topicId->getAlphadecimal() ) );
883        $pageId = $title->getArticleID();
884        DeferredUpdates::addCallableUpdate( static function () use ( $pageId, $postId, $moderated ) {
885            $eventMapper = new EventMapper();
886            $moderatedPostIdAlpha = $postId->getAlphadecimal();
887            $eventIds = [];
888
889            $events = $eventMapper->fetchByPage( $pageId );
890
891            foreach ( $events as $event ) {
892                /** @var UUID|string $eventPostId */
893                $eventPostId = $event->getExtraParam( 'post-id' );
894                $eventPostIdAlpha = $eventPostId instanceof UUID ? $eventPostId->getAlphadecimal() : $eventPostId;
895                if ( $eventPostIdAlpha === $moderatedPostIdAlpha ) {
896                    $eventIds[] = $event->getId();
897                }
898            }
899
900            ModerationController::moderate( $eventIds, $moderated );
901        } );
902    }
903}