Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
5.38% |
24 / 446 |
|
5.26% |
1 / 19 |
CRAP | |
0.00% |
0 / 1 |
Controller | |
5.38% |
24 / 446 |
|
5.26% |
1 / 19 |
9266.19 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
onBeforeCreateEchoEvent | |
0.00% |
0 / 41 |
|
0.00% |
0 / 1 |
2 | |||
notifyHeaderChange | |
0.00% |
0 / 38 |
|
0.00% |
0 / 1 |
42 | |||
notifyPostChange | |
0.00% |
0 / 77 |
|
0.00% |
0 / 1 |
182 | |||
notifySummaryChange | |
0.00% |
0 / 43 |
|
0.00% |
0 / 1 |
56 | |||
notifyNewTopic | |
0.00% |
0 / 49 |
|
0.00% |
0 / 1 |
110 | |||
notifyTopicLocked | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
42 | |||
notifyFlowEnabledOnTalkpage | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
generateMentionEvents | |
0.00% |
0 / 36 |
|
0.00% |
0 / 1 |
72 | |||
getMentionedUsersAndSkipState | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
filterMentionedUsers | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
30 | |||
getMentionedUsersFromWikitext | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
42 | |||
onEchoGetBundleRules | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
210 | |||
isFirstPost | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
getTopmostPostId | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
getFirstPreorderDepthFirst | |
81.82% |
9 / 11 |
|
0.00% |
0 / 1 |
5.15 | |||
getDeepestCommonRoot | |
84.62% |
11 / 13 |
|
0.00% |
0 / 1 |
6.13 | |||
moderateTopicNotifications | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
moderatePostNotifications | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
30 |
1 | <?php |
2 | |
3 | namespace Flow\Notifications; |
4 | |
5 | use Flow\Conversion\Utils; |
6 | use Flow\Exception\FlowException; |
7 | use Flow\Model\AbstractRevision; |
8 | use Flow\Model\Header; |
9 | use Flow\Model\PostRevision; |
10 | use Flow\Model\PostSummary; |
11 | use Flow\Model\UUID; |
12 | use Flow\Model\Workflow; |
13 | use Flow\Repository\TreeRepository; |
14 | use MediaWiki\Config\ServiceOptions; |
15 | use MediaWiki\Deferred\DeferredUpdates; |
16 | use MediaWiki\Extension\Notifications\Controller\ModerationController; |
17 | use MediaWiki\Extension\Notifications\Mapper\EventMapper; |
18 | use MediaWiki\Extension\Notifications\Model\Event; |
19 | use MediaWiki\Language\Language; |
20 | use MediaWiki\MediaWikiServices; |
21 | use MediaWiki\Parser\ParserOptions; |
22 | use MediaWiki\Registration\ExtensionRegistry; |
23 | use MediaWiki\Title\Title; |
24 | use MediaWiki\User\User; |
25 | use Wikimedia\Rdbms\IDBAccessObject; |
26 | |
27 | class 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 | } |