| @@ -9,8 +9,9 @@ | ||
| 9 | 9 | use FluentCommunity\App\Models\Reaction; |
| 10 | 10 | use FluentCommunity\App\Models\Term; |
| 11 | 11 | use FluentCommunity\App\Models\User; |
| 12 | 12 | use FluentCommunity\App\Models\XProfile; |
| 13 | +use FluentCommunity\Framework\Foundation\Exceptions\UnprocessableEntityHttpException; | |
| 13 | 14 | use FluentCommunity\Framework\Support\Arr; |
| 14 | 15 | use FluentCommunity\Framework\Validator\Validator; |
| 15 | 16 | |
| 16 | 17 | class FeedsHelper |
| @@ -58,8 +59,71 @@ | ||
| 58 | 59 | |
| 59 | 60 | return $user->spaces()->pluck('slug')->toArray(); |
| 60 | 61 | } |
| 61 | 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 | + | |
| 62 | 126 | public static function getLastFeedId() |
| 63 | 127 | { |
| 64 | 128 | $lastItem = Feed::where('status', 'published') |
| 65 | 129 | ->byUserAccess(get_current_user_id()) |
| @@ -547,9 +611,9 @@ | ||
| 547 | 611 | } |
| 548 | 612 | |
| 549 | 613 | public static function sanitizeAndValidateData($data) |
| 550 | 614 | { |
| 551 | - $message = CustomSanitizer::unslashMarkdown(trim(Arr::get($data, 'message'))); | |
| 615 | + $message = CustomSanitizer::unslashMarkdown(trim((string) Arr::get($data, 'message', ''))); | |
| 552 | 616 | |
| 553 | 617 | // Decode HTML entities and strip all whitespace for validation |
| 554 | 618 | $messageForValidation = html_entity_decode($message, ENT_QUOTES | ENT_HTML5, 'UTF-8'); |
| 555 | 619 | $messageForValidation = preg_replace('/\s+/u', '', $messageForValidation); |
| @@ -554,9 +618,12 @@ | ||
| 554 | 618 | $messageForValidation = html_entity_decode($message, ENT_QUOTES | ENT_HTML5, 'UTF-8'); |
| 555 | 619 | $messageForValidation = preg_replace('/\s+/u', '', $messageForValidation); |
| 556 | 620 | |
| 557 | 621 | if (!$messageForValidation) { |
| 558 | - throw new \Exception(esc_html__('Message is required', 'fluent-community')); | |
| 622 | + throw new UnprocessableEntityHttpException( | |
| 623 | + esc_html__('Message is required', 'fluent-community'), | |
| 624 | + 'feed_message_required' | |
| 625 | + ); | |
| 559 | 626 | } |
| 560 | 627 | |
| 561 | 628 | $processedData = [ |
| 562 | 629 | 'message' => $message, |
| @@ -596,10 +663,13 @@ | ||
| 596 | 663 | } |
| 597 | 664 | |
| 598 | 665 | $maxlen = apply_filters('fluent_community/max_post_length', 15000); |
| 599 | 666 | if (\strlen($message) > $maxlen) { |
| 600 | - /* translators: %s is the maximum allowed character count */ | |
| 601 | - throw new \Exception(esc_html(sprintf(__('The post is too long. Please keep it under %s characters.', 'fluent-community'), number_format($maxlen)))); | |
| 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 | + ); | |
| 602 | 672 | } |
| 603 | 673 | |
| 604 | 674 | $titlePref = Utility::postTitlePref(); |
| 605 | 675 | |
| @@ -605,9 +675,12 @@ | ||
| 605 | 675 | |
| 606 | 676 | if ($titlePref) { |
| 607 | 677 | $processedData['title'] = sanitize_text_field(Arr::get($data, 'title')); |
| 608 | 678 | if ($titlePref == 'required' && empty($processedData['title'])) { |
| 609 | - throw new \Exception(esc_html__('Title is required. Please provide a title', 'fluent-community')); | |
| 679 | + throw new UnprocessableEntityHttpException( | |
| 680 | + esc_html__('Title is required. Please provide a title', 'fluent-community'), | |
| 681 | + 'feed_title_required' | |
| 682 | + ); | |
| 610 | 683 | } |
| 611 | 684 | // trim the title if it's too long to 192 chars (multibyte-safe; column is VARCHAR(192) characters) |
| 612 | 685 | if (mb_strlen($processedData['title']) > 192) { |
| 613 | 686 | $processedData['title'] = mb_substr($processedData['title'], 0, 192, 'UTF-8'); |
| @@ -1054,8 +1127,24 @@ | ||
| 1054 | 1127 | } |
| 1055 | 1128 | |
| 1056 | 1129 | $spaceSettings = $feed->space ? $feed->space->settings : []; |
| 1057 | 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 | + } | |
| 1058 | 1147 | |
| 1059 | 1148 | self::setCurrentRelatedUserId($feed->user_id); |
| 1060 | 1149 | |
| 1061 | 1150 | return apply_filters('fluent_community/rendering_feed_model', $feed, $config); |