PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.7.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.7.0
2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 1.1.0 All 77 releases
fluent-community / app / Services / FeedsHelper.php

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

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