| @@ -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,9 +145,9 @@ | ||
| 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) |
| 111 | 152 | ->where(function ($query) { |
| 112 | 153 | $query->where('object_type', 'feed') |
| @@ -125,9 +166,9 @@ | ||
| 125 | 166 | // get the first 40 char from the title |
| 126 | 167 | $title = mb_substr($title, 0, 40, 'UTF-8'); |
| 127 | 168 | } else { |
| 128 | 169 | // get the first 25 char from the message |
| 129 | - $title = mb_substr($newModel->message, 0, 40, 'UTF-8'); | |
| 170 | + $title = mb_substr((string) $newModel->message, 0, 40, 'UTF-8'); | |
| 130 | 171 | } |
| 131 | 172 | |
| 132 | 173 | $title = Helper::normalizeToAscii($title); |
| 133 | 174 | $title = remove_accents($title); |
| @@ -146,9 +187,9 @@ | ||
| 146 | 187 | if ($count == 5) { |
| 147 | 188 | $count = time(); |
| 148 | 189 | } |
| 149 | 190 | $slug = $title . '-' . $count; |
| 150 | - $count++; | |
| 191 | + ++$count; | |
| 151 | 192 | } |
| 152 | 193 | |
| 153 | 194 | if (strlen($slug) <= 4) { |
| 154 | 195 | $slug = $slug . '-' . time(); |
| @@ -159,9 +200,9 @@ | ||
| 159 | 200 | |
| 160 | 201 | protected static function getDefaultMeta() |
| 161 | 202 | { |
| 162 | 203 | return [ |
| 163 | - 'preview_data' => null | |
| 204 | + 'preview_data' => null, | |
| 164 | 205 | ]; |
| 165 | 206 | } |
| 166 | 207 | |
| 167 | 208 | public function setMetaAttribute($value) |
| @@ -176,9 +217,9 @@ | ||
| 176 | 217 | if (!$meta) { |
| 177 | 218 | $meta = []; |
| 178 | 219 | } |
| 179 | 220 | |
| 180 | - return $meta; | |
| 221 | + return Helper::sanitizeStoredMediaPreview($meta); | |
| 181 | 222 | } |
| 182 | 223 | |
| 183 | 224 | public function scopeSearchBy($query, $search, $in = []) |
| 184 | 225 | { |
| @@ -186,9 +227,9 @@ | ||
| 186 | 227 | return $query; |
| 187 | 228 | } |
| 188 | 229 | |
| 189 | 230 | if (!$in || !is_array($in)) { |
| 190 | - $in = ['post_content']; | |
| 231 | + $in = [ 'post_content' ]; | |
| 191 | 232 | } |
| 192 | 233 | |
| 193 | 234 | $fields = $this->searchable; |
| 194 | 235 | $query->where(function ($query) use ($fields, $search, $in) { |
| @@ -207,9 +248,9 @@ | ||
| 207 | 248 | $query->orWhereHas('comments', function ($q) use ($search) { |
| 208 | 249 | return $q->where('message', 'LIKE', "%$search%"); |
| 209 | 250 | }); |
| 210 | 251 | } |
| 211 | - } else if ($in && in_array('post_comments', $in)) { | |
| 252 | + } elseif ($in && in_array('post_comments', $in)) { | |
| 212 | 253 | $query->whereHas('comments', function ($q) use ($search) { |
| 213 | 254 | return $q->where('message', 'LIKE', "%$search%"); |
| 214 | 255 | }); |
| 215 | 256 | } |
| @@ -251,16 +292,15 @@ | ||
| 251 | 292 | if ( |
| 252 | 293 | $user->hasCommunityModeratorAccess() || |
| 253 | 294 | ($space && $user->hasSpacePermission('edit_any_feed', $space)) |
| 254 | 295 | ) { |
| 255 | - return $query->whereIn('status', ['published', 'pending']); | |
| 296 | + return $query->whereIn('status', [ 'published', 'pending' ]); | |
| 256 | 297 | } |
| 257 | 298 | |
| 258 | - // This is a normal User. | |
| 259 | - return $query->where(function ($q) use ($user) { | |
| 260 | - $q->where('status', 'published') | |
| 261 | - ->orWhere(function ($q) use ($user) { | |
| 262 | - $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') | |
| 263 | 303 | ->where('user_id', $user->ID); |
| 264 | 304 | }); |
| 265 | 305 | }); |
| 266 | 306 | } |
| @@ -419,8 +459,44 @@ | ||
| 419 | 459 | return $this->hasMany(Reaction::class, 'object_id', 'id') |
| 420 | 460 | ->where('object_type', 'feed'); |
| 421 | 461 | } |
| 422 | 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 | + | |
| 423 | 499 | // New Relationship: Follow records where this post's user_id is the followed_id |
| 424 | 500 | public function follows() |
| 425 | 501 | { |
| 426 | 502 | return $this->hasMany(Follow::class, 'followed_id', 'user_id'); |
| @@ -442,9 +518,9 @@ | ||
| 442 | 518 | if (!$userId) { |
| 443 | 519 | return false; |
| 444 | 520 | } |
| 445 | 521 | |
| 446 | - return Reaction::select(['id']) | |
| 522 | + return Reaction::select([ 'id' ]) | |
| 447 | 523 | ->where('object_id', $this->id) |
| 448 | 524 | ->where('object_type', 'feed') |
| 449 | 525 | ->where('user_id', $userId) |
| 450 | 526 | ->where('type', $type) |
| @@ -555,9 +631,9 @@ | ||
| 555 | 631 | Meta::create([ |
| 556 | 632 | 'object_id' => $this->id, |
| 557 | 633 | 'object_type' => 'feed', |
| 558 | 634 | 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key |
| 559 | - 'value' => $value | |
| 635 | + 'value' => $value, | |
| 560 | 636 | ]); |
| 561 | 637 | } |
| 562 | 638 | |
| 563 | 639 | return true; |
| @@ -608,10 +684,10 @@ | ||
| 608 | 684 | return [ |
| 609 | 685 | 'name' => 'view_lesson', |
| 610 | 686 | 'params' => [ |
| 611 | 687 | 'course_slug' => $this->space ? $this->space->slug : 'uknown', |
| 612 | - 'lesson_slug' => $this->slug | |
| 613 | - ] | |
| 688 | + 'lesson_slug' => $this->slug, | |
| 689 | + ], | |
| 614 | 690 | ]; |
| 615 | 691 | } |
| 616 | 692 | |
| 617 | 693 | if ($this->space_id) { |
| @@ -618,17 +694,17 @@ | ||
| 618 | 694 | $route = [ |
| 619 | 695 | 'name' => 'space_feed', |
| 620 | 696 | 'params' => [ |
| 621 | 697 | 'space' => $this->space->slug, |
| 622 | - 'feed_slug' => $this->slug | |
| 623 | - ] | |
| 698 | + 'feed_slug' => $this->slug, | |
| 699 | + ], | |
| 624 | 700 | ]; |
| 625 | 701 | } else { |
| 626 | 702 | $route = [ |
| 627 | 703 | 'name' => 'single_feed', |
| 628 | 704 | 'params' => [ |
| 629 | - 'feed_slug' => $this->slug | |
| 630 | - ] | |
| 705 | + 'feed_slug' => $this->slug, | |
| 706 | + ], | |
| 631 | 707 | ]; |
| 632 | 708 | } |
| 633 | 709 | |
| 634 | 710 | return $route; |
| @@ -665,9 +741,9 @@ | ||
| 665 | 741 | |
| 666 | 742 | $feedHtml = $this->message_rendered; |
| 667 | 743 | $feedHtml .= FeedsHelper::getMediaHtml($this->meta, $postPermalink); |
| 668 | 744 | |
| 669 | - $buttonText = $buttonText ?: __('Join the conversation', 'fluent-community'); | |
| 745 | + $buttonText = $buttonText ? $buttonText : __('Join the conversation', 'fluent-community'); | |
| 670 | 746 | |
| 671 | 747 | $emailComposer->addBlock('post_boxed_content', $feedHtml, [ |
| 672 | 748 | 'user' => $this->user, |
| 673 | 749 | 'title' => $this->title, |
| @@ -672,13 +748,13 @@ | ||
| 672 | 748 | 'user' => $this->user, |
| 673 | 749 | 'title' => $this->title, |
| 674 | 750 | 'permalink' => $postPermalink, |
| 675 | 751 | 'space_name' => $this->space ? $this->space->title : __('Community', 'fluent-community'), |
| 676 | - 'is_single' => true | |
| 752 | + 'is_single' => true, | |
| 677 | 753 | ]); |
| 678 | 754 | |
| 679 | 755 | $emailComposer->addBlock('button', $buttonText, [ |
| 680 | - 'link' => $postPermalink | |
| 756 | + 'link' => $postPermalink, | |
| 681 | 757 | ]); |
| 682 | 758 | |
| 683 | 759 | $emailComposer->setDefaultLogo(); |
| 684 | 760 | |