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 / Services / FeedsHelper.php

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

1,328 lines 48.1 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\Services;
4
5 use FluentCommunity\App\Functions\Utility;
6 use FluentCommunity\App\Models\BaseSpace;
7 use FluentCommunity\App\Models\Feed;
8 use FluentCommunity\App\Models\Media;
9 use FluentCommunity\App\Models\Reaction;
10 use FluentCommunity\App\Models\Term;
11 use FluentCommunity\App\Models\User;
12 use FluentCommunity\App\Models\XProfile;
13 use FluentCommunity\Framework\Foundation\Exceptions\UnprocessableEntityHttpException;
14 use FluentCommunity\Framework\Support\Arr;
15 use FluentCommunity\Framework\Validator\Validator;
16
17 class FeedsHelper
18 {
19 static protected $currentRelatedUserIds = [];
20
21 public static function setCurrentRelatedUserId($userId)
22 {
23 self::$currentRelatedUserIds[] = $userId;
24 }
25
26 public static function getCurrentRelatedUserIds()
27 {
28 return array_values(array_unique(self::$currentRelatedUserIds));
29 }
30
31 /**
32 * Resolve who should receive the "post author" notification for a feed.
33 * Course lessons notify the COURSE creator (whoever created the course),
34 * not the user who uploaded the individual lesson.
35 */
36 public static function getNotificationAuthorId($feed)
37 {
38 if ($feed->type === 'course_lesson' && $feed->space_id) {
39 $course = BaseSpace::withoutGlobalScopes()->find($feed->space_id);
40 if ($course && $course->created_by) {
41 return (int) $course->created_by;
42 }
43 }
44
45 return (int) $feed->user_id;
46 }
47
48 public static function getSpaceSlugsByUserId($userId)
49 {
50 if (!$userId) {
51 $userId = get_current_user_id();
52 }
53
54 if (!$userId) {
55 return [];
56 }
57
58 $user = User::find($userId);
59
60 return $user->spaces()->pluck('slug')->toArray();
61 }
62
63 /**
64 * Statuses where a post is fully reachable by its direct link. An unlisted post is
65 * hidden from listings only, so it stays commentable and reactable like a published one.
66 *
67 * @return array
68 */
69 public static function getViewableByLinkStatuses()
70 {
71 return ['published', 'unlisted'];
72 }
73
74 /**
75 * Row types that opt IN to comments through meta.enable_comments, mapped to the value
76 * assumed when the key is absent.
77 *
78 * A feed post uses the opposite convention - meta.comments_disabled, absent meaning on -
79 * so it is deliberately not listed here and falls through to the permissive default.
80 *
81 * The fallbacks match each model's getDefaultMeta(): a lesson written before the
82 * setting existed keeps its thread, a page does not. Guessing one value for both
83 * would silently switch off every legacy lesson discussion.
84 *
85 * @return array<string, string>
86 */
87 public static function getOptInCommentTypes()
88 {
89 return apply_filters('fluent_community/opt_in_comment_types', [
90 'course_lesson' => 'yes',
91 'space_page' => 'no',
92 ]);
93 }
94
95 /**
96 * Whether a row accepts comments at all, by its own settings.
97 *
98 * This is the setting check only - it says nothing about who the current user is.
99 * Space membership and the course level kill switch are separate, in
100 * CommentsController::verifySpacePermission().
101 *
102 * Both the read and the write path go through here so they cannot disagree. They used
103 * to: the write path only ever read meta.comments_disabled, which pages and lessons
104 * do not set, so a POST landed a comment on a page whose thread the UI was hiding.
105 *
106 * @param \FluentCommunity\App\Models\Feed $feed
107 * @return bool
108 */
109 public static function commentsEnabled($feed)
110 {
111 $meta = $feed->meta;
112
113 if (Arr::get($meta, 'comments_disabled') === 'yes') {
114 return false;
115 }
116
117 $optIn = self::getOptInCommentTypes();
118
119 if (isset($optIn[$feed->type])) {
120 return Arr::get($meta, 'enable_comments', $optIn[$feed->type]) === 'yes';
121 }
122
123 return true;
124 }
125
126 public static function getLastFeedId()
127 {
128 $lastItem = Feed::where('status', 'published')
129 ->byUserAccess(get_current_user_id())
130 ->orderBy('id', 'DESC')
131 ->first();
132
133 if ($lastItem) {
134 return $lastItem->id;
135 }
136
137 return 1;
138 }
139
140 public static function mdToHtml($text, $options = [])
141 {
142 if (!$text) {
143 return '';
144 }
145
146 $text = str_replace('&#x20;', '', $text); // hide markdown empty content
147
148 $html = (new \FluentCommunity\App\Services\Parsedown([
149 ]))
150 ->setBreaksEnabled(true)
151 ->setUrlsLinked(false)
152 // ->setSafeMode(true)
153 ->text($text);
154
155 if (!Arr::get($options, 'disable_link_process')) {
156 // add nofollow to all links. But check if nofollow is already there
157 $html = self::addNoFollowToLinks($html);
158 }
159
160 $html = wp_kses($html, array(
161 'p' => array(),
162 'br' => array(),
163 'strong' => array(),
164 'em' => array(),
165 'hr' => array(),
166 'h1' => array(),
167 'h2' => array(),
168 'h3' => array(),
169 'h4' => array(),
170 'h5' => array(),
171 'h6' => array(),
172 'ul' => array(),
173 'b' => array(),
174 'ol' => array(),
175 'li' => array(),
176 'span' => array(),
177 'a' => array(
178 'href' => true,
179 'title' => true,
180 'rel' => true,
181 'target' => true,
182 ),
183 'img' => array(
184 'src' => true,
185 'alt' => true,
186 ),
187 'code' => array(),
188 'pre' => array(),
189 'blockquote' => array(),
190 'del' => array(),
191 'table' => array(),
192 'thead' => array(),
193 'tbody' => array(),
194 'tfoot' => array(),
195 'tr' => array(),
196 'th' => array(
197 'align' => true,
198 'style' => true,
199 'colspan' => true,
200 'rowspan' => true,
201 ),
202 'td' => array(
203 'align' => true,
204 'style' => true,
205 'colspan' => true,
206 'rowspan' => true,
207 ),
208 ));
209
210 return self::maybeTransformDynamicCodes($html);
211 }
212
213 public static function maybeTransformDynamicCodes($html)
214 {
215 // check if there has {{
216 if (strpos($html, '{{') === false) {
217 return $html;
218 }
219
220 return preg_replace_callback(
221 '/{{utc:(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})}}/',
222 function ($match) {
223 // Extract the datetime string (e.g., 2025-06-01 15:06:59)
224 $datetimeStr = $match[1];
225
226 try {
227 // Create a DateTime object from the UTC string
228 $date = new \DateTime($datetimeStr, new \DateTimeZone('UTC'));
229 // Get the Unix timestamp for the data-timestamp attribute
230 $timestamp = $date->getTimestamp();
231 // Format the display string
232 $displayFormat = $date->format('d F Y, H:i') . ' (UTC)';
233
234 // Return the formatted HTML
235 return '<span class="fcom_dynamic_prop" data-type="timestamp" data-timestamp="' . $timestamp . '">' . $displayFormat . '</span>';
236 } catch (\Exception $e) {
237 // Return original match if parsing fails
238 return $match[0];
239 }
240 },
241 $html
242 );
243 }
244
245 public static function addNoFollowToLinks($html)
246 {
247 if (!$html) {
248 return '';
249 }
250
251 $current_domain = wp_parse_url(home_url(), PHP_URL_HOST);
252
253 // Regular expression to match <a> tags
254 $pattern = '/<a\s[^>]*href=("|\')(https?:\/\/(?!' . preg_quote($current_domain, '/') . ').*?)("|\')\s?([^>]*)>/i';
255
256 // Callback function to modify each matched <a> tag
257 $callback = function ($matches) {
258 $url = $matches[2];
259 $attr = $matches[4];
260
261 // Remove existing rel attribute if present
262 $attr = preg_replace('/\srel=("|\').*?("|\')/i', '', $attr);
263
264 // Add nofollow
265 return '<a href="' . $url . '" rel="nofollow" ' . trim($attr) . '>';
266 };
267
268 // Perform the replacement
269 return preg_replace_callback($pattern, $callback, $html);
270 }
271
272 public static function addNewTabToLinks($html)
273 {
274 if (empty($html) || !is_string($html)) {
275 return '';
276 }
277
278 // return is there has no href
279 if (strpos($html, 'href=') === false) {
280 return $html;
281 }
282
283 // More comprehensive regex to capture existing attributes
284 $pattern = '/<a\s+([^>]*)>/i';
285
286 // Callback function to modify each matched <a> tag
287 $callback = function ($matches) {
288 $full_tag = $matches[0];
289 $attributes = $matches[1];
290
291 // Extract href
292 preg_match('/href=("|\')([^"\']+)("|\')/', $full_tag, $href_matches);
293 if (empty($href_matches)) {
294 return $full_tag;
295 }
296 $url = $href_matches[2];
297
298 // Check if it's an external URL and not an image
299 if (preg_match('/^https?:\/\//i', $url) && !preg_match('/\.(jpg|jpeg|png|gif|svg)$/i', $url)) {
300 // Check if target already exists
301 if (!preg_match('/\btarget=/i', $full_tag)) {
302 // Preserve existing attributes, add target="_blank"
303 return '<a ' . $attributes . ' target="_blank" rel="noopener noreferrer">';
304 }
305 }
306
307 // Return original tag if no modification needed
308 return $full_tag;
309 };
310
311 // Perform the replacement
312 return preg_replace_callback($pattern, $callback, $html);
313 }
314
315 public static function findFirstUrl($html)
316 {
317 if (!preg_match_all('/<a\s+(?:[^>]*?\s+)?href=([\'"])(.*?)\1/i', $html, $matches)) {
318 return '';
319 }
320
321 $profileUrlPrefix = Helper::baseUrl('u/');
322
323 foreach ($matches[2] as $href) {
324 // Rendered HTML encodes "&" as "&amp;". Left encoded, "?a=1&amp;b=2" is read
325 // as a parameter named "amp;b" — which makes YouTube drop the "list" param.
326 // Re-sanitized because decoding also restores quotes and angle brackets,
327 // and this value is fetched remotely and stored on the feed.
328 $href = sanitize_url(html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
329
330 // sanitize_url() empties a disallowed scheme. Returning that would report
331 // "no links" for the whole post and skip any later, usable link.
332 if (!$href || strpos($href, $profileUrlPrefix) === 0) {
333 continue;
334 }
335 return $href;
336 }
337
338 return '';
339 }
340
341 public static function extractHashTags($text, $limit = 5)
342 {
343 // Extract hashtag including - and _
344 preg_match_all('/#([a-zA-Z0-9_-]+)/', $text, $matches);
345
346 $tags = array_unique($matches[1]);
347
348 if (!$tags) {
349 return [];
350 }
351
352 $tags = array_slice($tags, 0, $limit);
353
354 $lowerCaseTags = array_map('strtolower', $tags);
355
356 $terms = Term::whereIn('slug', $lowerCaseTags)
357 ->where('taxonomy_name', 'hashtag')
358 ->get();
359
360 $termIds = [];
361
362 foreach ($terms as $term) {
363 $termIds[$term->slug] = $term->id;
364 }
365
366 if (count($termIds) == count($tags)) {
367 return array_values($termIds);
368 }
369
370 $excepts = array_diff($tags, array_keys($termIds));
371
372 foreach ($excepts as $except) {
373 $term = Term::create([
374 'taxonomy_name' => 'hashtag',
375 'slug' => strtolower($except),
376 'title' => $except
377 ]);
378
379 $termIds[$term->slug] = $term->id;
380 }
381
382 return array_values($termIds);
383 }
384
385 public static function getMentions($text, $spaceId = null, $withUsers = false)
386 {
387 // the mention may have . or _ or - in the username
388 preg_match_all('/@([a-zA-Z0-9_.-]+)/', $text, $matches);
389 $mentions = array_unique($matches[1]);
390
391 if (!$mentions) {
392 return null;
393 }
394
395 if ($spaceId) {
396 $xProfiles = XProfile::whereIn('username', $mentions)
397 ->whereHas('spaces', function ($query) use ($spaceId) {
398 $query->withoutGlobalScopes()->where('space_id', $spaceId);
399 })
400 ->get();
401 } else {
402 $xProfiles = XProfile::whereIn('username', $mentions)
403 ->get();
404 }
405
406 if ($xProfiles->isEmpty()) {
407 return null;
408 }
409
410 $userMentions = [];
411
412 $userIds = [];
413
414 foreach ($xProfiles as $xProfile) {
415 $userIds[] = $xProfile->user_id;
416 $html = '<a data-user_name="' . $xProfile->username . '" class="fcom_mention fcom_route" href="' . Helper::baseUrl('u/' . $xProfile->username . '/') . '">' . $xProfile->display_name . '</a>';
417 $userMentions['@' . $xProfile->username] = $html;
418 }
419
420 $data = [
421 'user_ids' => $userIds,
422 'text' => strtr($text, $userMentions)
423 ];
424
425 if ($withUsers) {
426 $data['users'] = User::whereIn('ID', $userIds)->get();
427 }
428
429 return $data;
430 }
431
432 public static function getLikedIdsByUserFeedId($feedId, $userId)
433 {
434 return Reaction::select('object_id')
435 ->where('object_type', 'comment')
436 ->where('parent_id', $feedId)
437 ->where('user_id', $userId)
438 ->get()
439 ->pluck('object_id')
440 ->toArray();
441 }
442
443 public static function castSurveyVote($newVoteIndexes, Feed $feed, $userId)
444 {
445 $surveyConfig = Arr::get($feed->meta, 'survey_config', []);
446
447 $slugs = array_map(function ($item) {
448 return $item['slug'];
449 }, $surveyConfig['options']);
450
451 $newVoteIndexes = array_filter(array_intersect($slugs, $newVoteIndexes));
452
453 $previousVotes = Reaction::where('type', 'survey_vote')
454 ->where('user_id', $userId)
455 ->where('object_id', $feed->id)
456 ->get();
457
458 $removedIndexes = [];
459 $alreadyIndexes = [];
460
461 foreach ($previousVotes as $previousVote) {
462 if (!in_array($previousVote->object_type, $newVoteIndexes)) {
463 // This vote need to be deleted
464 $removedIndexes[] = $previousVote->object_type;
465 $previousVote->delete();
466 } else {
467 $alreadyIndexes[] = $previousVote->object_type;
468 }
469 }
470
471 $newSyncIndexes = array_diff($newVoteIndexes, $alreadyIndexes);
472
473 foreach ($newSyncIndexes as $newSyncIndex) {
474 Reaction::create([
475 'user_id' => $userId,
476 'object_id' => $feed->id,
477 'type' => 'survey_vote',
478 'object_type' => $newSyncIndex
479 ]);
480 }
481
482 if (!empty($newSyncIndexes)) {
483 do_action('fluent_community/feed/cast_survey_vote', $newSyncIndexes, $feed, $userId);
484 }
485
486 foreach ($surveyConfig['options'] as $index => $option) {
487 $slug = $option['slug'];
488
489 if (in_array($slug, $removedIndexes)) {
490 $newCount = (int)Arr::get($option, 'vote_counts', 0) - 1;
491 $option['vote_counts'] = $newCount > 0 ? $newCount : 0;
492 } else if (in_array($slug, $newSyncIndexes)) {
493 $newCount = (int)Arr::get($option, 'vote_counts', 0) + 1;
494 $option['vote_counts'] = $newCount > 0 ? $newCount : 0;
495 }
496
497 $surveyConfig['options'][$index] = $option;
498 }
499
500 $surveyConfig = apply_filters('fluent_community/feed/updated_survey_config', $surveyConfig, $feed, $userId);
501
502 $meta = $feed->meta;
503 $meta['survey_config'] = $surveyConfig;
504 $feed->meta = $meta;
505 $feed->save();
506
507 Utility::forgetCache('survey_cast_' . $feed->id . '_' . $userId);
508
509 return $feed;
510 }
511
512 /**
513 * Create a new feed programmatically
514 * @param array $allData
515 * @return \FluentCommunity\App\Models\Feed|\WP_Error
516 **/
517 public static function createFeed($allData)
518 {
519 if (!is_array($allData)) {
520 return new \WP_Error('invalid_data', __('Invalid data. The data need to be array', 'fluent-community'), ['status' => 400]);
521 }
522
523 $acceptedKeys = ['message', 'title', 'user_id', 'space_id', 'type'];
524 $feedData = Arr::only($allData, $acceptedKeys);
525
526 // Let's validate the data
527 $validation = Validator::make($feedData, [
528 'message' => 'required',
529 'title' => 'nullable|string',
530 'user_id' => 'required|integer|exists:users,ID',
531 'space_id' => 'nullable|integer|exists:fcom_spaces,id'
532 ]);
533
534 if ($validation->fails()) {
535 return new \WP_Error('validation_failed', __('Validation failed', 'fluent-community'), $validation->errors());
536 }
537
538 $sanitizedData = self::sanitizeAndValidateData($feedData);
539
540 $feedData = wp_parse_args($sanitizedData, $feedData);
541
542 $user = User::find($feedData['user_id']);
543
544 if (!$user) {
545 return new \WP_Error('user_not_found', __('User not found', 'fluent-community'), ['status' => 400]);
546 }
547 $user->syncXProfile();
548 if ($user->xprofile->status != 'active') {
549 return new \WP_Error('user_inactive', __('User status is not active', 'fluent-community'), $validation->errors());
550 }
551
552 $markdown = $feedData['message'];
553 $mentions = null;
554
555 // Extra Validaton for space_id
556 if (!empty($feedData['space_id'])) {
557 if (!Helper::isUserInSpace($feedData['user_id'], $feedData['space_id'])) {
558 return new \WP_Error('invalid_space', __('User is not in the space', 'fluent-community'), ['status' => 400]);
559 }
560 $mentions = FeedsHelper::getMentions($markdown, Arr::get($feedData, 'space_id'), true);
561 if ($mentions) {
562 $markdown = $mentions['text'];
563 }
564 } else if (!Helper::hasGlobalPost()) {
565 return new \WP_Error('global_post_disabled', 'User is not allowed to post in global', ['status' => 400]);
566 }
567
568 $feedData['message_rendered'] = wp_kses_post(self::mdToHtml($markdown));
569 $feedData['status'] = 'published';
570
571 if (Arr::get($allData, 'meta.media_preview.provider') == 'inline') {
572 $allData['meta']['media_preview']['provider'] = 'giphy';
573 }
574
575 [$feedData, $mediaItems] = self::processFeedMetaData($feedData, $allData);
576
577 if ($mentions) {
578 $feedData['meta']['mentioned_user_ids'] = Arr::get($mentions, 'user_ids', []);
579 }
580
581 $data = apply_filters('fluent_community/feed/new_feed_data', $feedData, $allData);
582
583 if (is_wp_error($data)) {
584 return $data;
585 }
586
587 $feed = new Feed();
588 $feed->fill($data);
589 $feed->save();
590
591 if ($mentions) {
592 do_action('fluent_community/feed_mentioned', $feed, $mentions['users']);
593 }
594
595 if ($mediaItems) {
596 foreach ($mediaItems as $media) {
597 $media->feed_id = $feed->id;
598 $media->is_active = 1;
599 $media->object_source = 'feed';
600 $media->save();
601 }
602 }
603
604 do_action('fluent_community/feed/created', $feed);
605
606 if ($feed->space_id) {
607 do_action('fluent_community/space_feed/created', $feed);
608 }
609
610 return $feed;
611 }
612
613 public static function sanitizeAndValidateData($data)
614 {
615 $message = CustomSanitizer::unslashMarkdown(trim((string) Arr::get($data, 'message', '')));
616
617 // Decode HTML entities and strip all whitespace for validation
618 $messageForValidation = html_entity_decode($message, ENT_QUOTES | ENT_HTML5, 'UTF-8');
619 $messageForValidation = preg_replace('/\s+/u', '', $messageForValidation);
620
621 if (!$messageForValidation) {
622 throw new UnprocessableEntityHttpException(
623 esc_html__('Message is required', 'fluent-community'),
624 'feed_message_required'
625 );
626 }
627
628 $processedData = [
629 'message' => $message,
630 'type' => 'text'
631 ];
632
633 $survey = Arr::get($data, 'survey', []);
634
635 if ($survey) {
636 $options = Arr::get($survey, 'options', []);
637 $formattedOptions = [];
638 foreach ($options as $index => $option) {
639 if (empty($option['label'])) {
640 continue;
641 }
642
643 $formattedOptions[] = [
644 'label' => sanitize_text_field($option['label']),
645 'slug' => Arr::get($option, 'slug') ?: 'opt_' . ($index + 1)
646 ];
647 }
648
649 $endDate = Arr::get($survey, 'end_date', '');
650 if ($endDate) {
651 $endDate = gmdate('Y-m-d H:i:s', strtotime($endDate));
652 } else {
653 $endDate = '';
654 }
655
656 if ($formattedOptions) {
657 $processedData['survey'] = [
658 'type' => Arr::get($survey, 'type') == 'single_choice' ? 'single_choice' : 'multi_choice',
659 'options' => $formattedOptions,
660 'end_date' => $endDate
661 ];
662 }
663 }
664
665 $maxlen = apply_filters('fluent_community/max_post_length', 15000);
666 if (\strlen($message) > $maxlen) {
667 throw new UnprocessableEntityHttpException(
668 /* translators: %s is the maximum allowed character count */
669 esc_html(sprintf(__('The post is too long. Please keep it under %s characters.', 'fluent-community'), number_format($maxlen))),
670 'feed_message_too_long'
671 );
672 }
673
674 $titlePref = Utility::postTitlePref();
675
676 if ($titlePref) {
677 $processedData['title'] = sanitize_text_field(Arr::get($data, 'title'));
678 if ($titlePref == 'required' && empty($processedData['title'])) {
679 throw new UnprocessableEntityHttpException(
680 esc_html__('Title is required. Please provide a title', 'fluent-community'),
681 'feed_title_required'
682 );
683 }
684 // trim the title if it's too long to 192 chars (multibyte-safe; column is VARCHAR(192) characters)
685 if (mb_strlen($processedData['title']) > 192) {
686 $processedData['title'] = mb_substr($processedData['title'], 0, 192, 'UTF-8');
687 }
688 }
689
690 return $processedData;
691 }
692
693 public static function getSurveyOptionsUpdateError($existingSurveyOptions, $submittedSurvey)
694 {
695 if (empty($existingSurveyOptions) || empty($submittedSurvey)) {
696 return null;
697 }
698
699 $submittedLabelsBySlug = [];
700 foreach (Arr::get($submittedSurvey, 'options', []) as $option) {
701 $slug = Arr::get($option, 'slug', '');
702 if ($slug !== '') {
703 $submittedLabelsBySlug[$slug] = trim((string)Arr::get($option, 'label', ''));
704 }
705 }
706
707 foreach ($existingSurveyOptions as $existingOption) {
708 $slug = Arr::get($existingOption, 'slug', '');
709 if ($slug === '') {
710 continue;
711 }
712
713 if (!isset($submittedLabelsBySlug[$slug]) || $submittedLabelsBySlug[$slug] === '') {
714 return __('Existing poll options cannot be removed or left empty.', 'fluent-community');
715 }
716 }
717
718 return null;
719 }
720
721 public static function transformForEdit($feed)
722 {
723 $topicsConfig = Helper::getTopicsConfig();
724
725 $terms = $feed->terms;
726 $feed->topic_ids = $terms->where('taxonomy_name', 'post_topic')->pluck('id')->toArray();
727 if ($topicsConfig['max_topics_per_post'] == 1) {
728 if ($feed->topic_ids) {
729 $feed->topic_ids = Arr::first($feed->topic_ids);
730 } else {
731 $feed->topic_ids = '';
732 }
733 }
734
735 if (Arr::get($feed->meta, 'send_announcement_email') == 'yes') {
736 $feed->send_announcement_email = 'yes';
737 }
738
739 if ($feed->content_type == 'document') {
740 $documents = Media::where('object_source', 'space_document')
741 ->where('feed_id', $feed->id)
742 ->where('is_active', 1)
743 ->get();
744 $mediaIds = [];
745 foreach ($documents as $document) {
746 /** @var Media $document */
747 $mediaIds[] = $document->getPrivateFileMeta();
748 }
749 $feed->document_ids = $mediaIds;
750 $feed->load('space');
751 return $feed;
752 }
753
754 $surveyConfig = Arr::get($feed->meta, 'survey_config', []);
755
756 if ($surveyConfig) {
757 $feed->survey = [
758 'type' => Arr::get($surveyConfig, 'type'),
759 'options' => Arr::get($surveyConfig, 'options', []),
760 'end_date' => Arr::get($surveyConfig, 'end_date', '')
761 ];
762 }
763
764 $mediaImages = Arr::get($feed->meta, 'media_items', []);
765 $meta = $feed->meta;
766 unset($feed->meta);
767
768 if ($mediaImages) {
769 $feed->media_images = $mediaImages;
770 } else if ($mediaPreview = Arr::get($meta, 'media_preview')) {
771 $type = Arr::get($mediaPreview, 'type');
772 if ($type == 'oembed' || $type == 'iframe_html') {
773 $feed->media = $mediaPreview;
774 }
775
776 // Only fetch the specific attached media, not all media (which would include inline images).
777 $mediaId = Arr::get($mediaPreview, 'media_id');
778 if ($mediaId && $type != 'oembed' && $type != 'iframe_html') {
779 $media = Media::where('id', $mediaId)
780 ->where('feed_id', $feed->id)
781 ->where('is_active', 1)
782 ->first();
783
784 if ($media) {
785 $feed->media_images = [[
786 'url' => $media->public_url,
787 'type' => 'image',
788 'media_id' => $media->id,
789 'width' => Arr::get($media->settings, 'width'),
790 'height' => Arr::get($media->settings, 'height'),
791 'provider' => Arr::get($media->settings, 'provider', 'uploader')
792 ]];
793 }
794 } else if ($type != 'meta_data') {
795 $feed->meta = $meta;
796 }
797 }
798
799 // Preserve multi-audio so the edit composer can load, edit/remove, and re-save them
800 // (transformForEdit otherwise drops meta for audio-only posts).
801 $audioMedias = Arr::get($meta, 'audio_medias', []);
802 if ($audioMedias) {
803 $editMeta = (isset($feed->meta) && is_array($feed->meta)) ? $feed->meta : [];
804 $editMeta['audio_medias'] = $audioMedias;
805 $feed->meta = $editMeta;
806 }
807
808 $feed->load('space');
809 return $feed;
810 }
811
812 /**
813 * Whether the current request may attach a raw "HTML Code" (iframe_html) embed.
814 *
815 * Mirrors the frontend rule in _VideoEmbeder.vue, which exposes that editor tab only
816 * when is_admin is true — i.e. community_moderator globally or within the target
817 * space. Programmatic creation is judged on the supplied author's permission rather
818 * than the HTTP session, so integrations work without a logged-in user. Defaults to
819 * denying when no user can be established at all.
820 *
821 * @param array $requestData Raw request payload.
822 * @param array $data Feed data being assembled.
823 * @param \FluentCommunity\App\Models\Feed|null $existingFeed Set when editing.
824 * @return bool
825 */
826 private static function canEmbedRawHtml($requestData, $data, $existingFeed = null)
827 {
828 // FeedsController::store()/update() already resolved this against the target space.
829 $precomputed = Arr::get($requestData, 'is_admin');
830 if ($precomputed !== null) {
831 return (bool)$precomputed;
832 }
833
834 // Every other caller resolves it here, against the post's author where one has
835 // been established server-side (createFeed() takes user_id from its caller), and
836 // the current user otherwise. Read from $data and never $requestData: the author
837 // is assigned by the controller, so a request cannot nominate whose permission
838 // gets checked.
839 $userId = (int)Arr::get($data, 'user_id');
840 if (!$userId) {
841 $userId = get_current_user_id();
842 }
843
844 $user = $userId ? User::find($userId) : null;
845 if (!$user) {
846 return false;
847 }
848
849 $space = null;
850 if ($existingFeed) {
851 $space = $existingFeed->space;
852 } elseif ($spaceId = (Arr::get($data, 'space_id') ?: Arr::get($requestData, 'space_id'))) {
853 $space = BaseSpace::find($spaceId);
854 }
855
856 return (bool)$user->hasPermissionOrInCurrentSpace('community_moderator', $space);
857 }
858
859 public static function processFeedMetaData($data, $requestData, $existingFeed = null)
860 {
861 if (empty($data['meta'])) {
862 $data['meta'] = [];
863 }
864
865 $uplaodedDocs = [];
866 // Handle Survey
867 if (!empty($data['survey'])) {
868 $surveyConfig = $data['survey'];
869 if ($existingFeed) {
870 $surveyConfig = Arr::get($existingFeed->meta, 'survey_config', []);
871 if ($surveyConfig) {
872 $oldOptions = Arr::get($surveyConfig, 'options', []);
873 $newOptions = Arr::get($data['survey'], 'options', []);
874 $oldKeyedOptions = [];
875 foreach ($oldOptions as $option) {
876 $oldKeyedOptions[$option['slug']] = $option;
877 }
878 foreach ($newOptions as $index => $option) {
879 $slug = Arr::get($option, 'slug', '');
880 if (isset($oldKeyedOptions[$slug])) {
881 $newOptions[$index]['vote_counts'] = Arr::get($oldKeyedOptions[$slug], 'vote_counts', 0);
882 }
883 }
884 $surveyConfig['options'] = $newOptions;
885 } else {
886 $surveyConfig = $data['survey'];
887 }
888 }
889
890 if ($endDate = Arr::get($data['survey'], 'end_date', '')) {
891 $surveyConfig['end_date'] = gmdate('Y-m-d H:i:s', strtotime($endDate));
892 } else {
893 $surveyConfig['end_date'] = '';
894 }
895
896 $data['meta']['survey_config'] = $surveyConfig;
897 $data['content_type'] = 'survey';
898 unset($data['survey']);
899 }
900
901 // Handle Giphy
902 if (Arr::get($requestData, 'meta.media_preview.provider') == 'giphy') {
903 $url = Arr::get($requestData, 'meta.media_preview.image');
904 if (!$url || !filter_var($url, FILTER_VALIDATE_URL)) {
905 return [$data, $uplaodedDocs];
906 }
907
908 $data['meta']['media_preview'] = array_filter([
909 'image' => sanitize_url($url),
910 'type' => sanitize_text_field(Arr::get($requestData, 'meta.media_preview.type', 'image')),
911 'provider' => 'giphy',
912 'height' => (int)Arr::get($requestData, 'meta.media_preview.height', 0),
913 'width' => (int)Arr::get($requestData, 'meta.media_preview.width', 0),
914 ]);
915
916 return [$data, $uplaodedDocs];
917 }
918
919 // Handling Video Embed
920 if (
921 Arr::get($requestData, 'media') &&
922 (
923 (Arr::get($requestData, 'media.type') == 'oembed' && Arr::get($requestData, 'media.player') != 'fluent_player') ||
924 Arr::get($requestData, 'media.type') == 'iframe_html'
925 )
926 ) {
927 if (Arr::get($requestData, 'media.type') == 'iframe_html') {
928 // The UI only offers the "HTML Code" embed to moderators
929 // (_VideoEmbeder.vue passes has_iframe="is_admin"). That is a hint, not a
930 // control, so the same rule is enforced here. Reaching this branch without
931 // the permission means the field was posted straight to the REST API, so
932 // the embed is dropped rather than stored.
933 if (!self::canEmbedRawHtml($requestData, $data, $existingFeed)) {
934 return [$data, $uplaodedDocs];
935 }
936
937 $mediaPreview = array_filter(Arr::get($requestData, 'media', []));
938
939 // Moderators are trusted to embed, not to bypass sanitization: the markup
940 // still goes through the same allowlist the oembed branch below uses.
941 if (!empty($mediaPreview['html'])) {
942 $mediaPreview['html'] = RemoteUrlParser::sanitizeOembedHtml($mediaPreview['html']);
943
944 // Keep only if a usable <iframe> survived; else it renders as junk.
945 if (stripos($mediaPreview['html'], '<iframe') === false) {
946 unset($mediaPreview['html']);
947 }
948
949 $mediaPreview = array_filter($mediaPreview);
950 }
951
952 if (empty($mediaPreview['image']) && !empty($mediaPreview['html'])) {
953 $thumb = RemoteUrlParser::extractIframeThumbnail($mediaPreview['html']);
954 if ($thumb) {
955 $mediaPreview['image'] = $thumb;
956 }
957 }
958
959 // Nothing usable survived; skip storing a broken preview.
960 if (empty($mediaPreview['html']) && empty($mediaPreview['image'])) {
961 return [$data, $uplaodedDocs];
962 }
963
964 $data['meta']['media_preview'] = $mediaPreview;
965 return [$data, $uplaodedDocs];
966 }
967
968 $media = Arr::get($requestData, 'media');
969 $url = Arr::get($media, 'url');
970 $metaData = RemoteUrlParser::parse($url);
971 if ($metaData && !is_wp_error($metaData) && (!empty($metaData['image']) || !empty($metaData['html']))) {
972 $data['meta']['media_preview'] = $metaData;
973 }
974
975 return [$data, $uplaodedDocs];
976 }
977
978 // Let's handle the uploaded media
979 $mediaImages = Arr::get($requestData, 'media_images', []);
980 if ($mediaImages) {
981 $uploadedImages = Helper::getMediaByProvider($mediaImages);
982 if (!$existingFeed) {
983 $uploadedMediaItems = Helper::getMediaItemsFromUrl($uploadedImages);
984 } else {
985 $uploadedMediaItems = [];
986 foreach ($mediaImages as $mediaImage) {
987 $url = sanitize_url(Arr::get($mediaImage, 'url', ''));
988 if (!$url) {
989 continue;
990 }
991 $mediaItem = Helper::getMediaFromUrl($mediaImage);
992 if ($mediaItem) {
993 $uploadedMediaItems[] = $mediaItem;
994 } else {
995 // maybe this is a previously uploaded image
996 $media = Media::where('media_url', $url)
997 ->where('object_source', 'feed')
998 ->where('feed_id', $existingFeed->id)
999 ->where('is_active', 1)
1000 ->first();
1001
1002 if ($media) {
1003 $uploadedMediaItems[] = $media;
1004 }
1005 }
1006 }
1007 }
1008
1009 if (count($uploadedMediaItems) == 1) {
1010 $singleMedia = $uploadedMediaItems[0];
1011 $data['meta']['media_preview'] = [
1012 'is_uploaded' => true,
1013 'image' => $singleMedia->public_url,
1014 'type' => 'meta_data',
1015 'provider' => 'uploader',
1016 'width' => Arr::get($singleMedia->settings, 'width'),
1017 'height' => Arr::get($singleMedia->settings, 'height'),
1018 'media_id' => $singleMedia->id,
1019 ];
1020 } else if ($uploadedMediaItems) {
1021 $mediaPreviews = [];
1022 foreach ($uploadedMediaItems as $mediaItem) {
1023 $mediaData = [
1024 'media_id' => $mediaItem->id,
1025 'url' => $mediaItem->public_url,
1026 'type' => 'image',
1027 'width' => Arr::get($mediaItem->settings, 'width'),
1028 'height' => Arr::get($mediaItem->settings, 'height'),
1029 'provider' => Arr::get($mediaItem->settings, 'provider', 'uploader')
1030 ];
1031 $mediaPreviews[] = array_filter($mediaData);
1032 }
1033 $data['meta']['media_items'] = $mediaPreviews;
1034 }
1035
1036 $maxMediaPerPost = apply_filters('fluent_community/max_media_per_post', Utility::getCustomizationSetting('max_media_per_post'));
1037
1038 $allMediaItems = array_slice($uploadedMediaItems, 0, (int)$maxMediaPerPost);
1039
1040 return [$data, $allMediaItems];
1041 }
1042
1043 if ($existingFeed && Arr::get($existingFeed->meta, 'auto_flagged') == 'yes') {
1044 $data['meta']['auto_flagged'] = 'yes';
1045 $data['meta']['prevent_published'] = 'yes';
1046 $data['meta']['reports_count'] = Arr::get($existingFeed->meta, 'reports_count', 0);
1047 }
1048
1049 // Let's handle the fallback here
1050 $firstUrl = FeedsHelper::findFirstUrl(Arr::get($data, 'message_rendered'));
1051
1052 // check if this is another post or not
1053 if (strpos($firstUrl, Helper::baseUrl()) === 0) {
1054 // this is an internal URL
1055 if (Helper::getRouteNameByRequestPath($firstUrl) === 'feed_view') {
1056 $uriParts = explode('/', $firstUrl);
1057 if (count($uriParts) >= 2) {
1058 $postSlug = end($uriParts);
1059 $feed = Feed::where('slug', $postSlug)->first();
1060 if ($feed) {
1061 $firstUrl = null;
1062 $data['meta']['custom_app_preview'] = [
1063 'app_name' => 'child_post',
1064 'feed_id' => $feed->id
1065 ];
1066 }
1067 }
1068 }
1069 }
1070
1071 if ($firstUrl) {
1072 $metaData = RemoteUrlParser::parse($firstUrl);
1073 if ($metaData && !is_wp_error($metaData) && (!empty($metaData['image']) || !empty($metaData['html']))) {
1074 $data['meta']['media_preview'] = $metaData;
1075 }
1076 }
1077
1078 $uplaodedDocs = apply_filters('fluent_community/feed/uploaded_feed_medias', $uplaodedDocs, $requestData);
1079 return [$data, $uplaodedDocs];
1080 }
1081
1082 protected static function tranformFeedData(Feed $feed, $config = [])
1083 {
1084 $userId = Arr::get($config, 'user_id', 0);
1085 $commentLikeIds = $userId ? Arr::get($config, 'comment_like_ids', []) : [];
1086
1087 $feed->comments->each(function ($comment) use ($commentLikeIds) {
1088 self::setCurrentRelatedUserId($comment->user_id);
1089 if ($commentLikeIds && in_array($comment->id, $commentLikeIds)) {
1090 $comment->liked = 1;
1091 }
1092 });
1093
1094 // User-specific processing
1095 if ($userId) {
1096 $interactions = Arr::get($config, 'interactions', []);
1097
1098 if ($interactions) {
1099 $feed->has_user_react = Arr::get($interactions, 'like', false);
1100 $feed->bookmarked = Arr::get($interactions, 'bookmark', false);
1101 }
1102
1103 if ($feed->content_type == 'survey') {
1104 $votedOptions = $feed->getSurveyCastsByUserId($userId);
1105 if ($votedOptions) {
1106 $surveyConfig = Arr::get($feed->meta, 'survey_config', []);
1107 foreach ($surveyConfig['options'] as $index => $option) {
1108 if (in_array($option['slug'], $votedOptions)) {
1109 $surveyConfig['options'][$index]['voted'] = true;
1110 }
1111 }
1112 $meta = $feed->meta;
1113 $meta['survey_config'] = $surveyConfig;
1114 $feed->meta = $meta;
1115 }
1116 }
1117 }
1118
1119 if ($feed->content_type == 'document') {
1120 $feedMeta = $feed->meta;
1121 $documentLists = Arr::get($feedMeta, 'document_lists', []);
1122 foreach ($documentLists as $index => $document) {
1123 $documentLists[$index]['url'] = Helper::baseUrl('?fcom_action=download_document&media_key=' . $document['media_key'] . '&media_id=' . $document['id']);
1124 }
1125 $feedMeta['document_lists'] = $documentLists;
1126 $feed->meta = $feedMeta;
1127 }
1128
1129 $spaceSettings = $feed->space ? $feed->space->settings : [];
1130 $feed->default_comment_sort_by = Arr::get($spaceSettings, 'default_comment_sort_by', '');
1131
1132 // Feed::withPublicRelations() eager-loads the space with its raw settings, and
1133 // those settings carry links scoped to logged-in members or to specific
1134 // memberships. BaseSpace::formatSpaceData() filters them for the space
1135 // endpoints; nothing filtered them here, so every feed response handed all of
1136 // a space's links - titles and URLs - to any caller, anonymous included.
1137 if ($feed->space && Arr::get($spaceSettings, 'links')) {
1138 $currentUser = Helper::getCurrentUser();
1139
1140 $spaceSettings['links'] = Helper::filterAccessibleLinks(
1141 Arr::get($spaceSettings, 'links', []),
1142 $currentUser ? $currentUser : null
1143 );
1144
1145 $feed->space->settings = $spaceSettings;
1146 }
1147
1148 self::setCurrentRelatedUserId($feed->user_id);
1149
1150 return apply_filters('fluent_community/rendering_feed_model', $feed, $config);
1151 }
1152
1153 public static function transformFeed(Feed $feed)
1154 {
1155 $userId = get_current_user_id();
1156
1157 $config = apply_filters('fluent_community/feed_general_config', [
1158 'user_id' => $userId,
1159 'interactions' => [],
1160 'comment_like_ids' => [],
1161 'is_collection' => false
1162 ], $feed, $userId);
1163
1164 if ($userId) {
1165 $config['interactions'] = [
1166 'like' => $feed->hasUserReact($userId, 'like'),
1167 'bookmark' => $feed->hasUserReact($userId, 'bookmark'),
1168 ];
1169 $config['comment_like_ids'] = self::getLikedIdsByUserFeedId($feed->id, $userId);
1170 }
1171
1172 return self::tranformFeedData($feed, $config);
1173 }
1174
1175 public static function transformFeedsCollection($feeds)
1176 {
1177 if ($feeds->isEmpty()) {
1178 return $feeds;
1179 }
1180
1181 $userId = get_current_user_id();
1182 $commentLikeIds = [];
1183 $formattedInteractions = [];
1184 $feedIds = $feeds->pluck('id')->toArray();
1185
1186 if ($userId) {
1187 $interactions = Reaction::query()
1188 ->select(['user_id', 'type', 'object_id'])
1189 ->whereIn('object_id', $feedIds)
1190 ->where('object_type', 'feed')
1191 ->where('user_id', $userId)
1192 ->whereIn('type', ['like', 'bookmark'])
1193 ->get();
1194
1195 $formattedInteractions = [];
1196 foreach ($interactions as $interaction) {
1197 $objectId = (int)$interaction->object_id;
1198
1199 if (!isset($formattedInteractions[$objectId])) {
1200 $formattedInteractions[$objectId] = [];
1201 }
1202 $formattedInteractions[$objectId][$interaction->type] = true;
1203 }
1204
1205 $commentLikeIds = Reaction::select('object_id')
1206 ->where('object_type', 'comment')
1207 ->whereIn('parent_id', $feedIds)
1208 ->where('user_id', $userId)
1209 ->get()
1210 ->pluck('object_id')
1211 ->toArray();
1212 }
1213
1214 $generalConfig = apply_filters('fluent_community/feed_general_config', [
1215 'user_id' => $userId,
1216 'interactions' => [],
1217 'comment_like_ids' => $commentLikeIds,
1218 'is_collection' => true
1219 ], $feeds, $feedIds);
1220
1221 $feeds->each(function ($feed) use ($userId, $generalConfig, $formattedInteractions) {
1222 $config = $generalConfig;
1223 if ($userId) {
1224 $config['interactions'] = Arr::get($formattedInteractions, $feed->id, []);
1225 }
1226 return self::tranformFeedData($feed, $config);
1227 });
1228
1229 return $feeds;
1230 }
1231
1232 public static function getMediaHtml($meta, $postPermalink)
1233 {
1234 $mediaImage = Arr::get($meta, 'media_preview.image');
1235 $mediaCount = 0;
1236 if (!$mediaImage) {
1237 $mediaItems = Arr::get($meta, 'media_items', []);
1238 if ($mediaItems) {
1239 $mediaImage = Arr::get($mediaItems[0], 'url');
1240 $mediaCount = count($mediaItems);
1241 }
1242 }
1243
1244 $feedHtml = '';
1245
1246 if ($mediaImage) {
1247 $feedHtml .= '<div class="fcom_media" style="margin-top: 20px;">';
1248 $feedHtml .= '<a href="' . $postPermalink . '"><img src="' . $mediaImage . '" alt="" style="max-width: 100%; height: auto; display: block; margin: 0 auto 0px;" /></a>';
1249 if ($mediaCount > 1) {
1250 /* translators: %d is the number of additional images not shown in the preview. */
1251 $feedHtml .= '<p style="text-align: center; font-size: 14px; color: #666; margin-top: 10px;">' . sprintf(_n('+%d more image', '+%d more images', $mediaCount - 1, 'fluent-community'), $mediaCount - 1) . '</p>';
1252 }
1253 $feedHtml .= '</div>';
1254 }
1255
1256 return $feedHtml;
1257 }
1258
1259 public static function hasEveryoneTag($message)
1260 {
1261 // Updated regular expression to match @everyone with more flexibility
1262 $pattern = '/(?<=^|\W)@everyone(?=\W|\z)/iu';
1263
1264 return preg_match($pattern, $message) === 1;
1265 }
1266
1267 public static function replaceImageUrlsWithRealMediaArchive($markdown, $existingFeed = null)
1268 {
1269 $imageUrls = self::getInlineImageUrls($markdown);
1270
1271 if (!$imageUrls) {
1272 return [$markdown, []];
1273 }
1274
1275 $mediaItems = [];
1276
1277 foreach ($imageUrls as $url) {
1278 $url = sanitize_url($url);
1279 $media = Helper::getMediaFromUrl($url);
1280 if ($media) {
1281 if ($media->is_active && (!$existingFeed || $media->feed_id != $existingFeed->id) ) {
1282 continue;
1283 }
1284
1285 $realUrl = $media->public_url;
1286 $markdown = str_replace($url, $realUrl, $markdown);
1287 $mediaItems[] = $media;
1288 }
1289 }
1290
1291 return [$markdown, $mediaItems];
1292 }
1293
1294 private static function getInlineImageUrls($markdown)
1295 {
1296 $urls = [];
1297 // Match ![alt](url) and ![alt](url "title")
1298 if (preg_match_all('/!\[[^\]]*\]\(([^\s\)]+)(?:\s+"[^"]*")?\)/', $markdown, $matches)) {
1299 $urls = array_merge($urls, $matches[1]);
1300 }
1301
1302 // Match reference-style image usage: ![alt][id] and map only those IDs to their definitions [id]: url
1303 if (preg_match_all('/!\[[^\]]*\]\[([^\]]+)\]/', $markdown, $imageRefMatches)) {
1304 $usedIds = array_unique($imageRefMatches[1]);
1305
1306 if (preg_match_all('/^\[([^\]]+)\]:\s+(\S+)/m', $markdown, $refMatches)) {
1307 $refMap = [];
1308 foreach ($refMatches[1] as $index => $id) {
1309 $refMap[$id] = $refMatches[2][$index];
1310 }
1311
1312 foreach ($usedIds as $id) {
1313 if (isset($refMap[$id])) {
1314 $urls[] = $refMap[$id];
1315 }
1316 }
1317 }
1318 }
1319
1320 // Match HTML <img> tags
1321 if (preg_match_all('/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $markdown, $matches)) {
1322 $urls = array_merge($urls, $matches[1]);
1323 }
1324
1325 return array_unique($urls);
1326 }
1327 }
1328