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 +1009 -155 1.1.02.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,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,13 +104,13 @@
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 - $urlPath = parse_url($siteUrl, PHP_URL_PATH);
112 + $urlPath = wp_parse_url($siteUrl, PHP_URL_PATH);
63 113
64 114 if ($urlPath) {
65 115 // get the url without path
66 116 $siteUrl = str_replace($urlPath, '', $siteUrl);
@@ -102,15 +152,82 @@
102 152 return apply_filters('fluent_community/has_color_scheme', $status);
103 153 }
104 154
105 155 /**
106 - * Check if the user is a site admin.
107 - *
108 - * @param int|null $userId The user ID to check. If null, checks the current user.
109 - * @return bool True if the user is a site admin, false otherwise.
156 + * Admin default theme mode: 'light', 'dark', or 'system'.
110 157 */
111 - public static function isSiteAdmin($userId = null)
158 + public static function getDefaultThemeMode()
112 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 + {
113 230 $capability = apply_filters('fluent_community/super_admin_capability', 'manage_options');
114 231
115 232 if (!$capability) {
116 233 return false;
@@ -126,14 +243,37 @@
126 243
127 244 return user_can($userId, $capability);
128 245 }
129 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 +
130 269 public static function isModerator($user = null)
131 270 {
132 271 if (!$user) {
133 272 $user = self::getCurrentUser();
134 273 }
135 - return $user && $user->isCommunityModerator();
274 +
275 + return $user && $user->hasCommunityModeratorAccess();
136 276 }
137 277
138 278 /**
139 279 * Get the URL for an asset file.
@@ -212,8 +352,12 @@
212 352 * @return bool True if the user is in the space, false otherwise.
213 353 */
214 354 public static function isUserInSpace($userId, $spaceId)
215 355 {
356 + if (!$userId || !$spaceId) {
357 + return false;
358 + }
359 +
216 360 return SpaceUserPivot::where('user_id', $userId)
217 361 ->where('space_id', $spaceId)
218 362 ->where('status', 'active')
219 363 ->exists();
@@ -275,8 +419,19 @@
275 419
276 420 return Media::where('media_key', $key)->first();
277 421 }
278 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 +
279 434 /**
280 435 * Get media items from multiple URLs.
281 436 *
282 437 * @param array $urls An array of URLs.
@@ -317,8 +472,10 @@
317 472 'site_title' => get_bloginfo('name'),
318 473 'slug' => 'portal',
319 474 'logo' => '',
320 475 'white_logo' => '',
476 + 'logo_permalink_type' => 'default',
477 + 'logo_permalink' => '',
321 478 'featured_image' => '',
322 479 'access' => [
323 480 'acess_level' => 'public', // logged_in, public, role_based
324 481 'access_roles' => []
@@ -323,14 +480,17 @@
323 480 'acess_level' => 'public', // logged_in, public, role_based
324 481 'access_roles' => []
325 482 ],
326 483 'auth_form_type' => 'default',
484 + 'explicit_registration' => 'no',
327 485 'disable_global_posts' => 'yes',
328 486 'auth_content' => 'Please login first to access this page',
329 487 'auth_redirect' => '',
330 - '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.',
331 489 'auth_url' => '',
332 490 'cutsom_auth_url' => self::baseUrl('?fcom_action=auth'),
491 + 'use_custom_signup_page' => 'no',
492 + 'custom_signup_url' => ''
333 493 ];
334 494
335 495 $settings = wp_parse_args($settings, $defaults);
336 496 if ($settings['auth_form_type'] != 'custom' || empty($settings['auth_form_type'])) {
@@ -360,9 +520,9 @@
360 520 *
361 521 * @param int|null $userId The user ID. If null, uses the current user.
362 522 * @return bool True if the user can access the portal, false otherwise.
363 523 */
364 - public static function canAccessPortal($userId = null)
524 + public static function canAccessPortal($userId = null, $requireActiveProfile = true)
365 525 {
366 526 $settings = self::generalSettings();
367 527 $accessLevel = Arr::get($settings, 'access.acess_level');
368 528
@@ -399,8 +559,12 @@
399 559 if (!$result) {
400 560 return apply_filters('fluent_community/can_access_portal', false);
401 561 }
402 562
563 + if (!$requireActiveProfile) {
564 + return apply_filters('fluent_community/can_access_portal', true);
565 + }
566 +
403 567 $xProfile = Helper::getCurrentProfile();
404 568
405 569 $result = $xProfile && $xProfile->status == 'active';
406 570
@@ -415,19 +579,22 @@
415 579 public static function portalRoutePaths()
416 580 {
417 581 return apply_filters('fluent_community/app_route_paths', [
418 582 'portal_home',
583 + 'members',
584 + 'bookmarks',
585 + 'chat',
586 + 'dashboard',
587 + 'leaderboards',
588 + 'notifications',
419 589 'space',
420 590 'discover',
421 - 'members',
422 591 'courses',
423 592 'u',
424 - 'leaderboards',
425 - 'chat',
426 - 'notifications',
427 - 'bookmarks',
428 593 'post',
429 - 'admin'
594 + 'admin',
595 + 'course',
596 + 'site-maps'
430 597 ]);
431 598 }
432 599
433 600 /**
@@ -437,21 +604,18 @@
437 604 * @return XProfile|null The user's profile or null if not found.
438 605 */
439 606 public static function getCurrentProfile($cached = true)
440 607 {
441 - $userId = get_current_user_id();
442 - if (!$userId) {
443 - return null;
444 - }
445 -
446 608 static $profile;
447 -
448 609 if ($profile && $cached) {
449 610 return $profile;
450 611 }
451 612
613 + $userId = get_current_user_id();
614 +
452 615 if (!$userId) {
453 - return null;
616 + $profile = null;
617 + return $profile;
454 618 }
455 619
456 620 $profile = XProfile::where('user_id', $userId)->first();
457 621
@@ -470,16 +634,14 @@
470 634 if (!$userId) {
471 635 return false;
472 636 }
473 637
474 - static $user;
475 - if ($user && $cached) {
476 - return $user;
638 + static $users = [];
639 + if ($cached && isset($users[$userId])) {
640 + return $users[$userId];
477 641 }
478 642
479 - $user = User::find($userId);
480 -
481 - return $user;
643 + return $users[$userId] = User::find($userId);
482 644 }
483 645
484 646 /**
485 647 * Get the route paths for the community.
@@ -591,8 +753,31 @@
591 753 return false;
592 754 }
593 755
594 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 + /**
595 780 * Get a human-readable excerpt from content.
596 781 *
597 782 * @param string $content The content to extract from.
598 783 * @param int $length The maximum length of the excerpt.
@@ -613,12 +798,12 @@
613 798 // Blockquotes: remove '>' symbol
614 799 '/^\s*>\s?/m' => '',
615 800 // Horizontal rules: replace with empty line
616 801 '/^\s*([-*_])\1{2,}\s*$/m' => "\n",
802 + // Images: keep only the alt text (run before links)
803 + '/!\[([^\]]*)\]\([^\)]+\)/' => '$1',
617 804 // Links: keep only the link text
618 - '/\[([^\]]+)\]\([^\)]+\)/' => '$1',
619 - // Images: keep only the alt text
620 - '/!\[([^\]]+)\]\([^\)]+\)/' => '$1',
805 + '/\[([^\]]*)\]\([^\)]+\)/' => '$1',
621 806 // Strikethrough: remove '~~' symbols
622 807 '/~~(.*?)~~/' => '$1',
623 808 // Task lists: remove checkbox syntax
624 809 '/^\s*[-*+]\s+\[[ xX]\]\s+/m' => '',
@@ -625,8 +810,10 @@
625 810 ];
626 811
627 812 $content = preg_replace(array_keys($patterns), array_values($patterns), $content);
628 813
814 + $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
815 +
629 816 // remove all tags
630 817 $content = wp_strip_all_tags($content);
631 818 // remove new lines and tabs
632 819 $content = str_replace(["\r", "\n", "\t"], ' ', $content);
@@ -689,9 +876,9 @@
689 876 *
690 877 * @param User|null $user The user to get menu groups for.
691 878 * @return array The community menu groups.
692 879 */
693 - public static function getCommunityMenuGroups($user = null)
880 + public static function getCommunityMenuGroups($user = null, $view = true)
694 881 {
695 882 if (!$user) {
696 883 $user = self::getCurrentUser();
697 884 }
@@ -701,58 +888,74 @@
701 888 if ($communityGroups->isEmpty()) {
702 889 return [];
703 890 }
704 891
892 + $userSpaceIds = $user ? self::getUserSpaceIds($user->ID) : [];
705 893 $isComModerator = $user && $user->hasCommunityModeratorAccess();
706 894 $isCourseCreator = $user && $user->hasCourseCreatorAccess();
895 + $isSpaceModerator = $user && $user->isSpaceModerator();
707 896
708 897 $formattedGroups = [];
709 -
710 898 foreach ($communityGroups as $communityGroup) {
899 + $validSpaces = [];
711 900 $spaces = $communityGroup->spaces;
712 - $validSpaces = [];
713 901 $isShowAll = Arr::get($communityGroup->settings, 'always_show_spaces') === 'yes';
714 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 +
715 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 +
716 925 if ($isComModerator && $space->type != 'course') {
717 - $validSpaces[] = self::transformSpaceToLink($space);
926 + $validSpaces[] = $validSpace;
718 927 continue;
719 928 }
720 929
721 930 if ($isCourseCreator && $space->type == 'course') {
722 - $validSpaces[] = self::transformSpaceToLink($space);
931 + $validSpaces[] = $validSpace;
723 932 continue;
724 933 }
725 934
726 - if ($space->privacy === 'secret') {
727 - if (!$user || !$space->getMembership($user->ID)) {
728 - continue;
729 - }
730 - $validSpaces[] = self::transformSpaceToLink($space);
935 + if ($space->privacy == 'public') {
936 + $validSpaces[] = $validSpace;
731 937 continue;
732 938 }
733 939
734 - if ($isShowAll || $space->privacy = 'public') {
735 - $validSpace = self::transformSpaceToLink($space);
940 + $hasMembership = $user && $space->getMembership($user->ID);
736 941
737 - if ($space->privacy == 'private') {
738 - if (!$user || !$space->getMembership($user->ID)) {
739 - $validSpace['show_lock'] = true;
740 - }
942 + if ($space->privacy == 'private') {
943 + if (!$user || !$hasMembership) {
944 + $validSpace['show_lock'] = true;
741 945 }
742 -
743 - $validSpaces[] = $validSpace;
744 - continue;
745 946 }
746 947
747 - if (!$user || $space->getMembership($user->ID)) {
748 - continue;
948 + if ($space->privacy == 'secret') {
949 + if (!$user || !$hasMembership) {
950 + continue;
951 + }
749 952 }
750 953
751 - $validSpaces[] = self::transformSpaceToLink($space);
954 + $validSpaces[] = $validSpace;
752 955 }
753 956
754 - if (!$validSpaces && !$isComModerator && !$isCourseCreator) {
957 + if (!$validSpaces && !$isSpaceModerator) {
755 958 continue;
756 959 }
757 960
758 961 $formattedGroups[] = [
@@ -766,19 +969,55 @@
766 969
767 970 return apply_filters('fluent_community/menu_groups_for_user', $formattedGroups, $user);
768 971 }
769 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 +
770 1005 /**
771 1006 * Transform a space to a link array.
772 1007 *
773 1008 * @param Space $space The space to transform.
774 - * @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.
775 1011 */
776 - private static function transformSpaceToLink($space)
1012 + private static function transformSpaceToLink($space, $user = null)
777 1013 {
1014 + $isCustomLink = $space->type == 'sidebar_link';
1015 + if ($isCustomLink && !self::canViewSideLinkLink($space, $user)) {
1016 + return null;
1017 + }
778 1018
779 1019 $logo = $space->logo;
780 -
781 1020 $title = $space->title;
782 1021
783 1022 if ($space->status == 'draft') {
784 1023 $title = $title . ' ' . __('(Draft)', 'fluent-community');
@@ -789,12 +1028,44 @@
789 1028 'icon_image' => $logo,
790 1029 'shape_svg' => !$logo ? Arr::get($space->settings, 'shape_svg', '') : '',
791 1030 'emoji' => !$logo ? Arr::get($space->settings, 'emoji', '') : '',
792 1031 'permalink' => $space->getPermalink(),
1032 + 'is_custom' => $isCustomLink ? 'yes' : 'no',
1033 + 'new_tab' => ($isCustomLink && Arr::get($space, 'settings.new_tab', 'no') === 'yes') ? 'yes' : 'no',
793 1034 'link_classes' => 'space_menu_item route_url fcom_space_id_' . $space->id . ' fcom_space_' . $space->slug
794 1035 ];
795 1036 }
796 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 +
797 1068 public static function isAlreadyOnboarded()
798 1069 {
799 1070 $communitySettings = get_option('fluent_community_settings', []);
800 1071
@@ -866,12 +1137,14 @@
866 1137 if ($menuGroups && $context === 'view') {
867 1138 return $menuGroups;
868 1139 }
869 1140
870 - $menuGroups = Utility::getOption('fluent_community_menu_groups', []);
1141 + $menuGroups = (array) Utility::getOption('fluent_community_menu_groups', []);
871 1142
872 1143 $membersPageStatus = Utility::canViewMembersPage() ? 'yes' : 'no';
873 1144
1145 + $leaderboardPageVisibility = (Utility::canViewLeaderboardMembers() || is_user_logged_in()) ? 'yes' : 'no';
1146 +
874 1147 $defaultMainMenuItems = [
875 1148 'all_feeds' => [
876 1149 'slug' => 'all_feeds',
877 1150 'title' => __('Feed', 'fluent-community'),
@@ -893,9 +1166,9 @@
893 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>'
894 1167 ],
895 1168 'all_courses' => [
896 1169 'slug' => 'all_courses',
897 - 'title' => 'Courses',
1170 + 'title' => __('Courses', 'fluent-community'),
898 1171 'link_classes' => 'fcom_courses route_url',
899 1172 'is_system' => 'yes',
900 1173 'is_locked' => 'yes',
901 1174 'enabled' => 'yes',
@@ -917,10 +1190,10 @@
917 1190 'leaderboard' => [
918 1191 'slug' => 'leaderboard',
919 1192 'is_system' => 'yes',
920 1193 'is_locked' => 'yes',
921 - 'enabled' => 'yes',
922 - 'is_unavailable' => self::isFeatureEnabled('leader_board_module') ? 'no' : 'yes',
1194 + 'enabled' => $leaderboardPageVisibility,
1195 + 'is_unavailable' => self::isFeatureEnabled('leader_board_module') && $leaderboardPageVisibility == 'yes' ? 'no' : 'yes',
923 1196 'title' => __('Leaderboard', 'fluent-community'),
924 1197 'link_classes' => 'fcom_leaderboards route_url',
925 1198 'permalink' => self::baseUrl('leaderboards'),
926 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>'
@@ -950,8 +1223,11 @@
950 1223 }
951 1224 if (Arr::get($defaultItem, 'is_system') === 'yes') {
952 1225 $item['permalink'] = $defaultItem['permalink'];
953 1226 $item['link_classes'] = $defaultItem['link_classes'];
1227 + if (empty($item['shape_svg'])) {
1228 + $item['shape_svg'] = $defaultItem['shape_svg'];
1229 + }
954 1230 }
955 1231 }
956 1232 }
957 1233 } else {
@@ -990,11 +1266,9 @@
990 1266
991 1267 $profileDropDownItems = Arr::get($menuGroups, 'profileDropdownItems', []);
992 1268
993 1269 if ($profileDropDownItems && is_array($profileDropDownItems)) {
994 -
995 1270 unset($profileDropDownItems['profile']);
996 -
997 1271 foreach ($profileDropDownItems as $index => &$item) {
998 1272 if (empty($item['slug'])) {
999 1273 unset($profileDropDownItems[$index]);
1000 1274 continue;
@@ -1030,18 +1304,21 @@
1030 1304 $afterCommunityMenuGroups = [];
1031 1305 }
1032 1306
1033 1307 if ($context == 'view') {
1034 - $mainItems = array_filter($mainItems, function ($item) {
1035 - 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);
1036 1313 });
1037 1314
1038 - $profileDropDownItems = array_filter($profileDropDownItems, function ($item) {
1039 - 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);
1040 1317 });
1041 1318
1042 - $beforeCommunityMenuItems = array_filter($beforeCommunityMenuItems, function ($item) {
1043 - 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);
1044 1321 });
1045 1322
1046 1323 $validGroups = [];
1047 1324 foreach ($afterCommunityMenuGroups as $group) {
@@ -1048,10 +1325,10 @@
1048 1325 if (empty($group['items']) || !is_array($group['items'])) {
1049 1326 continue;
1050 1327 }
1051 1328
1052 - $group['items'] = array_filter($group['items'], function ($item) {
1053 - 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);
1054 1331 });
1055 1332
1056 1333 if ($group['items']) {
1057 1334 $validGroups[] = $group;
@@ -1073,8 +1350,72 @@
1073 1350 return $menuGroups;
1074 1351 }
1075 1352
1076 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 + /**
1077 1418 * Get the meta data for a space.
1078 1419 *
1079 1420 * @param int $spaceId The ID of the space.
1080 1421 * @param string $key The meta key.
@@ -1116,9 +1457,9 @@
1116 1457 } else {
1117 1458 $meta = Meta::create([
1118 1459 'object_type' => 'space',
1119 1460 'object_id' => $spaceId,
1120 - 'meta_key' => $key,
1461 + 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1121 1462 'value' => $value
1122 1463 ]);
1123 1464 }
1124 1465
@@ -1191,60 +1532,58 @@
1191 1532 * @return array The welcome banner configuration.
1192 1533 */
1193 1534 public static function getWelcomeBannerSettings()
1194 1535 {
1195 - return Utility::getFromCache('welcome_banner_settings', function () {
1196 - $defaults = [
1197 - 'login' => [
1198 - 'enabled' => 'no',
1199 - 'description' => '',
1200 - 'mediaType' => 'image',
1201 - 'allowClose' => 'no',
1202 - 'bannerImage' => '',
1203 - 'bannerVideo' => [
1204 - 'type' => 'oembed',
1205 - 'url' => '',
1206 - 'content_type' => '',
1207 - 'provider' => '',
1208 - 'title' => '',
1209 - 'author_name' => '',
1210 - 'html' => ''
1211 - ],
1212 - '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' => ''
1213 1551 ],
1214 - 'logout' => [
1215 - 'enabled' => 'no',
1216 - 'description' => '',
1217 - 'mediaType' => 'image',
1218 - 'useCustomUrl' => 'no',
1219 - 'bannerImage' => '',
1220 - 'bannerVideo' => [
1221 - 'type' => 'oembed',
1222 - 'url' => '',
1223 - 'content_type' => '',
1224 - 'provider' => '',
1225 - 'title' => '',
1226 - 'author_name' => '',
1227 - 'html' => ''
1228 - ],
1229 - 'ctaButtons' => []
1230 - ]
1231 - ];
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 + ];
1232 1572
1233 - $settings = Utility::getOption('welcome_banner_settings', []);
1573 + $settings = Utility::getOption('welcome_banner_settings', []);
1234 1574
1235 - $settings = wp_parse_args($settings, $defaults);
1575 + $settings = wp_parse_args($settings, $defaults);
1236 1576
1237 - if (empty(Arr::get($settings, 'login.bannerVideo'))) {
1238 - $settings['login']['bannerVideo'] = $defaults['login']['bannerVideo'];
1239 - }
1577 + if (empty(Arr::get($settings, 'login.bannerVideo'))) {
1578 + $settings['login']['bannerVideo'] = $defaults['login']['bannerVideo'];
1579 + }
1240 1580
1241 - if (empty(Arr::get($settings, 'logout.bannerVideo'))) {
1242 - $settings['logout']['bannerVideo'] = $defaults['logout']['bannerVideo'];
1243 - }
1581 + if (empty(Arr::get($settings, 'logout.bannerVideo'))) {
1582 + $settings['logout']['bannerVideo'] = $defaults['logout']['bannerVideo'];
1583 + }
1244 1584
1245 - return $settings;
1246 - }, WEEK_IN_SECONDS);
1585 + return $settings;
1247 1586 }
1248 1587
1249 1588 public static function getWelcomeBanner($view = 'login')
1250 1589 {
@@ -1265,25 +1604,75 @@
1265 1604
1266 1605 public static function getEnabledFeedLinks()
1267 1606 {
1268 1607 $links = array_filter(self::getFeedLinks(), function ($item) {
1269 - return Arr::get($item, 'enabled') == 'yes' && Arr::get($item, 'is_unavailable') != 'yes';
1608 + return self::isLinkAccessible($item);
1270 1609 });
1271 1610
1272 1611 return array_values($links);
1273 1612 }
1274 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 +
1275 1667 public static function getFeedLinks()
1276 1668 {
1277 - return Utility::getFromCache('feed_links', function () {
1278 - return Utility::getOption('feed_links', []);
1279 - }, WEEK_IN_SECONDS);
1669 + return Utility::getOption('feed_links', []);
1280 1670 }
1281 1671
1282 1672 public static function updateFeedLinks($links)
1283 1673 {
1284 1674 Utility::updateOption('feed_links', $links);
1285 - Utility::setCache('feed_links', $links, WEEK_IN_SECONDS);
1286 1675 }
1287 1676
1288 1677 /**
1289 1678 * Get the full name of a WordPress user.
@@ -1345,32 +1734,34 @@
1345 1734
1346 1735 /**
1347 1736 * Add a user to a space.
1348 1737 *
1349 - * @param Space | int $space space to add the user to.
1738 + * @param BaseSpace|int $space space to add the user to.
1350 1739 * @param int $userId The ID of the user to add.
1351 1740 * @param string $role The role of the user in the space.
1352 1741 * @param string $by The source of the action.
1353 1742 * @return bool True if the user was added, false otherwise.
1354 1743 */
1355 - public static function addToSpace($space, $userId, $role = 'member', $by = 'self')
1744 + public static function addToSpace($space, $userId, $role = 'member', $by = 'self', $skipSync = false)
1356 1745 {
1357 1746 if (is_numeric($space)) {
1358 - $space = BaseSpace::withoutGlobalScopes()->find($space);
1747 + $space = BaseSpace::onlyMain()->find($space);
1359 1748 }
1360 1749
1361 - if (!$space) {
1750 + if (!$space || !$space instanceof BaseSpace) {
1362 1751 return false;
1363 1752 }
1364 1753
1365 - $user = User::find($userId);
1754 + if (!$skipSync) {
1755 + $user = User::find($userId);
1366 1756
1367 - if (!$user) {
1368 - return false;
1757 + if (!$user) {
1758 + return false;
1759 + }
1760 +
1761 + $user->syncXProfile();
1369 1762 }
1370 1763
1371 - $user->syncXProfile();
1372 -
1373 1764 if ($role == 'member' && $space->type == 'course') {
1374 1765 $role = 'student';
1375 1766 }
1376 1767
@@ -1406,13 +1797,18 @@
1406 1797 'user_id' => $userId
1407 1798 ]);
1408 1799
1409 1800 if ($space->type == 'course') {
1410 - 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);
1411 1805 } else {
1412 - 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);
1413 1810 }
1414 -
1415 1811 return true;
1416 1812 }
1417 1813
1418 1814 /**
@@ -1430,15 +1826,16 @@
1430 1826 return false;
1431 1827 }
1432 1828
1433 1829 if (is_numeric($space)) {
1434 - $space = BaseSpace::query()->withoutGlobalScopes()->find($space);
1830 + $space = BaseSpace::query()->onlyMain()->find($space);
1435 1831 }
1436 1832
1437 - if (!$space) {
1833 + if (!$space || !$space instanceof BaseSpace) {
1438 1834 return false;
1439 1835 }
1440 1836
1837 +
1441 1838 if (!self::isUserInSpace($userId, $space->id)) {
1442 1839 return false;
1443 1840 }
1444 1841
@@ -1448,10 +1845,18 @@
1448 1845
1449 1846 $user->cacheAccessSpaces();
1450 1847
1451 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 +
1452 1853 do_action('fluent_community/course/student_left', $space, $userId, $by);
1453 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
1454 1859 do_action('fluent_community/space/user_left', $space, $userId, $by);
1455 1860 }
1456 1861
1457 1862 return true;
@@ -1466,21 +1871,29 @@
1466 1871 * @param bool $renderIcon Whether to render the icon or not.
1467 1872 */
1468 1873 public static function renderLink($link, $linkClass = '', $fallback = '<span class="fcom_no_avatar"></span>', $renderIcon = true)
1469 1874 {
1875 + if (!$link || empty($link['permalink'])) {
1876 + return;
1877 + }
1878 +
1879 + $isCustom = Arr::get($link, 'is_custom') == 'yes';
1880 +
1470 1881 $linkAtts = array_filter([
1471 - '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' : ''),
1472 1883 'target' => Arr::get($link, 'new_tab') === 'yes' ? '_blank' : '',
1473 1884 'rel' => Arr::get($link, 'new_tab') === 'yes' ? 'noopener noreferrer' : '',
1474 1885 ]);
1886 +
1475 1887 ?>
1476 - <a aria-label="Go to <?php echo esc_attr(Arr::get($link, 'title')); ?> page"
1477 - href="<?php echo esc_url($link['permalink']); ?>" <?php foreach ($linkAtts as $key => $value) {
1478 - 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) . '"';
1479 1891 } ?>>
1480 1892 <?php $renderIcon && self::printLinkIcon($link, $fallback); ?>
1481 - <span class="community_name"><?php echo wp_kses_post(Arr::get($link, 'title')); ?></span>
1482 - <?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')) : ?>
1483 1896 <span class="fcom_space_lock">
1484 1897 <i class="el-icon">
1485 1898 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
1486 1899 <path fill="currentColor"
@@ -1489,14 +1902,49 @@
1489 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>
1490 1903 </svg>
1491 1904 </i>
1492 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>
1493 1910 <?php endif; ?>
1494 -
1495 1911 </a>
1496 1912 <?php
1497 1913 }
1498 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 +
1499 1947 /**
1500 1948 * Print a link icon.
1501 1949 *
1502 1950 * @param array $link The link data.
@@ -1554,9 +2002,12 @@
1554 2002 // most probably it's local reverse proxy
1555 2003 if (isset($_SERVER["HTTP_CLIENT_IP"])) {
1556 2004 $ipAddress = sanitize_text_field(wp_unslash($_SERVER["HTTP_CLIENT_IP"]));
1557 2005 } else if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
1558 - $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 + }
1559 2010 }
1560 2011 }
1561 2012
1562 2013 if (!$ipAddress) {
@@ -1679,8 +2130,14 @@
1679 2130 public static function getPortalRequestPath($requestUri)
1680 2131 {
1681 2132 $portalSlug = self::getPortalSlug();
1682 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 +
1683 2140 if ($portalSlug == $requestUri) {
1684 2141 return 'portal_home';
1685 2142 }
1686 2143
@@ -1695,25 +2152,422 @@
1695 2152
1696 2153 $parts = explode('/', $requestUri);
1697 2154 $start = $parts[0];
1698 2155
2156 + if (!$portalSlug && $start == 'fcom_route') {
2157 + return $start;
2158 + }
2159 +
1699 2160 $routeStats = self::portalRoutePaths();
1700 2161
1701 2162 if (in_array($start, $routeStats)) {
1702 2163 return $requestUri;
1703 2164 }
2165 +
1704 2166 return false;
1705 2167 }
1706 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 +
1707 2193 public static function getTopicsConfig()
1708 2194 {
1709 - return Utility::getFromCache('topics_config', function () {
1710 - $config = Utility::getOption('topics_config', []);
1711 - $default = [
1712 - 'max_topics_per_post' => 1,
1713 - 'max_topics_per_space' => 20,
1714 - 'show_on_post_card' => 'yes'
1715 - ];
1716 - return wp_parse_args($config, $default);
1717 - }, 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);
1718 2572 }
1719 2573 }