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 +1029 -168 1.0.992.11.0 View file →
@@ -5,8 +5,10 @@
5 5 use FluentCommunity\App\App;
6 6 use FluentCommunity\App\Functions\Utility;
7 7 use FluentCommunity\App\Hooks\Handlers\ActivationHandler;
8 8 use FluentCommunity\App\Models\BaseSpace;
9 +use FluentCommunity\App\Models\Contact;
10 +use FluentCommunity\App\Models\Feed;
9 11 use FluentCommunity\App\Models\Space;
10 12 use FluentCommunity\App\Models\Media;
11 13 use FluentCommunity\App\Models\Meta;
12 14 use FluentCommunity\App\Models\SpaceUserPivot;
@@ -13,8 +15,9 @@
13 15 use FluentCommunity\App\Models\User;
14 16 use FluentCommunity\App\Models\XProfile;
15 17 use FluentCommunity\Framework\Support\Arr;
16 18 use FluentCommunity\App\Models\SpaceGroup;
19 +use FluentCommunity\Modules\Course\Model\Course;
17 20
18 21 /**
19 22 * Helper class for various utility functions.
20 23 */
@@ -26,8 +29,55 @@
26 29 return apply_filters('fluent_community/is_rtl', is_rtl());
27 30 }
28 31
29 32 /**
33 + * Run a callback inside a database transaction.
34 + *
35 + * @param callable $callback
36 + * @return mixed
37 + * @throws \Exception
38 + */
39 + public static function dbTransaction($callback)
40 + {
41 + $db = App::make('db');
42 + $db->beginTransaction();
43 +
44 + try {
45 + $result = $callback();
46 + $db->commit();
47 + return $result;
48 + } catch (\Exception $e) {
49 + $db->rollBack();
50 + throw $e;
51 + }
52 + }
53 +
54 + /**
55 + * Check if POST content length exceeds PHP limits
56 + *
57 + * @return array|false Error array if limit exceeded, false otherwise
58 + */
59 + public static function checkUploadSizeError()
60 + {
61 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Server variable check
62 + $contentLength = isset($_SERVER['CONTENT_LENGTH']) ? (int) $_SERVER['CONTENT_LENGTH'] : 0;
63 + $postMaxSize = wp_convert_hr_to_bytes(ini_get('post_max_size'));
64 +
65 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only existence check on superglobals, no state mutation
66 + if ($contentLength > 0 && ($contentLength > $postMaxSize || (empty($_FILES) && empty($_POST)))) {
67 + return [
68 + 'message' => sprintf(
69 + /* translators: %s: max size */
70 + __('Upload failed: File exceeds server limit (%s). Please upload a smaller file.', 'fluent-community'),
71 + size_format($postMaxSize)
72 + )
73 + ];
74 + }
75 +
76 + return false;
77 + }
78 +
79 + /**
30 80 * Get the portal slug.
31 81 *
32 82 * @return string The portal slug.
33 83 */
@@ -54,17 +104,20 @@
54 104 if (!$forRoute) {
55 105 return $slug;
56 106 }
57 107
58 - $siteUrl = get_site_url();
108 + $siteUrl = get_home_url();
59 109
60 110 $poralUrl = self::baseUrl('/');
61 111
62 - // get the url without path
63 - $siteUrl = str_replace(parse_url($siteUrl, PHP_URL_PATH), '', $siteUrl);
112 + $urlPath = wp_parse_url($siteUrl, PHP_URL_PATH);
64 113
114 + if ($urlPath) {
115 + // get the url without path
116 + $siteUrl = str_replace($urlPath, '', $siteUrl);
117 + }
118 +
65 119 $slug = str_replace($siteUrl, '', $poralUrl);
66 -
67 120 // remove the first and last slashes
68 121 return trim($slug, '/');
69 122 }
70 123
@@ -99,15 +152,82 @@
99 152 return apply_filters('fluent_community/has_color_scheme', $status);
100 153 }
101 154
102 155 /**
103 - * Check if the user is a site admin.
104 - *
105 - * @param int|null $userId The user ID to check. If null, checks the current user.
106 - * @return bool True if the user is a site admin, false otherwise.
156 + * Admin default theme mode: 'light', 'dark', or 'system'.
107 157 */
108 - public static function isSiteAdmin($userId = null)
158 + public static function getDefaultThemeMode()
109 159 {
160 + $settings = Utility::getCustomizationSettings();
161 + $mode = isset($settings['default_theme_mode']) ? $settings['default_theme_mode'] : 'light';
162 +
163 + if (!in_array($mode, ['light', 'dark', 'system'], true)) {
164 + $mode = 'light';
165 + }
166 +
167 + return apply_filters('fluent_community/default_theme_mode', $mode);
168 + }
169 +
170 + /**
171 + * Pre-paint script that sets the theme before first render (no flash).
172 + * Precedence mirrors runtime: host-theme cookie → user pick → admin default.
173 + * Not persisted. Gate on hasColorScheme().
174 + */
175 + public static function renderColorSchemePrePaintScript()
176 + {
177 + $defaultMode = self::getDefaultThemeMode();
178 + $portalVars = apply_filters('fluent_community/general_portal_vars', ['color_switch_cookie_name' => '']);
179 + $cookieName = isset($portalVars['color_switch_cookie_name']) ? $portalVars['color_switch_cookie_name'] : '';
180 + ?>
181 + <script>
182 + (function () {
183 + var root = document.documentElement;
184 + var cookieName = '<?php echo esc_js($cookieName); ?>';
185 + var mode = null;
186 +
187 + // host-theme integration cookie (Blocksy/Kadence) wins when present
188 + if (cookieName) {
189 + var safeName = cookieName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
190 + var match = document.cookie.match('(?:^|; )' + safeName + '=([^;]*)');
191 + if (match) {
192 + var cookieMode = decodeURIComponent(match[1]);
193 + if (cookieMode === 'dark' || cookieMode === 'light') {
194 + mode = cookieMode;
195 + }
196 + }
197 + }
198 +
199 + // explicit user pick
200 + if (!mode) {
201 + try {
202 + var stored = JSON.parse(localStorage.getItem('fcom_global_storage') || '{}').fcom_color_mode;
203 + if (stored === 'dark' || stored === 'light') {
204 + mode = stored;
205 + }
206 + } catch (error) {}
207 + }
208 +
209 + // admin default
210 + if (!mode) {
211 + var defaultMode = '<?php echo esc_js($defaultMode); ?>';
212 + if (defaultMode === 'system') {
213 + mode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
214 + } else {
215 + mode = defaultMode;
216 + }
217 + }
218 +
219 + root.setAttribute('data-color-mode', mode === 'dark' ? 'dark' : 'light');
220 + if (mode === 'dark') {
221 + root.classList.add('dark');
222 + }
223 + })();
224 + </script>
225 + <?php
226 + }
227 +
228 + public static function isSuperAdmin($userId = null)
229 + {
110 230 $capability = apply_filters('fluent_community/super_admin_capability', 'manage_options');
111 231
112 232 if (!$capability) {
113 233 return false;
@@ -123,14 +243,37 @@
123 243
124 244 return user_can($userId, $capability);
125 245 }
126 246
247 + /**
248 + * Check if the user is a site admin.
249 + *
250 + * @param int|null $userId The user ID to check. If null, checks the current user.
251 + * @param \FluentCommunity\App\Models\User|null $user Resolved user model, to save a lookup.
252 + * @return bool True if the user is a site admin, false otherwise.
253 + */
254 + public static function isSiteAdmin($userId = null, $user = null)
255 + {
256 + if (self::isSuperAdmin($userId)) {
257 + return true;
258 + }
259 +
260 + if (!$user) {
261 + $user = ($userId && (int)$userId !== get_current_user_id())
262 + ? User::find($userId)
263 + : self::getCurrentUser();
264 + }
265 +
266 + return $user && Arr::get($user->getPermissions(), 'community_admin');
267 + }
268 +
127 269 public static function isModerator($user = null)
128 270 {
129 271 if (!$user) {
130 272 $user = self::getCurrentUser();
131 273 }
132 - return $user && $user->isCommunityModerator();
274 +
275 + return $user && $user->hasCommunityModeratorAccess();
133 276 }
134 277
135 278 /**
136 279 * Get the URL for an asset file.
@@ -209,8 +352,12 @@
209 352 * @return bool True if the user is in the space, false otherwise.
210 353 */
211 354 public static function isUserInSpace($userId, $spaceId)
212 355 {
356 + if (!$userId || !$spaceId) {
357 + return false;
358 + }
359 +
213 360 return SpaceUserPivot::where('user_id', $userId)
214 361 ->where('space_id', $spaceId)
215 362 ->where('status', 'active')
216 363 ->exists();
@@ -272,8 +419,19 @@
272 419
273 420 return Media::where('media_key', $key)->first();
274 421 }
275 422
423 + public static function removeMediaByUrl($url = '', $subObjectId = null)
424 + {
425 + if (!$url || !$subObjectId) {
426 + return;
427 + }
428 +
429 + do_action('fluent_community/remove_medias_by_url', [$url], [
430 + 'sub_object_id' => $subObjectId,
431 + ]);
432 + }
433 +
276 434 /**
277 435 * Get media items from multiple URLs.
278 436 *
279 437 * @param array $urls An array of URLs.
@@ -314,8 +472,10 @@
314 472 'site_title' => get_bloginfo('name'),
315 473 'slug' => 'portal',
316 474 'logo' => '',
317 475 'white_logo' => '',
476 + 'logo_permalink_type' => 'default',
477 + 'logo_permalink' => '',
318 478 'featured_image' => '',
319 479 'access' => [
320 480 'acess_level' => 'public', // logged_in, public, role_based
321 481 'access_roles' => []
@@ -320,14 +480,17 @@
320 480 'acess_level' => 'public', // logged_in, public, role_based
321 481 'access_roles' => []
322 482 ],
323 483 'auth_form_type' => 'default',
484 + 'explicit_registration' => 'no',
324 485 'disable_global_posts' => 'yes',
325 486 'auth_content' => 'Please login first to access this page',
326 487 'auth_redirect' => '',
327 - 'restricted_role_content' => 'Sorry, you can not access to this page. Only authorized users can access this page.',
488 + 'restricted_role_content' => 'Sorry, you cannot access this page. Only authorized users can access this page.',
328 489 'auth_url' => '',
329 490 'cutsom_auth_url' => self::baseUrl('?fcom_action=auth'),
491 + 'use_custom_signup_page' => 'no',
492 + 'custom_signup_url' => ''
330 493 ];
331 494
332 495 $settings = wp_parse_args($settings, $defaults);
333 496 if ($settings['auth_form_type'] != 'custom' || empty($settings['auth_form_type'])) {
@@ -357,9 +520,9 @@
357 520 *
358 521 * @param int|null $userId The user ID. If null, uses the current user.
359 522 * @return bool True if the user can access the portal, false otherwise.
360 523 */
361 - public static function canAccessPortal($userId = null)
524 + public static function canAccessPortal($userId = null, $requireActiveProfile = true)
362 525 {
363 526 $settings = self::generalSettings();
364 527 $accessLevel = Arr::get($settings, 'access.acess_level');
365 528
@@ -396,8 +559,12 @@
396 559 if (!$result) {
397 560 return apply_filters('fluent_community/can_access_portal', false);
398 561 }
399 562
563 + if (!$requireActiveProfile) {
564 + return apply_filters('fluent_community/can_access_portal', true);
565 + }
566 +
400 567 $xProfile = Helper::getCurrentProfile();
401 568
402 569 $result = $xProfile && $xProfile->status == 'active';
403 570
@@ -412,19 +579,22 @@
412 579 public static function portalRoutePaths()
413 580 {
414 581 return apply_filters('fluent_community/app_route_paths', [
415 582 'portal_home',
583 + 'members',
584 + 'bookmarks',
585 + 'chat',
586 + 'dashboard',
587 + 'leaderboards',
588 + 'notifications',
416 589 'space',
417 590 'discover',
418 - 'members',
419 591 'courses',
420 592 'u',
421 - 'leaderboards',
422 - 'chat',
423 - 'notifications',
424 - 'bookmarks',
425 593 'post',
426 - 'admin'
594 + 'admin',
595 + 'course',
596 + 'site-maps'
427 597 ]);
428 598 }
429 599
430 600 /**
@@ -434,21 +604,18 @@
434 604 * @return XProfile|null The user's profile or null if not found.
435 605 */
436 606 public static function getCurrentProfile($cached = true)
437 607 {
438 - $userId = get_current_user_id();
439 - if (!$userId) {
440 - return null;
441 - }
442 -
443 608 static $profile;
444 -
445 609 if ($profile && $cached) {
446 610 return $profile;
447 611 }
448 612
613 + $userId = get_current_user_id();
614 +
449 615 if (!$userId) {
450 - return null;
616 + $profile = null;
617 + return $profile;
451 618 }
452 619
453 620 $profile = XProfile::where('user_id', $userId)->first();
454 621
@@ -467,16 +634,14 @@
467 634 if (!$userId) {
468 635 return false;
469 636 }
470 637
471 - static $user;
472 - if ($user && $cached) {
473 - return $user;
638 + static $users = [];
639 + if ($cached && isset($users[$userId])) {
640 + return $users[$userId];
474 641 }
475 642
476 - $user = User::find($userId);
477 -
478 - return $user;
643 + return $users[$userId] = User::find($userId);
479 644 }
480 645
481 646 /**
482 647 * Get the route paths for the community.
@@ -588,8 +753,31 @@
588 753 return false;
589 754 }
590 755
591 756 /**
757 + * Sanitize embed markup held in a feed/comment meta array on read.
758 + *
759 + * meta.media_preview.html is rendered with v-html in _MediaPreview.vue, so it is
760 + * sanitized on write. Doing it on read as well neutralizes rows that were stored
761 + * before the write-side fix landed, and covers any writer reaching the meta via
762 + * the fluent_community/feed/* filters. The emptiness check keeps this free for the
763 + * vast majority of rows, which carry no embed markup at all.
764 + *
765 + * @param array $meta The unserialized meta array.
766 + * @return array The meta array with any embed markup passed through the allowlist.
767 + */
768 + public static function sanitizeStoredMediaPreview($meta)
769 + {
770 + if (empty($meta['media_preview']['html'])) {
771 + return $meta;
772 + }
773 +
774 + $meta['media_preview']['html'] = RemoteUrlParser::sanitizeOembedHtml($meta['media_preview']['html']);
775 +
776 + return $meta;
777 + }
778 +
779 + /**
592 780 * Get a human-readable excerpt from content.
593 781 *
594 782 * @param string $content The content to extract from.
595 783 * @param int $length The maximum length of the excerpt.
@@ -610,12 +798,12 @@
610 798 // Blockquotes: remove '>' symbol
611 799 '/^\s*>\s?/m' => '',
612 800 // Horizontal rules: replace with empty line
613 801 '/^\s*([-*_])\1{2,}\s*$/m' => "\n",
802 + // Images: keep only the alt text (run before links)
803 + '/!\[([^\]]*)\]\([^\)]+\)/' => '$1',
614 804 // Links: keep only the link text
615 - '/\[([^\]]+)\]\([^\)]+\)/' => '$1',
616 - // Images: keep only the alt text
617 - '/!\[([^\]]+)\]\([^\)]+\)/' => '$1',
805 + '/\[([^\]]*)\]\([^\)]+\)/' => '$1',
618 806 // Strikethrough: remove '~~' symbols
619 807 '/~~(.*?)~~/' => '$1',
620 808 // Task lists: remove checkbox syntax
621 809 '/^\s*[-*+]\s+\[[ xX]\]\s+/m' => '',
@@ -622,8 +810,10 @@
622 810 ];
623 811
624 812 $content = preg_replace(array_keys($patterns), array_values($patterns), $content);
625 813
814 + $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
815 +
626 816 // remove all tags
627 817 $content = wp_strip_all_tags($content);
628 818 // remove new lines and tabs
629 819 $content = str_replace(["\r", "\n", "\t"], ' ', $content);
@@ -686,9 +876,9 @@
686 876 *
687 877 * @param User|null $user The user to get menu groups for.
688 878 * @return array The community menu groups.
689 879 */
690 - public static function getCommunityMenuGroups($user = null)
880 + public static function getCommunityMenuGroups($user = null, $view = true)
691 881 {
692 882 if (!$user) {
693 883 $user = self::getCurrentUser();
694 884 }
@@ -698,58 +888,74 @@
698 888 if ($communityGroups->isEmpty()) {
699 889 return [];
700 890 }
701 891
892 + $userSpaceIds = $user ? self::getUserSpaceIds($user->ID) : [];
702 893 $isComModerator = $user && $user->hasCommunityModeratorAccess();
703 894 $isCourseCreator = $user && $user->hasCourseCreatorAccess();
895 + $isSpaceModerator = $user && $user->isSpaceModerator();
704 896
705 897 $formattedGroups = [];
706 -
707 898 foreach ($communityGroups as $communityGroup) {
899 + $validSpaces = [];
708 900 $spaces = $communityGroup->spaces;
709 - $validSpaces = [];
710 901 $isShowAll = Arr::get($communityGroup->settings, 'always_show_spaces') === 'yes';
711 902
903 + if (!$isShowAll && !$isSpaceModerator) {
904 + $spaceIds = $spaces->pluck('id')->toArray();
905 + $isNotMemberOfAnySpace = empty(array_intersect($spaceIds, $userSpaceIds));
906 + if ($isNotMemberOfAnySpace) {
907 + continue;
908 + }
909 + }
910 +
911 + if ($user) {
912 + BaseSpace::preloadMemberships($spaces, $user->ID);
913 + }
914 +
712 915 foreach ($spaces as $space) {
916 + $validSpace = $view ? self::transformSpaceToLink($space, $user) : $space;
917 + if (!$validSpace) {
918 + continue;
919 + }
920 +
921 + if ($user && $space->isContentSpace()) {
922 + $validSpace['unread_badge'] = self::getUnreadFeedsCounts($space->id);
923 + }
924 +
713 925 if ($isComModerator && $space->type != 'course') {
714 - $validSpaces[] = self::transformSpaceToLink($space);
926 + $validSpaces[] = $validSpace;
715 927 continue;
716 928 }
717 929
718 930 if ($isCourseCreator && $space->type == 'course') {
719 - $validSpaces[] = self::transformSpaceToLink($space);
931 + $validSpaces[] = $validSpace;
720 932 continue;
721 933 }
722 934
723 - if ($space->privacy === 'secret') {
724 - if (!$user || !$space->getMembership($user->ID)) {
725 - continue;
726 - }
727 - $validSpaces[] = self::transformSpaceToLink($space);
935 + if ($space->privacy == 'public') {
936 + $validSpaces[] = $validSpace;
728 937 continue;
729 938 }
730 939
731 - if ($isShowAll || $space->privacy = 'public') {
732 - $validSpace = self::transformSpaceToLink($space);
940 + $hasMembership = $user && $space->getMembership($user->ID);
733 941
734 - if ($space->privacy == 'private') {
735 - if (!$user || !$space->getMembership($user->ID)) {
736 - $validSpace['show_lock'] = true;
737 - }
942 + if ($space->privacy == 'private') {
943 + if (!$user || !$hasMembership) {
944 + $validSpace['show_lock'] = true;
738 945 }
739 -
740 - $validSpaces[] = $validSpace;
741 - continue;
742 946 }
743 947
744 - if (!$user || $space->getMembership($user->ID)) {
745 - continue;
948 + if ($space->privacy == 'secret') {
949 + if (!$user || !$hasMembership) {
950 + continue;
951 + }
746 952 }
747 953
748 - $validSpaces[] = self::transformSpaceToLink($space);
954 + $validSpaces[] = $validSpace;
749 955 }
750 956
751 - if (!$validSpaces && !$isComModerator && !$isCourseCreator) {
957 + if (!$validSpaces && !$isSpaceModerator) {
752 958 continue;
753 959 }
754 960
755 961 $formattedGroups[] = [
@@ -763,19 +969,55 @@
763 969
764 970 return apply_filters('fluent_community/menu_groups_for_user', $formattedGroups, $user);
765 971 }
766 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 +
767 1005 /**
768 1006 * Transform a space to a link array.
769 1007 *
770 1008 * @param Space $space The space to transform.
771 - * @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.
772 1011 */
773 - private static function transformSpaceToLink($space)
1012 + private static function transformSpaceToLink($space, $user = null)
774 1013 {
1014 + $isCustomLink = $space->type == 'sidebar_link';
1015 + if ($isCustomLink && !self::canViewSideLinkLink($space, $user)) {
1016 + return null;
1017 + }
775 1018
776 1019 $logo = $space->logo;
777 -
778 1020 $title = $space->title;
779 1021
780 1022 if ($space->status == 'draft') {
781 1023 $title = $title . ' ' . __('(Draft)', 'fluent-community');
@@ -786,12 +1028,44 @@
786 1028 'icon_image' => $logo,
787 1029 'shape_svg' => !$logo ? Arr::get($space->settings, 'shape_svg', '') : '',
788 1030 'emoji' => !$logo ? Arr::get($space->settings, 'emoji', '') : '',
789 1031 'permalink' => $space->getPermalink(),
1032 + 'is_custom' => $isCustomLink ? 'yes' : 'no',
1033 + 'new_tab' => ($isCustomLink && Arr::get($space, 'settings.new_tab', 'no') === 'yes') ? 'yes' : 'no',
790 1034 'link_classes' => 'space_menu_item route_url fcom_space_id_' . $space->id . ' fcom_space_' . $space->slug
791 1035 ];
792 1036 }
793 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 +
794 1068 public static function isAlreadyOnboarded()
795 1069 {
796 1070 $communitySettings = get_option('fluent_community_settings', []);
797 1071
@@ -863,12 +1137,14 @@
863 1137 if ($menuGroups && $context === 'view') {
864 1138 return $menuGroups;
865 1139 }
866 1140
867 - $menuGroups = Utility::getOption('fluent_community_menu_groups', []);
1141 + $menuGroups = (array) Utility::getOption('fluent_community_menu_groups', []);
868 1142
869 1143 $membersPageStatus = Utility::canViewMembersPage() ? 'yes' : 'no';
870 1144
1145 + $leaderboardPageVisibility = (Utility::canViewLeaderboardMembers() || is_user_logged_in()) ? 'yes' : 'no';
1146 +
871 1147 $defaultMainMenuItems = [
872 1148 'all_feeds' => [
873 1149 'slug' => 'all_feeds',
874 1150 'title' => __('Feed', 'fluent-community'),
@@ -890,9 +1166,9 @@
890 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>'
891 1167 ],
892 1168 'all_courses' => [
893 1169 'slug' => 'all_courses',
894 - 'title' => 'Courses',
1170 + 'title' => __('Courses', 'fluent-community'),
895 1171 'link_classes' => 'fcom_courses route_url',
896 1172 'is_system' => 'yes',
897 1173 'is_locked' => 'yes',
898 1174 'enabled' => 'yes',
@@ -914,10 +1190,10 @@
914 1190 'leaderboard' => [
915 1191 'slug' => 'leaderboard',
916 1192 'is_system' => 'yes',
917 1193 'is_locked' => 'yes',
918 - 'enabled' => 'yes',
919 - 'is_unavailable' => self::isFeatureEnabled('leader_board_module') ? 'no' : 'yes',
1194 + 'enabled' => $leaderboardPageVisibility,
1195 + 'is_unavailable' => self::isFeatureEnabled('leader_board_module') && $leaderboardPageVisibility == 'yes' ? 'no' : 'yes',
920 1196 'title' => __('Leaderboard', 'fluent-community'),
921 1197 'link_classes' => 'fcom_leaderboards route_url',
922 1198 'permalink' => self::baseUrl('leaderboards'),
923 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>'
@@ -938,9 +1214,9 @@
938 1214 continue;
939 1215 }
940 1216 $defaultItem = Arr::get($defaultMainMenuItems, $item['slug'], []);
941 1217 if ($defaultItem) {
942 - $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug', 'shape_svg'];
1218 + $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug'];
943 1219 foreach ($preservedKeys as $key) {
944 1220 if (isset($defaultItem[$key])) {
945 1221 $item[$key] = Arr::get($defaultItem, $key);
946 1222 }
@@ -946,11 +1222,12 @@
946 1222 }
947 1223 }
948 1224 if (Arr::get($defaultItem, 'is_system') === 'yes') {
949 1225 $item['permalink'] = $defaultItem['permalink'];
950 - $item['emoji'] = '';
951 - $item['icon_image'] = '';
952 1226 $item['link_classes'] = $defaultItem['link_classes'];
1227 + if (empty($item['shape_svg'])) {
1228 + $item['shape_svg'] = $defaultItem['shape_svg'];
1229 + }
953 1230 }
954 1231 }
955 1232 }
956 1233 } else {
@@ -989,11 +1266,9 @@
989 1266
990 1267 $profileDropDownItems = Arr::get($menuGroups, 'profileDropdownItems', []);
991 1268
992 1269 if ($profileDropDownItems && is_array($profileDropDownItems)) {
993 -
994 1270 unset($profileDropDownItems['profile']);
995 -
996 1271 foreach ($profileDropDownItems as $index => &$item) {
997 1272 if (empty($item['slug'])) {
998 1273 unset($profileDropDownItems[$index]);
999 1274 continue;
@@ -999,9 +1274,9 @@
999 1274 continue;
1000 1275 }
1001 1276 $defaultItem = Arr::get($defaultProfileDropDownItems, $item['slug'], []);
1002 1277 if ($defaultItem) {
1003 - $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug', 'svg_icon'];
1278 + $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug'];
1004 1279 foreach ($preservedKeys as $key) {
1005 1280 if (isset($defaultItem[$key])) {
1006 1281 $item[$key] = Arr::get($defaultItem, $key);
1007 1282 }
@@ -1029,18 +1304,21 @@
1029 1304 $afterCommunityMenuGroups = [];
1030 1305 }
1031 1306
1032 1307 if ($context == 'view') {
1033 - $mainItems = array_filter($mainItems, function ($item) {
1034 - 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);
1035 1313 });
1036 1314
1037 - $profileDropDownItems = array_filter($profileDropDownItems, function ($item) {
1038 - 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);
1039 1317 });
1040 1318
1041 - $beforeCommunityMenuItems = array_filter($beforeCommunityMenuItems, function ($item) {
1042 - 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);
1043 1321 });
1044 1322
1045 1323 $validGroups = [];
1046 1324 foreach ($afterCommunityMenuGroups as $group) {
@@ -1047,10 +1325,10 @@
1047 1325 if (empty($group['items']) || !is_array($group['items'])) {
1048 1326 continue;
1049 1327 }
1050 1328
1051 - $group['items'] = array_filter($group['items'], function ($item) {
1052 - 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);
1053 1331 });
1054 1332
1055 1333 if ($group['items']) {
1056 1334 $validGroups[] = $group;
@@ -1072,8 +1350,72 @@
1072 1350 return $menuGroups;
1073 1351 }
1074 1352
1075 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 + /**
1076 1418 * Get the meta data for a space.
1077 1419 *
1078 1420 * @param int $spaceId The ID of the space.
1079 1421 * @param string $key The meta key.
@@ -1115,9 +1457,9 @@
1115 1457 } else {
1116 1458 $meta = Meta::create([
1117 1459 'object_type' => 'space',
1118 1460 'object_id' => $spaceId,
1119 - 'meta_key' => $key,
1461 + 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1120 1462 'value' => $value
1121 1463 ]);
1122 1464 }
1123 1465
@@ -1190,60 +1532,58 @@
1190 1532 * @return array The welcome banner configuration.
1191 1533 */
1192 1534 public static function getWelcomeBannerSettings()
1193 1535 {
1194 - return Utility::getFromCache('welcome_banner_settings', function () {
1195 - $defaults = [
1196 - 'login' => [
1197 - 'enabled' => 'no',
1198 - 'description' => '',
1199 - 'mediaType' => 'image',
1200 - 'allowClose' => 'no',
1201 - 'bannerImage' => '',
1202 - 'bannerVideo' => [
1203 - 'type' => 'oembed',
1204 - 'url' => '',
1205 - 'content_type' => '',
1206 - 'provider' => '',
1207 - 'title' => '',
1208 - 'author_name' => '',
1209 - 'html' => ''
1210 - ],
1211 - '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' => ''
1212 1551 ],
1213 - 'logout' => [
1214 - 'enabled' => 'no',
1215 - 'description' => '',
1216 - 'mediaType' => 'image',
1217 - 'useCustomUrl' => 'no',
1218 - 'bannerImage' => '',
1219 - 'bannerVideo' => [
1220 - 'type' => 'oembed',
1221 - 'url' => '',
1222 - 'content_type' => '',
1223 - 'provider' => '',
1224 - 'title' => '',
1225 - 'author_name' => '',
1226 - 'html' => ''
1227 - ],
1228 - 'ctaButtons' => []
1229 - ]
1230 - ];
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 + ];
1231 1572
1232 - $settings = Utility::getOption('welcome_banner_settings', []);
1573 + $settings = Utility::getOption('welcome_banner_settings', []);
1233 1574
1234 - $settings = wp_parse_args($settings, $defaults);
1575 + $settings = wp_parse_args($settings, $defaults);
1235 1576
1236 - if (empty(Arr::get($settings, 'login.bannerVideo'))) {
1237 - $settings['login']['bannerVideo'] = $defaults['login']['bannerVideo'];
1238 - }
1577 + if (empty(Arr::get($settings, 'login.bannerVideo'))) {
1578 + $settings['login']['bannerVideo'] = $defaults['login']['bannerVideo'];
1579 + }
1239 1580
1240 - if (empty(Arr::get($settings, 'logout.bannerVideo'))) {
1241 - $settings['logout']['bannerVideo'] = $defaults['logout']['bannerVideo'];
1242 - }
1581 + if (empty(Arr::get($settings, 'logout.bannerVideo'))) {
1582 + $settings['logout']['bannerVideo'] = $defaults['logout']['bannerVideo'];
1583 + }
1243 1584
1244 - return $settings;
1245 - }, WEEK_IN_SECONDS);
1585 + return $settings;
1246 1586 }
1247 1587
1248 1588 public static function getWelcomeBanner($view = 'login')
1249 1589 {
@@ -1264,25 +1604,75 @@
1264 1604
1265 1605 public static function getEnabledFeedLinks()
1266 1606 {
1267 1607 $links = array_filter(self::getFeedLinks(), function ($item) {
1268 - return Arr::get($item, 'enabled') == 'yes' && Arr::get($item, 'is_unavailable') != 'yes';
1608 + return self::isLinkAccessible($item);
1269 1609 });
1270 1610
1271 1611 return array_values($links);
1272 1612 }
1273 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 +
1274 1667 public static function getFeedLinks()
1275 1668 {
1276 - return Utility::getFromCache('feed_links', function () {
1277 - return Utility::getOption('feed_links', []);
1278 - }, WEEK_IN_SECONDS);
1669 + return Utility::getOption('feed_links', []);
1279 1670 }
1280 1671
1281 1672 public static function updateFeedLinks($links)
1282 1673 {
1283 1674 Utility::updateOption('feed_links', $links);
1284 - Utility::setCache('feed_links', $links, WEEK_IN_SECONDS);
1285 1675 }
1286 1676
1287 1677 /**
1288 1678 * Get the full name of a WordPress user.
@@ -1344,33 +1734,35 @@
1344 1734
1345 1735 /**
1346 1736 * Add a user to a space.
1347 1737 *
1348 - * @param Space | int $space space to add the user to.
1738 + * @param BaseSpace|int $space space to add the user to.
1349 1739 * @param int $userId The ID of the user to add.
1350 1740 * @param string $role The role of the user in the space.
1351 1741 * @param string $by The source of the action.
1352 1742 * @return bool True if the user was added, false otherwise.
1353 1743 */
1354 - public static function addToSpace($space, $userId, $role = 'member', $by = 'self')
1744 + public static function addToSpace($space, $userId, $role = 'member', $by = 'self', $skipSync = false)
1355 1745 {
1356 1746 if (is_numeric($space)) {
1357 - $space = BaseSpace::withoutGlobalScopes()->find($space);
1747 + $space = BaseSpace::onlyMain()->find($space);
1358 1748 }
1359 1749
1360 - if (!$space) {
1750 + if (!$space || !$space instanceof BaseSpace) {
1361 1751 return false;
1362 1752 }
1363 1753
1364 - $user = User::find($userId);
1754 + if (!$skipSync) {
1755 + $user = User::find($userId);
1365 1756
1366 - if (!$user) {
1367 - return false;
1757 + if (!$user) {
1758 + return false;
1759 + }
1760 +
1761 + $user->syncXProfile();
1368 1762 }
1369 1763
1370 - $user->syncXProfile();
1371 -
1372 - if($role == 'member' && $space->type == 'course') {
1764 + if ($role == 'member' && $space->type == 'course') {
1373 1765 $role = 'student';
1374 1766 }
1375 1767
1376 1768 $exist = SpaceUserPivot::where('user_id', $userId)
@@ -1379,9 +1771,13 @@
1379 1771
1380 1772 if ($exist) {
1381 1773 if ($exist->status != 'active') {
1382 1774 $exist->status = 'active';
1383 - $exist->role = $role;
1775 +
1776 + if (!in_array($exist->role, ['admin', 'moderator'])) {
1777 + $exist->role = $role;
1778 + }
1779 +
1384 1780 $exist->save();
1385 1781
1386 1782 if ($space->type == 'course') {
1387 1783 do_action('fluent_community/course/enrolled', $space, $userId, $by);
@@ -1394,9 +1790,9 @@
1394 1790
1395 1791 return false;
1396 1792 }
1397 1793
1398 - SpaceUserPivot::create([
1794 + $created = SpaceUserPivot::create([
1399 1795 'space_id' => $space->id,
1400 1796 'role' => $role,
1401 1797 'user_id' => $userId
1402 1798 ]);
@@ -1401,13 +1797,18 @@
1401 1797 'user_id' => $userId
1402 1798 ]);
1403 1799
1404 1800 if ($space->type == 'course') {
1405 - 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);
1406 1805 } else {
1407 - 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);
1408 1810 }
1409 -
1410 1811 return true;
1411 1812 }
1412 1813
1413 1814 /**
@@ -1425,28 +1826,37 @@
1425 1826 return false;
1426 1827 }
1427 1828
1428 1829 if (is_numeric($space)) {
1429 - $space = BaseSpace::query()->withoutGlobalScopes()->find($space);
1830 + $space = BaseSpace::query()->onlyMain()->find($space);
1430 1831 }
1431 1832
1432 - if (!$space) {
1833 + if (!$space || !$space instanceof BaseSpace) {
1433 1834 return false;
1434 1835 }
1435 1836
1837 +
1436 1838 if (!self::isUserInSpace($userId, $space->id)) {
1437 1839 return false;
1438 1840 }
1439 1841
1440 - SpaceUserPivot::bySpace($space->id)
1441 - ->byUser($userId)
1842 + SpaceUserPivot::where('space_id', $space->id)
1843 + ->where('user_id', $userId)
1442 1844 ->delete();
1443 1845
1444 1846 $user->cacheAccessSpaces();
1445 1847
1446 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 +
1447 1853 do_action('fluent_community/course/student_left', $space, $userId, $by);
1448 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
1449 1859 do_action('fluent_community/space/user_left', $space, $userId, $by);
1450 1860 }
1451 1861
1452 1862 return true;
@@ -1461,35 +1871,80 @@
1461 1871 * @param bool $renderIcon Whether to render the icon or not.
1462 1872 */
1463 1873 public static function renderLink($link, $linkClass = '', $fallback = '<span class="fcom_no_avatar"></span>', $renderIcon = true)
1464 1874 {
1875 + if (!$link || empty($link['permalink'])) {
1876 + return;
1877 + }
1878 +
1879 + $isCustom = Arr::get($link, 'is_custom') == 'yes';
1880 +
1465 1881 $linkAtts = array_filter([
1466 - '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' : ''),
1467 1883 'target' => Arr::get($link, 'new_tab') === 'yes' ? '_blank' : '',
1468 1884 'rel' => Arr::get($link, 'new_tab') === 'yes' ? 'noopener noreferrer' : '',
1469 1885 ]);
1886 +
1470 1887 ?>
1471 - <a aria-label="Go to <?php echo esc_attr(Arr::get($link, 'title')); ?> page"
1472 - href="<?php echo esc_url($link['permalink']); ?>" <?php foreach ($linkAtts as $key => $value) {
1473 - 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) . '"';
1474 1891 } ?>>
1475 1892 <?php $renderIcon && self::printLinkIcon($link, $fallback); ?>
1476 - <span class="community_name"><?php echo wp_kses_post(Arr::get($link, 'title')); ?></span>
1477 - <?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')) : ?>
1478 1896 <span class="fcom_space_lock">
1479 1897 <i class="el-icon">
1480 1898 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
1481 - <path fill="currentColor" d="M224 448a32 32 0 0 0-32 32v384a32 32 0 0 0 32 32h576a32 32 0 0 0 32-32V480a32 32 0 0 0-32-32zm0-64h576a96 96 0 0 1 96 96v384a96 96 0 0 1-96 96H224a96 96 0 0 1-96-96V480a96 96 0 0 1 96-96"></path>
1482 - <path fill="currentColor" 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>
1899 + <path fill="currentColor"
1900 + d="M224 448a32 32 0 0 0-32 32v384a32 32 0 0 0 32 32h576a32 32 0 0 0 32-32V480a32 32 0 0 0-32-32zm0-64h576a96 96 0 0 1 96 96v384a96 96 0 0 1-96 96H224a96 96 0 0 1-96-96V480a96 96 0 0 1 96-96"></path>
1901 + <path fill="currentColor"
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>
1483 1903 </svg>
1484 1904 </i>
1485 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>
1486 1910 <?php endif; ?>
1487 -
1488 1911 </a>
1489 1912 <?php
1490 1913 }
1491 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 +
1492 1947 /**
1493 1948 * Print a link icon.
1494 1949 *
1495 1950 * @param array $link The link data.
@@ -1547,9 +2002,12 @@
1547 2002 // most probably it's local reverse proxy
1548 2003 if (isset($_SERVER["HTTP_CLIENT_IP"])) {
1549 2004 $ipAddress = sanitize_text_field(wp_unslash($_SERVER["HTTP_CLIENT_IP"]));
1550 2005 } else if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
1551 - $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 + }
1552 2010 }
1553 2011 }
1554 2012
1555 2013 if (!$ipAddress) {
@@ -1672,8 +2130,14 @@
1672 2130 public static function getPortalRequestPath($requestUri)
1673 2131 {
1674 2132 $portalSlug = self::getPortalSlug();
1675 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 +
1676 2140 if ($portalSlug == $requestUri) {
1677 2141 return 'portal_home';
1678 2142 }
1679 2143
@@ -1688,25 +2152,422 @@
1688 2152
1689 2153 $parts = explode('/', $requestUri);
1690 2154 $start = $parts[0];
1691 2155
2156 + if (!$portalSlug && $start == 'fcom_route') {
2157 + return $start;
2158 + }
2159 +
1692 2160 $routeStats = self::portalRoutePaths();
1693 2161
1694 2162 if (in_array($start, $routeStats)) {
1695 2163 return $requestUri;
1696 2164 }
2165 +
1697 2166 return false;
1698 2167 }
1699 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 +
1700 2193 public static function getTopicsConfig()
1701 2194 {
1702 - return Utility::getFromCache('topics_config', function () {
1703 - $config = Utility::getOption('topics_config', []);
1704 - $default = [
1705 - 'max_topics_per_post' => 1,
1706 - 'max_topics_per_space' => 20,
1707 - 'show_on_post_card' => 'yes'
1708 - ];
1709 - return wp_parse_args($config, $default);
1710 - }, 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);
1711 2572 }
1712 2573 }