PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.10.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.10.0
2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 1.1.0 All 77 releases
← All changes | app/Models/Feed.php +337 -44 1.0.922.10.0 View file →
@@ -2,15 +2,47 @@
2 2
3 3 namespace FluentCommunity\App\Models;
4 4
5 5 use FluentCommunity\App\Functions\Utility;
6 +use FluentCommunity\App\Services\FeedsHelper;
6 7 use FluentCommunity\App\Services\Helper;
8 +use FluentCommunity\App\Services\ProfileHelper;
9 +use FluentCommunityPro\App\Models\Follow;
7 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 + */
8 40 class Feed extends Model
9 41 {
10 42 protected $table = 'fcom_posts';
11 43
12 - protected $guarded = ['id'];
44 + protected $guarded = [ 'id' ];
13 45
14 46 protected $casts = [
15 47 'comments_count' => 'int',
16 48 'reactions_count' => 'int',
@@ -37,14 +69,14 @@
37 69 'comments_count',
38 70 'reactions_count',
39 71 'meta',
40 72 'created_at',
41 - 'updated_at'
73 + 'updated_at',
42 74 ];
43 75
44 76 protected $searchable = [
45 77 'message',
46 - 'title'
78 + 'title',
47 79 ];
48 80
49 81 public static $publicColumns = [
50 82 'id',
@@ -60,15 +92,32 @@
60 92 'content_type',
61 93 'slug',
62 94 'space_id',
63 95 'user_id',
96 + 'status',
64 97 'is_sticky',
98 + 'scheduled_at',
65 99 'comments_count',
66 - 'reactions_count'
100 + 'reactions_count',
67 101 ];
68 102
103 + protected $appends = [
104 + 'permalink',
105 + ];
106 +
69 107 public static $scopeType = 'text';
70 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 +
71 120 public static function boot()
72 121 {
73 122 parent::boot();
74 123
@@ -82,8 +131,13 @@
82 131
83 132 if (empty($model->meta)) {
84 133 $model->meta = self::getDefaultMeta();
85 134 }
135 +
136 + if (empty($model->status)) {
137 + $model->status = 'published';
138 + }
139 +
86 140 });
87 141
88 142 static::addGlobalScope('type', function ($builder) {
89 143 $builder->where('type', self::$scopeType);
@@ -91,10 +145,16 @@
91 145
92 146 static::deleting(function ($feed) {
93 147 Media::where('feed_id', $feed->id)
94 148 ->update([
95 - 'is_active' => 0
149 + 'is_active' => 0,
96 150 ]);
151 + Reaction::where('object_id', $feed->id)
152 + ->where(function ($query) {
153 + $query->where('object_type', 'feed')
154 + ->orWhere('type', 'survey_vote');
155 + })
156 + ->delete();
97 157 });
98 158
99 159 }
100 160
@@ -101,21 +161,28 @@
101 161 protected static function generateNewSlug($newModel)
102 162 {
103 163 if ($newModel->title) {
104 164 // Remove the emojis
105 - $feedTitle = preg_replace('/[\x{10000}-\x{10FFFF}]/u', '', $newModel->title);
106 - $title = sanitize_title($feedTitle, $newModel->user_id . '-' . time());
165 + $title = preg_replace('/[\x{10000}-\x{10FFFF}]/u', '', $newModel->title);
166 + // get the first 40 char from the title
167 + $title = mb_substr($title, 0, 40, 'UTF-8');
107 168 } else {
108 169 // get the first 25 char from the message
109 - $title = sanitize_title(substr($newModel->message, 0, 25), $newModel->user_id . '-' . time());
170 + $title = mb_substr((string) $newModel->message, 0, 40, 'UTF-8');
110 171 }
111 172
173 + $title = Helper::normalizeToAscii($title);
174 + $title = remove_accents($title);
175 +
112 176 $title = strtolower($title);
113 177 // only allow alphanumeric, dash, and underscore
114 - $title = preg_replace('/[^a-z0-9-_]/', '', $title);
178 + $title = trim(preg_replace('/[^a-z0-9-_]/', ' ', $title));
115 179
180 + $title = sanitize_title($title, 'post-' . time());
181 +
116 182 // check if the slug is already exists
117 183 $slug = $title;
184 +
118 185 $count = 1;
119 186 while (self::where('slug', $slug)->exists()) {
120 187 if ($count == 5) {
121 188 $count = time();
@@ -120,11 +187,15 @@
120 187 if ($count == 5) {
121 188 $count = time();
122 189 }
123 190 $slug = $title . '-' . $count;
124 - $count++;
191 + ++$count;
125 192 }
126 193
194 + if (strlen($slug) <= 4) {
195 + $slug = $slug . '-' . time();
196 + }
197 +
127 198 return $slug;
128 199 }
129 200
130 201 protected static function getDefaultMeta()
@@ -129,9 +200,9 @@
129 200
130 201 protected static function getDefaultMeta()
131 202 {
132 203 return [
133 - 'preview_data' => null
204 + 'preview_data' => null,
134 205 ];
135 206 }
136 207
137 208 public function setMetaAttribute($value)
@@ -140,28 +211,49 @@
140 211 }
141 212
142 213 public function getMetaAttribute($value)
143 214 {
144 - $meta = maybe_unserialize($value);
215 + $meta = Utility::safeUnserialize($value);
145 216
146 217 if (!$meta) {
147 218 $meta = [];
148 219 }
149 220
150 - return $meta;
221 + return Helper::sanitizeStoredMediaPreview($meta);
151 222 }
152 223
153 - public function scopeSearchBy($query, $search)
224 + public function scopeSearchBy($query, $search, $in = [])
154 225 {
155 226 if (!$search) {
156 227 return $query;
157 228 }
158 229
230 + if (!$in || !is_array($in)) {
231 + $in = [ 'post_content' ];
232 + }
233 +
159 234 $fields = $this->searchable;
160 - $query->where(function ($query) use ($fields, $search) {
161 - $query->where(array_shift($fields), 'LIKE', "%$search%");
162 - foreach ($fields as $field) {
163 - $query->orWhere($field, 'LIKE', "$search%");
235 + $query->where(function ($query) use ($fields, $search, $in) {
236 + if (in_array('post_content', $in)) {
237 + $query->where(array_shift($fields), 'LIKE', "%$search%");
238 + foreach ($fields as $field) {
239 + $query->orWhere($field, 'LIKE', "%$search%");
240 + }
241 +
242 + $query->orWhere(function ($q) use ($search) {
243 + $q->where('content_type', 'document')
244 + ->where('meta', 'LIKE', '%document_lists%title%' . $search . '%');
245 + });
246 +
247 + if ($in && in_array('post_comments', $in)) {
248 + $query->orWhereHas('comments', function ($q) use ($search) {
249 + return $q->where('message', 'LIKE', "%$search%");
250 + });
251 + }
252 + } elseif ($in && in_array('post_comments', $in)) {
253 + $query->whereHas('comments', function ($q) use ($search) {
254 + return $q->where('message', 'LIKE', "%$search%");
255 + });
164 256 }
165 257 });
166 258
167 259 return $query;
@@ -169,23 +261,49 @@
169 261
170 262 public function scopeByUserAccess($query, $userId)
171 263 {
172 264 if ($userId) {
173 - return $query->where('user_id', $userId)->orWhereNull('space_id')
174 - ->orWhereHas('space', function ($q) use ($userId) {
175 - $spaceIds = get_user_meta($userId, '_fcom_space_ids', true);
176 - if ($spaceIds) {
177 - $q->whereIn('id', $spaceIds);
178 - return $q;
179 - }
180 - return $q->where('privacy', 'public');
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 + });
276 + }
277 +
278 + return $query->where(function ($q) {
279 + $q->whereNull('space_id')
280 + ->orWhereHas('space', function ($q) {
281 + $q->where('privacy', 'public');
181 282 });
283 + });
284 + }
285 +
286 + public function scopeByContentModerationAccessStatus($query, $user, $space = null)
287 + {
288 + if (!$user || !Helper::isFeatureEnabled('content_moderation')) {
289 + return $query->where('status', 'published');
182 290 }
183 291
184 - return $query->whereNull('space_id')
185 - ->orWhereHas('space', function ($q) {
186 - $q->where('privacy', 'public');
187 - });
292 + if (
293 + $user->hasCommunityModeratorAccess() ||
294 + ($space && $user->hasSpacePermission('edit_any_feed', $space))
295 + ) {
296 + return $query->whereIn('status', [ 'published', 'pending' ]);
297 + }
298 +
299 + return $query->where(function ($statusQuery) use ($user) {
300 + $statusQuery->where('status', 'published')
301 + ->orWhere(function ($subQuery) use ($user) {
302 + $subQuery->where('status', 'pending')
303 + ->where('user_id', $user->ID);
304 + });
305 + });
188 306 }
189 307
190 308 public function scopeByBookMarked($query, $userId)
191 309 {
@@ -201,10 +319,10 @@
201 319 return $query;
202 320 }
203 321
204 322 return $query->whereHas('terms', function ($q) use ($topicSlug) {
205 - $topic = Term::where('slug', $topicSlug)->first();
206 - if($topic) {
323 + $topic = Term::where('taxonomy_name', 'post_topic')->where('slug', $topicSlug)->first();
324 + if ($topic) {
207 325 $q->where('term_id', $topic->id);
208 326 }
209 327 });
210 328 }
@@ -234,11 +352,11 @@
234 352 }
235 353
236 354 public function scopeCustomOrderBy($query, $type)
237 355 {
238 - $acceptedTypes = ['new_activity', 'oldest', 'popular', 'likes', 'alphabetical', 'unanswered'];
356 + $acceptedTypes = array_keys(Helper::getPostOrderOptions());
239 357
240 - if (!in_array($type, $acceptedTypes)) {
358 + if (!in_array($type, $acceptedTypes) || $type == 'latest') {
241 359 return $query->orderBy('created_at', 'DESC');
242 360 }
243 361
244 362 if ($type == 'new_activity') {
@@ -266,8 +384,10 @@
266 384 // sort by comments_count + reactions_count desc
267 385 return $query->orderByRaw('(reactions_count + (comments_count * 2)) DESC');
268 386 }
269 387
388 + $query = apply_filters('fluent_community/custom_order_by', $query, $type);
389 +
270 390 return $query;
271 391 }
272 392
273 393 public function scopeByStatus($query, $status)
@@ -280,8 +400,29 @@
280 400
281 401 return $query;
282 402 }
283 403
404 + public function scopeByFollowing($query, $userId = null)
405 + {
406 + $query->orderBy('updated_at', 'DESC');
407 +
408 + if (!Helper::isFeatureEnabled('followers_module')) {
409 + return $query;
410 + }
411 +
412 + if (!$userId) {
413 + $userId = get_current_user_id();
414 + }
415 +
416 + if (!$userId) {
417 + return $query;
418 + }
419 +
420 + return $query->whereHas('follows', function ($query) use ($userId) {
421 + $query->where('follower_id', $userId);
422 + });
423 + }
424 +
284 425 public function scopeFilterByUserId($query, $userId)
285 426 {
286 427 if (!$userId) {
287 428 return $query;
@@ -318,8 +459,50 @@
318 459 return $this->hasMany(Reaction::class, 'object_id', 'id')
319 460 ->where('object_type', 'feed');
320 461 }
321 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 +
499 + // New Relationship: Follow records where this post's user_id is the followed_id
500 + public function follows()
501 + {
502 + return $this->hasMany(Follow::class, 'followed_id', 'user_id');
503 + }
504 +
322 505 public function surveyVotes()
323 506 {
324 507 return $this->hasMany(Reaction::class, 'object_id', 'id')
325 508 ->where('type', 'survey_vote');
@@ -335,14 +518,14 @@
335 518 if (!$userId) {
336 519 return false;
337 520 }
338 521
339 - return (bool)Reaction::where('object_id', $this->id)
340 - ->select(['id'])
522 + return Reaction::select([ 'id' ])
523 + ->where('object_id', $this->id)
341 524 ->where('object_type', 'feed')
342 525 ->where('user_id', $userId)
343 526 ->where('type', $type)
344 - ->first();
527 + ->exists();
345 528 }
346 529
347 530 public function hasEditAccess($userId)
348 531 {
@@ -355,9 +538,17 @@
355 538 }
356 539
357 540 $userModel = User::find($userId);
358 541
359 - return $userModel && $userModel->isCommunityModerator();
542 + if (!$userModel) {
543 + return false;
544 + }
545 +
546 + if ($this->space_id) {
547 + return $userModel->hasSpacePermission('edit_any_feed', $this->space);
548 + }
549 +
550 + return $userModel->hasCommunityPermission('edit_any_feed');
360 551 }
361 552
362 553 public function getHumanExcerpt($length = 40)
363 554 {
@@ -370,17 +561,34 @@
370 561 }
371 562
372 563 public function getPermalink()
373 564 {
565 + $sectionPrefix = 'space';
566 + $contentPrefix = 'post';
567 + $isLesson = $this->type === 'course_lesson';
568 +
569 + if ($isLesson) {
570 + $sectionPrefix = 'course';
571 + $contentPrefix = 'lessons';
572 + }
573 +
574 + $urlPath = $contentPrefix . '/' . $this->slug;
575 +
374 576 if ($this->space_id && $this->space) {
375 - $path = 'space/' . $this->space->slug . '/post/' . $this->slug;
376 - } else {
377 - $path = 'post/' . $this->slug;
577 + $urlPath = $sectionPrefix . '/' . $this->space->slug . '/' . $contentPrefix . '/' . $this->slug;
578 + if ($isLesson) {
579 + $urlPath .= '/view';
580 + }
378 581 }
379 582
380 - return Helper::baseUrl($path);
583 + return Helper::baseUrl($urlPath);
381 584 }
382 585
586 + public function getPermalinkAttribute()
587 + {
588 + return $this->getPermalink();
589 + }
590 +
383 591 public function activities()
384 592 {
385 593 return $this->hasMany(Activity::class, 'feed_id', 'id');
386 594 }
@@ -422,10 +630,10 @@
422 630 } else {
423 631 Meta::create([
424 632 'object_id' => $this->id,
425 633 'object_type' => 'feed',
426 - 'meta_key' => $key,
427 - 'value' => $value
634 + 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
635 + 'value' => $value,
428 636 ]);
429 637 }
430 638
431 639 return true;
@@ -467,6 +675,91 @@
467 675 $this->terms()->attach($validTopicIds);
468 676 }
469 677
470 678 return $this;
679 + }
680 +
681 + public function getJsRoute()
682 + {
683 + if ($this->type == 'course_lesson') {
684 + return [
685 + 'name' => 'view_lesson',
686 + 'params' => [
687 + 'course_slug' => $this->space ? $this->space->slug : 'uknown',
688 + 'lesson_slug' => $this->slug,
689 + ],
690 + ];
691 + }
692 +
693 + if ($this->space_id) {
694 + $route = [
695 + 'name' => 'space_feed',
696 + 'params' => [
697 + 'space' => $this->space->slug,
698 + 'feed_slug' => $this->slug,
699 + ],
700 + ];
701 + } else {
702 + $route = [
703 + 'name' => 'single_feed',
704 + 'params' => [
705 + 'feed_slug' => $this->slug,
706 + ],
707 + ];
708 + }
709 +
710 + return $route;
711 + }
712 +
713 + public function recountStats()
714 + {
715 + $this->comments_count = Comment::where('post_id', $this->id)
716 + ->where('type', 'comment')
717 + ->count();
718 +
719 + $this->reactions_count = Reaction::where('object_type', 'feed')->where('type', 'like')
720 + ->where('object_id', $this->id)
721 + ->count();
722 +
723 + $this->save();
724 + return $this;
725 + }
726 +
727 + public function isEnabledForEveryoneTag()
728 + {
729 + return ($this->meta['send_announcement_email'] ?? null) === 'yes' && Utility::hasEmailAnnouncementEnabled();
730 + }
731 +
732 + public function getFeedHtml($withPlaceholder = false, $buttonText = null)
733 + {
734 + $emailComposer = new \FluentCommunity\App\Services\Libs\EmailComposer();
735 +
736 + if ($withPlaceholder) {
737 + $postPermalink = '##feed_permalink##';
738 + } else {
739 + $postPermalink = $this->getPermalink();
740 + }
741 +
742 + $feedHtml = $this->message_rendered;
743 + $feedHtml .= FeedsHelper::getMediaHtml($this->meta, $postPermalink);
744 +
745 + $buttonText = $buttonText ? $buttonText : __('Join the conversation', 'fluent-community');
746 +
747 + $emailComposer->addBlock('post_boxed_content', $feedHtml, [
748 + 'user' => $this->user,
749 + 'title' => $this->title,
750 + 'permalink' => $postPermalink,
751 + 'space_name' => $this->space ? $this->space->title : __('Community', 'fluent-community'),
752 + 'is_single' => true,
753 + ]);
754 +
755 + $emailComposer->addBlock('button', $buttonText, [
756 + 'link' => $postPermalink,
757 + ]);
758 +
759 + $emailComposer->setDefaultLogo();
760 +
761 + $emailComposer->setDefaultFooter();
762 +
763 + return $emailComposer->getHtml();
471 764 }
472 765 }