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.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 1.1.0 All 77 releases
← All changes | app/Services/Helper.php +1140 -186 1.0.922.10.0 View file →
@@ -5,8 +5,10 @@
5 5 use FluentCommunity\App\App;
6 6 use FluentCommunity\App\Functions\Utility;
7 7 use FluentCommunity\App\Hooks\Handlers\ActivationHandler;
8 8 use FluentCommunity\App\Models\BaseSpace;
9 +use FluentCommunity\App\Models\Contact;
10 +use FluentCommunity\App\Models\Feed;
9 11 use FluentCommunity\App\Models\Space;
10 12 use FluentCommunity\App\Models\Media;
11 13 use FluentCommunity\App\Models\Meta;
12 14 use FluentCommunity\App\Models\SpaceUserPivot;
@@ -13,8 +15,9 @@
13 15 use FluentCommunity\App\Models\User;
14 16 use FluentCommunity\App\Models\XProfile;
15 17 use FluentCommunity\Framework\Support\Arr;
16 18 use FluentCommunity\App\Models\SpaceGroup;
19 +use FluentCommunity\Modules\Course\Model\Course;
17 20
18 21 /**
19 22 * Helper class for various utility functions.
20 23 */
@@ -26,30 +29,95 @@
26 29 return apply_filters('fluent_community/is_rtl', is_rtl());
27 30 }
28 31
29 32 /**
33 + * Run a callback inside a database transaction.
34 + *
35 + * @param callable $callback
36 + * @return mixed
37 + * @throws \Exception
38 + */
39 + public static function dbTransaction($callback)
40 + {
41 + $db = App::make('db');
42 + $db->beginTransaction();
43 +
44 + try {
45 + $result = $callback();
46 + $db->commit();
47 + return $result;
48 + } catch (\Exception $e) {
49 + $db->rollBack();
50 + throw $e;
51 + }
52 + }
53 +
54 + /**
55 + * Check if POST content length exceeds PHP limits
56 + *
57 + * @return array|false Error array if limit exceeded, false otherwise
58 + */
59 + public static function checkUploadSizeError()
60 + {
61 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Server variable check
62 + $contentLength = isset($_SERVER['CONTENT_LENGTH']) ? (int) $_SERVER['CONTENT_LENGTH'] : 0;
63 + $postMaxSize = wp_convert_hr_to_bytes(ini_get('post_max_size'));
64 +
65 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only existence check on superglobals, no state mutation
66 + if ($contentLength > 0 && ($contentLength > $postMaxSize || (empty($_FILES) && empty($_POST)))) {
67 + return [
68 + 'message' => sprintf(
69 + /* translators: %s: max size */
70 + __('Upload failed: File exceeds server limit (%s). Please upload a smaller file.', 'fluent-community'),
71 + size_format($postMaxSize)
72 + )
73 + ];
74 + }
75 +
76 + return false;
77 + }
78 +
79 + /**
30 80 * Get the portal slug.
31 81 *
32 82 * @return string The portal slug.
33 83 */
84 + /**
85 + * Get the portal slug.
86 + *
87 + * @return string The portal slug.
88 + */
34 89 public static function getPortalSlug($forRoute = false)
35 90 {
36 - $settings = self::generalSettings();
37 - $slug = apply_filters('fluent_community/portal_slug', $settings['slug']);
91 + $settings = get_option('fluent_community_settings', []);
92 + if (isset($settings['slug'])) {
93 + $slug = $settings['slug'];
94 + } else {
95 + $slug = 'portal';
96 + }
38 97
98 + if (defined('FLUENT_COMMUNITY_PORTAL_SLUG')) {
99 + $slug = \FLUENT_COMMUNITY_PORTAL_SLUG;
100 + }
101 +
102 + $slug = apply_filters('fluent_community/portal_slug', $slug);
103 +
39 104 if (!$forRoute) {
40 105 return $slug;
41 106 }
42 107
43 - $siteUrl = get_site_url();
108 + $siteUrl = get_home_url();
44 109
45 110 $poralUrl = self::baseUrl('/');
46 111
47 - // get the url without path
48 - $siteUrl = str_replace(parse_url($siteUrl, PHP_URL_PATH), '', $siteUrl);
112 + $urlPath = wp_parse_url($siteUrl, PHP_URL_PATH);
49 113
114 + if ($urlPath) {
115 + // get the url without path
116 + $siteUrl = str_replace($urlPath, '', $siteUrl);
117 + }
118 +
50 119 $slug = str_replace($siteUrl, '', $poralUrl);
51 -
52 120 // remove the first and last slashes
53 121 return trim($slug, '/');
54 122 }
55 123
@@ -84,15 +152,82 @@
84 152 return apply_filters('fluent_community/has_color_scheme', $status);
85 153 }
86 154
87 155 /**
88 - * Check if the user is a site admin.
89 - *
90 - * @param int|null $userId The user ID to check. If null, checks the current user.
91 - * @return bool True if the user is a site admin, false otherwise.
156 + * Admin default theme mode: 'light', 'dark', or 'system'.
92 157 */
93 - public static function isSiteAdmin($userId = null)
158 + public static function getDefaultThemeMode()
94 159 {
160 + $settings = Utility::getCustomizationSettings();
161 + $mode = isset($settings['default_theme_mode']) ? $settings['default_theme_mode'] : 'light';
162 +
163 + if (!in_array($mode, ['light', 'dark', 'system'], true)) {
164 + $mode = 'light';
165 + }
166 +
167 + return apply_filters('fluent_community/default_theme_mode', $mode);
168 + }
169 +
170 + /**
171 + * Pre-paint script that sets the theme before first render (no flash).
172 + * Precedence mirrors runtime: host-theme cookie → user pick → admin default.
173 + * Not persisted. Gate on hasColorScheme().
174 + */
175 + public static function renderColorSchemePrePaintScript()
176 + {
177 + $defaultMode = self::getDefaultThemeMode();
178 + $portalVars = apply_filters('fluent_community/general_portal_vars', ['color_switch_cookie_name' => '']);
179 + $cookieName = isset($portalVars['color_switch_cookie_name']) ? $portalVars['color_switch_cookie_name'] : '';
180 + ?>
181 + <script>
182 + (function () {
183 + var root = document.documentElement;
184 + var cookieName = '<?php echo esc_js($cookieName); ?>';
185 + var mode = null;
186 +
187 + // host-theme integration cookie (Blocksy/Kadence) wins when present
188 + if (cookieName) {
189 + var safeName = cookieName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
190 + var match = document.cookie.match('(?:^|; )' + safeName + '=([^;]*)');
191 + if (match) {
192 + var cookieMode = decodeURIComponent(match[1]);
193 + if (cookieMode === 'dark' || cookieMode === 'light') {
194 + mode = cookieMode;
195 + }
196 + }
197 + }
198 +
199 + // explicit user pick
200 + if (!mode) {
201 + try {
202 + var stored = JSON.parse(localStorage.getItem('fcom_global_storage') || '{}').fcom_color_mode;
203 + if (stored === 'dark' || stored === 'light') {
204 + mode = stored;
205 + }
206 + } catch (error) {}
207 + }
208 +
209 + // admin default
210 + if (!mode) {
211 + var defaultMode = '<?php echo esc_js($defaultMode); ?>';
212 + if (defaultMode === 'system') {
213 + mode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
214 + } else {
215 + mode = defaultMode;
216 + }
217 + }
218 +
219 + root.setAttribute('data-color-mode', mode === 'dark' ? 'dark' : 'light');
220 + if (mode === 'dark') {
221 + root.classList.add('dark');
222 + }
223 + })();
224 + </script>
225 + <?php
226 + }
227 +
228 + public static function isSuperAdmin($userId = null)
229 + {
95 230 $capability = apply_filters('fluent_community/super_admin_capability', 'manage_options');
96 231
97 232 if (!$capability) {
98 233 return false;
@@ -105,15 +240,42 @@
105 240 if (!$userId) {
106 241 return false;
107 242 }
108 243
109 - if (!$userId) {
110 - return current_user_can($capability);
244 + return user_can($userId, $capability);
245 + }
246 +
247 + /**
248 + * Check if the user is a site admin.
249 + *
250 + * @param int|null $userId The user ID to check. If null, checks the current user.
251 + * @param \FluentCommunity\App\Models\User|null $user Resolved user model, to save a lookup.
252 + * @return bool True if the user is a site admin, false otherwise.
253 + */
254 + public static function isSiteAdmin($userId = null, $user = null)
255 + {
256 + if (self::isSuperAdmin($userId)) {
257 + return true;
111 258 }
112 259
113 - return user_can($userId, $capability);
260 + if (!$user) {
261 + $user = ($userId && (int)$userId !== get_current_user_id())
262 + ? User::find($userId)
263 + : self::getCurrentUser();
264 + }
265 +
266 + return $user && Arr::get($user->getPermissions(), 'community_admin');
114 267 }
115 268
269 + public static function isModerator($user = null)
270 + {
271 + if (!$user) {
272 + $user = self::getCurrentUser();
273 + }
274 +
275 + return $user && $user->hasCommunityModeratorAccess();
276 + }
277 +
116 278 /**
117 279 * Get the URL for an asset file.
118 280 *
119 281 * @param string $file The file name.
@@ -145,8 +307,15 @@
145 307
146 308 return $baseUrl . '/#/' . ltrim($path, '/');
147 309 }
148 310
311 + public static function getAuthUrl()
312 + {
313 + $settings = self::generalSettings();
314 +
315 + return Arr::get($settings, 'cutsom_auth_url', '');
316 + }
317 +
149 318 /**
150 319 * Get the space IDs for a user.
151 320 *
152 321 * @param int|null $userId The user ID. If null, uses the current user.
@@ -163,8 +332,19 @@
163 332 ->pluck('space_id')
164 333 ->toArray();
165 334 }
166 335
336 + public static function getUserSpaces($userId = null)
337 + {
338 + if (!$userId) {
339 + $userId = get_current_user_id();
340 + }
341 +
342 + return Space::whereHas('members', function ($query) use ($userId) {
343 + $query->where('user_id', $userId);
344 + })->get();
345 + }
346 +
167 347 /**
168 348 * Check if a user is in a specific space.
169 349 *
170 350 * @param int $userId The user ID.
@@ -172,10 +352,15 @@
172 352 * @return bool True if the user is in the space, false otherwise.
173 353 */
174 354 public static function isUserInSpace($userId, $spaceId)
175 355 {
356 + if (!$userId || !$spaceId) {
357 + return false;
358 + }
359 +
176 360 return SpaceUserPivot::where('user_id', $userId)
177 361 ->where('space_id', $spaceId)
362 + ->where('status', 'active')
178 363 ->exists();
179 364 }
180 365
181 366 /**
@@ -234,8 +419,19 @@
234 419
235 420 return Media::where('media_key', $key)->first();
236 421 }
237 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 +
238 434 /**
239 435 * Get media items from multiple URLs.
240 436 *
241 437 * @param array $urls An array of URLs.
@@ -276,21 +472,31 @@
276 472 'site_title' => get_bloginfo('name'),
277 473 'slug' => 'portal',
278 474 'logo' => '',
279 475 'white_logo' => '',
476 + 'logo_permalink_type' => 'default',
477 + 'logo_permalink' => '',
280 478 'featured_image' => '',
281 479 'access' => [
282 - 'acess_level' => 'logged_in', // logged_in, public, role_based
480 + 'acess_level' => 'public', // logged_in, public, role_based
283 481 'access_roles' => []
284 482 ],
483 + 'auth_form_type' => 'default',
484 + 'explicit_registration' => 'no',
285 485 'disable_global_posts' => 'yes',
286 486 'auth_content' => 'Please login first to access this page',
287 487 'auth_redirect' => '',
288 - 'restricted_role_content' => 'Sorry, you can not access to this page. Only authorized users can access this page.',
289 - 'auth_url' => ''
488 + 'restricted_role_content' => 'Sorry, you cannot access this page. Only authorized users can access this page.',
489 + 'auth_url' => '',
490 + 'cutsom_auth_url' => self::baseUrl('?fcom_action=auth'),
491 + 'use_custom_signup_page' => 'no',
492 + 'custom_signup_url' => ''
290 493 ];
291 494
292 495 $settings = wp_parse_args($settings, $defaults);
496 + if ($settings['auth_form_type'] != 'custom' || empty($settings['auth_form_type'])) {
497 + $settings['cutsom_auth_url'] = self::baseUrl('?fcom_action=auth');
498 + }
293 499
294 500 if (defined('FLUENT_COMMUNITY_PORTAL_SLUG')) {
295 501 $settings['slug'] = \FLUENT_COMMUNITY_PORTAL_SLUG;
296 502 $settings['is_slug_defined'] = true;
@@ -303,9 +509,11 @@
303 509
304 510 public static function hasGlobalPost()
305 511 {
306 512 $settings = self::generalSettings();
307 - return Arr::get($settings, 'disable_global_posts', '') != 'yes';
513 + $status = Arr::get($settings, 'disable_global_posts', '') != 'yes';
514 +
515 + return apply_filters('fluent_community/has_global_post', $status);
308 516 }
309 517
310 518 /**
311 519 * Check if a user can access the portal.
@@ -312,16 +520,15 @@
312 520 *
313 521 * @param int|null $userId The user ID. If null, uses the current user.
314 522 * @return bool True if the user can access the portal, false otherwise.
315 523 */
316 - public static function canAccessPortal($userId = null)
524 + public static function canAccessPortal($userId = null, $requireActiveProfile = true)
317 525 {
318 526 $settings = self::generalSettings();
319 -
320 527 $accessLevel = Arr::get($settings, 'access.acess_level');
321 528
322 529 if ($accessLevel == 'public') {
323 - return true;
530 + return apply_filters('fluent_community/can_access_portal', true);
324 531 }
325 532
326 533 if (!$userId) {
327 534 $userId = get_current_user_id();
@@ -327,17 +534,17 @@
327 534 $userId = get_current_user_id();
328 535 }
329 536
330 537 if (!$userId) {
331 - return false;
538 + return apply_filters('fluent_community/can_access_portal', false);
332 539 }
333 540
334 541 if ($accessLevel == 'logged_in') {
335 - return true;
542 + return apply_filters('fluent_community/can_access_portal', true);
336 543 }
337 544
338 545 if (user_can($userId, 'edit_pages')) {
339 - return true;
546 + return apply_filters('fluent_community/can_access_portal', true);
340 547 }
341 548
342 549 $roles = Arr::get($settings, 'access.access_roles', []);
343 550
@@ -343,20 +550,26 @@
343 550
344 551 $user = get_user_by('ID', $userId);
345 552
346 553 if (!$user) {
347 - return false;
554 + return apply_filters('fluent_community/can_access_portal', false);
348 555 }
349 556
350 557 $result = !!array_intersect(array_values($user->roles), $roles);
351 558
352 559 if (!$result) {
353 - return false;
560 + return apply_filters('fluent_community/can_access_portal', false);
354 561 }
355 562
563 + if (!$requireActiveProfile) {
564 + return apply_filters('fluent_community/can_access_portal', true);
565 + }
566 +
356 567 $xProfile = Helper::getCurrentProfile();
357 568
358 - return $xProfile && $xProfile->status == 'active';
569 + $result = $xProfile && $xProfile->status == 'active';
570 +
571 + return apply_filters('fluent_community/can_access_portal', $result);
359 572 }
360 573
361 574 /**
362 575 * Get the portal route paths.
@@ -366,16 +579,22 @@
366 579 public static function portalRoutePaths()
367 580 {
368 581 return apply_filters('fluent_community/app_route_paths', [
369 582 'portal_home',
370 - 'community',
371 - 'admin',
372 583 'members',
584 + 'bookmarks',
585 + 'chat',
586 + 'dashboard',
587 + 'leaderboards',
588 + 'notifications',
589 + 'space',
590 + 'discover',
591 + 'courses',
373 592 'u',
374 - 'discussions',
375 - 'notifications',
376 - 'bookmarks',
377 - 'courses'
593 + 'post',
594 + 'admin',
595 + 'course',
596 + 'site-maps'
378 597 ]);
379 598 }
380 599
381 600 /**
@@ -385,21 +604,18 @@
385 604 * @return XProfile|null The user's profile or null if not found.
386 605 */
387 606 public static function getCurrentProfile($cached = true)
388 607 {
389 - $userId = get_current_user_id();
390 - if (!$userId) {
391 - return null;
392 - }
393 -
394 608 static $profile;
395 -
396 609 if ($profile && $cached) {
397 610 return $profile;
398 611 }
399 612
613 + $userId = get_current_user_id();
614 +
400 615 if (!$userId) {
401 - return null;
616 + $profile = null;
617 + return $profile;
402 618 }
403 619
404 620 $profile = XProfile::where('user_id', $userId)->first();
405 621
@@ -418,16 +634,14 @@
418 634 if (!$userId) {
419 635 return false;
420 636 }
421 637
422 - static $user;
423 - if ($user && $cached) {
424 - return $user;
638 + static $users = [];
639 + if ($cached && isset($users[$userId])) {
640 + return $users[$userId];
425 641 }
426 642
427 - $user = User::find($userId);
428 -
429 - return $user;
643 + return $users[$userId] = User::find($userId);
430 644 }
431 645
432 646 /**
433 647 * Get the route paths for the community.
@@ -539,8 +753,31 @@
539 753 return false;
540 754 }
541 755
542 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 + /**
543 780 * Get a human-readable excerpt from content.
544 781 *
545 782 * @param string $content The content to extract from.
546 783 * @param int $length The maximum length of the excerpt.
@@ -561,12 +798,12 @@
561 798 // Blockquotes: remove '>' symbol
562 799 '/^\s*>\s?/m' => '',
563 800 // Horizontal rules: replace with empty line
564 801 '/^\s*([-*_])\1{2,}\s*$/m' => "\n",
802 + // Images: keep only the alt text (run before links)
803 + '/!\[([^\]]*)\]\([^\)]+\)/' => '$1',
565 804 // Links: keep only the link text
566 - '/\[([^\]]+)\]\([^\)]+\)/' => '$1',
567 - // Images: keep only the alt text
568 - '/!\[([^\]]+)\]\([^\)]+\)/' => '$1',
805 + '/\[([^\]]*)\]\([^\)]+\)/' => '$1',
569 806 // Strikethrough: remove '~~' symbols
570 807 '/~~(.*?)~~/' => '$1',
571 808 // Task lists: remove checkbox syntax
572 809 '/^\s*[-*+]\s+\[[ xX]\]\s+/m' => '',
@@ -573,8 +810,10 @@
573 810 ];
574 811
575 812 $content = preg_replace(array_keys($patterns), array_values($patterns), $content);
576 813
814 + $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
815 +
577 816 // remove all tags
578 817 $content = wp_strip_all_tags($content);
579 818 // remove new lines and tabs
580 819 $content = str_replace(["\r", "\n", "\t"], ' ', $content);
@@ -604,9 +843,8 @@
604 843 */
605 844 public static function isPublicAccessible()
606 845 {
607 846 $settings = self::generalSettings();
608 -
609 847 return Arr::get($settings, 'access.acess_level') == 'public';
610 848 }
611 849
612 850 /**
@@ -638,9 +876,9 @@
638 876 *
639 877 * @param User|null $user The user to get menu groups for.
640 878 * @return array The community menu groups.
641 879 */
642 - public static function getCommunityMenuGroups($user = null)
880 + public static function getCommunityMenuGroups($user = null, $view = true)
643 881 {
644 882 if (!$user) {
645 883 $user = self::getCurrentUser();
646 884 }
@@ -650,52 +888,74 @@
650 888 if ($communityGroups->isEmpty()) {
651 889 return [];
652 890 }
653 891
654 - $isMod = $user && $user->isCommunityModerator();
892 + $userSpaceIds = $user ? self::getUserSpaceIds($user->ID) : [];
893 + $isComModerator = $user && $user->hasCommunityModeratorAccess();
894 + $isCourseCreator = $user && $user->hasCourseCreatorAccess();
895 + $isSpaceModerator = $user && $user->isSpaceModerator();
655 896
656 897 $formattedGroups = [];
657 -
658 898 foreach ($communityGroups as $communityGroup) {
899 + $validSpaces = [];
659 900 $spaces = $communityGroup->spaces;
660 - $validSpaces = [];
661 901 $isShowAll = Arr::get($communityGroup->settings, 'always_show_spaces') === 'yes';
662 902
663 - foreach ($spaces as $space) {
664 - if ($isMod) {
665 - $validSpaces[] = self::transformSpaceToLink($space);
903 + if (!$isShowAll && !$isSpaceModerator) {
904 + $spaceIds = $spaces->pluck('id')->toArray();
905 + $isNotMemberOfAnySpace = empty(array_intersect($spaceIds, $userSpaceIds));
906 + if ($isNotMemberOfAnySpace) {
666 907 continue;
667 908 }
909 + }
668 910
669 - if ($space->privacy === 'secret') {
670 - if (!$user || !$space->getMembership($user->ID)) {
671 - continue;
672 - }
673 - $validSpaces[] = self::transformSpaceToLink($space);
911 + if ($user) {
912 + BaseSpace::preloadMemberships($spaces, $user->ID);
913 + }
914 +
915 + foreach ($spaces as $space) {
916 + $validSpace = $view ? self::transformSpaceToLink($space, $user) : $space;
917 + if (!$validSpace) {
674 918 continue;
675 919 }
676 920
677 - if ($isShowAll || $space->privacy = 'public') {
678 - $validSpace = self::transformSpaceToLink($space);
921 + if ($user && $space->isContentSpace()) {
922 + $validSpace['unread_badge'] = self::getUnreadFeedsCounts($space->id);
923 + }
679 924
680 - if ($space->privacy == 'private') {
681 - if (!$user || !$space->getMembership($user->ID)) {
682 - $validSpace['show_lock'] = true;
683 - }
684 - }
925 + if ($isComModerator && $space->type != 'course') {
926 + $validSpaces[] = $validSpace;
927 + continue;
928 + }
685 929
930 + if ($isCourseCreator && $space->type == 'course') {
686 931 $validSpaces[] = $validSpace;
687 932 continue;
688 933 }
689 934
690 - if (!$user || $space->getMembership($user->ID)) {
935 + if ($space->privacy == 'public') {
936 + $validSpaces[] = $validSpace;
691 937 continue;
692 938 }
693 939
694 - $validSpaces[] = self::transformSpaceToLink($space);
940 + $hasMembership = $user && $space->getMembership($user->ID);
941 +
942 + if ($space->privacy == 'private') {
943 + if (!$user || !$hasMembership) {
944 + $validSpace['show_lock'] = true;
945 + }
946 + }
947 +
948 + if ($space->privacy == 'secret') {
949 + if (!$user || !$hasMembership) {
950 + continue;
951 + }
952 + }
953 +
954 + $validSpaces[] = $validSpace;
695 955 }
696 956
697 - if (!$validSpaces && !$isMod) {
957 + if (!$validSpaces && !$isSpaceModerator) {
698 958 continue;
699 959 }
700 960
701 961 $formattedGroups[] = [
@@ -706,22 +966,58 @@
706 966 'children' => $validSpaces
707 967 ];
708 968 }
709 969
710 - return $formattedGroups;
970 + return apply_filters('fluent_community/menu_groups_for_user', $formattedGroups, $user);
711 971 }
712 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 +
713 1005 /**
714 1006 * Transform a space to a link array.
715 1007 *
716 1008 * @param Space $space The space to transform.
717 - * @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.
718 1011 */
719 - private static function transformSpaceToLink($space)
1012 + private static function transformSpaceToLink($space, $user = null)
720 1013 {
1014 + $isCustomLink = $space->type == 'sidebar_link';
1015 + if ($isCustomLink && !self::canViewSideLinkLink($space, $user)) {
1016 + return null;
1017 + }
721 1018
722 1019 $logo = $space->logo;
723 -
724 1020 $title = $space->title;
725 1021
726 1022 if ($space->status == 'draft') {
727 1023 $title = $title . ' ' . __('(Draft)', 'fluent-community');
@@ -732,12 +1028,44 @@
732 1028 'icon_image' => $logo,
733 1029 'shape_svg' => !$logo ? Arr::get($space->settings, 'shape_svg', '') : '',
734 1030 'emoji' => !$logo ? Arr::get($space->settings, 'emoji', '') : '',
735 1031 'permalink' => $space->getPermalink(),
1032 + 'is_custom' => $isCustomLink ? 'yes' : 'no',
1033 + 'new_tab' => ($isCustomLink && Arr::get($space, 'settings.new_tab', 'no') === 'yes') ? 'yes' : 'no',
736 1034 'link_classes' => 'space_menu_item route_url fcom_space_id_' . $space->id . ' fcom_space_' . $space->slug
737 1035 ];
738 1036 }
739 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 +
740 1068 public static function isAlreadyOnboarded()
741 1069 {
742 1070 $communitySettings = get_option('fluent_community_settings', []);
743 1071
@@ -809,10 +1137,14 @@
809 1137 if ($menuGroups && $context === 'view') {
810 1138 return $menuGroups;
811 1139 }
812 1140
813 - $menuGroups = Utility::getOption('fluent_community_menu_groups', []);
1141 + $menuGroups = (array) Utility::getOption('fluent_community_menu_groups', []);
814 1142
1143 + $membersPageStatus = Utility::canViewMembersPage() ? 'yes' : 'no';
1144 +
1145 + $leaderboardPageVisibility = (Utility::canViewLeaderboardMembers() || is_user_logged_in()) ? 'yes' : 'no';
1146 +
815 1147 $defaultMainMenuItems = [
816 1148 'all_feeds' => [
817 1149 'slug' => 'all_feeds',
818 1150 'title' => __('Feed', 'fluent-community'),
@@ -834,9 +1166,9 @@
834 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>'
835 1167 ],
836 1168 'all_courses' => [
837 1169 'slug' => 'all_courses',
838 - 'title' => 'Courses',
1170 + 'title' => __('Courses', 'fluent-community'),
839 1171 'link_classes' => 'fcom_courses route_url',
840 1172 'is_system' => 'yes',
841 1173 'is_locked' => 'yes',
842 1174 'enabled' => 'yes',
@@ -844,23 +1176,24 @@
844 1176 'permalink' => self::baseUrl('courses'),
845 1177 'shape_svg' => '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10.734 5.84746L14.7114 6.9072M9.88139 9.01146L11.8701 9.54132M9.98031 14.9723L10.7758 15.1843C13.0258 15.7838 14.1508 16.0835 15.037 15.5747C15.9233 15.0659 16.2247 13.9473 16.8276 11.71L17.6802 8.54599C18.2831 6.3087 18.5845 5.19006 18.0728 4.30879C17.5611 3.42752 16.4362 3.12778 14.1862 2.52831L13.3907 2.31636C11.1407 1.71688 10.0157 1.41714 9.12948 1.92594C8.24322 2.43474 7.94178 3.55338 7.3389 5.79067L6.4863 8.95466C5.88342 11.1919 5.58198 12.3106 6.09367 13.1919C6.60536 14.0731 7.73034 14.3729 9.98031 14.9723Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M9.99996 17.4559L9.20634 17.672C6.96165 18.2832 5.83931 18.5889 4.95512 18.0701C4.07093 17.5513 3.7702 16.4107 3.16874 14.1295L2.31814 10.9035C1.71668 8.62232 1.41595 7.48174 1.92643 6.58318C2.36802 5.80591 3.33329 5.83421 4.58329 5.83411" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>'
846 1178 ],
847 1179 'all_members' => [
848 - 'slug' => 'all_members',
849 - 'title' => __('Members', 'fluent-community'),
850 - 'is_system' => 'yes',
851 - 'is_locked' => 'yes',
852 - 'enabled' => 'yes',
853 - 'permalink' => self::baseUrl('members'),
854 - 'link_classes' => 'fcom_all_members route_url',
855 - 'shape_svg' => '<svg width="20" height="16" viewBox="0 0 20 16" fill="none"><path d="M17.3116 13C17.936 13 18.4327 12.6071 18.8786 12.0576C19.7915 10.9329 18.2927 10.034 17.721 9.59383C17.1399 9.14635 16.4911 8.89285 15.8332 8.83333M14.9999 7.16667C16.1505 7.16667 17.0832 6.23393 17.0832 5.08333C17.0832 3.93274 16.1505 3 14.9999 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M2.68822 13C2.0638 13 1.56714 12.6071 1.12121 12.0576C0.208326 10.9329 1.70714 10.034 2.27879 9.59383C2.8599 9.14635 3.50874 8.89285 4.16659 8.83333M4.58325 7.16667C3.43266 7.16667 2.49992 6.23393 2.49992 5.08333C2.49992 3.93274 3.43266 3 4.58325 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M6.73642 10.592C5.88494 11.1185 3.65241 12.1936 5.01217 13.5389C5.6764 14.196 6.41619 14.666 7.34627 14.666H12.6536C13.5837 14.666 14.3234 14.196 14.9877 13.5389C16.3474 12.1936 14.1149 11.1185 13.2634 10.592C11.2667 9.35735 8.73313 9.35735 6.73642 10.592Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12.9166 4.24967C12.9166 5.86051 11.6107 7.16634 9.99992 7.16634C8.38909 7.16634 7.08325 5.86051 7.08325 4.24967C7.08325 2.63884 8.38909 1.33301 9.99992 1.33301C11.6107 1.33301 12.9166 2.63884 12.9166 4.24967Z" stroke="currentColor" stroke-width="1.5"/></svg>',
1180 + 'slug' => 'all_members',
1181 + 'title' => __('Members', 'fluent-community'),
1182 + 'is_system' => 'yes',
1183 + 'is_locked' => 'yes',
1184 + 'is_unavailable' => $membersPageStatus == 'yes' ? 'no' : 'yes',
1185 + 'enabled' => $membersPageStatus,
1186 + 'permalink' => self::baseUrl('members'),
1187 + 'link_classes' => 'fcom_all_members route_url',
1188 + 'shape_svg' => '<svg width="20" height="16" viewBox="0 0 20 16" fill="none"><path d="M17.3116 13C17.936 13 18.4327 12.6071 18.8786 12.0576C19.7915 10.9329 18.2927 10.034 17.721 9.59383C17.1399 9.14635 16.4911 8.89285 15.8332 8.83333M14.9999 7.16667C16.1505 7.16667 17.0832 6.23393 17.0832 5.08333C17.0832 3.93274 16.1505 3 14.9999 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M2.68822 13C2.0638 13 1.56714 12.6071 1.12121 12.0576C0.208326 10.9329 1.70714 10.034 2.27879 9.59383C2.8599 9.14635 3.50874 8.89285 4.16659 8.83333M4.58325 7.16667C3.43266 7.16667 2.49992 6.23393 2.49992 5.08333C2.49992 3.93274 3.43266 3 4.58325 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M6.73642 10.592C5.88494 11.1185 3.65241 12.1936 5.01217 13.5389C5.6764 14.196 6.41619 14.666 7.34627 14.666H12.6536C13.5837 14.666 14.3234 14.196 14.9877 13.5389C16.3474 12.1936 14.1149 11.1185 13.2634 10.592C11.2667 9.35735 8.73313 9.35735 6.73642 10.592Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12.9166 4.24967C12.9166 5.86051 11.6107 7.16634 9.99992 7.16634C8.38909 7.16634 7.08325 5.86051 7.08325 4.24967C7.08325 2.63884 8.38909 1.33301 9.99992 1.33301C11.6107 1.33301 12.9166 2.63884 12.9166 4.24967Z" stroke="currentColor" stroke-width="1.5"/></svg>',
856 1189 ],
857 1190 'leaderboard' => [
858 1191 'slug' => 'leaderboard',
859 1192 'is_system' => 'yes',
860 1193 'is_locked' => 'yes',
861 - 'enabled' => 'yes',
862 - 'is_unavailable' => self::isFeatureEnabled('leader_board_module') ? 'no' : 'yes',
1194 + 'enabled' => $leaderboardPageVisibility,
1195 + 'is_unavailable' => self::isFeatureEnabled('leader_board_module') && $leaderboardPageVisibility == 'yes' ? 'no' : 'yes',
863 1196 'title' => __('Leaderboard', 'fluent-community'),
864 1197 'link_classes' => 'fcom_leaderboards route_url',
865 1198 'permalink' => self::baseUrl('leaderboards'),
866 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>'
@@ -881,9 +1214,9 @@
881 1214 continue;
882 1215 }
883 1216 $defaultItem = Arr::get($defaultMainMenuItems, $item['slug'], []);
884 1217 if ($defaultItem) {
885 - $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug', 'shape_svg'];
1218 + $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug'];
886 1219 foreach ($preservedKeys as $key) {
887 1220 if (isset($defaultItem[$key])) {
888 1221 $item[$key] = Arr::get($defaultItem, $key);
889 1222 }
@@ -889,11 +1222,12 @@
889 1222 }
890 1223 }
891 1224 if (Arr::get($defaultItem, 'is_system') === 'yes') {
892 1225 $item['permalink'] = $defaultItem['permalink'];
893 - $item['emoji'] = '';
894 - $item['icon_image'] = '';
895 1226 $item['link_classes'] = $defaultItem['link_classes'];
1227 + if (empty($item['shape_svg'])) {
1228 + $item['shape_svg'] = $defaultItem['shape_svg'];
1229 + }
896 1230 }
897 1231 }
898 1232 }
899 1233 } else {
@@ -932,11 +1266,9 @@
932 1266
933 1267 $profileDropDownItems = Arr::get($menuGroups, 'profileDropdownItems', []);
934 1268
935 1269 if ($profileDropDownItems && is_array($profileDropDownItems)) {
936 -
937 1270 unset($profileDropDownItems['profile']);
938 -
939 1271 foreach ($profileDropDownItems as $index => &$item) {
940 1272 if (empty($item['slug'])) {
941 1273 unset($profileDropDownItems[$index]);
942 1274 continue;
@@ -942,9 +1274,9 @@
942 1274 continue;
943 1275 }
944 1276 $defaultItem = Arr::get($defaultProfileDropDownItems, $item['slug'], []);
945 1277 if ($defaultItem) {
946 - $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug', 'svg_icon'];
1278 + $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug'];
947 1279 foreach ($preservedKeys as $key) {
948 1280 if (isset($defaultItem[$key])) {
949 1281 $item[$key] = Arr::get($defaultItem, $key);
950 1282 }
@@ -972,18 +1304,21 @@
972 1304 $afterCommunityMenuGroups = [];
973 1305 }
974 1306
975 1307 if ($context == 'view') {
976 - $mainItems = array_filter($mainItems, function ($item) {
977 - 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);
978 1313 });
979 1314
980 - $profileDropDownItems = array_filter($profileDropDownItems, function ($item) {
981 - 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);
982 1317 });
983 1318
984 - $beforeCommunityMenuItems = array_filter($beforeCommunityMenuItems, function ($item) {
985 - 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);
986 1321 });
987 1322
988 1323 $validGroups = [];
989 1324 foreach ($afterCommunityMenuGroups as $group) {
@@ -990,10 +1325,10 @@
990 1325 if (empty($group['items']) || !is_array($group['items'])) {
991 1326 continue;
992 1327 }
993 1328
994 - $group['items'] = array_filter($group['items'], function ($item) {
995 - 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);
996 1331 });
997 1332
998 1333 if ($group['items']) {
999 1334 $validGroups[] = $group;
@@ -1015,8 +1350,72 @@
1015 1350 return $menuGroups;
1016 1351 }
1017 1352
1018 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 + /**
1019 1418 * Get the meta data for a space.
1020 1419 *
1021 1420 * @param int $spaceId The ID of the space.
1022 1421 * @param string $key The meta key.
@@ -1058,9 +1457,9 @@
1058 1457 } else {
1059 1458 $meta = Meta::create([
1060 1459 'object_type' => 'space',
1061 1460 'object_id' => $spaceId,
1062 - 'meta_key' => $key,
1461 + 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1063 1462 'value' => $value
1064 1463 ]);
1065 1464 }
1066 1465
@@ -1133,49 +1532,58 @@
1133 1532 * @return array The welcome banner configuration.
1134 1533 */
1135 1534 public static function getWelcomeBannerSettings()
1136 1535 {
1137 - return Utility::getFromCache('welcome_banner_settings', function () {
1138 - $settings = Utility::getOption('welcome_banner_settings', []);
1139 - $defaults = [
1140 - 'login' => [
1141 - 'enabled' => 'no',
1142 - 'description' => '',
1143 - 'mediaType' => 'image',
1144 - 'allowClose' => 'no',
1145 - 'bannerImage' => '',
1146 - 'bannerVideo' => [
1147 - 'type' => '',
1148 - 'url' => '',
1149 - 'content_type' => '',
1150 - 'provider' => '',
1151 - 'title' => '',
1152 - 'author_name' => '',
1153 - 'html' => ''
1154 - ],
1155 - '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' => ''
1156 1551 ],
1157 - 'logout' => [
1158 - 'enabled' => 'no',
1159 - 'description' => '',
1160 - 'mediaType' => 'image',
1161 - 'useCustomUrl' => 'no',
1162 - 'bannerImage' => '',
1163 - 'bannerVideo' => [
1164 - 'type' => '',
1165 - 'url' => '',
1166 - 'content_type' => '',
1167 - 'provider' => '',
1168 - 'title' => '',
1169 - 'author_name' => '',
1170 - 'html' => ''
1171 - ],
1172 - 'ctaButtons' => []
1173 - ]
1174 - ];
1175 - $settings = wp_parse_args($settings, $defaults);
1176 - return $settings;
1177 - }, WEEK_IN_SECONDS);
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 + ];
1572 +
1573 + $settings = Utility::getOption('welcome_banner_settings', []);
1574 +
1575 + $settings = wp_parse_args($settings, $defaults);
1576 +
1577 + if (empty(Arr::get($settings, 'login.bannerVideo'))) {
1578 + $settings['login']['bannerVideo'] = $defaults['login']['bannerVideo'];
1579 + }
1580 +
1581 + if (empty(Arr::get($settings, 'logout.bannerVideo'))) {
1582 + $settings['logout']['bannerVideo'] = $defaults['logout']['bannerVideo'];
1583 + }
1584 +
1585 + return $settings;
1178 1586 }
1179 1587
1180 1588 public static function getWelcomeBanner($view = 'login')
1181 1589 {
@@ -1186,31 +1594,85 @@
1186 1594 }
1187 1595
1188 1596 unset($welcomeBanner['description']);
1189 1597
1190 - return $welcomeBanner;
1598 + if ($view == 'login') {
1599 + return apply_filters('fluent_community/welcome_banner_for_logged_in', $welcomeBanner);
1600 + }
1601 +
1602 + return apply_filters('fluent_community/welcome_banner_for_guests', $welcomeBanner);
1191 1603 }
1192 1604
1193 1605 public static function getEnabledFeedLinks()
1194 1606 {
1195 1607 $links = array_filter(self::getFeedLinks(), function ($item) {
1196 - return Arr::get($item, 'enabled') == 'yes' && Arr::get($item, 'is_unavailable') != 'yes';
1608 + return self::isLinkAccessible($item);
1197 1609 });
1198 1610
1199 1611 return array_values($links);
1200 1612 }
1201 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 +
1202 1667 public static function getFeedLinks()
1203 1668 {
1204 - return Utility::getFromCache('feed_links', function () {
1205 - return Utility::getOption('feed_links', []);
1206 - }, WEEK_IN_SECONDS);
1669 + return Utility::getOption('feed_links', []);
1207 1670 }
1208 1671
1209 1672 public static function updateFeedLinks($links)
1210 1673 {
1211 1674 Utility::updateOption('feed_links', $links);
1212 - Utility::setCache('feed_links', $links, WEEK_IN_SECONDS);
1213 1675 }
1214 1676
1215 1677 /**
1216 1678 * Get the full name of a WordPress user.
@@ -1272,24 +1734,38 @@
1272 1734
1273 1735 /**
1274 1736 * Add a user to a space.
1275 1737 *
1276 - * @param Space | int $space space to add the user to.
1738 + * @param BaseSpace|int $space space to add the user to.
1277 1739 * @param int $userId The ID of the user to add.
1278 1740 * @param string $role The role of the user in the space.
1279 1741 * @param string $by The source of the action.
1280 1742 * @return bool True if the user was added, false otherwise.
1281 1743 */
1282 - public static function addToSpace($space, $userId, $role = 'member', $by = 'self')
1744 + public static function addToSpace($space, $userId, $role = 'member', $by = 'self', $skipSync = false)
1283 1745 {
1284 1746 if (is_numeric($space)) {
1285 - $space = Space::find($space);
1747 + $space = BaseSpace::onlyMain()->find($space);
1286 1748 }
1287 1749
1288 - if (!$space) {
1750 + if (!$space || !$space instanceof BaseSpace) {
1289 1751 return false;
1290 1752 }
1291 1753
1754 + if (!$skipSync) {
1755 + $user = User::find($userId);
1756 +
1757 + if (!$user) {
1758 + return false;
1759 + }
1760 +
1761 + $user->syncXProfile();
1762 + }
1763 +
1764 + if ($role == 'member' && $space->type == 'course') {
1765 + $role = 'student';
1766 + }
1767 +
1292 1768 $exist = SpaceUserPivot::where('user_id', $userId)
1293 1769 ->where('space_id', $space->id)
1294 1770 ->first();
1295 1771
@@ -1295,10 +1771,21 @@
1295 1771
1296 1772 if ($exist) {
1297 1773 if ($exist->status != 'active') {
1298 1774 $exist->status = 'active';
1299 - $exist->role = $role;
1775 +
1776 + if (!in_array($exist->role, ['admin', 'moderator'])) {
1777 + $exist->role = $role;
1778 + }
1779 +
1300 1780 $exist->save();
1781 +
1782 + if ($space->type == 'course') {
1783 + do_action('fluent_community/course/enrolled', $space, $userId, $by);
1784 + } else {
1785 + do_action('fluent_community/space/joined', $space, $userId, $by);
1786 + }
1787 +
1301 1788 return true;
1302 1789 }
1303 1790
1304 1791 return false;
@@ -1303,9 +1790,9 @@
1303 1790
1304 1791 return false;
1305 1792 }
1306 1793
1307 - SpaceUserPivot::create([
1794 + $created = SpaceUserPivot::create([
1308 1795 'space_id' => $space->id,
1309 1796 'role' => $role,
1310 1797 'user_id' => $userId
1311 1798 ]);
@@ -1310,13 +1797,18 @@
1310 1797 'user_id' => $userId
1311 1798 ]);
1312 1799
1313 1800 if ($space->type == 'course') {
1314 - 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);
1315 1805 } else {
1316 - 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);
1317 1810 }
1318 -
1319 1811 return true;
1320 1812 }
1321 1813
1322 1814 /**
@@ -1334,26 +1826,40 @@
1334 1826 return false;
1335 1827 }
1336 1828
1337 1829 if (is_numeric($space)) {
1338 - $space = Space::find($space);
1830 + $space = BaseSpace::query()->onlyMain()->find($space);
1339 1831 }
1340 1832
1341 - if (!$space) {
1833 + if (!$space || !$space instanceof BaseSpace) {
1342 1834 return false;
1343 1835 }
1344 1836
1837 +
1345 1838 if (!self::isUserInSpace($userId, $space->id)) {
1346 1839 return false;
1347 1840 }
1348 1841
1349 - SpaceUserPivot::bySpace($space->id)
1350 - ->byUser(get_current_user_id())
1842 + SpaceUserPivot::where('space_id', $space->id)
1843 + ->where('user_id', $userId)
1351 1844 ->delete();
1352 1845
1353 1846 $user->cacheAccessSpaces();
1354 - do_action('fluent_community/space/user_left', $space, get_current_user_id(), $by);
1355 1847
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 +
1853 + do_action('fluent_community/course/student_left', $space, $userId, $by);
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
1859 + do_action('fluent_community/space/user_left', $space, $userId, $by);
1860 + }
1861 +
1356 1862 return true;
1357 1863 }
1358 1864
1359 1865 /**
@@ -1365,21 +1871,29 @@
1365 1871 * @param bool $renderIcon Whether to render the icon or not.
1366 1872 */
1367 1873 public static function renderLink($link, $linkClass = '', $fallback = '<span class="fcom_no_avatar"></span>', $renderIcon = true)
1368 1874 {
1875 + if (!$link || empty($link['permalink'])) {
1876 + return;
1877 + }
1878 +
1879 + $isCustom = Arr::get($link, 'is_custom') == 'yes';
1880 +
1369 1881 $linkAtts = array_filter([
1370 - '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' : ''),
1371 1883 'target' => Arr::get($link, 'new_tab') === 'yes' ? '_blank' : '',
1372 1884 'rel' => Arr::get($link, 'new_tab') === 'yes' ? 'noopener noreferrer' : '',
1373 1885 ]);
1886 +
1374 1887 ?>
1375 - <a aria-label="Go to <?php echo esc_attr(Arr::get($link, 'title')); ?> page"
1376 - href="<?php echo esc_url($link['permalink']); ?>" <?php foreach ($linkAtts as $key => $value) {
1377 - 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) . '"';
1378 1891 } ?>>
1379 1892 <?php $renderIcon && self::printLinkIcon($link, $fallback); ?>
1380 - <span class="community_name"><?php echo wp_kses_post(Arr::get($link, 'title')); ?></span>
1381 - <?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')) : ?>
1382 1896 <span class="fcom_space_lock">
1383 1897 <i class="el-icon">
1384 1898 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
1385 1899 <path fill="currentColor"
@@ -1388,14 +1902,49 @@
1388 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>
1389 1903 </svg>
1390 1904 </i>
1391 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>
1392 1910 <?php endif; ?>
1393 -
1394 1911 </a>
1395 1912 <?php
1396 1913 }
1397 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 +
1398 1947 /**
1399 1948 * Print a link icon.
1400 1949 *
1401 1950 * @param array $link The link data.
@@ -1453,9 +2002,12 @@
1453 2002 // most probably it's local reverse proxy
1454 2003 if (isset($_SERVER["HTTP_CLIENT_IP"])) {
1455 2004 $ipAddress = sanitize_text_field(wp_unslash($_SERVER["HTTP_CLIENT_IP"]));
1456 2005 } else if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
1457 - $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 + }
1458 2010 }
1459 2011 }
1460 2012
1461 2013 if (!$ipAddress) {
@@ -1578,8 +2130,14 @@
1578 2130 public static function getPortalRequestPath($requestUri)
1579 2131 {
1580 2132 $portalSlug = self::getPortalSlug();
1581 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 +
1582 2140 if ($portalSlug == $requestUri) {
1583 2141 return 'portal_home';
1584 2142 }
1585 2143
@@ -1594,26 +2152,422 @@
1594 2152
1595 2153 $parts = explode('/', $requestUri);
1596 2154 $start = $parts[0];
1597 2155
1598 - $routeStats = [
1599 - 'fcom_route',
1600 - 'portal_home',
1601 - 'admin',
1602 - 'post',
1603 - 'space',
1604 - 'discover',
1605 - 'members',
1606 - 'u',
1607 - 'notifications',
1608 - 'bookmarks',
1609 - 'courses',
1610 - 'course',
1611 - 'leaderboards'
1612 - ];
2156 + if (!$portalSlug && $start == 'fcom_route') {
2157 + return $start;
2158 + }
1613 2159
2160 + $routeStats = self::portalRoutePaths();
2161 +
1614 2162 if (in_array($start, $routeStats)) {
1615 2163 return $requestUri;
1616 2164 }
2165 +
1617 2166 return false;
2167 + }
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 +
2193 + public static function getTopicsConfig()
2194 + {
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);
1618 2572 }
1619 2573 }