| @@ -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 = [ |