PluginProbe
UpStream: a Project Management Plugin for WordPress / trunk
UpStream: a Project Management Plugin for WordPress vtrunk
trunk 1.39.0 1.39.1 1.39.2 1.39.3 2.0.7 2.1.0
← All changes | includes/class-comments.php +1408 -1408 2.0.7trunk View file →
@@ -1,1408 +1,1408 @@
1 -<?php
2 -/**
3 - * A controller handling incoming requests regarding comments on UpStream items.
4 - *
5 - * @package UpStream
6 - */
7 -
8 -namespace UpStream;
9 -
10 -// Prevent direct access.
11 -if ( ! defined( 'ABSPATH' ) ) {
12 - exit;
13 -}
14 -
15 -use UpStream\Traits\Singleton;
16 -
17 -/**
18 - * This class will act as a controller handling incoming requests regarding comments on UpStream items.
19 - *
20 - * @since 1.13.0
21 - */
22 -class Comments {
23 - use Singleton;
24 -
25 - /**
26 - * The current full namespace.
27 - *
28 - * @since 1.13.0
29 - * @access private
30 - * @static
31 - *
32 - * @var string $namespace
33 - */
34 - private static $namespace;
35 -
36 - /**
37 - * Class constructor.
38 - *
39 - * @since 1.13.0
40 - */
41 - public function __construct() {
42 - self::$namespace = get_class(
43 - empty( self::$instance )
44 - ? $this
45 - : self::$instance
46 - );
47 -
48 - $this->attach_hooks();
49 -
50 - self::remove_comment_type();
51 - }
52 -
53 - /**
54 - * Attach all relevant actions to handle comments.
55 - *
56 - * @since 1.13.0
57 - * @access private
58 - */
59 - private function attach_hooks() {
60 - add_action( 'wp_ajax_upstream:project.add_comment', array( self::$namespace, 'store_comment' ) );
61 - add_action( 'wp_ajax_upstream:project.add_comment_reply', array( self::$namespace, 'store_comment_reply' ) );
62 - add_action( 'wp_ajax_upstream:project.trash_comment', array( self::$namespace, 'trash_comment' ) );
63 - add_action( 'wp_ajax_upstream:project.unapprove_comment', array( self::$namespace, 'unapprove_comment' ) );
64 - add_action( 'wp_ajax_upstream:project.approve_comment', array( self::$namespace, 'approve_comment' ) );
65 - add_action( 'wp_ajax_upstream:project.fetch_comments', array( self::$namespace, 'fetch_comments' ) );
66 -
67 - add_filter( 'comment_notification_subject', array( self::$namespace, 'define_notification_header' ), 10, 2 );
68 - add_filter( 'comment_notification_recipients', array( self::$namespace, 'define_notification_recipients' ), 10, 2 );
69 - add_filter( 'comment_notification_text', array( self::$namespace, 'add_item_title_to_notification' ), 10, 2 );
70 -
71 - add_filter( 'upstream_allowed_tags_in_comments', array( self::$namespace, 'filter_allowed_tags' ) );
72 - add_filter(
73 - 'comment_notification_headers',
74 - array( self::$namespace, 'filter_comment_notification_headers' ),
75 - 10,
76 - 2
77 - );
78 -
79 - add_filter( 'comment_notification_text', array( self::$namespace, 'filter_comment_notification_text' ), 10, 2 );
80 - }
81 -
82 - /**
83 - * Empties the comment_type="comment" column from UpStream comments.
84 - *
85 - * @since 1.16.3
86 - * @static
87 - */
88 - public static function remove_comment_type() {
89 - $did_remove_comments_type = (bool) get_option( 'upstream:remove_comments_type' );
90 -
91 - if ( ! $did_remove_comments_type ) {
92 - global $wpdb;
93 -
94 - $wpdb->query(
95 - sprintf(
96 - 'UPDATE `%s` AS `comment`
97 - LEFT JOIN `%s` AS `post`
98 - ON `post`.`ID` = `comment`.`comment_post_ID`
99 - SET `comment_type` = ""
100 - WHERE `comment_type` = "comment"
101 - AND `post_type` = "project"',
102 - $wpdb->prefix . 'comments',
103 - $wpdb->prefix . 'posts'
104 - )
105 - );
106 -
107 - update_option( 'upstream:remove_comments_type', 1 );
108 - }
109 - }
110 -
111 - /**
112 - * Filter allowed tags.
113 - *
114 - * @param array $allowed_tags Allowed tags.
115 - *
116 - * @return array
117 - */
118 - public static function filter_allowed_tags( $allowed_tags ) {
119 - global $allowedtags;
120 -
121 - // Add default allowed tags.
122 - $allowed_tags = array_merge( $allowed_tags, $allowedtags );
123 -
124 - // Add basic tags.
125 - if ( ! array_key_exists( 'p', $allowed_tags ) ) {
126 - $allowed_tags['p'] = array(
127 - 'class' => true,
128 - 'id' => true,
129 - );
130 - }
131 -
132 - if ( ! array_key_exists( 'br', $allowed_tags ) ) {
133 - $allowed_tags['br'] = array();
134 - }
135 -
136 - if ( ! array_key_exists( 'strong', $allowed_tags ) ) {
137 - $allowed_tags['strong'] = array(
138 - 'class' => true,
139 - 'id' => true,
140 - );
141 - }
142 -
143 - if ( ! array_key_exists( 'em', $allowed_tags ) ) {
144 - $allowed_tags['em'] = array(
145 - 'class' => true,
146 - 'id' => true,
147 - );
148 - }
149 -
150 - if ( ! array_key_exists( 'span', $allowed_tags ) ) {
151 - $allowed_tags['span'] = array(
152 - 'class' => true,
153 - 'id' => true,
154 - 'style' => true,
155 - );
156 - }
157 -
158 - if ( ! array_key_exists( 'del', $allowed_tags ) ) {
159 - $allowed_tags['del'] = array(
160 - 'class' => true,
161 - 'id' => true,
162 - );
163 - }
164 -
165 - if ( ! array_key_exists( 'ul', $allowed_tags ) ) {
166 - $allowed_tags['ul'] = array(
167 - 'class' => true,
168 - 'id' => true,
169 - );
170 - }
171 -
172 - if ( ! array_key_exists( 'ol', $allowed_tags ) ) {
173 - $allowed_tags['ol'] = array(
174 - 'class' => true,
175 - 'id' => true,
176 - );
177 - }
178 -
179 - if ( ! array_key_exists( 'li', $allowed_tags ) ) {
180 - $allowed_tags['li'] = array(
181 - 'class' => true,
182 - 'id' => true,
183 - );
184 - }
185 -
186 - if ( ! array_key_exists( 'a', $allowed_tags ) ) {
187 - $allowed_tags['a'] = array(
188 - 'class' => true,
189 - 'id' => true,
190 - 'href' => true,
191 - 'charset' => true,
192 - 'name' => true,
193 - 'rel' => true,
194 - 'target' => true,
195 - 'type' => true,
196 - );
197 - } else {
198 - $allowed_tags['a']['class'] = true;
199 - $allowed_tags['a']['id'] = true;
200 - $allowed_tags['a']['href'] = true;
201 - $allowed_tags['a']['charset'] = true;
202 - $allowed_tags['a']['name'] = true;
203 - $allowed_tags['a']['rel'] = true;
204 - $allowed_tags['a']['target'] = true;
205 - $allowed_tags['a']['type'] = true;
206 - }
207 -
208 - // If the current can't post images, we return current supported tags.
209 - if ( ! current_user_can( 'upstream_comment_images' ) ) {
210 - return $allowed_tags;
211 - }
212 -
213 - // The user can post images, so let's allow the img tag.
214 - if ( ! is_array( $allowed_tags ) ) {
215 - $allowed_tags = array();
216 - }
217 -
218 - $allowed_tags['img'] = array(
219 - 'class' => true,
220 - 'src' => true,
221 - 'alt' => true,
222 - 'width' => true,
223 - 'height' => true,
224 - );
225 -
226 - return $allowed_tags;
227 - }
228 -
229 - /**
230 - * AJAX endpoint that stores a new comment.
231 - *
232 - * @throws \Exception Set the error message.
233 - * @since 1.13.0
234 - * @static
235 - */
236 - public static function store_comment() {
237 - header( 'Content-Type: application/json' );
238 -
239 - $post_data = isset( $_POST ) ? wp_unslash( $_POST ) : array();
240 - $response = array(
241 - 'success' => false,
242 - 'error' => null,
243 - );
244 -
245 - try {
246 - // Check if the request payload is potentially invalid.
247 - if (
248 - ! defined( 'DOING_AJAX' )
249 - || ! DOING_AJAX
250 - || empty( $post_data )
251 - || ! isset( $post_data['nonce'] )
252 - || ! isset( $post_data['project_id'] )
253 - || ! isset( $post_data['item_type'] )
254 - || ! self::is_item_type_valid( sanitize_text_field( $post_data['item_type'] ) )
255 - || ! isset( $post_data['content'] )
256 - ) {
257 - throw new \Exception( __( 'Invalid request.', 'upstream' ) );
258 - }
259 -
260 - // Prepare data to verify nonce.
261 - $comment_target_item_type = strtolower( sanitize_text_field( $post_data['item_type'] ) );
262 - if ( 'project' !== $comment_target_item_type ) {
263 - if (
264 - ! isset( $post_data['item_id'] )
265 - || empty( $post_data['item_id'] )
266 - ) {
267 - throw new \Exception( __( 'Invalid item.', 'upstream' ) );
268 - }
269 -
270 - // non-numeric id.
271 - $item_id = sanitize_text_field( $post_data['item_id'] );
272 -
273 - $nonce_identifier = 'upstream:project.' . $comment_target_item_type . 's.add_comment';
274 - } else {
275 - $item_id = absint( $post_data['project_id'] );
276 - $nonce_identifier = 'upstream:project.add_comment';
277 - }
278 -
279 - // Verify nonce.
280 - if ( ! check_ajax_referer( $nonce_identifier, 'nonce', false ) ) {
281 - throw new \Exception( __( 'Invalid nonce.', 'upstream' ) );
282 - }
283 -
284 - // Check if the project exists.
285 - $project_id = absint( $post_data['project_id'] );
286 - if ( $project_id <= 0 ) {
287 - throw new \Exception( __( 'Invalid Project.', 'upstream' ) );
288 - }
289 -
290 - // Check if commenting is disabled on the given project.
291 - if ( upstream_are_comments_disabled( $project_id ) ) {
292 - throw new \Exception( __( 'Commenting is disabled on this project.', 'upstream' ) );
293 - }
294 -
295 - // Check if the user has enough permissions to insert a new comment.
296 - if ( ! upstream_can_access_field( 'publish_project_discussion', $comment_target_item_type, $item_id, UPSTREAM_ITEM_TYPE_PROJECT, $project_id, 'comments', UPSTREAM_PERMISSIONS_ACTION_EDIT, true ) ) {
297 - throw new \Exception( __( "You're not allowed to do this.", 'upstream' ) );
298 - }
299 -
300 - $user_id = get_current_user_id();
301 -
302 - $comment_content = stripslashes( wp_kses_post( $post_data['content'] ) );
303 -
304 - $item_title = isset( $post_data['item_title'] ) ? sanitize_text_field( $post_data['item_title'] ) : '';
305 -
306 - $comment = new Comment( $comment_content, $project_id, $user_id );
307 - $server = isset( $_SERVER ) ? wp_unslash( $_SERVER ) : array();
308 -
309 - $comment->created_by->ip = preg_replace( '/[^0-9a-fA-F:., ]/', '', sanitize_text_field( $server['REMOTE_ADDR'] ) );
310 - $comment->created_by->agent = isset( $server['HTTP_USER_AGENT'] ) ? sanitize_text_field( $server['HTTP_USER_AGENT'] ) : null;
311 -
312 - $comment->save();
313 -
314 - update_comment_meta( $comment->id, 'type', $comment_target_item_type );
315 -
316 - if ( 'project' !== $comment_target_item_type ) {
317 - update_comment_meta( $comment->id, 'id', $item_id );
318 - // We store the item title here because of the project's data structure.
319 - // It is faster to retrieve from metadata then seek item by item from a project.
320 - update_comment_meta( $comment->id, 'title', $item_title );
321 - }
322 -
323 - wp_new_comment_notify_moderator( $comment->id );
324 - wp_notify_postauthor( $comment->id );
325 -
326 - $use_admin_layout = ! isset( $post_data['teeny'] ) ? true : boolval( $post_data['teeny'] ) === false;
327 -
328 - $response['comment_html'] = stripslashes( $comment->render( true, $use_admin_layout ) );
329 -
330 - $response['success'] = true;
331 - } catch ( \Exception $e ) {
332 - $response['error'] = $e->getMessage();
333 - }
334 -
335 - wp_send_json( $response );
336 - }
337 -
338 -
339 - /**
340 - * Check if the item type is valid.
341 - *
342 - * @since 1.13.0
343 - * @static
344 - *
345 - * @param string $item_type Value to be validated.
346 - * @throws \Exception Set error message.
347 - *
348 - * @return bool
349 - */
350 - public static function is_item_type_valid( $item_type ) {
351 - $item_types = array( 'project', 'milestone', 'task', 'bug', 'file' );
352 -
353 - return in_array( $item_type, $item_types );
354 - }
355 -
356 - /**
357 - * AJAX endpoint that adds a new comment reply.
358 - *
359 - * @throws \Exception Set the error message.
360 - * @since 1.13.0
361 - * @static
362 - */
363 - public static function store_comment_reply() {
364 - header( 'Content-Type: application/json' );
365 -
366 - $post_data = isset( $_POST ) ? wp_unslash( $_POST ) : array();
367 - $server = isset( $_SERVER ) ? wp_unslash( $_SERVER ) : array();
368 - $response = array(
369 - 'success' => false,
370 - 'error' => null,
371 - );
372 -
373 - try {
374 - // Check if the request payload is potentially invalid.
375 - if (
376 - ! defined( 'DOING_AJAX' )
377 - || ! DOING_AJAX
378 - || empty( $post_data )
379 - || ! isset( $post_data['nonce'] )
380 - || ! isset( $post_data['project_id'] )
381 - || ! isset( $post_data['item_type'] )
382 - || ! self::is_item_type_valid( sanitize_text_field( $post_data['item_type'] ) )
383 - || ! isset( $post_data['content'] )
384 - || ! isset( $post_data['parent_id'] )
385 - || ! is_numeric( sanitize_text_field( $post_data['parent_id'] ) )
386 - || ! check_ajax_referer( 'upstream:project.add_comment_reply:' . sanitize_text_field( $post_data['parent_id'] ), 'nonce', false )
387 - ) {
388 - throw new \Exception( __( 'Invalid request.', 'upstream' ) );
389 - }
390 -
391 - // could be alnum ID.
392 - $item_id = sanitize_text_field( $post_data['item_id'] );
393 -
394 - // Check if the project exists.
395 - $project_id = absint( $post_data['project_id'] );
396 - if ( $project_id <= 0 ) {
397 - throw new \Exception( __( 'Invalid Project.', 'upstream' ) );
398 - }
399 -
400 - $comment_target_item_type = strtolower( sanitize_text_field( $post_data['item_type'] ) );
401 - if ( 'project' !== $comment_target_item_type ) {
402 - if (
403 - ! isset( $post_data['item_id'] )
404 - || empty( $post_data['item_id'] )
405 - ) {
406 - throw new \Exception( __( 'Invalid request.', 'upstream' ) );
407 - }
408 - } else {
409 - $item_id = $project_id;
410 - }
411 -
412 - // Check if the user has enough permissions to insert a new comment.
413 - if ( ! upstream_can_access_field( 'publish_project_discussion', $comment_target_item_type, $item_id, UPSTREAM_ITEM_TYPE_PROJECT, $project_id, 'comments', UPSTREAM_PERMISSIONS_ACTION_EDIT, true ) ) {
414 - throw new \Exception( __( "You're not allowed to do this.", 'upstream' ) );
415 - }
416 -
417 - // Check if commenting is disabled on the given project.
418 - if ( upstream_are_comments_disabled( $project_id ) ) {
419 - throw new \Exception( __( 'Commenting is disabled on this project.', 'upstream' ) );
420 - }
421 -
422 - $user_id = get_current_user_id();
423 -
424 - $comment = new Comment( stripslashes( wp_kses_post( $post_data['content'] ) ), $project_id, $user_id );
425 - $comment->parent_id = absint( $post_data['parent_id'] );
426 - $comment->created_by->ip = preg_replace( '/[^0-9a-fA-F:., ]/', '', sanitize_text_field( $server['REMOTE_ADDR'] ) );
427 - $comment->created_by->agent = isset( $server['HTTP_USER_AGENT'] ) ? sanitize_textarea_field( $server['HTTP_USER_AGENT'] ) : null;
428 -
429 - $comment->save();
430 -
431 - update_comment_meta( $comment->id, 'type', $comment_target_item_type );
432 -
433 - if ( 'project' !== $comment_target_item_type ) {
434 - update_comment_meta( $comment->id, 'id', sanitize_text_field( $post_data['item_id'] ) );
435 - }
436 -
437 - $use_admin_layout = ! isset( $post_data['teeny'] ) ? true : boolval( $post_data['teeny'] ) === false;
438 -
439 - $parent = get_comment( $comment->parent_id );
440 -
441 - $comments_cache = array(
442 - $parent->comment_ID => json_decode(
443 - json_encode(
444 - array(
445 - 'created_by' => array(
446 - 'name' => $parent->comment_author,
447 - ),
448 - )
449 - )
450 - ),
451 - );
452 -
453 - $response['comment_html'] = stripslashes( $comment->render( true, $use_admin_layout, $comments_cache ) );
454 -
455 - wp_new_comment_notify_moderator( $comment->id );
456 - wp_notify_postauthor( $comment->id );
457 -
458 - $response['success'] = true;
459 - } catch ( \Exception $e ) {
460 - $response['error'] = $e->getMessage();
461 - }
462 -
463 - wp_send_json( $response );
464 - }
465 -
466 - /**
467 - * AJAX endpoint that trashes a comment.
468 - *
469 - * @throws \Exception Set the error message.
470 - * @since 1.13.0
471 - * @static
472 - */
473 - public static function trash_comment() {
474 - header( 'Content-Type: application/json' );
475 -
476 - $post_data = isset( $_POST ) ? wp_unslash( $_POST ) : array();
477 - $response = array(
478 - 'success' => false,
479 - 'error' => null,
480 - );
481 -
482 - try {
483 - // Check if the request payload is potentially invalid.
484 - if (
485 - ! defined( 'DOING_AJAX' )
486 - || ! DOING_AJAX
487 - || empty( $post_data )
488 - || ! isset( $post_data['nonce'] )
489 - || ! isset( $post_data['project_id'] )
490 - || ! isset( $post_data['comment_id'] )
491 - || ! check_ajax_referer( 'upstream:project.trash_comment:' . sanitize_textarea_field( $post_data['comment_id'] ), 'nonce', false )
492 - ) {
493 - throw new \Exception( __( 'Invalid request.', 'upstream' ) );
494 - }
495 -
496 - // Check if the project exists.
497 - $project_id = absint( $post_data['project_id'] );
498 - if ( $project_id <= 0 ) {
499 - throw new \Exception( __( 'Invalid Project.', 'upstream' ) );
500 - }
501 -
502 - // Check if the Discussion/Comments section is disabled for the current project.
503 - if ( upstream_are_comments_disabled( $project_id ) ) {
504 - throw new \Exception( __( 'Comments are disabled for this project.', 'upstream' ) );
505 - }
506 -
507 - // Check if the parent comment exists.
508 - $comment_id = absint( $post_data['comment_id'] );
509 - $comment = get_comment( $comment_id );
510 -
511 - if ( empty( $comment )
512 - // Check if the comment belongs to that project.
513 - || (
514 - isset( $comment->comment_post_ID )
515 - && (int) $comment->comment_post_ID !== $project_id
516 - )
517 - ) {
518 - throw new \Exception( _x( 'Comment not found.', 'Removing a comment in projects', 'upstream' ) );
519 - }
520 -
521 - $user_id = (int) get_current_user_id();
522 -
523 - if ( ! upstream_admin_permissions( 'delete_project_discussion' )
524 - && ! current_user_can( 'moderate_comments' )
525 - && (int) $comment->user_id !== $user_id
526 - ) {
527 - throw new \Exception( __( "You're not allowed to do this.", 'upstream' ) );
528 - }
529 -
530 - $success = wp_trash_comment( $comment );
531 - if ( ! $success ) {
532 - throw new \Exception( __( "It wasn't possible to delete this comment.", 'upstream' ) );
533 - }
534 -
535 - $response['success'] = true;
536 - } catch ( \Exception $e ) {
537 - $response['error'] = $e->getMessage();
538 - }
539 -
540 - wp_send_json( $response );
541 - }
542 -
543 - /**
544 - * AJAX endpoint that unapproves a comment.
545 - *
546 - * @since 1.13.0
547 - * @static
548 - */
549 - public static function unapprove_comment() {
550 - header( 'Content-Type: application/json' );
551 -
552 - $post_data = isset( $_POST ) ? wp_unslash( $_POST ) : array();
553 - $comment_id = isset( $post_data['comment_id'] ) ? absint( $post_data['comment_id'] ) : 0;
554 -
555 - check_ajax_referer( 'upstream:project.unapprove_comment:' . $comment_id, 'nonce' );
556 -
557 - $response = array(
558 - 'success' => false,
559 - 'error' => null,
560 - );
561 -
562 - try {
563 - $comment = self::toggle_comment_approval_status( $comment_id, false );
564 -
565 - $comments = array();
566 - if ( $comment->parent_id > 0 ) {
567 - $parent_comment = get_comment( $comment->parent_id );
568 - if ( is_numeric( $parent_comment->comment_approved ) ) {
569 - if ( (bool) $parent_comment->comment_approved ) {
570 - $comments = array(
571 - $comment->parent_id => json_decode(
572 - json_encode(
573 - array(
574 - 'created_by' => array(
575 - 'name' => $parent_comment->comment_author,
576 - ),
577 - )
578 - )
579 - ),
580 - );
581 - } else {
582 - $user = wp_get_current_user();
583 - $user_has_admin_capabilities = upstream_is_user_either_manager_or_admin( $user );
584 - $user_can_moderate_comments = ! $user_has_admin_capabilities ? user_can(
585 - $user,
586 - 'moderate_comments'
587 - ) : true;
588 -
589 - if ( $user_can_moderate_comments ) {
590 - $comments = array(
591 - $comment->parent_id => json_decode(
592 - json_encode(
593 - array(
594 - 'created_by' => array(
595 - 'name' => $parent_comment->comment_author,
596 - ),
597 - )
598 - )
599 - ),
600 - );
601 - }
602 - }
603 - }
604 - unset( $parent_comment );
605 - }
606 -
607 - $use_admin_layout = ! isset( $post_data['teeny'] ) ? true : boolval( $post_data['teeny'] ) === false;
608 -
609 - $response['comment_html'] = $comment->render( true, $use_admin_layout, $comments );
610 -
611 - wp_new_comment_notify_moderator( $comment->id );
612 -
613 - $response['success'] = true;
614 - } catch ( \Exception $e ) {
615 - $response['error'] = $e->getMessage();
616 - }
617 -
618 - wp_send_json( $response );
619 - }
620 -
621 - /**
622 - * Either approves/unapproves a given comment.
623 - * This method is called by the correspondent AJAX endpoints.
624 - *
625 - * @since 1.13.0
626 - * @access private
627 - * @static
628 - *
629 - * @throws \Exception When something went wrong or failed on validations.
630 - *
631 - * @param int $comment_id Comment ID being edited.
632 - * @param bool $is_approved Either the comment will be approved or not.
633 - */
634 - private static function toggle_comment_approval_status( $comment_id, $is_approved ) {
635 - $post_data = isset( $_POST ) ? wp_unslash( $_POST ) : array();
636 -
637 - // Check if the request payload is potentially invalid.
638 - if (
639 - ! defined( 'DOING_AJAX' )
640 - || ! DOING_AJAX
641 - || empty( $post_data )
642 - || ! isset( $post_data['nonce'] )
643 - || ! isset( $post_data['project_id'] )
644 - || ! isset( $post_data['comment_id'] )
645 - || ! check_ajax_referer(
646 - 'upstream:project.' . ( $is_approved ? 'approve_comment' : 'unapprove_comment' ) . ':' . sanitize_textarea_field( $post_data['comment_id'] ),
647 - 'nonce',
648 - false
649 - )
650 - ) {
651 - throw new \Exception( __( 'Invalid request.', 'upstream' ) );
652 - }
653 -
654 - // Check if the user has enough permissions to do this.
655 - if ( ! current_user_can( 'moderate_comments' ) ) {
656 - throw new \Exception( __( "You're not allowed to do this.", 'upstream' ) );
657 - }
658 -
659 - // Check if the project potentially exists.
660 - $project_id = absint( $post_data['project_id'] );
661 - if ( $project_id <= 0 ) {
662 - // translators: %s: parameter name.
663 - throw new \Exception( sprintf( __( 'Invalid "%s" parameter.', 'upstream' ), 'project_id' ) );
664 - }
665 -
666 - // Check if the Discussion/Comments section is disabled for the current project.
667 - if ( upstream_are_comments_disabled( $project_id ) ) {
668 - throw new \Exception( __( 'Comments are disabled for this project.', 'upstream' ) );
669 - }
670 -
671 - $cid = isset( $post_data['comment_id'] ) ? absint( $post_data['comment_id'] ) : 0;
672 - $comment = Comment::load( $cid );
673 - if ( ! ( $comment instanceof Comment ) ) {
674 - throw new \Exception( __( 'Comment not found.', 'upstream' ) );
675 - }
676 -
677 - $success = (bool) $is_approved ? $comment->approve() : $comment->unapprove();
678 - if ( ! $success ) {
679 - throw new \Exception( __( 'Unable to save the data into database.', 'upstream' ) );
680 - }
681 -
682 - return $comment;
683 - }
684 -
685 - /**
686 - * AJAX endpoint that approves a comment.
687 - *
688 - * @since 1.13.0
689 - * @static
690 - */
691 - public static function approve_comment() {
692 - header( 'Content-Type: application/json' );
693 -
694 - $post_data = isset( $_POST ) ? wp_unslash( $_POST ) : array();
695 - $comment_id = isset( $post_data['comment_id'] ) ? absint( $post_data['comment_id'] ) : 0;
696 -
697 - check_ajax_referer( 'upstream:project.approve_comment:' . $comment_id, 'nonce' );
698 -
699 - $response = array(
700 - 'success' => false,
701 - 'error' => null,
702 - );
703 -
704 - try {
705 - $comment_id = isset( $post_data['comment_id'] ) ? absint( $post_data['comment_id'] ) : 0;
706 - $comment = self::toggle_comment_approval_status( $comment_id, true );
707 -
708 - $comments = array();
709 - if ( $comment->parent_id > 0 ) {
710 - $parent_comment = get_comment( $comment->parent_id );
711 - if ( is_numeric( $parent_comment->comment_approved ) ) {
712 - if ( (bool) $parent_comment->comment_approved ) {
713 - $comments = array(
714 - $comment->parent_id => json_decode(
715 - wp_json_encode(
716 - array(
717 - 'created_by' => array(
718 - 'name' => $parent_comment->comment_author,
719 - ),
720 - )
721 - )
722 - ),
723 - );
724 - } else {
725 - $user = wp_get_current_user();
726 - $user_has_admin_capabilities = upstream_is_user_either_manager_or_admin( $user );
727 - $user_can_moderate_comments = ! $user_has_admin_capabilities ? user_can(
728 - $user,
729 - 'moderate_comments'
730 - ) : true;
731 -
732 - if ( $user_can_moderate_comments ) {
733 - $comments = array(
734 - $comment->parent_id => json_decode(
735 - json_encode(
736 - array(
737 - 'created_by' => array(
738 - 'name' => $parent_comment->comment_author,
739 - ),
740 - )
741 - )
742 - ),
743 - );
744 - }
745 - }
746 - }
747 - unset( $parent_comment );
748 - }
749 -
750 - $use_admin_layout = ! isset( $post_data['teeny'] ) ? true : boolval( $post_data['teeny'] ) === false;
751 -
752 - $response['comment_html'] = $comment->render( true, $use_admin_layout, $comments );
753 -
754 - $response['success'] = true;
755 - } catch ( \Exception $e ) {
756 - $response['error'] = $e->getMessage();
757 - }
758 -
759 - wp_send_json( $response );
760 - }
761 -
762 - /**
763 - * AJAX endpoint that fetches all comments from a given item/project.
764 - *
765 - * @throws \Exception When something went wrong or failed on validations.
766 - * @since 1.13.0
767 - * @static
768 - */
769 - public static function fetch_comments() {
770 - header( 'Content-Type: application/json' );
771 -
772 - $get_data = isset( $_GET ) ? wp_unslash( $_GET ) : array();
773 - $response = array(
774 - 'success' => false,
775 - 'data' => array(),
776 - 'error' => null,
777 - );
778 -
779 - try {
780 - // Check if the request payload is potentially invalid.
781 - if (
782 - ! defined( 'DOING_AJAX' )
783 - || ! DOING_AJAX
784 - || empty( $get_data )
785 - || ! isset( $get_data['nonce'] )
786 - || ! isset( $get_data['project_id'] )
787 - || ! isset( $get_data['item_type'] )
788 - || ! self::is_item_type_valid( sanitize_textarea_field( $get_data['item_type'] ) )
789 - ) {
790 - throw new \Exception( __( 'Invalid request.', 'upstream' ) );
791 - }
792 -
793 - // Check if the project potentially exists.
794 - $project_id = absint( $get_data['project_id'] );
795 - if ( $project_id <= 0 ) {
796 - throw new \Exception( __( 'Invalid Project.', 'upstream' ) );
797 - }
798 -
799 - // Prepare data to verify nonce.
800 - $comment_target_item_type = strtolower( sanitize_text_field( $get_data['item_type'] ) );
801 - $item_id = null;
802 - if ( 'project' !== $comment_target_item_type ) {
803 - if (
804 - ! isset( $get_data['item_id'] )
805 - || empty( $get_data['item_id'] )
806 - ) {
807 - throw new \Exception( __( 'Invalid request.', 'upstream' ) );
808 - }
809 -
810 - // non-numeric id.
811 - $item_id = sanitize_textarea_field( $get_data['item_id'] );
812 -
813 - $nonce_identifier = 'upstream:project.' . $comment_target_item_type . 's.fetch_comments';
814 - } else {
815 - $nonce_identifier = 'upstream:project.fetch_comments';
816 - }
817 -
818 - // Verify nonce.
819 - if ( ! check_ajax_referer( $nonce_identifier, 'nonce', false ) ) {
820 - throw new \Exception( __( 'Invalid nonce.', 'upstream' ) );
821 - }
822 -
823 - // Check if commenting is disabled on the given project.
824 - if ( upstream_are_comments_disabled( $project_id ) ) {
825 - throw new \Exception( __( 'Commenting is disabled on this project.', 'upstream' ) );
826 - }
827 -
828 - $use_admin_layout = ! isset( $get_data['teeny'] ) ? true : boolval( $get_data['teeny'] ) === false;
829 -
830 - $comments_cache = static::get_comments( $project_id, $comment_target_item_type, $item_id );
831 -
832 - foreach ( $comments_cache as $comment ) {
833 - if ( 0 === $comment->parent_id ) {
834 - ob_start();
835 - if ( $use_admin_layout ) {
836 - upstream_admin_display_message_item( $comment, array() );
837 - } else {
838 - upstream_display_message_item( $comment, array() );
839 - }
840 -
841 - $response['data'][] = trim( ob_get_contents() );
842 - ob_end_clean();
843 - }
844 - }
845 -
846 - $response['success'] = true;
847 - } catch ( \Exception $e ) {
848 - $response['error'] = $e->getMessage();
849 - }
850 -
851 - wp_send_json( $response );
852 - }
853 -
854 - /**
855 - * Get comments.
856 - *
857 - * @param int $project_id Project ID.
858 - * @param string $item_type Item type.
859 - * @param int $item_id Item ID.
860 - *
861 - * @return array
862 - */
863 - public static function get_comments( $project_id, $item_type, $item_id = null ) {
864 - $comments_cache = array();
865 - $users_cache = array();
866 - $users_rowset = get_users(
867 - array(
868 - 'fields' => array(
869 - 'ID',
870 - 'display_name',
871 - ),
872 - )
873 - );
874 - foreach ( $users_rowset as $user_row ) {
875 - $user_row->ID *= 1;
876 -
877 - $users_cache[ $user_row->ID ] = (object) array(
878 - 'id' => $user_row->ID,
879 - 'name' => $user_row->display_name,
880 - 'avatar' => get_userAvatarURL( $user_row->ID ),
881 - );
882 - }
883 - unset( $user_row, $users_rowset );
884 -
885 - if ( 'project' === $item_type ) {
886 - $item_id = $project_id;
887 - }
888 -
889 - $date_format = get_option( 'date_format' );
890 - $time_format = get_option( 'time_format' );
891 - $the_date_time_format = $date_format . ' ' . $time_format;
892 - $current_timestamp = time();
893 - $user = wp_get_current_user();
894 - $user_has_admin_capabilities = upstream_is_user_either_manager_or_admin( $user );
895 - $user_can_reply = ! $user_has_admin_capabilities ? user_can(
896 - $user,
897 - 'publish_project_discussion'
898 - ) : true;
899 -
900 - $user_can_reply = upstream_override_access_field( $user_can_reply, $item_type, $item_id, UPSTREAM_ITEM_TYPE_PROJECT, $project_id, 'comments', UPSTREAM_PERMISSIONS_ACTION_EDIT );
901 -
902 - $user_can_moderate = ! $user_has_admin_capabilities ? user_can( $user, 'moderate_comments' ) : true;
903 - $user_can_delete = ! $user_has_admin_capabilities ? $user_can_moderate || user_can(
904 - $user,
905 - 'delete_project_discussion'
906 - ) : true;
907 -
908 - $user_can_delete = upstream_override_access_field( $user_can_delete, $item_type, $item_id, UPSTREAM_ITEM_TYPE_PROJECT, $project_id, 'comments', UPSTREAM_PERMISSIONS_ACTION_DELETE );
909 -
910 - $comments_statuses = array( 'approve' );
911 - if ( $user_has_admin_capabilities || $user_can_moderate ) {
912 - $comments_statuses[] = 'hold';
913 - }
914 -
915 - $items_rowset = (array) get_post_meta(
916 - $project_id,
917 - '_upstream_project_' . $item_type . 's',
918 - true
919 - );
920 -
921 - if ( count( $items_rowset ) > 0 ) {
922 - foreach ( $items_rowset as $row ) {
923 - if ( empty( $row ) ) {
924 - continue;
925 - }
926 -
927 - if ( ! empty( $item_id ) ) {
928 - if ( $item_id != $row['id'] ) {
929 - continue;
930 - }
931 - }
932 -
933 - $comments = (array) get_comments(
934 - array(
935 - 'post_id' => $project_id,
936 - 'status' => $comments_statuses,
937 - 'meta_query' => array(
938 - 'relation' => 'AND',
939 - array(
940 - 'key' => 'type',
941 - 'value' => $item_type,
942 - ),
943 - array(
944 - 'key' => 'id',
945 - 'value' => $row['id'],
946 - ),
947 - ),
948 - )
949 - );
950 -
951 - if ( count( $comments ) > 0 ) {
952 - foreach ( $comments as $comment ) {
953 - $author = $users_cache[ (int) $comment->user_id ];
954 -
955 - $date = \DateTime::createFromFormat( 'Y-m-d H:i:s', $comment->comment_date_gmt );
956 -
957 - $comment_data = json_decode(
958 - json_encode(
959 - array(
960 - 'id' => (int) $comment->comment_ID,
961 - 'parent_id' => (int) $comment->comment_parent,
962 - 'content' => $comment->comment_content,
963 - 'state' => $comment->comment_approved,
964 - 'created_by' => $author,
965 - 'created_at' => array(
966 - 'localized' => '',
967 - 'humanized' => sprintf(
968 - // translators: %s: human-readable time difference.
969 - _x( '%s ago', '%s = human-readable time difference', 'upstream' ),
970 - human_time_diff( $date->getTimestamp(), $current_timestamp )
971 - ),
972 - ),
973 - 'current_user_cap' => array(
974 - 'can_reply' => $user_can_reply,
975 - 'can_moderate' => $user_can_moderate,
976 - 'can_delete' => $user_can_delete || $author->id === $user->ID,
977 - ),
978 - 'replies' => array(),
979 - )
980 - )
981 - );
982 -
983 - $comment_data->created_at->localized = $date->format( $the_date_time_format );
984 -
985 - $comments_cache[ $comment_data->id ] = $comment_data;
986 - }
987 -
988 - foreach ( $comments_cache as $comment ) {
989 - if ( $comment->parent_id > 0 ) {
990 - if ( isset( $comments_cache[ $comment->parent_id ] ) ) {
991 - $comments_cache[ $comment->parent_id ]->replies[] = $comment;
992 - } else {
993 - unset( $comments_cache[ $comment->id ] );
994 - }
995 - }
996 - }
997 - }
998 - }
999 - }
1000 -
1001 - return $comments_cache;
1002 - }
1003 -
1004 - /**
1005 - * Set additional notification recipients as needed for newly added comments.
1006 - *
1007 - * @since 1.15.0
1008 - * @static
1009 - *
1010 - * @param array $recipients Recipients list.
1011 - * @param int $comment_id The new comment ID.
1012 - *
1013 - * @return array
1014 - */
1015 - public static function define_notification_recipients( $recipients, $comment_id ) {
1016 - $should_send = upstream_send_notifications_for_new_comments();
1017 - if ( ! $should_send ) {
1018 - return array();
1019 - }
1020 -
1021 - // 2 minutes.
1022 - $transient_expiration = 60 * 2;
1023 -
1024 - $comment = get_comment( $comment_id );
1025 - $comment = (object) array(
1026 - 'id' => (int) $comment->comment_ID,
1027 - 'project_id' => (int) $comment->comment_post_ID,
1028 - 'parent' => (int) $comment->comment_parent,
1029 - 'created_by' => (int) $comment->user_id,
1030 - 'target' => get_comment_meta( $comment_id, 'type', true ),
1031 - 'target_id' => (int) $comment->comment_post_ID,
1032 - );
1033 -
1034 - // Check if we should disable all emaill notifications for this project.
1035 - $meta = (array) get_post_meta( $comment->project_id, '_upstream_project_disable_all_notifications' );
1036 - if ( count( $meta ) > 0 && 'on' === $meta[0] ) {
1037 - return array();
1038 - }
1039 -
1040 - // Check if we need to skip further data processing.
1041 - if ( ! in_array( $comment->target, array( 'project', 'milestone', 'task', 'bug', 'file' ) ) ) {
1042 - return $recipients;
1043 - }
1044 -
1045 - $comment->target_label = call_user_func( 'upstream_' . $comment->target . '_label' );
1046 -
1047 - if ( 'project' !== $comment->target ) {
1048 - $comment->target_id = get_comment_meta( $comment_id, 'id', true );
1049 - }
1050 -
1051 - set_transient( 'upstream:comment_notification.comment:' . $comment_id, $comment, $transient_expiration );
1052 -
1053 - $get_user = function ( $user_id ) use ( $transient_expiration ) {
1054 - if ( $user_id <= 0 ) {
1055 - return null;
1056 - }
1057 -
1058 - // Check if the user is cached.
1059 - $user = get_transient( 'upstream:comment_notification.user:' . $user_id );
1060 - if ( empty( $user ) ) {
1061 - // Check if the user exists.
1062 - $user = get_user_by( 'id', $user_id );
1063 - if ( false === $user ) {
1064 - return null;
1065 - }
1066 -
1067 - // Prepare user data.
1068 - $user = (object) array(
1069 - 'id' => (int) $user->ID,
1070 - 'name' => (string) $user->display_name,
1071 - 'email' => (string) $user->user_email,
1072 - );
1073 -
1074 - // Cache user.
1075 - set_transient( 'upstream:comment_notification.user:' . $user->id, $user, $transient_expiration );
1076 - }
1077 -
1078 - return $user;
1079 - };
1080 -
1081 - $fetch_project_meta_as_map = function ( $project_id, $key, &$map ) use ( $transient_expiration, $get_user ) {
1082 -
1083 - $rowset = array();
1084 - if ( 'milestone' === $key ) {
1085 - $rowset = (array) ( \UpStream\Milestones::getInstance()->get_milestones_as_rowset( $project_id ) );
1086 - } else {
1087 - $rowset = (array) get_post_meta( $project_id, '_upstream_project_' . $key . 's', true );
1088 - }
1089 -
1090 - foreach ( $rowset as $row ) {
1091 - $title_key = 'milestone' !== $key ? 'title' : 'milestone';
1092 -
1093 - if ( isset( $row['id'] )
1094 - && ! empty( $row['id'] )
1095 - && isset( $row[ $title_key ] )
1096 - && ! empty( $row[ $title_key ] )
1097 - ) {
1098 - $item = (object) array(
1099 - 'id' => $row['id'],
1100 - 'title' => $row[ $title_key ],
1101 - 'assigned_to' => isset( $row['assigned_to'] ) ? $row['assigned_to'] : array(),
1102 - 'created_by' => isset( $row['created_by'] ) ? (int) $row['created_by'] : 0,
1103 - 'type' => $key,
1104 - );
1105 -
1106 - if ( count( $item->assigned_to ) > 0 ) {
1107 - foreach ( $item->assigned_to as $a ) {
1108 - $user = $get_user( $a );
1109 - $recipients[] = $user->email;
1110 - }
1111 - }
1112 -
1113 - if ( $item->created_by > 0 ) {
1114 - $user = $get_user( $item->created_by );
1115 - if ( empty( $user ) ) {
1116 - $item->created_by = 0;
1117 - } else {
1118 - $item->created_by = $user->id;
1119 - }
1120 - }
1121 -
1122 - $map[ $item->id ] = $item;
1123 - }
1124 - }
1125 -
1126 - };
1127 -
1128 - // RSD: this cache is causing issues
1129 - // $project = get_transient('upstream:comment_notification.project:' . $comment->project_id).
1130 - if ( empty( $project ) ) {
1131 - $project = get_post( $comment->project_id );
1132 - $project = (object) array(
1133 - 'id' => (int) $project->ID,
1134 - 'title' => $project->post_title,
1135 - 'created_by' => (int) $project->post_author,
1136 - 'owner_id' => (int) get_post_meta( $project->ID, '_upstream_project_owner', true ),
1137 - 'owner_email' => '',
1138 - 'milestones' => array(),
1139 - 'tasks' => array(),
1140 - 'bugs' => array(),
1141 - 'files' => array(),
1142 - );
1143 -
1144 - if ( $project->owner_id > 0 ) {
1145 - $owner = get_transient( 'upstream:comment_notification.user:' . $project->owner_id );
1146 - if ( empty( $owner ) ) {
1147 - $owner = get_user_by( 'id', $project->owner_id );
1148 - $owner = (object) array(
1149 - 'id' => $project->owner_id,
1150 - 'name' => (string) $owner->display_name,
1151 - 'email' => (string) $owner->user_email,
1152 - );
1153 -
1154 - set_transient( 'upstream:comment_notification.user:' . $owner->id, $owner, $transient_expiration );
1155 - }
1156 -
1157 - $pms = upstream_project_members_ids( $comment->project_id );
1158 - foreach ( $pms as $pm ) {
1159 - $user_info = get_userdata( $pm );
1160 - $email = $user_info->user_email;
1161 - $recipients[] = $email;
1162 - }
1163 - }
1164 -
1165 - if ( 'project' !== $comment->target ) {
1166 -
1167 - $fetch_project_meta_as_map( $project->id, $comment->target, $project->{$comment->target . 's'} );
1168 -
1169 - foreach ( $project->{$comment->target . 's'} as $item ) {
1170 - $r = $comment->target_id;
1171 - if ( $item->id === $comment->target_id ) {
1172 - if ( count( $item->assigned_to ) > 0 ) {
1173 - foreach ( $item->assigned_to as $a ) {
1174 - $user = $get_user( $a );
1175 - $recipients[] = $user->email;
1176 - }
1177 - }
1178 -
1179 - if ( $item->created_by > 0 ) {
1180 - $user = $get_user( $item->created_by );
1181 - $recipients[] = $user->email;
1182 - }
1183 - }
1184 - }
1185 - }
1186 -
1187 - set_transient(
1188 - 'upstream:comment_notification.project:' . $comment->project_id,
1189 - $project,
1190 - $transient_expiration
1191 - );
1192 - } else {
1193 - if ( 'project' !== $comment->target
1194 - && empty( $project->{$comment->target . 's'} )
1195 - ) {
1196 - $fetch_project_meta_as_map( $project->id, $comment->target, $project->{$comment->target . 's'} );
1197 -
1198 - set_transient(
1199 - 'upstream:comment_notification.project:' . $comment->project_id,
1200 - $project,
1201 - $transient_expiration
1202 - );
1203 - }
1204 - }
1205 -
1206 - if ( $comment->parent > 0 ) {
1207 - $parent_id = $comment->parent;
1208 -
1209 - $users_cache = array();
1210 -
1211 - do {
1212 - $parent_comment = get_comment( $parent_id );
1213 -
1214 - $parent_exists = ! empty( $parent_comment );
1215 - if ( $parent_exists ) {
1216 - if ( ! isset( $users_cache[ $parent_comment->user_id ] ) ) {
1217 - $users_cache[ $parent_comment->user_id ] = $get_user( $parent_comment->user_id );
1218 - $users_cache[ $parent_comment->user_id ]->notify = upstream_user_can_receive_comment_replies_notification( $parent_comment->user_id );
1219 - }
1220 -
1221 - $user = &$users_cache[ $parent_comment->user_id ];
1222 -
1223 - $parent_comment_author = $get_user( $parent_comment->user_id );
1224 -
1225 - if ( $user->notify ) {
1226 - $recipients[] = $parent_comment_author->email;
1227 - }
1228 -
1229 - $parent_id = (int) $parent_comment->comment_parent;
1230 - }
1231 - } while ( $parent_exists );
1232 - }
1233 -
1234 - $recipients = array_unique( array_filter( $recipients ) );
1235 -
1236 - $recipients = apply_filters( 'upstream:comment_notification.recipients', $recipients, $comment );
1237 -
1238 - return $recipients;
1239 - }
1240 -
1241 - /**
1242 - * Add additional info to comment notifications subject.
1243 - *
1244 - * @since 1.15.0
1245 - * @static
1246 - *
1247 - * @param string $subject The original subject.
1248 - * @param int $comment_id The new comment ID.
1249 - *
1250 - * @return string
1251 - */
1252 - public static function define_notification_header( $subject, $comment_id ) {
1253 - $comment = get_transient( 'upstream:comment_notification.comment:' . $comment_id );
1254 - // Check if we need to skip further data processing in case of comments written outside UpStream's scope.
1255 - if ( empty( $comment )
1256 - || in_array( $comment->target, array( 'project', 'milestone', 'task', 'bug', 'file' ) )
1257 - ) {
1258 - return $subject;
1259 - }
1260 -
1261 - $project = get_transient( 'upstream:comment_notification.project:' . $comment->project_id );
1262 - $site_name = get_bloginfo( 'name' );
1263 -
1264 - $subject = sprintf(
1265 - '[%s][%s] %s',
1266 - $site_name,
1267 - $project->title,
1268 - sprintf(
1269 - // translators: %s: Comment notification subject.
1270 - _x( 'New comment on %s', 'Comment notification subject', 'upstream' ),
1271 - $comment->target_label
1272 - )
1273 - );
1274 -
1275 - if ( 'project' !== $comment->target ) {
1276 - $meta = (array) get_post_meta( $project->id, '_upstream_project_' . $comment->target . 's', true );
1277 - foreach ( $meta as $item ) {
1278 - if ( isset( $item['id'] ) && $item['id'] === $comment->target_id ) {
1279 - $title_key = 'milestone' === $comment->target ? 'milestone' : 'title';
1280 -
1281 - if ( isset( $item[ $title_key ] ) ) {
1282 - $subject .= sprintf( ': "%s"', $item[ $title_key ] );
1283 - }
1284 -
1285 - break;
1286 - }
1287 - }
1288 - }
1289 -
1290 - $subject = apply_filters( 'upstream:comment_notification.subject', $subject, $comment, $project );
1291 -
1292 - return $subject;
1293 - }
1294 -
1295 - /**
1296 - * Add items title to notification.
1297 - *
1298 - * @param string $comment_text Comment text.
1299 - * @param int $comment_id Comment ID.
1300 - *
1301 - * @return mixed
1302 - */
1303 - public static function add_item_title_to_notification( $comment_text, $comment_id ) {
1304 - // Check if the comment has item_title in the metadata.
1305 - $item_title = get_comment_meta( $comment_id, 'title', true );
1306 - $item_type = get_comment_meta( $comment_id, 'type', true );
1307 -
1308 - if ( ! empty( $item_title ) ) {
1309 - if ( 'milestone' === $item_type ) {
1310 - // Get the milestone's title.
1311 - $milestones = upstream_get_milestones_titles();
1312 -
1313 - if ( isset( $milestones[ $item_title ] ) ) {
1314 - $item_title = $milestones[ $item_title ];
1315 - }
1316 - }
1317 -
1318 - $comment_text = __( 'Item Title: ', 'upstream' ) . $item_title . "\r\n\r\n" . $comment_text;
1319 - }
1320 -
1321 - if ( ! empty( $item_type ) ) {
1322 - $labels = upstream_get_default_labels();
1323 -
1324 - $item_type_label = $labels[ $item_type . 's' ]['singular'];
1325 -
1326 - $comment_text = __( 'Item Type: ', 'upstream' ) . $item_type_label . "\r\n" . $comment_text;
1327 - }
1328 -
1329 - return $comment_text;
1330 - }
1331 -
1332 - /**
1333 - * Convert notifications text for comments in projects into HTML.
1334 - *
1335 - * @param string $text Comment text.
1336 - * @param int $comment_id Comment ID.
1337 - *
1338 - * @return string
1339 - */
1340 - public static function filter_comment_notification_text( $text, $comment_id ) {
1341 - if ( self::is_comment_from_project( $comment_id ) ) {
1342 - // Convert from txt to html.
1343 - $text = str_replace( "\n", '<br>', $text );
1344 - $text = self::replace_email_with_html_link( $text );
1345 - $text = self::replace_link_with_html_link( $text );
1346 - }
1347 -
1348 - return $text;
1349 - }
1350 -
1351 - /**
1352 - * Is comment from project.
1353 - *
1354 - * @param int $comment_id Comment ID.
1355 - *
1356 - * @return bool
1357 - */
1358 - protected static function is_comment_from_project( $comment_id ) {
1359 - // Check if the post is a project.
1360 - $comment = get_comment( $comment_id );
1361 - $post = get_post( $comment->comment_post_ID );
1362 -
1363 - return 'project' === $post->post_type;
1364 - }
1365 -
1366 - /**
1367 - * Replace email with HTML link.
1368 - *
1369 - * @param string $text Email text.
1370 - *
1371 - * @return string
1372 - */
1373 - protected static function replace_email_with_html_link( $text ) {
1374 - $text = preg_replace( '/([^\s@]+@[a-z\._\-0-9]+)/i', '<a href="mailto:${1}" target="_blank">${1}</a>', $text );
1375 -
1376 - return $text;
1377 - }
1378 -
1379 - /**
1380 - * Replace link with HTML link.
1381 - *
1382 - * @param string $text Link.
1383 - *
1384 - * @return string
1385 - */
1386 - protected static function replace_link_with_html_link( $text ) {
1387 - $text = preg_replace( '~([a-z]+:\/\/\S+)~i', '<a href="${1}" target="_blank">${1}</a>', $text );
1388 -
1389 - return $text;
1390 - }
1391 -
1392 - /**
1393 - * Convert notifications for comments in projects into HTML.
1394 - *
1395 - * @param string $headers Headers.
1396 - * @param int $comment_id Comment ID.
1397 - *
1398 - * @return string
1399 - */
1400 - public static function filter_comment_notification_headers( $headers, $comment_id ) {
1401 - if ( self::is_comment_from_project( $comment_id ) ) {
1402 - // Convert from txt to html.
1403 - $headers = str_replace( 'text/plain;', 'text/html;', $headers );
1404 - }
1405 -
1406 - return $headers;
1407 - }
1408 -}
1 +<?php
2 +/**
3 + * A controller handling incoming requests regarding comments on UpStream items.
4 + *
5 + * @package UpStream
6 + */
7 +
8 +namespace UpStream;
9 +
10 +// Prevent direct access.
11 +if ( ! defined( 'ABSPATH' ) ) {
12 + exit;
13 +}
14 +
15 +use UpStream\Traits\Singleton;
16 +
17 +/**
18 + * This class will act as a controller handling incoming requests regarding comments on UpStream items.
19 + *
20 + * @since 1.13.0
21 + */
22 +class Comments {
23 + use Singleton;
24 +
25 + /**
26 + * The current full namespace.
27 + *
28 + * @since 1.13.0
29 + * @access private
30 + * @static
31 + *
32 + * @var string $namespace
33 + */
34 + private static $namespace;
35 +
36 + /**
37 + * Class constructor.
38 + *
39 + * @since 1.13.0
40 + */
41 + public function __construct() {
42 + self::$namespace = get_class(
43 + empty( self::$instance )
44 + ? $this
45 + : self::$instance
46 + );
47 +
48 + $this->attach_hooks();
49 +
50 + self::remove_comment_type();
51 + }
52 +
53 + /**
54 + * Attach all relevant actions to handle comments.
55 + *
56 + * @since 1.13.0
57 + * @access private
58 + */
59 + private function attach_hooks() {
60 + add_action( 'wp_ajax_upstream:project.add_comment', array( self::$namespace, 'store_comment' ) );
61 + add_action( 'wp_ajax_upstream:project.add_comment_reply', array( self::$namespace, 'store_comment_reply' ) );
62 + add_action( 'wp_ajax_upstream:project.trash_comment', array( self::$namespace, 'trash_comment' ) );
63 + add_action( 'wp_ajax_upstream:project.unapprove_comment', array( self::$namespace, 'unapprove_comment' ) );
64 + add_action( 'wp_ajax_upstream:project.approve_comment', array( self::$namespace, 'approve_comment' ) );
65 + add_action( 'wp_ajax_upstream:project.fetch_comments', array( self::$namespace, 'fetch_comments' ) );
66 +
67 + add_filter( 'comment_notification_subject', array( self::$namespace, 'define_notification_header' ), 10, 2 );
68 + add_filter( 'comment_notification_recipients', array( self::$namespace, 'define_notification_recipients' ), 10, 2 );
69 + add_filter( 'comment_notification_text', array( self::$namespace, 'add_item_title_to_notification' ), 10, 2 );
70 +
71 + add_filter( 'upstream_allowed_tags_in_comments', array( self::$namespace, 'filter_allowed_tags' ) );
72 + add_filter(
73 + 'comment_notification_headers',
74 + array( self::$namespace, 'filter_comment_notification_headers' ),
75 + 10,
76 + 2
77 + );
78 +
79 + add_filter( 'comment_notification_text', array( self::$namespace, 'filter_comment_notification_text' ), 10, 2 );
80 + }
81 +
82 + /**
83 + * Empties the comment_type="comment" column from UpStream comments.
84 + *
85 + * @since 1.16.3
86 + * @static
87 + */
88 + public static function remove_comment_type() {
89 + $did_remove_comments_type = (bool) get_option( 'upstream:remove_comments_type' );
90 +
91 + if ( ! $did_remove_comments_type ) {
92 + global $wpdb;
93 +
94 + $wpdb->query(
95 + sprintf(
96 + 'UPDATE `%s` AS `comment`
97 + LEFT JOIN `%s` AS `post`
98 + ON `post`.`ID` = `comment`.`comment_post_ID`
99 + SET `comment_type` = ""
100 + WHERE `comment_type` = "comment"
101 + AND `post_type` = "project"',
102 + $wpdb->prefix . 'comments',
103 + $wpdb->prefix . 'posts'
104 + )
105 + );
106 +
107 + update_option( 'upstream:remove_comments_type', 1 );
108 + }
109 + }
110 +
111 + /**
112 + * Filter allowed tags.
113 + *
114 + * @param array $allowed_tags Allowed tags.
115 + *
116 + * @return array
117 + */
118 + public static function filter_allowed_tags( $allowed_tags ) {
119 + global $allowedtags;
120 +
121 + // Add default allowed tags.
122 + $allowed_tags = array_merge( $allowed_tags, $allowedtags );
123 +
124 + // Add basic tags.
125 + if ( ! array_key_exists( 'p', $allowed_tags ) ) {
126 + $allowed_tags['p'] = array(
127 + 'class' => true,
128 + 'id' => true,
129 + );
130 + }
131 +
132 + if ( ! array_key_exists( 'br', $allowed_tags ) ) {
133 + $allowed_tags['br'] = array();
134 + }
135 +
136 + if ( ! array_key_exists( 'strong', $allowed_tags ) ) {
137 + $allowed_tags['strong'] = array(
138 + 'class' => true,
139 + 'id' => true,
140 + );
141 + }
142 +
143 + if ( ! array_key_exists( 'em', $allowed_tags ) ) {
144 + $allowed_tags['em'] = array(
145 + 'class' => true,
146 + 'id' => true,
147 + );
148 + }
149 +
150 + if ( ! array_key_exists( 'span', $allowed_tags ) ) {
151 + $allowed_tags['span'] = array(
152 + 'class' => true,
153 + 'id' => true,
154 + 'style' => true,
155 + );
156 + }
157 +
158 + if ( ! array_key_exists( 'del', $allowed_tags ) ) {
159 + $allowed_tags['del'] = array(
160 + 'class' => true,
161 + 'id' => true,
162 + );
163 + }
164 +
165 + if ( ! array_key_exists( 'ul', $allowed_tags ) ) {
166 + $allowed_tags['ul'] = array(
167 + 'class' => true,
168 + 'id' => true,
169 + );
170 + }
171 +
172 + if ( ! array_key_exists( 'ol', $allowed_tags ) ) {
173 + $allowed_tags['ol'] = array(
174 + 'class' => true,
175 + 'id' => true,
176 + );
177 + }
178 +
179 + if ( ! array_key_exists( 'li', $allowed_tags ) ) {
180 + $allowed_tags['li'] = array(
181 + 'class' => true,
182 + 'id' => true,
183 + );
184 + }
185 +
186 + if ( ! array_key_exists( 'a', $allowed_tags ) ) {
187 + $allowed_tags['a'] = array(
188 + 'class' => true,
189 + 'id' => true,
190 + 'href' => true,
191 + 'charset' => true,
192 + 'name' => true,
193 + 'rel' => true,
194 + 'target' => true,
195 + 'type' => true,
196 + );
197 + } else {
198 + $allowed_tags['a']['class'] = true;
199 + $allowed_tags['a']['id'] = true;
200 + $allowed_tags['a']['href'] = true;
201 + $allowed_tags['a']['charset'] = true;
202 + $allowed_tags['a']['name'] = true;
203 + $allowed_tags['a']['rel'] = true;
204 + $allowed_tags['a']['target'] = true;
205 + $allowed_tags['a']['type'] = true;
206 + }
207 +
208 + // If the current can't post images, we return current supported tags.
209 + if ( ! current_user_can( 'upstream_comment_images' ) ) {
210 + return $allowed_tags;
211 + }
212 +
213 + // The user can post images, so let's allow the img tag.
214 + if ( ! is_array( $allowed_tags ) ) {
215 + $allowed_tags = array();
216 + }
217 +
218 + $allowed_tags['img'] = array(
219 + 'class' => true,
220 + 'src' => true,
221 + 'alt' => true,
222 + 'width' => true,
223 + 'height' => true,
224 + );
225 +
226 + return $allowed_tags;
227 + }
228 +
229 + /**
230 + * AJAX endpoint that stores a new comment.
231 + *
232 + * @throws \Exception Set the error message.
233 + * @since 1.13.0
234 + * @static
235 + */
236 + public static function store_comment() {
237 + header( 'Content-Type: application/json' );
238 +
239 + $post_data = isset( $_POST ) ? wp_unslash( $_POST ) : array();
240 + $response = array(
241 + 'success' => false,
242 + 'error' => null,
243 + );
244 +
245 + try {
246 + // Check if the request payload is potentially invalid.
247 + if (
248 + ! defined( 'DOING_AJAX' )
249 + || ! DOING_AJAX
250 + || empty( $post_data )
251 + || ! isset( $post_data['nonce'] )
252 + || ! isset( $post_data['project_id'] )
253 + || ! isset( $post_data['item_type'] )
254 + || ! self::is_item_type_valid( sanitize_text_field( $post_data['item_type'] ) )
255 + || ! isset( $post_data['content'] )
256 + ) {
257 + throw new \Exception( __( 'Invalid request.', 'upstream' ) );
258 + }
259 +
260 + // Prepare data to verify nonce.
261 + $comment_target_item_type = strtolower( sanitize_text_field( $post_data['item_type'] ) );
262 + if ( 'project' !== $comment_target_item_type ) {
263 + if (
264 + ! isset( $post_data['item_id'] )
265 + || empty( $post_data['item_id'] )
266 + ) {
267 + throw new \Exception( __( 'Invalid item.', 'upstream' ) );
268 + }
269 +
270 + // non-numeric id.
271 + $item_id = sanitize_text_field( $post_data['item_id'] );
272 +
273 + $nonce_identifier = 'upstream:project.' . $comment_target_item_type . 's.add_comment';
274 + } else {
275 + $item_id = absint( $post_data['project_id'] );
276 + $nonce_identifier = 'upstream:project.add_comment';
277 + }
278 +
279 + // Verify nonce.
280 + if ( ! check_ajax_referer( $nonce_identifier, 'nonce', false ) ) {
281 + throw new \Exception( __( 'Invalid nonce.', 'upstream' ) );
282 + }
283 +
284 + // Check if the project exists.
285 + $project_id = absint( $post_data['project_id'] );
286 + if ( $project_id <= 0 ) {
287 + throw new \Exception( __( 'Invalid Project.', 'upstream' ) );
288 + }
289 +
290 + // Check if commenting is disabled on the given project.
291 + if ( upstream_are_comments_disabled( $project_id ) ) {
292 + throw new \Exception( __( 'Commenting is disabled on this project.', 'upstream' ) );
293 + }
294 +
295 + // Check if the user has enough permissions to insert a new comment.
296 + if ( ! upstream_can_access_field( 'publish_project_discussion', $comment_target_item_type, $item_id, UPSTREAM_ITEM_TYPE_PROJECT, $project_id, 'comments', UPSTREAM_PERMISSIONS_ACTION_EDIT, true ) ) {
297 + throw new \Exception( __( "You're not allowed to do this.", 'upstream' ) );
298 + }
299 +
300 + $user_id = get_current_user_id();
301 +
302 + $comment_content = stripslashes( wp_kses_post( $post_data['content'] ) );
303 +
304 + $item_title = isset( $post_data['item_title'] ) ? sanitize_text_field( $post_data['item_title'] ) : '';
305 +
306 + $comment = new Comment( $comment_content, $project_id, $user_id );
307 + $server = isset( $_SERVER ) ? wp_unslash( $_SERVER ) : array();
308 +
309 + $comment->created_by->ip = preg_replace( '/[^0-9a-fA-F:., ]/', '', sanitize_text_field( $server['REMOTE_ADDR'] ) );
310 + $comment->created_by->agent = isset( $server['HTTP_USER_AGENT'] ) ? sanitize_text_field( $server['HTTP_USER_AGENT'] ) : null;
311 +
312 + $comment->save();
313 +
314 + update_comment_meta( $comment->id, 'type', $comment_target_item_type );
315 +
316 + if ( 'project' !== $comment_target_item_type ) {
317 + update_comment_meta( $comment->id, 'id', $item_id );
318 + // We store the item title here because of the project's data structure.
319 + // It is faster to retrieve from metadata then seek item by item from a project.
320 + update_comment_meta( $comment->id, 'title', $item_title );
321 + }
322 +
323 + wp_new_comment_notify_moderator( $comment->id );
324 + wp_notify_postauthor( $comment->id );
325 +
326 + $use_admin_layout = ! isset( $post_data['teeny'] ) ? true : boolval( $post_data['teeny'] ) === false;
327 +
328 + $response['comment_html'] = stripslashes( $comment->render( true, $use_admin_layout ) );
329 +
330 + $response['success'] = true;
331 + } catch ( \Exception $e ) {
332 + $response['error'] = $e->getMessage();
333 + }
334 +
335 + wp_send_json( $response );
336 + }
337 +
338 +
339 + /**
340 + * Check if the item type is valid.
341 + *
342 + * @since 1.13.0
343 + * @static
344 + *
345 + * @param string $item_type Value to be validated.
346 + * @throws \Exception Set error message.
347 + *
348 + * @return bool
349 + */
350 + public static function is_item_type_valid( $item_type ) {
351 + $item_types = array( 'project', 'milestone', 'task', 'bug', 'file' );
352 +
353 + return in_array( $item_type, $item_types );
354 + }
355 +
356 + /**
357 + * AJAX endpoint that adds a new comment reply.
358 + *
359 + * @throws \Exception Set the error message.
360 + * @since 1.13.0
361 + * @static
362 + */
363 + public static function store_comment_reply() {
364 + header( 'Content-Type: application/json' );
365 +
366 + $post_data = isset( $_POST ) ? wp_unslash( $_POST ) : array();
367 + $server = isset( $_SERVER ) ? wp_unslash( $_SERVER ) : array();
368 + $response = array(
369 + 'success' => false,
370 + 'error' => null,
371 + );
372 +
373 + try {
374 + // Check if the request payload is potentially invalid.
375 + if (
376 + ! defined( 'DOING_AJAX' )
377 + || ! DOING_AJAX
378 + || empty( $post_data )
379 + || ! isset( $post_data['nonce'] )
380 + || ! isset( $post_data['project_id'] )
381 + || ! isset( $post_data['item_type'] )
382 + || ! self::is_item_type_valid( sanitize_text_field( $post_data['item_type'] ) )
383 + || ! isset( $post_data['content'] )
384 + || ! isset( $post_data['parent_id'] )
385 + || ! is_numeric( sanitize_text_field( $post_data['parent_id'] ) )
386 + || ! check_ajax_referer( 'upstream:project.add_comment_reply:' . sanitize_text_field( $post_data['parent_id'] ), 'nonce', false )
387 + ) {
388 + throw new \Exception( __( 'Invalid request.', 'upstream' ) );
389 + }
390 +
391 + // could be alnum ID.
392 + $item_id = sanitize_text_field( $post_data['item_id'] );
393 +
394 + // Check if the project exists.
395 + $project_id = absint( $post_data['project_id'] );
396 + if ( $project_id <= 0 ) {
397 + throw new \Exception( __( 'Invalid Project.', 'upstream' ) );
398 + }
399 +
400 + $comment_target_item_type = strtolower( sanitize_text_field( $post_data['item_type'] ) );
401 + if ( 'project' !== $comment_target_item_type ) {
402 + if (
403 + ! isset( $post_data['item_id'] )
404 + || empty( $post_data['item_id'] )
405 + ) {
406 + throw new \Exception( __( 'Invalid request.', 'upstream' ) );
407 + }
408 + } else {
409 + $item_id = $project_id;
410 + }
411 +
412 + // Check if the user has enough permissions to insert a new comment.
413 + if ( ! upstream_can_access_field( 'publish_project_discussion', $comment_target_item_type, $item_id, UPSTREAM_ITEM_TYPE_PROJECT, $project_id, 'comments', UPSTREAM_PERMISSIONS_ACTION_EDIT, true ) ) {
414 + throw new \Exception( __( "You're not allowed to do this.", 'upstream' ) );
415 + }
416 +
417 + // Check if commenting is disabled on the given project.
418 + if ( upstream_are_comments_disabled( $project_id ) ) {
419 + throw new \Exception( __( 'Commenting is disabled on this project.', 'upstream' ) );
420 + }
421 +
422 + $user_id = get_current_user_id();
423 +
424 + $comment = new Comment( stripslashes( wp_kses_post( $post_data['content'] ) ), $project_id, $user_id );
425 + $comment->parent_id = absint( $post_data['parent_id'] );
426 + $comment->created_by->ip = preg_replace( '/[^0-9a-fA-F:., ]/', '', sanitize_text_field( $server['REMOTE_ADDR'] ) );
427 + $comment->created_by->agent = isset( $server['HTTP_USER_AGENT'] ) ? sanitize_textarea_field( $server['HTTP_USER_AGENT'] ) : null;
428 +
429 + $comment->save();
430 +
431 + update_comment_meta( $comment->id, 'type', $comment_target_item_type );
432 +
433 + if ( 'project' !== $comment_target_item_type ) {
434 + update_comment_meta( $comment->id, 'id', sanitize_text_field( $post_data['item_id'] ) );
435 + }
436 +
437 + $use_admin_layout = ! isset( $post_data['teeny'] ) ? true : boolval( $post_data['teeny'] ) === false;
438 +
439 + $parent = get_comment( $comment->parent_id );
440 +
441 + $comments_cache = array(
442 + $parent->comment_ID => json_decode(
443 + json_encode(
444 + array(
445 + 'created_by' => array(
446 + 'name' => $parent->comment_author,
447 + ),
448 + )
449 + )
450 + ),
451 + );
452 +
453 + $response['comment_html'] = stripslashes( $comment->render( true, $use_admin_layout, $comments_cache ) );
454 +
455 + wp_new_comment_notify_moderator( $comment->id );
456 + wp_notify_postauthor( $comment->id );
457 +
458 + $response['success'] = true;
459 + } catch ( \Exception $e ) {
460 + $response['error'] = $e->getMessage();
461 + }
462 +
463 + wp_send_json( $response );
464 + }
465 +
466 + /**
467 + * AJAX endpoint that trashes a comment.
468 + *
469 + * @throws \Exception Set the error message.
470 + * @since 1.13.0
471 + * @static
472 + */
473 + public static function trash_comment() {
474 + header( 'Content-Type: application/json' );
475 +
476 + $post_data = isset( $_POST ) ? wp_unslash( $_POST ) : array();
477 + $response = array(
478 + 'success' => false,
479 + 'error' => null,
480 + );
481 +
482 + try {
483 + // Check if the request payload is potentially invalid.
484 + if (
485 + ! defined( 'DOING_AJAX' )
486 + || ! DOING_AJAX
487 + || empty( $post_data )
488 + || ! isset( $post_data['nonce'] )
489 + || ! isset( $post_data['project_id'] )
490 + || ! isset( $post_data['comment_id'] )
491 + || ! check_ajax_referer( 'upstream:project.trash_comment:' . sanitize_textarea_field( $post_data['comment_id'] ), 'nonce', false )
492 + ) {
493 + throw new \Exception( __( 'Invalid request.', 'upstream' ) );
494 + }
495 +
496 + // Check if the project exists.
497 + $project_id = absint( $post_data['project_id'] );
498 + if ( $project_id <= 0 ) {
499 + throw new \Exception( __( 'Invalid Project.', 'upstream' ) );
500 + }
501 +
502 + // Check if the Discussion/Comments section is disabled for the current project.
503 + if ( upstream_are_comments_disabled( $project_id ) ) {
504 + throw new \Exception( __( 'Comments are disabled for this project.', 'upstream' ) );
505 + }
506 +
507 + // Check if the parent comment exists.
508 + $comment_id = absint( $post_data['comment_id'] );
509 + $comment = get_comment( $comment_id );
510 +
511 + if ( empty( $comment )
512 + // Check if the comment belongs to that project.
513 + || (
514 + isset( $comment->comment_post_ID )
515 + && (int) $comment->comment_post_ID !== $project_id
516 + )
517 + ) {
518 + throw new \Exception( _x( 'Comment not found.', 'Removing a comment in projects', 'upstream' ) );
519 + }
520 +
521 + $user_id = (int) get_current_user_id();
522 +
523 + if ( ! upstream_admin_permissions( 'delete_project_discussion' )
524 + && ! current_user_can( 'moderate_comments' )
525 + && (int) $comment->user_id !== $user_id
526 + ) {
527 + throw new \Exception( __( "You're not allowed to do this.", 'upstream' ) );
528 + }
529 +
530 + $success = wp_trash_comment( $comment );
531 + if ( ! $success ) {
532 + throw new \Exception( __( "It wasn't possible to delete this comment.", 'upstream' ) );
533 + }
534 +
535 + $response['success'] = true;
536 + } catch ( \Exception $e ) {
537 + $response['error'] = $e->getMessage();
538 + }
539 +
540 + wp_send_json( $response );
541 + }
542 +
543 + /**
544 + * AJAX endpoint that unapproves a comment.
545 + *
546 + * @since 1.13.0
547 + * @static
548 + */
549 + public static function unapprove_comment() {
550 + header( 'Content-Type: application/json' );
551 +
552 + $post_data = isset( $_POST ) ? wp_unslash( $_POST ) : array();
553 + $comment_id = isset( $post_data['comment_id'] ) ? absint( $post_data['comment_id'] ) : 0;
554 +
555 + check_ajax_referer( 'upstream:project.unapprove_comment:' . $comment_id, 'nonce' );
556 +
557 + $response = array(
558 + 'success' => false,
559 + 'error' => null,
560 + );
561 +
562 + try {
563 + $comment = self::toggle_comment_approval_status( $comment_id, false );
564 +
565 + $comments = array();
566 + if ( $comment->parent_id > 0 ) {
567 + $parent_comment = get_comment( $comment->parent_id );
568 + if ( is_numeric( $parent_comment->comment_approved ) ) {
569 + if ( (bool) $parent_comment->comment_approved ) {
570 + $comments = array(
571 + $comment->parent_id => json_decode(
572 + json_encode(
573 + array(
574 + 'created_by' => array(
575 + 'name' => $parent_comment->comment_author,
576 + ),
577 + )
578 + )
579 + ),
580 + );
581 + } else {
582 + $user = wp_get_current_user();
583 + $user_has_admin_capabilities = upstream_is_user_either_manager_or_admin( $user );
584 + $user_can_moderate_comments = ! $user_has_admin_capabilities ? user_can(
585 + $user,
586 + 'moderate_comments'
587 + ) : true;
588 +
589 + if ( $user_can_moderate_comments ) {
590 + $comments = array(
591 + $comment->parent_id => json_decode(
592 + json_encode(
593 + array(
594 + 'created_by' => array(
595 + 'name' => $parent_comment->comment_author,
596 + ),
597 + )
598 + )
599 + ),
600 + );
601 + }
602 + }
603 + }
604 + unset( $parent_comment );
605 + }
606 +
607 + $use_admin_layout = ! isset( $post_data['teeny'] ) ? true : boolval( $post_data['teeny'] ) === false;
608 +
609 + $response['comment_html'] = $comment->render( true, $use_admin_layout, $comments );
610 +
611 + wp_new_comment_notify_moderator( $comment->id );
612 +
613 + $response['success'] = true;
614 + } catch ( \Exception $e ) {
615 + $response['error'] = $e->getMessage();
616 + }
617 +
618 + wp_send_json( $response );
619 + }
620 +
621 + /**
622 + * Either approves/unapproves a given comment.
623 + * This method is called by the correspondent AJAX endpoints.
624 + *
625 + * @since 1.13.0
626 + * @access private
627 + * @static
628 + *
629 + * @throws \Exception When something went wrong or failed on validations.
630 + *
631 + * @param int $comment_id Comment ID being edited.
632 + * @param bool $is_approved Either the comment will be approved or not.
633 + */
634 + private static function toggle_comment_approval_status( $comment_id, $is_approved ) {
635 + $post_data = isset( $_POST ) ? wp_unslash( $_POST ) : array();
636 +
637 + // Check if the request payload is potentially invalid.
638 + if (
639 + ! defined( 'DOING_AJAX' )
640 + || ! DOING_AJAX
641 + || empty( $post_data )
642 + || ! isset( $post_data['nonce'] )
643 + || ! isset( $post_data['project_id'] )
644 + || ! isset( $post_data['comment_id'] )
645 + || ! check_ajax_referer(
646 + 'upstream:project.' . ( $is_approved ? 'approve_comment' : 'unapprove_comment' ) . ':' . sanitize_textarea_field( $post_data['comment_id'] ),
647 + 'nonce',
648 + false
649 + )
650 + ) {
651 + throw new \Exception( __( 'Invalid request.', 'upstream' ) );
652 + }
653 +
654 + // Check if the user has enough permissions to do this.
655 + if ( ! current_user_can( 'moderate_comments' ) ) {
656 + throw new \Exception( __( "You're not allowed to do this.", 'upstream' ) );
657 + }
658 +
659 + // Check if the project potentially exists.
660 + $project_id = absint( $post_data['project_id'] );
661 + if ( $project_id <= 0 ) {
662 + // translators: %s: parameter name.
663 + throw new \Exception( sprintf( __( 'Invalid "%s" parameter.', 'upstream' ), 'project_id' ) );
664 + }
665 +
666 + // Check if the Discussion/Comments section is disabled for the current project.
667 + if ( upstream_are_comments_disabled( $project_id ) ) {
668 + throw new \Exception( __( 'Comments are disabled for this project.', 'upstream' ) );
669 + }
670 +
671 + $cid = isset( $post_data['comment_id'] ) ? absint( $post_data['comment_id'] ) : 0;
672 + $comment = Comment::load( $cid );
673 + if ( ! ( $comment instanceof Comment ) ) {
674 + throw new \Exception( __( 'Comment not found.', 'upstream' ) );
675 + }
676 +
677 + $success = (bool) $is_approved ? $comment->approve() : $comment->unapprove();
678 + if ( ! $success ) {
679 + throw new \Exception( __( 'Unable to save the data into database.', 'upstream' ) );
680 + }
681 +
682 + return $comment;
683 + }
684 +
685 + /**
686 + * AJAX endpoint that approves a comment.
687 + *
688 + * @since 1.13.0
689 + * @static
690 + */
691 + public static function approve_comment() {
692 + header( 'Content-Type: application/json' );
693 +
694 + $post_data = isset( $_POST ) ? wp_unslash( $_POST ) : array();
695 + $comment_id = isset( $post_data['comment_id'] ) ? absint( $post_data['comment_id'] ) : 0;
696 +
697 + check_ajax_referer( 'upstream:project.approve_comment:' . $comment_id, 'nonce' );
698 +
699 + $response = array(
700 + 'success' => false,
701 + 'error' => null,
702 + );
703 +
704 + try {
705 + $comment_id = isset( $post_data['comment_id'] ) ? absint( $post_data['comment_id'] ) : 0;
706 + $comment = self::toggle_comment_approval_status( $comment_id, true );
707 +
708 + $comments = array();
709 + if ( $comment->parent_id > 0 ) {
710 + $parent_comment = get_comment( $comment->parent_id );
711 + if ( is_numeric( $parent_comment->comment_approved ) ) {
712 + if ( (bool) $parent_comment->comment_approved ) {
713 + $comments = array(
714 + $comment->parent_id => json_decode(
715 + wp_json_encode(
716 + array(
717 + 'created_by' => array(
718 + 'name' => $parent_comment->comment_author,
719 + ),
720 + )
721 + )
722 + ),
723 + );
724 + } else {
725 + $user = wp_get_current_user();
726 + $user_has_admin_capabilities = upstream_is_user_either_manager_or_admin( $user );
727 + $user_can_moderate_comments = ! $user_has_admin_capabilities ? user_can(
728 + $user,
729 + 'moderate_comments'
730 + ) : true;
731 +
732 + if ( $user_can_moderate_comments ) {
733 + $comments = array(
734 + $comment->parent_id => json_decode(
735 + json_encode(
736 + array(
737 + 'created_by' => array(
738 + 'name' => $parent_comment->comment_author,
739 + ),
740 + )
741 + )
742 + ),
743 + );
744 + }
745 + }
746 + }
747 + unset( $parent_comment );
748 + }
749 +
750 + $use_admin_layout = ! isset( $post_data['teeny'] ) ? true : boolval( $post_data['teeny'] ) === false;
751 +
752 + $response['comment_html'] = $comment->render( true, $use_admin_layout, $comments );
753 +
754 + $response['success'] = true;
755 + } catch ( \Exception $e ) {
756 + $response['error'] = $e->getMessage();
757 + }
758 +
759 + wp_send_json( $response );
760 + }
761 +
762 + /**
763 + * AJAX endpoint that fetches all comments from a given item/project.
764 + *
765 + * @throws \Exception When something went wrong or failed on validations.
766 + * @since 1.13.0
767 + * @static
768 + */
769 + public static function fetch_comments() {
770 + header( 'Content-Type: application/json' );
771 +
772 + $get_data = isset( $_GET ) ? wp_unslash( $_GET ) : array();
773 + $response = array(
774 + 'success' => false,
775 + 'data' => array(),
776 + 'error' => null,
777 + );
778 +
779 + try {
780 + // Check if the request payload is potentially invalid.
781 + if (
782 + ! defined( 'DOING_AJAX' )
783 + || ! DOING_AJAX
784 + || empty( $get_data )
785 + || ! isset( $get_data['nonce'] )
786 + || ! isset( $get_data['project_id'] )
787 + || ! isset( $get_data['item_type'] )
788 + || ! self::is_item_type_valid( sanitize_textarea_field( $get_data['item_type'] ) )
789 + ) {
790 + throw new \Exception( __( 'Invalid request.', 'upstream' ) );
791 + }
792 +
793 + // Check if the project potentially exists.
794 + $project_id = absint( $get_data['project_id'] );
795 + if ( $project_id <= 0 ) {
796 + throw new \Exception( __( 'Invalid Project.', 'upstream' ) );
797 + }
798 +
799 + // Prepare data to verify nonce.
800 + $comment_target_item_type = strtolower( sanitize_text_field( $get_data['item_type'] ) );
801 + $item_id = null;
802 + if ( 'project' !== $comment_target_item_type ) {
803 + if (
804 + ! isset( $get_data['item_id'] )
805 + || empty( $get_data['item_id'] )
806 + ) {
807 + throw new \Exception( __( 'Invalid request.', 'upstream' ) );
808 + }
809 +
810 + // non-numeric id.
811 + $item_id = sanitize_textarea_field( $get_data['item_id'] );
812 +
813 + $nonce_identifier = 'upstream:project.' . $comment_target_item_type . 's.fetch_comments';
814 + } else {
815 + $nonce_identifier = 'upstream:project.fetch_comments';
816 + }
817 +
818 + // Verify nonce.
819 + if ( ! check_ajax_referer( $nonce_identifier, 'nonce', false ) ) {
820 + throw new \Exception( __( 'Invalid nonce.', 'upstream' ) );
821 + }
822 +
823 + // Check if commenting is disabled on the given project.
824 + if ( upstream_are_comments_disabled( $project_id ) ) {
825 + throw new \Exception( __( 'Commenting is disabled on this project.', 'upstream' ) );
826 + }
827 +
828 + $use_admin_layout = ! isset( $get_data['teeny'] ) ? true : boolval( $get_data['teeny'] ) === false;
829 +
830 + $comments_cache = static::get_comments( $project_id, $comment_target_item_type, $item_id );
831 +
832 + foreach ( $comments_cache as $comment ) {
833 + if ( 0 === $comment->parent_id ) {
834 + ob_start();
835 + if ( $use_admin_layout ) {
836 + upstream_admin_display_message_item( $comment, array() );
837 + } else {
838 + upstream_display_message_item( $comment, array() );
839 + }
840 +
841 + $response['data'][] = trim( ob_get_contents() );
842 + ob_end_clean();
843 + }
844 + }
845 +
846 + $response['success'] = true;
847 + } catch ( \Exception $e ) {
848 + $response['error'] = $e->getMessage();
849 + }
850 +
851 + wp_send_json( $response );
852 + }
853 +
854 + /**
855 + * Get comments.
856 + *
857 + * @param int $project_id Project ID.
858 + * @param string $item_type Item type.
859 + * @param int $item_id Item ID.
860 + *
861 + * @return array
862 + */
863 + public static function get_comments( $project_id, $item_type, $item_id = null ) {
864 + $comments_cache = array();
865 + $users_cache = array();
866 + $users_rowset = get_users(
867 + array(
868 + 'fields' => array(
869 + 'ID',
870 + 'display_name',
871 + ),
872 + )
873 + );
874 + foreach ( $users_rowset as $user_row ) {
875 + $user_row->ID *= 1;
876 +
877 + $users_cache[ $user_row->ID ] = (object) array(
878 + 'id' => $user_row->ID,
879 + 'name' => $user_row->display_name,
880 + 'avatar' => get_userAvatarURL( $user_row->ID ),
881 + );
882 + }
883 + unset( $user_row, $users_rowset );
884 +
885 + if ( 'project' === $item_type ) {
886 + $item_id = $project_id;
887 + }
888 +
889 + $date_format = get_option( 'date_format' );
890 + $time_format = get_option( 'time_format' );
891 + $the_date_time_format = $date_format . ' ' . $time_format;
892 + $current_timestamp = time();
893 + $user = wp_get_current_user();
894 + $user_has_admin_capabilities = upstream_is_user_either_manager_or_admin( $user );
895 + $user_can_reply = ! $user_has_admin_capabilities ? user_can(
896 + $user,
897 + 'publish_project_discussion'
898 + ) : true;
899 +
900 + $user_can_reply = upstream_override_access_field( $user_can_reply, $item_type, $item_id, UPSTREAM_ITEM_TYPE_PROJECT, $project_id, 'comments', UPSTREAM_PERMISSIONS_ACTION_EDIT );
901 +
902 + $user_can_moderate = ! $user_has_admin_capabilities ? user_can( $user, 'moderate_comments' ) : true;
903 + $user_can_delete = ! $user_has_admin_capabilities ? $user_can_moderate || user_can(
904 + $user,
905 + 'delete_project_discussion'
906 + ) : true;
907 +
908 + $user_can_delete = upstream_override_access_field( $user_can_delete, $item_type, $item_id, UPSTREAM_ITEM_TYPE_PROJECT, $project_id, 'comments', UPSTREAM_PERMISSIONS_ACTION_DELETE );
909 +
910 + $comments_statuses = array( 'approve' );
911 + if ( $user_has_admin_capabilities || $user_can_moderate ) {
912 + $comments_statuses[] = 'hold';
913 + }
914 +
915 + $items_rowset = (array) get_post_meta(
916 + $project_id,
917 + '_upstream_project_' . $item_type . 's',
918 + true
919 + );
920 +
921 + if ( count( $items_rowset ) > 0 ) {
922 + foreach ( $items_rowset as $row ) {
923 + if ( empty( $row ) ) {
924 + continue;
925 + }
926 +
927 + if ( ! empty( $item_id ) ) {
928 + if ( $item_id != $row['id'] ) {
929 + continue;
930 + }
931 + }
932 +
933 + $comments = (array) get_comments(
934 + array(
935 + 'post_id' => $project_id,
936 + 'status' => $comments_statuses,
937 + 'meta_query' => array(
938 + 'relation' => 'AND',
939 + array(
940 + 'key' => 'type',
941 + 'value' => $item_type,
942 + ),
943 + array(
944 + 'key' => 'id',
945 + 'value' => $row['id'],
946 + ),
947 + ),
948 + )
949 + );
950 +
951 + if ( count( $comments ) > 0 ) {
952 + foreach ( $comments as $comment ) {
953 + $author = $users_cache[ (int) $comment->user_id ];
954 +
955 + $date = \DateTime::createFromFormat( 'Y-m-d H:i:s', $comment->comment_date_gmt );
956 +
957 + $comment_data = json_decode(
958 + json_encode(
959 + array(
960 + 'id' => (int) $comment->comment_ID,
961 + 'parent_id' => (int) $comment->comment_parent,
962 + 'content' => $comment->comment_content,
963 + 'state' => $comment->comment_approved,
964 + 'created_by' => $author,
965 + 'created_at' => array(
966 + 'localized' => '',
967 + 'humanized' => sprintf(
968 + // translators: %s: human-readable time difference.
969 + _x( '%s ago', '%s = human-readable time difference', 'upstream' ),
970 + human_time_diff( $date->getTimestamp(), $current_timestamp )
971 + ),
972 + ),
973 + 'current_user_cap' => array(
974 + 'can_reply' => $user_can_reply,
975 + 'can_moderate' => $user_can_moderate,
976 + 'can_delete' => $user_can_delete || $author->id === $user->ID,
977 + ),
978 + 'replies' => array(),
979 + )
980 + )
981 + );
982 +
983 + $comment_data->created_at->localized = $date->format( $the_date_time_format );
984 +
985 + $comments_cache[ $comment_data->id ] = $comment_data;
986 + }
987 +
988 + foreach ( $comments_cache as $comment ) {
989 + if ( $comment->parent_id > 0 ) {
990 + if ( isset( $comments_cache[ $comment->parent_id ] ) ) {
991 + $comments_cache[ $comment->parent_id ]->replies[] = $comment;
992 + } else {
993 + unset( $comments_cache[ $comment->id ] );
994 + }
995 + }
996 + }
997 + }
998 + }
999 + }
1000 +
1001 + return $comments_cache;
1002 + }
1003 +
1004 + /**
1005 + * Set additional notification recipients as needed for newly added comments.
1006 + *
1007 + * @since 1.15.0
1008 + * @static
1009 + *
1010 + * @param array $recipients Recipients list.
1011 + * @param int $comment_id The new comment ID.
1012 + *
1013 + * @return array
1014 + */
1015 + public static function define_notification_recipients( $recipients, $comment_id ) {
1016 + $should_send = upstream_send_notifications_for_new_comments();
1017 + if ( ! $should_send ) {
1018 + return array();
1019 + }
1020 +
1021 + // 2 minutes.
1022 + $transient_expiration = 60 * 2;
1023 +
1024 + $comment = get_comment( $comment_id );
1025 + $comment = (object) array(
1026 + 'id' => (int) $comment->comment_ID,
1027 + 'project_id' => (int) $comment->comment_post_ID,
1028 + 'parent' => (int) $comment->comment_parent,
1029 + 'created_by' => (int) $comment->user_id,
1030 + 'target' => get_comment_meta( $comment_id, 'type', true ),
1031 + 'target_id' => (int) $comment->comment_post_ID,
1032 + );
1033 +
1034 + // Check if we should disable all emaill notifications for this project.
1035 + $meta = (array) get_post_meta( $comment->project_id, '_upstream_project_disable_all_notifications' );
1036 + if ( count( $meta ) > 0 && 'on' === $meta[0] ) {
1037 + return array();
1038 + }
1039 +
1040 + // Check if we need to skip further data processing.
1041 + if ( ! in_array( $comment->target, array( 'project', 'milestone', 'task', 'bug', 'file' ) ) ) {
1042 + return $recipients;
1043 + }
1044 +
1045 + $comment->target_label = call_user_func( 'upstream_' . $comment->target . '_label' );
1046 +
1047 + if ( 'project' !== $comment->target ) {
1048 + $comment->target_id = get_comment_meta( $comment_id, 'id', true );
1049 + }
1050 +
1051 + set_transient( 'upstream:comment_notification.comment:' . $comment_id, $comment, $transient_expiration );
1052 +
1053 + $get_user = function ( $user_id ) use ( $transient_expiration ) {
1054 + if ( $user_id <= 0 ) {
1055 + return null;
1056 + }
1057 +
1058 + // Check if the user is cached.
1059 + $user = get_transient( 'upstream:comment_notification.user:' . $user_id );
1060 + if ( empty( $user ) ) {
1061 + // Check if the user exists.
1062 + $user = get_user_by( 'id', $user_id );
1063 + if ( false === $user ) {
1064 + return null;
1065 + }
1066 +
1067 + // Prepare user data.
1068 + $user = (object) array(
1069 + 'id' => (int) $user->ID,
1070 + 'name' => (string) $user->display_name,
1071 + 'email' => (string) $user->user_email,
1072 + );
1073 +
1074 + // Cache user.
1075 + set_transient( 'upstream:comment_notification.user:' . $user->id, $user, $transient_expiration );
1076 + }
1077 +
1078 + return $user;
1079 + };
1080 +
1081 + $fetch_project_meta_as_map = function ( $project_id, $key, &$map ) use ( $transient_expiration, $get_user ) {
1082 +
1083 + $rowset = array();
1084 + if ( 'milestone' === $key ) {
1085 + $rowset = (array) ( \UpStream\Milestones::getInstance()->get_milestones_as_rowset( $project_id ) );
1086 + } else {
1087 + $rowset = (array) get_post_meta( $project_id, '_upstream_project_' . $key . 's', true );
1088 + }
1089 +
1090 + foreach ( $rowset as $row ) {
1091 + $title_key = 'milestone' !== $key ? 'title' : 'milestone';
1092 +
1093 + if ( isset( $row['id'] )
1094 + && ! empty( $row['id'] )
1095 + && isset( $row[ $title_key ] )
1096 + && ! empty( $row[ $title_key ] )
1097 + ) {
1098 + $item = (object) array(
1099 + 'id' => $row['id'],
1100 + 'title' => $row[ $title_key ],
1101 + 'assigned_to' => isset( $row['assigned_to'] ) ? $row['assigned_to'] : array(),
1102 + 'created_by' => isset( $row['created_by'] ) ? (int) $row['created_by'] : 0,
1103 + 'type' => $key,
1104 + );
1105 +
1106 + if ( count( $item->assigned_to ) > 0 ) {
1107 + foreach ( $item->assigned_to as $a ) {
1108 + $user = $get_user( $a );
1109 + $recipients[] = $user->email;
1110 + }
1111 + }
1112 +
1113 + if ( $item->created_by > 0 ) {
1114 + $user = $get_user( $item->created_by );
1115 + if ( empty( $user ) ) {
1116 + $item->created_by = 0;
1117 + } else {
1118 + $item->created_by = $user->id;
1119 + }
1120 + }
1121 +
1122 + $map[ $item->id ] = $item;
1123 + }
1124 + }
1125 +
1126 + };
1127 +
1128 + // RSD: this cache is causing issues
1129 + // $project = get_transient('upstream:comment_notification.project:' . $comment->project_id).
1130 + if ( empty( $project ) ) {
1131 + $project = get_post( $comment->project_id );
1132 + $project = (object) array(
1133 + 'id' => (int) $project->ID,
1134 + 'title' => $project->post_title,
1135 + 'created_by' => (int) $project->post_author,
1136 + 'owner_id' => (int) get_post_meta( $project->ID, '_upstream_project_owner', true ),
1137 + 'owner_email' => '',
1138 + 'milestones' => array(),
1139 + 'tasks' => array(),
1140 + 'bugs' => array(),
1141 + 'files' => array(),
1142 + );
1143 +
1144 + if ( $project->owner_id > 0 ) {
1145 + $owner = get_transient( 'upstream:comment_notification.user:' . $project->owner_id );
1146 + if ( empty( $owner ) ) {
1147 + $owner = get_user_by( 'id', $project->owner_id );
1148 + $owner = (object) array(
1149 + 'id' => $project->owner_id,
1150 + 'name' => (string) $owner->display_name,
1151 + 'email' => (string) $owner->user_email,
1152 + );
1153 +
1154 + set_transient( 'upstream:comment_notification.user:' . $owner->id, $owner, $transient_expiration );
1155 + }
1156 +
1157 + $pms = upstream_project_members_ids( $comment->project_id );
1158 + foreach ( $pms as $pm ) {
1159 + $user_info = get_userdata( $pm );
1160 + $email = $user_info->user_email;
1161 + $recipients[] = $email;
1162 + }
1163 + }
1164 +
1165 + if ( 'project' !== $comment->target ) {
1166 +
1167 + $fetch_project_meta_as_map( $project->id, $comment->target, $project->{$comment->target . 's'} );
1168 +
1169 + foreach ( $project->{$comment->target . 's'} as $item ) {
1170 + $r = $comment->target_id;
1171 + if ( $item->id === $comment->target_id ) {
1172 + if ( count( $item->assigned_to ) > 0 ) {
1173 + foreach ( $item->assigned_to as $a ) {
1174 + $user = $get_user( $a );
1175 + $recipients[] = $user->email;
1176 + }
1177 + }
1178 +
1179 + if ( $item->created_by > 0 ) {
1180 + $user = $get_user( $item->created_by );
1181 + $recipients[] = $user->email;
1182 + }
1183 + }
1184 + }
1185 + }
1186 +
1187 + set_transient(
1188 + 'upstream:comment_notification.project:' . $comment->project_id,
1189 + $project,
1190 + $transient_expiration
1191 + );
1192 + } else {
1193 + if ( 'project' !== $comment->target
1194 + && empty( $project->{$comment->target . 's'} )
1195 + ) {
1196 + $fetch_project_meta_as_map( $project->id, $comment->target, $project->{$comment->target . 's'} );
1197 +
1198 + set_transient(
1199 + 'upstream:comment_notification.project:' . $comment->project_id,
1200 + $project,
1201 + $transient_expiration
1202 + );
1203 + }
1204 + }
1205 +
1206 + if ( $comment->parent > 0 ) {
1207 + $parent_id = $comment->parent;
1208 +
1209 + $users_cache = array();
1210 +
1211 + do {
1212 + $parent_comment = get_comment( $parent_id );
1213 +
1214 + $parent_exists = ! empty( $parent_comment );
1215 + if ( $parent_exists ) {
1216 + if ( ! isset( $users_cache[ $parent_comment->user_id ] ) ) {
1217 + $users_cache[ $parent_comment->user_id ] = $get_user( $parent_comment->user_id );
1218 + $users_cache[ $parent_comment->user_id ]->notify = upstream_user_can_receive_comment_replies_notification( $parent_comment->user_id );
1219 + }
1220 +
1221 + $user = &$users_cache[ $parent_comment->user_id ];
1222 +
1223 + $parent_comment_author = $get_user( $parent_comment->user_id );
1224 +
1225 + if ( $user->notify ) {
1226 + $recipients[] = $parent_comment_author->email;
1227 + }
1228 +
1229 + $parent_id = (int) $parent_comment->comment_parent;
1230 + }
1231 + } while ( $parent_exists );
1232 + }
1233 +
1234 + $recipients = array_unique( array_filter( $recipients ) );
1235 +
1236 + $recipients = apply_filters( 'upstream:comment_notification.recipients', $recipients, $comment );
1237 +
1238 + return $recipients;
1239 + }
1240 +
1241 + /**
1242 + * Add additional info to comment notifications subject.
1243 + *
1244 + * @since 1.15.0
1245 + * @static
1246 + *
1247 + * @param string $subject The original subject.
1248 + * @param int $comment_id The new comment ID.
1249 + *
1250 + * @return string
1251 + */
1252 + public static function define_notification_header( $subject, $comment_id ) {
1253 + $comment = get_transient( 'upstream:comment_notification.comment:' . $comment_id );
1254 + // Check if we need to skip further data processing in case of comments written outside UpStream's scope.
1255 + if ( empty( $comment )
1256 + || in_array( $comment->target, array( 'project', 'milestone', 'task', 'bug', 'file' ) )
1257 + ) {
1258 + return $subject;
1259 + }
1260 +
1261 + $project = get_transient( 'upstream:comment_notification.project:' . $comment->project_id );
1262 + $site_name = get_bloginfo( 'name' );
1263 +
1264 + $subject = sprintf(
1265 + '[%s][%s] %s',
1266 + $site_name,
1267 + $project->title,
1268 + sprintf(
1269 + // translators: %s: Comment notification subject.
1270 + _x( 'New comment on %s', 'Comment notification subject', 'upstream' ),
1271 + $comment->target_label
1272 + )
1273 + );
1274 +
1275 + if ( 'project' !== $comment->target ) {
1276 + $meta = (array) get_post_meta( $project->id, '_upstream_project_' . $comment->target . 's', true );
1277 + foreach ( $meta as $item ) {
1278 + if ( isset( $item['id'] ) && $item['id'] === $comment->target_id ) {
1279 + $title_key = 'milestone' === $comment->target ? 'milestone' : 'title';
1280 +
1281 + if ( isset( $item[ $title_key ] ) ) {
1282 + $subject .= sprintf( ': "%s"', $item[ $title_key ] );
1283 + }
1284 +
1285 + break;
1286 + }
1287 + }
1288 + }
1289 +
1290 + $subject = apply_filters( 'upstream:comment_notification.subject', $subject, $comment, $project );
1291 +
1292 + return $subject;
1293 + }
1294 +
1295 + /**
1296 + * Add items title to notification.
1297 + *
1298 + * @param string $comment_text Comment text.
1299 + * @param int $comment_id Comment ID.
1300 + *
1301 + * @return mixed
1302 + */
1303 + public static function add_item_title_to_notification( $comment_text, $comment_id ) {
1304 + // Check if the comment has item_title in the metadata.
1305 + $item_title = get_comment_meta( $comment_id, 'title', true );
1306 + $item_type = get_comment_meta( $comment_id, 'type', true );
1307 +
1308 + if ( ! empty( $item_title ) ) {
1309 + if ( 'milestone' === $item_type ) {
1310 + // Get the milestone's title.
1311 + $milestones = upstream_get_milestones_titles();
1312 +
1313 + if ( isset( $milestones[ $item_title ] ) ) {
1314 + $item_title = $milestones[ $item_title ];
1315 + }
1316 + }
1317 +
1318 + $comment_text = __( 'Item Title: ', 'upstream' ) . $item_title . "\r\n\r\n" . $comment_text;
1319 + }
1320 +
1321 + if ( ! empty( $item_type ) ) {
1322 + $labels = upstream_get_default_labels();
1323 +
1324 + $item_type_label = $labels[ $item_type . 's' ]['singular'];
1325 +
1326 + $comment_text = __( 'Item Type: ', 'upstream' ) . $item_type_label . "\r\n" . $comment_text;
1327 + }
1328 +
1329 + return $comment_text;
1330 + }
1331 +
1332 + /**
1333 + * Convert notifications text for comments in projects into HTML.
1334 + *
1335 + * @param string $text Comment text.
1336 + * @param int $comment_id Comment ID.
1337 + *
1338 + * @return string
1339 + */
1340 + public static function filter_comment_notification_text( $text, $comment_id ) {
1341 + if ( self::is_comment_from_project( $comment_id ) ) {
1342 + // Convert from txt to html.
1343 + $text = str_replace( "\n", '<br>', $text );
1344 + $text = self::replace_email_with_html_link( $text );
1345 + $text = self::replace_link_with_html_link( $text );
1346 + }
1347 +
1348 + return $text;
1349 + }
1350 +
1351 + /**
1352 + * Is comment from project.
1353 + *
1354 + * @param int $comment_id Comment ID.
1355 + *
1356 + * @return bool
1357 + */
1358 + protected static function is_comment_from_project( $comment_id ) {
1359 + // Check if the post is a project.
1360 + $comment = get_comment( $comment_id );
1361 + $post = get_post( $comment->comment_post_ID );
1362 +
1363 + return 'project' === $post->post_type;
1364 + }
1365 +
1366 + /**
1367 + * Replace email with HTML link.
1368 + *
1369 + * @param string $text Email text.
1370 + *
1371 + * @return string
1372 + */
1373 + protected static function replace_email_with_html_link( $text ) {
1374 + $text = preg_replace( '/([^\s@]+@[a-z\._\-0-9]+)/i', '<a href="mailto:${1}" target="_blank">${1}</a>', $text );
1375 +
1376 + return $text;
1377 + }
1378 +
1379 + /**
1380 + * Replace link with HTML link.
1381 + *
1382 + * @param string $text Link.
1383 + *
1384 + * @return string
1385 + */
1386 + protected static function replace_link_with_html_link( $text ) {
1387 + $text = preg_replace( '~([a-z]+:\/\/\S+)~i', '<a href="${1}" target="_blank">${1}</a>', $text );
1388 +
1389 + return $text;
1390 + }
1391 +
1392 + /**
1393 + * Convert notifications for comments in projects into HTML.
1394 + *
1395 + * @param string $headers Headers.
1396 + * @param int $comment_id Comment ID.
1397 + *
1398 + * @return string
1399 + */
1400 + public static function filter_comment_notification_headers( $headers, $comment_id ) {
1401 + if ( self::is_comment_from_project( $comment_id ) ) {
1402 + // Convert from txt to html.
1403 + $headers = str_replace( 'text/plain;', 'text/html;', $headers );
1404 + }
1405 +
1406 + return $headers;
1407 + }
1408 +}