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

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