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