| @@ -2,17 +2,50 @@ | ||
| 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; |
| 8 | +use FluentCommunity\App\Models\Media; | |
| 7 | 9 | use FluentCommunity\App\Models\Reaction; |
| 8 | 10 | use FluentCommunity\App\Models\Term; |
| 9 | 11 | use FluentCommunity\App\Models\User; |
| 10 | 12 | use FluentCommunity\App\Models\XProfile; |
| 13 | +use FluentCommunity\Framework\Foundation\Exceptions\UnprocessableEntityHttpException; | |
| 11 | 14 | use FluentCommunity\Framework\Support\Arr; |
| 15 | +use FluentCommunity\Framework\Validator\Validator; | |
| 12 | 16 | |
| 13 | 17 | class FeedsHelper |
| 14 | 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 | + | |
| 15 | 48 | public static function getSpaceSlugsByUserId($userId) |
| 16 | 49 | { |
| 17 | 50 | if (!$userId) { |
| 18 | 51 | $userId = get_current_user_id(); |
| @@ -26,8 +59,71 @@ | ||
| 26 | 59 | |
| 27 | 60 | return $user->spaces()->pluck('slug')->toArray(); |
| 28 | 61 | } |
| 29 | 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 | + | |
| 30 | 126 | public static function getLastFeedId() |
| 31 | 127 | { |
| 32 | 128 | $lastItem = Feed::where('status', 'published') |
| 33 | 129 | ->byUserAccess(get_current_user_id()) |
| @@ -45,13 +141,16 @@ | ||
| 45 | 141 | { |
| 46 | 142 | if (!$text) { |
| 47 | 143 | return ''; |
| 48 | 144 | } |
| 145 | + | |
| 49 | 146 | $text = str_replace(' ', '', $text); // hide markdown empty content |
| 50 | 147 | |
| 51 | 148 | $html = (new \FluentCommunity\App\Services\Parsedown([ |
| 52 | 149 | ])) |
| 53 | 150 | ->setBreaksEnabled(true) |
| 151 | + ->setUrlsLinked(false) | |
| 152 | + // ->setSafeMode(true) | |
| 54 | 153 | ->text($text); |
| 55 | 154 | |
| 56 | 155 | if (!Arr::get($options, 'disable_link_process')) { |
| 57 | 156 | // add nofollow to all links. But check if nofollow is already there |
| @@ -57,24 +156,106 @@ | ||
| 57 | 156 | // add nofollow to all links. But check if nofollow is already there |
| 58 | 157 | $html = self::addNoFollowToLinks($html); |
| 59 | 158 | } |
| 60 | 159 | |
| 61 | - 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); | |
| 62 | 211 | } |
| 63 | 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 | + | |
| 64 | 245 | public static function addNoFollowToLinks($html) |
| 65 | 246 | { |
| 66 | - if(!$html) { | |
| 247 | + if (!$html) { | |
| 67 | 248 | return ''; |
| 68 | 249 | } |
| 69 | 250 | |
| 70 | - $current_domain = parse_url(home_url(), PHP_URL_HOST); | |
| 251 | + $current_domain = wp_parse_url(home_url(), PHP_URL_HOST); | |
| 71 | 252 | |
| 72 | 253 | // Regular expression to match <a> tags |
| 73 | - $pattern = '/<a\s[^>]*href=("|\')(https?:\/\/(?!'. preg_quote($current_domain, '/') .').*?)("|\')\s?([^>]*)>/i'; | |
| 254 | + $pattern = '/<a\s[^>]*href=("|\')(https?:\/\/(?!' . preg_quote($current_domain, '/') . ').*?)("|\')\s?([^>]*)>/i'; | |
| 74 | 255 | |
| 75 | 256 | // Callback function to modify each matched <a> tag |
| 76 | - $callback = function($matches) { | |
| 257 | + $callback = function ($matches) { | |
| 77 | 258 | $url = $matches[2]; |
| 78 | 259 | $attr = $matches[4]; |
| 79 | 260 | |
| 80 | 261 | // Remove existing rel attribute if present |
| @@ -87,17 +268,72 @@ | ||
| 87 | 268 | // Perform the replacement |
| 88 | 269 | return preg_replace_callback($pattern, $callback, $html); |
| 89 | 270 | } |
| 90 | 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 | + | |
| 91 | 315 | public static function findFirstUrl($html) |
| 92 | 316 | { |
| 93 | - // use regular expression to find the first URL in a href tag | |
| 94 | - // do not take the url which contains /u/ in it | |
| 95 | - $pattern = '/<a\s+(?:[^>]*?\s+)?href=([\'"])(?!.*\/u\/)(.*?)\1/'; | |
| 96 | - preg_match($pattern, $html, $matches); | |
| 317 | + if (!preg_match_all('/<a\s+(?:[^>]*?\s+)?href=([\'"])(.*?)\1/i', $html, $matches)) { | |
| 318 | + return ''; | |
| 319 | + } | |
| 97 | 320 | |
| 98 | - if (isset($matches[2])) { | |
| 99 | - return $matches[2]; | |
| 321 | + $profileUrlPrefix = Helper::baseUrl('u/'); | |
| 322 | + | |
| 323 | + foreach ($matches[2] as $href) { | |
| 324 | + // Rendered HTML encodes "&" as "&". Left encoded, "?a=1&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; | |
| 100 | 336 | } |
| 101 | 337 | |
| 102 | 338 | return ''; |
| 103 | 339 | } |
| @@ -145,9 +381,9 @@ | ||
| 145 | 381 | |
| 146 | 382 | return array_values($termIds); |
| 147 | 383 | } |
| 148 | 384 | |
| 149 | - public static function getMentions($text, $spaceId = null) | |
| 385 | + public static function getMentions($text, $spaceId = null, $withUsers = false) | |
| 150 | 386 | { |
| 151 | 387 | // the mention may have . or _ or - in the username |
| 152 | 388 | preg_match_all('/@([a-zA-Z0-9_.-]+)/', $text, $matches); |
| 153 | 389 | $mentions = array_unique($matches[1]); |
| @@ -158,9 +394,9 @@ | ||
| 158 | 394 | |
| 159 | 395 | if ($spaceId) { |
| 160 | 396 | $xProfiles = XProfile::whereIn('username', $mentions) |
| 161 | 397 | ->whereHas('spaces', function ($query) use ($spaceId) { |
| 162 | - $query->where('space_id', $spaceId); | |
| 398 | + $query->withoutGlobalScopes()->where('space_id', $spaceId); | |
| 163 | 399 | }) |
| 164 | 400 | ->get(); |
| 165 | 401 | } else { |
| 166 | 402 | $xProfiles = XProfile::whereIn('username', $mentions) |
| @@ -180,14 +416,18 @@ | ||
| 180 | 416 | $html = '<a data-user_name="' . $xProfile->username . '" class="fcom_mention fcom_route" href="' . Helper::baseUrl('u/' . $xProfile->username . '/') . '">' . $xProfile->display_name . '</a>'; |
| 181 | 417 | $userMentions['@' . $xProfile->username] = $html; |
| 182 | 418 | } |
| 183 | 419 | |
| 184 | - $users = User::whereIn('ID', $userIds)->get(); | |
| 420 | + $data = [ | |
| 421 | + 'user_ids' => $userIds, | |
| 422 | + 'text' => strtr($text, $userMentions) | |
| 423 | + ]; | |
| 185 | 424 | |
| 186 | - return [ | |
| 187 | - 'users' => $users, | |
| 188 | - 'text' => strtr($text, $userMentions) | |
| 189 | - ]; | |
| 425 | + if ($withUsers) { | |
| 426 | + $data['users'] = User::whereIn('ID', $userIds)->get(); | |
| 427 | + } | |
| 428 | + | |
| 429 | + return $data; | |
| 190 | 430 | } |
| 191 | 431 | |
| 192 | 432 | public static function getLikedIdsByUserFeedId($feedId, $userId) |
| 193 | 433 | { |
| @@ -238,8 +478,12 @@ | ||
| 238 | 478 | 'object_type' => $newSyncIndex |
| 239 | 479 | ]); |
| 240 | 480 | } |
| 241 | 481 | |
| 482 | + if (!empty($newSyncIndexes)) { | |
| 483 | + do_action('fluent_community/feed/cast_survey_vote', $newSyncIndexes, $feed, $userId); | |
| 484 | + } | |
| 485 | + | |
| 242 | 486 | foreach ($surveyConfig['options'] as $index => $option) { |
| 243 | 487 | $slug = $option['slug']; |
| 244 | 488 | |
| 245 | 489 | if (in_array($slug, $removedIndexes)) { |
| @@ -252,8 +496,10 @@ | ||
| 252 | 496 | |
| 253 | 497 | $surveyConfig['options'][$index] = $option; |
| 254 | 498 | } |
| 255 | 499 | |
| 500 | + $surveyConfig = apply_filters('fluent_community/feed/updated_survey_config', $surveyConfig, $feed, $userId); | |
| 501 | + | |
| 256 | 502 | $meta = $feed->meta; |
| 257 | 503 | $meta['survey_config'] = $surveyConfig; |
| 258 | 504 | $feed->meta = $meta; |
| 259 | 505 | $feed->save(); |
| @@ -260,6 +506,822 @@ | ||
| 260 | 506 | |
| 261 | 507 | Utility::forgetCache('survey_cast_' . $feed->id . '_' . $userId); |
| 262 | 508 | |
| 263 | 509 | return $feed; |
| 510 | + } | |
| 511 | + | |
| 512 | + /** | |
| 513 | + * Create a new feed programmatically | |
| 514 | + * @param array $allData | |
| 515 | + * @return \FluentCommunity\App\Models\Feed|\WP_Error | |
| 516 | + **/ | |
| 517 | + public static function createFeed($allData) | |
| 518 | + { | |
| 519 | + if (!is_array($allData)) { | |
| 520 | + return new \WP_Error('invalid_data', __('Invalid data. The data need to be array', 'fluent-community'), ['status' => 400]); | |
| 521 | + } | |
| 522 | + | |
| 523 | + $acceptedKeys = ['message', 'title', 'user_id', 'space_id', 'type']; | |
| 524 | + $feedData = Arr::only($allData, $acceptedKeys); | |
| 525 | + | |
| 526 | + // Let's validate the data | |
| 527 | + $validation = Validator::make($feedData, [ | |
| 528 | + 'message' => 'required', | |
| 529 | + 'title' => 'nullable|string', | |
| 530 | + 'user_id' => 'required|integer|exists:users,ID', | |
| 531 | + 'space_id' => 'nullable|integer|exists:fcom_spaces,id' | |
| 532 | + ]); | |
| 533 | + | |
| 534 | + if ($validation->fails()) { | |
| 535 | + return new \WP_Error('validation_failed', __('Validation failed', 'fluent-community'), $validation->errors()); | |
| 536 | + } | |
| 537 | + | |
| 538 | + $sanitizedData = self::sanitizeAndValidateData($feedData); | |
| 539 | + | |
| 540 | + $feedData = wp_parse_args($sanitizedData, $feedData); | |
| 541 | + | |
| 542 | + $user = User::find($feedData['user_id']); | |
| 543 | + | |
| 544 | + if (!$user) { | |
| 545 | + return new \WP_Error('user_not_found', __('User not found', 'fluent-community'), ['status' => 400]); | |
| 546 | + } | |
| 547 | + $user->syncXProfile(); | |
| 548 | + if ($user->xprofile->status != 'active') { | |
| 549 | + return new \WP_Error('user_inactive', __('User status is not active', 'fluent-community'), $validation->errors()); | |
| 550 | + } | |
| 551 | + | |
| 552 | + $markdown = $feedData['message']; | |
| 553 | + $mentions = null; | |
| 554 | + | |
| 555 | + // Extra Validaton for space_id | |
| 556 | + if (!empty($feedData['space_id'])) { | |
| 557 | + if (!Helper::isUserInSpace($feedData['user_id'], $feedData['space_id'])) { | |
| 558 | + return new \WP_Error('invalid_space', __('User is not in the space', 'fluent-community'), ['status' => 400]); | |
| 559 | + } | |
| 560 | + $mentions = FeedsHelper::getMentions($markdown, Arr::get($feedData, 'space_id'), true); | |
| 561 | + if ($mentions) { | |
| 562 | + $markdown = $mentions['text']; | |
| 563 | + } | |
| 564 | + } else if (!Helper::hasGlobalPost()) { | |
| 565 | + return new \WP_Error('global_post_disabled', 'User is not allowed to post in global', ['status' => 400]); | |
| 566 | + } | |
| 567 | + | |
| 568 | + $feedData['message_rendered'] = wp_kses_post(self::mdToHtml($markdown)); | |
| 569 | + $feedData['status'] = 'published'; | |
| 570 | + | |
| 571 | + if (Arr::get($allData, 'meta.media_preview.provider') == 'inline') { | |
| 572 | + $allData['meta']['media_preview']['provider'] = 'giphy'; | |
| 573 | + } | |
| 574 | + | |
| 575 | + [$feedData, $mediaItems] = self::processFeedMetaData($feedData, $allData); | |
| 576 | + | |
| 577 | + if ($mentions) { | |
| 578 | + $feedData['meta']['mentioned_user_ids'] = Arr::get($mentions, 'user_ids', []); | |
| 579 | + } | |
| 580 | + | |
| 581 | + $data = apply_filters('fluent_community/feed/new_feed_data', $feedData, $allData); | |
| 582 | + | |
| 583 | + if (is_wp_error($data)) { | |
| 584 | + return $data; | |
| 585 | + } | |
| 586 | + | |
| 587 | + $feed = new Feed(); | |
| 588 | + $feed->fill($data); | |
| 589 | + $feed->save(); | |
| 590 | + | |
| 591 | + if ($mentions) { | |
| 592 | + do_action('fluent_community/feed_mentioned', $feed, $mentions['users']); | |
| 593 | + } | |
| 594 | + | |
| 595 | + if ($mediaItems) { | |
| 596 | + foreach ($mediaItems as $media) { | |
| 597 | + $media->feed_id = $feed->id; | |
| 598 | + $media->is_active = 1; | |
| 599 | + $media->object_source = 'feed'; | |
| 600 | + $media->save(); | |
| 601 | + } | |
| 602 | + } | |
| 603 | + | |
| 604 | + do_action('fluent_community/feed/created', $feed); | |
| 605 | + | |
| 606 | + if ($feed->space_id) { | |
| 607 | + do_action('fluent_community/space_feed/created', $feed); | |
| 608 | + } | |
| 609 | + | |
| 610 | + return $feed; | |
| 611 | + } | |
| 612 | + | |
| 613 | + public static function sanitizeAndValidateData($data) | |
| 614 | + { | |
| 615 | + $message = CustomSanitizer::unslashMarkdown(trim((string) Arr::get($data, 'message', ''))); | |
| 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 | + | |
| 628 | + $processedData = [ | |
| 629 | + 'message' => $message, | |
| 630 | + 'type' => 'text' | |
| 631 | + ]; | |
| 632 | + | |
| 633 | + $survey = Arr::get($data, 'survey', []); | |
| 634 | + | |
| 635 | + if ($survey) { | |
| 636 | + $options = Arr::get($survey, 'options', []); | |
| 637 | + $formattedOptions = []; | |
| 638 | + foreach ($options as $index => $option) { | |
| 639 | + if (empty($option['label'])) { | |
| 640 | + continue; | |
| 641 | + } | |
| 642 | + | |
| 643 | + $formattedOptions[] = [ | |
| 644 | + 'label' => sanitize_text_field($option['label']), | |
| 645 | + 'slug' => Arr::get($option, 'slug') ?: 'opt_' . ($index + 1) | |
| 646 | + ]; | |
| 647 | + } | |
| 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 | + | |
| 656 | + if ($formattedOptions) { | |
| 657 | + $processedData['survey'] = [ | |
| 658 | + 'type' => Arr::get($survey, 'type') == 'single_choice' ? 'single_choice' : 'multi_choice', | |
| 659 | + 'options' => $formattedOptions, | |
| 660 | + 'end_date' => $endDate | |
| 661 | + ]; | |
| 662 | + } | |
| 663 | + } | |
| 664 | + | |
| 665 | + $maxlen = apply_filters('fluent_community/max_post_length', 15000); | |
| 666 | + if (\strlen($message) > $maxlen) { | |
| 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 | + ); | |
| 672 | + } | |
| 673 | + | |
| 674 | + $titlePref = Utility::postTitlePref(); | |
| 675 | + | |
| 676 | + if ($titlePref) { | |
| 677 | + $processedData['title'] = sanitize_text_field(Arr::get($data, 'title')); | |
| 678 | + if ($titlePref == 'required' && empty($processedData['title'])) { | |
| 679 | + throw new UnprocessableEntityHttpException( | |
| 680 | + esc_html__('Title is required. Please provide a title', 'fluent-community'), | |
| 681 | + 'feed_title_required' | |
| 682 | + ); | |
| 683 | + } | |
| 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'); | |
| 687 | + } | |
| 688 | + } | |
| 689 | + | |
| 690 | + return $processedData; | |
| 691 | + } | |
| 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 | + | |
| 721 | + public static function transformForEdit($feed) | |
| 722 | + { | |
| 723 | + $topicsConfig = Helper::getTopicsConfig(); | |
| 724 | + | |
| 725 | + $terms = $feed->terms; | |
| 726 | + $feed->topic_ids = $terms->where('taxonomy_name', 'post_topic')->pluck('id')->toArray(); | |
| 727 | + if ($topicsConfig['max_topics_per_post'] == 1) { | |
| 728 | + if ($feed->topic_ids) { | |
| 729 | + $feed->topic_ids = Arr::first($feed->topic_ids); | |
| 730 | + } else { | |
| 731 | + $feed->topic_ids = ''; | |
| 732 | + } | |
| 733 | + } | |
| 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 | + | |
| 754 | + $surveyConfig = Arr::get($feed->meta, 'survey_config', []); | |
| 755 | + | |
| 756 | + if ($surveyConfig) { | |
| 757 | + $feed->survey = [ | |
| 758 | + 'type' => Arr::get($surveyConfig, 'type'), | |
| 759 | + 'options' => Arr::get($surveyConfig, 'options', []), | |
| 760 | + 'end_date' => Arr::get($surveyConfig, 'end_date', '') | |
| 761 | + ]; | |
| 762 | + } | |
| 763 | + | |
| 764 | + $mediaImages = Arr::get($feed->meta, 'media_items', []); | |
| 765 | + $meta = $feed->meta; | |
| 766 | + unset($feed->meta); | |
| 767 | + | |
| 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) | |
| 780 | + ->where('feed_id', $feed->id) | |
| 781 | + ->where('is_active', 1) | |
| 782 | + ->first(); | |
| 783 | + | |
| 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 | + ]]; | |
| 793 | + } | |
| 794 | + } else if ($type != 'meta_data') { | |
| 795 | + $feed->meta = $meta; | |
| 796 | + } | |
| 797 | + } | |
| 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 | + | |
| 808 | + $feed->load('space'); | |
| 809 | + return $feed; | |
| 810 | + } | |
| 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 | + | |
| 859 | + public static function processFeedMetaData($data, $requestData, $existingFeed = null) | |
| 860 | + { | |
| 861 | + if (empty($data['meta'])) { | |
| 862 | + $data['meta'] = []; | |
| 863 | + } | |
| 864 | + | |
| 865 | + $uplaodedDocs = []; | |
| 866 | + // Handle Survey | |
| 867 | + if (!empty($data['survey'])) { | |
| 868 | + $surveyConfig = $data['survey']; | |
| 869 | + if ($existingFeed) { | |
| 870 | + $surveyConfig = Arr::get($existingFeed->meta, 'survey_config', []); | |
| 871 | + if ($surveyConfig) { | |
| 872 | + $oldOptions = Arr::get($surveyConfig, 'options', []); | |
| 873 | + $newOptions = Arr::get($data['survey'], 'options', []); | |
| 874 | + $oldKeyedOptions = []; | |
| 875 | + foreach ($oldOptions as $option) { | |
| 876 | + $oldKeyedOptions[$option['slug']] = $option; | |
| 877 | + } | |
| 878 | + foreach ($newOptions as $index => $option) { | |
| 879 | + $slug = Arr::get($option, 'slug', ''); | |
| 880 | + if (isset($oldKeyedOptions[$slug])) { | |
| 881 | + $newOptions[$index]['vote_counts'] = Arr::get($oldKeyedOptions[$slug], 'vote_counts', 0); | |
| 882 | + } | |
| 883 | + } | |
| 884 | + $surveyConfig['options'] = $newOptions; | |
| 885 | + } else { | |
| 886 | + $surveyConfig = $data['survey']; | |
| 887 | + } | |
| 888 | + } | |
| 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 | + | |
| 896 | + $data['meta']['survey_config'] = $surveyConfig; | |
| 897 | + $data['content_type'] = 'survey'; | |
| 898 | + unset($data['survey']); | |
| 899 | + } | |
| 900 | + | |
| 901 | + // Handle Giphy | |
| 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 | + | |
| 908 | + $data['meta']['media_preview'] = array_filter([ | |
| 909 | + 'image' => sanitize_url($url), | |
| 910 | + 'type' => sanitize_text_field(Arr::get($requestData, 'meta.media_preview.type', 'image')), | |
| 911 | + 'provider' => 'giphy', | |
| 912 | + 'height' => (int)Arr::get($requestData, 'meta.media_preview.height', 0), | |
| 913 | + 'width' => (int)Arr::get($requestData, 'meta.media_preview.width', 0), | |
| 914 | + ]); | |
| 915 | + | |
| 916 | + return [$data, $uplaodedDocs]; | |
| 917 | + } | |
| 918 | + | |
| 919 | + // Handling Video Embed | |
| 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 | + | |
| 968 | + $media = Arr::get($requestData, 'media'); | |
| 969 | + $url = Arr::get($media, 'url'); | |
| 970 | + $metaData = RemoteUrlParser::parse($url); | |
| 971 | + if ($metaData && !is_wp_error($metaData) && (!empty($metaData['image']) || !empty($metaData['html']))) { | |
| 972 | + $data['meta']['media_preview'] = $metaData; | |
| 973 | + } | |
| 974 | + | |
| 975 | + return [$data, $uplaodedDocs]; | |
| 976 | + } | |
| 977 | + | |
| 978 | + // Let's handle the uploaded media | |
| 979 | + $mediaImages = Arr::get($requestData, 'media_images', []); | |
| 980 | + if ($mediaImages) { | |
| 981 | + $uploadedImages = Helper::getMediaByProvider($mediaImages); | |
| 982 | + if (!$existingFeed) { | |
| 983 | + $uploadedMediaItems = Helper::getMediaItemsFromUrl($uploadedImages); | |
| 984 | + } else { | |
| 985 | + $uploadedMediaItems = []; | |
| 986 | + foreach ($mediaImages as $mediaImage) { | |
| 987 | + $url = sanitize_url(Arr::get($mediaImage, 'url', '')); | |
| 988 | + if (!$url) { | |
| 989 | + continue; | |
| 990 | + } | |
| 991 | + $mediaItem = Helper::getMediaFromUrl($mediaImage); | |
| 992 | + if ($mediaItem) { | |
| 993 | + $uploadedMediaItems[] = $mediaItem; | |
| 994 | + } else { | |
| 995 | + // maybe this is a previously uploaded image | |
| 996 | + $media = Media::where('media_url', $url) | |
| 997 | + ->where('object_source', 'feed') | |
| 998 | + ->where('feed_id', $existingFeed->id) | |
| 999 | + ->where('is_active', 1) | |
| 1000 | + ->first(); | |
| 1001 | + | |
| 1002 | + if ($media) { | |
| 1003 | + $uploadedMediaItems[] = $media; | |
| 1004 | + } | |
| 1005 | + } | |
| 1006 | + } | |
| 1007 | + } | |
| 1008 | + | |
| 1009 | + if (count($uploadedMediaItems) == 1) { | |
| 1010 | + $singleMedia = $uploadedMediaItems[0]; | |
| 1011 | + $data['meta']['media_preview'] = [ | |
| 1012 | + 'is_uploaded' => true, | |
| 1013 | + 'image' => $singleMedia->public_url, | |
| 1014 | + 'type' => 'meta_data', | |
| 1015 | + 'provider' => 'uploader', | |
| 1016 | + 'width' => Arr::get($singleMedia->settings, 'width'), | |
| 1017 | + 'height' => Arr::get($singleMedia->settings, 'height'), | |
| 1018 | + 'media_id' => $singleMedia->id, | |
| 1019 | + ]; | |
| 1020 | + } else if ($uploadedMediaItems) { | |
| 1021 | + $mediaPreviews = []; | |
| 1022 | + foreach ($uploadedMediaItems as $mediaItem) { | |
| 1023 | + $mediaData = [ | |
| 1024 | + 'media_id' => $mediaItem->id, | |
| 1025 | + 'url' => $mediaItem->public_url, | |
| 1026 | + 'type' => 'image', | |
| 1027 | + 'width' => Arr::get($mediaItem->settings, 'width'), | |
| 1028 | + 'height' => Arr::get($mediaItem->settings, 'height'), | |
| 1029 | + 'provider' => Arr::get($mediaItem->settings, 'provider', 'uploader') | |
| 1030 | + ]; | |
| 1031 | + $mediaPreviews[] = array_filter($mediaData); | |
| 1032 | + } | |
| 1033 | + $data['meta']['media_items'] = $mediaPreviews; | |
| 1034 | + } | |
| 1035 | + | |
| 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]; | |
| 1041 | + } | |
| 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 | + | |
| 1049 | + // Let's handle the fallback here | |
| 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 | + | |
| 1071 | + if ($firstUrl) { | |
| 1072 | + $metaData = RemoteUrlParser::parse($firstUrl); | |
| 1073 | + if ($metaData && !is_wp_error($metaData) && (!empty($metaData['image']) || !empty($metaData['html']))) { | |
| 1074 | + $data['meta']['media_preview'] = $metaData; | |
| 1075 | + } | |
| 1076 | + } | |
| 1077 | + | |
| 1078 | + $uplaodedDocs = apply_filters('fluent_community/feed/uploaded_feed_medias', $uplaodedDocs, $requestData); | |
| 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  and  | |
| 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); | |
| 264 | 1326 | } |
| 265 | 1327 | } |