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 +1052 -168 1.0.962.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,30 +29,95 @@
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 */
84 + /**
85 + * Get the portal slug.
86 + *
87 + * @return string The portal slug.
88 + */
34 89 public static function getPortalSlug($forRoute = false)
35 90 {
36 - $settings = self::generalSettings();
37 - $slug = apply_filters('fluent_community/portal_slug', $settings['slug']);
91 + $settings = get_option('fluent_community_settings', []);
92 + if (isset($settings['slug'])) {
93 + $slug = $settings['slug'];
94 + } else {
95 + $slug = 'portal';
96 + }
38 97
98 + if (defined('FLUENT_COMMUNITY_PORTAL_SLUG')) {
99 + $slug = \FLUENT_COMMUNITY_PORTAL_SLUG;
100 + }
101 +
102 + $slug = apply_filters('fluent_community/portal_slug', $slug);
103 +
39 104 if (!$forRoute) {
40 105 return $slug;
41 106 }
42 107
43 - $siteUrl = get_site_url();
108 + $siteUrl = get_home_url();
44 109
45 110 $poralUrl = self::baseUrl('/');
46 111
47 - // get the url without path
48 - $siteUrl = str_replace(parse_url($siteUrl, PHP_URL_PATH), '', $siteUrl);
112 + $urlPath = wp_parse_url($siteUrl, PHP_URL_PATH);
49 113
114 + if ($urlPath) {
115 + // get the url without path
116 + $siteUrl = str_replace($urlPath, '', $siteUrl);
117 + }
118 +
50 119 $slug = str_replace($siteUrl, '', $poralUrl);
51 -
52 120 // remove the first and last slashes
53 121 return trim($slug, '/');
54 122 }
55 123
@@ -84,15 +152,82 @@
84 152 return apply_filters('fluent_community/has_color_scheme', $status);
85 153 }
86 154
87 155 /**
88 - * Check if the user is a site admin.
89 - *
90 - * @param int|null $userId The user ID to check. If null, checks the current user.
91 - * @return bool True if the user is a site admin, false otherwise.
156 + * Admin default theme mode: 'light', 'dark', or 'system'.
92 157 */
93 - public static function isSiteAdmin($userId = null)
158 + public static function getDefaultThemeMode()
94 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 + {
95 230 $capability = apply_filters('fluent_community/super_admin_capability', 'manage_options');
96 231
97 232 if (!$capability) {
98 233 return false;
@@ -108,14 +243,37 @@
108 243
109 244 return user_can($userId, $capability);
110 245 }
111 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 +
112 269 public static function isModerator($user = null)
113 270 {
114 271 if (!$user) {
115 272 $user = self::getCurrentUser();
116 273 }
117 - return $user && $user->isCommunityModerator();
274 +
275 + return $user && $user->hasCommunityModeratorAccess();
118 276 }
119 277
120 278 /**
121 279 * Get the URL for an asset file.
@@ -194,8 +352,12 @@
194 352 * @return bool True if the user is in the space, false otherwise.
195 353 */
196 354 public static function isUserInSpace($userId, $spaceId)
197 355 {
356 + if (!$userId || !$spaceId) {
357 + return false;
358 + }
359 +
198 360 return SpaceUserPivot::where('user_id', $userId)
199 361 ->where('space_id', $spaceId)
200 362 ->where('status', 'active')
201 363 ->exists();
@@ -257,8 +419,19 @@
257 419
258 420 return Media::where('media_key', $key)->first();
259 421 }
260 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 +
261 434 /**
262 435 * Get media items from multiple URLs.
263 436 *
264 437 * @param array $urls An array of URLs.
@@ -299,24 +472,29 @@
299 472 'site_title' => get_bloginfo('name'),
300 473 'slug' => 'portal',
301 474 'logo' => '',
302 475 'white_logo' => '',
476 + 'logo_permalink_type' => 'default',
477 + 'logo_permalink' => '',
303 478 'featured_image' => '',
304 479 'access' => [
305 - 'acess_level' => 'logged_in', // logged_in, public, role_based
480 + 'acess_level' => 'public', // logged_in, public, role_based
306 481 'access_roles' => []
307 482 ],
308 483 'auth_form_type' => 'default',
484 + 'explicit_registration' => 'no',
309 485 'disable_global_posts' => 'yes',
310 486 'auth_content' => 'Please login first to access this page',
311 487 'auth_redirect' => '',
312 - '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.',
313 489 'auth_url' => '',
314 - 'cutsom_auth_url' => self::baseUrl('?fcom_action=auth'),
490 + 'cutsom_auth_url' => self::baseUrl('?fcom_action=auth'),
491 + 'use_custom_signup_page' => 'no',
492 + 'custom_signup_url' => ''
315 493 ];
316 494
317 495 $settings = wp_parse_args($settings, $defaults);
318 - if($settings['auth_form_type'] != 'custom' || empty($settings['auth_form_type'])) {
496 + if ($settings['auth_form_type'] != 'custom' || empty($settings['auth_form_type'])) {
319 497 $settings['cutsom_auth_url'] = self::baseUrl('?fcom_action=auth');
320 498 }
321 499
322 500 if (defined('FLUENT_COMMUNITY_PORTAL_SLUG')) {
@@ -342,9 +520,9 @@
342 520 *
343 521 * @param int|null $userId The user ID. If null, uses the current user.
344 522 * @return bool True if the user can access the portal, false otherwise.
345 523 */
346 - public static function canAccessPortal($userId = null)
524 + public static function canAccessPortal($userId = null, $requireActiveProfile = true)
347 525 {
348 526 $settings = self::generalSettings();
349 527 $accessLevel = Arr::get($settings, 'access.acess_level');
350 528
@@ -381,8 +559,12 @@
381 559 if (!$result) {
382 560 return apply_filters('fluent_community/can_access_portal', false);
383 561 }
384 562
563 + if (!$requireActiveProfile) {
564 + return apply_filters('fluent_community/can_access_portal', true);
565 + }
566 +
385 567 $xProfile = Helper::getCurrentProfile();
386 568
387 569 $result = $xProfile && $xProfile->status == 'active';
388 570
@@ -397,19 +579,22 @@
397 579 public static function portalRoutePaths()
398 580 {
399 581 return apply_filters('fluent_community/app_route_paths', [
400 582 'portal_home',
583 + 'members',
584 + 'bookmarks',
585 + 'chat',
586 + 'dashboard',
587 + 'leaderboards',
588 + 'notifications',
401 589 'space',
402 590 'discover',
403 - 'members',
404 591 'courses',
405 592 'u',
406 - 'leaderboards',
407 - 'chat',
408 - 'notifications',
409 - 'bookmarks',
410 593 'post',
411 - 'admin'
594 + 'admin',
595 + 'course',
596 + 'site-maps'
412 597 ]);
413 598 }
414 599
415 600 /**
@@ -419,21 +604,18 @@
419 604 * @return XProfile|null The user's profile or null if not found.
420 605 */
421 606 public static function getCurrentProfile($cached = true)
422 607 {
423 - $userId = get_current_user_id();
424 - if (!$userId) {
425 - return null;
426 - }
427 -
428 608 static $profile;
429 -
430 609 if ($profile && $cached) {
431 610 return $profile;
432 611 }
433 612
613 + $userId = get_current_user_id();
614 +
434 615 if (!$userId) {
435 - return null;
616 + $profile = null;
617 + return $profile;
436 618 }
437 619
438 620 $profile = XProfile::where('user_id', $userId)->first();
439 621
@@ -452,16 +634,14 @@
452 634 if (!$userId) {
453 635 return false;
454 636 }
455 637
456 - static $user;
457 - if ($user && $cached) {
458 - return $user;
638 + static $users = [];
639 + if ($cached && isset($users[$userId])) {
640 + return $users[$userId];
459 641 }
460 642
461 - $user = User::find($userId);
462 -
463 - return $user;
643 + return $users[$userId] = User::find($userId);
464 644 }
465 645
466 646 /**
467 647 * Get the route paths for the community.
@@ -573,8 +753,31 @@
573 753 return false;
574 754 }
575 755
576 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 + /**
577 780 * Get a human-readable excerpt from content.
578 781 *
579 782 * @param string $content The content to extract from.
580 783 * @param int $length The maximum length of the excerpt.
@@ -595,12 +798,12 @@
595 798 // Blockquotes: remove '>' symbol
596 799 '/^\s*>\s?/m' => '',
597 800 // Horizontal rules: replace with empty line
598 801 '/^\s*([-*_])\1{2,}\s*$/m' => "\n",
802 + // Images: keep only the alt text (run before links)
803 + '/!\[([^\]]*)\]\([^\)]+\)/' => '$1',
599 804 // Links: keep only the link text
600 - '/\[([^\]]+)\]\([^\)]+\)/' => '$1',
601 - // Images: keep only the alt text
602 - '/!\[([^\]]+)\]\([^\)]+\)/' => '$1',
805 + '/\[([^\]]*)\]\([^\)]+\)/' => '$1',
603 806 // Strikethrough: remove '~~' symbols
604 807 '/~~(.*?)~~/' => '$1',
605 808 // Task lists: remove checkbox syntax
606 809 '/^\s*[-*+]\s+\[[ xX]\]\s+/m' => '',
@@ -607,8 +810,10 @@
607 810 ];
608 811
609 812 $content = preg_replace(array_keys($patterns), array_values($patterns), $content);
610 813
814 + $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
815 +
611 816 // remove all tags
612 817 $content = wp_strip_all_tags($content);
613 818 // remove new lines and tabs
614 819 $content = str_replace(["\r", "\n", "\t"], ' ', $content);
@@ -671,9 +876,9 @@
671 876 *
672 877 * @param User|null $user The user to get menu groups for.
673 878 * @return array The community menu groups.
674 879 */
675 - public static function getCommunityMenuGroups($user = null)
880 + public static function getCommunityMenuGroups($user = null, $view = true)
676 881 {
677 882 if (!$user) {
678 883 $user = self::getCurrentUser();
679 884 }
@@ -683,52 +888,74 @@
683 888 if ($communityGroups->isEmpty()) {
684 889 return [];
685 890 }
686 891
687 - $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();
688 896
689 897 $formattedGroups = [];
690 -
691 898 foreach ($communityGroups as $communityGroup) {
899 + $validSpaces = [];
692 900 $spaces = $communityGroup->spaces;
693 - $validSpaces = [];
694 901 $isShowAll = Arr::get($communityGroup->settings, 'always_show_spaces') === 'yes';
695 902
696 - foreach ($spaces as $space) {
697 - if ($isMod) {
698 - $validSpaces[] = self::transformSpaceToLink($space);
903 + if (!$isShowAll && !$isSpaceModerator) {
904 + $spaceIds = $spaces->pluck('id')->toArray();
905 + $isNotMemberOfAnySpace = empty(array_intersect($spaceIds, $userSpaceIds));
906 + if ($isNotMemberOfAnySpace) {
699 907 continue;
700 908 }
909 + }
701 910
702 - if ($space->privacy === 'secret') {
703 - if (!$user || !$space->getMembership($user->ID)) {
704 - continue;
705 - }
706 - $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) {
707 918 continue;
708 919 }
709 920
710 - if ($isShowAll || $space->privacy = 'public') {
711 - $validSpace = self::transformSpaceToLink($space);
921 + if ($user && $space->isContentSpace()) {
922 + $validSpace['unread_badge'] = self::getUnreadFeedsCounts($space->id);
923 + }
712 924
713 - if ($space->privacy == 'private') {
714 - if (!$user || !$space->getMembership($user->ID)) {
715 - $validSpace['show_lock'] = true;
716 - }
717 - }
925 + if ($isComModerator && $space->type != 'course') {
926 + $validSpaces[] = $validSpace;
927 + continue;
928 + }
718 929
930 + if ($isCourseCreator && $space->type == 'course') {
719 931 $validSpaces[] = $validSpace;
720 932 continue;
721 933 }
722 934
723 - if (!$user || $space->getMembership($user->ID)) {
935 + if ($space->privacy == 'public') {
936 + $validSpaces[] = $validSpace;
724 937 continue;
725 938 }
726 939
727 - $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;
728 955 }
729 956
730 - if (!$validSpaces && !$isMod) {
957 + if (!$validSpaces && !$isSpaceModerator) {
731 958 continue;
732 959 }
733 960
734 961 $formattedGroups[] = [
@@ -742,19 +969,55 @@
742 969
743 970 return apply_filters('fluent_community/menu_groups_for_user', $formattedGroups, $user);
744 971 }
745 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 +
746 1005 /**
747 1006 * Transform a space to a link array.
748 1007 *
749 1008 * @param Space $space The space to transform.
750 - * @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.
751 1011 */
752 - private static function transformSpaceToLink($space)
1012 + private static function transformSpaceToLink($space, $user = null)
753 1013 {
1014 + $isCustomLink = $space->type == 'sidebar_link';
1015 + if ($isCustomLink && !self::canViewSideLinkLink($space, $user)) {
1016 + return null;
1017 + }
754 1018
755 1019 $logo = $space->logo;
756 -
757 1020 $title = $space->title;
758 1021
759 1022 if ($space->status == 'draft') {
760 1023 $title = $title . ' ' . __('(Draft)', 'fluent-community');
@@ -765,12 +1028,44 @@
765 1028 'icon_image' => $logo,
766 1029 'shape_svg' => !$logo ? Arr::get($space->settings, 'shape_svg', '') : '',
767 1030 'emoji' => !$logo ? Arr::get($space->settings, 'emoji', '') : '',
768 1031 'permalink' => $space->getPermalink(),
1032 + 'is_custom' => $isCustomLink ? 'yes' : 'no',
1033 + 'new_tab' => ($isCustomLink && Arr::get($space, 'settings.new_tab', 'no') === 'yes') ? 'yes' : 'no',
769 1034 'link_classes' => 'space_menu_item route_url fcom_space_id_' . $space->id . ' fcom_space_' . $space->slug
770 1035 ];
771 1036 }
772 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 +
773 1068 public static function isAlreadyOnboarded()
774 1069 {
775 1070 $communitySettings = get_option('fluent_community_settings', []);
776 1071
@@ -842,12 +1137,14 @@
842 1137 if ($menuGroups && $context === 'view') {
843 1138 return $menuGroups;
844 1139 }
845 1140
846 - $menuGroups = Utility::getOption('fluent_community_menu_groups', []);
1141 + $menuGroups = (array) Utility::getOption('fluent_community_menu_groups', []);
847 1142
848 1143 $membersPageStatus = Utility::canViewMembersPage() ? 'yes' : 'no';
849 1144
1145 + $leaderboardPageVisibility = (Utility::canViewLeaderboardMembers() || is_user_logged_in()) ? 'yes' : 'no';
1146 +
850 1147 $defaultMainMenuItems = [
851 1148 'all_feeds' => [
852 1149 'slug' => 'all_feeds',
853 1150 'title' => __('Feed', 'fluent-community'),
@@ -869,9 +1166,9 @@
869 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>'
870 1167 ],
871 1168 'all_courses' => [
872 1169 'slug' => 'all_courses',
873 - 'title' => 'Courses',
1170 + 'title' => __('Courses', 'fluent-community'),
874 1171 'link_classes' => 'fcom_courses route_url',
875 1172 'is_system' => 'yes',
876 1173 'is_locked' => 'yes',
877 1174 'enabled' => 'yes',
@@ -893,10 +1190,10 @@
893 1190 'leaderboard' => [
894 1191 'slug' => 'leaderboard',
895 1192 'is_system' => 'yes',
896 1193 'is_locked' => 'yes',
897 - 'enabled' => 'yes',
898 - 'is_unavailable' => self::isFeatureEnabled('leader_board_module') ? 'no' : 'yes',
1194 + 'enabled' => $leaderboardPageVisibility,
1195 + 'is_unavailable' => self::isFeatureEnabled('leader_board_module') && $leaderboardPageVisibility == 'yes' ? 'no' : 'yes',
899 1196 'title' => __('Leaderboard', 'fluent-community'),
900 1197 'link_classes' => 'fcom_leaderboards route_url',
901 1198 'permalink' => self::baseUrl('leaderboards'),
902 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>'
@@ -917,9 +1214,9 @@
917 1214 continue;
918 1215 }
919 1216 $defaultItem = Arr::get($defaultMainMenuItems, $item['slug'], []);
920 1217 if ($defaultItem) {
921 - $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug', 'shape_svg'];
1218 + $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug'];
922 1219 foreach ($preservedKeys as $key) {
923 1220 if (isset($defaultItem[$key])) {
924 1221 $item[$key] = Arr::get($defaultItem, $key);
925 1222 }
@@ -925,11 +1222,12 @@
925 1222 }
926 1223 }
927 1224 if (Arr::get($defaultItem, 'is_system') === 'yes') {
928 1225 $item['permalink'] = $defaultItem['permalink'];
929 - $item['emoji'] = '';
930 - $item['icon_image'] = '';
931 1226 $item['link_classes'] = $defaultItem['link_classes'];
1227 + if (empty($item['shape_svg'])) {
1228 + $item['shape_svg'] = $defaultItem['shape_svg'];
1229 + }
932 1230 }
933 1231 }
934 1232 }
935 1233 } else {
@@ -968,11 +1266,9 @@
968 1266
969 1267 $profileDropDownItems = Arr::get($menuGroups, 'profileDropdownItems', []);
970 1268
971 1269 if ($profileDropDownItems && is_array($profileDropDownItems)) {
972 -
973 1270 unset($profileDropDownItems['profile']);
974 -
975 1271 foreach ($profileDropDownItems as $index => &$item) {
976 1272 if (empty($item['slug'])) {
977 1273 unset($profileDropDownItems[$index]);
978 1274 continue;
@@ -978,9 +1274,9 @@
978 1274 continue;
979 1275 }
980 1276 $defaultItem = Arr::get($defaultProfileDropDownItems, $item['slug'], []);
981 1277 if ($defaultItem) {
982 - $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug', 'svg_icon'];
1278 + $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug'];
983 1279 foreach ($preservedKeys as $key) {
984 1280 if (isset($defaultItem[$key])) {
985 1281 $item[$key] = Arr::get($defaultItem, $key);
986 1282 }
@@ -1008,18 +1304,21 @@
1008 1304 $afterCommunityMenuGroups = [];
1009 1305 }
1010 1306
1011 1307 if ($context == 'view') {
1012 - $mainItems = array_filter($mainItems, function ($item) {
1013 - 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);
1014 1313 });
1015 1314
1016 - $profileDropDownItems = array_filter($profileDropDownItems, function ($item) {
1017 - 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);
1018 1317 });
1019 1318
1020 - $beforeCommunityMenuItems = array_filter($beforeCommunityMenuItems, function ($item) {
1021 - 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);
1022 1321 });
1023 1322
1024 1323 $validGroups = [];
1025 1324 foreach ($afterCommunityMenuGroups as $group) {
@@ -1026,10 +1325,10 @@
1026 1325 if (empty($group['items']) || !is_array($group['items'])) {
1027 1326 continue;
1028 1327 }
1029 1328
1030 - $group['items'] = array_filter($group['items'], function ($item) {
1031 - 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);
1032 1331 });
1033 1332
1034 1333 if ($group['items']) {
1035 1334 $validGroups[] = $group;
@@ -1051,8 +1350,72 @@
1051 1350 return $menuGroups;
1052 1351 }
1053 1352
1054 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 + /**
1055 1418 * Get the meta data for a space.
1056 1419 *
1057 1420 * @param int $spaceId The ID of the space.
1058 1421 * @param string $key The meta key.
@@ -1094,9 +1457,9 @@
1094 1457 } else {
1095 1458 $meta = Meta::create([
1096 1459 'object_type' => 'space',
1097 1460 'object_id' => $spaceId,
1098 - 'meta_key' => $key,
1461 + 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1099 1462 'value' => $value
1100 1463 ]);
1101 1464 }
1102 1465
@@ -1169,60 +1532,58 @@
1169 1532 * @return array The welcome banner configuration.
1170 1533 */
1171 1534 public static function getWelcomeBannerSettings()
1172 1535 {
1173 - return Utility::getFromCache('welcome_banner_settings', function () {
1174 - $defaults = [
1175 - 'login' => [
1176 - 'enabled' => 'no',
1177 - 'description' => '',
1178 - 'mediaType' => 'image',
1179 - 'allowClose' => 'no',
1180 - 'bannerImage' => '',
1181 - 'bannerVideo' => [
1182 - 'type' => 'oembed',
1183 - 'url' => '',
1184 - 'content_type' => '',
1185 - 'provider' => '',
1186 - 'title' => '',
1187 - 'author_name' => '',
1188 - 'html' => ''
1189 - ],
1190 - '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' => ''
1191 1551 ],
1192 - 'logout' => [
1193 - 'enabled' => 'no',
1194 - 'description' => '',
1195 - 'mediaType' => 'image',
1196 - 'useCustomUrl' => 'no',
1197 - 'bannerImage' => '',
1198 - 'bannerVideo' => [
1199 - 'type' => 'oembed',
1200 - 'url' => '',
1201 - 'content_type' => '',
1202 - 'provider' => '',
1203 - 'title' => '',
1204 - 'author_name' => '',
1205 - 'html' => ''
1206 - ],
1207 - 'ctaButtons' => []
1208 - ]
1209 - ];
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 + ];
1210 1572
1211 - $settings = Utility::getOption('welcome_banner_settings', []);
1573 + $settings = Utility::getOption('welcome_banner_settings', []);
1212 1574
1213 - $settings = wp_parse_args($settings, $defaults);
1575 + $settings = wp_parse_args($settings, $defaults);
1214 1576
1215 - if (empty(Arr::get($settings, 'login.bannerVideo'))) {
1216 - $settings['login']['bannerVideo'] = $defaults['login']['bannerVideo'];
1217 - }
1577 + if (empty(Arr::get($settings, 'login.bannerVideo'))) {
1578 + $settings['login']['bannerVideo'] = $defaults['login']['bannerVideo'];
1579 + }
1218 1580
1219 - if (empty(Arr::get($settings, 'logout.bannerVideo'))) {
1220 - $settings['logout']['bannerVideo'] = $defaults['logout']['bannerVideo'];
1221 - }
1581 + if (empty(Arr::get($settings, 'logout.bannerVideo'))) {
1582 + $settings['logout']['bannerVideo'] = $defaults['logout']['bannerVideo'];
1583 + }
1222 1584
1223 - return $settings;
1224 - }, WEEK_IN_SECONDS);
1585 + return $settings;
1225 1586 }
1226 1587
1227 1588 public static function getWelcomeBanner($view = 'login')
1228 1589 {
@@ -1243,25 +1604,75 @@
1243 1604
1244 1605 public static function getEnabledFeedLinks()
1245 1606 {
1246 1607 $links = array_filter(self::getFeedLinks(), function ($item) {
1247 - return Arr::get($item, 'enabled') == 'yes' && Arr::get($item, 'is_unavailable') != 'yes';
1608 + return self::isLinkAccessible($item);
1248 1609 });
1249 1610
1250 1611 return array_values($links);
1251 1612 }
1252 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 +
1253 1667 public static function getFeedLinks()
1254 1668 {
1255 - return Utility::getFromCache('feed_links', function () {
1256 - return Utility::getOption('feed_links', []);
1257 - }, WEEK_IN_SECONDS);
1669 + return Utility::getOption('feed_links', []);
1258 1670 }
1259 1671
1260 1672 public static function updateFeedLinks($links)
1261 1673 {
1262 1674 Utility::updateOption('feed_links', $links);
1263 - Utility::setCache('feed_links', $links, WEEK_IN_SECONDS);
1264 1675 }
1265 1676
1266 1677 /**
1267 1678 * Get the full name of a WordPress user.
@@ -1323,31 +1734,37 @@
1323 1734
1324 1735 /**
1325 1736 * Add a user to a space.
1326 1737 *
1327 - * @param Space | int $space space to add the user to.
1738 + * @param BaseSpace|int $space space to add the user to.
1328 1739 * @param int $userId The ID of the user to add.
1329 1740 * @param string $role The role of the user in the space.
1330 1741 * @param string $by The source of the action.
1331 1742 * @return bool True if the user was added, false otherwise.
1332 1743 */
1333 - public static function addToSpace($space, $userId, $role = 'member', $by = 'self')
1744 + public static function addToSpace($space, $userId, $role = 'member', $by = 'self', $skipSync = false)
1334 1745 {
1335 1746 if (is_numeric($space)) {
1336 - $space = BaseSpace::withoutGlobalScopes()->find($space);
1747 + $space = BaseSpace::onlyMain()->find($space);
1337 1748 }
1338 1749
1339 - if (!$space) {
1750 + if (!$space || !$space instanceof BaseSpace) {
1340 1751 return false;
1341 1752 }
1342 1753
1343 - $user = User::find($userId);
1754 + if (!$skipSync) {
1755 + $user = User::find($userId);
1344 1756
1345 - if (!$user) {
1346 - return false;
1757 + if (!$user) {
1758 + return false;
1759 + }
1760 +
1761 + $user->syncXProfile();
1347 1762 }
1348 1763
1349 - $user->syncXProfile();
1764 + if ($role == 'member' && $space->type == 'course') {
1765 + $role = 'student';
1766 + }
1350 1767
1351 1768 $exist = SpaceUserPivot::where('user_id', $userId)
1352 1769 ->where('space_id', $space->id)
1353 1770 ->first();
@@ -1354,9 +1771,13 @@
1354 1771
1355 1772 if ($exist) {
1356 1773 if ($exist->status != 'active') {
1357 1774 $exist->status = 'active';
1358 - $exist->role = $role;
1775 +
1776 + if (!in_array($exist->role, ['admin', 'moderator'])) {
1777 + $exist->role = $role;
1778 + }
1779 +
1359 1780 $exist->save();
1360 1781
1361 1782 if ($space->type == 'course') {
1362 1783 do_action('fluent_community/course/enrolled', $space, $userId, $by);
@@ -1369,9 +1790,9 @@
1369 1790
1370 1791 return false;
1371 1792 }
1372 1793
1373 - SpaceUserPivot::create([
1794 + $created = SpaceUserPivot::create([
1374 1795 'space_id' => $space->id,
1375 1796 'role' => $role,
1376 1797 'user_id' => $userId
1377 1798 ]);
@@ -1376,13 +1797,18 @@
1376 1797 'user_id' => $userId
1377 1798 ]);
1378 1799
1379 1800 if ($space->type == 'course') {
1380 - 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);
1381 1805 } else {
1382 - 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);
1383 1810 }
1384 -
1385 1811 return true;
1386 1812 }
1387 1813
1388 1814 /**
@@ -1400,28 +1826,37 @@
1400 1826 return false;
1401 1827 }
1402 1828
1403 1829 if (is_numeric($space)) {
1404 - $space = BaseSpace::query()->withoutGlobalScopes()->find($space);
1830 + $space = BaseSpace::query()->onlyMain()->find($space);
1405 1831 }
1406 1832
1407 - if (!$space) {
1833 + if (!$space || !$space instanceof BaseSpace) {
1408 1834 return false;
1409 1835 }
1410 1836
1837 +
1411 1838 if (!self::isUserInSpace($userId, $space->id)) {
1412 1839 return false;
1413 1840 }
1414 1841
1415 - SpaceUserPivot::bySpace($space->id)
1416 - ->byUser($userId)
1842 + SpaceUserPivot::where('space_id', $space->id)
1843 + ->where('user_id', $userId)
1417 1844 ->delete();
1418 1845
1419 1846 $user->cacheAccessSpaces();
1420 1847
1421 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 +
1422 1853 do_action('fluent_community/course/student_left', $space, $userId, $by);
1423 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
1424 1859 do_action('fluent_community/space/user_left', $space, $userId, $by);
1425 1860 }
1426 1861
1427 1862 return true;
@@ -1436,21 +1871,29 @@
1436 1871 * @param bool $renderIcon Whether to render the icon or not.
1437 1872 */
1438 1873 public static function renderLink($link, $linkClass = '', $fallback = '<span class="fcom_no_avatar"></span>', $renderIcon = true)
1439 1874 {
1875 + if (!$link || empty($link['permalink'])) {
1876 + return;
1877 + }
1878 +
1879 + $isCustom = Arr::get($link, 'is_custom') == 'yes';
1880 +
1440 1881 $linkAtts = array_filter([
1441 - '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' : ''),
1442 1883 'target' => Arr::get($link, 'new_tab') === 'yes' ? '_blank' : '',
1443 1884 'rel' => Arr::get($link, 'new_tab') === 'yes' ? 'noopener noreferrer' : '',
1444 1885 ]);
1886 +
1445 1887 ?>
1446 - <a aria-label="Go to <?php echo esc_attr(Arr::get($link, 'title')); ?> page"
1447 - href="<?php echo esc_url($link['permalink']); ?>" <?php foreach ($linkAtts as $key => $value) {
1448 - 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) . '"';
1449 1891 } ?>>
1450 1892 <?php $renderIcon && self::printLinkIcon($link, $fallback); ?>
1451 - <span class="community_name"><?php echo wp_kses_post(Arr::get($link, 'title')); ?></span>
1452 - <?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')) : ?>
1453 1896 <span class="fcom_space_lock">
1454 1897 <i class="el-icon">
1455 1898 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
1456 1899 <path fill="currentColor"
@@ -1459,14 +1902,49 @@
1459 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>
1460 1903 </svg>
1461 1904 </i>
1462 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>
1463 1910 <?php endif; ?>
1464 -
1465 1911 </a>
1466 1912 <?php
1467 1913 }
1468 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 +
1469 1947 /**
1470 1948 * Print a link icon.
1471 1949 *
1472 1950 * @param array $link The link data.
@@ -1524,9 +2002,12 @@
1524 2002 // most probably it's local reverse proxy
1525 2003 if (isset($_SERVER["HTTP_CLIENT_IP"])) {
1526 2004 $ipAddress = sanitize_text_field(wp_unslash($_SERVER["HTTP_CLIENT_IP"]));
1527 2005 } else if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
1528 - $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 + }
1529 2010 }
1530 2011 }
1531 2012
1532 2013 if (!$ipAddress) {
@@ -1649,8 +2130,14 @@
1649 2130 public static function getPortalRequestPath($requestUri)
1650 2131 {
1651 2132 $portalSlug = self::getPortalSlug();
1652 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 +
1653 2140 if ($portalSlug == $requestUri) {
1654 2141 return 'portal_home';
1655 2142 }
1656 2143
@@ -1665,25 +2152,422 @@
1665 2152
1666 2153 $parts = explode('/', $requestUri);
1667 2154 $start = $parts[0];
1668 2155
2156 + if (!$portalSlug && $start == 'fcom_route') {
2157 + return $start;
2158 + }
2159 +
1669 2160 $routeStats = self::portalRoutePaths();
1670 2161
1671 2162 if (in_array($start, $routeStats)) {
1672 2163 return $requestUri;
1673 2164 }
2165 +
1674 2166 return false;
1675 2167 }
1676 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 +
1677 2193 public static function getTopicsConfig()
1678 2194 {
1679 - return Utility::getFromCache('topics_config', function () {
1680 - $config = Utility::getOption('topics_config', []);
1681 - $default = [
1682 - 'max_topics_per_post' => 1,
1683 - 'max_topics_per_space' => 20,
1684 - 'show_on_post_card' => 'yes'
1685 - ];
1686 - return wp_parse_args($config, $default);
1687 - }, 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);
1688 2572 }
1689 2573 }