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.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 +277 -291 2.5.02.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,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,11 +482,25 @@
551 482 }
552 483
553 484 $user->canEditFeed($existingFeed, true);
554 485
555 - if ($status = Arr::get($requestData, 'status')) {
556 - if (in_array($status, $editableStatuses)) {
557 - $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);
558 503 }
559 504 }
560 505
561 506 $message = $data['message'];
@@ -579,10 +524,8 @@
579 524 if (isset($existingFeed->meta['comments_disabled'])) {
580 525 $data['meta']['comments_disabled'] = $existingFeed->meta['comments_disabled'];
581 526 }
582 527
583 - $requestData['is_admin'] = $user->hasPermissionOrInCurrentSpace('community_moderator', $existingFeed->space);
584 -
585 528 if (Arr::get($requestData, 'send_announcement_email') == 'yes' && $requestData['is_admin']) {
586 529 $data['meta']['send_announcement_email'] = 'yes';
587 530 } else if (Arr::get($existingFeed->meta, 'send_announcement_email')) {
588 531 $data['meta']['send_announcement_email'] = Arr::get($existingFeed->meta, 'send_announcement_email');
@@ -625,8 +568,10 @@
625 568 'time' => current_time('mysql')
626 569 ];
627 570 }
628 571
572 + $movingToProfile = false;
573 +
629 574 if ($newSpaceId = $request->get('new_space_id')) {
630 575 if (!Helper::isUserInSpace($existingFeed->user_id, $newSpaceId)) {
631 576 return $this->sendError([
632 577 'message' => __('The author is not a member of the selected space', 'fluent-community')
@@ -653,8 +598,9 @@
653 598 ]);
654 599 }
655 600
656 601 $data['space_id'] = null;
602 + $movingToProfile = true;
657 603
658 604 \FluentCommunity\App\Models\Activity::where('feed_id', $existingFeed->id)
659 605 ->update(['space_id' => null]);
660 606 }
@@ -688,13 +634,20 @@
688 634 foreach ($mediaItems as $mediaItem) {
689 635 $mediaItemIds[] = $mediaItem->id;
690 636 }
691 637
692 - Media::where('object_source', 'feed')
693 - ->where('feed_id', $existingFeed->id)
694 - ->whereNotIn('id', $mediaItemIds)
695 - ->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);
696 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 +
697 650 if ($mediaItems) {
698 651 $this->saveMediaItems($existingFeed, $mediaItems);
699 652 }
700 653
@@ -713,8 +666,11 @@
713 666 if ($space && Arr::get($space->settings, 'topic_required') != 'yes') {
714 667 $existingFeed->terms()->where('taxonomy_name', 'post_topic')->detach();
715 668 }
716 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();
717 673 }
718 674
719 675 if ($dirty) {
720 676 do_action('fluent_community/feed/updated', $existingFeed, $dirty);
@@ -756,14 +712,25 @@
756 712 $data = Arr::only($allData, $validKeys);
757 713
758 714 $data = array_map('intval', $data);
759 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 +
760 726 if (isset($data['is_sticky'])) {
761 727 $data['is_sticky'] = $data['is_sticky'] ? 1 : 0;
762 728 if ($data['is_sticky'] && $feed->space_id) {
763 - // 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.
764 730 Feed::where('space_id', $feed->space_id)
765 731 ->where('is_sticky', 1)
732 + ->toBase()
766 733 ->update(['is_sticky' => 0]);
767 734 }
768 735 }
769 736
@@ -776,8 +743,13 @@
776 743 if ($data) {
777 744 $feed->fill($data);
778 745 $dirty = $feed->getDirty();
779 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 +
780 752 $feed->save();
781 753 do_action('fluent_community/feed/updated', $feed, $dirty);
782 754 }
783 755 }
@@ -912,9 +884,9 @@
912 884
913 885 do_action('fluent_community/feed/deleted', $feed_id);
914 886
915 887 return [
916 - 'message' => 'Feed has been deleted successfully'
888 + 'message' => __('Feed has been deleted successfully', 'fluent-community')
917 889 ];
918 890 }
919 891
920 892 public function deleteMediaPreview(Request $request, $feed_id)
@@ -984,10 +956,11 @@
984 956 $allowedFileSize = $maxFileSize * 1024 * 1024;
985 957 }
986 958
987 959 $files = $this->validate($this->request->files(), [
988 - 'file' => 'mimetypes:' . $allowedTypes . '|max:' . $allowedFileSize,
960 + 'file' => 'required|mimetypes:' . $allowedTypes . '|max:' . $allowedFileSize,
989 961 ], [
962 + 'file.required' => __('No upload file was received. Please try again.', 'fluent-community'),
990 963 'file.mimetypes' => __('The file must be an image type.', 'fluent-community'),
991 964 /* translators: %$1s is replaced by the maximum allowed file size, %2$s is replaced by the file size unit (e.g. MB) */
992 965 'file.max' => sprintf(__('The file size must be less than %1$s%2$s.', 'fluent-community'), $maxFileSize, $maxFileUnit)
993 966 ]);
@@ -999,14 +972,27 @@
999 972 'message' => __('HEIC image format is not supported on this system.', 'fluent-community')
1000 973 ]);
1001 974 }
1002 975
1003 - add_filter('wp_handle_upload', [$this, 'fixImageOrientation']);
976 + add_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']);
1004 977 $uploadedFiles = FileSystem::put($files);
1005 - remove_filter('wp_handle_upload', [$this, 'fixImageOrientation']);
978 + remove_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']);
1006 979
1007 - $file = $uploadedFiles[0];
980 + $file = Arr::get($uploadedFiles, 0);
1008 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 +
1009 995 $upload_dir = wp_upload_dir();
1010 996
1011 997 $originalUrl = $file['url'];
1012 998 $orginalPath = $upload_dir['basedir'] . '/fluent-community/' . $file['file'];
@@ -1149,81 +1135,8 @@
1149 1135 ]
1150 1136 ];
1151 1137 }
1152 1138
1153 - public function fixImageOrientation($file)
1154 - {
1155 - // Only process JPEG images (since they typically have EXIF data)
1156 - $image_types = array('image/jpeg', 'image/jpg');
1157 - if (!in_array($file['type'], $image_types)) {
1158 - return $file;
1159 - }
1160 -
1161 - // Check if the EXIF extension is available
1162 - if (!function_exists('exif_read_data')) {
1163 - return $file;
1164 - }
1165 -
1166 - // Read EXIF data from the uploaded image
1167 - $exif = @exif_read_data($file['file']);
1168 -
1169 - if (!$exif || !isset($exif['Orientation'])) {
1170 - return $file;
1171 - }
1172 -
1173 - $orientation = $exif['Orientation'];
1174 -
1175 - // Load the image based on the available library (Imagick or GD)
1176 - if (extension_loaded('imagick') && class_exists('Imagick')) {
1177 - // Use Imagick if available
1178 - try {
1179 - $image = new \Imagick($file['file']);
1180 - switch ($orientation) {
1181 - case 3: // 180°
1182 - $image->rotateImage(new \ImagickPixel(), 180);
1183 - break;
1184 - case 6: // 90° clockwise
1185 - $image->rotateImage(new \ImagickPixel(), 90);
1186 - break;
1187 - case 8: // 90° counter-clockwise
1188 - $image->rotateImage(new \ImagickPixel(), -90);
1189 - break;
1190 - }
1191 - // Strip EXIF data to prevent further issues
1192 - $image->stripImage();
1193 - // Save the rotated image
1194 - $image->writeImage($file['file']);
1195 - $image->destroy();
1196 - } catch (\Exception $e) {
1197 -
1198 - }
1199 - } elseif (function_exists('imagecreatefromjpeg')) {
1200 - // Use GD if Imagick is not available
1201 - $image = @imagecreatefromjpeg($file['file']);
1202 - if ($image === false) {
1203 - return $file;
1204 - }
1205 -
1206 - switch ($orientation) {
1207 - case 3: // 180°
1208 - $image = imagerotate($image, 180, 0);
1209 - break;
1210 - case 6: // 90° clockwise
1211 - $image = imagerotate($image, -90, 0);
1212 - break;
1213 - case 8: // 90° counter-clockwise
1214 - $image = imagerotate($image, 90, 0);
1215 - break;
1216 - }
1217 -
1218 - // Save the rotated image
1219 - imagejpeg($image, $file['file'], 100);
1220 - imagedestroy($image);
1221 - }
1222 -
1223 - return $file;
1224 - }
1225 -
1226 1139 public function getTicker(Request $request)
1227 1140 {
1228 1141 $start = microtime(true);
1229 1142
@@ -1231,9 +1144,9 @@
1231 1144 if (!$userId) {
1232 1145 return [
1233 1146 'timestamp' => current_time('mysql', true),
1234 1147 'has_changes' => false,
1235 - 'error' => 'User not authenticated',
1148 + 'error' => __('User not authenticated', 'fluent-community'),
1236 1149 'feeds' => []
1237 1150 ];
1238 1151 }
1239 1152
@@ -1260,38 +1173,9 @@
1260 1173 $currentUserModel = Helper::getCurrentUser();
1261 1174 $updatedFeeds = Feed::where('updated_at', '>', $since)
1262 1175 ->where('status', 'published')
1263 1176 ->byUserAccess($userId)
1264 - ->with([
1265 - 'xprofile' => function ($q) {
1266 - $q->select(ProfileHelper::getXProfilePublicFields());
1267 - },
1268 - 'comments' => function ($q) use ($currentUserModel) {
1269 - $q->byContentModerationAccessStatus($currentUserModel, null)
1270 - ->with(['xprofile' => function ($q) {
1271 - $q->select(ProfileHelper::getXProfilePublicFields());
1272 - }])
1273 - ->whereHas('xprofile', function ($q) {
1274 - $q->where('status', 'active');
1275 - });
1276 - },
1277 - 'space' => function ($q) {
1278 - $q->select(['id', 'title', 'slug', 'type']);
1279 - },
1280 - 'reactions' => function ($q) {
1281 - $q->with([
1282 - 'xprofile' => function ($query) {
1283 - $query->select(['user_id', 'avatar', 'display_name']);
1284 - }
1285 - ])
1286 - ->where('type', 'like')
1287 - ->limit(3);
1288 - },
1289 - 'terms' => function ($q) {
1290 - $q->select(['title', 'slug'])
1291 - ->where('taxonomy_name', 'post_topic');
1292 - }
1293 - ])
1177 + ->with(Feed::withPublicRelations($currentUserModel, null))
1294 1178 ->orderBy('updated_at', 'desc')
1295 1179 ->limit(20) // Reduced limit since we're sending full data
1296 1180 ->get();
1297 1181
@@ -1322,8 +1206,10 @@
1322 1206
1323 1207 // Get notification count
1324 1208 $notificationCount = NotificationSubscriber::unread()->where('user_id', $userId)->count();
1325 1209
1210 + $newNotifications = $this->getToastNotifications($userId, $since, $notificationCount);
1211 +
1326 1212 $response = [
1327 1213 'timestamp' => current_time('mysql'),
1328 1214 'has_changes' => $hasChanges,
1329 1215 'feeds' => $feedUpdates,
@@ -1328,9 +1214,10 @@
1328 1214 'has_changes' => $hasChanges,
1329 1215 'feeds' => $feedUpdates,
1330 1216 'notifications' => [
1331 1217 'unread_count' => $notificationCount,
1332 - 'new_count' => 0 // Could track new since last check
1218 + 'new_count' => count($newNotifications),
1219 + 'new_items' => $newNotifications
1333 1220 ],
1334 1221 'spaces' => [], // For future use
1335 1222 'execution_time' => microtime(true) - $start
1336 1223 ];
@@ -1337,8 +1224,135 @@
1337 1224
1338 1225 return apply_filters('fluent_community/feed_ticker', $response, $request->all());
1339 1226 }
1340 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 +
1341 1355 public function batchFetch(Request $request)
1342 1356 {
1343 1357 $feedIds = $request->get('feed_ids', []);
1344 1358
@@ -1344,9 +1358,9 @@
1344 1358
1345 1359 if (empty($feedIds) || !is_array($feedIds)) {
1346 1360 return [
1347 1361 'feeds' => [],
1348 - 'error' => 'No feed IDs provided'
1362 + 'error' => __('No feed IDs provided', 'fluent-community')
1349 1363 ];
1350 1364 }
1351 1365
1352 1366 $userId = get_current_user_id();
@@ -1361,39 +1375,9 @@
1361 1375
1362 1376 $currentUserModel = $this->getUser();
1363 1377
1364 1378 $feeds = $query
1365 - ->with([
1366 - 'xprofile' => function ($q) {
1367 - $q->select(ProfileHelper::getXProfilePublicFields());
1368 - },
1369 - 'comments' => function ($q) use ($currentUserModel) {
1370 - $q->byContentModerationAccessStatus($currentUserModel)
1371 - ->with(['xprofile' => function ($q) {
1372 - $q->select(ProfileHelper::getXProfilePublicFields());
1373 - }])
1374 - ->whereHas('xprofile', function ($q) {
1375 - $q->where('status', 'active');
1376 - });
1377 - },
1378 - 'space' => function ($q) {
1379 - $q->select(['id', 'title', 'slug', 'type']);
1380 - },
1381 - 'reactions' => function ($q) {
1382 - $q->with([
1383 - 'xprofile' => function ($query) {
1384 - $query->select(['user_id', 'avatar', 'display_name']);
1385 - }
1386 - ])
1387 - ->where('type', 'like')
1388 - ->limit(3);
1389 - },
1390 - 'terms' => function ($q) {
1391 - $q->select(['title', 'slug'])
1392 - ->where('taxonomy_name', 'post_topic');
1393 - }
1394 - ]
1395 - )
1379 + ->with(Feed::withPublicRelations($currentUserModel))
1396 1380 ->get();
1397 1381
1398 1382 $feeds = FeedsHelper::transformFeedsCollection($feeds);
1399 1383
@@ -1413,9 +1397,9 @@
1413 1397 return [
1414 1398 'updates' => [],
1415 1399 'timestamp' => current_time('mysql', true),
1416 1400 'has_changes' => false,
1417 - 'error' => 'User not authenticated'
1401 + 'error' => __('User not authenticated', 'fluent-community')
1418 1402 ];
1419 1403 }
1420 1404
1421 1405 // Parse since timestamp
@@ -1425,9 +1409,9 @@
1425 1409 return [
1426 1410 'updates' => [],
1427 1411 'timestamp' => current_time('mysql', true),
1428 1412 'has_changes' => false,
1429 - 'error' => 'Invalid timestamp format'
1413 + 'error' => __('Invalid timestamp format', 'fluent-community')
1430 1414 ];
1431 1415 }
1432 1416
1433 1417 // Build query based on context
@@ -1432,17 +1416,15 @@
1432 1416
1433 1417 // Build query based on context
1434 1418 $query = Feed::query();
1435 1419
1436 - if ($context === 'global') {
1437 - $query->where('type', 'feed');
1438 - } elseif (str_starts_with($context, 'space-')) {
1420 + if (strpos($context, 'space-') === 0) {
1439 1421 $spaceSlug = str_replace('space-', '', $context);
1440 1422 $space = Space::where('slug', $spaceSlug)->first();
1441 1423 if ($space) {
1442 1424 $query->where('space_id', $space->id);
1443 1425 }
1444 - } elseif (str_starts_with($context, 'user-')) {
1426 + } elseif (strpos($context, 'user-') === 0) {
1445 1427 $targetUserId = str_replace('user-', '', $context);
1446 1428 $query->where('user_id', $targetUserId);
1447 1429 }
1448 1430
@@ -1496,10 +1478,14 @@
1496 1478 }
1497 1479
1498 1480 public function getOembed(Request $request)
1499 1481 {
1500 - $url = $request->get('url');
1501 - // 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 +
1502 1488 $metaData = RemoteUrlParser::parse($url);
1503 1489
1504 1490 if ($metaData && !is_wp_error($metaData)) {
1505 1491 $data = [