| @@ -4,15 +4,45 @@ | ||
| 4 | 4 | |
| 5 | 5 | use FluentCommunity\App\Functions\Utility; |
| 6 | 6 | use FluentCommunity\App\Services\FeedsHelper; |
| 7 | 7 | use FluentCommunity\App\Services\Helper; |
| 8 | +use FluentCommunity\App\Services\ProfileHelper; | |
| 8 | 9 | use FluentCommunityPro\App\Models\Follow; |
| 9 | 10 | |
| 11 | +/** | |
| 12 | + * @property int $id | |
| 13 | + * @property int|null $user_id | |
| 14 | + * @property string|null $title | |
| 15 | + * @property string|null $slug | |
| 16 | + * @property string|null $message | |
| 17 | + * @property string|null $message_rendered | |
| 18 | + * @property string|null $type | |
| 19 | + * @property string|null $content_type | |
| 20 | + * @property int|null $space_id | |
| 21 | + * @property string|null $privacy | |
| 22 | + * @property string|null $status | |
| 23 | + * @property int|null $priority | |
| 24 | + * @property string|null $featured_image | |
| 25 | + * @property int|null $is_sticky | |
| 26 | + * @property string|null $expired_at | |
| 27 | + * @property string|null $scheduled_at | |
| 28 | + * @property int|null $comments_count | |
| 29 | + * @property int|null $reactions_count | |
| 30 | + * @property array $meta | |
| 31 | + * @property string|null $created_at | |
| 32 | + * @property string|null $updated_at | |
| 33 | + * @property-read User|null $user | |
| 34 | + * @property-read BaseSpace|null $space | |
| 35 | + * @property-read \FluentCommunity\Framework\Database\Orm\Collection $comments | |
| 36 | + * @property bool|null $has_user_react | |
| 37 | + * @property bool|null $bookmarked | |
| 38 | + * @property string|null $default_comment_sort_by | |
| 39 | + */ | |
| 10 | 40 | class Feed extends Model |
| 11 | 41 | { |
| 12 | 42 | protected $table = 'fcom_posts'; |
| 13 | 43 | |
| 14 | - protected $guarded = ['id']; | |
| 44 | + protected $guarded = [ 'id' ]; | |
| 15 | 45 | |
| 16 | 46 | protected $casts = [ |
| 17 | 47 | 'comments_count' => 'int', |
| 18 | 48 | 'reactions_count' => 'int', |
| @@ -39,14 +69,14 @@ | ||
| 39 | 69 | 'comments_count', |
| 40 | 70 | 'reactions_count', |
| 41 | 71 | 'meta', |
| 42 | 72 | 'created_at', |
| 43 | - 'updated_at' | |
| 73 | + 'updated_at', | |
| 44 | 74 | ]; |
| 45 | 75 | |
| 46 | 76 | protected $searchable = [ |
| 47 | 77 | 'message', |
| 48 | - 'title' | |
| 78 | + 'title', | |
| 49 | 79 | ]; |
| 50 | 80 | |
| 51 | 81 | public static $publicColumns = [ |
| 52 | 82 | 'id', |
| @@ -66,17 +96,28 @@ | ||
| 66 | 96 | 'status', |
| 67 | 97 | 'is_sticky', |
| 68 | 98 | 'scheduled_at', |
| 69 | 99 | 'comments_count', |
| 70 | - 'reactions_count' | |
| 100 | + 'reactions_count', | |
| 71 | 101 | ]; |
| 72 | 102 | |
| 73 | 103 | protected $appends = [ |
| 74 | - 'permalink' | |
| 104 | + 'permalink', | |
| 75 | 105 | ]; |
| 76 | 106 | |
| 77 | 107 | public static $scopeType = 'text'; |
| 78 | 108 | |
| 109 | + /** | |
| 110 | + * The types a /post/{slug} URL can serve. | |
| 111 | + * | |
| 112 | + * fcom_posts is shared: it also holds course_lesson, course_section and | |
| 113 | + * space_page rows, and each of those owns a different route. Any lookup that | |
| 114 | + * drops the type global scope to reach image and article posts must narrow to | |
| 115 | + * this list, or a feed URL can answer with a lesson or a page that happens to | |
| 116 | + * share the slug. Add a new feed shape here when one is introduced. | |
| 117 | + */ | |
| 118 | + public static $feedViewTypes = ['text', 'image', 'article']; | |
| 119 | + | |
| 79 | 120 | public static function boot() |
| 80 | 121 | { |
| 81 | 122 | parent::boot(); |
| 82 | 123 | |
| @@ -104,11 +145,15 @@ | ||
| 104 | 145 | |
| 105 | 146 | static::deleting(function ($feed) { |
| 106 | 147 | Media::where('feed_id', $feed->id) |
| 107 | 148 | ->update([ |
| 108 | - 'is_active' => 0 | |
| 149 | + 'is_active' => 0, | |
| 109 | 150 | ]); |
| 110 | 151 | Reaction::where('object_id', $feed->id) |
| 152 | + ->where(function ($query) { | |
| 153 | + $query->where('object_type', 'feed') | |
| 154 | + ->orWhere('type', 'survey_vote'); | |
| 155 | + }) | |
| 111 | 156 | ->delete(); |
| 112 | 157 | }); |
| 113 | 158 | |
| 114 | 159 | } |
| @@ -121,9 +166,9 @@ | ||
| 121 | 166 | // get the first 40 char from the title |
| 122 | 167 | $title = mb_substr($title, 0, 40, 'UTF-8'); |
| 123 | 168 | } else { |
| 124 | 169 | // get the first 25 char from the message |
| 125 | - $title = mb_substr($newModel->message, 0, 40, 'UTF-8'); | |
| 170 | + $title = mb_substr((string) $newModel->message, 0, 40, 'UTF-8'); | |
| 126 | 171 | } |
| 127 | 172 | |
| 128 | 173 | $title = Helper::normalizeToAscii($title); |
| 129 | 174 | $title = remove_accents($title); |
| @@ -142,9 +187,9 @@ | ||
| 142 | 187 | if ($count == 5) { |
| 143 | 188 | $count = time(); |
| 144 | 189 | } |
| 145 | 190 | $slug = $title . '-' . $count; |
| 146 | - $count++; | |
| 191 | + ++$count; | |
| 147 | 192 | } |
| 148 | 193 | |
| 149 | 194 | if (strlen($slug) <= 4) { |
| 150 | 195 | $slug = $slug . '-' . time(); |
| @@ -155,9 +200,9 @@ | ||
| 155 | 200 | |
| 156 | 201 | protected static function getDefaultMeta() |
| 157 | 202 | { |
| 158 | 203 | return [ |
| 159 | - 'preview_data' => null | |
| 204 | + 'preview_data' => null, | |
| 160 | 205 | ]; |
| 161 | 206 | } |
| 162 | 207 | |
| 163 | 208 | public function setMetaAttribute($value) |
| @@ -172,9 +217,9 @@ | ||
| 172 | 217 | if (!$meta) { |
| 173 | 218 | $meta = []; |
| 174 | 219 | } |
| 175 | 220 | |
| 176 | - return $meta; | |
| 221 | + return Helper::sanitizeStoredMediaPreview($meta); | |
| 177 | 222 | } |
| 178 | 223 | |
| 179 | 224 | public function scopeSearchBy($query, $search, $in = []) |
| 180 | 225 | { |
| @@ -182,9 +227,9 @@ | ||
| 182 | 227 | return $query; |
| 183 | 228 | } |
| 184 | 229 | |
| 185 | 230 | if (!$in || !is_array($in)) { |
| 186 | - $in = ['post_content']; | |
| 231 | + $in = [ 'post_content' ]; | |
| 187 | 232 | } |
| 188 | 233 | |
| 189 | 234 | $fields = $this->searchable; |
| 190 | 235 | $query->where(function ($query) use ($fields, $search, $in) { |
| @@ -203,9 +248,9 @@ | ||
| 203 | 248 | $query->orWhereHas('comments', function ($q) use ($search) { |
| 204 | 249 | return $q->where('message', 'LIKE', "%$search%"); |
| 205 | 250 | }); |
| 206 | 251 | } |
| 207 | - } else if ($in && in_array('post_comments', $in)) { | |
| 252 | + } elseif ($in && in_array('post_comments', $in)) { | |
| 208 | 253 | $query->whereHas('comments', function ($q) use ($search) { |
| 209 | 254 | return $q->where('message', 'LIKE', "%$search%"); |
| 210 | 255 | }); |
| 211 | 256 | } |
| @@ -216,17 +261,19 @@ | ||
| 216 | 261 | |
| 217 | 262 | public function scopeByUserAccess($query, $userId) |
| 218 | 263 | { |
| 219 | 264 | if ($userId) { |
| 220 | - return $query->where('user_id', $userId)->orWhereNull('space_id') | |
| 221 | - ->orWhereHas('space', function ($q) use ($userId) { | |
| 222 | - $spaceIds = get_user_meta($userId, '_fcom_space_ids', true); | |
| 223 | - if ($spaceIds) { | |
| 224 | - $q->whereIn('id', $spaceIds); | |
| 225 | - return $q; | |
| 226 | - } | |
| 227 | - return $q->where('privacy', 'public'); | |
| 228 | - }); | |
| 265 | + return $query->where(function ($subQuery) use ($userId) { | |
| 266 | + $subQuery->where('user_id', $userId)->orWhereNull('space_id') | |
| 267 | + ->orWhereHas('space', function ($q) use ($userId) { | |
| 268 | + $spaceIds = get_user_meta($userId, '_fcom_space_ids', true); | |
| 269 | + if ($spaceIds) { | |
| 270 | + $q->whereIn('id', $spaceIds); | |
| 271 | + return $q; | |
| 272 | + } | |
| 273 | + return $q->where('privacy', 'public'); | |
| 274 | + }); | |
| 275 | + }); | |
| 229 | 276 | } |
| 230 | 277 | |
| 231 | 278 | return $query->where(function ($q) { |
| 232 | 279 | $q->whereNull('space_id') |
| @@ -245,16 +292,15 @@ | ||
| 245 | 292 | if ( |
| 246 | 293 | $user->hasCommunityModeratorAccess() || |
| 247 | 294 | ($space && $user->hasSpacePermission('edit_any_feed', $space)) |
| 248 | 295 | ) { |
| 249 | - return $query->whereIn('status', ['published', 'pending']); | |
| 296 | + return $query->whereIn('status', [ 'published', 'pending' ]); | |
| 250 | 297 | } |
| 251 | 298 | |
| 252 | - // This is a normal User. | |
| 253 | - return $query->where(function ($q) use ($user) { | |
| 254 | - $q->where('status', 'published') | |
| 255 | - ->orWhere(function ($q) use ($user) { | |
| 256 | - $q->where('status', 'pending') | |
| 299 | + return $query->where(function ($statusQuery) use ($user) { | |
| 300 | + $statusQuery->where('status', 'published') | |
| 301 | + ->orWhere(function ($subQuery) use ($user) { | |
| 302 | + $subQuery->where('status', 'pending') | |
| 257 | 303 | ->where('user_id', $user->ID); |
| 258 | 304 | }); |
| 259 | 305 | }); |
| 260 | 306 | } |
| @@ -413,8 +459,44 @@ | ||
| 413 | 459 | return $this->hasMany(Reaction::class, 'object_id', 'id') |
| 414 | 460 | ->where('object_type', 'feed'); |
| 415 | 461 | } |
| 416 | 462 | |
| 463 | + /** | |
| 464 | + * Eager-load closures for rendering a feed with its public relations | |
| 465 | + * (author, moderation-scoped comments, space, top reactions, topics). | |
| 466 | + */ | |
| 467 | + public static function withPublicRelations($currentUserModel, $space = null) | |
| 468 | + { | |
| 469 | + return [ | |
| 470 | + 'xprofile' => function ($q) { | |
| 471 | + $q->select(ProfileHelper::getXProfilePublicFields()); | |
| 472 | + }, | |
| 473 | + 'comments' => function ($q) use ($currentUserModel, $space) { | |
| 474 | + $q->byContentModerationAccessStatus($currentUserModel, $space) | |
| 475 | + ->with(['xprofile' => function ($q) { | |
| 476 | + $q->select(ProfileHelper::getXProfilePublicFields()); | |
| 477 | + }]) | |
| 478 | + ->whereHas('xprofile', function ($q) { | |
| 479 | + $q->where('status', 'active'); | |
| 480 | + }); | |
| 481 | + }, | |
| 482 | + 'space' => function ($q) { | |
| 483 | + $q->select(['id', 'title', 'slug', 'type', 'settings']); | |
| 484 | + }, | |
| 485 | + 'reactions' => function ($q) { | |
| 486 | + $q->with(['xprofile' => function ($query) { | |
| 487 | + $query->select(['user_id', 'avatar', 'display_name']); | |
| 488 | + }]) | |
| 489 | + ->where('type', 'like') | |
| 490 | + ->limit(3); | |
| 491 | + }, | |
| 492 | + 'terms' => function ($q) { | |
| 493 | + $q->select(['title', 'slug']) | |
| 494 | + ->where('taxonomy_name', 'post_topic'); | |
| 495 | + } | |
| 496 | + ]; | |
| 497 | + } | |
| 498 | + | |
| 417 | 499 | // New Relationship: Follow records where this post's user_id is the followed_id |
| 418 | 500 | public function follows() |
| 419 | 501 | { |
| 420 | 502 | return $this->hasMany(Follow::class, 'followed_id', 'user_id'); |
| @@ -436,9 +518,9 @@ | ||
| 436 | 518 | if (!$userId) { |
| 437 | 519 | return false; |
| 438 | 520 | } |
| 439 | 521 | |
| 440 | - return Reaction::select(['id']) | |
| 522 | + return Reaction::select([ 'id' ]) | |
| 441 | 523 | ->where('object_id', $this->id) |
| 442 | 524 | ->where('object_type', 'feed') |
| 443 | 525 | ->where('user_id', $userId) |
| 444 | 526 | ->where('type', $type) |
| @@ -549,9 +631,9 @@ | ||
| 549 | 631 | Meta::create([ |
| 550 | 632 | 'object_id' => $this->id, |
| 551 | 633 | 'object_type' => 'feed', |
| 552 | 634 | 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key |
| 553 | - 'value' => $value | |
| 635 | + 'value' => $value, | |
| 554 | 636 | ]); |
| 555 | 637 | } |
| 556 | 638 | |
| 557 | 639 | return true; |
| @@ -602,10 +684,10 @@ | ||
| 602 | 684 | return [ |
| 603 | 685 | 'name' => 'view_lesson', |
| 604 | 686 | 'params' => [ |
| 605 | 687 | 'course_slug' => $this->space ? $this->space->slug : 'uknown', |
| 606 | - 'lesson_slug' => $this->slug | |
| 607 | - ] | |
| 688 | + 'lesson_slug' => $this->slug, | |
| 689 | + ], | |
| 608 | 690 | ]; |
| 609 | 691 | } |
| 610 | 692 | |
| 611 | 693 | if ($this->space_id) { |
| @@ -612,17 +694,17 @@ | ||
| 612 | 694 | $route = [ |
| 613 | 695 | 'name' => 'space_feed', |
| 614 | 696 | 'params' => [ |
| 615 | 697 | 'space' => $this->space->slug, |
| 616 | - 'feed_slug' => $this->slug | |
| 617 | - ] | |
| 698 | + 'feed_slug' => $this->slug, | |
| 699 | + ], | |
| 618 | 700 | ]; |
| 619 | 701 | } else { |
| 620 | 702 | $route = [ |
| 621 | 703 | 'name' => 'single_feed', |
| 622 | 704 | 'params' => [ |
| 623 | - 'feed_slug' => $this->slug | |
| 624 | - ] | |
| 705 | + 'feed_slug' => $this->slug, | |
| 706 | + ], | |
| 625 | 707 | ]; |
| 626 | 708 | } |
| 627 | 709 | |
| 628 | 710 | return $route; |
| @@ -659,9 +741,9 @@ | ||
| 659 | 741 | |
| 660 | 742 | $feedHtml = $this->message_rendered; |
| 661 | 743 | $feedHtml .= FeedsHelper::getMediaHtml($this->meta, $postPermalink); |
| 662 | 744 | |
| 663 | - $buttonText = $buttonText ?: __('Join the conversation', 'fluent-community'); | |
| 745 | + $buttonText = $buttonText ? $buttonText : __('Join the conversation', 'fluent-community'); | |
| 664 | 746 | |
| 665 | 747 | $emailComposer->addBlock('post_boxed_content', $feedHtml, [ |
| 666 | 748 | 'user' => $this->user, |
| 667 | 749 | 'title' => $this->title, |
| @@ -666,13 +748,13 @@ | ||
| 666 | 748 | 'user' => $this->user, |
| 667 | 749 | 'title' => $this->title, |
| 668 | 750 | 'permalink' => $postPermalink, |
| 669 | 751 | 'space_name' => $this->space ? $this->space->title : __('Community', 'fluent-community'), |
| 670 | - 'is_single' => true | |
| 752 | + 'is_single' => true, | |
| 671 | 753 | ]); |
| 672 | 754 | |
| 673 | 755 | $emailComposer->addBlock('button', $buttonText, [ |
| 674 | - 'link' => $postPermalink | |
| 756 | + 'link' => $postPermalink, | |
| 675 | 757 | ]); |
| 676 | 758 | |
| 677 | 759 | $emailComposer->setDefaultLogo(); |
| 678 | 760 | |