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