PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.9.1
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.9.1
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.9.1, at app/Services/FeedsHelper.php

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