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
fluent-community / app / Models / Feed.php

Feed.php in FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses 2.10.01, at app/Models/Feed.php

766 lines 21.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCommunity\App\Models;
4
5 use FluentCommunity\App\Functions\Utility;
6 use FluentCommunity\App\Services\FeedsHelper;
7 use FluentCommunity\App\Services\Helper;
8 use FluentCommunity\App\Services\ProfileHelper;
9 use FluentCommunityPro\App\Models\Follow;
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 */
40 class Feed extends Model
41 {
42 protected $table = 'fcom_posts';
43
44 protected $guarded = [ 'id' ];
45
46 protected $casts = [
47 'comments_count' => 'int',
48 'reactions_count' => 'int',
49 'is_sticky' => 'int',
50 'priority' => 'int',
51 ];
52
53 protected $fillable = [
54 'user_id',
55 'title',
56 'slug',
57 'message',
58 'message_rendered',
59 'type',
60 'content_type',
61 'space_id',
62 'privacy',
63 'status',
64 'priority',
65 'featured_image',
66 'is_sticky',
67 'expired_at',
68 'scheduled_at',
69 'comments_count',
70 'reactions_count',
71 'meta',
72 'created_at',
73 'updated_at',
74 ];
75
76 protected $searchable = [
77 'message',
78 'title',
79 ];
80
81 public static $publicColumns = [
82 'id',
83 'slug',
84 'message_rendered',
85 'meta',
86 'title',
87 'featured_image',
88 'created_at',
89 'privacy',
90 'priority',
91 'type',
92 'content_type',
93 'slug',
94 'space_id',
95 'user_id',
96 'status',
97 'is_sticky',
98 'scheduled_at',
99 'comments_count',
100 'reactions_count',
101 ];
102
103 protected $appends = [
104 'permalink',
105 ];
106
107 public static $scopeType = 'text';
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
120 public static function boot()
121 {
122 parent::boot();
123
124 static::creating(function ($model) {
125 if (empty($model->user_id)) {
126 $model->user_id = get_current_user_id();
127 }
128 if (empty($model->slug)) {
129 $model->slug = self::generateNewSlug($model);
130 }
131
132 if (empty($model->meta)) {
133 $model->meta = self::getDefaultMeta();
134 }
135
136 if (empty($model->status)) {
137 $model->status = 'published';
138 }
139
140 });
141
142 static::addGlobalScope('type', function ($builder) {
143 $builder->where('type', self::$scopeType);
144 });
145
146 static::deleting(function ($feed) {
147 Media::where('feed_id', $feed->id)
148 ->update([
149 'is_active' => 0,
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();
157 });
158
159 }
160
161 protected static function generateNewSlug($newModel)
162 {
163 if ($newModel->title) {
164 // Remove the emojis
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');
168 } else {
169 // get the first 25 char from the message
170 $title = mb_substr((string) $newModel->message, 0, 40, 'UTF-8');
171 }
172
173 $title = Helper::normalizeToAscii($title);
174 $title = remove_accents($title);
175
176 $title = strtolower($title);
177 // only allow alphanumeric, dash, and underscore
178 $title = trim(preg_replace('/[^a-z0-9-_]/', ' ', $title));
179
180 $title = sanitize_title($title, 'post-' . time());
181
182 // check if the slug is already exists
183 $slug = $title;
184
185 $count = 1;
186 while (self::where('slug', $slug)->exists()) {
187 if ($count == 5) {
188 $count = time();
189 }
190 $slug = $title . '-' . $count;
191 ++$count;
192 }
193
194 if (strlen($slug) <= 4) {
195 $slug = $slug . '-' . time();
196 }
197
198 return $slug;
199 }
200
201 protected static function getDefaultMeta()
202 {
203 return [
204 'preview_data' => null,
205 ];
206 }
207
208 public function setMetaAttribute($value)
209 {
210 $this->attributes['meta'] = maybe_serialize($value);
211 }
212
213 public function getMetaAttribute($value)
214 {
215 $meta = Utility::safeUnserialize($value);
216
217 if (!$meta) {
218 $meta = [];
219 }
220
221 return Helper::sanitizeStoredMediaPreview($meta);
222 }
223
224 public function scopeSearchBy($query, $search, $in = [])
225 {
226 if (!$search) {
227 return $query;
228 }
229
230 if (!$in || !is_array($in)) {
231 $in = [ 'post_content' ];
232 }
233
234 $fields = $this->searchable;
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 });
256 }
257 });
258
259 return $query;
260 }
261
262 public function scopeByUserAccess($query, $userId)
263 {
264 if ($userId) {
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');
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');
290 }
291
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 });
306 }
307
308 public function scopeByBookMarked($query, $userId)
309 {
310 return $query->whereHas('reactions', function ($q) use ($userId) {
311 $q->where('user_id', $userId)
312 ->where('type', 'bookmark');
313 });
314 }
315
316 public function scopeByTopicSlug($query, $topicSlug)
317 {
318 if (!$topicSlug) {
319 return $query;
320 }
321
322 return $query->whereHas('terms', function ($q) use ($topicSlug) {
323 $topic = Term::where('taxonomy_name', 'post_topic')->where('slug', $topicSlug)->first();
324 if ($topic) {
325 $q->where('term_id', $topic->id);
326 }
327 });
328 }
329
330 public function scopeFilterBySpaceSlug($query, $space)
331 {
332 if (!$space) {
333 return $query;
334 }
335
336 $query->whereHas('space', function ($q) use ($space) {
337 $q->where('slug', $space);
338 });
339
340 return $query;
341 }
342
343 public function scopeByType($query, $type)
344 {
345 if (!$type) {
346 return $query;
347 }
348
349 $query->where('type', $type);
350
351 return $query;
352 }
353
354 public function scopeCustomOrderBy($query, $type)
355 {
356 $acceptedTypes = array_keys(Helper::getPostOrderOptions());
357
358 if (!in_array($type, $acceptedTypes) || $type == 'latest') {
359 return $query->orderBy('created_at', 'DESC');
360 }
361
362 if ($type == 'new_activity') {
363 return $query->orderBy('updated_at', 'DESC');
364 }
365
366 if ($type == 'oldest') {
367 return $query->orderBy('created_at', 'ASC');
368 }
369
370 if ($type == 'likes') {
371 return $query->orderBy('reactions_count', 'DESC');
372 }
373
374 if ($type == 'unanswered') {
375 return $query->where('comments_count', 0)
376 ->orderBy('created_at', 'DESC');
377 }
378
379 if ($type == 'alphabetical') {
380 return $query->orderBy('slug', 'ASC');
381 }
382
383 if ($type == 'popular') {
384 // sort by comments_count + reactions_count desc
385 return $query->orderByRaw('(reactions_count + (comments_count * 2)) DESC');
386 }
387
388 $query = apply_filters('fluent_community/custom_order_by', $query, $type);
389
390 return $query;
391 }
392
393 public function scopeByStatus($query, $status)
394 {
395 if (!$status) {
396 return $query->where('status', 'published');
397 }
398
399 $query->where('status', $status);
400
401 return $query;
402 }
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
425 public function scopeFilterByUserId($query, $userId)
426 {
427 if (!$userId) {
428 return $query;
429 }
430
431 $query->where('user_id', $userId);
432
433 return $query;
434 }
435
436 public function user()
437 {
438 return $this->belongsTo(User::class, 'user_id', 'ID');
439 }
440
441 public function xprofile()
442 {
443 return $this->belongsTo(XProfile::class, 'user_id', 'user_id');
444 }
445
446 public function space()
447 {
448 return $this->belongsTo(BaseSpace::class, 'space_id', 'id')
449 ->withoutGlobalScopes();
450 }
451
452 public function comments()
453 {
454 return $this->hasMany(Comment::class, 'post_id', 'id');
455 }
456
457 public function reactions()
458 {
459 return $this->hasMany(Reaction::class, 'object_id', 'id')
460 ->where('object_type', 'feed');
461 }
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
505 public function surveyVotes()
506 {
507 return $this->hasMany(Reaction::class, 'object_id', 'id')
508 ->where('type', 'survey_vote');
509 }
510
511 public function terms()
512 {
513 return $this->belongsToMany(Term::class, 'fcom_term_feed', 'post_id', 'term_id');
514 }
515
516 public function hasUserReact($userId, $type = 'like')
517 {
518 if (!$userId) {
519 return false;
520 }
521
522 return Reaction::select([ 'id' ])
523 ->where('object_id', $this->id)
524 ->where('object_type', 'feed')
525 ->where('user_id', $userId)
526 ->where('type', $type)
527 ->exists();
528 }
529
530 public function hasEditAccess($userId)
531 {
532 if (!$userId) {
533 return false;
534 }
535
536 if ($this->user_id == $userId) {
537 return true;
538 }
539
540 $userModel = User::find($userId);
541
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');
551 }
552
553 public function getHumanExcerpt($length = 40)
554 {
555 $content = $this->title;
556 if (!$content) {
557 $content = $this->message;
558 }
559
560 return Helper::getHumanExcerpt($content, $length);
561 }
562
563 public function getPermalink()
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
576 if ($this->space_id && $this->space) {
577 $urlPath = $sectionPrefix . '/' . $this->space->slug . '/' . $contentPrefix . '/' . $this->slug;
578 if ($isLesson) {
579 $urlPath .= '/view';
580 }
581 }
582
583 return Helper::baseUrl($urlPath);
584 }
585
586 public function getPermalinkAttribute()
587 {
588 return $this->getPermalink();
589 }
590
591 public function activities()
592 {
593 return $this->hasMany(Activity::class, 'feed_id', 'id');
594 }
595
596 public function notifications()
597 {
598 return $this->hasMany(Notification::class, 'feed_id', 'id');
599 }
600
601 public function media()
602 {
603 return $this->hasMany(Media::class, 'feed_id', 'id');
604 }
605
606 public function getSurveyCastsByUserId($userId = null)
607 {
608 if (!$userId || $this->content_type != 'survey') {
609 return [];
610 }
611
612 return Utility::getFromCache('survey_cast_' . $this->id . '_' . $userId, function () use ($userId) {
613 return Reaction::where('type', 'survey_vote')
614 ->where('user_id', $userId)
615 ->where('object_id', $this->id)
616 ->pluck('object_type')->toArray();
617 }, 86400);
618 }
619
620 public function updateCustomMeta($key, $value)
621 {
622 $exist = Meta::where('object_id', $this->id)
623 ->where('object_type', 'feed')
624 ->where('meta_key', $key)
625 ->first();
626
627 if ($exist) {
628 $exist->value = $value;
629 $exist->save();
630 } else {
631 Meta::create([
632 'object_id' => $this->id,
633 'object_type' => 'feed',
634 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
635 'value' => $value,
636 ]);
637 }
638
639 return true;
640 }
641
642 public function getCustomMeta($key, $default = null)
643 {
644 $exist = Meta::where('object_id', $this->id)
645 ->where('object_type', 'feed')
646 ->where('meta_key', $key)
647 ->first();
648
649 if ($exist) {
650 return $exist->value;
651 }
652
653 return $default;
654 }
655
656 public function attachTopics($topicIds, $sync = false)
657 {
658 if ((!$topicIds && !$sync) || !$this->space_id) {
659 return $this;
660 }
661
662 // let's find the valid topics for this space
663 $spaceTopics = Utility::getTopicsBySpaceId($this->space_id);
664 $spaceTopicIds = array_map(function ($topic) {
665 return $topic['id'];
666 }, $spaceTopics);
667
668 $validTopicIds = array_filter($topicIds, function ($topicId) use ($spaceTopicIds) {
669 return in_array($topicId, $spaceTopicIds);
670 });
671
672 if ($sync) {
673 $this->terms()->sync($validTopicIds);
674 } else {
675 $this->terms()->attach($validTopicIds);
676 }
677
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();
764 }
765 }
766