PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.10.01
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.10.01
2.11.0 2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 All 78 releases
← All changes | app/Http/Controllers/FeedsController.php +279 -284 2.4.012.10.01 View file →
@@ -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;
@@ -10,14 +11,16 @@
10 11 use FluentCommunity\App\Services\CustomSanitizer;
11 12 use FluentCommunity\App\Services\FeedsHelper;
12 13 use FluentCommunity\App\Services\Helper;
13 14 use FluentCommunity\App\Services\Libs\FileSystem;
14 -use FluentCommunity\App\Services\ProfileHelper;
15 +use FluentCommunity\App\Services\UploadHelper;
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)
@@ -31,58 +34,46 @@
31 34 if ($bySpace) {
32 35 // just for validation
33 36 $space = BaseSpace::where('slug', $bySpace)->first();
34 37 if (!$space) {
35 - return $this->sendError(__('Invalid space slug', 'fluent-community'));
38 + return $this->sendError(['message' => __('Invalid space slug', 'fluent-community')]);
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)
50 - ->with([
51 - 'xprofile' => function ($q) {
52 - $q->select(ProfileHelper::getXProfilePublicFields());
53 - },
54 - 'comments' => function ($q) use ($space, $currentUserModel) {
55 - $q->byContentModerationAccessStatus($currentUserModel, $space)
56 - ->with(['xprofile' => function ($q) {
57 - $q->select(ProfileHelper::getXProfilePublicFields());
58 - }])
59 - ->whereHas('xprofile', function ($q) {
60 - $q->where('status', 'active');
61 - });
62 - },
63 - 'space' => function ($q) {
64 - $q->select(['id', 'title', 'slug', 'type']);
65 - },
66 - 'reactions' => function ($q) {
67 - $q->with([
68 - 'xprofile' => function ($query) {
69 - $query->select(['user_id', 'avatar', 'display_name']);
70 - }
71 - ])
72 - ->where('type', 'like')
73 - ->limit(3);
74 - },
75 - 'terms' => function ($q) {
76 - $q->select(['title', 'slug'])
77 - ->where('taxonomy_name', 'post_topic');
78 - }
79 - ]
80 - )
64 + $feedsQuery = Feed::select(Feed::$publicColumns)
65 + ->with(Feed::withPublicRelations($currentUserModel, $space))
81 66 ->searchBy($search, (array)$request->get('search_in', ['post_content']))
82 67 ->byTopicSlug($selectedTopic)
83 68 ->customOrderBy($request->getSafe('order_by_type'));
84 69
70 + if ($applyStatusFilter) {
71 + $feedsQuery->byStatus($statusFilter);
72 + } else {
73 + $feedsQuery->byContentModerationAccessStatus($currentUserModel, $space);
74 + }
75 +
85 76 $stickyFeed = null;
86 77
87 78 $disableSticky = $request->get('disable_sticky', '') == 'yes' || !!$search || !!$selectedTopic;
88 79
@@ -95,44 +86,15 @@
95 86 $feedsQuery = $feedsQuery->where('is_sticky', 0);
96 87 if ($queryArgs['page'] === 1) {
97 88 $stickyFeed = Feed::where('space_id', $space->id)
98 89 ->where('is_sticky', 1)
99 - ->with([
100 - 'xprofile' => function ($q) {
101 - $q->select(ProfileHelper::getXProfilePublicFields());
102 - },
103 - 'comments' => function ($q) use ($space) {
104 - $q->byContentModerationAccessStatus($this->getUser(), $space)
105 - ->with(['xprofile' => function ($q) {
106 - $q->select(ProfileHelper::getXProfilePublicFields());
107 - }])
108 - ->whereHas('xprofile', function ($q) {
109 - $q->where('status', 'active');
110 - });
111 - },
112 - 'space' => function ($q) {
113 - $q->select(['id', 'title', 'slug', 'type']);
114 - },
115 - 'reactions' => function ($q) {
116 - $q->with([
117 - 'xprofile' => function ($query) {
118 - $query->select(['user_id', 'avatar', 'display_name']);
119 - }
120 - ])
121 - ->where('type', 'like')
122 - ->limit(3);
123 - },
124 - 'terms' => function ($q) {
125 - $q->select(['title', 'slug'])
126 - ->where('taxonomy_name', 'post_topic');
127 - }
128 - ])
90 + ->byUserAccess($currentUserId)
91 + ->byContentModerationAccessStatus($currentUserModel, $space)
92 + ->with(Feed::withPublicRelations($this->getUser(), $space))
129 93 ->first();
130 94 }
131 95 }
132 96
133 - $currentUserId = get_current_user_id();
134 -
135 97 if ($userId) {
136 98 $feedsQuery = $feedsQuery->where('user_id', $userId);
137 99
138 100 if (!Helper::isModerator()) {
@@ -217,38 +179,9 @@
217 179 }
218 180
219 181 $feed = Feed::where('slug', $feed_slug)
220 182 ->select(Feed::$publicColumns)
221 - ->with([
222 - 'xprofile' => function ($q) {
223 - $q->select(ProfileHelper::getXProfilePublicFields());
224 - },
225 - 'space' => function ($q) {
226 - $q->select(['id', 'title', 'slug', 'type']);
227 - },
228 - 'comments' => function ($q) {
229 - $q->byContentModerationAccessStatus($this->getUser())
230 - ->with(['xprofile' => function ($q) {
231 - $q->select(ProfileHelper::getXProfilePublicFields());
232 - }])
233 - ->whereHas('xprofile', function ($q) {
234 - $q->where('status', 'active');
235 - });
236 - },
237 - 'reactions' => function ($q) {
238 - $q->with([
239 - 'xprofile' => function ($query) {
240 - $query->select(['user_id', 'avatar', 'display_name']);
241 - }
242 - ])
243 - ->where('type', 'like')
244 - ->limit(3);
245 - },
246 - 'terms' => function ($q) {
247 - $q->select(['title', 'slug'])
248 - ->where('taxonomy_name', 'post_topic');
249 - }
250 - ])
183 + ->with(Feed::withPublicRelations($this->getUser()))
251 184 ->whereHas('xprofile', function ($q) {
252 185 $q->where('status', 'active');
253 186 })
254 187 ->byUserAccess($this->getUserId())
@@ -259,9 +192,11 @@
259 192 'message' => __('The feed could not be found', 'fluent-community')
260 193 ], 404);
261 194 }
262 195
263 - 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())) {
264 199 return $this->sendError([
265 200 'message' => __('Sorry, you do not have permission to view this post', 'fluent-community')
266 201 ], 404);
267 202 }
@@ -286,24 +221,9 @@
286 221 $userId = $this->getUserId();
287 222
288 223 $feedsQuery = Feed::where('status', 'published')
289 224 ->select(Feed::$publicColumns)
290 - ->with([
291 - 'xprofile' => function ($q) {
292 - $q->select(ProfileHelper::getXProfilePublicFields());
293 - },
294 - 'comments' => function ($q) {
295 - $q->byContentModerationAccessStatus($this->getUser())
296 - ->with(['xprofile' => function ($q) {
297 - $q->select(ProfileHelper::getXProfilePublicFields());
298 - }])
299 - ->whereHas('xprofile', function ($q) {
300 - $q->where('status', 'active');
301 - });
302 - },
303 - 'space'
304 - ]
305 - )
225 + ->with(Feed::withPublicRelations($this->getUser()))
306 226 ->byBookMarked($userId)
307 227 ->byUserAccess($userId)
308 228 ->byTopicSlug($request->getSafe('topic_slug'))
309 229 ->customOrderBy($request->getSafe('order_by_type'))
@@ -363,8 +283,10 @@
363 283 $data = $this->sanitizeAndValidateData($requestData);
364 284 $data['user_id'] = $user->ID;
365 285 $data['status'] = 'published';
366 286
287 + $data['status'] = apply_filters('fluent_community/feed/save_status', $data['status'], $requestData, null);
288 +
367 289 $feed = new Feed();
368 290 $feed->user_id = $user->ID;
369 291 $space = null;
370 292
@@ -408,11 +330,9 @@
408 330
409 331 $spaceId = Arr::get($data, 'space_id');
410 332 $message = Arr::get($data, 'message');
411 333
412 - if ($isDulicate = $this->checkForDuplicatePost($user->ID, $message, $spaceId)) {
413 - return $isDulicate;
414 - }
334 + $duplicateCheckMessage = $message;
415 335
416 336 $mentions = FeedsHelper::getMentions($data['message'], Arr::get($data, 'space_id'), true);
417 337 if ($mentions) {
418 338 $data['message'] = $message;
@@ -457,10 +377,27 @@
457 377 ]);
458 378 }
459 379
460 380 $feed->fill($data);
461 - $feed->save();
462 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 +
463 400 $feed = Feed::find($feed->id); // just renewing the feed
464 401
465 402 if ($mentions) {
466 403 do_action('fluent_community/feed_mentioned', $feed, Arr::get($mentions, 'users'));
@@ -499,9 +436,9 @@
499 436 'last_fetched_timestamp' => current_time('timestamp')
500 437 ];
501 438 }
502 439
503 - if ($feed->status != 'published') {
440 + if (!in_array($feed->status, ['published', 'unlisted'])) {
504 441 do_action('fluent_community/feed/new_feed_' . $feed->status, $feed);
505 442 /* translators: %s: The status of the post */
506 443 $message = sprintf(__('Your post has been marked as %s', 'fluent-community'), $feed->status);
507 444 return apply_filters('fluent_community/feed/new_feed_response', [
@@ -518,11 +455,13 @@
518 455 } else {
519 456 do_action('fluent_community/profile_feed/created', $feed);
520 457 }
521 458
459 + $message = __('Your post has been published', 'fluent-community');
460 +
522 461 return apply_filters('fluent_community/feed/new_feed_response', [
523 462 'feed' => FeedsHelper::transformFeed($feed),
524 - 'message' => __('Your post has been published', 'fluent-community'),
463 + 'message' => $message,
525 464 'last_fetched_timestamp' => current_time('timestamp')
526 465 ], $feed, $request->all());
527 466 }
528 467
@@ -531,8 +470,9 @@
531 470 $requestData = $request->all();
532 471 $data = $this->sanitizeAndValidateData($requestData);
533 472 $user = $this->getUser(true);
534 473 $existingFeed = Feed::findOrFail($feedId);
474 + /** @var Feed $existingFeed */
535 475
536 476 $editableStatuses = ['published', 'unlisted', 'scheduled', 'pending'];
537 477
538 478 if (!in_array($existingFeed->status, $editableStatuses)) {
@@ -542,11 +482,25 @@
542 482 }
543 483
544 484 $user->canEditFeed($existingFeed, true);
545 485
546 - if ($status = Arr::get($requestData, 'status')) {
547 - if (in_array($status, $editableStatuses)) {
548 - $data['status'] = $status;
486 + // Must resolve before processFeedMetaData() reads it.
487 + $isModerator = $user->hasPermissionOrInCurrentSpace('community_moderator', $existingFeed->space);
488 + $requestData['is_admin'] = $isModerator;
489 +
490 + if ($surveyOptionError = FeedsHelper::getSurveyOptionsUpdateError(
491 + Arr::get($existingFeed->meta, 'survey_config.options', []),
492 + Arr::get($requestData, 'survey', [])
493 + )) {
494 + return $this->sendError([
495 + 'message' => $surveyOptionError
496 + ]);
497 + }
498 +
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);
549 503 }
550 504 }
551 505
552 506 $message = $data['message'];
@@ -570,10 +524,8 @@
570 524 if (isset($existingFeed->meta['comments_disabled'])) {
571 525 $data['meta']['comments_disabled'] = $existingFeed->meta['comments_disabled'];
572 526 }
573 527
574 - $requestData['is_admin'] = $user->hasPermissionOrInCurrentSpace('community_moderator', $existingFeed->space);
575 -
576 528 if (Arr::get($requestData, 'send_announcement_email') == 'yes' && $requestData['is_admin']) {
577 529 $data['meta']['send_announcement_email'] = 'yes';
578 530 } else if (Arr::get($existingFeed->meta, 'send_announcement_email')) {
579 531 $data['meta']['send_announcement_email'] = Arr::get($existingFeed->meta, 'send_announcement_email');
@@ -616,8 +568,10 @@
616 568 'time' => current_time('mysql')
617 569 ];
618 570 }
619 571
572 + $movingToProfile = false;
573 +
620 574 if ($newSpaceId = $request->get('new_space_id')) {
621 575 if (!Helper::isUserInSpace($existingFeed->user_id, $newSpaceId)) {
622 576 return $this->sendError([
623 577 'message' => __('The author is not a member of the selected space', 'fluent-community')
@@ -644,8 +598,9 @@
644 598 ]);
645 599 }
646 600
647 601 $data['space_id'] = null;
602 + $movingToProfile = true;
648 603
649 604 \FluentCommunity\App\Models\Activity::where('feed_id', $existingFeed->id)
650 605 ->update(['space_id' => null]);
651 606 }
@@ -679,13 +634,20 @@
679 634 foreach ($mediaItems as $mediaItem) {
680 635 $mediaItemIds[] = $mediaItem->id;
681 636 }
682 637
683 - Media::where('object_source', 'feed')
684 - ->where('feed_id', $existingFeed->id)
685 - ->whereNotIn('id', $mediaItemIds)
686 - ->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);
687 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 +
688 650 if ($mediaItems) {
689 651 $this->saveMediaItems($existingFeed, $mediaItems);
690 652 }
691 653
@@ -704,8 +666,11 @@
704 666 if ($space && Arr::get($space->settings, 'topic_required') != 'yes') {
705 667 $existingFeed->terms()->where('taxonomy_name', 'post_topic')->detach();
706 668 }
707 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();
708 673 }
709 674
710 675 if ($dirty) {
711 676 do_action('fluent_community/feed/updated', $existingFeed, $dirty);
@@ -747,14 +712,25 @@
747 712 $data = Arr::only($allData, $validKeys);
748 713
749 714 $data = array_map('intval', $data);
750 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 +
751 726 if (isset($data['is_sticky'])) {
752 727 $data['is_sticky'] = $data['is_sticky'] ? 1 : 0;
753 728 if ($data['is_sticky'] && $feed->space_id) {
754 - // 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.
755 730 Feed::where('space_id', $feed->space_id)
756 731 ->where('is_sticky', 1)
732 + ->toBase()
757 733 ->update(['is_sticky' => 0]);
758 734 }
759 735 }
760 736
@@ -767,8 +743,13 @@
767 743 if ($data) {
768 744 $feed->fill($data);
769 745 $dirty = $feed->getDirty();
770 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 +
771 752 $feed->save();
772 753 do_action('fluent_community/feed/updated', $feed, $dirty);
773 754 }
774 755 }
@@ -903,9 +884,9 @@
903 884
904 885 do_action('fluent_community/feed/deleted', $feed_id);
905 886
906 887 return [
907 - 'message' => 'Feed has been deleted successfully'
888 + 'message' => __('Feed has been deleted successfully', 'fluent-community')
908 889 ];
909 890 }
910 891
911 892 public function deleteMediaPreview(Request $request, $feed_id)
@@ -975,10 +956,11 @@
975 956 $allowedFileSize = $maxFileSize * 1024 * 1024;
976 957 }
977 958
978 959 $files = $this->validate($this->request->files(), [
979 - 'file' => 'mimetypes:' . $allowedTypes . '|max:' . $allowedFileSize,
960 + 'file' => 'required|mimetypes:' . $allowedTypes . '|max:' . $allowedFileSize,
980 961 ], [
962 + 'file.required' => __('No upload file was received. Please try again.', 'fluent-community'),
981 963 'file.mimetypes' => __('The file must be an image type.', 'fluent-community'),
982 964 /* translators: %$1s is replaced by the maximum allowed file size, %2$s is replaced by the file size unit (e.g. MB) */
983 965 'file.max' => sprintf(__('The file size must be less than %1$s%2$s.', 'fluent-community'), $maxFileSize, $maxFileUnit)
984 966 ]);
@@ -990,14 +972,27 @@
990 972 'message' => __('HEIC image format is not supported on this system.', 'fluent-community')
991 973 ]);
992 974 }
993 975
994 - add_filter('wp_handle_upload', [$this, 'fixImageOrientation']);
976 + add_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']);
995 977 $uploadedFiles = FileSystem::put($files);
996 - remove_filter('wp_handle_upload', [$this, 'fixImageOrientation']);
978 + remove_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']);
997 979
998 - $file = $uploadedFiles[0];
980 + $file = Arr::get($uploadedFiles, 0);
999 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 +
1000 995 $upload_dir = wp_upload_dir();
1001 996
1002 997 $originalUrl = $file['url'];
1003 998 $orginalPath = $upload_dir['basedir'] . '/fluent-community/' . $file['file'];
@@ -1140,81 +1135,8 @@
1140 1135 ]
1141 1136 ];
1142 1137 }
1143 1138
1144 - public function fixImageOrientation($file)
1145 - {
1146 - // Only process JPEG images (since they typically have EXIF data)
1147 - $image_types = array('image/jpeg', 'image/jpg');
1148 - if (!in_array($file['type'], $image_types)) {
1149 - return $file;
1150 - }
1151 -
1152 - // Check if the EXIF extension is available
1153 - if (!function_exists('exif_read_data')) {
1154 - return $file;
1155 - }
1156 -
1157 - // Read EXIF data from the uploaded image
1158 - $exif = @exif_read_data($file['file']);
1159 -
1160 - if (!$exif || !isset($exif['Orientation'])) {
1161 - return $file;
1162 - }
1163 -
1164 - $orientation = $exif['Orientation'];
1165 -
1166 - // Load the image based on the available library (Imagick or GD)
1167 - if (extension_loaded('imagick') && class_exists('Imagick')) {
1168 - // Use Imagick if available
1169 - try {
1170 - $image = new \Imagick($file['file']);
1171 - switch ($orientation) {
1172 - case 3: // 180°
1173 - $image->rotateImage(new \ImagickPixel(), 180);
1174 - break;
1175 - case 6: // 90° clockwise
1176 - $image->rotateImage(new \ImagickPixel(), 90);
1177 - break;
1178 - case 8: // 90° counter-clockwise
1179 - $image->rotateImage(new \ImagickPixel(), -90);
1180 - break;
1181 - }
1182 - // Strip EXIF data to prevent further issues
1183 - $image->stripImage();
1184 - // Save the rotated image
1185 - $image->writeImage($file['file']);
1186 - $image->destroy();
1187 - } catch (\Exception $e) {
1188 -
1189 - }
1190 - } elseif (function_exists('imagecreatefromjpeg')) {
1191 - // Use GD if Imagick is not available
1192 - $image = @imagecreatefromjpeg($file['file']);
1193 - if ($image === false) {
1194 - return $file;
1195 - }
1196 -
1197 - switch ($orientation) {
1198 - case 3: // 180°
1199 - $image = imagerotate($image, 180, 0);
1200 - break;
1201 - case 6: // 90° clockwise
1202 - $image = imagerotate($image, -90, 0);
1203 - break;
1204 - case 8: // 90° counter-clockwise
1205 - $image = imagerotate($image, 90, 0);
1206 - break;
1207 - }
1208 -
1209 - // Save the rotated image
1210 - imagejpeg($image, $file['file'], 100);
1211 - imagedestroy($image);
1212 - }
1213 -
1214 - return $file;
1215 - }
1216 -
1217 1139 public function getTicker(Request $request)
1218 1140 {
1219 1141 $start = microtime(true);
1220 1142
@@ -1222,9 +1144,9 @@
1222 1144 if (!$userId) {
1223 1145 return [
1224 1146 'timestamp' => current_time('mysql', true),
1225 1147 'has_changes' => false,
1226 - 'error' => 'User not authenticated',
1148 + 'error' => __('User not authenticated', 'fluent-community'),
1227 1149 'feeds' => []
1228 1150 ];
1229 1151 }
1230 1152
@@ -1233,13 +1155,13 @@
1233 1155
1234 1156 // Support both old and new format
1235 1157 $since = $request->get('since');
1236 1158 if (!$since) {
1237 - $since = date('Y-m-d H:i:s', current_time('timestamp') - 60);
1159 + $since = date('Y-m-d H:i:s', current_time('timestamp') - 60); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
1238 1160 } else {
1239 1161 $timestamp = strtotime($since);
1240 1162 if (current_time('timestamp') - $timestamp > 300) {
1241 - $since = date('Y-m-d H:i:s', current_time('timestamp') - 60);
1163 + $since = date('Y-m-d H:i:s', current_time('timestamp') - 60); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
1242 1164 }
1243 1165 }
1244 1166
1245 1167 $feedUpdates = [];
@@ -1251,38 +1173,9 @@
1251 1173 $currentUserModel = Helper::getCurrentUser();
1252 1174 $updatedFeeds = Feed::where('updated_at', '>', $since)
1253 1175 ->where('status', 'published')
1254 1176 ->byUserAccess($userId)
1255 - ->with([
1256 - 'xprofile' => function ($q) {
1257 - $q->select(ProfileHelper::getXProfilePublicFields());
1258 - },
1259 - 'comments' => function ($q) use ($currentUserModel) {
1260 - $q->byContentModerationAccessStatus($currentUserModel, null)
1261 - ->with(['xprofile' => function ($q) {
1262 - $q->select(ProfileHelper::getXProfilePublicFields());
1263 - }])
1264 - ->whereHas('xprofile', function ($q) {
1265 - $q->where('status', 'active');
1266 - });
1267 - },
1268 - 'space' => function ($q) {
1269 - $q->select(['id', 'title', 'slug', 'type']);
1270 - },
1271 - 'reactions' => function ($q) {
1272 - $q->with([
1273 - 'xprofile' => function ($query) {
1274 - $query->select(['user_id', 'avatar', 'display_name']);
1275 - }
1276 - ])
1277 - ->where('type', 'like')
1278 - ->limit(3);
1279 - },
1280 - 'terms' => function ($q) {
1281 - $q->select(['title', 'slug'])
1282 - ->where('taxonomy_name', 'post_topic');
1283 - }
1284 - ])
1177 + ->with(Feed::withPublicRelations($currentUserModel, null))
1285 1178 ->orderBy('updated_at', 'desc')
1286 1179 ->limit(20) // Reduced limit since we're sending full data
1287 1180 ->get();
1288 1181
@@ -1313,8 +1206,10 @@
1313 1206
1314 1207 // Get notification count
1315 1208 $notificationCount = NotificationSubscriber::unread()->where('user_id', $userId)->count();
1316 1209
1210 + $newNotifications = $this->getToastNotifications($userId, $since, $notificationCount);
1211 +
1317 1212 $response = [
1318 1213 'timestamp' => current_time('mysql'),
1319 1214 'has_changes' => $hasChanges,
1320 1215 'feeds' => $feedUpdates,
@@ -1319,9 +1214,10 @@
1319 1214 'has_changes' => $hasChanges,
1320 1215 'feeds' => $feedUpdates,
1321 1216 'notifications' => [
1322 1217 'unread_count' => $notificationCount,
1323 - 'new_count' => 0 // Could track new since last check
1218 + 'new_count' => count($newNotifications),
1219 + 'new_items' => $newNotifications
1324 1220 ],
1325 1221 'spaces' => [], // For future use
1326 1222 'execution_time' => microtime(true) - $start
1327 1223 ];
@@ -1328,8 +1224,135 @@
1328 1224
1329 1225 return apply_filters('fluent_community/feed_ticker', $response, $request->all());
1330 1226 }
1331 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 +
1332 1355 public function batchFetch(Request $request)
1333 1356 {
1334 1357 $feedIds = $request->get('feed_ids', []);
1335 1358
@@ -1335,9 +1358,9 @@
1335 1358
1336 1359 if (empty($feedIds) || !is_array($feedIds)) {
1337 1360 return [
1338 1361 'feeds' => [],
1339 - 'error' => 'No feed IDs provided'
1362 + 'error' => __('No feed IDs provided', 'fluent-community')
1340 1363 ];
1341 1364 }
1342 1365
1343 1366 $userId = get_current_user_id();
@@ -1352,39 +1375,9 @@
1352 1375
1353 1376 $currentUserModel = $this->getUser();
1354 1377
1355 1378 $feeds = $query
1356 - ->with([
1357 - 'xprofile' => function ($q) {
1358 - $q->select(ProfileHelper::getXProfilePublicFields());
1359 - },
1360 - 'comments' => function ($q) use ($currentUserModel) {
1361 - $q->byContentModerationAccessStatus($currentUserModel)
1362 - ->with(['xprofile' => function ($q) {
1363 - $q->select(ProfileHelper::getXProfilePublicFields());
1364 - }])
1365 - ->whereHas('xprofile', function ($q) {
1366 - $q->where('status', 'active');
1367 - });
1368 - },
1369 - 'space' => function ($q) {
1370 - $q->select(['id', 'title', 'slug', 'type']);
1371 - },
1372 - 'reactions' => function ($q) {
1373 - $q->with([
1374 - 'xprofile' => function ($query) {
1375 - $query->select(['user_id', 'avatar', 'display_name']);
1376 - }
1377 - ])
1378 - ->where('type', 'like')
1379 - ->limit(3);
1380 - },
1381 - 'terms' => function ($q) {
1382 - $q->select(['title', 'slug'])
1383 - ->where('taxonomy_name', 'post_topic');
1384 - }
1385 - ]
1386 - )
1379 + ->with(Feed::withPublicRelations($currentUserModel))
1387 1380 ->get();
1388 1381
1389 1382 $feeds = FeedsHelper::transformFeedsCollection($feeds);
1390 1383
@@ -1404,9 +1397,9 @@
1404 1397 return [
1405 1398 'updates' => [],
1406 1399 'timestamp' => current_time('mysql', true),
1407 1400 'has_changes' => false,
1408 - 'error' => 'User not authenticated'
1401 + 'error' => __('User not authenticated', 'fluent-community')
1409 1402 ];
1410 1403 }
1411 1404
1412 1405 // Parse since timestamp
@@ -1416,9 +1409,9 @@
1416 1409 return [
1417 1410 'updates' => [],
1418 1411 'timestamp' => current_time('mysql', true),
1419 1412 'has_changes' => false,
1420 - 'error' => 'Invalid timestamp format'
1413 + 'error' => __('Invalid timestamp format', 'fluent-community')
1421 1414 ];
1422 1415 }
1423 1416
1424 1417 // Build query based on context
@@ -1423,17 +1416,15 @@
1423 1416
1424 1417 // Build query based on context
1425 1418 $query = Feed::query();
1426 1419
1427 - if ($context === 'global') {
1428 - $query->where('type', 'feed');
1429 - } elseif (str_starts_with($context, 'space-')) {
1420 + if (strpos($context, 'space-') === 0) {
1430 1421 $spaceSlug = str_replace('space-', '', $context);
1431 1422 $space = Space::where('slug', $spaceSlug)->first();
1432 1423 if ($space) {
1433 1424 $query->where('space_id', $space->id);
1434 1425 }
1435 - } elseif (str_starts_with($context, 'user-')) {
1426 + } elseif (strpos($context, 'user-') === 0) {
1436 1427 $targetUserId = str_replace('user-', '', $context);
1437 1428 $query->where('user_id', $targetUserId);
1438 1429 }
1439 1430
@@ -1487,10 +1478,14 @@
1487 1478 }
1488 1479
1489 1480 public function getOembed(Request $request)
1490 1481 {
1491 - $url = $request->get('url');
1492 - // 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 +
1493 1488 $metaData = RemoteUrlParser::parse($url);
1494 1489
1495 1490 if ($metaData && !is_wp_error($metaData)) {
1496 1491 $data = [