# fluent-community/trunk/app/Services/FeedsHelper.php

FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS &amp; Online Courses, version trunk. 1,328 lines.

- Page: https://pluginprobe.com/plugins/fluent-community/trunk/code/app/Services/FeedsHelper.php
- Raw: https://pluginprobe.com/plugins/fluent-community/trunk/raw/app/Services/FeedsHelper.php
- Modified: 2026-09-14T14:31:46+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/fluent-community/trunk/code/app/Services/FeedsHelper.php#L10-L20`.

```php
<?php

namespace FluentCommunity\App\Services;

use FluentCommunity\App\Functions\Utility;
use FluentCommunity\App\Models\BaseSpace;
use FluentCommunity\App\Models\Feed;
use FluentCommunity\App\Models\Media;
use FluentCommunity\App\Models\Reaction;
use FluentCommunity\App\Models\Term;
use FluentCommunity\App\Models\User;
use FluentCommunity\App\Models\XProfile;
use FluentCommunity\Framework\Foundation\Exceptions\UnprocessableEntityHttpException;
use FluentCommunity\Framework\Support\Arr;
use FluentCommunity\Framework\Validator\Validator;

class FeedsHelper
{
    static protected $currentRelatedUserIds = [];

    public static function setCurrentRelatedUserId($userId)
    {
        self::$currentRelatedUserIds[] = $userId;
    }

    public static function getCurrentRelatedUserIds()
    {
        return array_values(array_unique(self::$currentRelatedUserIds));
    }

    /**
     * Resolve who should receive the "post author" notification for a feed.
     * Course lessons notify the COURSE creator (whoever created the course),
     * not the user who uploaded the individual lesson.
     */
    public static function getNotificationAuthorId($feed)
    {
        if ($feed->type === 'course_lesson' && $feed->space_id) {
            $course = BaseSpace::withoutGlobalScopes()->find($feed->space_id);
            if ($course && $course->created_by) {
                return (int) $course->created_by;
            }
        }

        return (int) $feed->user_id;
    }

    public static function getSpaceSlugsByUserId($userId)
    {
        if (!$userId) {
            $userId = get_current_user_id();
        }

        if (!$userId) {
            return [];
        }

        $user = User::find($userId);

        return $user->spaces()->pluck('slug')->toArray();
    }

    /**
     * Statuses where a post is fully reachable by its direct link. An unlisted post is
     * hidden from listings only, so it stays commentable and reactable like a published one.
     *
     * @return array
     */
    public static function getViewableByLinkStatuses()
    {
        return ['published', 'unlisted'];
    }

    /**
     * Row types that opt IN to comments through meta.enable_comments, mapped to the value
     * assumed when the key is absent.
     *
     * A feed post uses the opposite convention - meta.comments_disabled, absent meaning on -
     * so it is deliberately not listed here and falls through to the permissive default.
     *
     * The fallbacks match each model's getDefaultMeta(): a lesson written before the
     * setting existed keeps its thread, a page does not. Guessing one value for both
     * would silently switch off every legacy lesson discussion.
     *
     * @return array<string, string>
     */
    public static function getOptInCommentTypes()
    {
        return apply_filters('fluent_community/opt_in_comment_types', [
            'course_lesson' => 'yes',
            'space_page'    => 'no',
        ]);
    }

    /**
     * Whether a row accepts comments at all, by its own settings.
     *
     * This is the setting check only - it says nothing about who the current user is.
     * Space membership and the course level kill switch are separate, in
     * CommentsController::verifySpacePermission().
     *
     * Both the read and the write path go through here so they cannot disagree. They used
     * to: the write path only ever read meta.comments_disabled, which pages and lessons
     * do not set, so a POST landed a comment on a page whose thread the UI was hiding.
     *
     * @param  \FluentCommunity\App\Models\Feed $feed
     * @return bool
     */
    public static function commentsEnabled($feed)
    {
        $meta = $feed->meta;

        if (Arr::get($meta, 'comments_disabled') === 'yes') {
            return false;
        }

        $optIn = self::getOptInCommentTypes();

        if (isset($optIn[$feed->type])) {
            return Arr::get($meta, 'enable_comments', $optIn[$feed->type]) === 'yes';
        }

        return true;
    }

    public static function getLastFeedId()
    {
        $lastItem = Feed::where('status', 'published')
            ->byUserAccess(get_current_user_id())
            ->orderBy('id', 'DESC')
            ->first();

        if ($lastItem) {
            return $lastItem->id;
        }

        return 1;
    }

    public static function mdToHtml($text, $options = [])
    {
        if (!$text) {
            return '';
        }

        $text = str_replace('&#x20;', '', $text); // hide markdown empty content

        $html = (new \FluentCommunity\App\Services\Parsedown([
        ]))
            ->setBreaksEnabled(true)
            ->setUrlsLinked(false)
            //  ->setSafeMode(true)
            ->text($text);

        if (!Arr::get($options, 'disable_link_process')) {
            // add nofollow to all links. But check if nofollow is already there
            $html = self::addNoFollowToLinks($html);
        }

        $html = wp_kses($html, array(
            'p'          => array(),
            'br'         => array(),
            'strong'     => array(),
            'em'         => array(),
            'hr'         => array(),
            'h1'         => array(),
            'h2'         => array(),
            'h3'         => array(),
            'h4'         => array(),
            'h5'         => array(),
            'h6'         => array(),
            'ul'         => array(),
            'b'          => array(),
            'ol'         => array(),
            'li'         => array(),
            'span'       => array(),
            'a'          => array(
                'href'   => true,
                'title'  => true,
                'rel'    => true,
                'target' => true,
            ),
            'img'        => array(
                'src' => true,
                'alt' => true,
            ),
            'code'       => array(),
            'pre'        => array(),
            'blockquote' => array(),
            'del'        => array(),
            'table'      => array(),
            'thead'      => array(),
            'tbody'      => array(),
            'tfoot'      => array(),
            'tr'         => array(),
            'th'         => array(
                'align'   => true,
                'style'   => true,
                'colspan' => true,
                'rowspan' => true,
            ),
            'td'         => array(
                'align'   => true,
                'style'   => true,
                'colspan' => true,
                'rowspan' => true,
            ),
        ));

        return self::maybeTransformDynamicCodes($html);
    }

    public static function maybeTransformDynamicCodes($html)
    {
        // check if there has {{
        if (strpos($html, '{{') === false) {
            return $html;
        }

        return preg_replace_callback(
            '/{{utc:(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})}}/',
            function ($match) {
                // Extract the datetime string (e.g., 2025-06-01 15:06:59)
                $datetimeStr = $match[1];

                try {
                    // Create a DateTime object from the UTC string
                    $date = new \DateTime($datetimeStr, new \DateTimeZone('UTC'));
                    // Get the Unix timestamp for the data-timestamp attribute
                    $timestamp = $date->getTimestamp();
                    // Format the display string
                    $displayFormat = $date->format('d F Y, H:i') . ' (UTC)';

                    // Return the formatted HTML
                    return '<span class="fcom_dynamic_prop" data-type="timestamp" data-timestamp="' . $timestamp . '">' . $displayFormat . '</span>';
                } catch (\Exception $e) {
                    // Return original match if parsing fails
                    return $match[0];
                }
            },
            $html
        );
    }

    public static function addNoFollowToLinks($html)
    {
        if (!$html) {
            return '';
        }

        $current_domain = wp_parse_url(home_url(), PHP_URL_HOST);

        // Regular expression to match <a> tags
        $pattern = '/<a\s[^>]*href=("|\')(https?:\/\/(?!' . preg_quote($current_domain, '/') . ').*?)("|\')\s?([^>]*)>/i';

        // Callback function to modify each matched <a> tag
        $callback = function ($matches) {
            $url = $matches[2];
            $attr = $matches[4];

            // Remove existing rel attribute if present
            $attr = preg_replace('/\srel=("|\').*?("|\')/i', '', $attr);

            // Add nofollow
            return '<a href="' . $url . '" rel="nofollow" ' . trim($attr) . '>';
        };

        // Perform the replacement
        return preg_replace_callback($pattern, $callback, $html);
    }

    public static function addNewTabToLinks($html)
    {
        if (empty($html) || !is_string($html)) {
            return '';
        }

        // return is there has no href
        if (strpos($html, 'href=') === false) {
            return $html;
        }

        // More comprehensive regex to capture existing attributes
        $pattern = '/<a\s+([^>]*)>/i';

        // Callback function to modify each matched <a> tag
        $callback = function ($matches) {
            $full_tag = $matches[0];
            $attributes = $matches[1];

            // Extract href
            preg_match('/href=("|\')([^"\']+)("|\')/', $full_tag, $href_matches);
            if (empty($href_matches)) {
                return $full_tag;
            }
            $url = $href_matches[2];

            // Check if it's an external URL and not an image
            if (preg_match('/^https?:\/\//i', $url) && !preg_match('/\.(jpg|jpeg|png|gif|svg)$/i', $url)) {
                // Check if target already exists
                if (!preg_match('/\btarget=/i', $full_tag)) {
                    // Preserve existing attributes, add target="_blank"
                    return '<a ' . $attributes . ' target="_blank" rel="noopener noreferrer">';
                }
            }

            // Return original tag if no modification needed
            return $full_tag;
        };

        // Perform the replacement
        return preg_replace_callback($pattern, $callback, $html);
    }

    public static function findFirstUrl($html)
    {
        if (!preg_match_all('/<a\s+(?:[^>]*?\s+)?href=([\'"])(.*?)\1/i', $html, $matches)) {
            return '';
        }

        $profileUrlPrefix = Helper::baseUrl('u/');

        foreach ($matches[2] as $href) {
            // Rendered HTML encodes "&" as "&amp;". Left encoded, "?a=1&amp;b=2" is read
            // as a parameter named "amp;b" — which makes YouTube drop the "list" param.
            // Re-sanitized because decoding also restores quotes and angle brackets,
            // and this value is fetched remotely and stored on the feed.
            $href = sanitize_url(html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'));

            // sanitize_url() empties a disallowed scheme. Returning that would report
            // "no links" for the whole post and skip any later, usable link.
            if (!$href || strpos($href, $profileUrlPrefix) === 0) {
                continue;
            }
            return $href;
        }

        return '';
    }

    public static function extractHashTags($text, $limit = 5)
    {
        // Extract hashtag including - and _
        preg_match_all('/#([a-zA-Z0-9_-]+)/', $text, $matches);

        $tags = array_unique($matches[1]);

        if (!$tags) {
            return [];
        }

        $tags = array_slice($tags, 0, $limit);

        $lowerCaseTags = array_map('strtolower', $tags);

        $terms = Term::whereIn('slug', $lowerCaseTags)
            ->where('taxonomy_name', 'hashtag')
            ->get();

        $termIds = [];

        foreach ($terms as $term) {
            $termIds[$term->slug] = $term->id;
        }

        if (count($termIds) == count($tags)) {
            return array_values($termIds);
        }

        $excepts = array_diff($tags, array_keys($termIds));

        foreach ($excepts as $except) {
            $term = Term::create([
                'taxonomy_name' => 'hashtag',
                'slug'          => strtolower($except),
                'title'         => $except
            ]);

            $termIds[$term->slug] = $term->id;
        }

        return array_values($termIds);
    }

    public static function getMentions($text, $spaceId = null, $withUsers = false)
    {
        // the mention may have . or _ or - in the username
        preg_match_all('/@([a-zA-Z0-9_.-]+)/', $text, $matches);
        $mentions = array_unique($matches[1]);

        if (!$mentions) {
            return null;
        }

        if ($spaceId) {
            $xProfiles = XProfile::whereIn('username', $mentions)
                ->whereHas('spaces', function ($query) use ($spaceId) {
                    $query->withoutGlobalScopes()->where('space_id', $spaceId);
                })
                ->get();
        } else {
            $xProfiles = XProfile::whereIn('username', $mentions)
                ->get();
        }

        if ($xProfiles->isEmpty()) {
            return null;
        }

        $userMentions = [];

        $userIds = [];

        foreach ($xProfiles as $xProfile) {
            $userIds[] = $xProfile->user_id;
            $html = '<a data-user_name="' . $xProfile->username . '" class="fcom_mention fcom_route" href="' . Helper::baseUrl('u/' . $xProfile->username . '/') . '">' . $xProfile->display_name . '</a>';
            $userMentions['@' . $xProfile->username] = $html;
        }

        $data = [
            'user_ids' => $userIds,
            'text'     => strtr($text, $userMentions)
        ];

        if ($withUsers) {
            $data['users'] = User::whereIn('ID', $userIds)->get();
        }

        return $data;
    }

    public static function getLikedIdsByUserFeedId($feedId, $userId)
    {
        return Reaction::select('object_id')
            ->where('object_type', 'comment')
            ->where('parent_id', $feedId)
            ->where('user_id', $userId)
            ->get()
            ->pluck('object_id')
            ->toArray();
    }

    public static function castSurveyVote($newVoteIndexes, Feed $feed, $userId)
    {
        $surveyConfig = Arr::get($feed->meta, 'survey_config', []);

        $slugs = array_map(function ($item) {
            return $item['slug'];
        }, $surveyConfig['options']);

        $newVoteIndexes = array_filter(array_intersect($slugs, $newVoteIndexes));

        $previousVotes = Reaction::where('type', 'survey_vote')
            ->where('user_id', $userId)
            ->where('object_id', $feed->id)
            ->get();

        $removedIndexes = [];
        $alreadyIndexes = [];

        foreach ($previousVotes as $previousVote) {
            if (!in_array($previousVote->object_type, $newVoteIndexes)) {
                // This vote need to be deleted
                $removedIndexes[] = $previousVote->object_type;
                $previousVote->delete();
            } else {
                $alreadyIndexes[] = $previousVote->object_type;
            }
        }

        $newSyncIndexes = array_diff($newVoteIndexes, $alreadyIndexes);

        foreach ($newSyncIndexes as $newSyncIndex) {
            Reaction::create([
                'user_id'     => $userId,
                'object_id'   => $feed->id,
                'type'        => 'survey_vote',
                'object_type' => $newSyncIndex
            ]);
        }

        if (!empty($newSyncIndexes)) {
            do_action('fluent_community/feed/cast_survey_vote', $newSyncIndexes, $feed, $userId);
        }

        foreach ($surveyConfig['options'] as $index => $option) {
            $slug = $option['slug'];

            if (in_array($slug, $removedIndexes)) {
                $newCount = (int)Arr::get($option, 'vote_counts', 0) - 1;
                $option['vote_counts'] = $newCount > 0 ? $newCount : 0;
            } else if (in_array($slug, $newSyncIndexes)) {
                $newCount = (int)Arr::get($option, 'vote_counts', 0) + 1;
                $option['vote_counts'] = $newCount > 0 ? $newCount : 0;
            }

            $surveyConfig['options'][$index] = $option;
        }

        $surveyConfig = apply_filters('fluent_community/feed/updated_survey_config', $surveyConfig, $feed, $userId);

        $meta = $feed->meta;
        $meta['survey_config'] = $surveyConfig;
        $feed->meta = $meta;
        $feed->save();

        Utility::forgetCache('survey_cast_' . $feed->id . '_' . $userId);

        return $feed;
    }

    /**
     * Create a new feed programmatically
     * @param array $allData
     * @return \FluentCommunity\App\Models\Feed|\WP_Error
     **/
    public static function createFeed($allData)
    {
        if (!is_array($allData)) {
            return new \WP_Error('invalid_data', __('Invalid data. The data need to be array', 'fluent-community'), ['status' => 400]);
        }

        $acceptedKeys = ['message', 'title', 'user_id', 'space_id', 'type'];
        $feedData = Arr::only($allData, $acceptedKeys);

        // Let's validate the data
        $validation = Validator::make($feedData, [
            'message'  => 'required',
            'title'    => 'nullable|string',
            'user_id'  => 'required|integer|exists:users,ID',
            'space_id' => 'nullable|integer|exists:fcom_spaces,id'
        ]);

        if ($validation->fails()) {
            return new \WP_Error('validation_failed', __('Validation failed', 'fluent-community'), $validation->errors());
        }

        $sanitizedData = self::sanitizeAndValidateData($feedData);

        $feedData = wp_parse_args($sanitizedData, $feedData);

        $user = User::find($feedData['user_id']);

        if (!$user) {
            return new \WP_Error('user_not_found', __('User not found', 'fluent-community'), ['status' => 400]);
        }
        $user->syncXProfile();
        if ($user->xprofile->status != 'active') {
            return new \WP_Error('user_inactive', __('User status is not active', 'fluent-community'), $validation->errors());
        }

        $markdown = $feedData['message'];
        $mentions = null;

        // Extra Validaton for space_id
        if (!empty($feedData['space_id'])) {
            if (!Helper::isUserInSpace($feedData['user_id'], $feedData['space_id'])) {
                return new \WP_Error('invalid_space', __('User is not in the space', 'fluent-community'), ['status' => 400]);
            }
            $mentions = FeedsHelper::getMentions($markdown, Arr::get($feedData, 'space_id'), true);
            if ($mentions) {
                $markdown = $mentions['text'];
            }
        } else if (!Helper::hasGlobalPost()) {
            return new \WP_Error('global_post_disabled', 'User is not allowed to post in global', ['status' => 400]);
        }

        $feedData['message_rendered'] = wp_kses_post(self::mdToHtml($markdown));
        $feedData['status'] = 'published';

        if (Arr::get($allData, 'meta.media_preview.provider') == 'inline') {
            $allData['meta']['media_preview']['provider'] = 'giphy';
        }

        [$feedData, $mediaItems] = self::processFeedMetaData($feedData, $allData);

        if ($mentions) {
            $feedData['meta']['mentioned_user_ids'] = Arr::get($mentions, 'user_ids', []);
        }

        $data = apply_filters('fluent_community/feed/new_feed_data', $feedData, $allData);

        if (is_wp_error($data)) {
            return $data;
        }

        $feed = new Feed();
        $feed->fill($data);
        $feed->save();

        if ($mentions) {
            do_action('fluent_community/feed_mentioned', $feed, $mentions['users']);
        }

        if ($mediaItems) {
            foreach ($mediaItems as $media) {
                $media->feed_id = $feed->id;
                $media->is_active = 1;
                $media->object_source = 'feed';
                $media->save();
            }
        }

        do_action('fluent_community/feed/created', $feed);

        if ($feed->space_id) {
            do_action('fluent_community/space_feed/created', $feed);
        }

        return $feed;
    }

    public static function sanitizeAndValidateData($data)
    {
        $message = CustomSanitizer::unslashMarkdown(trim((string) Arr::get($data, 'message', '')));

        // Decode HTML entities and strip all whitespace for validation
        $messageForValidation = html_entity_decode($message, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        $messageForValidation = preg_replace('/\s+/u', '', $messageForValidation);

        if (!$messageForValidation) {
            throw new UnprocessableEntityHttpException(
                esc_html__('Message is required', 'fluent-community'),
                'feed_message_required'
            );
        }

        $processedData = [
            'message' => $message,
            'type'    => 'text'
        ];

        $survey = Arr::get($data, 'survey', []);

        if ($survey) {
            $options = Arr::get($survey, 'options', []);
            $formattedOptions = [];
            foreach ($options as $index => $option) {
                if (empty($option['label'])) {
                    continue;
                }

                $formattedOptions[] = [
                    'label' => sanitize_text_field($option['label']),
                    'slug'  => Arr::get($option, 'slug') ?: 'opt_' . ($index + 1)
                ];
            }

            $endDate = Arr::get($survey, 'end_date', '');
            if ($endDate) {
                $endDate = gmdate('Y-m-d H:i:s', strtotime($endDate));
            } else {
                $endDate = '';
            }

            if ($formattedOptions) {
                $processedData['survey'] = [
                    'type'     => Arr::get($survey, 'type') == 'single_choice' ? 'single_choice' : 'multi_choice',
                    'options'  => $formattedOptions,
                    'end_date' => $endDate
                ];
            }
        }

        $maxlen = apply_filters('fluent_community/max_post_length', 15000);
        if (\strlen($message) > $maxlen) {
            throw new UnprocessableEntityHttpException(
                /* translators: %s is the maximum allowed character count */
                esc_html(sprintf(__('The post is too long. Please keep it under %s characters.', 'fluent-community'), number_format($maxlen))),
                'feed_message_too_long'
            );
        }

        $titlePref = Utility::postTitlePref();

        if ($titlePref) {
            $processedData['title'] = sanitize_text_field(Arr::get($data, 'title'));
            if ($titlePref == 'required' && empty($processedData['title'])) {
                throw new UnprocessableEntityHttpException(
                    esc_html__('Title is required. Please provide a title', 'fluent-community'),
                    'feed_title_required'
                );
            }
            // trim the title if it's too long to 192 chars (multibyte-safe; column is VARCHAR(192) characters)
            if (mb_strlen($processedData['title']) > 192) {
                $processedData['title'] = mb_substr($processedData['title'], 0, 192, 'UTF-8');
            }
        }

        return $processedData;
    }

    public static function getSurveyOptionsUpdateError($existingSurveyOptions, $submittedSurvey)
    {
        if (empty($existingSurveyOptions) || empty($submittedSurvey)) {
            return null;
        }

        $submittedLabelsBySlug = [];
        foreach (Arr::get($submittedSurvey, 'options', []) as $option) {
            $slug = Arr::get($option, 'slug', '');
            if ($slug !== '') {
                $submittedLabelsBySlug[$slug] = trim((string)Arr::get($option, 'label', ''));
            }
        }

        foreach ($existingSurveyOptions as $existingOption) {
            $slug = Arr::get($existingOption, 'slug', '');
            if ($slug === '') {
                continue;
            }

            if (!isset($submittedLabelsBySlug[$slug]) || $submittedLabelsBySlug[$slug] === '') {
                return __('Existing poll options cannot be removed or left empty.', 'fluent-community');
            }
        }

        return null;
    }

    public static function transformForEdit($feed)
    {
        $topicsConfig = Helper::getTopicsConfig();

        $terms = $feed->terms;
        $feed->topic_ids = $terms->where('taxonomy_name', 'post_topic')->pluck('id')->toArray();
        if ($topicsConfig['max_topics_per_post'] == 1) {
            if ($feed->topic_ids) {
                $feed->topic_ids = Arr::first($feed->topic_ids);
            } else {
                $feed->topic_ids = '';
            }
        }

        if (Arr::get($feed->meta, 'send_announcement_email') == 'yes') {
            $feed->send_announcement_email = 'yes';
        }

        if ($feed->content_type == 'document') {
            $documents = Media::where('object_source', 'space_document')
                ->where('feed_id', $feed->id)
                ->where('is_active', 1)
                ->get();
            $mediaIds = [];
            foreach ($documents as $document) {
                /** @var Media $document */
                $mediaIds[] = $document->getPrivateFileMeta();
            }
            $feed->document_ids = $mediaIds;
            $feed->load('space');
            return $feed;
        }

        $surveyConfig = Arr::get($feed->meta, 'survey_config', []);

        if ($surveyConfig) {
            $feed->survey = [
                'type'     => Arr::get($surveyConfig, 'type'),
                'options'  => Arr::get($surveyConfig, 'options', []),
                'end_date' => Arr::get($surveyConfig, 'end_date', '')
            ];
        }

        $mediaImages = Arr::get($feed->meta, 'media_items', []);
        $meta = $feed->meta;
        unset($feed->meta);

        if ($mediaImages) {
            $feed->media_images = $mediaImages;
        } else if ($mediaPreview = Arr::get($meta, 'media_preview')) {
            $type = Arr::get($mediaPreview, 'type');
            if ($type == 'oembed' || $type == 'iframe_html') {
                $feed->media = $mediaPreview;
            }

            // Only fetch the specific attached media, not all media (which would include inline images).
            $mediaId = Arr::get($mediaPreview, 'media_id');
            if ($mediaId && $type != 'oembed' && $type != 'iframe_html') {
                $media = Media::where('id', $mediaId)
                    ->where('feed_id', $feed->id)
                    ->where('is_active', 1)
                    ->first();

                if ($media) {
                    $feed->media_images = [[
                        'url'      => $media->public_url,
                        'type'     => 'image',
                        'media_id' => $media->id,
                        'width'    => Arr::get($media->settings, 'width'),
                        'height'   => Arr::get($media->settings, 'height'),
                        'provider' => Arr::get($media->settings, 'provider', 'uploader')
                    ]];
                }
            } else if ($type != 'meta_data') {
                $feed->meta = $meta;
            }
        }

        // Preserve multi-audio so the edit composer can load, edit/remove, and re-save them
        // (transformForEdit otherwise drops meta for audio-only posts).
        $audioMedias = Arr::get($meta, 'audio_medias', []);
        if ($audioMedias) {
            $editMeta = (isset($feed->meta) && is_array($feed->meta)) ? $feed->meta : [];
            $editMeta['audio_medias'] = $audioMedias;
            $feed->meta = $editMeta;
        }

        $feed->load('space');
        return $feed;
    }

    /**
     * Whether the current request may attach a raw "HTML Code" (iframe_html) embed.
     *
     * Mirrors the frontend rule in _VideoEmbeder.vue, which exposes that editor tab only
     * when is_admin is true — i.e. community_moderator globally or within the target
     * space. Programmatic creation is judged on the supplied author's permission rather
     * than the HTTP session, so integrations work without a logged-in user. Defaults to
     * denying when no user can be established at all.
     *
     * @param array $requestData Raw request payload.
     * @param array $data Feed data being assembled.
     * @param \FluentCommunity\App\Models\Feed|null $existingFeed Set when editing.
     * @return bool
     */
    private static function canEmbedRawHtml($requestData, $data, $existingFeed = null)
    {
        // FeedsController::store()/update() already resolved this against the target space.
        $precomputed = Arr::get($requestData, 'is_admin');
        if ($precomputed !== null) {
            return (bool)$precomputed;
        }

        // Every other caller resolves it here, against the post's author where one has
        // been established server-side (createFeed() takes user_id from its caller), and
        // the current user otherwise. Read from $data and never $requestData: the author
        // is assigned by the controller, so a request cannot nominate whose permission
        // gets checked.
        $userId = (int)Arr::get($data, 'user_id');
        if (!$userId) {
            $userId = get_current_user_id();
        }

        $user = $userId ? User::find($userId) : null;
        if (!$user) {
            return false;
        }

        $space = null;
        if ($existingFeed) {
            $space = $existingFeed->space;
        } elseif ($spaceId = (Arr::get($data, 'space_id') ?: Arr::get($requestData, 'space_id'))) {
            $space = BaseSpace::find($spaceId);
        }

        return (bool)$user->hasPermissionOrInCurrentSpace('community_moderator', $space);
    }

    public static function processFeedMetaData($data, $requestData, $existingFeed = null)
    {
        if (empty($data['meta'])) {
            $data['meta'] = [];
        }

        $uplaodedDocs = [];
        // Handle Survey
        if (!empty($data['survey'])) {
            $surveyConfig = $data['survey'];
            if ($existingFeed) {
                $surveyConfig = Arr::get($existingFeed->meta, 'survey_config', []);
                if ($surveyConfig) {
                    $oldOptions = Arr::get($surveyConfig, 'options', []);
                    $newOptions = Arr::get($data['survey'], 'options', []);
                    $oldKeyedOptions = [];
                    foreach ($oldOptions as $option) {
                        $oldKeyedOptions[$option['slug']] = $option;
                    }
                    foreach ($newOptions as $index => $option) {
                        $slug = Arr::get($option, 'slug', '');
                        if (isset($oldKeyedOptions[$slug])) {
                            $newOptions[$index]['vote_counts'] = Arr::get($oldKeyedOptions[$slug], 'vote_counts', 0);
                        }
                    }
                    $surveyConfig['options'] = $newOptions;
                } else {
                    $surveyConfig = $data['survey'];
                }
            }

            if ($endDate = Arr::get($data['survey'], 'end_date', '')) {
                $surveyConfig['end_date'] = gmdate('Y-m-d H:i:s', strtotime($endDate));
            } else {
                $surveyConfig['end_date'] = '';
            }

            $data['meta']['survey_config'] = $surveyConfig;
            $data['content_type'] = 'survey';
            unset($data['survey']);
        }

        // Handle Giphy
        if (Arr::get($requestData, 'meta.media_preview.provider') == 'giphy') {
            $url = Arr::get($requestData, 'meta.media_preview.image');
            if (!$url || !filter_var($url, FILTER_VALIDATE_URL)) {
                return [$data, $uplaodedDocs];
            }

            $data['meta']['media_preview'] = array_filter([
                'image'    => sanitize_url($url),
                'type'     => sanitize_text_field(Arr::get($requestData, 'meta.media_preview.type', 'image')),
                'provider' => 'giphy',
                'height'   => (int)Arr::get($requestData, 'meta.media_preview.height', 0),
                'width'    => (int)Arr::get($requestData, 'meta.media_preview.width', 0),
            ]);

            return [$data, $uplaodedDocs];
        }

        // Handling Video Embed
	    if (
		    Arr::get($requestData, 'media') &&
		    (
			    (Arr::get($requestData, 'media.type') == 'oembed' && Arr::get($requestData, 'media.player') != 'fluent_player') ||
			    Arr::get($requestData, 'media.type') == 'iframe_html'
		    )
	    ) {
            if (Arr::get($requestData, 'media.type') == 'iframe_html') {
                // The UI only offers the "HTML Code" embed to moderators
                // (_VideoEmbeder.vue passes has_iframe="is_admin"). That is a hint, not a
                // control, so the same rule is enforced here. Reaching this branch without
                // the permission means the field was posted straight to the REST API, so
                // the embed is dropped rather than stored.
                if (!self::canEmbedRawHtml($requestData, $data, $existingFeed)) {
                    return [$data, $uplaodedDocs];
                }

                $mediaPreview = array_filter(Arr::get($requestData, 'media', []));

                // Moderators are trusted to embed, not to bypass sanitization: the markup
                // still goes through the same allowlist the oembed branch below uses.
                if (!empty($mediaPreview['html'])) {
                    $mediaPreview['html'] = RemoteUrlParser::sanitizeOembedHtml($mediaPreview['html']);

                    // Keep only if a usable <iframe> survived; else it renders as junk.
                    if (stripos($mediaPreview['html'], '<iframe') === false) {
                        unset($mediaPreview['html']);
                    }

                    $mediaPreview = array_filter($mediaPreview);
                }

                if (empty($mediaPreview['image']) && !empty($mediaPreview['html'])) {
                    $thumb = RemoteUrlParser::extractIframeThumbnail($mediaPreview['html']);
                    if ($thumb) {
                        $mediaPreview['image'] = $thumb;
                    }
                }

                // Nothing usable survived; skip storing a broken preview.
                if (empty($mediaPreview['html']) && empty($mediaPreview['image'])) {
                    return [$data, $uplaodedDocs];
                }

                $data['meta']['media_preview'] = $mediaPreview;
                return [$data, $uplaodedDocs];
            }

            $media = Arr::get($requestData, 'media');
            $url = Arr::get($media, 'url');
            $metaData = RemoteUrlParser::parse($url);
            if ($metaData && !is_wp_error($metaData) && (!empty($metaData['image']) || !empty($metaData['html']))) {
                $data['meta']['media_preview'] = $metaData;
            }

            return [$data, $uplaodedDocs];
        }

        // Let's handle the uploaded media
        $mediaImages = Arr::get($requestData, 'media_images', []);
        if ($mediaImages) {
            $uploadedImages = Helper::getMediaByProvider($mediaImages);
            if (!$existingFeed) {
                $uploadedMediaItems = Helper::getMediaItemsFromUrl($uploadedImages);
            } else {
                $uploadedMediaItems = [];
                foreach ($mediaImages as $mediaImage) {
                    $url = sanitize_url(Arr::get($mediaImage, 'url', ''));
                    if (!$url) {
                        continue;
                    }
                    $mediaItem = Helper::getMediaFromUrl($mediaImage);
                    if ($mediaItem) {
                        $uploadedMediaItems[] = $mediaItem;
                    } else {
                        // maybe this is a previously uploaded image
                        $media = Media::where('media_url', $url)
                            ->where('object_source', 'feed')
                            ->where('feed_id', $existingFeed->id)
                            ->where('is_active', 1)
                            ->first();

                        if ($media) {
                            $uploadedMediaItems[] = $media;
                        }
                    }
                }
            }

            if (count($uploadedMediaItems) == 1) {
                $singleMedia = $uploadedMediaItems[0];
                $data['meta']['media_preview'] = [
                    'is_uploaded' => true,
                    'image'       => $singleMedia->public_url,
                    'type'        => 'meta_data',
                    'provider'    => 'uploader',
                    'width'       => Arr::get($singleMedia->settings, 'width'),
                    'height'      => Arr::get($singleMedia->settings, 'height'),
                    'media_id'    => $singleMedia->id,
                ];
            } else if ($uploadedMediaItems) {
                $mediaPreviews = [];
                foreach ($uploadedMediaItems as $mediaItem) {
                    $mediaData = [
                        'media_id' => $mediaItem->id,
                        'url'      => $mediaItem->public_url,
                        'type'     => 'image',
                        'width'    => Arr::get($mediaItem->settings, 'width'),
                        'height'   => Arr::get($mediaItem->settings, 'height'),
                        'provider' => Arr::get($mediaItem->settings, 'provider', 'uploader')
                    ];
                    $mediaPreviews[] = array_filter($mediaData);
                }
                $data['meta']['media_items'] = $mediaPreviews;
            }

            $maxMediaPerPost = apply_filters('fluent_community/max_media_per_post', Utility::getCustomizationSetting('max_media_per_post'));

            $allMediaItems = array_slice($uploadedMediaItems, 0, (int)$maxMediaPerPost);

            return [$data, $allMediaItems];
        }

        if ($existingFeed && Arr::get($existingFeed->meta, 'auto_flagged') == 'yes') {
            $data['meta']['auto_flagged'] = 'yes';
            $data['meta']['prevent_published'] = 'yes';
            $data['meta']['reports_count'] = Arr::get($existingFeed->meta, 'reports_count', 0);
        }

        // Let's handle the fallback here
        $firstUrl = FeedsHelper::findFirstUrl(Arr::get($data, 'message_rendered'));

        // check if this is another post or not
        if (strpos($firstUrl, Helper::baseUrl()) === 0) {
            // this is an internal URL
            if (Helper::getRouteNameByRequestPath($firstUrl) === 'feed_view') {
                $uriParts = explode('/', $firstUrl);
                if (count($uriParts) >= 2) {
                    $postSlug = end($uriParts);
                    $feed = Feed::where('slug', $postSlug)->first();
                    if ($feed) {
                        $firstUrl = null;
                        $data['meta']['custom_app_preview'] = [
                            'app_name' => 'child_post',
                            'feed_id'  => $feed->id
                        ];
                    }
                }
            }
        }

        if ($firstUrl) {
            $metaData = RemoteUrlParser::parse($firstUrl);
            if ($metaData && !is_wp_error($metaData) && (!empty($metaData['image']) || !empty($metaData['html']))) {
                $data['meta']['media_preview'] = $metaData;
            }
        }

        $uplaodedDocs = apply_filters('fluent_community/feed/uploaded_feed_medias', $uplaodedDocs, $requestData);
        return [$data, $uplaodedDocs];
    }

    protected static function tranformFeedData(Feed $feed, $config = [])
    {
        $userId = Arr::get($config, 'user_id', 0);
        $commentLikeIds = $userId ? Arr::get($config, 'comment_like_ids', []) : [];

        $feed->comments->each(function ($comment) use ($commentLikeIds) {
            self::setCurrentRelatedUserId($comment->user_id);
            if ($commentLikeIds && in_array($comment->id, $commentLikeIds)) {
                $comment->liked = 1;
            }
        });

        // User-specific processing
        if ($userId) {
            $interactions = Arr::get($config, 'interactions', []);

            if ($interactions) {
                $feed->has_user_react = Arr::get($interactions, 'like', false);
                $feed->bookmarked = Arr::get($interactions, 'bookmark', false);
            }

            if ($feed->content_type == 'survey') {
                $votedOptions = $feed->getSurveyCastsByUserId($userId);
                if ($votedOptions) {
                    $surveyConfig = Arr::get($feed->meta, 'survey_config', []);
                    foreach ($surveyConfig['options'] as $index => $option) {
                        if (in_array($option['slug'], $votedOptions)) {
                            $surveyConfig['options'][$index]['voted'] = true;
                        }
                    }
                    $meta = $feed->meta;
                    $meta['survey_config'] = $surveyConfig;
                    $feed->meta = $meta;
                }
            }
        }

        if ($feed->content_type == 'document') {
            $feedMeta = $feed->meta;
            $documentLists = Arr::get($feedMeta, 'document_lists', []);
            foreach ($documentLists as $index => $document) {
                $documentLists[$index]['url'] = Helper::baseUrl('?fcom_action=download_document&media_key=' . $document['media_key'] . '&media_id=' . $document['id']);
            }
            $feedMeta['document_lists'] = $documentLists;
            $feed->meta = $feedMeta;
        }

        $spaceSettings = $feed->space ? $feed->space->settings : [];
        $feed->default_comment_sort_by = Arr::get($spaceSettings, 'default_comment_sort_by', '');

        // Feed::withPublicRelations() eager-loads the space with its raw settings, and
        // those settings carry links scoped to logged-in members or to specific
        // memberships. BaseSpace::formatSpaceData() filters them for the space
        // endpoints; nothing filtered them here, so every feed response handed all of
        // a space's links - titles and URLs - to any caller, anonymous included.
        if ($feed->space && Arr::get($spaceSettings, 'links')) {
            $currentUser = Helper::getCurrentUser();

            $spaceSettings['links'] = Helper::filterAccessibleLinks(
                Arr::get($spaceSettings, 'links', []),
                $currentUser ? $currentUser : null
            );

            $feed->space->settings = $spaceSettings;
        }

        self::setCurrentRelatedUserId($feed->user_id);

        return apply_filters('fluent_community/rendering_feed_model', $feed, $config);
    }

    public static function transformFeed(Feed $feed)
    {
        $userId = get_current_user_id();

        $config = apply_filters('fluent_community/feed_general_config', [
            'user_id'          => $userId,
            'interactions'     => [],
            'comment_like_ids' => [],
            'is_collection'    => false
        ], $feed, $userId);

        if ($userId) {
            $config['interactions'] = [
                'like'     => $feed->hasUserReact($userId, 'like'),
                'bookmark' => $feed->hasUserReact($userId, 'bookmark'),
            ];
            $config['comment_like_ids'] = self::getLikedIdsByUserFeedId($feed->id, $userId);
        }

        return self::tranformFeedData($feed, $config);
    }

    public static function transformFeedsCollection($feeds)
    {
        if ($feeds->isEmpty()) {
            return $feeds;
        }

        $userId = get_current_user_id();
        $commentLikeIds = [];
        $formattedInteractions = [];
        $feedIds = $feeds->pluck('id')->toArray();

        if ($userId) {
            $interactions = Reaction::query()
                ->select(['user_id', 'type', 'object_id'])
                ->whereIn('object_id', $feedIds)
                ->where('object_type', 'feed')
                ->where('user_id', $userId)
                ->whereIn('type', ['like', 'bookmark'])
                ->get();

            $formattedInteractions = [];
            foreach ($interactions as $interaction) {
                $objectId = (int)$interaction->object_id;

                if (!isset($formattedInteractions[$objectId])) {
                    $formattedInteractions[$objectId] = [];
                }
                $formattedInteractions[$objectId][$interaction->type] = true;
            }

            $commentLikeIds = Reaction::select('object_id')
                ->where('object_type', 'comment')
                ->whereIn('parent_id', $feedIds)
                ->where('user_id', $userId)
                ->get()
                ->pluck('object_id')
                ->toArray();
        }

        $generalConfig = apply_filters('fluent_community/feed_general_config', [
            'user_id'          => $userId,
            'interactions'     => [],
            'comment_like_ids' => $commentLikeIds,
            'is_collection'    => true
        ], $feeds, $feedIds);

        $feeds->each(function ($feed) use ($userId, $generalConfig, $formattedInteractions) {
            $config = $generalConfig;
            if ($userId) {
                $config['interactions'] = Arr::get($formattedInteractions, $feed->id, []);
            }
            return self::tranformFeedData($feed, $config);
        });

        return $feeds;
    }

    public static function getMediaHtml($meta, $postPermalink)
    {
        $mediaImage = Arr::get($meta, 'media_preview.image');
        $mediaCount = 0;
        if (!$mediaImage) {
            $mediaItems = Arr::get($meta, 'media_items', []);
            if ($mediaItems) {
                $mediaImage = Arr::get($mediaItems[0], 'url');
                $mediaCount = count($mediaItems);
            }
        }

        $feedHtml = '';

        if ($mediaImage) {
            $feedHtml .= '<div class="fcom_media" style="margin-top: 20px;">';
            $feedHtml .= '<a href="' . $postPermalink . '"><img src="' . $mediaImage . '" alt="" style="max-width: 100%; height: auto; display: block; margin: 0 auto 0px;" /></a>';
            if ($mediaCount > 1) {
                /* translators: %d is the number of additional images not shown in the preview. */
                $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>';
            }
            $feedHtml .= '</div>';
        }

        return $feedHtml;
    }

    public static function hasEveryoneTag($message)
    {
        // Updated regular expression to match @everyone with more flexibility
        $pattern = '/(?<=^|\W)@everyone(?=\W|\z)/iu';

        return preg_match($pattern, $message) === 1;
    }

    public static function replaceImageUrlsWithRealMediaArchive($markdown, $existingFeed = null)
    {
        $imageUrls = self::getInlineImageUrls($markdown);

        if (!$imageUrls) {
            return [$markdown, []];
        }

        $mediaItems = [];

        foreach ($imageUrls as $url) {
            $url = sanitize_url($url);
            $media = Helper::getMediaFromUrl($url);
            if ($media) {
                if ($media->is_active && (!$existingFeed || $media->feed_id != $existingFeed->id) ) {
                    continue;
                }

                $realUrl = $media->public_url;
                $markdown = str_replace($url, $realUrl, $markdown);
                $mediaItems[] = $media;
            }
        }

        return [$markdown, $mediaItems];
    }

    private static function getInlineImageUrls($markdown)
    {
        $urls = [];
        // Match ![alt](url) and ![alt](url "title")
        if (preg_match_all('/!\[[^\]]*\]\(([^\s\)]+)(?:\s+"[^"]*")?\)/', $markdown, $matches)) {
            $urls = array_merge($urls, $matches[1]);
        }

        // Match reference-style image usage: ![alt][id] and map only those IDs to their definitions [id]: url
        if (preg_match_all('/!\[[^\]]*\]\[([^\]]+)\]/', $markdown, $imageRefMatches)) {
            $usedIds = array_unique($imageRefMatches[1]);

            if (preg_match_all('/^\[([^\]]+)\]:\s+(\S+)/m', $markdown, $refMatches)) {
                $refMap = [];
                foreach ($refMatches[1] as $index => $id) {
                    $refMap[$id] = $refMatches[2][$index];
                }

                foreach ($usedIds as $id) {
                    if (isset($refMap[$id])) {
                        $urls[] = $refMap[$id];
                    }
                }
            }
        }

        // Match HTML <img> tags
        if (preg_match_all('/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $markdown, $matches)) {
            $urls = array_merge($urls, $matches[1]);
        }

        return array_unique($urls);
    }
}

```
