PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.10.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.10.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 +1063 -171 1.0.952.10.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;
@@ -105,21 +240,40 @@
105 240 if (!$userId) {
106 241 return false;
107 242 }
108 243
109 - if (!$userId) {
110 - return current_user_can($capability);
244 + return user_can($userId, $capability);
245 + }
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;
111 258 }
112 259
113 - return user_can($userId, $capability);
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');
114 267 }
115 268
116 269 public static function isModerator($user = null)
117 270 {
118 - if(!$user) {
271 + if (!$user) {
119 272 $user = self::getCurrentUser();
120 273 }
121 - return $user && $user->isCommunityModerator();
274 +
275 + return $user && $user->hasCommunityModeratorAccess();
122 276 }
123 277
124 278 /**
125 279 * Get the URL for an asset file.
@@ -153,8 +307,15 @@
153 307
154 308 return $baseUrl . '/#/' . ltrim($path, '/');
155 309 }
156 310
311 + public static function getAuthUrl()
312 + {
313 + $settings = self::generalSettings();
314 +
315 + return Arr::get($settings, 'cutsom_auth_url', '');
316 + }
317 +
157 318 /**
158 319 * Get the space IDs for a user.
159 320 *
160 321 * @param int|null $userId The user ID. If null, uses the current user.
@@ -191,8 +352,12 @@
191 352 * @return bool True if the user is in the space, false otherwise.
192 353 */
193 354 public static function isUserInSpace($userId, $spaceId)
194 355 {
356 + if (!$userId || !$spaceId) {
357 + return false;
358 + }
359 +
195 360 return SpaceUserPivot::where('user_id', $userId)
196 361 ->where('space_id', $spaceId)
197 362 ->where('status', 'active')
198 363 ->exists();
@@ -254,8 +419,19 @@
254 419
255 420 return Media::where('media_key', $key)->first();
256 421 }
257 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 +
258 434 /**
259 435 * Get media items from multiple URLs.
260 436 *
261 437 * @param array $urls An array of URLs.
@@ -296,21 +472,31 @@
296 472 'site_title' => get_bloginfo('name'),
297 473 'slug' => 'portal',
298 474 'logo' => '',
299 475 'white_logo' => '',
476 + 'logo_permalink_type' => 'default',
477 + 'logo_permalink' => '',
300 478 'featured_image' => '',
301 479 'access' => [
302 - 'acess_level' => 'logged_in', // logged_in, public, role_based
480 + 'acess_level' => 'public', // logged_in, public, role_based
303 481 'access_roles' => []
304 482 ],
483 + 'auth_form_type' => 'default',
484 + 'explicit_registration' => 'no',
305 485 'disable_global_posts' => 'yes',
306 486 'auth_content' => 'Please login first to access this page',
307 487 'auth_redirect' => '',
308 - 'restricted_role_content' => 'Sorry, you can not access to this page. Only authorized users can access this page.',
309 - 'auth_url' => ''
488 + 'restricted_role_content' => 'Sorry, you cannot access this page. Only authorized users can access this page.',
489 + 'auth_url' => '',
490 + 'cutsom_auth_url' => self::baseUrl('?fcom_action=auth'),
491 + 'use_custom_signup_page' => 'no',
492 + 'custom_signup_url' => ''
310 493 ];
311 494
312 495 $settings = wp_parse_args($settings, $defaults);
496 + if ($settings['auth_form_type'] != 'custom' || empty($settings['auth_form_type'])) {
497 + $settings['cutsom_auth_url'] = self::baseUrl('?fcom_action=auth');
498 + }
313 499
314 500 if (defined('FLUENT_COMMUNITY_PORTAL_SLUG')) {
315 501 $settings['slug'] = \FLUENT_COMMUNITY_PORTAL_SLUG;
316 502 $settings['is_slug_defined'] = true;
@@ -334,9 +520,9 @@
334 520 *
335 521 * @param int|null $userId The user ID. If null, uses the current user.
336 522 * @return bool True if the user can access the portal, false otherwise.
337 523 */
338 - public static function canAccessPortal($userId = null)
524 + public static function canAccessPortal($userId = null, $requireActiveProfile = true)
339 525 {
340 526 $settings = self::generalSettings();
341 527 $accessLevel = Arr::get($settings, 'access.acess_level');
342 528
@@ -373,8 +559,12 @@
373 559 if (!$result) {
374 560 return apply_filters('fluent_community/can_access_portal', false);
375 561 }
376 562
563 + if (!$requireActiveProfile) {
564 + return apply_filters('fluent_community/can_access_portal', true);
565 + }
566 +
377 567 $xProfile = Helper::getCurrentProfile();
378 568
379 569 $result = $xProfile && $xProfile->status == 'active';
380 570
@@ -389,19 +579,22 @@
389 579 public static function portalRoutePaths()
390 580 {
391 581 return apply_filters('fluent_community/app_route_paths', [
392 582 'portal_home',
583 + 'members',
584 + 'bookmarks',
585 + 'chat',
586 + 'dashboard',
587 + 'leaderboards',
588 + 'notifications',
393 589 'space',
394 590 'discover',
395 - 'members',
396 591 'courses',
397 592 'u',
398 - 'leaderboards',
399 - 'chat',
400 - 'notifications',
401 - 'bookmarks',
402 593 'post',
403 - 'admin'
594 + 'admin',
595 + 'course',
596 + 'site-maps'
404 597 ]);
405 598 }
406 599
407 600 /**
@@ -411,21 +604,18 @@
411 604 * @return XProfile|null The user's profile or null if not found.
412 605 */
413 606 public static function getCurrentProfile($cached = true)
414 607 {
415 - $userId = get_current_user_id();
416 - if (!$userId) {
417 - return null;
418 - }
419 -
420 608 static $profile;
421 -
422 609 if ($profile && $cached) {
423 610 return $profile;
424 611 }
425 612
613 + $userId = get_current_user_id();
614 +
426 615 if (!$userId) {
427 - return null;
616 + $profile = null;
617 + return $profile;
428 618 }
429 619
430 620 $profile = XProfile::where('user_id', $userId)->first();
431 621
@@ -444,16 +634,14 @@
444 634 if (!$userId) {
445 635 return false;
446 636 }
447 637
448 - static $user;
449 - if ($user && $cached) {
450 - return $user;
638 + static $users = [];
639 + if ($cached && isset($users[$userId])) {
640 + return $users[$userId];
451 641 }
452 642
453 - $user = User::find($userId);
454 -
455 - return $user;
643 + return $users[$userId] = User::find($userId);
456 644 }
457 645
458 646 /**
459 647 * Get the route paths for the community.
@@ -565,8 +753,31 @@
565 753 return false;
566 754 }
567 755
568 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 + /**
569 780 * Get a human-readable excerpt from content.
570 781 *
571 782 * @param string $content The content to extract from.
572 783 * @param int $length The maximum length of the excerpt.
@@ -587,12 +798,12 @@
587 798 // Blockquotes: remove '>' symbol
588 799 '/^\s*>\s?/m' => '',
589 800 // Horizontal rules: replace with empty line
590 801 '/^\s*([-*_])\1{2,}\s*$/m' => "\n",
802 + // Images: keep only the alt text (run before links)
803 + '/!\[([^\]]*)\]\([^\)]+\)/' => '$1',
591 804 // Links: keep only the link text
592 - '/\[([^\]]+)\]\([^\)]+\)/' => '$1',
593 - // Images: keep only the alt text
594 - '/!\[([^\]]+)\]\([^\)]+\)/' => '$1',
805 + '/\[([^\]]*)\]\([^\)]+\)/' => '$1',
595 806 // Strikethrough: remove '~~' symbols
596 807 '/~~(.*?)~~/' => '$1',
597 808 // Task lists: remove checkbox syntax
598 809 '/^\s*[-*+]\s+\[[ xX]\]\s+/m' => '',
@@ -599,8 +810,10 @@
599 810 ];
600 811
601 812 $content = preg_replace(array_keys($patterns), array_values($patterns), $content);
602 813
814 + $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
815 +
603 816 // remove all tags
604 817 $content = wp_strip_all_tags($content);
605 818 // remove new lines and tabs
606 819 $content = str_replace(["\r", "\n", "\t"], ' ', $content);
@@ -663,9 +876,9 @@
663 876 *
664 877 * @param User|null $user The user to get menu groups for.
665 878 * @return array The community menu groups.
666 879 */
667 - public static function getCommunityMenuGroups($user = null)
880 + public static function getCommunityMenuGroups($user = null, $view = true)
668 881 {
669 882 if (!$user) {
670 883 $user = self::getCurrentUser();
671 884 }
@@ -675,52 +888,74 @@
675 888 if ($communityGroups->isEmpty()) {
676 889 return [];
677 890 }
678 891
679 - $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();
680 896
681 897 $formattedGroups = [];
682 -
683 898 foreach ($communityGroups as $communityGroup) {
899 + $validSpaces = [];
684 900 $spaces = $communityGroup->spaces;
685 - $validSpaces = [];
686 901 $isShowAll = Arr::get($communityGroup->settings, 'always_show_spaces') === 'yes';
687 902
688 - foreach ($spaces as $space) {
689 - if ($isMod) {
690 - $validSpaces[] = self::transformSpaceToLink($space);
903 + if (!$isShowAll && !$isSpaceModerator) {
904 + $spaceIds = $spaces->pluck('id')->toArray();
905 + $isNotMemberOfAnySpace = empty(array_intersect($spaceIds, $userSpaceIds));
906 + if ($isNotMemberOfAnySpace) {
691 907 continue;
692 908 }
909 + }
693 910
694 - if ($space->privacy === 'secret') {
695 - if (!$user || !$space->getMembership($user->ID)) {
696 - continue;
697 - }
698 - $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) {
699 918 continue;
700 919 }
701 920
702 - if ($isShowAll || $space->privacy = 'public') {
703 - $validSpace = self::transformSpaceToLink($space);
921 + if ($user && $space->isContentSpace()) {
922 + $validSpace['unread_badge'] = self::getUnreadFeedsCounts($space->id);
923 + }
704 924
705 - if ($space->privacy == 'private') {
706 - if (!$user || !$space->getMembership($user->ID)) {
707 - $validSpace['show_lock'] = true;
708 - }
709 - }
925 + if ($isComModerator && $space->type != 'course') {
926 + $validSpaces[] = $validSpace;
927 + continue;
928 + }
710 929
930 + if ($isCourseCreator && $space->type == 'course') {
711 931 $validSpaces[] = $validSpace;
712 932 continue;
713 933 }
714 934
715 - if (!$user || $space->getMembership($user->ID)) {
935 + if ($space->privacy == 'public') {
936 + $validSpaces[] = $validSpace;
716 937 continue;
717 938 }
718 939
719 - $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;
720 955 }
721 956
722 - if (!$validSpaces && !$isMod) {
957 + if (!$validSpaces && !$isSpaceModerator) {
723 958 continue;
724 959 }
725 960
726 961 $formattedGroups[] = [
@@ -734,19 +969,55 @@
734 969
735 970 return apply_filters('fluent_community/menu_groups_for_user', $formattedGroups, $user);
736 971 }
737 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 +
738 1005 /**
739 1006 * Transform a space to a link array.
740 1007 *
741 1008 * @param Space $space The space to transform.
742 - * @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.
743 1011 */
744 - private static function transformSpaceToLink($space)
1012 + private static function transformSpaceToLink($space, $user = null)
745 1013 {
1014 + $isCustomLink = $space->type == 'sidebar_link';
1015 + if ($isCustomLink && !self::canViewSideLinkLink($space, $user)) {
1016 + return null;
1017 + }
746 1018
747 1019 $logo = $space->logo;
748 -
749 1020 $title = $space->title;
750 1021
751 1022 if ($space->status == 'draft') {
752 1023 $title = $title . ' ' . __('(Draft)', 'fluent-community');
@@ -757,12 +1028,44 @@
757 1028 'icon_image' => $logo,
758 1029 'shape_svg' => !$logo ? Arr::get($space->settings, 'shape_svg', '') : '',
759 1030 'emoji' => !$logo ? Arr::get($space->settings, 'emoji', '') : '',
760 1031 'permalink' => $space->getPermalink(),
1032 + 'is_custom' => $isCustomLink ? 'yes' : 'no',
1033 + 'new_tab' => ($isCustomLink && Arr::get($space, 'settings.new_tab', 'no') === 'yes') ? 'yes' : 'no',
761 1034 'link_classes' => 'space_menu_item route_url fcom_space_id_' . $space->id . ' fcom_space_' . $space->slug
762 1035 ];
763 1036 }
764 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 +
765 1068 public static function isAlreadyOnboarded()
766 1069 {
767 1070 $communitySettings = get_option('fluent_community_settings', []);
768 1071
@@ -834,12 +1137,14 @@
834 1137 if ($menuGroups && $context === 'view') {
835 1138 return $menuGroups;
836 1139 }
837 1140
838 - $menuGroups = Utility::getOption('fluent_community_menu_groups', []);
1141 + $menuGroups = (array) Utility::getOption('fluent_community_menu_groups', []);
839 1142
840 1143 $membersPageStatus = Utility::canViewMembersPage() ? 'yes' : 'no';
841 1144
1145 + $leaderboardPageVisibility = (Utility::canViewLeaderboardMembers() || is_user_logged_in()) ? 'yes' : 'no';
1146 +
842 1147 $defaultMainMenuItems = [
843 1148 'all_feeds' => [
844 1149 'slug' => 'all_feeds',
845 1150 'title' => __('Feed', 'fluent-community'),
@@ -861,9 +1166,9 @@
861 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>'
862 1167 ],
863 1168 'all_courses' => [
864 1169 'slug' => 'all_courses',
865 - 'title' => 'Courses',
1170 + 'title' => __('Courses', 'fluent-community'),
866 1171 'link_classes' => 'fcom_courses route_url',
867 1172 'is_system' => 'yes',
868 1173 'is_locked' => 'yes',
869 1174 'enabled' => 'yes',
@@ -885,10 +1190,10 @@
885 1190 'leaderboard' => [
886 1191 'slug' => 'leaderboard',
887 1192 'is_system' => 'yes',
888 1193 'is_locked' => 'yes',
889 - 'enabled' => 'yes',
890 - 'is_unavailable' => self::isFeatureEnabled('leader_board_module') ? 'no' : 'yes',
1194 + 'enabled' => $leaderboardPageVisibility,
1195 + 'is_unavailable' => self::isFeatureEnabled('leader_board_module') && $leaderboardPageVisibility == 'yes' ? 'no' : 'yes',
891 1196 'title' => __('Leaderboard', 'fluent-community'),
892 1197 'link_classes' => 'fcom_leaderboards route_url',
893 1198 'permalink' => self::baseUrl('leaderboards'),
894 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>'
@@ -909,9 +1214,9 @@
909 1214 continue;
910 1215 }
911 1216 $defaultItem = Arr::get($defaultMainMenuItems, $item['slug'], []);
912 1217 if ($defaultItem) {
913 - $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug', 'shape_svg'];
1218 + $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug'];
914 1219 foreach ($preservedKeys as $key) {
915 1220 if (isset($defaultItem[$key])) {
916 1221 $item[$key] = Arr::get($defaultItem, $key);
917 1222 }
@@ -917,11 +1222,12 @@
917 1222 }
918 1223 }
919 1224 if (Arr::get($defaultItem, 'is_system') === 'yes') {
920 1225 $item['permalink'] = $defaultItem['permalink'];
921 - $item['emoji'] = '';
922 - $item['icon_image'] = '';
923 1226 $item['link_classes'] = $defaultItem['link_classes'];
1227 + if (empty($item['shape_svg'])) {
1228 + $item['shape_svg'] = $defaultItem['shape_svg'];
1229 + }
924 1230 }
925 1231 }
926 1232 }
927 1233 } else {
@@ -960,11 +1266,9 @@
960 1266
961 1267 $profileDropDownItems = Arr::get($menuGroups, 'profileDropdownItems', []);
962 1268
963 1269 if ($profileDropDownItems && is_array($profileDropDownItems)) {
964 -
965 1270 unset($profileDropDownItems['profile']);
966 -
967 1271 foreach ($profileDropDownItems as $index => &$item) {
968 1272 if (empty($item['slug'])) {
969 1273 unset($profileDropDownItems[$index]);
970 1274 continue;
@@ -970,9 +1274,9 @@
970 1274 continue;
971 1275 }
972 1276 $defaultItem = Arr::get($defaultProfileDropDownItems, $item['slug'], []);
973 1277 if ($defaultItem) {
974 - $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug', 'svg_icon'];
1278 + $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug'];
975 1279 foreach ($preservedKeys as $key) {
976 1280 if (isset($defaultItem[$key])) {
977 1281 $item[$key] = Arr::get($defaultItem, $key);
978 1282 }
@@ -1000,18 +1304,21 @@
1000 1304 $afterCommunityMenuGroups = [];
1001 1305 }
1002 1306
1003 1307 if ($context == 'view') {
1004 - $mainItems = array_filter($mainItems, function ($item) {
1005 - 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);
1006 1313 });
1007 1314
1008 - $profileDropDownItems = array_filter($profileDropDownItems, function ($item) {
1009 - 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);
1010 1317 });
1011 1318
1012 - $beforeCommunityMenuItems = array_filter($beforeCommunityMenuItems, function ($item) {
1013 - 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);
1014 1321 });
1015 1322
1016 1323 $validGroups = [];
1017 1324 foreach ($afterCommunityMenuGroups as $group) {
@@ -1018,10 +1325,10 @@
1018 1325 if (empty($group['items']) || !is_array($group['items'])) {
1019 1326 continue;
1020 1327 }
1021 1328
1022 - $group['items'] = array_filter($group['items'], function ($item) {
1023 - 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);
1024 1331 });
1025 1332
1026 1333 if ($group['items']) {
1027 1334 $validGroups[] = $group;
@@ -1043,8 +1350,72 @@
1043 1350 return $menuGroups;
1044 1351 }
1045 1352
1046 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 + /**
1047 1418 * Get the meta data for a space.
1048 1419 *
1049 1420 * @param int $spaceId The ID of the space.
1050 1421 * @param string $key The meta key.
@@ -1086,9 +1457,9 @@
1086 1457 } else {
1087 1458 $meta = Meta::create([
1088 1459 'object_type' => 'space',
1089 1460 'object_id' => $spaceId,
1090 - 'meta_key' => $key,
1461 + 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1091 1462 'value' => $value
1092 1463 ]);
1093 1464 }
1094 1465
@@ -1161,60 +1532,58 @@
1161 1532 * @return array The welcome banner configuration.
1162 1533 */
1163 1534 public static function getWelcomeBannerSettings()
1164 1535 {
1165 - return Utility::getFromCache('welcome_banner_settings', function () {
1166 - $defaults = [
1167 - 'login' => [
1168 - 'enabled' => 'no',
1169 - 'description' => '',
1170 - 'mediaType' => 'image',
1171 - 'allowClose' => 'no',
1172 - 'bannerImage' => '',
1173 - 'bannerVideo' => [
1174 - 'type' => 'oembed',
1175 - 'url' => '',
1176 - 'content_type' => '',
1177 - 'provider' => '',
1178 - 'title' => '',
1179 - 'author_name' => '',
1180 - 'html' => ''
1181 - ],
1182 - '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' => ''
1183 1551 ],
1184 - 'logout' => [
1185 - 'enabled' => 'no',
1186 - 'description' => '',
1187 - 'mediaType' => 'image',
1188 - 'useCustomUrl' => 'no',
1189 - 'bannerImage' => '',
1190 - 'bannerVideo' => [
1191 - 'type' => 'oembed',
1192 - 'url' => '',
1193 - 'content_type' => '',
1194 - 'provider' => '',
1195 - 'title' => '',
1196 - 'author_name' => '',
1197 - 'html' => ''
1198 - ],
1199 - 'ctaButtons' => []
1200 - ]
1201 - ];
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 + ];
1202 1572
1203 - $settings = Utility::getOption('welcome_banner_settings', []);
1573 + $settings = Utility::getOption('welcome_banner_settings', []);
1204 1574
1205 - $settings = wp_parse_args($settings, $defaults);
1575 + $settings = wp_parse_args($settings, $defaults);
1206 1576
1207 - if (empty(Arr::get($settings, 'login.bannerVideo'))) {
1208 - $settings['login']['bannerVideo'] = $defaults['login']['bannerVideo'];
1209 - }
1577 + if (empty(Arr::get($settings, 'login.bannerVideo'))) {
1578 + $settings['login']['bannerVideo'] = $defaults['login']['bannerVideo'];
1579 + }
1210 1580
1211 - if (empty(Arr::get($settings, 'logout.bannerVideo'))) {
1212 - $settings['logout']['bannerVideo'] = $defaults['logout']['bannerVideo'];
1213 - }
1581 + if (empty(Arr::get($settings, 'logout.bannerVideo'))) {
1582 + $settings['logout']['bannerVideo'] = $defaults['logout']['bannerVideo'];
1583 + }
1214 1584
1215 - return $settings;
1216 - }, WEEK_IN_SECONDS);
1585 + return $settings;
1217 1586 }
1218 1587
1219 1588 public static function getWelcomeBanner($view = 'login')
1220 1589 {
@@ -1235,25 +1604,75 @@
1235 1604
1236 1605 public static function getEnabledFeedLinks()
1237 1606 {
1238 1607 $links = array_filter(self::getFeedLinks(), function ($item) {
1239 - return Arr::get($item, 'enabled') == 'yes' && Arr::get($item, 'is_unavailable') != 'yes';
1608 + return self::isLinkAccessible($item);
1240 1609 });
1241 1610
1242 1611 return array_values($links);
1243 1612 }
1244 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 +
1245 1667 public static function getFeedLinks()
1246 1668 {
1247 - return Utility::getFromCache('feed_links', function () {
1248 - return Utility::getOption('feed_links', []);
1249 - }, WEEK_IN_SECONDS);
1669 + return Utility::getOption('feed_links', []);
1250 1670 }
1251 1671
1252 1672 public static function updateFeedLinks($links)
1253 1673 {
1254 1674 Utility::updateOption('feed_links', $links);
1255 - Utility::setCache('feed_links', $links, WEEK_IN_SECONDS);
1256 1675 }
1257 1676
1258 1677 /**
1259 1678 * Get the full name of a WordPress user.
@@ -1315,31 +1734,37 @@
1315 1734
1316 1735 /**
1317 1736 * Add a user to a space.
1318 1737 *
1319 - * @param Space | int $space space to add the user to.
1738 + * @param BaseSpace|int $space space to add the user to.
1320 1739 * @param int $userId The ID of the user to add.
1321 1740 * @param string $role The role of the user in the space.
1322 1741 * @param string $by The source of the action.
1323 1742 * @return bool True if the user was added, false otherwise.
1324 1743 */
1325 - public static function addToSpace($space, $userId, $role = 'member', $by = 'self')
1744 + public static function addToSpace($space, $userId, $role = 'member', $by = 'self', $skipSync = false)
1326 1745 {
1327 1746 if (is_numeric($space)) {
1328 - $space = BaseSpace::withoutGlobalScopes()->find($space);
1747 + $space = BaseSpace::onlyMain()->find($space);
1329 1748 }
1330 1749
1331 - if (!$space) {
1750 + if (!$space || !$space instanceof BaseSpace) {
1332 1751 return false;
1333 1752 }
1334 1753
1335 - $user = User::find($userId);
1754 + if (!$skipSync) {
1755 + $user = User::find($userId);
1336 1756
1337 - if (!$user) {
1338 - return false;
1757 + if (!$user) {
1758 + return false;
1759 + }
1760 +
1761 + $user->syncXProfile();
1339 1762 }
1340 1763
1341 - $user->syncXProfile();
1764 + if ($role == 'member' && $space->type == 'course') {
1765 + $role = 'student';
1766 + }
1342 1767
1343 1768 $exist = SpaceUserPivot::where('user_id', $userId)
1344 1769 ->where('space_id', $space->id)
1345 1770 ->first();
@@ -1346,9 +1771,13 @@
1346 1771
1347 1772 if ($exist) {
1348 1773 if ($exist->status != 'active') {
1349 1774 $exist->status = 'active';
1350 - $exist->role = $role;
1775 +
1776 + if (!in_array($exist->role, ['admin', 'moderator'])) {
1777 + $exist->role = $role;
1778 + }
1779 +
1351 1780 $exist->save();
1352 1781
1353 1782 if ($space->type == 'course') {
1354 1783 do_action('fluent_community/course/enrolled', $space, $userId, $by);
@@ -1361,9 +1790,9 @@
1361 1790
1362 1791 return false;
1363 1792 }
1364 1793
1365 - SpaceUserPivot::create([
1794 + $created = SpaceUserPivot::create([
1366 1795 'space_id' => $space->id,
1367 1796 'role' => $role,
1368 1797 'user_id' => $userId
1369 1798 ]);
@@ -1368,13 +1797,18 @@
1368 1797 'user_id' => $userId
1369 1798 ]);
1370 1799
1371 1800 if ($space->type == 'course') {
1372 - 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);
1373 1805 } else {
1374 - 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);
1375 1810 }
1376 -
1377 1811 return true;
1378 1812 }
1379 1813
1380 1814 /**
@@ -1392,28 +1826,37 @@
1392 1826 return false;
1393 1827 }
1394 1828
1395 1829 if (is_numeric($space)) {
1396 - $space = BaseSpace::query()->withoutGlobalScopes()->find($space);
1830 + $space = BaseSpace::query()->onlyMain()->find($space);
1397 1831 }
1398 1832
1399 - if (!$space) {
1833 + if (!$space || !$space instanceof BaseSpace) {
1400 1834 return false;
1401 1835 }
1402 1836
1837 +
1403 1838 if (!self::isUserInSpace($userId, $space->id)) {
1404 1839 return false;
1405 1840 }
1406 1841
1407 - SpaceUserPivot::bySpace($space->id)
1408 - ->byUser($userId)
1842 + SpaceUserPivot::where('space_id', $space->id)
1843 + ->where('user_id', $userId)
1409 1844 ->delete();
1410 1845
1411 1846 $user->cacheAccessSpaces();
1412 1847
1413 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 +
1414 1853 do_action('fluent_community/course/student_left', $space, $userId, $by);
1415 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
1416 1859 do_action('fluent_community/space/user_left', $space, $userId, $by);
1417 1860 }
1418 1861
1419 1862 return true;
@@ -1428,21 +1871,29 @@
1428 1871 * @param bool $renderIcon Whether to render the icon or not.
1429 1872 */
1430 1873 public static function renderLink($link, $linkClass = '', $fallback = '<span class="fcom_no_avatar"></span>', $renderIcon = true)
1431 1874 {
1875 + if (!$link || empty($link['permalink'])) {
1876 + return;
1877 + }
1878 +
1879 + $isCustom = Arr::get($link, 'is_custom') == 'yes';
1880 +
1432 1881 $linkAtts = array_filter([
1433 - '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' : ''),
1434 1883 'target' => Arr::get($link, 'new_tab') === 'yes' ? '_blank' : '',
1435 1884 'rel' => Arr::get($link, 'new_tab') === 'yes' ? 'noopener noreferrer' : '',
1436 1885 ]);
1886 +
1437 1887 ?>
1438 - <a aria-label="Go to <?php echo esc_attr(Arr::get($link, 'title')); ?> page"
1439 - href="<?php echo esc_url($link['permalink']); ?>" <?php foreach ($linkAtts as $key => $value) {
1440 - 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) . '"';
1441 1891 } ?>>
1442 1892 <?php $renderIcon && self::printLinkIcon($link, $fallback); ?>
1443 - <span class="community_name"><?php echo wp_kses_post(Arr::get($link, 'title')); ?></span>
1444 - <?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')) : ?>
1445 1896 <span class="fcom_space_lock">
1446 1897 <i class="el-icon">
1447 1898 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
1448 1899 <path fill="currentColor"
@@ -1451,14 +1902,49 @@
1451 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>
1452 1903 </svg>
1453 1904 </i>
1454 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>
1455 1910 <?php endif; ?>
1456 -
1457 1911 </a>
1458 1912 <?php
1459 1913 }
1460 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 +
1461 1947 /**
1462 1948 * Print a link icon.
1463 1949 *
1464 1950 * @param array $link The link data.
@@ -1516,9 +2002,12 @@
1516 2002 // most probably it's local reverse proxy
1517 2003 if (isset($_SERVER["HTTP_CLIENT_IP"])) {
1518 2004 $ipAddress = sanitize_text_field(wp_unslash($_SERVER["HTTP_CLIENT_IP"]));
1519 2005 } else if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
1520 - $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 + }
1521 2010 }
1522 2011 }
1523 2012
1524 2013 if (!$ipAddress) {
@@ -1641,8 +2130,14 @@
1641 2130 public static function getPortalRequestPath($requestUri)
1642 2131 {
1643 2132 $portalSlug = self::getPortalSlug();
1644 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 +
1645 2140 if ($portalSlug == $requestUri) {
1646 2141 return 'portal_home';
1647 2142 }
1648 2143
@@ -1657,25 +2152,422 @@
1657 2152
1658 2153 $parts = explode('/', $requestUri);
1659 2154 $start = $parts[0];
1660 2155
2156 + if (!$portalSlug && $start == 'fcom_route') {
2157 + return $start;
2158 + }
2159 +
1661 2160 $routeStats = self::portalRoutePaths();
1662 2161
1663 2162 if (in_array($start, $routeStats)) {
1664 2163 return $requestUri;
1665 2164 }
2165 +
1666 2166 return false;
1667 2167 }
1668 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 +
1669 2193 public static function getTopicsConfig()
1670 2194 {
1671 - return Utility::getFromCache('topics_config', function () {
1672 - $config = Utility::getOption('topics_config', []);
1673 - $default = [
1674 - 'max_topics_per_post' => 1,
1675 - 'max_topics_per_space' => 20,
1676 - 'show_on_post_card' => 'yes'
1677 - ];
1678 - return wp_parse_args($config, $default);
1679 - }, 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);
1680 2572 }
1681 2573 }