| @@ -3,8 +3,9 @@ | ||
| 3 | 3 | namespace FluentCommunity\App\Http\Controllers; |
| 4 | 4 | |
| 5 | 5 | use FluentCommunity\App\Functions\Utility; |
| 6 | 6 | use FluentCommunity\App\Models\Media; |
| 7 | +use FluentCommunity\App\Models\Notification; | |
| 7 | 8 | use FluentCommunity\App\Models\NotificationSubscriber; |
| 8 | 9 | use FluentCommunity\App\Models\Space; |
| 9 | 10 | use FluentCommunity\App\Models\User; |
| 10 | 11 | use FluentCommunity\App\Services\CustomSanitizer; |
| @@ -15,9 +16,11 @@ | ||
| 15 | 16 | use FluentCommunity\App\Services\RemoteUrlParser; |
| 16 | 17 | use FluentCommunity\Framework\Http\Request\Request; |
| 17 | 18 | use FluentCommunity\App\Models\Feed; |
| 18 | 19 | use FluentCommunity\App\Models\BaseSpace; |
| 20 | +use FluentCommunity\App\Models\XProfile; | |
| 19 | 21 | use FluentCommunity\Framework\Support\Arr; |
| 22 | +use FluentCommunity\Modules\PushNotification\PushNotificationModule; | |
| 20 | 23 | |
| 21 | 24 | class FeedsController extends Controller |
| 22 | 25 | { |
| 23 | 26 | public function get(Request $request) |
| @@ -36,23 +39,41 @@ | ||
| 36 | 39 | } |
| 37 | 40 | } |
| 38 | 41 | |
| 39 | 42 | $currentUserModel = $this->getUser(); |
| 43 | + $currentUserId = get_current_user_id(); | |
| 40 | 44 | |
| 45 | + $isOwnProfile = $userId && (int)$userId === (int)$currentUserId; | |
| 46 | + | |
| 47 | + $filterableStatuses = apply_filters('fluent_community/feed/filterable_statuses', []); | |
| 48 | + | |
| 49 | + $statusFilter = $request->getSafe('status', 'sanitize_text_field', ''); | |
| 50 | + | |
| 51 | + $applyStatusFilter = $statusFilter | |
| 52 | + && in_array($statusFilter, $filterableStatuses, true) | |
| 53 | + && (Helper::isModerator() || $isOwnProfile); | |
| 54 | + | |
| 55 | + $maxPerPage = (int) apply_filters('fluent_community/max_per_page', 100) ?: 100; | |
| 56 | + | |
| 41 | 57 | $queryArgs = [ |
| 42 | 58 | 'selected_topic' => $selectedTopic, |
| 43 | - 'per_page' => (int)$request->get('per_page', 10), | |
| 44 | - 'page' => (int)$request->get('page', 1), | |
| 59 | + 'per_page' => min($maxPerPage, max(1, (int)$request->get('per_page', 10))), | |
| 60 | + 'page' => max(1, (int)$request->get('page', 1)), | |
| 45 | 61 | 'search' => $search, |
| 46 | 62 | ]; |
| 47 | 63 | |
| 48 | - $feedsQuery = Feed::byContentModerationAccessStatus($currentUserModel, $space) | |
| 49 | - ->select(Feed::$publicColumns) | |
| 64 | + $feedsQuery = Feed::select(Feed::$publicColumns) | |
| 50 | 65 | ->with(Feed::withPublicRelations($currentUserModel, $space)) |
| 51 | 66 | ->searchBy($search, (array)$request->get('search_in', ['post_content'])) |
| 52 | 67 | ->byTopicSlug($selectedTopic) |
| 53 | 68 | ->customOrderBy($request->getSafe('order_by_type')); |
| 54 | 69 | |
| 70 | + if ($applyStatusFilter) { | |
| 71 | + $feedsQuery->byStatus($statusFilter); | |
| 72 | + } else { | |
| 73 | + $feedsQuery->byContentModerationAccessStatus($currentUserModel, $space); | |
| 74 | + } | |
| 75 | + | |
| 55 | 76 | $stickyFeed = null; |
| 56 | 77 | |
| 57 | 78 | $disableSticky = $request->get('disable_sticky', '') == 'yes' || !!$search || !!$selectedTopic; |
| 58 | 79 | |
| @@ -65,15 +86,15 @@ | ||
| 65 | 86 | $feedsQuery = $feedsQuery->where('is_sticky', 0); |
| 66 | 87 | if ($queryArgs['page'] === 1) { |
| 67 | 88 | $stickyFeed = Feed::where('space_id', $space->id) |
| 68 | 89 | ->where('is_sticky', 1) |
| 90 | + ->byUserAccess($currentUserId) | |
| 91 | + ->byContentModerationAccessStatus($currentUserModel, $space) | |
| 69 | 92 | ->with(Feed::withPublicRelations($this->getUser(), $space)) |
| 70 | 93 | ->first(); |
| 71 | 94 | } |
| 72 | 95 | } |
| 73 | 96 | |
| 74 | - $currentUserId = get_current_user_id(); | |
| 75 | - | |
| 76 | 97 | if ($userId) { |
| 77 | 98 | $feedsQuery = $feedsQuery->where('user_id', $userId); |
| 78 | 99 | |
| 79 | 100 | if (!Helper::isModerator()) { |
| @@ -171,9 +192,11 @@ | ||
| 171 | 192 | 'message' => __('The feed could not be found', 'fluent-community') |
| 172 | 193 | ], 404); |
| 173 | 194 | } |
| 174 | 195 | |
| 175 | - if ($feed->status != 'published' && !$feed->hasEditAccess($this->getUserId())) { | |
| 196 | + $viewableByLinkStatuses = FeedsHelper::getViewableByLinkStatuses(); | |
| 197 | + | |
| 198 | + if (!in_array($feed->status, $viewableByLinkStatuses, true) && !$feed->hasEditAccess($this->getUserId())) { | |
| 176 | 199 | return $this->sendError([ |
| 177 | 200 | 'message' => __('Sorry, you do not have permission to view this post', 'fluent-community') |
| 178 | 201 | ], 404); |
| 179 | 202 | } |
| @@ -260,8 +283,10 @@ | ||
| 260 | 283 | $data = $this->sanitizeAndValidateData($requestData); |
| 261 | 284 | $data['user_id'] = $user->ID; |
| 262 | 285 | $data['status'] = 'published'; |
| 263 | 286 | |
| 287 | + $data['status'] = apply_filters('fluent_community/feed/save_status', $data['status'], $requestData, null); | |
| 288 | + | |
| 264 | 289 | $feed = new Feed(); |
| 265 | 290 | $feed->user_id = $user->ID; |
| 266 | 291 | $space = null; |
| 267 | 292 | |
| @@ -305,11 +330,9 @@ | ||
| 305 | 330 | |
| 306 | 331 | $spaceId = Arr::get($data, 'space_id'); |
| 307 | 332 | $message = Arr::get($data, 'message'); |
| 308 | 333 | |
| 309 | - if ($isDulicate = $this->checkForDuplicatePost($user->ID, $message, $spaceId)) { | |
| 310 | - return $isDulicate; | |
| 311 | - } | |
| 334 | + $duplicateCheckMessage = $message; | |
| 312 | 335 | |
| 313 | 336 | $mentions = FeedsHelper::getMentions($data['message'], Arr::get($data, 'space_id'), true); |
| 314 | 337 | if ($mentions) { |
| 315 | 338 | $data['message'] = $message; |
| @@ -354,12 +377,28 @@ | ||
| 354 | 377 | ]); |
| 355 | 378 | } |
| 356 | 379 | |
| 357 | 380 | $feed->fill($data); |
| 358 | - $feed->save(); | |
| 359 | 381 | |
| 382 | + // Serialize a member's concurrent submissions by locking their profile row, | |
| 383 | + // so parallel matching requests cannot pass the duplicate check and both insert. | |
| 384 | + $isDuplicate = Helper::dbTransaction(function () use ($feed, $user, $spaceId, $duplicateCheckMessage) { | |
| 385 | + XProfile::where('user_id', $user->ID)->lockForUpdate()->first(); | |
| 386 | + | |
| 387 | + if ($duplicate = $this->checkForDuplicatePost($user->ID, $duplicateCheckMessage, $spaceId)) { | |
| 388 | + return $duplicate; | |
| 389 | + } | |
| 390 | + | |
| 391 | + $feed->save(); | |
| 392 | + | |
| 393 | + return null; | |
| 394 | + }); | |
| 395 | + | |
| 396 | + if ($isDuplicate) { | |
| 397 | + return $isDuplicate; | |
| 398 | + } | |
| 399 | + | |
| 360 | 400 | $feed = Feed::find($feed->id); // just renewing the feed |
| 361 | - /** @var Feed $feed */ | |
| 362 | 401 | |
| 363 | 402 | if ($mentions) { |
| 364 | 403 | do_action('fluent_community/feed_mentioned', $feed, Arr::get($mentions, 'users')); |
| 365 | 404 | } |
| @@ -397,9 +436,9 @@ | ||
| 397 | 436 | 'last_fetched_timestamp' => current_time('timestamp') |
| 398 | 437 | ]; |
| 399 | 438 | } |
| 400 | 439 | |
| 401 | - if ($feed->status != 'published') { | |
| 440 | + if (!in_array($feed->status, ['published', 'unlisted'])) { | |
| 402 | 441 | do_action('fluent_community/feed/new_feed_' . $feed->status, $feed); |
| 403 | 442 | /* translators: %s: The status of the post */ |
| 404 | 443 | $message = sprintf(__('Your post has been marked as %s', 'fluent-community'), $feed->status); |
| 405 | 444 | return apply_filters('fluent_community/feed/new_feed_response', [ |
| @@ -416,11 +455,13 @@ | ||
| 416 | 455 | } else { |
| 417 | 456 | do_action('fluent_community/profile_feed/created', $feed); |
| 418 | 457 | } |
| 419 | 458 | |
| 459 | + $message = __('Your post has been published', 'fluent-community'); | |
| 460 | + | |
| 420 | 461 | return apply_filters('fluent_community/feed/new_feed_response', [ |
| 421 | 462 | 'feed' => FeedsHelper::transformFeed($feed), |
| 422 | - 'message' => __('Your post has been published', 'fluent-community'), | |
| 463 | + 'message' => $message, | |
| 423 | 464 | 'last_fetched_timestamp' => current_time('timestamp') |
| 424 | 465 | ], $feed, $request->all()); |
| 425 | 466 | } |
| 426 | 467 | |
| @@ -441,8 +482,12 @@ | ||
| 441 | 482 | } |
| 442 | 483 | |
| 443 | 484 | $user->canEditFeed($existingFeed, true); |
| 444 | 485 | |
| 486 | + // Must resolve before processFeedMetaData() reads it. | |
| 487 | + $isModerator = $user->hasPermissionOrInCurrentSpace('community_moderator', $existingFeed->space); | |
| 488 | + $requestData['is_admin'] = $isModerator; | |
| 489 | + | |
| 445 | 490 | if ($surveyOptionError = FeedsHelper::getSurveyOptionsUpdateError( |
| 446 | 491 | Arr::get($existingFeed->meta, 'survey_config.options', []), |
| 447 | 492 | Arr::get($requestData, 'survey', []) |
| 448 | 493 | )) { |
| @@ -450,11 +495,12 @@ | ||
| 450 | 495 | 'message' => $surveyOptionError |
| 451 | 496 | ]); |
| 452 | 497 | } |
| 453 | 498 | |
| 454 | - if ($status = Arr::get($requestData, 'status')) { | |
| 455 | - if (in_array($status, $editableStatuses)) { | |
| 456 | - $data['status'] = $status; | |
| 499 | + if ($isModerator && ($status = Arr::get($requestData, 'status'))) { | |
| 500 | + if (in_array($status, $editableStatuses, true)) { | |
| 501 | + $fallbackStatus = $status === 'unlisted' ? $existingFeed->status : $status; | |
| 502 | + $data['status'] = apply_filters('fluent_community/feed/save_status', $fallbackStatus, $requestData, $existingFeed); | |
| 457 | 503 | } |
| 458 | 504 | } |
| 459 | 505 | |
| 460 | 506 | $message = $data['message']; |
| @@ -478,10 +524,8 @@ | ||
| 478 | 524 | if (isset($existingFeed->meta['comments_disabled'])) { |
| 479 | 525 | $data['meta']['comments_disabled'] = $existingFeed->meta['comments_disabled']; |
| 480 | 526 | } |
| 481 | 527 | |
| 482 | - $requestData['is_admin'] = $user->hasPermissionOrInCurrentSpace('community_moderator', $existingFeed->space); | |
| 483 | - | |
| 484 | 528 | if (Arr::get($requestData, 'send_announcement_email') == 'yes' && $requestData['is_admin']) { |
| 485 | 529 | $data['meta']['send_announcement_email'] = 'yes'; |
| 486 | 530 | } else if (Arr::get($existingFeed->meta, 'send_announcement_email')) { |
| 487 | 531 | $data['meta']['send_announcement_email'] = Arr::get($existingFeed->meta, 'send_announcement_email'); |
| @@ -524,8 +568,10 @@ | ||
| 524 | 568 | 'time' => current_time('mysql') |
| 525 | 569 | ]; |
| 526 | 570 | } |
| 527 | 571 | |
| 572 | + $movingToProfile = false; | |
| 573 | + | |
| 528 | 574 | if ($newSpaceId = $request->get('new_space_id')) { |
| 529 | 575 | if (!Helper::isUserInSpace($existingFeed->user_id, $newSpaceId)) { |
| 530 | 576 | return $this->sendError([ |
| 531 | 577 | 'message' => __('The author is not a member of the selected space', 'fluent-community') |
| @@ -552,8 +598,9 @@ | ||
| 552 | 598 | ]); |
| 553 | 599 | } |
| 554 | 600 | |
| 555 | 601 | $data['space_id'] = null; |
| 602 | + $movingToProfile = true; | |
| 556 | 603 | |
| 557 | 604 | \FluentCommunity\App\Models\Activity::where('feed_id', $existingFeed->id) |
| 558 | 605 | ->update(['space_id' => null]); |
| 559 | 606 | } |
| @@ -587,13 +634,20 @@ | ||
| 587 | 634 | foreach ($mediaItems as $mediaItem) { |
| 588 | 635 | $mediaItemIds[] = $mediaItem->id; |
| 589 | 636 | } |
| 590 | 637 | |
| 591 | - Media::where('object_source', 'feed') | |
| 592 | - ->where('feed_id', $existingFeed->id) | |
| 593 | - ->whereNotIn('id', $mediaItemIds) | |
| 594 | - ->update(['is_active' => 0]); | |
| 638 | + if (Arr::has($requestData, 'media_images')) { | |
| 639 | + $deactivateQuery = Media::where('object_source', 'feed') | |
| 640 | + ->where('feed_id', $existingFeed->id) | |
| 641 | + ->whereNotIn('id', $mediaItemIds); | |
| 595 | 642 | |
| 643 | + if (empty(Arr::get($requestData, 'media_images'))) { | |
| 644 | + $deactivateQuery->where('media_type', '!=', 'fluent_player'); | |
| 645 | + } | |
| 646 | + | |
| 647 | + $deactivateQuery->update(['is_active' => 0]); | |
| 648 | + } | |
| 649 | + | |
| 596 | 650 | if ($mediaItems) { |
| 597 | 651 | $this->saveMediaItems($existingFeed, $mediaItems); |
| 598 | 652 | } |
| 599 | 653 | |
| @@ -612,8 +666,11 @@ | ||
| 612 | 666 | if ($space && Arr::get($space->settings, 'topic_required') != 'yes') { |
| 613 | 667 | $existingFeed->terms()->where('taxonomy_name', 'post_topic')->detach(); |
| 614 | 668 | } |
| 615 | 669 | } |
| 670 | + } else if ($movingToProfile) { | |
| 671 | + // Topics are space-scoped; a post moved to the profile must not keep them. | |
| 672 | + $existingFeed->terms()->where('taxonomy_name', 'post_topic')->detach(); | |
| 616 | 673 | } |
| 617 | 674 | |
| 618 | 675 | if ($dirty) { |
| 619 | 676 | do_action('fluent_community/feed/updated', $existingFeed, $dirty); |
| @@ -655,14 +712,25 @@ | ||
| 655 | 712 | $data = Arr::only($allData, $validKeys); |
| 656 | 713 | |
| 657 | 714 | $data = array_map('intval', $data); |
| 658 | 715 | |
| 716 | + // List/unlist toggle — community-moderator only, routed through the shared save_status filter. | |
| 717 | + if (Helper::isModerator($user) | |
| 718 | + && ($reqStatus = Arr::get($allData, 'status')) | |
| 719 | + && in_array($reqStatus, ['published', 'unlisted'], true) | |
| 720 | + && in_array($feed->status, ['published', 'unlisted'], true) | |
| 721 | + ) { | |
| 722 | + $fallbackStatus = $reqStatus === 'unlisted' ? $feed->status : $reqStatus; | |
| 723 | + $data['status'] = apply_filters('fluent_community/feed/save_status', $fallbackStatus, $allData, $feed); | |
| 724 | + } | |
| 725 | + | |
| 659 | 726 | if (isset($data['is_sticky'])) { |
| 660 | 727 | $data['is_sticky'] = $data['is_sticky'] ? 1 : 0; |
| 661 | 728 | if ($data['is_sticky'] && $feed->space_id) { |
| 662 | - // remove all the sticky posts from the space | |
| 729 | + // toBase() keeps the type scope but skips the Orm update()'s updated_at stamp, which would bump the post being un-stuck. | |
| 663 | 730 | Feed::where('space_id', $feed->space_id) |
| 664 | 731 | ->where('is_sticky', 1) |
| 732 | + ->toBase() | |
| 665 | 733 | ->update(['is_sticky' => 0]); |
| 666 | 734 | } |
| 667 | 735 | } |
| 668 | 736 | |
| @@ -675,8 +743,13 @@ | ||
| 675 | 743 | if ($data) { |
| 676 | 744 | $feed->fill($data); |
| 677 | 745 | $dirty = $feed->getDirty(); |
| 678 | 746 | if ($dirty) { |
| 747 | + // Only a real list/unlist transition is activity, so read $dirty, not the request. | |
| 748 | + if (!array_key_exists('status', $dirty)) { | |
| 749 | + $feed->timestamps = false; | |
| 750 | + } | |
| 751 | + | |
| 679 | 752 | $feed->save(); |
| 680 | 753 | do_action('fluent_community/feed/updated', $feed, $dirty); |
| 681 | 754 | } |
| 682 | 755 | } |
| @@ -883,10 +956,11 @@ | ||
| 883 | 956 | $allowedFileSize = $maxFileSize * 1024 * 1024; |
| 884 | 957 | } |
| 885 | 958 | |
| 886 | 959 | $files = $this->validate($this->request->files(), [ |
| 887 | - 'file' => 'mimetypes:' . $allowedTypes . '|max:' . $allowedFileSize, | |
| 960 | + 'file' => 'required|mimetypes:' . $allowedTypes . '|max:' . $allowedFileSize, | |
| 888 | 961 | ], [ |
| 962 | + 'file.required' => __('No upload file was received. Please try again.', 'fluent-community'), | |
| 889 | 963 | 'file.mimetypes' => __('The file must be an image type.', 'fluent-community'), |
| 890 | 964 | /* translators: %$1s is replaced by the maximum allowed file size, %2$s is replaced by the file size unit (e.g. MB) */ |
| 891 | 965 | 'file.max' => sprintf(__('The file size must be less than %1$s%2$s.', 'fluent-community'), $maxFileSize, $maxFileUnit) |
| 892 | 966 | ]); |
| @@ -902,10 +976,23 @@ | ||
| 902 | 976 | add_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']); |
| 903 | 977 | $uploadedFiles = FileSystem::put($files); |
| 904 | 978 | remove_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']); |
| 905 | 979 | |
| 906 | - $file = $uploadedFiles[0]; | |
| 980 | + $file = Arr::get($uploadedFiles, 0); | |
| 907 | 981 | |
| 982 | + if (is_wp_error($file)) { | |
| 983 | + return $this->sendError([ | |
| 984 | + 'message' => $file->get_error_message() | |
| 985 | + ]); | |
| 986 | + } | |
| 987 | + | |
| 988 | + // an empty request body reaches here with nothing uploaded; never build media data from it | |
| 989 | + if (!is_array($file) || empty($file['url']) || empty($file['file']) || empty($file['type'])) { | |
| 990 | + return $this->sendError([ | |
| 991 | + 'message' => __('No upload file was received. Please try again.', 'fluent-community') | |
| 992 | + ]); | |
| 993 | + } | |
| 994 | + | |
| 908 | 995 | $upload_dir = wp_upload_dir(); |
| 909 | 996 | |
| 910 | 997 | $originalUrl = $file['url']; |
| 911 | 998 | $orginalPath = $upload_dir['basedir'] . '/fluent-community/' . $file['file']; |
| @@ -1119,8 +1206,10 @@ | ||
| 1119 | 1206 | |
| 1120 | 1207 | // Get notification count |
| 1121 | 1208 | $notificationCount = NotificationSubscriber::unread()->where('user_id', $userId)->count(); |
| 1122 | 1209 | |
| 1210 | + $newNotifications = $this->getToastNotifications($userId, $since, $notificationCount); | |
| 1211 | + | |
| 1123 | 1212 | $response = [ |
| 1124 | 1213 | 'timestamp' => current_time('mysql'), |
| 1125 | 1214 | 'has_changes' => $hasChanges, |
| 1126 | 1215 | 'feeds' => $feedUpdates, |
| @@ -1125,9 +1214,10 @@ | ||
| 1125 | 1214 | 'has_changes' => $hasChanges, |
| 1126 | 1215 | 'feeds' => $feedUpdates, |
| 1127 | 1216 | 'notifications' => [ |
| 1128 | 1217 | 'unread_count' => $notificationCount, |
| 1129 | - 'new_count' => 0 // Could track new since last check | |
| 1218 | + 'new_count' => count($newNotifications), | |
| 1219 | + 'new_items' => $newNotifications | |
| 1130 | 1220 | ], |
| 1131 | 1221 | 'spaces' => [], // For future use |
| 1132 | 1222 | 'execution_time' => microtime(true) - $start |
| 1133 | 1223 | ]; |
| @@ -1134,8 +1224,135 @@ | ||
| 1134 | 1224 | |
| 1135 | 1225 | return apply_filters('fluent_community/feed_ticker', $response, $request->all()); |
| 1136 | 1226 | } |
| 1137 | 1227 | |
| 1228 | + /** | |
| 1229 | + * Unread notifications that landed since the previous ticker check, shaped for the | |
| 1230 | + * in-app toast. Deliberately cheap: | |
| 1231 | + * | |
| 1232 | + * - returns before touching the DB when the toast is filtered off or the user has | |
| 1233 | + * nothing unread, so the steady state costs zero extra queries | |
| 1234 | + * - the predicate is answered by the (user_id, is_read, object_type, updated_at) | |
| 1235 | + * index added in NotificationUserMigrator, so this is a short range scan with | |
| 1236 | + * no filesort - on a 177k-row table it examines a single row instead of the | |
| 1237 | + * ~88k the single-column is_read index used to force | |
| 1238 | + * - the cursor is the subscriber `updated_at`, not `created_at`: a re-notification | |
| 1239 | + * ("X and 3 others reacted to your post") bumps the existing subscriber row in | |
| 1240 | + * place instead of inserting a new one - see NotificationEventHandler | |
| 1241 | + * - the xprofile eager load only fires when at least one row came back | |
| 1242 | + * | |
| 1243 | + * @param int $userId | |
| 1244 | + * @param string $since MySQL datetime in site local time | |
| 1245 | + * @param int $unreadCount | |
| 1246 | + * @return array | |
| 1247 | + */ | |
| 1248 | + protected function getToastNotifications($userId, $since, $unreadCount) | |
| 1249 | + { | |
| 1250 | + if (!$unreadCount || !$since) { | |
| 1251 | + return []; | |
| 1252 | + } | |
| 1253 | + | |
| 1254 | + if (!apply_filters('fluent_community/enable_notification_toast', true, $userId)) { | |
| 1255 | + return []; | |
| 1256 | + } | |
| 1257 | + | |
| 1258 | + $limit = (int)apply_filters('fluent_community/notification_toast_limit', 3, $userId); | |
| 1259 | + | |
| 1260 | + if ($limit < 1) { | |
| 1261 | + return []; | |
| 1262 | + } | |
| 1263 | + | |
| 1264 | + $notifications = Notification::query() | |
| 1265 | + ->select([ | |
| 1266 | + 'fcom_notifications.id', | |
| 1267 | + 'fcom_notifications.feed_id', | |
| 1268 | + 'fcom_notifications.object_id', | |
| 1269 | + 'fcom_notifications.src_user_id', | |
| 1270 | + 'fcom_notifications.action', | |
| 1271 | + 'fcom_notifications.content', | |
| 1272 | + 'fcom_notifications.route', | |
| 1273 | + 'fcom_notification_users.updated_at as notified_at' | |
| 1274 | + ]) | |
| 1275 | + ->join('fcom_notification_users', 'fcom_notification_users.object_id', '=', 'fcom_notifications.id') | |
| 1276 | + ->where('fcom_notification_users.user_id', $userId) | |
| 1277 | + ->where('fcom_notification_users.is_read', 0) | |
| 1278 | + ->where('fcom_notification_users.object_type', 'notification') | |
| 1279 | + ->where('fcom_notification_users.updated_at', '>', $since) | |
| 1280 | + ->with(['xprofile' => function ($q) { | |
| 1281 | + return $q->select(['user_id', 'display_name', 'username', 'avatar']); | |
| 1282 | + }]) | |
| 1283 | + ->orderBy('fcom_notification_users.updated_at', 'DESC') | |
| 1284 | + ->limit($limit) | |
| 1285 | + ->get(); | |
| 1286 | + | |
| 1287 | + $commentIds = []; | |
| 1288 | + foreach ($notifications as $notification) { | |
| 1289 | + if (!in_array($notification->action, PushNotificationModule::PUSHED_ACTIONS, true)) { | |
| 1290 | + continue; | |
| 1291 | + } | |
| 1292 | + | |
| 1293 | + $commentIds[] = (int)$notification->object_id; | |
| 1294 | + $commentIds[] = (int)Arr::get((array)$notification->route, 'query.comment_id'); | |
| 1295 | + } | |
| 1296 | + | |
| 1297 | + $pushedCommentIds = PushNotificationModule::getPushedCommentIds( | |
| 1298 | + $userId, | |
| 1299 | + array_values(array_filter(array_unique($commentIds))) | |
| 1300 | + ); | |
| 1301 | + | |
| 1302 | + $items = []; | |
| 1303 | + | |
| 1304 | + foreach ($notifications as $notification) { | |
| 1305 | + $wasPushed = in_array($notification->action, PushNotificationModule::PUSHED_ACTIONS, true) | |
| 1306 | + && (in_array((int)$notification->object_id, $pushedCommentIds, true) | |
| 1307 | + || in_array((int)Arr::get((array)$notification->route, 'query.comment_id'), $pushedCommentIds, true)); | |
| 1308 | + | |
| 1309 | + // The push already told this member; a toast would say it twice. | |
| 1310 | + if ($wasPushed) { | |
| 1311 | + continue; | |
| 1312 | + } | |
| 1313 | + | |
| 1314 | + $xprofile = $notification->xprofile; | |
| 1315 | + | |
| 1316 | + $items[] = [ | |
| 1317 | + 'id' => (int)$notification->id, | |
| 1318 | + 'feed_id' => $notification->feed_id ? (int)$notification->feed_id : null, | |
| 1319 | + 'object_id' => $notification->object_id ? (int)$notification->object_id : null, | |
| 1320 | + 'action' => $notification->action, | |
| 1321 | + 'route' => $notification->route, | |
| 1322 | + 'text' => $this->getToastText($notification->content), | |
| 1323 | + 'notified_at' => $notification->notified_at, | |
| 1324 | + 'avatar' => $xprofile ? $xprofile->avatar : '', | |
| 1325 | + 'name' => $xprofile ? $xprofile->display_name : '' | |
| 1326 | + ]; | |
| 1327 | + } | |
| 1328 | + | |
| 1329 | + return apply_filters('fluent_community/notification_toast_items', $items, $userId); | |
| 1330 | + } | |
| 1331 | + | |
| 1332 | + /** | |
| 1333 | + * Flatten stored notification HTML to a single line of plain text. The toast renders | |
| 1334 | + * this with v-text, so it must never carry markup back to the client. | |
| 1335 | + * | |
| 1336 | + * @param string $content | |
| 1337 | + * @return string | |
| 1338 | + */ | |
| 1339 | + protected function getToastText($content) | |
| 1340 | + { | |
| 1341 | + if (!$content) { | |
| 1342 | + return ''; | |
| 1343 | + } | |
| 1344 | + | |
| 1345 | + $text = wp_specialchars_decode(wp_strip_all_tags($content), ENT_QUOTES); | |
| 1346 | + $text = trim(preg_replace('/\s+/', ' ', $text)); | |
| 1347 | + | |
| 1348 | + if (mb_strlen($text) > 140) { | |
| 1349 | + $text = mb_substr($text, 0, 140) . '...'; | |
| 1350 | + } | |
| 1351 | + | |
| 1352 | + return $text; | |
| 1353 | + } | |
| 1354 | + | |
| 1138 | 1355 | public function batchFetch(Request $request) |
| 1139 | 1356 | { |
| 1140 | 1357 | $feedIds = $request->get('feed_ids', []); |
| 1141 | 1358 | |
| @@ -1261,10 +1478,14 @@ | ||
| 1261 | 1478 | } |
| 1262 | 1479 | |
| 1263 | 1480 | public function getOembed(Request $request) |
| 1264 | 1481 | { |
| 1265 | - $url = $request->get('url'); | |
| 1266 | - // check if the url is valid | |
| 1482 | + $currentUser = $this->getUser(true); | |
| 1483 | + | |
| 1484 | + do_action('fluent_community/check_rate_limit/oembed', $currentUser); | |
| 1485 | + | |
| 1486 | + $url = $request->getSafe('url', 'sanitize_url'); | |
| 1487 | + | |
| 1267 | 1488 | $metaData = RemoteUrlParser::parse($url); |
| 1268 | 1489 | |
| 1269 | 1490 | if ($metaData && !is_wp_error($metaData)) { |
| 1270 | 1491 | $data = [ |