PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.10.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.10.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 1.1.0 All 77 releases
← All changes | app/Http/Controllers/FeedsController.php +262 -285 2.7.02.10.0 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,33 +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 - 'reactions' => function ($q) {
305 - $q->with([
306 - 'xprofile' => function ($query) {
307 - $query->select(['user_id', 'avatar', 'display_name']);
308 - }
309 - ])
310 - ->where('type', 'like')
311 - ->limit(3);
312 - },
313 - ]
314 - )
225 + ->with(Feed::withPublicRelations($this->getUser()))
315 226 ->byBookMarked($userId)
316 227 ->byUserAccess($userId)
317 228 ->byTopicSlug($request->getSafe('topic_slug'))
318 229 ->customOrderBy($request->getSafe('order_by_type'))
@@ -372,8 +283,10 @@
372 283 $data = $this->sanitizeAndValidateData($requestData);
373 284 $data['user_id'] = $user->ID;
374 285 $data['status'] = 'published';
375 286
287 + $data['status'] = apply_filters('fluent_community/feed/save_status', $data['status'], $requestData, null);
288 +
376 289 $feed = new Feed();
377 290 $feed->user_id = $user->ID;
378 291 $space = null;
379 292
@@ -417,11 +330,9 @@
417 330
418 331 $spaceId = Arr::get($data, 'space_id');
419 332 $message = Arr::get($data, 'message');
420 333
421 - if ($isDulicate = $this->checkForDuplicatePost($user->ID, $message, $spaceId)) {
422 - return $isDulicate;
423 - }
334 + $duplicateCheckMessage = $message;
424 335
425 336 $mentions = FeedsHelper::getMentions($data['message'], Arr::get($data, 'space_id'), true);
426 337 if ($mentions) {
427 338 $data['message'] = $message;
@@ -466,10 +377,27 @@
466 377 ]);
467 378 }
468 379
469 380 $feed->fill($data);
470 - $feed->save();
471 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 +
472 400 $feed = Feed::find($feed->id); // just renewing the feed
473 401
474 402 if ($mentions) {
475 403 do_action('fluent_community/feed_mentioned', $feed, Arr::get($mentions, 'users'));
@@ -508,9 +436,9 @@
508 436 'last_fetched_timestamp' => current_time('timestamp')
509 437 ];
510 438 }
511 439
512 - if ($feed->status != 'published') {
440 + if (!in_array($feed->status, ['published', 'unlisted'])) {
513 441 do_action('fluent_community/feed/new_feed_' . $feed->status, $feed);
514 442 /* translators: %s: The status of the post */
515 443 $message = sprintf(__('Your post has been marked as %s', 'fluent-community'), $feed->status);
516 444 return apply_filters('fluent_community/feed/new_feed_response', [
@@ -527,11 +455,13 @@
527 455 } else {
528 456 do_action('fluent_community/profile_feed/created', $feed);
529 457 }
530 458
459 + $message = __('Your post has been published', 'fluent-community');
460 +
531 461 return apply_filters('fluent_community/feed/new_feed_response', [
532 462 'feed' => FeedsHelper::transformFeed($feed),
533 - 'message' => __('Your post has been published', 'fluent-community'),
463 + 'message' => $message,
534 464 'last_fetched_timestamp' => current_time('timestamp')
535 465 ], $feed, $request->all());
536 466 }
537 467
@@ -540,8 +470,9 @@
540 470 $requestData = $request->all();
541 471 $data = $this->sanitizeAndValidateData($requestData);
542 472 $user = $this->getUser(true);
543 473 $existingFeed = Feed::findOrFail($feedId);
474 + /** @var Feed $existingFeed */
544 475
545 476 $editableStatuses = ['published', 'unlisted', 'scheduled', 'pending'];
546 477
547 478 if (!in_array($existingFeed->status, $editableStatuses)) {
@@ -551,8 +482,12 @@
551 482 }
552 483
553 484 $user->canEditFeed($existingFeed, true);
554 485
486 + // Must resolve before processFeedMetaData() reads it.
487 + $isModerator = $user->hasPermissionOrInCurrentSpace('community_moderator', $existingFeed->space);
488 + $requestData['is_admin'] = $isModerator;
489 +
555 490 if ($surveyOptionError = FeedsHelper::getSurveyOptionsUpdateError(
556 491 Arr::get($existingFeed->meta, 'survey_config.options', []),
557 492 Arr::get($requestData, 'survey', [])
558 493 )) {
@@ -560,11 +495,12 @@
560 495 'message' => $surveyOptionError
561 496 ]);
562 497 }
563 498
564 - if ($status = Arr::get($requestData, 'status')) {
565 - if (in_array($status, $editableStatuses)) {
566 - $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);
567 503 }
568 504 }
569 505
570 506 $message = $data['message'];
@@ -588,10 +524,8 @@
588 524 if (isset($existingFeed->meta['comments_disabled'])) {
589 525 $data['meta']['comments_disabled'] = $existingFeed->meta['comments_disabled'];
590 526 }
591 527
592 - $requestData['is_admin'] = $user->hasPermissionOrInCurrentSpace('community_moderator', $existingFeed->space);
593 -
594 528 if (Arr::get($requestData, 'send_announcement_email') == 'yes' && $requestData['is_admin']) {
595 529 $data['meta']['send_announcement_email'] = 'yes';
596 530 } else if (Arr::get($existingFeed->meta, 'send_announcement_email')) {
597 531 $data['meta']['send_announcement_email'] = Arr::get($existingFeed->meta, 'send_announcement_email');
@@ -634,8 +568,10 @@
634 568 'time' => current_time('mysql')
635 569 ];
636 570 }
637 571
572 + $movingToProfile = false;
573 +
638 574 if ($newSpaceId = $request->get('new_space_id')) {
639 575 if (!Helper::isUserInSpace($existingFeed->user_id, $newSpaceId)) {
640 576 return $this->sendError([
641 577 'message' => __('The author is not a member of the selected space', 'fluent-community')
@@ -662,8 +598,9 @@
662 598 ]);
663 599 }
664 600
665 601 $data['space_id'] = null;
602 + $movingToProfile = true;
666 603
667 604 \FluentCommunity\App\Models\Activity::where('feed_id', $existingFeed->id)
668 605 ->update(['space_id' => null]);
669 606 }
@@ -697,13 +634,20 @@
697 634 foreach ($mediaItems as $mediaItem) {
698 635 $mediaItemIds[] = $mediaItem->id;
699 636 }
700 637
701 - Media::where('object_source', 'feed')
702 - ->where('feed_id', $existingFeed->id)
703 - ->whereNotIn('id', $mediaItemIds)
704 - ->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);
705 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 +
706 650 if ($mediaItems) {
707 651 $this->saveMediaItems($existingFeed, $mediaItems);
708 652 }
709 653
@@ -722,8 +666,11 @@
722 666 if ($space && Arr::get($space->settings, 'topic_required') != 'yes') {
723 667 $existingFeed->terms()->where('taxonomy_name', 'post_topic')->detach();
724 668 }
725 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();
726 673 }
727 674
728 675 if ($dirty) {
729 676 do_action('fluent_community/feed/updated', $existingFeed, $dirty);
@@ -765,14 +712,25 @@
765 712 $data = Arr::only($allData, $validKeys);
766 713
767 714 $data = array_map('intval', $data);
768 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 +
769 726 if (isset($data['is_sticky'])) {
770 727 $data['is_sticky'] = $data['is_sticky'] ? 1 : 0;
771 728 if ($data['is_sticky'] && $feed->space_id) {
772 - // 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.
773 730 Feed::where('space_id', $feed->space_id)
774 731 ->where('is_sticky', 1)
732 + ->toBase()
775 733 ->update(['is_sticky' => 0]);
776 734 }
777 735 }
778 736
@@ -785,8 +743,13 @@
785 743 if ($data) {
786 744 $feed->fill($data);
787 745 $dirty = $feed->getDirty();
788 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 +
789 752 $feed->save();
790 753 do_action('fluent_community/feed/updated', $feed, $dirty);
791 754 }
792 755 }
@@ -993,10 +956,11 @@
993 956 $allowedFileSize = $maxFileSize * 1024 * 1024;
994 957 }
995 958
996 959 $files = $this->validate($this->request->files(), [
997 - 'file' => 'mimetypes:' . $allowedTypes . '|max:' . $allowedFileSize,
960 + 'file' => 'required|mimetypes:' . $allowedTypes . '|max:' . $allowedFileSize,
998 961 ], [
962 + 'file.required' => __('No upload file was received. Please try again.', 'fluent-community'),
999 963 'file.mimetypes' => __('The file must be an image type.', 'fluent-community'),
1000 964 /* translators: %$1s is replaced by the maximum allowed file size, %2$s is replaced by the file size unit (e.g. MB) */
1001 965 'file.max' => sprintf(__('The file size must be less than %1$s%2$s.', 'fluent-community'), $maxFileSize, $maxFileUnit)
1002 966 ]);
@@ -1008,14 +972,27 @@
1008 972 'message' => __('HEIC image format is not supported on this system.', 'fluent-community')
1009 973 ]);
1010 974 }
1011 975
1012 - add_filter('wp_handle_upload', [$this, 'fixImageOrientation']);
976 + add_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']);
1013 977 $uploadedFiles = FileSystem::put($files);
1014 - remove_filter('wp_handle_upload', [$this, 'fixImageOrientation']);
978 + remove_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']);
1015 979
1016 - $file = $uploadedFiles[0];
980 + $file = Arr::get($uploadedFiles, 0);
1017 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 +
1018 995 $upload_dir = wp_upload_dir();
1019 996
1020 997 $originalUrl = $file['url'];
1021 998 $orginalPath = $upload_dir['basedir'] . '/fluent-community/' . $file['file'];
@@ -1158,81 +1135,8 @@
1158 1135 ]
1159 1136 ];
1160 1137 }
1161 1138
1162 - public function fixImageOrientation($file)
1163 - {
1164 - // Only process JPEG images (since they typically have EXIF data)
1165 - $image_types = array('image/jpeg', 'image/jpg');
1166 - if (!in_array($file['type'], $image_types)) {
1167 - return $file;
1168 - }
1169 -
1170 - // Check if the EXIF extension is available
1171 - if (!function_exists('exif_read_data')) {
1172 - return $file;
1173 - }
1174 -
1175 - // Read EXIF data from the uploaded image
1176 - $exif = @exif_read_data($file['file']);
1177 -
1178 - if (!$exif || !isset($exif['Orientation'])) {
1179 - return $file;
1180 - }
1181 -
1182 - $orientation = $exif['Orientation'];
1183 -
1184 - // Load the image based on the available library (Imagick or GD)
1185 - if (extension_loaded('imagick') && class_exists('Imagick')) {
1186 - // Use Imagick if available
1187 - try {
1188 - $image = new \Imagick($file['file']);
1189 - switch ($orientation) {
1190 - case 3: // 180°
1191 - $image->rotateImage(new \ImagickPixel(), 180);
1192 - break;
1193 - case 6: // 90° clockwise
1194 - $image->rotateImage(new \ImagickPixel(), 90);
1195 - break;
1196 - case 8: // 90° counter-clockwise
1197 - $image->rotateImage(new \ImagickPixel(), -90);
1198 - break;
1199 - }
1200 - // Strip EXIF data to prevent further issues
1201 - $image->stripImage();
1202 - // Save the rotated image
1203 - $image->writeImage($file['file']);
1204 - $image->destroy();
1205 - } catch (\Exception $e) {
1206 -
1207 - }
1208 - } elseif (function_exists('imagecreatefromjpeg')) {
1209 - // Use GD if Imagick is not available
1210 - $image = @imagecreatefromjpeg($file['file']);
1211 - if ($image === false) {
1212 - return $file;
1213 - }
1214 -
1215 - switch ($orientation) {
1216 - case 3: // 180°
1217 - $image = imagerotate($image, 180, 0);
1218 - break;
1219 - case 6: // 90° clockwise
1220 - $image = imagerotate($image, -90, 0);
1221 - break;
1222 - case 8: // 90° counter-clockwise
1223 - $image = imagerotate($image, 90, 0);
1224 - break;
1225 - }
1226 -
1227 - // Save the rotated image
1228 - imagejpeg($image, $file['file'], 100);
1229 - imagedestroy($image);
1230 - }
1231 -
1232 - return $file;
1233 - }
1234 -
1235 1139 public function getTicker(Request $request)
1236 1140 {
1237 1141 $start = microtime(true);
1238 1142
@@ -1269,38 +1173,9 @@
1269 1173 $currentUserModel = Helper::getCurrentUser();
1270 1174 $updatedFeeds = Feed::where('updated_at', '>', $since)
1271 1175 ->where('status', 'published')
1272 1176 ->byUserAccess($userId)
1273 - ->with([
1274 - 'xprofile' => function ($q) {
1275 - $q->select(ProfileHelper::getXProfilePublicFields());
1276 - },
1277 - 'comments' => function ($q) use ($currentUserModel) {
1278 - $q->byContentModerationAccessStatus($currentUserModel, null)
1279 - ->with(['xprofile' => function ($q) {
1280 - $q->select(ProfileHelper::getXProfilePublicFields());
1281 - }])
1282 - ->whereHas('xprofile', function ($q) {
1283 - $q->where('status', 'active');
1284 - });
1285 - },
1286 - 'space' => function ($q) {
1287 - $q->select(['id', 'title', 'slug', 'type']);
1288 - },
1289 - 'reactions' => function ($q) {
1290 - $q->with([
1291 - 'xprofile' => function ($query) {
1292 - $query->select(['user_id', 'avatar', 'display_name']);
1293 - }
1294 - ])
1295 - ->where('type', 'like')
1296 - ->limit(3);
1297 - },
1298 - 'terms' => function ($q) {
1299 - $q->select(['title', 'slug'])
1300 - ->where('taxonomy_name', 'post_topic');
1301 - }
1302 - ])
1177 + ->with(Feed::withPublicRelations($currentUserModel, null))
1303 1178 ->orderBy('updated_at', 'desc')
1304 1179 ->limit(20) // Reduced limit since we're sending full data
1305 1180 ->get();
1306 1181
@@ -1331,8 +1206,10 @@
1331 1206
1332 1207 // Get notification count
1333 1208 $notificationCount = NotificationSubscriber::unread()->where('user_id', $userId)->count();
1334 1209
1210 + $newNotifications = $this->getToastNotifications($userId, $since, $notificationCount);
1211 +
1335 1212 $response = [
1336 1213 'timestamp' => current_time('mysql'),
1337 1214 'has_changes' => $hasChanges,
1338 1215 'feeds' => $feedUpdates,
@@ -1337,9 +1214,10 @@
1337 1214 'has_changes' => $hasChanges,
1338 1215 'feeds' => $feedUpdates,
1339 1216 'notifications' => [
1340 1217 'unread_count' => $notificationCount,
1341 - 'new_count' => 0 // Could track new since last check
1218 + 'new_count' => count($newNotifications),
1219 + 'new_items' => $newNotifications
1342 1220 ],
1343 1221 'spaces' => [], // For future use
1344 1222 'execution_time' => microtime(true) - $start
1345 1223 ];
@@ -1346,8 +1224,135 @@
1346 1224
1347 1225 return apply_filters('fluent_community/feed_ticker', $response, $request->all());
1348 1226 }
1349 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 +
1350 1355 public function batchFetch(Request $request)
1351 1356 {
1352 1357 $feedIds = $request->get('feed_ids', []);
1353 1358
@@ -1370,39 +1375,9 @@
1370 1375
1371 1376 $currentUserModel = $this->getUser();
1372 1377
1373 1378 $feeds = $query
1374 - ->with([
1375 - 'xprofile' => function ($q) {
1376 - $q->select(ProfileHelper::getXProfilePublicFields());
1377 - },
1378 - 'comments' => function ($q) use ($currentUserModel) {
1379 - $q->byContentModerationAccessStatus($currentUserModel)
1380 - ->with(['xprofile' => function ($q) {
1381 - $q->select(ProfileHelper::getXProfilePublicFields());
1382 - }])
1383 - ->whereHas('xprofile', function ($q) {
1384 - $q->where('status', 'active');
1385 - });
1386 - },
1387 - 'space' => function ($q) {
1388 - $q->select(['id', 'title', 'slug', 'type']);
1389 - },
1390 - 'reactions' => function ($q) {
1391 - $q->with([
1392 - 'xprofile' => function ($query) {
1393 - $query->select(['user_id', 'avatar', 'display_name']);
1394 - }
1395 - ])
1396 - ->where('type', 'like')
1397 - ->limit(3);
1398 - },
1399 - 'terms' => function ($q) {
1400 - $q->select(['title', 'slug'])
1401 - ->where('taxonomy_name', 'post_topic');
1402 - }
1403 - ]
1404 - )
1379 + ->with(Feed::withPublicRelations($currentUserModel))
1405 1380 ->get();
1406 1381
1407 1382 $feeds = FeedsHelper::transformFeedsCollection($feeds);
1408 1383
@@ -1441,11 +1416,9 @@
1441 1416
1442 1417 // Build query based on context
1443 1418 $query = Feed::query();
1444 1419
1445 - if ($context === 'global') {
1446 - $query->where('type', 'feed');
1447 - } elseif (strpos($context, 'space-') === 0) {
1420 + if (strpos($context, 'space-') === 0) {
1448 1421 $spaceSlug = str_replace('space-', '', $context);
1449 1422 $space = Space::where('slug', $spaceSlug)->first();
1450 1423 if ($space) {
1451 1424 $query->where('space_id', $space->id);
@@ -1505,10 +1478,14 @@
1505 1478 }
1506 1479
1507 1480 public function getOembed(Request $request)
1508 1481 {
1509 - $url = $request->get('url');
1510 - // 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 +
1511 1488 $metaData = RemoteUrlParser::parse($url);
1512 1489
1513 1490 if ($metaData && !is_wp_error($metaData)) {
1514 1491 $data = [