PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.11.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.11.0
2.11.0 2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 All 78 releases
← All changes | app/Services/Helper.php +1033 -164 1.0.972.11.0 View file →
@@ -5,8 +5,10 @@
5 5 use FluentCommunity\App\App;
6 6 use FluentCommunity\App\Functions\Utility;
7 7 use FluentCommunity\App\Hooks\Handlers\ActivationHandler;
8 8 use FluentCommunity\App\Models\BaseSpace;
9 +use FluentCommunity\App\Models\Contact;
10 +use FluentCommunity\App\Models\Feed;
9 11 use FluentCommunity\App\Models\Space;
10 12 use FluentCommunity\App\Models\Media;
11 13 use FluentCommunity\App\Models\Meta;
12 14 use FluentCommunity\App\Models\SpaceUserPivot;
@@ -13,8 +15,9 @@
13 15 use FluentCommunity\App\Models\User;
14 16 use FluentCommunity\App\Models\XProfile;
15 17 use FluentCommunity\Framework\Support\Arr;
16 18 use FluentCommunity\App\Models\SpaceGroup;
19 +use FluentCommunity\Modules\Course\Model\Course;
17 20
18 21 /**
19 22 * Helper class for various utility functions.
20 23 */
@@ -26,8 +29,55 @@
26 29 return apply_filters('fluent_community/is_rtl', is_rtl());
27 30 }
28 31
29 32 /**
33 + * Run a callback inside a database transaction.
34 + *
35 + * @param callable $callback
36 + * @return mixed
37 + * @throws \Exception
38 + */
39 + public static function dbTransaction($callback)
40 + {
41 + $db = App::make('db');
42 + $db->beginTransaction();
43 +
44 + try {
45 + $result = $callback();
46 + $db->commit();
47 + return $result;
48 + } catch (\Exception $e) {
49 + $db->rollBack();
50 + throw $e;
51 + }
52 + }
53 +
54 + /**
55 + * Check if POST content length exceeds PHP limits
56 + *
57 + * @return array|false Error array if limit exceeded, false otherwise
58 + */
59 + public static function checkUploadSizeError()
60 + {
61 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Server variable check
62 + $contentLength = isset($_SERVER['CONTENT_LENGTH']) ? (int) $_SERVER['CONTENT_LENGTH'] : 0;
63 + $postMaxSize = wp_convert_hr_to_bytes(ini_get('post_max_size'));
64 +
65 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only existence check on superglobals, no state mutation
66 + if ($contentLength > 0 && ($contentLength > $postMaxSize || (empty($_FILES) && empty($_POST)))) {
67 + return [
68 + 'message' => sprintf(
69 + /* translators: %s: max size */
70 + __('Upload failed: File exceeds server limit (%s). Please upload a smaller file.', 'fluent-community'),
71 + size_format($postMaxSize)
72 + )
73 + ];
74 + }
75 +
76 + return false;
77 + }
78 +
79 + /**
30 80 * Get the portal slug.
31 81 *
32 82 * @return string The portal slug.
33 83 */
@@ -54,17 +104,20 @@
54 104 if (!$forRoute) {
55 105 return $slug;
56 106 }
57 107
58 - $siteUrl = get_site_url();
108 + $siteUrl = get_home_url();
59 109
60 110 $poralUrl = self::baseUrl('/');
61 111
62 - // get the url without path
63 - $siteUrl = str_replace(parse_url($siteUrl, PHP_URL_PATH), '', $siteUrl);
112 + $urlPath = wp_parse_url($siteUrl, PHP_URL_PATH);
64 113
114 + if ($urlPath) {
115 + // get the url without path
116 + $siteUrl = str_replace($urlPath, '', $siteUrl);
117 + }
118 +
65 119 $slug = str_replace($siteUrl, '', $poralUrl);
66 -
67 120 // remove the first and last slashes
68 121 return trim($slug, '/');
69 122 }
70 123
@@ -99,15 +152,82 @@
99 152 return apply_filters('fluent_community/has_color_scheme', $status);
100 153 }
101 154
102 155 /**
103 - * Check if the user is a site admin.
104 - *
105 - * @param int|null $userId The user ID to check. If null, checks the current user.
106 - * @return bool True if the user is a site admin, false otherwise.
156 + * Admin default theme mode: 'light', 'dark', or 'system'.
107 157 */
108 - public static function isSiteAdmin($userId = null)
158 + public static function getDefaultThemeMode()
109 159 {
160 + $settings = Utility::getCustomizationSettings();
161 + $mode = isset($settings['default_theme_mode']) ? $settings['default_theme_mode'] : 'light';
162 +
163 + if (!in_array($mode, ['light', 'dark', 'system'], true)) {
164 + $mode = 'light';
165 + }
166 +
167 + return apply_filters('fluent_community/default_theme_mode', $mode);
168 + }
169 +
170 + /**
171 + * Pre-paint script that sets the theme before first render (no flash).
172 + * Precedence mirrors runtime: host-theme cookie → user pick → admin default.
173 + * Not persisted. Gate on hasColorScheme().
174 + */
175 + public static function renderColorSchemePrePaintScript()
176 + {
177 + $defaultMode = self::getDefaultThemeMode();
178 + $portalVars = apply_filters('fluent_community/general_portal_vars', ['color_switch_cookie_name' => '']);
179 + $cookieName = isset($portalVars['color_switch_cookie_name']) ? $portalVars['color_switch_cookie_name'] : '';
180 + ?>
181 + <script>
182 + (function () {
183 + var root = document.documentElement;
184 + var cookieName = '<?php echo esc_js($cookieName); ?>';
185 + var mode = null;
186 +
187 + // host-theme integration cookie (Blocksy/Kadence) wins when present
188 + if (cookieName) {
189 + var safeName = cookieName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
190 + var match = document.cookie.match('(?:^|; )' + safeName + '=([^;]*)');
191 + if (match) {
192 + var cookieMode = decodeURIComponent(match[1]);
193 + if (cookieMode === 'dark' || cookieMode === 'light') {
194 + mode = cookieMode;
195 + }
196 + }
197 + }
198 +
199 + // explicit user pick
200 + if (!mode) {
201 + try {
202 + var stored = JSON.parse(localStorage.getItem('fcom_global_storage') || '{}').fcom_color_mode;
203 + if (stored === 'dark' || stored === 'light') {
204 + mode = stored;
205 + }
206 + } catch (error) {}
207 + }
208 +
209 + // admin default
210 + if (!mode) {
211 + var defaultMode = '<?php echo esc_js($defaultMode); ?>';
212 + if (defaultMode === 'system') {
213 + mode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
214 + } else {
215 + mode = defaultMode;
216 + }
217 + }
218 +
219 + root.setAttribute('data-color-mode', mode === 'dark' ? 'dark' : 'light');
220 + if (mode === 'dark') {
221 + root.classList.add('dark');
222 + }
223 + })();
224 + </script>
225 + <?php
226 + }
227 +
228 + public static function isSuperAdmin($userId = null)
229 + {
110 230 $capability = apply_filters('fluent_community/super_admin_capability', 'manage_options');
111 231
112 232 if (!$capability) {
113 233 return false;
@@ -123,14 +243,37 @@
123 243
124 244 return user_can($userId, $capability);
125 245 }
126 246
247 + /**
248 + * Check if the user is a site admin.
249 + *
250 + * @param int|null $userId The user ID to check. If null, checks the current user.
251 + * @param \FluentCommunity\App\Models\User|null $user Resolved user model, to save a lookup.
252 + * @return bool True if the user is a site admin, false otherwise.
253 + */
254 + public static function isSiteAdmin($userId = null, $user = null)
255 + {
256 + if (self::isSuperAdmin($userId)) {
257 + return true;
258 + }
259 +
260 + if (!$user) {
261 + $user = ($userId && (int)$userId !== get_current_user_id())
262 + ? User::find($userId)
263 + : self::getCurrentUser();
264 + }
265 +
266 + return $user && Arr::get($user->getPermissions(), 'community_admin');
267 + }
268 +
127 269 public static function isModerator($user = null)
128 270 {
129 271 if (!$user) {
130 272 $user = self::getCurrentUser();
131 273 }
132 - return $user && $user->isCommunityModerator();
274 +
275 + return $user && $user->hasCommunityModeratorAccess();
133 276 }
134 277
135 278 /**
136 279 * Get the URL for an asset file.
@@ -209,8 +352,12 @@
209 352 * @return bool True if the user is in the space, false otherwise.
210 353 */
211 354 public static function isUserInSpace($userId, $spaceId)
212 355 {
356 + if (!$userId || !$spaceId) {
357 + return false;
358 + }
359 +
213 360 return SpaceUserPivot::where('user_id', $userId)
214 361 ->where('space_id', $spaceId)
215 362 ->where('status', 'active')
216 363 ->exists();
@@ -272,8 +419,19 @@
272 419
273 420 return Media::where('media_key', $key)->first();
274 421 }
275 422
423 + public static function removeMediaByUrl($url = '', $subObjectId = null)
424 + {
425 + if (!$url || !$subObjectId) {
426 + return;
427 + }
428 +
429 + do_action('fluent_community/remove_medias_by_url', [$url], [
430 + 'sub_object_id' => $subObjectId,
431 + ]);
432 + }
433 +
276 434 /**
277 435 * Get media items from multiple URLs.
278 436 *
279 437 * @param array $urls An array of URLs.
@@ -314,20 +472,25 @@
314 472 'site_title' => get_bloginfo('name'),
315 473 'slug' => 'portal',
316 474 'logo' => '',
317 475 'white_logo' => '',
476 + 'logo_permalink_type' => 'default',
477 + 'logo_permalink' => '',
318 478 'featured_image' => '',
319 479 'access' => [
320 - 'acess_level' => 'logged_in', // logged_in, public, role_based
480 + 'acess_level' => 'public', // logged_in, public, role_based
321 481 'access_roles' => []
322 482 ],
323 483 'auth_form_type' => 'default',
484 + 'explicit_registration' => 'no',
324 485 'disable_global_posts' => 'yes',
325 486 'auth_content' => 'Please login first to access this page',
326 487 'auth_redirect' => '',
327 - 'restricted_role_content' => 'Sorry, you can not access to this page. Only authorized users can access this page.',
488 + 'restricted_role_content' => 'Sorry, you cannot access this page. Only authorized users can access this page.',
328 489 'auth_url' => '',
329 490 'cutsom_auth_url' => self::baseUrl('?fcom_action=auth'),
491 + 'use_custom_signup_page' => 'no',
492 + 'custom_signup_url' => ''
330 493 ];
331 494
332 495 $settings = wp_parse_args($settings, $defaults);
333 496 if ($settings['auth_form_type'] != 'custom' || empty($settings['auth_form_type'])) {
@@ -357,9 +520,9 @@
357 520 *
358 521 * @param int|null $userId The user ID. If null, uses the current user.
359 522 * @return bool True if the user can access the portal, false otherwise.
360 523 */
361 - public static function canAccessPortal($userId = null)
524 + public static function canAccessPortal($userId = null, $requireActiveProfile = true)
362 525 {
363 526 $settings = self::generalSettings();
364 527 $accessLevel = Arr::get($settings, 'access.acess_level');
365 528
@@ -396,8 +559,12 @@
396 559 if (!$result) {
397 560 return apply_filters('fluent_community/can_access_portal', false);
398 561 }
399 562
563 + if (!$requireActiveProfile) {
564 + return apply_filters('fluent_community/can_access_portal', true);
565 + }
566 +
400 567 $xProfile = Helper::getCurrentProfile();
401 568
402 569 $result = $xProfile && $xProfile->status == 'active';
403 570
@@ -412,19 +579,22 @@
412 579 public static function portalRoutePaths()
413 580 {
414 581 return apply_filters('fluent_community/app_route_paths', [
415 582 'portal_home',
583 + 'members',
584 + 'bookmarks',
585 + 'chat',
586 + 'dashboard',
587 + 'leaderboards',
588 + 'notifications',
416 589 'space',
417 590 'discover',
418 - 'members',
419 591 'courses',
420 592 'u',
421 - 'leaderboards',
422 - 'chat',
423 - 'notifications',
424 - 'bookmarks',
425 593 'post',
426 - 'admin'
594 + 'admin',
595 + 'course',
596 + 'site-maps'
427 597 ]);
428 598 }
429 599
430 600 /**
@@ -434,21 +604,18 @@
434 604 * @return XProfile|null The user's profile or null if not found.
435 605 */
436 606 public static function getCurrentProfile($cached = true)
437 607 {
438 - $userId = get_current_user_id();
439 - if (!$userId) {
440 - return null;
441 - }
442 -
443 608 static $profile;
444 -
445 609 if ($profile && $cached) {
446 610 return $profile;
447 611 }
448 612
613 + $userId = get_current_user_id();
614 +
449 615 if (!$userId) {
450 - return null;
616 + $profile = null;
617 + return $profile;
451 618 }
452 619
453 620 $profile = XProfile::where('user_id', $userId)->first();
454 621
@@ -467,16 +634,14 @@
467 634 if (!$userId) {
468 635 return false;
469 636 }
470 637
471 - static $user;
472 - if ($user && $cached) {
473 - return $user;
638 + static $users = [];
639 + if ($cached && isset($users[$userId])) {
640 + return $users[$userId];
474 641 }
475 642
476 - $user = User::find($userId);
477 -
478 - return $user;
643 + return $users[$userId] = User::find($userId);
479 644 }
480 645
481 646 /**
482 647 * Get the route paths for the community.
@@ -588,8 +753,31 @@
588 753 return false;
589 754 }
590 755
591 756 /**
757 + * Sanitize embed markup held in a feed/comment meta array on read.
758 + *
759 + * meta.media_preview.html is rendered with v-html in _MediaPreview.vue, so it is
760 + * sanitized on write. Doing it on read as well neutralizes rows that were stored
761 + * before the write-side fix landed, and covers any writer reaching the meta via
762 + * the fluent_community/feed/* filters. The emptiness check keeps this free for the
763 + * vast majority of rows, which carry no embed markup at all.
764 + *
765 + * @param array $meta The unserialized meta array.
766 + * @return array The meta array with any embed markup passed through the allowlist.
767 + */
768 + public static function sanitizeStoredMediaPreview($meta)
769 + {
770 + if (empty($meta['media_preview']['html'])) {
771 + return $meta;
772 + }
773 +
774 + $meta['media_preview']['html'] = RemoteUrlParser::sanitizeOembedHtml($meta['media_preview']['html']);
775 +
776 + return $meta;
777 + }
778 +
779 + /**
592 780 * Get a human-readable excerpt from content.
593 781 *
594 782 * @param string $content The content to extract from.
595 783 * @param int $length The maximum length of the excerpt.
@@ -610,12 +798,12 @@
610 798 // Blockquotes: remove '>' symbol
611 799 '/^\s*>\s?/m' => '',
612 800 // Horizontal rules: replace with empty line
613 801 '/^\s*([-*_])\1{2,}\s*$/m' => "\n",
802 + // Images: keep only the alt text (run before links)
803 + '/!\[([^\]]*)\]\([^\)]+\)/' => '$1',
614 804 // Links: keep only the link text
615 - '/\[([^\]]+)\]\([^\)]+\)/' => '$1',
616 - // Images: keep only the alt text
617 - '/!\[([^\]]+)\]\([^\)]+\)/' => '$1',
805 + '/\[([^\]]*)\]\([^\)]+\)/' => '$1',
618 806 // Strikethrough: remove '~~' symbols
619 807 '/~~(.*?)~~/' => '$1',
620 808 // Task lists: remove checkbox syntax
621 809 '/^\s*[-*+]\s+\[[ xX]\]\s+/m' => '',
@@ -622,8 +810,10 @@
622 810 ];
623 811
624 812 $content = preg_replace(array_keys($patterns), array_values($patterns), $content);
625 813
814 + $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
815 +
626 816 // remove all tags
627 817 $content = wp_strip_all_tags($content);
628 818 // remove new lines and tabs
629 819 $content = str_replace(["\r", "\n", "\t"], ' ', $content);
@@ -686,9 +876,9 @@
686 876 *
687 877 * @param User|null $user The user to get menu groups for.
688 878 * @return array The community menu groups.
689 879 */
690 - public static function getCommunityMenuGroups($user = null)
880 + public static function getCommunityMenuGroups($user = null, $view = true)
691 881 {
692 882 if (!$user) {
693 883 $user = self::getCurrentUser();
694 884 }
@@ -698,52 +888,74 @@
698 888 if ($communityGroups->isEmpty()) {
699 889 return [];
700 890 }
701 891
702 - $isMod = $user && $user->isCommunityModerator();
892 + $userSpaceIds = $user ? self::getUserSpaceIds($user->ID) : [];
893 + $isComModerator = $user && $user->hasCommunityModeratorAccess();
894 + $isCourseCreator = $user && $user->hasCourseCreatorAccess();
895 + $isSpaceModerator = $user && $user->isSpaceModerator();
703 896
704 897 $formattedGroups = [];
705 -
706 898 foreach ($communityGroups as $communityGroup) {
899 + $validSpaces = [];
707 900 $spaces = $communityGroup->spaces;
708 - $validSpaces = [];
709 901 $isShowAll = Arr::get($communityGroup->settings, 'always_show_spaces') === 'yes';
710 902
711 - foreach ($spaces as $space) {
712 - if ($isMod) {
713 - $validSpaces[] = self::transformSpaceToLink($space);
903 + if (!$isShowAll && !$isSpaceModerator) {
904 + $spaceIds = $spaces->pluck('id')->toArray();
905 + $isNotMemberOfAnySpace = empty(array_intersect($spaceIds, $userSpaceIds));
906 + if ($isNotMemberOfAnySpace) {
714 907 continue;
715 908 }
909 + }
716 910
717 - if ($space->privacy === 'secret') {
718 - if (!$user || !$space->getMembership($user->ID)) {
719 - continue;
720 - }
721 - $validSpaces[] = self::transformSpaceToLink($space);
911 + if ($user) {
912 + BaseSpace::preloadMemberships($spaces, $user->ID);
913 + }
914 +
915 + foreach ($spaces as $space) {
916 + $validSpace = $view ? self::transformSpaceToLink($space, $user) : $space;
917 + if (!$validSpace) {
722 918 continue;
723 919 }
724 920
725 - if ($isShowAll || $space->privacy = 'public') {
726 - $validSpace = self::transformSpaceToLink($space);
921 + if ($user && $space->isContentSpace()) {
922 + $validSpace['unread_badge'] = self::getUnreadFeedsCounts($space->id);
923 + }
727 924
728 - if ($space->privacy == 'private') {
729 - if (!$user || !$space->getMembership($user->ID)) {
730 - $validSpace['show_lock'] = true;
731 - }
732 - }
925 + if ($isComModerator && $space->type != 'course') {
926 + $validSpaces[] = $validSpace;
927 + continue;
928 + }
733 929
930 + if ($isCourseCreator && $space->type == 'course') {
734 931 $validSpaces[] = $validSpace;
735 932 continue;
736 933 }
737 934
738 - if (!$user || $space->getMembership($user->ID)) {
935 + if ($space->privacy == 'public') {
936 + $validSpaces[] = $validSpace;
739 937 continue;
740 938 }
741 939
742 - $validSpaces[] = self::transformSpaceToLink($space);
940 + $hasMembership = $user && $space->getMembership($user->ID);
941 +
942 + if ($space->privacy == 'private') {
943 + if (!$user || !$hasMembership) {
944 + $validSpace['show_lock'] = true;
945 + }
946 + }
947 +
948 + if ($space->privacy == 'secret') {
949 + if (!$user || !$hasMembership) {
950 + continue;
951 + }
952 + }
953 +
954 + $validSpaces[] = $validSpace;
743 955 }
744 956
745 - if (!$validSpaces && !$isMod) {
957 + if (!$validSpaces && !$isSpaceModerator) {
746 958 continue;
747 959 }
748 960
749 961 $formattedGroups[] = [
@@ -757,19 +969,55 @@
757 969
758 970 return apply_filters('fluent_community/menu_groups_for_user', $formattedGroups, $user);
759 971 }
760 972
973 + public static function getUnreadFeedsCounts($spaceId, $force = false)
974 + {
975 + static $coutsCache = null;
976 + if ($coutsCache !== null && !$force) {
977 + return Arr::get((array)$coutsCache, $spaceId);
978 + }
979 +
980 + $xprofile = self::getCurrentProfile();
981 +
982 + if (!$xprofile || !$xprofile->last_activity) {
983 + return 0;
984 + }
985 +
986 + $lastActivityDate = gmdate('Y-m-d H:i:s', strtotime($xprofile->last_activity) - 300);
987 +
988 + $lastActivityDate = apply_filters('fluent_community/last_activity_date_for_unread_feeds', $lastActivityDate, $xprofile);
989 +
990 + $unreadCounts = Feed::query()
991 + ->select('space_id', Utility::getApp('db')->raw('COUNT(*) as feed_count'))
992 + ->where('status', 'published')
993 + ->where('created_at', '>', $lastActivityDate)
994 + ->groupBy('space_id')
995 + ->get();
996 +
997 + $coutsCache = [];
998 + foreach ($unreadCounts as $unreadCount) {
999 + $coutsCache[$unreadCount->space_id] = $unreadCount->feed_count > 10 ? '10+' : $unreadCount->feed_count;
1000 + }
1001 +
1002 + return Arr::get($coutsCache, $spaceId);
1003 + }
1004 +
761 1005 /**
762 1006 * Transform a space to a link array.
763 1007 *
764 1008 * @param Space $space The space to transform.
765 - * @return array The transformed space link array.
1009 + * @param User|null $user The user to check permissions for.
1010 + * @return array|null The transformed space link array.
766 1011 */
767 - private static function transformSpaceToLink($space)
1012 + private static function transformSpaceToLink($space, $user = null)
768 1013 {
1014 + $isCustomLink = $space->type == 'sidebar_link';
1015 + if ($isCustomLink && !self::canViewSideLinkLink($space, $user)) {
1016 + return null;
1017 + }
769 1018
770 1019 $logo = $space->logo;
771 -
772 1020 $title = $space->title;
773 1021
774 1022 if ($space->status == 'draft') {
775 1023 $title = $title . ' ' . __('(Draft)', 'fluent-community');
@@ -780,12 +1028,44 @@
780 1028 'icon_image' => $logo,
781 1029 'shape_svg' => !$logo ? Arr::get($space->settings, 'shape_svg', '') : '',
782 1030 'emoji' => !$logo ? Arr::get($space->settings, 'emoji', '') : '',
783 1031 'permalink' => $space->getPermalink(),
1032 + 'is_custom' => $isCustomLink ? 'yes' : 'no',
1033 + 'new_tab' => ($isCustomLink && Arr::get($space, 'settings.new_tab', 'no') === 'yes') ? 'yes' : 'no',
784 1034 'link_classes' => 'space_menu_item route_url fcom_space_id_' . $space->id . ' fcom_space_' . $space->slug
785 1035 ];
786 1036 }
787 1037
1038 + public static function canViewSideLinkLink($space, $user = null)
1039 + {
1040 + $privacy = $space->privacy;
1041 +
1042 + if ($privacy == 'public') {
1043 + return true;
1044 + }
1045 +
1046 + if ($privacy == 'logged_in') {
1047 + return !!$user;
1048 + }
1049 +
1050 + if ($privacy == 'logged_out_only') {
1051 + return !$user;
1052 + }
1053 +
1054 + if (!$user) {
1055 + return false;
1056 + }
1057 +
1058 + $accessIds = Arr::get($space->settings, 'membership_ids', []);
1059 +
1060 + if (!$accessIds) {
1061 + return true;
1062 + }
1063 +
1064 + $userSpaces = $user->getSpaceIds();
1065 + return !!array_intersect($userSpaces, $accessIds);
1066 + }
1067 +
788 1068 public static function isAlreadyOnboarded()
789 1069 {
790 1070 $communitySettings = get_option('fluent_community_settings', []);
791 1071
@@ -857,12 +1137,14 @@
857 1137 if ($menuGroups && $context === 'view') {
858 1138 return $menuGroups;
859 1139 }
860 1140
861 - $menuGroups = Utility::getOption('fluent_community_menu_groups', []);
1141 + $menuGroups = (array) Utility::getOption('fluent_community_menu_groups', []);
862 1142
863 1143 $membersPageStatus = Utility::canViewMembersPage() ? 'yes' : 'no';
864 1144
1145 + $leaderboardPageVisibility = (Utility::canViewLeaderboardMembers() || is_user_logged_in()) ? 'yes' : 'no';
1146 +
865 1147 $defaultMainMenuItems = [
866 1148 'all_feeds' => [
867 1149 'slug' => 'all_feeds',
868 1150 'title' => __('Feed', 'fluent-community'),
@@ -884,9 +1166,9 @@
884 1166 'shape_svg' => '<svg version="1.1" viewBox="0 0 128 128" xml:space="preserve"><g><path d="M64,42c-13.2,0-24,10.8-24,24s10.8,24,24,24s24-10.8,24-24S77.2,42,64,42z M64,82c-8.8,0-16-7.2-16-16s7.2-16,16-16 s16,7.2,16,16S72.8,82,64,82z"/><path d="M64,100.8c-14.9,0-29.2,6.2-39.4,17.1l-2.7,2.9l5.8,5.5l2.7-2.9c8.8-9.4,20.7-14.6,33.6-14.6s24.8,5.2,33.6,14.6l2.7,2.9 l5.8-5.5l-2.7-2.9C93.2,107.1,78.9,100.8,64,100.8z"/><path d="M97,47.9v8c9.4,0,18.1,3.8,24.6,10.7l5.8-5.5C119.6,52.7,108.5,47.9,97,47.9z"/><path d="M116.1,20c0-10.5-8.6-19.1-19.1-19.1S77.9,9.5,77.9,20S86.5,39.1,97,39.1S116.1,30.5,116.1,20z M85.9,20 c0-6.1,5-11.1,11.1-11.1s11.1,5,11.1,11.1s-5,11.1-11.1,11.1S85.9,26.1,85.9,20z"/><path d="M31,47.9c-11.5,0-22.6,4.8-30.4,13.2l5.8,5.5c6.4-6.9,15.2-10.7,24.6-10.7V47.9z"/><path d="M50.1,20C50.1,9.5,41.5,0.9,31,0.9S11.9,9.5,11.9,20S20.5,39.1,31,39.1S50.1,30.5,50.1,20z M31,31.1 c-6.1,0-11.1-5-11.1-11.1S24.9,8.9,31,8.9s11.1,5,11.1,11.1S37.1,31.1,31,31.1z"/></g></svg>'
885 1167 ],
886 1168 'all_courses' => [
887 1169 'slug' => 'all_courses',
888 - 'title' => 'Courses',
1170 + 'title' => __('Courses', 'fluent-community'),
889 1171 'link_classes' => 'fcom_courses route_url',
890 1172 'is_system' => 'yes',
891 1173 'is_locked' => 'yes',
892 1174 'enabled' => 'yes',
@@ -908,10 +1190,10 @@
908 1190 'leaderboard' => [
909 1191 'slug' => 'leaderboard',
910 1192 'is_system' => 'yes',
911 1193 'is_locked' => 'yes',
912 - 'enabled' => 'yes',
913 - 'is_unavailable' => self::isFeatureEnabled('leader_board_module') ? 'no' : 'yes',
1194 + 'enabled' => $leaderboardPageVisibility,
1195 + 'is_unavailable' => self::isFeatureEnabled('leader_board_module') && $leaderboardPageVisibility == 'yes' ? 'no' : 'yes',
914 1196 'title' => __('Leaderboard', 'fluent-community'),
915 1197 'link_classes' => 'fcom_leaderboards route_url',
916 1198 'permalink' => self::baseUrl('leaderboards'),
917 1199 'shape_svg' => '<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor"><path d="M160-200h160v-320H160v320Zm240 0h160v-560H400v560Zm240 0h160v-240H640v240ZM80-120v-480h240v-240h320v320h240v400H80Z"/></svg>'
@@ -932,9 +1214,9 @@
932 1214 continue;
933 1215 }
934 1216 $defaultItem = Arr::get($defaultMainMenuItems, $item['slug'], []);
935 1217 if ($defaultItem) {
936 - $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug', 'shape_svg'];
1218 + $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug'];
937 1219 foreach ($preservedKeys as $key) {
938 1220 if (isset($defaultItem[$key])) {
939 1221 $item[$key] = Arr::get($defaultItem, $key);
940 1222 }
@@ -940,11 +1222,12 @@
940 1222 }
941 1223 }
942 1224 if (Arr::get($defaultItem, 'is_system') === 'yes') {
943 1225 $item['permalink'] = $defaultItem['permalink'];
944 - $item['emoji'] = '';
945 - $item['icon_image'] = '';
946 1226 $item['link_classes'] = $defaultItem['link_classes'];
1227 + if (empty($item['shape_svg'])) {
1228 + $item['shape_svg'] = $defaultItem['shape_svg'];
1229 + }
947 1230 }
948 1231 }
949 1232 }
950 1233 } else {
@@ -983,11 +1266,9 @@
983 1266
984 1267 $profileDropDownItems = Arr::get($menuGroups, 'profileDropdownItems', []);
985 1268
986 1269 if ($profileDropDownItems && is_array($profileDropDownItems)) {
987 -
988 1270 unset($profileDropDownItems['profile']);
989 -
990 1271 foreach ($profileDropDownItems as $index => &$item) {
991 1272 if (empty($item['slug'])) {
992 1273 unset($profileDropDownItems[$index]);
993 1274 continue;
@@ -993,9 +1274,9 @@
993 1274 continue;
994 1275 }
995 1276 $defaultItem = Arr::get($defaultProfileDropDownItems, $item['slug'], []);
996 1277 if ($defaultItem) {
997 - $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug', 'svg_icon'];
1278 + $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug'];
998 1279 foreach ($preservedKeys as $key) {
999 1280 if (isset($defaultItem[$key])) {
1000 1281 $item[$key] = Arr::get($defaultItem, $key);
1001 1282 }
@@ -1023,18 +1304,21 @@
1023 1304 $afterCommunityMenuGroups = [];
1024 1305 }
1025 1306
1026 1307 if ($context == 'view') {
1027 - $mainItems = array_filter($mainItems, function ($item) {
1028 - return Arr::get($item, 'enabled') === 'yes' && Arr::get($item, 'is_unavailable') !== 'yes';
1308 +
1309 + $currentUser = self::getCurrentUser();
1310 +
1311 + $mainItems = array_filter($mainItems, function ($item) use ($currentUser) {
1312 + return self::isLinkAccessible($item, $currentUser);
1029 1313 });
1030 1314
1031 - $profileDropDownItems = array_filter($profileDropDownItems, function ($item) {
1032 - return Arr::get($item, 'enabled') === 'yes' && Arr::get($item, 'is_unavailable') !== 'yes';
1315 + $profileDropDownItems = array_filter($profileDropDownItems, function ($item) use ($currentUser) {
1316 + return self::isLinkAccessible($item, $currentUser);
1033 1317 });
1034 1318
1035 - $beforeCommunityMenuItems = array_filter($beforeCommunityMenuItems, function ($item) {
1036 - return Arr::get($item, 'enabled') === 'yes' && Arr::get($item, 'is_unavailable') !== 'yes';
1319 + $beforeCommunityMenuItems = array_filter($beforeCommunityMenuItems, function ($item) use ($currentUser) {
1320 + return self::isLinkAccessible($item, $currentUser);
1037 1321 });
1038 1322
1039 1323 $validGroups = [];
1040 1324 foreach ($afterCommunityMenuGroups as $group) {
@@ -1041,10 +1325,10 @@
1041 1325 if (empty($group['items']) || !is_array($group['items'])) {
1042 1326 continue;
1043 1327 }
1044 1328
1045 - $group['items'] = array_filter($group['items'], function ($item) {
1046 - return Arr::get($item, 'enabled') === 'yes' && Arr::get($item, 'is_unavailable') !== 'yes';
1329 + $group['items'] = array_filter($group['items'], function ($item) use ($currentUser) {
1330 + return self::isLinkAccessible($item, $currentUser);
1047 1331 });
1048 1332
1049 1333 if ($group['items']) {
1050 1334 $validGroups[] = $group;
@@ -1066,8 +1350,72 @@
1066 1350 return $menuGroups;
1067 1351 }
1068 1352
1069 1353 /**
1354 + * Drop the links the given user may not see.
1355 + *
1356 + * Space links carry their own privacy, so every place that hands a space's settings
1357 + * to a client has to filter them. Doing that inline is how the feed endpoints came
1358 + * to skip it, so both call sites go through here.
1359 + *
1360 + * @param array $links
1361 + * @param \FluentCommunity\App\Models\User|null $currentUser
1362 + * @return array
1363 + */
1364 + public static function filterAccessibleLinks($links, $currentUser = null)
1365 + {
1366 + if (!$links || !is_array($links)) {
1367 + return [];
1368 + }
1369 +
1370 + return array_values(array_filter($links, function ($link) use ($currentUser) {
1371 + return self::isLinkAccessible($link, $currentUser);
1372 + }));
1373 + }
1374 +
1375 + public static function isLinkAccessible($link, $currentUser = null)
1376 + {
1377 + $isEnabled = Arr::get($link, 'enabled', 'yes') === 'yes';
1378 + $isUnavailable = Arr::get($link, 'is_unavailable') === 'yes';
1379 +
1380 + if (!$isEnabled || $isUnavailable) {
1381 + return false;
1382 + }
1383 +
1384 + $privacy = Arr::get($link, 'privacy', '');
1385 +
1386 + if (!$privacy || $privacy === 'public') {
1387 + return true;
1388 + }
1389 +
1390 + if ($privacy == 'logged_in') {
1391 + return !!$currentUser;
1392 + }
1393 +
1394 + if ($privacy == 'logged_out_only') {
1395 + return !$currentUser;
1396 + }
1397 +
1398 + $membershipIds = Arr::get($link, 'membership_ids', []);
1399 + if (!$membershipIds) {
1400 + return true;
1401 + }
1402 +
1403 + if (!$currentUser) {
1404 + return false;
1405 + }
1406 +
1407 + static $userSpacesIds = [];
1408 + if (!isset($userSpacesIds[$currentUser->ID])) {
1409 + $userSpacesIds[$currentUser->ID] = $currentUser->getJoinedSpaceIds();
1410 + }
1411 +
1412 + $ids = $userSpacesIds[$currentUser->ID];
1413 +
1414 + return $ids && !!array_intersect($ids, $membershipIds);
1415 + }
1416 +
1417 + /**
1070 1418 * Get the meta data for a space.
1071 1419 *
1072 1420 * @param int $spaceId The ID of the space.
1073 1421 * @param string $key The meta key.
@@ -1109,9 +1457,9 @@
1109 1457 } else {
1110 1458 $meta = Meta::create([
1111 1459 'object_type' => 'space',
1112 1460 'object_id' => $spaceId,
1113 - 'meta_key' => $key,
1461 + 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1114 1462 'value' => $value
1115 1463 ]);
1116 1464 }
1117 1465
@@ -1184,60 +1532,58 @@
1184 1532 * @return array The welcome banner configuration.
1185 1533 */
1186 1534 public static function getWelcomeBannerSettings()
1187 1535 {
1188 - return Utility::getFromCache('welcome_banner_settings', function () {
1189 - $defaults = [
1190 - 'login' => [
1191 - 'enabled' => 'no',
1192 - 'description' => '',
1193 - 'mediaType' => 'image',
1194 - 'allowClose' => 'no',
1195 - 'bannerImage' => '',
1196 - 'bannerVideo' => [
1197 - 'type' => 'oembed',
1198 - 'url' => '',
1199 - 'content_type' => '',
1200 - 'provider' => '',
1201 - 'title' => '',
1202 - 'author_name' => '',
1203 - 'html' => ''
1204 - ],
1205 - 'ctaButtons' => []
1536 + $defaults = [
1537 + 'login' => [
1538 + 'enabled' => 'no',
1539 + 'description' => '',
1540 + 'mediaType' => 'image',
1541 + 'allowClose' => 'no',
1542 + 'bannerImage' => '',
1543 + 'bannerVideo' => [
1544 + 'type' => 'oembed',
1545 + 'url' => '',
1546 + 'content_type' => '',
1547 + 'provider' => '',
1548 + 'title' => '',
1549 + 'author_name' => '',
1550 + 'html' => ''
1206 1551 ],
1207 - 'logout' => [
1208 - 'enabled' => 'no',
1209 - 'description' => '',
1210 - 'mediaType' => 'image',
1211 - 'useCustomUrl' => 'no',
1212 - 'bannerImage' => '',
1213 - 'bannerVideo' => [
1214 - 'type' => 'oembed',
1215 - 'url' => '',
1216 - 'content_type' => '',
1217 - 'provider' => '',
1218 - 'title' => '',
1219 - 'author_name' => '',
1220 - 'html' => ''
1221 - ],
1222 - 'ctaButtons' => []
1223 - ]
1224 - ];
1552 + 'ctaButtons' => []
1553 + ],
1554 + 'logout' => [
1555 + 'enabled' => 'no',
1556 + 'description' => '',
1557 + 'mediaType' => 'image',
1558 + 'useCustomUrl' => 'no',
1559 + 'bannerImage' => '',
1560 + 'bannerVideo' => [
1561 + 'type' => 'oembed',
1562 + 'url' => '',
1563 + 'content_type' => '',
1564 + 'provider' => '',
1565 + 'title' => '',
1566 + 'author_name' => '',
1567 + 'html' => ''
1568 + ],
1569 + 'ctaButtons' => []
1570 + ]
1571 + ];
1225 1572
1226 - $settings = Utility::getOption('welcome_banner_settings', []);
1573 + $settings = Utility::getOption('welcome_banner_settings', []);
1227 1574
1228 - $settings = wp_parse_args($settings, $defaults);
1575 + $settings = wp_parse_args($settings, $defaults);
1229 1576
1230 - if (empty(Arr::get($settings, 'login.bannerVideo'))) {
1231 - $settings['login']['bannerVideo'] = $defaults['login']['bannerVideo'];
1232 - }
1577 + if (empty(Arr::get($settings, 'login.bannerVideo'))) {
1578 + $settings['login']['bannerVideo'] = $defaults['login']['bannerVideo'];
1579 + }
1233 1580
1234 - if (empty(Arr::get($settings, 'logout.bannerVideo'))) {
1235 - $settings['logout']['bannerVideo'] = $defaults['logout']['bannerVideo'];
1236 - }
1581 + if (empty(Arr::get($settings, 'logout.bannerVideo'))) {
1582 + $settings['logout']['bannerVideo'] = $defaults['logout']['bannerVideo'];
1583 + }
1237 1584
1238 - return $settings;
1239 - }, WEEK_IN_SECONDS);
1585 + return $settings;
1240 1586 }
1241 1587
1242 1588 public static function getWelcomeBanner($view = 'login')
1243 1589 {
@@ -1258,25 +1604,75 @@
1258 1604
1259 1605 public static function getEnabledFeedLinks()
1260 1606 {
1261 1607 $links = array_filter(self::getFeedLinks(), function ($item) {
1262 - return Arr::get($item, 'enabled') == 'yes' && Arr::get($item, 'is_unavailable') != 'yes';
1608 + return self::isLinkAccessible($item);
1263 1609 });
1264 1610
1265 1611 return array_values($links);
1266 1612 }
1267 1613
1614 + public static function getMobileMenuItems($context = 'headless')
1615 + {
1616 + $xprofile = Helper::getCurrentProfile();
1617 +
1618 + $mainMenuItems = Arr::get(self::getMenuItemsGroup('view'), 'mainMenuItems', []);
1619 +
1620 + $defaultMobileIcons = [
1621 + 'all_feeds' => '<svg width="20" height="18" viewBox="0 0 20 18" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M10 13.166H10.0075H10Z" fill="currentColor"></path><path d="M10 13.166H10.0075" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path><path d="M16.6666 6.08301V10.2497C16.6666 13.3924 16.6666 14.9637 15.6903 15.94C14.714 16.9163 13.1426 16.9163 9.99992 16.9163C6.85722 16.9163 5.28587 16.9163 4.30956 15.94C3.33325 14.9637 3.33325 13.3924 3.33325 10.2497V6.08301" stroke="currentColor" stroke-width="1.5"></path><path d="M18.3333 7.74967L14.714 4.27925C12.4918 2.14842 11.3807 1.08301 9.99996 1.08301C8.61925 1.08301 7.50814 2.14842 5.28592 4.27924L1.66663 7.74967" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path></svg>',
1622 + 'spaces' => '<svg version="1.1" viewBox="0 0 128 128" xml:space="preserve"><g><path d="M64,42c-13.2,0-24,10.8-24,24s10.8,24,24,24s24-10.8,24-24S77.2,42,64,42z M64,82c-8.8,0-16-7.2-16-16s7.2-16,16-16 s16,7.2,16,16S72.8,82,64,82z"></path><path d="M64,100.8c-14.9,0-29.2,6.2-39.4,17.1l-2.7,2.9l5.8,5.5l2.7-2.9c8.8-9.4,20.7-14.6,33.6-14.6s24.8,5.2,33.6,14.6l2.7,2.9 l5.8-5.5l-2.7-2.9C93.2,107.1,78.9,100.8,64,100.8z"></path><path d="M97,47.9v8c9.4,0,18.1,3.8,24.6,10.7l5.8-5.5C119.6,52.7,108.5,47.9,97,47.9z"></path><path d="M116.1,20c0-10.5-8.6-19.1-19.1-19.1S77.9,9.5,77.9,20S86.5,39.1,97,39.1S116.1,30.5,116.1,20z M85.9,20 c0-6.1,5-11.1,11.1-11.1s11.1,5,11.1,11.1s-5,11.1-11.1,11.1S85.9,26.1,85.9,20z"></path><path d="M31,47.9c-11.5,0-22.6,4.8-30.4,13.2l5.8,5.5c6.4-6.9,15.2-10.7,24.6-10.7V47.9z"></path><path d="M50.1,20C50.1,9.5,41.5,0.9,31,0.9S11.9,9.5,11.9,20S20.5,39.1,31,39.1S50.1,30.5,50.1,20z M31,31.1 c-6.1,0-11.1-5-11.1-11.1S24.9,8.9,31,8.9s11.1,5,11.1,11.1S37.1,31.1,31,31.1z"></path></g></svg>'
1623 + ];
1624 +
1625 + $mobileMenuItems = [];
1626 +
1627 + foreach ($defaultMobileIcons as $slug => $defaultIcon) {
1628 + $menuItem = Arr::get($mainMenuItems, $slug);
1629 + if (!$menuItem) {
1630 + continue;
1631 + }
1632 +
1633 + $iconSvg = Arr::get($menuItem, 'shape_svg');
1634 +
1635 + $mobileMenuItems[] = [
1636 + 'route' => [
1637 + 'name' => $slug
1638 + ],
1639 + 'title' => Arr::get($menuItem, 'title'),
1640 + 'icon_svg' => $iconSvg ? CustomSanitizer::sanitizeSvg($iconSvg) : $defaultIcon
1641 + ];
1642 + }
1643 +
1644 + if ($xprofile) {
1645 + $mobileMenuItems[] = [
1646 + 'route' => [
1647 + 'name' => 'user_profile',
1648 + 'params' => [
1649 + 'username' => $xprofile->username
1650 + ]
1651 + ],
1652 + 'title' => __('Profile', 'fluent-community'),
1653 + 'icon_svg' => '<svg viewBox="0 0 1024 1024"><path fill="currentColor" d="M512 512a192 192 0 1 0 0-384 192 192 0 0 0 0 384m0 64a256 256 0 1 1 0-512 256 256 0 0 1 0 512m320 320v-96a96 96 0 0 0-96-96H288a96 96 0 0 0-96 96v96a32 32 0 1 1-64 0v-96a160 160 0 0 1 160-160h448a160 160 0 0 1 160 160v96a32 32 0 1 1-64 0"></path></svg>'
1654 + ];
1655 + } else if (!get_current_user_id()) {
1656 + $mobileMenuItems[] = [
1657 + 'name' => 'login',
1658 + 'title' => __('Login', 'fluent-community'),
1659 + 'permalink' => Helper::getAuthUrl(),
1660 + 'icon_svg' => '<svg viewBox="0 0 1024 1024"><path fill="currentColor" d="M512 512a192 192 0 1 0 0-384 192 192 0 0 0 0 384m0 64a256 256 0 1 1 0-512 256 256 0 0 1 0 512m320 320v-96a96 96 0 0 0-96-96H288a96 96 0 0 0-96 96v96a32 32 0 1 1-64 0v-96a160 160 0 0 1 160-160h448a160 160 0 0 1 160 160v96a32 32 0 1 1-64 0"></path></svg>'
1661 + ];
1662 + }
1663 +
1664 + return apply_filters('fluent_community/mobile_menu', $mobileMenuItems, $xprofile, $context);
1665 + }
1666 +
1268 1667 public static function getFeedLinks()
1269 1668 {
1270 - return Utility::getFromCache('feed_links', function () {
1271 - return Utility::getOption('feed_links', []);
1272 - }, WEEK_IN_SECONDS);
1669 + return Utility::getOption('feed_links', []);
1273 1670 }
1274 1671
1275 1672 public static function updateFeedLinks($links)
1276 1673 {
1277 1674 Utility::updateOption('feed_links', $links);
1278 - Utility::setCache('feed_links', $links, WEEK_IN_SECONDS);
1279 1675 }
1280 1676
1281 1677 /**
1282 1678 * Get the full name of a WordPress user.
@@ -1338,31 +1734,37 @@
1338 1734
1339 1735 /**
1340 1736 * Add a user to a space.
1341 1737 *
1342 - * @param Space | int $space space to add the user to.
1738 + * @param BaseSpace|int $space space to add the user to.
1343 1739 * @param int $userId The ID of the user to add.
1344 1740 * @param string $role The role of the user in the space.
1345 1741 * @param string $by The source of the action.
1346 1742 * @return bool True if the user was added, false otherwise.
1347 1743 */
1348 - public static function addToSpace($space, $userId, $role = 'member', $by = 'self')
1744 + public static function addToSpace($space, $userId, $role = 'member', $by = 'self', $skipSync = false)
1349 1745 {
1350 1746 if (is_numeric($space)) {
1351 - $space = BaseSpace::withoutGlobalScopes()->find($space);
1747 + $space = BaseSpace::onlyMain()->find($space);
1352 1748 }
1353 1749
1354 - if (!$space) {
1750 + if (!$space || !$space instanceof BaseSpace) {
1355 1751 return false;
1356 1752 }
1357 1753
1358 - $user = User::find($userId);
1754 + if (!$skipSync) {
1755 + $user = User::find($userId);
1359 1756
1360 - if (!$user) {
1361 - return false;
1757 + if (!$user) {
1758 + return false;
1759 + }
1760 +
1761 + $user->syncXProfile();
1362 1762 }
1363 1763
1364 - $user->syncXProfile();
1764 + if ($role == 'member' && $space->type == 'course') {
1765 + $role = 'student';
1766 + }
1365 1767
1366 1768 $exist = SpaceUserPivot::where('user_id', $userId)
1367 1769 ->where('space_id', $space->id)
1368 1770 ->first();
@@ -1369,9 +1771,13 @@
1369 1771
1370 1772 if ($exist) {
1371 1773 if ($exist->status != 'active') {
1372 1774 $exist->status = 'active';
1373 - $exist->role = $role;
1775 +
1776 + if (!in_array($exist->role, ['admin', 'moderator'])) {
1777 + $exist->role = $role;
1778 + }
1779 +
1374 1780 $exist->save();
1375 1781
1376 1782 if ($space->type == 'course') {
1377 1783 do_action('fluent_community/course/enrolled', $space, $userId, $by);
@@ -1384,9 +1790,9 @@
1384 1790
1385 1791 return false;
1386 1792 }
1387 1793
1388 - SpaceUserPivot::create([
1794 + $created = SpaceUserPivot::create([
1389 1795 'space_id' => $space->id,
1390 1796 'role' => $role,
1391 1797 'user_id' => $userId
1392 1798 ]);
@@ -1391,13 +1797,18 @@
1391 1797 'user_id' => $userId
1392 1798 ]);
1393 1799
1394 1800 if ($space->type == 'course') {
1395 - do_action('fluent_community/course/enrolled', $space, $userId, $by);
1801 + if (!$space instanceof Course) {
1802 + $space = Course::find($space->id); // we are renewing the model to have access to course relations
1803 + }
1804 + do_action('fluent_community/course/enrolled', $space, $userId, $by, $created);
1396 1805 } else {
1397 - do_action('fluent_community/space/joined', $space, $userId, $by);
1806 + if (!$space instanceof Space) {
1807 + $space = Space::find($space->id); // we are renewing the model to have access to space relations
1808 + }
1809 + do_action('fluent_community/space/joined', $space, $userId, $by, $created);
1398 1810 }
1399 -
1400 1811 return true;
1401 1812 }
1402 1813
1403 1814 /**
@@ -1415,28 +1826,37 @@
1415 1826 return false;
1416 1827 }
1417 1828
1418 1829 if (is_numeric($space)) {
1419 - $space = BaseSpace::query()->withoutGlobalScopes()->find($space);
1830 + $space = BaseSpace::query()->onlyMain()->find($space);
1420 1831 }
1421 1832
1422 - if (!$space) {
1833 + if (!$space || !$space instanceof BaseSpace) {
1423 1834 return false;
1424 1835 }
1425 1836
1837 +
1426 1838 if (!self::isUserInSpace($userId, $space->id)) {
1427 1839 return false;
1428 1840 }
1429 1841
1430 - SpaceUserPivot::bySpace($space->id)
1431 - ->byUser($userId)
1842 + SpaceUserPivot::where('space_id', $space->id)
1843 + ->where('user_id', $userId)
1432 1844 ->delete();
1433 1845
1434 1846 $user->cacheAccessSpaces();
1435 1847
1436 1848 if ($space->type == 'course') {
1849 + if (!$space instanceof Course) {
1850 + $space = Course::find($space->id); // we are renewing the model to have access to course relations
1851 + }
1852 +
1437 1853 do_action('fluent_community/course/student_left', $space, $userId, $by);
1438 1854 } else {
1855 + if (!$space instanceof Space) {
1856 + $space = Space::find($space->id);
1857 + }
1858 + // we are renewing the model to have access to space relations
1439 1859 do_action('fluent_community/space/user_left', $space, $userId, $by);
1440 1860 }
1441 1861
1442 1862 return true;
@@ -1451,21 +1871,29 @@
1451 1871 * @param bool $renderIcon Whether to render the icon or not.
1452 1872 */
1453 1873 public static function renderLink($link, $linkClass = '', $fallback = '<span class="fcom_no_avatar"></span>', $renderIcon = true)
1454 1874 {
1875 + if (!$link || empty($link['permalink'])) {
1876 + return;
1877 + }
1878 +
1879 + $isCustom = Arr::get($link, 'is_custom') == 'yes';
1880 +
1455 1881 $linkAtts = array_filter([
1456 - 'class' => trim($linkClass . ' ' . Arr::get($link, 'link_classes')) . ' fcom_compt_link',
1882 + 'class' => trim($linkClass . ' ' . Arr::get($link, 'link_classes')) . ' fcom_compt_link' . ($isCustom ? ' fcom_custom_link' : ''),
1457 1883 'target' => Arr::get($link, 'new_tab') === 'yes' ? '_blank' : '',
1458 1884 'rel' => Arr::get($link, 'new_tab') === 'yes' ? 'noopener noreferrer' : '',
1459 1885 ]);
1886 +
1460 1887 ?>
1461 - <a aria-label="Go to <?php echo esc_attr(Arr::get($link, 'title')); ?> page"
1462 - href="<?php echo esc_url($link['permalink']); ?>" <?php foreach ($linkAtts as $key => $value) {
1463 - echo esc_attr($key) . '="' . esc_attr($value) . '"';
1888 + <a data-fcom-hint="<?php echo esc_attr(Arr::get($link, 'title')); ?>"
1889 + href="<?php echo esc_url($link['permalink']); ?>"<?php foreach ($linkAtts as $key => $value) {
1890 + echo ' ' . esc_attr($key) . '="' . esc_attr($value) . '"';
1464 1891 } ?>>
1465 1892 <?php $renderIcon && self::printLinkIcon($link, $fallback); ?>
1466 - <span class="community_name"><?php echo wp_kses_post(Arr::get($link, 'title')); ?></span>
1467 - <?php if (Arr::get($link, 'show_lock')): ?>
1893 + <?php // The native title sits on the label span (not the anchor) so it can not duplicate the link's accessible name for screen readers. ?>
1894 + <span class="community_name" title="<?php echo esc_attr(Arr::get($link, 'title')); ?>"><?php echo wp_kses_post((string) Arr::get($link, 'title', '')); ?></span>
1895 + <?php if (Arr::get($link, 'show_lock')) : ?>
1468 1896 <span class="fcom_space_lock">
1469 1897 <i class="el-icon">
1470 1898 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
1471 1899 <path fill="currentColor"
@@ -1474,14 +1902,49 @@
1474 1902 d="M512 544a32 32 0 0 1 32 32v192a32 32 0 1 1-64 0V576a32 32 0 0 1 32-32m192-160v-64a192 192 0 1 0-384 0v64zM512 64a256 256 0 0 1 256 256v128H256V320A256 256 0 0 1 512 64"></path>
1475 1903 </svg>
1476 1904 </i>
1477 1905 </span>
1906 + <?php elseif ($unreadBardge = Arr::get($link, 'unread_badge')) : ?>
1907 + <span class="fcom_space_lock fcom_unread_count">
1908 + <?php echo wp_kses_post($unreadBardge); ?>
1909 + </span>
1478 1910 <?php endif; ?>
1479 -
1480 1911 </a>
1481 1912 <?php
1482 1913 }
1483 1914
1915 + public static function renderMenuItems($menuItems, $linkClass, $fallback = '', $renderIcon = false)
1916 + {
1917 + if (!$menuItems) {
1918 + return;
1919 + }
1920 +
1921 + $renderIcon = $renderIcon || Utility::isCustomizationEnabled('icon_on_header_menu');
1922 +
1923 + foreach ($menuItems as $itemKey => $item): ?>
1924 + <li class="<?php echo esc_attr('fcom_menu_item_' . $itemKey); ?>">
1925 + <?php self::renderLink($item, $linkClass, $fallback, $renderIcon); ?>
1926 + </li>
1927 + <?php endforeach;
1928 + }
1929 +
1930 + public static function renderSettingsItems($settingsItems = [])
1931 + {
1932 + foreach ($settingsItems as $itemKey => $item): ?>
1933 + <li class="<?php echo esc_attr('fcom_menu_item_' . $itemKey); ?>">
1934 + <a class="fcom_menu_link <?php echo esc_attr(Arr::get($item, 'link_classes')); ?>"
1935 + href="<?php echo esc_url($item['permalink']); ?>">
1936 + <?php if (!empty($item['icon_svg'])): ?>
1937 + <i class="el-icon">
1938 + <?php echo CustomSanitizer::sanitizeSvg(Arr::get($item, 'el-icon', '')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
1939 + </i>
1940 + <?php endif; ?>
1941 + <span class="community_name"><?php echo wp_kses_post($item['title']); ?></span>
1942 + </a>
1943 + </li>
1944 + <?php endforeach;
1945 + }
1946 +
1484 1947 /**
1485 1948 * Print a link icon.
1486 1949 *
1487 1950 * @param array $link The link data.
@@ -1539,9 +2002,12 @@
1539 2002 // most probably it's local reverse proxy
1540 2003 if (isset($_SERVER["HTTP_CLIENT_IP"])) {
1541 2004 $ipAddress = sanitize_text_field(wp_unslash($_SERVER["HTTP_CLIENT_IP"]));
1542 2005 } else if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
1543 - $ipAddress = (string)rest_is_ip_address(trim(current(preg_split('/,/', sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR']))))));
2006 + $forwardedIp = trim(current(preg_split('/,/', sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR'])))));
2007 + if (rest_is_ip_address($forwardedIp)) {
2008 + $ipAddress = $forwardedIp;
2009 + }
1544 2010 }
1545 2011 }
1546 2012
1547 2013 if (!$ipAddress) {
@@ -1664,8 +2130,14 @@
1664 2130 public static function getPortalRequestPath($requestUri)
1665 2131 {
1666 2132 $portalSlug = self::getPortalSlug();
1667 2133
2134 + // If portal is mounted at site root with empty requestUri, ignore query-only requests that do not relate to the community portal.
2135 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing check on $_GET, no state mutation
2136 + if ($portalSlug === '' && $requestUri === '' && !empty($_GET) && !self::hasSupportedQueryParam()) {
2137 + return false;
2138 + }
2139 +
1668 2140 if ($portalSlug == $requestUri) {
1669 2141 return 'portal_home';
1670 2142 }
1671 2143
@@ -1680,25 +2152,422 @@
1680 2152
1681 2153 $parts = explode('/', $requestUri);
1682 2154 $start = $parts[0];
1683 2155
2156 + if (!$portalSlug && $start == 'fcom_route') {
2157 + return $start;
2158 + }
2159 +
1684 2160 $routeStats = self::portalRoutePaths();
1685 2161
1686 2162 if (in_array($start, $routeStats)) {
1687 2163 return $requestUri;
1688 2164 }
2165 +
1689 2166 return false;
1690 2167 }
1691 2168
2169 + /**
2170 + * Check if the current request has any supported query parameter.
2171 + *
2172 + * @return bool
2173 + */
2174 + private static function hasSupportedQueryParam()
2175 + {
2176 + $supportedParams = (array) apply_filters('fluent_community/portal_supported_query_params', [
2177 + 'customizer_panel',
2178 + 'create_space'
2179 + ]);
2180 +
2181 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2182 + foreach (array_keys($_GET) as $key) {
2183 + if (strpos($key, 'fcom_') === 0) {
2184 + return true;
2185 + }
2186 + if (in_array($key, $supportedParams, true)) {
2187 + return true;
2188 + }
2189 + }
2190 + return false;
2191 + }
2192 +
1692 2193 public static function getTopicsConfig()
1693 2194 {
1694 - return Utility::getFromCache('topics_config', function () {
1695 - $config = Utility::getOption('topics_config', []);
1696 - $default = [
1697 - 'max_topics_per_post' => 1,
1698 - 'max_topics_per_space' => 20,
1699 - 'show_on_post_card' => 'yes'
1700 - ];
1701 - return wp_parse_args($config, $default);
1702 - }, WEEK_IN_SECONDS);
2195 + $config = Utility::getOption('topics_config', []);
2196 + $default = [
2197 + 'max_topics_per_post' => 1,
2198 + 'max_topics_per_space' => 20,
2199 + 'show_on_post_card' => 'yes'
2200 + ];
2201 + return wp_parse_args($config, $default);
2202 + }
2203 +
2204 + public static function getModerationConfig()
2205 + {
2206 + $config = Utility::getOption('moderation_config', []);
2207 +
2208 + $default = [
2209 + 'is_enabled' => 'no',
2210 + 'profanity_filter' => "",
2211 + 'flag_after_threshold' => 0,
2212 + 'flag_all_new_posts' => 'no',
2213 + 'first_post_approval' => 'no',
2214 + 'first_comment_approval' => 'no',
2215 + 'flag_all_new_posts_spaces' => [],
2216 + 'auto_flag_user_reject_threshold' => 0,
2217 + 'auto_flag_user_report_threshold' => 0,
2218 + ];
2219 +
2220 + return wp_parse_args($config, $default);
2221 + }
2222 +
2223 + public static function getReportReasons()
2224 + {
2225 + return apply_filters('fluent_community/report_reasons', [
2226 + 'harassment' => __('Harassment', 'fluent-community'),
2227 + 'spam' => __('Spam', 'fluent-community'),
2228 + 'offensive' => __('Offensive', 'fluent-community'),
2229 + 'incorrect_space' => __('Incorrect Space', 'fluent-community'),
2230 + 'against_community' => __('Against Community Rules', 'fluent-community'),
2231 + 'other' => __('Other', 'fluent-community'),
2232 + ]);
2233 + }
2234 +
2235 + public static function htmlToMd($html)
2236 + {
2237 + return preg_replace('/<a.*?href="(.*?)".*?>(.*?)<\/a>/', '[$2]($1)', $html);
2238 + }
2239 +
2240 + public static function isProfanity($profanity, $text)
2241 + {
2242 + $profanity = explode(',', $profanity);
2243 + if (empty($profanity)) {
2244 + return false;
2245 + }
2246 + $profanity = array_map('trim', $profanity);
2247 + $profanity = array_map(function ($word) {
2248 + return mb_strtolower($word, 'UTF-8');
2249 + }, $profanity);
2250 + $text = mb_strtolower($text, 'UTF-8');
2251 +
2252 + // Convert words into a regex pattern (ensuring whole-word matching)
2253 + $pattern = '/(?<!\p{L})(' . implode('|', array_map('preg_quote', $profanity)) . ')(?!\p{L})/iu';
2254 +
2255 + if (preg_match($pattern, $text, $matches)) {
2256 + return $matches[0];
2257 + }
2258 +
2259 + return false;
2260 + }
2261 +
2262 + public static function getFullDayName($day)
2263 + {
2264 + $dayMap = [
2265 + 'sun' => 'sunday',
2266 + 'mon' => 'monday',
2267 + 'tue' => 'tuesday',
2268 + 'wed' => 'wednesday',
2269 + 'thu' => 'thursday',
2270 + 'fri' => 'friday',
2271 + 'sat' => 'saturday'
2272 + ];
2273 +
2274 + return isset($dayMap[$day]) ? $dayMap[$day] : $day . 'day';
2275 + }
2276 +
2277 + public static function getPostOrderOptions($context = 'feed')
2278 + {
2279 + $options = [
2280 + 'new_activity' => __('New Activity', 'fluent-community'),
2281 + 'latest' => __('Latest', 'fluent-community'),
2282 + 'oldest' => __('Oldest', 'fluent-community'),
2283 + 'popular' => __('Popular', 'fluent-community'),
2284 + 'likes' => __('Likes', 'fluent-community'),
2285 + 'alphabetical' => __('Alphabetical', 'fluent-community'),
2286 + 'unanswered' => __('Unanswered', 'fluent-community'),
2287 + ];
2288 +
2289 + return apply_filters('fluent_community/post_order_options', $options, $context);
2290 + }
2291 +
2292 + public static function getCommentOrderOptions($context = 'comment')
2293 + {
2294 + $options = [
2295 + 'oldest' => __('Earliest', 'fluent-community'),
2296 + 'latest' => __('Latest', 'fluent-community'),
2297 + 'popular' => __('Popular', 'fluent-community'),
2298 + 'most_replied' => __('Most Replied', 'fluent-community'),
2299 + ];
2300 +
2301 + return apply_filters('fluent_community/comment_order_options', $options, $context);
2302 + }
2303 +
2304 + public static function convertPhpDateToDayJSFormay($phpFormat)
2305 + {
2306 + // Mapping PHP date format characters to Day.js format characters
2307 + $replacements = [
2308 + // Day
2309 + 'd' => 'DD', // Day of the month, 2 digits with leading zeros
2310 + 'D' => 'ddd', // A textual representation of a day, three letters
2311 + 'j' => 'D', // Day of the month without leading zeros
2312 + 'l' => 'dddd', // A full textual representation of the day of the week
2313 + 'N' => 'E', // ISO-8601 numeric representation of the day of the week
2314 + 'S' => 'o', // English ordinal suffix for the day of the month, 2 characters
2315 + 'w' => 'd', // Numeric representation of the day of the week
2316 + 'z' => 'DDD', // The day of the year (starting from 0)
2317 +
2318 + // Week
2319 + 'W' => 'W', // ISO-8601 week number of year, weeks starting on Monday
2320 +
2321 + // Month
2322 + 'F' => 'MMMM', // A full textual representation of a month
2323 + 'm' => 'MM', // Numeric representation of a month, with leading zeros
2324 + 'M' => 'MMM', // A short textual representation of a month, three letters
2325 + 'n' => 'M', // Numeric representation of a month, without leading zeros
2326 + 't' => '', // Not supported in Day.js (Number of days in the given month)
2327 +
2328 + // Year
2329 + 'L' => '', // Not supported in Day.js (Whether it's a leap year)
2330 + 'o' => 'GGGG', // ISO-8601 week-numbering year
2331 + 'Y' => 'YYYY', // A full numeric representation of a year, 4 digits
2332 + 'y' => 'YY', // A two digit representation of a year
2333 +
2334 + // Time
2335 + 'a' => 'a', // Lowercase Ante meridiem and Post meridiem
2336 + 'A' => 'A', // Uppercase Ante meridiem and Post meridiem
2337 + 'B' => '', // Not supported in Day.js (Swatch Internet time)
2338 + 'g' => 'h', // 12-hour format of an hour without leading zeros
2339 + 'G' => 'H', // 24-hour format of an hour without leading zeros
2340 + 'h' => 'hh', // 12-hour format of an hour with leading zeros
2341 + 'H' => 'HH', // 24-hour format of an hour with leading zeros
2342 + 'i' => 'mm', // Minutes with leading zeros
2343 + 's' => 'ss', // Seconds with leading zeros
2344 + 'u' => 'SSS', // Milliseconds (Day.js uses SSS for fractional seconds)
2345 + 'v' => 'SSS', // Milliseconds (Day.js uses SSS for fractional seconds)
2346 +
2347 + // Timezone
2348 + 'e' => '', // Not supported in Day.js (Timezone identifier)
2349 + 'I' => '', // Not supported in Day.js (Whether or not the date is in daylight saving time)
2350 + 'O' => 'ZZ', // Difference to Greenwich time (GMT) in hours
2351 + 'P' => 'Z', // Difference to Greenwich time (GMT) with colon between hours and minutes
2352 + 'T' => '', // Not supported in Day.js (Timezone abbreviation)
2353 + 'Z' => '', // Not supported in Day.js (Timezone offset in seconds)
2354 +
2355 + // Full Date/Time
2356 + 'c' => 'YYYY-MM-DDTHH:mm:ssZ', // ISO 8601 date
2357 + 'r' => 'ddd, DD MMM YYYY HH:mm:ss ZZ', // RFC 2822 formatted date
2358 + 'U' => 'X', // Seconds since the Unix Epoch (January 1 1970 00:00:00 GMT)
2359 + ];
2360 +
2361 + // Replace each PHP date format character with Day.js equivalent
2362 + $dayjsFormat = "";
2363 +
2364 + for ($i = 0; $i < strlen($phpFormat); $i++) {
2365 + $char = $phpFormat[$i];
2366 +
2367 + // Special handling for G\hi pattern
2368 + if ($char === 'G' && $i + 2 < strlen($phpFormat) &&
2369 + $phpFormat[$i + 1] === '\\' && $phpFormat[$i + 2] === 'h') {
2370 + $dayjsFormat .= 'H[h]';
2371 + $i += 2;
2372 + continue;
2373 + }
2374 +
2375 + // Check if the character is escaped
2376 + if ($char === "\\") {
2377 + // Day.js escapes literal text with square brackets, not backslashes
2378 + $i++;
2379 + if ($i < strlen($phpFormat)) {
2380 + $dayjsFormat .= "[" . $phpFormat[$i] . "]";
2381 + }
2382 + continue;
2383 + }
2384 +
2385 + // Add the mapped character or the character itself if not found in the mapping
2386 + $dayjsFormat .= $replacements[$char] ?? $char;
2387 + }
2388 +
2389 + return $dayjsFormat;
2390 + }
2391 +
2392 + public static function getDateFormatter($isDayJs = false)
2393 + {
2394 + $format = get_option('date_format');
2395 + if ($isDayJs) {
2396 + return self::convertPhpDateToDayJSFormay($format);
2397 + }
2398 +
2399 + return $format;
2400 + }
2401 +
2402 + public static function getTimeFormatter($isDayJs = false)
2403 + {
2404 + $format = get_option('time_format');
2405 +
2406 + if ($isDayJs) {
2407 + return self::convertPhpDateToDayJSFormay($format);
2408 + }
2409 +
2410 + return $format;
2411 + }
2412 +
2413 + public static function normalizeToAscii($text)
2414 + {
2415 + if (function_exists('transliterator_transliterate')) {
2416 + $result = transliterator_transliterate('Any-Latin; Latin-ASCII', $text);
2417 + if ($result !== false) {
2418 + return $result;
2419 + }
2420 + }
2421 +
2422 + if (function_exists('iconv')) {
2423 + $result = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text);
2424 + if ($result !== false) {
2425 + return $result;
2426 + }
2427 + }
2428 +
2429 + return $text;
2430 + }
2431 +
2432 + public static function getPathFromUrl($url)
2433 + {
2434 + return rtrim((string) wp_parse_url($url, PHP_URL_PATH), '/');
2435 + }
2436 +
2437 + // Return the ID of the group that contains the current page
2438 + public static function getActiveSidebarGroupId($groups)
2439 + {
2440 + if (empty($_SERVER['REQUEST_URI'])) {
2441 + return '';
2442 + }
2443 + $url=esc_url_raw(wp_unslash($_SERVER['REQUEST_URI'])); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated
2444 + $currentPath = self::getPathFromUrl($url);
2445 + if ($currentPath === '') {
2446 + return '';
2447 + }
2448 +
2449 + foreach ($groups as $group) {
2450 + $items = isset($group['children']) ? $group['children'] : ($group['items'] ?? []);
2451 + foreach ($items as $item) {
2452 + $item_path = isset($item['permalink']) ? self::getPathFromUrl($item['permalink']) : '';
2453 +
2454 + if (is_array($item) && !empty($item['permalink']) && $item_path === $currentPath) {
2455 + return sanitize_key((string) ($group['id'] ?? $group['slug'] ?? ''));
2456 + }
2457 + }
2458 + }
2459 +
2460 + return '';
2461 + }
2462 +
2463 + // Explicit per-user choices from the cookie. Format: "c:1,2|e:3,4" (c = collapsed, e = expanded).
2464 + public static function getSidebarGroupStates()
2465 + {
2466 + $collapsed = [];
2467 + $expanded = [];
2468 + $cookie = isset($_COOKIE['fcom_sidebar_group_states']) ? sanitize_text_field(wp_unslash($_COOKIE['fcom_sidebar_group_states'])) : '';
2469 + foreach (explode('|', $cookie) as $section) {
2470 + list($flag, $ids) = array_pad(explode(':', $section, 2), 2, '');
2471 + $list = array_filter(array_map('sanitize_key', explode(',', $ids)));
2472 +
2473 + if ($flag === 'c') {
2474 + $collapsed = $list;
2475 + } elseif ($flag === 'e') {
2476 + $expanded = $list;
2477 + }
2478 + }
2479 +
2480 + return [$collapsed, $expanded];
2481 + }
2482 +
2483 + public static function getCollapsedSidebarGroups($groups = [])
2484 + {
2485 + $isDefaultCollapse = Utility::isCustomizationEnabled('collapse_sidebar_groups');
2486 + list($collapsedByUser, $expandedByUser) = self::getSidebarGroupStates();
2487 + $activeGroupId = self::getActiveSidebarGroupId($groups);
2488 +
2489 + /**
2490 + * Precendence.
2491 + * 1. No group id or slug => Fallback to expanded
2492 + * 2. Has Active Link => Always expanded
2493 + * 3. Explicitly expanded by user => Always expanded
2494 + * 4. Explicitly collapsed by user => Collapsed
2495 + * 5. Default Collapse by setting => Collapsed
2496 + * 6. Otherwise => Expanded
2497 + */
2498 +
2499 + $collapsed = [];
2500 + foreach ($groups as $group) {
2501 + $id = sanitize_key((string) ($group['id'] ?? $group['slug'] ?? ''));
2502 + // Matching expanded condition (1,2,3)
2503 + if ($id === '' || $id === $activeGroupId || in_array($id, $expandedByUser, true)) {
2504 + continue;
2505 + }
2506 + // Matching collapsed condition (4,5)
2507 + if ($isDefaultCollapse || in_array($id, $collapsedByUser, true)) {
2508 + $collapsed[] = $id;
2509 + }
2510 + // else (6) => expanded, do nothing
2511 + }
2512 +
2513 + return $collapsed;
2514 + }
2515 +
2516 + public static function getUndeliverableEmails($emails)
2517 + {
2518 + if (!$emails || !defined('FLUENTCRM')) {
2519 + return [];
2520 + }
2521 +
2522 + if (Utility::getPrivacySetting('skip_crm_undeliverable_emails') != 'yes') {
2523 + return [];
2524 + }
2525 +
2526 + /**
2527 + * FluentCRM contact statuses that FluentCommunity treats as undeliverable.
2528 + * Emails to contacts with these statuses are skipped for notification emails.
2529 + *
2530 + * @param array $statuses Contact statuses to skip. Default: bounced, complained, spammed.
2531 + */
2532 + $skippableStatuses = apply_filters('fluent_community/undeliverable_crm_contact_statuses', ['bounced', 'complained', 'spammed']);
2533 +
2534 + if (!$skippableStatuses) {
2535 + return [];
2536 + }
2537 +
2538 + $undeliverableEmails = Contact::whereIn('email', $emails)
2539 + ->whereIn('status', $skippableStatuses)
2540 + ->pluck('email')
2541 + ->toArray();
2542 +
2543 + return array_map('strtolower', $undeliverableEmails);
2544 + }
2545 +
2546 + public static function getCrmUndeliverableStatus($email)
2547 + {
2548 + if (!$email || !defined('FLUENTCRM')) {
2549 + return '';
2550 + }
2551 +
2552 + if (Utility::getPrivacySetting('skip_crm_undeliverable_emails') != 'yes') {
2553 + return '';
2554 + }
2555 +
2556 + $skippableStatuses = apply_filters('fluent_community/undeliverable_crm_contact_statuses', ['bounced', 'complained', 'spammed']);
2557 +
2558 + if (!$skippableStatuses) {
2559 + return '';
2560 + }
2561 +
2562 + $contact = Contact::where('email', $email)
2563 + ->whereIn('status', $skippableStatuses)
2564 + ->first();
2565 +
2566 + return $contact ? $contact->status : '';
2567 + }
2568 +
2569 + public static function isUndeliverableEmail($email)
2570 + {
2571 + return (bool)self::getCrmUndeliverableStatus($email);
1703 2572 }
1704 2573 }