PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.7.7
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.7.7
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
fluent-community / app / Services / Helper.php

Helper.php in FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses 2.7.7, at app/Services/Helper.php

2,488 lines 91.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCommunity\App\Services;
4
5 use FluentCommunity\App\App;
6 use FluentCommunity\App\Functions\Utility;
7 use FluentCommunity\App\Hooks\Handlers\ActivationHandler;
8 use FluentCommunity\App\Models\BaseSpace;
9 use FluentCommunity\App\Models\Feed;
10 use FluentCommunity\App\Models\Space;
11 use FluentCommunity\App\Models\Media;
12 use FluentCommunity\App\Models\Meta;
13 use FluentCommunity\App\Models\SpaceUserPivot;
14 use FluentCommunity\App\Models\User;
15 use FluentCommunity\App\Models\XProfile;
16 use FluentCommunity\Framework\Support\Arr;
17 use FluentCommunity\App\Models\SpaceGroup;
18 use FluentCommunity\Modules\Course\Model\Course;
19
20 /**
21 * Helper class for various utility functions.
22 */
23 class Helper
24 {
25
26 public static function isRtl()
27 {
28 return apply_filters('fluent_community/is_rtl', is_rtl());
29 }
30
31 /**
32 * Run a callback inside a database transaction.
33 *
34 * @param callable $callback
35 * @return mixed
36 * @throws \Exception
37 */
38 public static function dbTransaction($callback)
39 {
40 $db = App::make('db');
41 $db->beginTransaction();
42
43 try {
44 $result = $callback();
45 $db->commit();
46 return $result;
47 } catch (\Exception $e) {
48 $db->rollBack();
49 throw $e;
50 }
51 }
52
53 /**
54 * Check if POST content length exceeds PHP limits
55 *
56 * @return array|false Error array if limit exceeded, false otherwise
57 */
58 public static function checkUploadSizeError()
59 {
60 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Server variable check
61 $contentLength = isset($_SERVER['CONTENT_LENGTH']) ? (int) $_SERVER['CONTENT_LENGTH'] : 0;
62 $postMaxSize = wp_convert_hr_to_bytes(ini_get('post_max_size'));
63
64 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only existence check on superglobals, no state mutation
65 if ($contentLength > 0 && ($contentLength > $postMaxSize || (empty($_FILES) && empty($_POST)))) {
66 return [
67 'message' => sprintf(
68 /* translators: %s: max size */
69 __('Upload failed: File exceeds server limit (%s). Please upload a smaller file.', 'fluent-community'),
70 size_format($postMaxSize)
71 )
72 ];
73 }
74
75 return false;
76 }
77
78 /**
79 * Get the portal slug.
80 *
81 * @return string The portal slug.
82 */
83 /**
84 * Get the portal slug.
85 *
86 * @return string The portal slug.
87 */
88 public static function getPortalSlug($forRoute = false)
89 {
90 $settings = get_option('fluent_community_settings', []);
91 if (isset($settings['slug'])) {
92 $slug = $settings['slug'];
93 } else {
94 $slug = 'portal';
95 }
96
97 if (defined('FLUENT_COMMUNITY_PORTAL_SLUG')) {
98 $slug = \FLUENT_COMMUNITY_PORTAL_SLUG;
99 }
100
101 $slug = apply_filters('fluent_community/portal_slug', $slug);
102
103 if (!$forRoute) {
104 return $slug;
105 }
106
107 $siteUrl = get_home_url();
108
109 $poralUrl = self::baseUrl('/');
110
111 $urlPath = wp_parse_url($siteUrl, PHP_URL_PATH);
112
113 if ($urlPath) {
114 // get the url without path
115 $siteUrl = str_replace($urlPath, '', $siteUrl);
116 }
117
118 $slug = str_replace($siteUrl, '', $poralUrl);
119 // remove the first and last slashes
120 return trim($slug, '/');
121 }
122
123 /**
124 * Get the portal route type.
125 *
126 * @return string The portal route type.
127 */
128 public static function getPortalRouteType()
129 {
130 return apply_filters('fluent_community/portal_route_type', 'WebHistory');
131 }
132
133 /**
134 * Check if the portal is headless.
135 *
136 * @return bool True if headless, false otherwise.
137 */
138 public static function isHeadless()
139 {
140 return apply_filters('fluent_community/portal_page_headless', false);
141 }
142
143 /**
144 * Check if the portal has a color scheme.
145 *
146 * @return bool True if has color scheme, false otherwise.
147 */
148 public static function hasColorScheme()
149 {
150 $status = Utility::isCustomizationEnabled('dark_mode');
151 return apply_filters('fluent_community/has_color_scheme', $status);
152 }
153
154 /**
155 * Admin default theme mode: 'light', 'dark', or 'system'.
156 */
157 public static function getDefaultThemeMode()
158 {
159 $settings = Utility::getCustomizationSettings();
160 $mode = isset($settings['default_theme_mode']) ? $settings['default_theme_mode'] : 'light';
161
162 if (!in_array($mode, ['light', 'dark', 'system'], true)) {
163 $mode = 'light';
164 }
165
166 return apply_filters('fluent_community/default_theme_mode', $mode);
167 }
168
169 /**
170 * Pre-paint script that sets the theme before first render (no flash).
171 * Precedence mirrors runtime: host-theme cookie → user pick → admin default.
172 * Not persisted. Gate on hasColorScheme().
173 */
174 public static function renderColorSchemePrePaintScript()
175 {
176 $defaultMode = self::getDefaultThemeMode();
177 $portalVars = apply_filters('fluent_community/general_portal_vars', ['color_switch_cookie_name' => '']);
178 $cookieName = isset($portalVars['color_switch_cookie_name']) ? $portalVars['color_switch_cookie_name'] : '';
179 ?>
180 <script>
181 (function () {
182 var root = document.documentElement;
183 var cookieName = '<?php echo esc_js($cookieName); ?>';
184 var mode = null;
185
186 // host-theme integration cookie (Blocksy/Kadence) wins when present
187 if (cookieName) {
188 var safeName = cookieName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
189 var match = document.cookie.match('(?:^|; )' + safeName + '=([^;]*)');
190 if (match) {
191 var cookieMode = decodeURIComponent(match[1]);
192 if (cookieMode === 'dark' || cookieMode === 'light') {
193 mode = cookieMode;
194 }
195 }
196 }
197
198 // explicit user pick
199 if (!mode) {
200 try {
201 var stored = JSON.parse(localStorage.getItem('fcom_global_storage') || '{}').fcom_color_mode;
202 if (stored === 'dark' || stored === 'light') {
203 mode = stored;
204 }
205 } catch (error) {}
206 }
207
208 // admin default
209 if (!mode) {
210 var defaultMode = '<?php echo esc_js($defaultMode); ?>';
211 if (defaultMode === 'system') {
212 mode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
213 } else {
214 mode = defaultMode;
215 }
216 }
217
218 root.setAttribute('data-color-mode', mode === 'dark' ? 'dark' : 'light');
219 if (mode === 'dark') {
220 root.classList.add('dark');
221 }
222 })();
223 </script>
224 <?php
225 }
226
227 public static function isSuperAdmin($userId = null)
228 {
229 $capability = apply_filters('fluent_community/super_admin_capability', 'manage_options');
230
231 if (!$capability) {
232 return false;
233 }
234
235 if ($userId === null) {
236 $userId = get_current_user_id();
237 }
238
239 if (!$userId) {
240 return false;
241 }
242
243 return user_can($userId, $capability);
244 }
245
246 /**
247 * Check if the user is a site admin.
248 *
249 * @param int|null $userId The user ID to check. If null, checks the current user.
250 * @return bool True if the user is a site admin, false otherwise.
251 */
252 public static function isSiteAdmin($userId = null, $user = null)
253 {
254 if (self::isSuperAdmin($userId)) {
255 return true;
256 }
257
258 if (!$user) {
259 $user = self::getCurrentUser();
260 }
261
262 return $user && Arr::get($user->getPermissions(), 'community_admin');
263 }
264
265 public static function isModerator($user = null)
266 {
267 if (!$user) {
268 $user = self::getCurrentUser();
269 }
270
271 return $user && $user->hasCommunityModeratorAccess();
272 }
273
274 /**
275 * Get the URL for an asset file.
276 *
277 * @param string $file The file name.
278 * @return string The full URL to the asset.
279 */
280 public static function assetUrl($file = '')
281 {
282 return FLUENT_COMMUNITY_PLUGIN_URL . 'assets/' . $file;
283 }
284
285 /**
286 * Get the base URL for the portal.
287 *
288 * @param string $path The path to append to the base URL.
289 * @return string The full base URL.
290 */
291 public static function baseUrl($path = '')
292 {
293 $baseUrl = apply_filters('fluent_community/base_url', home_url(self::getPortalSlug()));
294 $baseUrl = rtrim($baseUrl, '/');
295
296 if (self::getPortalRouteType() != 'hash') {
297 return $baseUrl . '/' . ltrim($path, '/');
298 }
299
300 if (!$path) {
301 return $baseUrl . '/';
302 }
303
304 return $baseUrl . '/#/' . ltrim($path, '/');
305 }
306
307 public static function getAuthUrl()
308 {
309 $settings = self::generalSettings();
310
311 return Arr::get($settings, 'cutsom_auth_url', '');
312 }
313
314 /**
315 * Get the space IDs for a user.
316 *
317 * @param int|null $userId The user ID. If null, uses the current user.
318 * @return array An array of space IDs.
319 */
320 public static function getUserSpaceIds($userId = null)
321 {
322 if (!$userId) {
323 $userId = get_current_user_id();
324 }
325
326 return SpaceUserPivot::where('user_id', $userId)
327 ->where('status', 'active')
328 ->pluck('space_id')
329 ->toArray();
330 }
331
332 public static function getUserSpaces($userId = null)
333 {
334 if (!$userId) {
335 $userId = get_current_user_id();
336 }
337
338 return Space::whereHas('members', function ($query) use ($userId) {
339 $query->where('user_id', $userId);
340 })->get();
341 }
342
343 /**
344 * Check if a user is in a specific space.
345 *
346 * @param int $userId The user ID.
347 * @param int $spaceId The space ID.
348 * @return bool True if the user is in the space, false otherwise.
349 */
350 public static function isUserInSpace($userId, $spaceId)
351 {
352 if (!$userId || !$spaceId) {
353 return false;
354 }
355
356 return SpaceUserPivot::where('user_id', $userId)
357 ->where('space_id', $spaceId)
358 ->where('status', 'active')
359 ->exists();
360 }
361
362 /**
363 * Generate HTML attributes from an array.
364 *
365 * @param array $atts An array of attribute key-value pairs.
366 * @return string The generated HTML attributes string.
367 */
368 public static function attrs($atts = [])
369 {
370 $text = '';
371
372 foreach ($atts as $key => $value) {
373 $text .= "$key=\"$value\" ";
374 }
375
376 return $text;
377 }
378
379 /**
380 * Get media from a URL.
381 *
382 * @param string|array $url The URL or an array containing URL information.
383 * @return Media|null The Media object if found, null otherwise.
384 */
385 public static function getMediaFromUrl($url)
386 {
387 if (is_array($url) && isset($url['provider'])) {
388 $provider = Arr::get($url, 'provider');
389
390 if ($provider == 'giphy') {
391 return null;
392 }
393
394 $url = Arr::get($url, 'url');
395 }
396
397 if (!$url) {
398 return null;
399 }
400
401 $parsedUrl = wp_parse_url($url, PHP_URL_QUERY);
402
403 if (!$parsedUrl) {
404 return null;
405 }
406
407 // Parse the query string to get the media_key value
408 parse_str($parsedUrl, $queryParams);
409
410 $key = Arr::get($queryParams, 'media_key');
411
412 if (!$key) {
413 return null;
414 }
415
416 return Media::where('media_key', $key)->first();
417 }
418
419 public static function removeMediaByUrl($url = '', $subObjectId = null)
420 {
421 if (!$url || !$subObjectId) {
422 return;
423 }
424
425 do_action('fluent_community/remove_medias_by_url', [$url], [
426 'sub_object_id' => $subObjectId,
427 ]);
428 }
429
430 /**
431 * Get media items from multiple URLs.
432 *
433 * @param array $urls An array of URLs.
434 * @return array An array of Media objects.
435 */
436 public static function getMediaItemsFromUrl($urls)
437 {
438 $mediaItems = [];
439
440 foreach ($urls as $url) {
441 $media = self::getMediaFromUrl($url);
442
443 if ($media) {
444 $mediaItems[] = $media;
445 }
446 }
447
448 return $mediaItems;
449 }
450
451 /**
452 * Get general settings for the community.
453 *
454 * @param bool $cached Whether to use cached settings.
455 * @return array The general settings.
456 */
457 public static function generalSettings($cached = true)
458 {
459 static $settings = null;
460
461 if ($cached && $settings) {
462 return $settings;
463 }
464
465 $settings = get_option('fluent_community_settings', []);
466
467 $defaults = [
468 'site_title' => get_bloginfo('name'),
469 'slug' => 'portal',
470 'logo' => '',
471 'white_logo' => '',
472 'logo_permalink_type' => 'default',
473 'logo_permalink' => '',
474 'featured_image' => '',
475 'access' => [
476 'acess_level' => 'public', // logged_in, public, role_based
477 'access_roles' => []
478 ],
479 'auth_form_type' => 'default',
480 'explicit_registration' => 'no',
481 'disable_global_posts' => 'yes',
482 'auth_content' => 'Please login first to access this page',
483 'auth_redirect' => '',
484 'restricted_role_content' => 'Sorry, you cannot access this page. Only authorized users can access this page.',
485 'auth_url' => '',
486 'cutsom_auth_url' => self::baseUrl('?fcom_action=auth'),
487 'use_custom_signup_page' => 'no',
488 'custom_signup_url' => ''
489 ];
490
491 $settings = wp_parse_args($settings, $defaults);
492 if ($settings['auth_form_type'] != 'custom' || empty($settings['auth_form_type'])) {
493 $settings['cutsom_auth_url'] = self::baseUrl('?fcom_action=auth');
494 }
495
496 if (defined('FLUENT_COMMUNITY_PORTAL_SLUG')) {
497 $settings['slug'] = \FLUENT_COMMUNITY_PORTAL_SLUG;
498 $settings['is_slug_defined'] = true;
499 } else {
500 unset($settings['is_slug_defined']);
501 }
502
503 return $settings;
504 }
505
506 public static function hasGlobalPost()
507 {
508 $settings = self::generalSettings();
509 $status = Arr::get($settings, 'disable_global_posts', '') != 'yes';
510
511 return apply_filters('fluent_community/has_global_post', $status);
512 }
513
514 /**
515 * Check if a user can access the portal.
516 *
517 * @param int|null $userId The user ID. If null, uses the current user.
518 * @return bool True if the user can access the portal, false otherwise.
519 */
520 public static function canAccessPortal($userId = null, $requireActiveProfile = true)
521 {
522 $settings = self::generalSettings();
523 $accessLevel = Arr::get($settings, 'access.acess_level');
524
525 if ($accessLevel == 'public') {
526 return apply_filters('fluent_community/can_access_portal', true);
527 }
528
529 if (!$userId) {
530 $userId = get_current_user_id();
531 }
532
533 if (!$userId) {
534 return apply_filters('fluent_community/can_access_portal', false);
535 }
536
537 if ($accessLevel == 'logged_in') {
538 return apply_filters('fluent_community/can_access_portal', true);
539 }
540
541 if (user_can($userId, 'edit_pages')) {
542 return apply_filters('fluent_community/can_access_portal', true);
543 }
544
545 $roles = Arr::get($settings, 'access.access_roles', []);
546
547 $user = get_user_by('ID', $userId);
548
549 if (!$user) {
550 return apply_filters('fluent_community/can_access_portal', false);
551 }
552
553 $result = !!array_intersect(array_values($user->roles), $roles);
554
555 if (!$result) {
556 return apply_filters('fluent_community/can_access_portal', false);
557 }
558
559 if (!$requireActiveProfile) {
560 return apply_filters('fluent_community/can_access_portal', true);
561 }
562
563 $xProfile = Helper::getCurrentProfile();
564
565 $result = $xProfile && $xProfile->status == 'active';
566
567 return apply_filters('fluent_community/can_access_portal', $result);
568 }
569
570 /**
571 * Get the portal route paths.
572 *
573 * @return array An array of portal route paths.
574 */
575 public static function portalRoutePaths()
576 {
577 return apply_filters('fluent_community/app_route_paths', [
578 'portal_home',
579 'members',
580 'bookmarks',
581 'chat',
582 'dashboard',
583 'leaderboards',
584 'notifications',
585 'space',
586 'discover',
587 'courses',
588 'u',
589 'post',
590 'admin',
591 'course',
592 'site-maps'
593 ]);
594 }
595
596 /**
597 * Get the current user's profile.
598 *
599 * @param bool $cached Whether to use cached profile.
600 * @return XProfile|null The user's profile or null if not found.
601 */
602 public static function getCurrentProfile($cached = true)
603 {
604 static $profile;
605 if ($profile && $cached) {
606 return $profile;
607 }
608
609 $userId = get_current_user_id();
610
611 if (!$userId) {
612 $profile = null;
613 return $profile;
614 }
615
616 $profile = XProfile::where('user_id', $userId)->first();
617
618 return $profile;
619 }
620
621 /**
622 * Get the current user Model.
623 *
624 * @param bool $cached Whether to use cached user.
625 * @return User|false The User model or false if not found.
626 */
627 public static function getCurrentUser($cached = true)
628 {
629 $userId = get_current_user_id();
630 if (!$userId) {
631 return false;
632 }
633
634 static $users = [];
635 if ($cached && isset($users[$userId])) {
636 return $users[$userId];
637 }
638
639 return $users[$userId] = User::find($userId);
640 }
641
642 /**
643 * Get the route paths for the community.
644 *
645 * @return array An array of route paths.
646 */
647 private static function getRoutePaths()
648 {
649 return [
650 'dashboard' => '/dashboard',
651 'all_feeds' => '/',
652 'single_feed' => '/post/:feed_slug',
653 'space_feeds' => '/space/:space/home',
654 'space_feed' => '/space/:space/post/:feed_slug',
655 'space_members' => '/space/:space/members',
656 'spaces' => '/discover/spaces',
657 'settings' => '/admin/settings',
658 'admin_moderators' => '/admin/settings/moderators',
659 'all_members' => '/members',
660 'user_profile' => '/u/:username/',
661 'user_communities' => '/u/:username/spaces',
662 'update_profile' => '/u/:username/update',
663 'discussions' => '/discussions',
664 'create_topic' => '/discussions/create-topic',
665 'topic' => '/discussions/topic/:slug',
666 'notifications' => '/notifications',
667 'bookmarks' => '/bookmarks',
668 'courses' => '/courses',
669 'view_course' => '/courses/view/:course_id/lessons',
670 'view_lesson' => '/courses/view/:course_id/lessons/:lesson_slug/view',
671 'manage_courses' => '/admin/manage-courses',
672 'edit_lessons' => '/admin/manage-courses/edit/:course_id/lessons',
673 'course_students' => '/admin/manage-courses/edit/:course_id/students',
674 'course_overview' => '/admin/manage-courses/edit/:course_id/overview',
675 'manage_leaderboard' => '/admin/manage-leaderboard',
676 ];
677 }
678
679 /**
680 * Get the URL for a JavaScript route.
681 *
682 * @param array $route The route information.
683 * @return string The URL for the route.
684 */
685 public static function getUrlByJsRoute($route = [])
686 {
687 $routePaths = self::getRoutePaths();
688
689 $routeName = Arr::get($route, 'name', '');
690
691 if (!$routeName || !isset($routePaths[$routeName])) {
692 return self::baseUrl();
693 }
694
695 $path = $routePaths[$routeName];
696
697 $params = (array)Arr::get($route, 'params', []);
698
699 if (!$params) {
700 return self::baseUrl($path);
701 }
702
703 $replaces = [];
704
705 foreach ($params as $paramKey => $paramValue) {
706 $replaces[':' . $paramKey] = $paramValue;
707 }
708
709 $path = str_replace(array_keys($replaces), array_values($replaces), $path);
710
711 return self::baseUrl($path);
712
713 }
714
715 /**
716 * Get the route name from a request path.
717 *
718 * @param string $path The request path.
719 * @return string|false The route name or false if not found.
720 */
721 public static function getRouteNameByRequestPath($path)
722 {
723 $path = '//' . $path;
724
725 if (strpos($path, '/u/')) {
726 return 'user_profile';
727 }
728
729 if (strpos($path, '/post/')) {
730 return 'feed_view';
731 }
732
733 if (strpos($path, '/lessons/')) {
734 return 'lesson_view';
735 }
736
737 if (strpos($path, '/course/')) {
738 return 'course_view';
739 }
740
741 if (strpos($path, '/space/') && !strpos($path, '/discover/spaces')) {
742 return 'community_view';
743 }
744
745 if (strpos($path, '/admin')) {
746 return 'admin';
747 }
748
749 return false;
750 }
751
752 /**
753 * Sanitize embed markup held in a feed/comment meta array on read.
754 *
755 * meta.media_preview.html is rendered with v-html in _MediaPreview.vue, so it is
756 * sanitized on write. Doing it on read as well neutralizes rows that were stored
757 * before the write-side fix landed, and covers any writer reaching the meta via
758 * the fluent_community/feed/* filters. The emptiness check keeps this free for the
759 * vast majority of rows, which carry no embed markup at all.
760 *
761 * @param array $meta The unserialized meta array.
762 * @return array The meta array with any embed markup passed through the allowlist.
763 */
764 public static function sanitizeStoredMediaPreview($meta)
765 {
766 if (empty($meta['media_preview']['html'])) {
767 return $meta;
768 }
769
770 $meta['media_preview']['html'] = RemoteUrlParser::sanitizeOembedHtml($meta['media_preview']['html']);
771
772 return $meta;
773 }
774
775 /**
776 * Get a human-readable excerpt from content.
777 *
778 * @param string $content The content to extract from.
779 * @param int $length The maximum length of the excerpt.
780 * @return string The human-readable excerpt.
781 */
782 public static function getHumanExcerpt($content, $length = 100)
783 {
784 if ($content) {
785 $patterns = [
786 '/^#{1,6}\s+/m' => '',
787 // Bold and Italic: remove '*' and '_' symbols
788 '/(\*\*|__)(.*?)\1/' => '$2',
789 '/(\*|_)(.*?)\1/' => '$2',
790 // Code blocks: remove triple backticks
791 '/^```\s*\w*\s*\n([\s\S]*?)\n```\s*$/m' => '$1',
792 // Inline code: remove single backticks
793 '/`([^`]+)`/' => '$1',
794 // Blockquotes: remove '>' symbol
795 '/^\s*>\s?/m' => '',
796 // Horizontal rules: replace with empty line
797 '/^\s*([-*_])\1{2,}\s*$/m' => "\n",
798 // Links: keep only the link text
799 '/\[([^\]]+)\]\([^\)]+\)/' => '$1',
800 // Images: keep only the alt text
801 '/!\[([^\]]+)\]\([^\)]+\)/' => '$1',
802 // Strikethrough: remove '~~' symbols
803 '/~~(.*?)~~/' => '$1',
804 // Task lists: remove checkbox syntax
805 '/^\s*[-*+]\s+\[[ xX]\]\s+/m' => '',
806 ];
807
808 $content = preg_replace(array_keys($patterns), array_values($patterns), $content);
809
810 // remove all tags
811 $content = wp_strip_all_tags($content);
812 // remove new lines and tabs
813 $content = str_replace(["\r", "\n", "\t"], ' ', $content);
814 // remove multiple spaces
815 $content = preg_replace('/\s+/', ' ', $content);
816
817 // trim
818 $content = trim($content);
819 }
820
821 if (!$content) {
822 return '';
823 }
824
825 if (mb_strlen($content) <= $length) {
826 return $content;
827 }
828
829 // return the first $length chars of the content with ... at the end
830 return mb_substr($content, 0, $length) . '...';
831 }
832
833 /**
834 * Check if the portal is publicly accessible.
835 *
836 * @return bool True if publicly accessible, false otherwise.
837 */
838 public static function isPublicAccessible()
839 {
840 $settings = self::generalSettings();
841 return Arr::get($settings, 'access.acess_level') == 'public';
842 }
843
844 /**
845 * Get media by provider.
846 *
847 * @param array $images The array of images.
848 * @param string $provider The provider to filter by.
849 * @return array The filtered array of images.
850 */
851 public static function getMediaByProvider($images, $provider = 'uploader')
852 {
853 if (is_array($images)) {
854 return array_filter($images, function ($image) use ($provider) {
855 if (is_array($image)) {
856 if (isset($image['provider'])) {
857 return Arr::get($image, 'provider') == $provider;
858 }
859
860 return $provider === 'uploader'; // for existing images when no provider was set.
861 }
862 });
863 }
864
865 return [];
866 }
867
868 /**
869 * Get the community menu groups.
870 *
871 * @param User|null $user The user to get menu groups for.
872 * @return array The community menu groups.
873 */
874 public static function getCommunityMenuGroups($user = null, $view = true)
875 {
876 if (!$user) {
877 $user = self::getCurrentUser();
878 }
879
880 $communityGroups = self::getAllCommunityGroups($user);
881
882 if ($communityGroups->isEmpty()) {
883 return [];
884 }
885
886 $userSpaceIds = $user ? self::getUserSpaceIds($user->ID) : [];
887 $isComModerator = $user && $user->hasCommunityModeratorAccess();
888 $isCourseCreator = $user && $user->hasCourseCreatorAccess();
889 $isSpaceModerator = $user && $user->isSpaceModerator();
890
891 $formattedGroups = [];
892 foreach ($communityGroups as $communityGroup) {
893 $validSpaces = [];
894 $spaces = $communityGroup->spaces;
895 $isShowAll = Arr::get($communityGroup->settings, 'always_show_spaces') === 'yes';
896
897 if (!$isShowAll && !$isSpaceModerator) {
898 $spaceIds = $spaces->pluck('id')->toArray();
899 $isNotMemberOfAnySpace = empty(array_intersect($spaceIds, $userSpaceIds));
900 if ($isNotMemberOfAnySpace) {
901 continue;
902 }
903 }
904
905 if ($user) {
906 BaseSpace::preloadMemberships($spaces, $user->ID);
907 }
908
909 foreach ($spaces as $space) {
910 $validSpace = $view ? self::transformSpaceToLink($space, $user) : $space;
911 if (!$validSpace) {
912 continue;
913 }
914
915 if ($user && $space->isContentSpace()) {
916 $validSpace['unread_badge'] = self::getUnreadFeedsCounts($space->id);
917 }
918
919 if ($isComModerator && $space->type != 'course') {
920 $validSpaces[] = $validSpace;
921 continue;
922 }
923
924 if ($isCourseCreator && $space->type == 'course') {
925 $validSpaces[] = $validSpace;
926 continue;
927 }
928
929 if ($space->privacy == 'public') {
930 $validSpaces[] = $validSpace;
931 continue;
932 }
933
934 $hasMembership = $user && $space->getMembership($user->ID);
935
936 if ($space->privacy == 'private') {
937 if (!$user || !$hasMembership) {
938 $validSpace['show_lock'] = true;
939 }
940 }
941
942 if ($space->privacy == 'secret') {
943 if (!$user || !$hasMembership) {
944 continue;
945 }
946 }
947
948 $validSpaces[] = $validSpace;
949 }
950
951 if (!$validSpaces && !$isSpaceModerator) {
952 continue;
953 }
954
955 $formattedGroups[] = [
956 'id' => $communityGroup->id,
957 'title' => $communityGroup->title,
958 'slug' => $communityGroup->slug,
959 'logo' => $communityGroup->logo,
960 'children' => $validSpaces
961 ];
962 }
963
964 return apply_filters('fluent_community/menu_groups_for_user', $formattedGroups, $user);
965 }
966
967 public static function getUnreadFeedsCounts($spaceId, $force = false)
968 {
969 static $coutsCache = null;
970 if ($coutsCache !== null && !$force) {
971 return Arr::get((array)$coutsCache, $spaceId);
972 }
973
974 $xprofile = self::getCurrentProfile();
975
976 if (!$xprofile || !$xprofile->last_activity) {
977 return 0;
978 }
979
980 $lastActivityDate = gmdate('Y-m-d H:i:s', strtotime($xprofile->last_activity) - 300);
981
982 $lastActivityDate = apply_filters('fluent_community/last_activity_date_for_unread_feeds', $lastActivityDate, $xprofile);
983
984 $unreadCounts = Feed::query()
985 ->select('space_id', Utility::getApp('db')->raw('COUNT(*) as feed_count'))
986 ->where('status', 'published')
987 ->where('created_at', '>', $lastActivityDate)
988 ->groupBy('space_id')
989 ->get();
990
991 $coutsCache = [];
992 foreach ($unreadCounts as $unreadCount) {
993 $coutsCache[$unreadCount->space_id] = $unreadCount->feed_count > 10 ? '10+' : $unreadCount->feed_count;
994 }
995
996 return Arr::get($coutsCache, $spaceId);
997 }
998
999 /**
1000 * Transform a space to a link array.
1001 *
1002 * @param Space $space The space to transform.
1003 * @param User|null $user The user to check permissions for.
1004 * @return array|null The transformed space link array.
1005 */
1006 private static function transformSpaceToLink($space, $user = null)
1007 {
1008 $isCustomLink = $space->type == 'sidebar_link';
1009 if ($isCustomLink && !self::canViewSideLinkLink($space, $user)) {
1010 return null;
1011 }
1012
1013 $logo = $space->logo;
1014 $title = $space->title;
1015
1016 if ($space->status == 'draft') {
1017 $title = $title . ' ' . __('(Draft)', 'fluent-community');
1018 }
1019
1020 return [
1021 'title' => $title,
1022 'icon_image' => $logo,
1023 'shape_svg' => !$logo ? Arr::get($space->settings, 'shape_svg', '') : '',
1024 'emoji' => !$logo ? Arr::get($space->settings, 'emoji', '') : '',
1025 'permalink' => $space->getPermalink(),
1026 'is_custom' => $isCustomLink ? 'yes' : 'no',
1027 'new_tab' => ($isCustomLink && Arr::get($space, 'settings.new_tab', 'no') === 'yes') ? 'yes' : 'no',
1028 'link_classes' => 'space_menu_item route_url fcom_space_id_' . $space->id . ' fcom_space_' . $space->slug
1029 ];
1030 }
1031
1032 public static function canViewSideLinkLink($space, $user = null)
1033 {
1034 $privacy = $space->privacy;
1035
1036 if ($privacy == 'public') {
1037 return true;
1038 }
1039
1040 if ($privacy == 'logged_in') {
1041 return !!$user;
1042 }
1043
1044 if ($privacy == 'logged_out_only') {
1045 return !$user;
1046 }
1047
1048 if (!$user) {
1049 return false;
1050 }
1051
1052 $accessIds = Arr::get($space->settings, 'membership_ids', []);
1053
1054 if (!$accessIds) {
1055 return true;
1056 }
1057
1058 $userSpaces = $user->getSpaceIds();
1059 return !!array_intersect($userSpaces, $accessIds);
1060 }
1061
1062 public static function isAlreadyOnboarded()
1063 {
1064 $communitySettings = get_option('fluent_community_settings', []);
1065
1066 return !empty($communitySettings);
1067 }
1068
1069 /**
1070 * Get all community groups.
1071 *
1072 * @param User $user The user to get groups for.
1073 * @param bool $willCreate Whether to create a default group if none exist.
1074 * @return \FluentCommunity\Framework\Support\Collection Collection of Groups
1075 */
1076 public static function getAllCommunityGroups($user, $willCreate = true)
1077 {
1078 $isModerator = $user && $user->isCommunityModerator();
1079
1080 $communityGroups = SpaceGroup::query()->orderBy('serial', 'ASC')
1081 ->with([
1082 'spaces' => function ($query) use ($isModerator) {
1083 if ($isModerator) {
1084 $query->orderBy('serial', 'ASC');
1085 } else {
1086 $query->where('status', 'published')
1087 ->orderBy('serial', 'ASC');
1088 }
1089 }
1090 ])
1091 ->get();
1092
1093 if ($communityGroups->isEmpty() && $willCreate) {
1094 $createdSpace = (new ActivationHandler(App::make()))->maybeCreateDefaultSpaceGroup();
1095
1096 if ($createdSpace) {
1097 Space::where('type', 'community')->update([
1098 'parent_id' => $createdSpace->id
1099 ]);
1100 }
1101
1102 return self::getAllCommunityGroups($user, false);
1103 }
1104
1105 return $communityGroups;
1106 }
1107
1108 /**
1109 * Check if a feature is enabled.
1110 *
1111 * @param string $feature The feature to check.
1112 * @return bool True if the feature is enabled, false otherwise.
1113 */
1114 public static function isFeatureEnabled($feature)
1115 {
1116 $features = Utility::getFeaturesConfig();
1117
1118 return isset($features[$feature]) && $features[$feature] === 'yes';
1119 }
1120
1121 /**
1122 * Get the menu items group.
1123 *
1124 * @param string $context The context for getting menu items.
1125 * @return array The menu items group.
1126 */
1127 public static function getMenuItemsGroup($context = 'view')
1128 {
1129 static $menuGroups;
1130
1131 if ($menuGroups && $context === 'view') {
1132 return $menuGroups;
1133 }
1134
1135 $menuGroups = (array) Utility::getOption('fluent_community_menu_groups', []);
1136
1137 $membersPageStatus = Utility::canViewMembersPage() ? 'yes' : 'no';
1138
1139 $leaderboardPageVisibility = (Utility::canViewLeaderboardMembers() || is_user_logged_in()) ? 'yes' : 'no';
1140
1141 $defaultMainMenuItems = [
1142 'all_feeds' => [
1143 'slug' => 'all_feeds',
1144 'title' => __('Feed', 'fluent-community'),
1145 'is_system' => 'yes',
1146 'is_locked' => 'yes',
1147 'enabled' => 'yes',
1148 'permalink' => self::baseUrl('/'),
1149 'link_classes' => 'fcom_dashboard route_url',
1150 'shape_svg' => '<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 d="M10 13.166H10.0075" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><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 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"/></svg>'
1151 ],
1152 'spaces' => [
1153 'slug' => 'spaces',
1154 'title' => __('Spaces', 'fluent-community'),
1155 'is_system' => 'yes',
1156 'is_locked' => 'yes',
1157 'enabled' => 'yes',
1158 'permalink' => self::baseUrl('discover/spaces'),
1159 'link_classes' => 'fcom_spaces route_url',
1160 '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>'
1161 ],
1162 'all_courses' => [
1163 'slug' => 'all_courses',
1164 'title' => __('Courses', 'fluent-community'),
1165 'link_classes' => 'fcom_courses route_url',
1166 'is_system' => 'yes',
1167 'is_locked' => 'yes',
1168 'enabled' => 'yes',
1169 'is_unavailable' => self::isFeatureEnabled('course_module') ? 'no' : 'yes',
1170 'permalink' => self::baseUrl('courses'),
1171 '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>'
1172 ],
1173 'all_members' => [
1174 'slug' => 'all_members',
1175 'title' => __('Members', 'fluent-community'),
1176 'is_system' => 'yes',
1177 'is_locked' => 'yes',
1178 'is_unavailable' => $membersPageStatus == 'yes' ? 'no' : 'yes',
1179 'enabled' => $membersPageStatus,
1180 'permalink' => self::baseUrl('members'),
1181 'link_classes' => 'fcom_all_members route_url',
1182 '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>',
1183 ],
1184 'leaderboard' => [
1185 'slug' => 'leaderboard',
1186 'is_system' => 'yes',
1187 'is_locked' => 'yes',
1188 'enabled' => $leaderboardPageVisibility,
1189 'is_unavailable' => self::isFeatureEnabled('leader_board_module') && $leaderboardPageVisibility == 'yes' ? 'no' : 'yes',
1190 'title' => __('Leaderboard', 'fluent-community'),
1191 'link_classes' => 'fcom_leaderboards route_url',
1192 'permalink' => self::baseUrl('leaderboards'),
1193 '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>'
1194 ]
1195 ];
1196
1197 $mainItems = Arr::get($menuGroups, 'mainMenuItems', []);
1198
1199 if ($mainItems && is_array($mainItems)) {
1200 if (isset($mainItems['all_communities'])) {
1201 $mainItems['spaces'] = $defaultMainMenuItems['spaces'];
1202 unset($mainItems['all_communities']);
1203 }
1204
1205 foreach ($mainItems as $index => &$item) {
1206 if (empty($item['slug'])) {
1207 unset($mainItems[$index]);
1208 continue;
1209 }
1210 $defaultItem = Arr::get($defaultMainMenuItems, $item['slug'], []);
1211 if ($defaultItem) {
1212 $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug'];
1213 foreach ($preservedKeys as $key) {
1214 if (isset($defaultItem[$key])) {
1215 $item[$key] = Arr::get($defaultItem, $key);
1216 }
1217 }
1218 if (Arr::get($defaultItem, 'is_system') === 'yes') {
1219 $item['permalink'] = $defaultItem['permalink'];
1220 $item['link_classes'] = $defaultItem['link_classes'];
1221 if (empty($item['shape_svg'])) {
1222 $item['shape_svg'] = $defaultItem['shape_svg'];
1223 }
1224 }
1225 }
1226 }
1227 } else {
1228 $mainItems = $defaultMainMenuItems;
1229 }
1230
1231 $defaultProfileDropDownItems = [
1232 'my_spaces' => [
1233 'slug' => 'my_spaces',
1234 'title' => __('My Spaces', 'fluent-community'),
1235 'is_system' => 'yes',
1236 'is_locked' => 'yes',
1237 'enabled' => 'yes',
1238 'permalink' => '#{{user_url}}/spaces',
1239 'shape_svg' => '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0.5 16.5C0.5 14.9087 1.13214 13.3826 2.25736 12.2574C3.38258 11.1321 4.9087 10.5 6.5 10.5C8.0913 10.5 9.61742 11.1321 10.7426 12.2574C11.8679 13.3826 12.5 14.9087 12.5 16.5H11C11 15.3065 10.5259 14.1619 9.68198 13.318C8.83807 12.4741 7.69347 12 6.5 12C5.30653 12 4.16193 12.4741 3.31802 13.318C2.47411 14.1619 2 15.3065 2 16.5H0.5ZM6.5 9.75C4.01375 9.75 2 7.73625 2 5.25C2 2.76375 4.01375 0.75 6.5 0.75C8.98625 0.75 11 2.76375 11 5.25C11 7.73625 8.98625 9.75 6.5 9.75ZM6.5 8.25C8.1575 8.25 9.5 6.9075 9.5 5.25C9.5 3.5925 8.1575 2.25 6.5 2.25C4.8425 2.25 3.5 3.5925 3.5 5.25C3.5 6.9075 4.8425 8.25 6.5 8.25ZM12.713 11.0273C13.767 11.5019 14.6615 12.2709 15.2889 13.2418C15.9164 14.2126 16.2501 15.344 16.25 16.5H14.75C14.7502 15.633 14.4999 14.7844 14.0293 14.0562C13.5587 13.328 12.8878 12.7512 12.0972 12.3953L12.7123 11.0273H12.713ZM12.197 2.55975C12.9526 2.87122 13.5987 3.40015 14.0533 4.07942C14.5078 4.75869 14.7503 5.55768 14.75 6.375C14.7503 7.40425 14.3658 8.39642 13.6719 9.15662C12.978 9.91682 12.025 10.3901 11 10.4835V8.97375C11.5557 8.89416 12.0713 8.63851 12.471 8.24434C12.8707 7.85017 13.1335 7.33824 13.2209 6.7837C13.3082 6.22916 13.2155 5.66122 12.9563 5.16327C12.6971 4.66531 12.2851 4.26356 11.7808 4.017L12.197 2.55975Z" fill="currentColor"/></svg>'
1240 ],
1241 'bookmarks' => [
1242 'slug' => 'bookmarks',
1243 'title' => __('Bookmarks', 'fluent-community'),
1244 'is_system' => 'yes',
1245 'is_locked' => 'yes',
1246 'enabled' => 'yes',
1247 'permalink' => self::baseUrl('bookmarks'),
1248 'shape_svg' => '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0.75 0.5H11.25C11.4489 0.5 11.6397 0.579018 11.7803 0.71967C11.921 0.860322 12 1.05109 12 1.25V15.6073C12.0001 15.6743 11.9822 15.7402 11.9482 15.7979C11.9142 15.8557 11.8653 15.9033 11.8066 15.9358C11.7479 15.9683 11.6816 15.9844 11.6146 15.9826C11.5476 15.9807 11.4823 15.9609 11.4255 15.9252L6 12.5225L0.5745 15.9245C0.517776 15.9601 0.452541 15.9799 0.385576 15.9818C0.318612 15.9837 0.252365 15.9676 0.193721 15.9352C0.135078 15.9029 0.0861801 15.8554 0.0521121 15.7977C0.0180441 15.74 4.98531e-05 15.6742 0 15.6073V1.25C0 1.05109 0.0790178 0.860322 0.21967 0.71967C0.360322 0.579018 0.551088 0.5 0.75 0.5ZM10.5 2H1.5V13.574L6 10.7533L10.5 13.574V2Z" fill="currentColor"/></svg>'
1249 ],
1250 'logout' => [
1251 'slug' => 'logout',
1252 'title' => __('Logout', 'fluent-community'),
1253 'is_system' => 'yes',
1254 'is_locked' => 'yes',
1255 'enabled' => 'yes',
1256 'permalink' => '#{{logout_url}}',
1257 'shape_svg' => '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0.75 15.5C0.551088 15.5 0.360322 15.421 0.21967 15.2803C0.0790178 15.1397 0 14.9489 0 14.75V1.25C0 1.05109 0.0790178 0.860322 0.21967 0.71967C0.360322 0.579018 0.551088 0.5 0.75 0.5H11.25C11.4489 0.5 11.6397 0.579018 11.7803 0.71967C11.921 0.860322 12 1.05109 12 1.25V3.5H10.5V2H1.5V14H10.5V12.5H12V14.75C12 14.9489 11.921 15.1397 11.7803 15.2803C11.6397 15.421 11.4489 15.5 11.25 15.5H0.75ZM10.5 11V8.75H5.25V7.25H10.5V5L14.25 8L10.5 11Z" fill="currentColor"/></svg>'
1258 ]
1259 ];
1260
1261 $profileDropDownItems = Arr::get($menuGroups, 'profileDropdownItems', []);
1262
1263 if ($profileDropDownItems && is_array($profileDropDownItems)) {
1264 unset($profileDropDownItems['profile']);
1265 foreach ($profileDropDownItems as $index => &$item) {
1266 if (empty($item['slug'])) {
1267 unset($profileDropDownItems[$index]);
1268 continue;
1269 }
1270 $defaultItem = Arr::get($defaultProfileDropDownItems, $item['slug'], []);
1271 if ($defaultItem) {
1272 $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug'];
1273 foreach ($preservedKeys as $key) {
1274 if (isset($defaultItem[$key])) {
1275 $item[$key] = Arr::get($defaultItem, $key);
1276 }
1277 }
1278 if (Arr::get($defaultItem, 'is_system') === 'yes') {
1279 $item['permalink'] = $defaultItem['permalink'];
1280 if (empty($item['shape_svg'])) {
1281 $item['shape_svg'] = $defaultItem['shape_svg'];
1282 }
1283 }
1284 }
1285 }
1286 } else {
1287 $profileDropDownItems = $defaultProfileDropDownItems;
1288 }
1289
1290 $beforeCommunityMenuItems = Arr::get($menuGroups, 'beforeCommunityMenuItems', []);
1291 $afterCommunityMenuGroups = Arr::get($menuGroups, 'afterCommunityLinkGroups', []);
1292
1293 if (!is_array($beforeCommunityMenuItems)) {
1294 $beforeCommunityMenuItems = [];
1295 }
1296
1297 if (!is_array($afterCommunityMenuGroups)) {
1298 $afterCommunityMenuGroups = [];
1299 }
1300
1301 if ($context == 'view') {
1302
1303 $currentUser = self::getCurrentUser();
1304
1305 $mainItems = array_filter($mainItems, function ($item) use ($currentUser) {
1306 return self::isLinkAccessible($item, $currentUser);
1307 });
1308
1309 $profileDropDownItems = array_filter($profileDropDownItems, function ($item) use ($currentUser) {
1310 return self::isLinkAccessible($item, $currentUser);
1311 });
1312
1313 $beforeCommunityMenuItems = array_filter($beforeCommunityMenuItems, function ($item) use ($currentUser) {
1314 return self::isLinkAccessible($item, $currentUser);
1315 });
1316
1317 $validGroups = [];
1318 foreach ($afterCommunityMenuGroups as $group) {
1319 if (empty($group['items']) || !is_array($group['items'])) {
1320 continue;
1321 }
1322
1323 $group['items'] = array_filter($group['items'], function ($item) use ($currentUser) {
1324 return self::isLinkAccessible($item, $currentUser);
1325 });
1326
1327 if ($group['items']) {
1328 $validGroups[] = $group;
1329 }
1330 }
1331
1332 $afterCommunityMenuGroups = $validGroups;
1333 }
1334
1335 $menuGroups['mainMenuItems'] = $mainItems;
1336 $menuGroups['profileDropdownItems'] = $profileDropDownItems;
1337 $menuGroups['beforeCommunityMenuItems'] = $beforeCommunityMenuItems;
1338 $menuGroups['afterCommunityLinkGroups'] = $afterCommunityMenuGroups;
1339
1340 if ($context == 'view') {
1341 $menuGroups = apply_filters('fluent_community/menu_groups', $menuGroups);
1342 }
1343
1344 return $menuGroups;
1345 }
1346
1347 public static function isLinkAccessible($link, $currentUser = null)
1348 {
1349 $isEnabled = Arr::get($link, 'enabled', 'yes') === 'yes';
1350 $isUnavailable = Arr::get($link, 'is_unavailable') === 'yes';
1351
1352 if (!$isEnabled || $isUnavailable) {
1353 return false;
1354 }
1355
1356 $privacy = Arr::get($link, 'privacy', '');
1357
1358 if (!$privacy || $privacy === 'public') {
1359 return true;
1360 }
1361
1362 if ($privacy == 'logged_in') {
1363 return !!$currentUser;
1364 }
1365
1366 if ($privacy == 'logged_out_only') {
1367 return !$currentUser;
1368 }
1369
1370 $membershipIds = Arr::get($link, 'membership_ids', []);
1371 if (!$membershipIds) {
1372 return true;
1373 }
1374
1375 if (!$currentUser) {
1376 return false;
1377 }
1378
1379 static $userSpacesIds = [];
1380 if (!isset($userSpacesIds[$currentUser->ID])) {
1381 $userSpacesIds[$currentUser->ID] = $currentUser->getJoinedSpaceIds();
1382 }
1383
1384 $ids = $userSpacesIds[$currentUser->ID];
1385
1386 return $ids && !!array_intersect($ids, $membershipIds);
1387 }
1388
1389 /**
1390 * Get the meta data for a space.
1391 *
1392 * @param int $spaceId The ID of the space.
1393 * @param string $key The meta key.
1394 * @param mixed $default The default value if the meta key is not found.
1395 * @return mixed The meta value or the default value if not found.
1396 */
1397 public static function getSpaceMeta($spaceId, $key, $default = null)
1398 {
1399 $meta = Meta::where('object_type', 'space')
1400 ->where('meta_key', $key)
1401 ->where('object_id', $spaceId)
1402 ->first();
1403
1404 if (!$meta) {
1405 return $default;
1406 }
1407
1408 return $meta->value;
1409 }
1410
1411 /**
1412 * Update the meta data for a space.
1413 *
1414 * @param int $spaceId The ID of the space.
1415 * @param string $key The meta key.
1416 * @param mixed $value The meta value.
1417 * @return Meta The updated meta object.
1418 */
1419 public static function updateSpaceMeta($spaceId, $key, $value)
1420 {
1421 $meta = Meta::where('object_type', 'space')
1422 ->where('meta_key', $key)
1423 ->where('object_id', $spaceId)
1424 ->first();
1425
1426 if ($meta) {
1427 $meta->value = $value;
1428 $meta->save();
1429 } else {
1430 $meta = Meta::create([
1431 'object_type' => 'space',
1432 'object_id' => $spaceId,
1433 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1434 'value' => $value
1435 ]);
1436 }
1437
1438 return $meta;
1439 }
1440
1441
1442 /**
1443 * Encrypt or decrypt a value.
1444 *
1445 * @param string $value The value to encrypt or decrypt.
1446 * @param string $type The type of operation ('e' for encrypt, 'd' for decrypt).
1447 * @return string|false The encrypted or decrypted value or false if an error occurs.
1448 */
1449 public static function encryptDecrypt($value, $type = 'e')
1450 {
1451 if (!$value) {
1452 return $value;
1453 }
1454
1455 if (!extension_loaded('openssl')) {
1456 return $value;
1457 }
1458
1459 if (defined('FLUENT_COM_ENCRYPT_SALT')) {
1460 $salt = FLUENT_COM_ENCRYPT_SALT;
1461 } else {
1462 $salt = (defined('LOGGED_IN_SALT') && '' !== LOGGED_IN_SALT) ? LOGGED_IN_SALT : 'this-is-a-fallback-salt-but-not-secure';
1463 }
1464
1465 if (defined('FLUENT_COM__ENCRYPT_KEY')) {
1466 $key = FLUENT_COM__ENCRYPT_KEY;
1467 } else {
1468 $key = (defined('LOGGED_IN_KEY') && '' !== LOGGED_IN_KEY) ? LOGGED_IN_KEY : 'this-is-a-fallback-key-but-not-secure';
1469 }
1470
1471 if ($type == 'e') {
1472 $method = 'aes-256-ctr';
1473 $ivlen = openssl_cipher_iv_length($method);
1474 $iv = openssl_random_pseudo_bytes($ivlen);
1475
1476 $raw_value = openssl_encrypt($value . $salt, $method, $key, 0, $iv);
1477 if (!$raw_value) {
1478 return false;
1479 }
1480
1481 return base64_encode($iv . $raw_value); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
1482 }
1483
1484 $raw_value = base64_decode($value, true); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
1485
1486 $method = 'aes-256-ctr';
1487 $ivlen = openssl_cipher_iv_length($method);
1488 $iv = substr($raw_value, 0, $ivlen);
1489
1490 $raw_value = substr($raw_value, $ivlen);
1491
1492 $newValue = openssl_decrypt($raw_value, $method, $key, 0, $iv);
1493 if (!$newValue || substr($newValue, -strlen($salt)) !== $salt) {
1494 return false;
1495 }
1496
1497 return substr($newValue, 0, -strlen($salt));
1498 }
1499
1500
1501 /**
1502 * Get the welcome banner configuration.
1503 *
1504 * @return array The welcome banner configuration.
1505 */
1506 public static function getWelcomeBannerSettings()
1507 {
1508 $defaults = [
1509 'login' => [
1510 'enabled' => 'no',
1511 'description' => '',
1512 'mediaType' => 'image',
1513 'allowClose' => 'no',
1514 'bannerImage' => '',
1515 'bannerVideo' => [
1516 'type' => 'oembed',
1517 'url' => '',
1518 'content_type' => '',
1519 'provider' => '',
1520 'title' => '',
1521 'author_name' => '',
1522 'html' => ''
1523 ],
1524 'ctaButtons' => []
1525 ],
1526 'logout' => [
1527 'enabled' => 'no',
1528 'description' => '',
1529 'mediaType' => 'image',
1530 'useCustomUrl' => 'no',
1531 'bannerImage' => '',
1532 'bannerVideo' => [
1533 'type' => 'oembed',
1534 'url' => '',
1535 'content_type' => '',
1536 'provider' => '',
1537 'title' => '',
1538 'author_name' => '',
1539 'html' => ''
1540 ],
1541 'ctaButtons' => []
1542 ]
1543 ];
1544
1545 $settings = Utility::getOption('welcome_banner_settings', []);
1546
1547 $settings = wp_parse_args($settings, $defaults);
1548
1549 if (empty(Arr::get($settings, 'login.bannerVideo'))) {
1550 $settings['login']['bannerVideo'] = $defaults['login']['bannerVideo'];
1551 }
1552
1553 if (empty(Arr::get($settings, 'logout.bannerVideo'))) {
1554 $settings['logout']['bannerVideo'] = $defaults['logout']['bannerVideo'];
1555 }
1556
1557 return $settings;
1558 }
1559
1560 public static function getWelcomeBanner($view = 'login')
1561 {
1562 $settings = self::getWelcomeBannerSettings();
1563 $welcomeBanner = Arr::get($settings, $view, []);
1564 if (Arr::get($welcomeBanner, 'enabled') != 'yes') {
1565 return null;
1566 }
1567
1568 unset($welcomeBanner['description']);
1569
1570 if ($view == 'login') {
1571 return apply_filters('fluent_community/welcome_banner_for_logged_in', $welcomeBanner);
1572 }
1573
1574 return apply_filters('fluent_community/welcome_banner_for_guests', $welcomeBanner);
1575 }
1576
1577 public static function getEnabledFeedLinks()
1578 {
1579 $links = array_filter(self::getFeedLinks(), function ($item) {
1580 return self::isLinkAccessible($item);
1581 });
1582
1583 return array_values($links);
1584 }
1585
1586 public static function getMobileMenuItems($context = 'headless')
1587 {
1588 $xprofile = Helper::getCurrentProfile();
1589
1590 $mainMenuItems = Arr::get(self::getMenuItemsGroup('view'), 'mainMenuItems', []);
1591
1592 $defaultMobileIcons = [
1593 '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>',
1594 '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>'
1595 ];
1596
1597 $mobileMenuItems = [];
1598
1599 foreach ($defaultMobileIcons as $slug => $defaultIcon) {
1600 $menuItem = Arr::get($mainMenuItems, $slug);
1601 if (!$menuItem) {
1602 continue;
1603 }
1604
1605 $iconSvg = Arr::get($menuItem, 'shape_svg');
1606
1607 $mobileMenuItems[] = [
1608 'route' => [
1609 'name' => $slug
1610 ],
1611 'title' => Arr::get($menuItem, 'title'),
1612 'icon_svg' => $iconSvg ? CustomSanitizer::sanitizeSvg($iconSvg) : $defaultIcon
1613 ];
1614 }
1615
1616 if ($xprofile) {
1617 $mobileMenuItems[] = [
1618 'route' => [
1619 'name' => 'user_profile',
1620 'params' => [
1621 'username' => $xprofile->username
1622 ]
1623 ],
1624 'title' => __('Profile', 'fluent-community'),
1625 '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>'
1626 ];
1627 } else if (!get_current_user_id()) {
1628 $mobileMenuItems[] = [
1629 'name' => 'login',
1630 'title' => __('Login', 'fluent-community'),
1631 'permalink' => Helper::getAuthUrl(),
1632 '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>'
1633 ];
1634 }
1635
1636 return apply_filters('fluent_community/mobile_menu', $mobileMenuItems, $xprofile, $context);
1637 }
1638
1639 public static function getFeedLinks()
1640 {
1641 return Utility::getOption('feed_links', []);
1642 }
1643
1644 public static function updateFeedLinks($links)
1645 {
1646 Utility::updateOption('feed_links', $links);
1647 }
1648
1649 /**
1650 * Get the full name of a WordPress user.
1651 *
1652 * @param int|null $id The ID of the user.
1653 * @return string The full name of the user.
1654 */
1655 public static function getWpUserFullName($id = null)
1656 {
1657 $id = $id ?: get_current_user_id();
1658 $user = get_user_by('ID', $id);
1659
1660 $fullName = $user->display_name;
1661 if ($user->first_name && $user->last_name) {
1662 $fullName = $user->first_name . ' ' . $user->last_name;
1663 }
1664
1665 return $fullName;
1666 }
1667
1668 /**
1669 * Get the onboarding settings.
1670 *
1671 * @return array The onboarding settings.
1672 */
1673 public static function getOnboardingSettings()
1674 {
1675 $default = [
1676 'is_onboarding_enabled' => 'no',
1677 'registration_page_url' => '',
1678 ];
1679
1680 $settings = Utility::getOption('onboarding_settings', $default);
1681
1682 return wp_parse_args($settings, $default);
1683 }
1684
1685 /**
1686 * Get all WordPress published pages.
1687 *
1688 * @return array An array of page data.
1689 */
1690 public static function getAllWpPublishedPage()
1691 {
1692 $posts = get_posts(array(
1693 'post_status' => 'publish',
1694 'numberposts' => -1,
1695 'post_type' => 'any',
1696 ));
1697
1698 return array_map(function ($post) {
1699 return array(
1700 'id' => $post->ID,
1701 'permalink' => get_permalink($post),
1702 'title' => get_the_title($post),
1703 );
1704 }, $posts);
1705 }
1706
1707 /**
1708 * Add a user to a space.
1709 *
1710 * @param BaseSpace|int $space space to add the user to.
1711 * @param int $userId The ID of the user to add.
1712 * @param string $role The role of the user in the space.
1713 * @param string $by The source of the action.
1714 * @return bool True if the user was added, false otherwise.
1715 */
1716 public static function addToSpace($space, $userId, $role = 'member', $by = 'self', $skipSync = false)
1717 {
1718 if (is_numeric($space)) {
1719 $space = BaseSpace::onlyMain()->find($space);
1720 }
1721
1722 if (!$space || !$space instanceof BaseSpace) {
1723 return false;
1724 }
1725
1726 if (!$skipSync) {
1727 $user = User::find($userId);
1728
1729 if (!$user) {
1730 return false;
1731 }
1732
1733 $user->syncXProfile();
1734 }
1735
1736 if ($role == 'member' && $space->type == 'course') {
1737 $role = 'student';
1738 }
1739
1740 $exist = SpaceUserPivot::where('user_id', $userId)
1741 ->where('space_id', $space->id)
1742 ->first();
1743
1744 if ($exist) {
1745 if ($exist->status != 'active') {
1746 $exist->status = 'active';
1747
1748 if (!in_array($exist->role, ['admin', 'moderator'])) {
1749 $exist->role = $role;
1750 }
1751
1752 $exist->save();
1753
1754 if ($space->type == 'course') {
1755 do_action('fluent_community/course/enrolled', $space, $userId, $by);
1756 } else {
1757 do_action('fluent_community/space/joined', $space, $userId, $by);
1758 }
1759
1760 return true;
1761 }
1762
1763 return false;
1764 }
1765
1766 $created = SpaceUserPivot::create([
1767 'space_id' => $space->id,
1768 'role' => $role,
1769 'user_id' => $userId
1770 ]);
1771
1772 if ($space->type == 'course') {
1773 if (!$space instanceof Course) {
1774 $space = Course::find($space->id); // we are renewing the model to have access to course relations
1775 }
1776 do_action('fluent_community/course/enrolled', $space, $userId, $by, $created);
1777 } else {
1778 if (!$space instanceof Space) {
1779 $space = Space::find($space->id); // we are renewing the model to have access to space relations
1780 }
1781 do_action('fluent_community/space/joined', $space, $userId, $by, $created);
1782 }
1783 return true;
1784 }
1785
1786 /**
1787 * Remove a user from a space if exist.
1788 *
1789 * @param int $userId The ID of the user.
1790 * @param int $spaceId The ID of the space.
1791 * @param string $by The source of the action. self | by_admin
1792 * @return bool True if the user is in the space, false otherwise.
1793 */
1794 public static function removeFromSpace($space, $userId, $by = 'self')
1795 {
1796 $user = User::find($userId);
1797 if (!$user) {
1798 return false;
1799 }
1800
1801 if (is_numeric($space)) {
1802 $space = BaseSpace::query()->onlyMain()->find($space);
1803 }
1804
1805 if (!$space || !$space instanceof BaseSpace) {
1806 return false;
1807 }
1808
1809
1810 if (!self::isUserInSpace($userId, $space->id)) {
1811 return false;
1812 }
1813
1814 SpaceUserPivot::where('space_id', $space->id)
1815 ->where('user_id', $userId)
1816 ->delete();
1817
1818 $user->cacheAccessSpaces();
1819
1820 if ($space->type == 'course') {
1821 if (!$space instanceof Course) {
1822 $space = Course::find($space->id); // we are renewing the model to have access to course relations
1823 }
1824
1825 do_action('fluent_community/course/student_left', $space, $userId, $by);
1826 } else {
1827 if (!$space instanceof Space) {
1828 $space = Space::find($space->id);
1829 }
1830 // we are renewing the model to have access to space relations
1831 do_action('fluent_community/space/user_left', $space, $userId, $by);
1832 }
1833
1834 return true;
1835 }
1836
1837 /**
1838 * Render a link with icon.
1839 *
1840 * @param array $link The link data.
1841 * @param string $linkClass Additional classes for the link.
1842 * @param string $fallback The fallback content if no icon is found.
1843 * @param bool $renderIcon Whether to render the icon or not.
1844 */
1845 public static function renderLink($link, $linkClass = '', $fallback = '<span class="fcom_no_avatar"></span>', $renderIcon = true)
1846 {
1847 if (!$link || empty($link['permalink'])) {
1848 return;
1849 }
1850
1851 $isCustom = Arr::get($link, 'is_custom') == 'yes';
1852
1853 $linkAtts = array_filter([
1854 'class' => trim($linkClass . ' ' . Arr::get($link, 'link_classes')) . ' fcom_compt_link' . ($isCustom ? ' fcom_custom_link' : ''),
1855 'target' => Arr::get($link, 'new_tab') === 'yes' ? '_blank' : '',
1856 'rel' => Arr::get($link, 'new_tab') === 'yes' ? 'noopener noreferrer' : '',
1857 ]);
1858
1859 ?>
1860 <a aria-label="Go to <?php echo esc_attr(Arr::get($link, 'title')); ?> page"
1861 data-fcom-tip="<?php echo esc_attr(Arr::get($link, 'title')); ?>"
1862 href="<?php echo esc_url($link['permalink']); ?>"<?php foreach ($linkAtts as $key => $value) {
1863 echo esc_attr($key) . '="' . esc_attr($value) . '"';
1864 } ?>>
1865 <?php $renderIcon && self::printLinkIcon($link, $fallback); ?>
1866 <span class="community_name"><?php echo wp_kses_post(Arr::get($link, 'title')); ?></span>
1867 <?php if (Arr::get($link, 'show_lock')) : ?>
1868 <span class="fcom_space_lock">
1869 <i class="el-icon">
1870 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
1871 <path fill="currentColor"
1872 d="M224 448a32 32 0 0 0-32 32v384a32 32 0 0 0 32 32h576a32 32 0 0 0 32-32V480a32 32 0 0 0-32-32zm0-64h576a96 96 0 0 1 96 96v384a96 96 0 0 1-96 96H224a96 96 0 0 1-96-96V480a96 96 0 0 1 96-96"></path>
1873 <path fill="currentColor"
1874 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>
1875 </svg>
1876 </i>
1877 </span>
1878 <?php elseif ($unreadBardge = Arr::get($link, 'unread_badge')) : ?>
1879 <span class="fcom_space_lock fcom_unread_count">
1880 <?php echo wp_kses_post($unreadBardge); ?>
1881 </span>
1882 <?php endif; ?>
1883 </a>
1884 <?php
1885 }
1886
1887 public static function renderMenuItems($menuItems, $linkClass, $fallback = '', $renderIcon = false)
1888 {
1889 if (!$menuItems) {
1890 return;
1891 }
1892
1893 $renderIcon = $renderIcon || Utility::isCustomizationEnabled('icon_on_header_menu');
1894
1895 foreach ($menuItems as $itemKey => $item): ?>
1896 <li class="<?php echo esc_attr('fcom_menu_item_' . $itemKey); ?>">
1897 <?php self::renderLink($item, $linkClass, $fallback, $renderIcon); ?>
1898 </li>
1899 <?php endforeach;
1900 }
1901
1902 public static function renderSettingsItems($settingsItems = [])
1903 {
1904 foreach ($settingsItems as $itemKey => $item): ?>
1905 <li class="<?php echo esc_attr('fcom_menu_item_' . $itemKey); ?>">
1906 <a class="fcom_menu_link <?php echo esc_attr(Arr::get($item, 'link_classes')); ?>"
1907 href="<?php echo esc_url($item['permalink']); ?>">
1908 <?php if (!empty($item['icon_svg'])): ?>
1909 <i class="el-icon">
1910 <?php echo CustomSanitizer::sanitizeSvg(Arr::get($item, 'el-icon', '')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
1911 </i>
1912 <?php endif; ?>
1913 <span class="community_name"><?php echo wp_kses_post($item['title']); ?></span>
1914 </a>
1915 </li>
1916 <?php endforeach;
1917 }
1918
1919 /**
1920 * Print a link icon.
1921 *
1922 * @param array $link The link data.
1923 * @param string $fallback The fallback content if no icon is found.
1924 */
1925 public static function printLinkIcon($link, $fallback = '<span class="fcom_no_avatar"></span>')
1926 {
1927 ?>
1928 <?php if ($img = Arr::get($link, 'icon_image')): ?>
1929 <div class="community_avatar">
1930 <img alt="" src="<?php echo esc_url($img); ?>"/>
1931 </div>
1932 <?php elseif ($emoji = Arr::get($link, 'emoji')): ?>
1933 <div class="community_avatar">
1934 <span class="fcom_emoji"><?php echo esc_html($emoji); ?></span>
1935 </div>
1936 <?php elseif ($svg = Arr::get($link, 'shape_svg')): ?>
1937 <div class="community_avatar">
1938 <span class="fcom_shape"><i
1939 class="el-icon"><?php echo \FluentCommunity\App\Services\CustomSanitizer::sanitizeSvg($svg); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></i></span>
1940 </div>
1941 <?php else:
1942 echo '<div class="community_avatar">' . wp_kses_post($fallback) . '</div>';
1943 endif;
1944 }
1945
1946 /**
1947 * Get the IP address of the user.
1948 *
1949 * @param bool $anonymize Whether to anonymize the IP address.
1950 * @return string The IP address.
1951 */
1952 public static function getIp($anonymize = false)
1953 {
1954 static $ipAddress;
1955
1956 if ($ipAddress) {
1957 return $ipAddress;
1958 }
1959
1960 if (empty($_SERVER['REMOTE_ADDR'])) {
1961 // It's a local cli request
1962 return '127.0.0.1';
1963 }
1964
1965 $ipAddress = '';
1966 if (isset($_SERVER["HTTP_CF_CONNECTING_IP"])) {
1967 $ipAddress = sanitize_text_field(wp_unslash($_SERVER["REMOTE_ADDR"]));
1968 //If it's a valid Cloudflare request
1969 if (self::isCfIp($ipAddress)) {
1970 //Use the CF-Connecting-IP header.
1971 $ipAddress = sanitize_text_field(wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP']));
1972 }
1973 } else if ($_SERVER['REMOTE_ADDR'] == '127.0.0.1') {
1974 // most probably it's local reverse proxy
1975 if (isset($_SERVER["HTTP_CLIENT_IP"])) {
1976 $ipAddress = sanitize_text_field(wp_unslash($_SERVER["HTTP_CLIENT_IP"]));
1977 } else if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
1978 $forwardedIp = trim(current(preg_split('/,/', sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR'])))));
1979 if (rest_is_ip_address($forwardedIp)) {
1980 $ipAddress = $forwardedIp;
1981 }
1982 }
1983 }
1984
1985 if (!$ipAddress) {
1986 $ipAddress = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR']));
1987 }
1988
1989 $ipAddress = preg_replace('/^(\d+\.\d+\.\d+\.\d+):\d+$/', '\1', $ipAddress);
1990
1991 $ipAddress = apply_filters('fluent_auth/user_ip', $ipAddress);
1992
1993 if ($anonymize) {
1994 return wp_privacy_anonymize_ip($ipAddress);
1995 }
1996
1997 return $ipAddress;
1998 }
1999
2000 /**
2001 * Check if the IP address is from Cloudflare.
2002 *
2003 * @param string $ip The IP address to check.
2004 * @return bool True if the IP is from Cloudflare, false otherwise.
2005 */
2006 public static function isCfIp($ip = '')
2007 {
2008 if (!$ip && isset($_SERVER["REMOTE_ADDR"])) {
2009 $ip = sanitize_text_field(wp_unslash($_SERVER["REMOTE_ADDR"]));
2010 }
2011
2012 if (!$ip) {
2013 return false;
2014 }
2015
2016 $cloudflareIPRanges = array(
2017 '173.245.48.0/20',
2018 '103.21.244.0/22',
2019 '103.22.200.0/22',
2020 '103.31.4.0/22',
2021 '141.101.64.0/18',
2022 '108.162.192.0/18',
2023 '190.93.240.0/20',
2024 '188.114.96.0/20',
2025 '197.234.240.0/22',
2026 '198.41.128.0/17',
2027 '162.158.0.0/15',
2028 '104.16.0.0/13',
2029 '104.24.0.0/14',
2030 '172.64.0.0/13',
2031 '131.0.72.0/22',
2032 );
2033
2034 //Make sure that the request came via Cloudflare.
2035 foreach ($cloudflareIPRanges as $range) {
2036 //Use the ip_in_range function from Joomla.
2037 if (self::ipInRange($ip, $range)) {
2038 //IP is valid. Belongs to Cloudflare.
2039 return true;
2040 }
2041 }
2042
2043 return false;
2044 }
2045
2046 /**
2047 * Check if the IP address is in the given range.
2048 *
2049 * @param string $ip The IP address to check.
2050 * @param string $range The range to check against.
2051 * @return bool True if the IP is in the range, false otherwise.
2052 */
2053 private static function ipInRange($ip, $range)
2054 {
2055 if (strpos($range, '/') !== false) {
2056 // $range is in IP/NETMASK format
2057 list($range, $netmask) = explode('/', $range, 2);
2058 if (strpos($netmask, '.') !== false) {
2059 // $netmask is a 255.255.0.0 format
2060 $netmask = str_replace('*', '0', $netmask);
2061 $netmask_dec = ip2long($netmask);
2062 return ((ip2long($ip) & $netmask_dec) == (ip2long($range) & $netmask_dec));
2063 } else {
2064 // $netmask is a CIDR size block
2065 // fix the range argument
2066 $x = explode('.', $range);
2067 while (count($x) < 4) $x[] = '0';
2068 list($a, $b, $c, $d) = $x;
2069 $range = sprintf("%u.%u.%u.%u", empty($a) ? '0' : $a, empty($b) ? '0' : $b, empty($c) ? '0' : $c, empty($d) ? '0' : $d);
2070 $range_dec = ip2long($range);
2071 $ip_dec = ip2long($ip);
2072
2073 # Strategy 1 - Create the netmask with 'netmask' 1s and then fill it to 32 with 0s
2074 #$netmask_dec = bindec(str_pad('', $netmask, '1') . str_pad('', 32-$netmask, '0'));
2075
2076 # Strategy 2 - Use math to create it
2077 $wildcard_dec = pow(2, (32 - $netmask)) - 1;
2078 $netmask_dec = ~$wildcard_dec;
2079
2080 return (($ip_dec & $netmask_dec) == ($range_dec & $netmask_dec));
2081 }
2082 } else {
2083 // range might be 255.255.*.* or 1.2.3.0-1.2.3.255
2084 if (strpos($range, '*') !== false) { // a.b.*.* format
2085 // Just convert to A-B format by setting * to 0 for A and 255 for B
2086 $lower = str_replace('*', '0', $range);
2087 $upper = str_replace('*', '255', $range);
2088 $range = "$lower-$upper";
2089 }
2090
2091 if (strpos($range, '-') !== false) { // A-B format
2092 list($lower, $upper) = explode('-', $range, 2);
2093 $lower_dec = (float)sprintf("%u", ip2long($lower));
2094 $upper_dec = (float)sprintf("%u", ip2long($upper));
2095 $ip_dec = (float)sprintf("%u", ip2long($ip));
2096 return (($ip_dec >= $lower_dec) && ($ip_dec <= $upper_dec));
2097 }
2098 return false;
2099 }
2100 }
2101
2102 public static function getPortalRequestPath($requestUri)
2103 {
2104 $portalSlug = self::getPortalSlug();
2105
2106 // If portal is mounted at site root with empty requestUri, ignore query-only requests that do not relate to the community portal.
2107 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing check on $_GET, no state mutation
2108 if ($portalSlug === '' && $requestUri === '' && !empty($_GET) && !self::hasSupportedQueryParam()) {
2109 return false;
2110 }
2111
2112 if ($portalSlug == $requestUri) {
2113 return 'portal_home';
2114 }
2115
2116 if (!$requestUri) {
2117 return false;
2118 }
2119
2120 if ($portalSlug) {
2121 // remove the portal slug from the request uri. Don't use str_replace as it will replace all occurrences
2122 $requestUri = substr($requestUri, strlen($portalSlug));
2123 }
2124
2125 $parts = explode('/', $requestUri);
2126 $start = $parts[0];
2127
2128 if (!$portalSlug && $start == 'fcom_route') {
2129 return $start;
2130 }
2131
2132 $routeStats = self::portalRoutePaths();
2133
2134 if (in_array($start, $routeStats)) {
2135 return $requestUri;
2136 }
2137
2138 return false;
2139 }
2140
2141 /**
2142 * Check if the current request has any supported query parameter.
2143 *
2144 * @return bool
2145 */
2146 private static function hasSupportedQueryParam()
2147 {
2148 $supportedParams = (array) apply_filters('fluent_community/portal_supported_query_params', [
2149 'customizer_panel',
2150 'create_space'
2151 ]);
2152
2153 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2154 foreach (array_keys($_GET) as $key) {
2155 if (strpos($key, 'fcom_') === 0) {
2156 return true;
2157 }
2158 if (in_array($key, $supportedParams, true)) {
2159 return true;
2160 }
2161 }
2162 return false;
2163 }
2164
2165 public static function getTopicsConfig()
2166 {
2167 $config = Utility::getOption('topics_config', []);
2168 $default = [
2169 'max_topics_per_post' => 1,
2170 'max_topics_per_space' => 20,
2171 'show_on_post_card' => 'yes'
2172 ];
2173 return wp_parse_args($config, $default);
2174 }
2175
2176 public static function getModerationConfig()
2177 {
2178 $config = Utility::getOption('moderation_config', []);
2179
2180 $default = [
2181 'is_enabled' => 'no',
2182 'profanity_filter' => "",
2183 'flag_after_threshold' => 0,
2184 'flag_all_new_posts' => 'no',
2185 'first_post_approval' => 'no',
2186 'first_comment_approval' => 'no',
2187 'flag_all_new_posts_spaces' => [],
2188 'auto_flag_user_reject_threshold' => 0,
2189 'auto_flag_user_report_threshold' => 0,
2190 ];
2191
2192 return wp_parse_args($config, $default);
2193 }
2194
2195 public static function getReportReasons()
2196 {
2197 return apply_filters('fluent_community/report_reasons', [
2198 'harassment' => __('Harassment', 'fluent-community'),
2199 'spam' => __('Spam', 'fluent-community'),
2200 'offensive' => __('Offensive', 'fluent-community'),
2201 'incorrect_space' => __('Incorrect Space', 'fluent-community'),
2202 'against_community' => __('Against Community Rules', 'fluent-community'),
2203 'other' => __('Other', 'fluent-community'),
2204 ]);
2205 }
2206
2207 public static function htmlToMd($html)
2208 {
2209 return preg_replace('/<a.*?href="(.*?)".*?>(.*?)<\/a>/', '[$2]($1)', $html);
2210 }
2211
2212 public static function isProfanity($profanity, $text)
2213 {
2214 $profanity = explode(',', $profanity);
2215 if (empty($profanity)) {
2216 return false;
2217 }
2218 $profanity = array_map('trim', $profanity);
2219 $profanity = array_map(function ($word) {
2220 return mb_strtolower($word, 'UTF-8');
2221 }, $profanity);
2222 $text = mb_strtolower($text, 'UTF-8');
2223
2224 // Convert words into a regex pattern (ensuring whole-word matching)
2225 $pattern = '/(?<!\p{L})(' . implode('|', array_map('preg_quote', $profanity)) . ')(?!\p{L})/iu';
2226
2227 if (preg_match($pattern, $text, $matches)) {
2228 return $matches[0];
2229 }
2230
2231 return false;
2232 }
2233
2234 public static function getFullDayName($day)
2235 {
2236 $dayMap = [
2237 'sun' => 'sunday',
2238 'mon' => 'monday',
2239 'tue' => 'tuesday',
2240 'wed' => 'wednesday',
2241 'thu' => 'thursday',
2242 'fri' => 'friday',
2243 'sat' => 'saturday'
2244 ];
2245
2246 return isset($dayMap[$day]) ? $dayMap[$day] : $day . 'day';
2247 }
2248
2249 public static function getPostOrderOptions($context = 'feed')
2250 {
2251 $options = [
2252 'new_activity' => __('New Activity', 'fluent-community'),
2253 'latest' => __('Latest', 'fluent-community'),
2254 'oldest' => __('Oldest', 'fluent-community'),
2255 'popular' => __('Popular', 'fluent-community'),
2256 'likes' => __('Likes', 'fluent-community'),
2257 'alphabetical' => __('Alphabetical', 'fluent-community'),
2258 'unanswered' => __('Unanswered', 'fluent-community'),
2259 ];
2260
2261 return apply_filters('fluent_community/post_order_options', $options, $context);
2262 }
2263
2264 public static function getCommentOrderOptions($context = 'comment')
2265 {
2266 $options = [
2267 'oldest' => __('Earliest', 'fluent-community'),
2268 'latest' => __('Latest', 'fluent-community'),
2269 'popular' => __('Popular', 'fluent-community'),
2270 'most_replied' => __('Most Replied', 'fluent-community'),
2271 ];
2272
2273 return apply_filters('fluent_community/comment_order_options', $options, $context);
2274 }
2275
2276 public static function convertPhpDateToDayJSFormay($phpFormat)
2277 {
2278 // Mapping PHP date format characters to Day.js format characters
2279 $replacements = [
2280 // Day
2281 'd' => 'DD', // Day of the month, 2 digits with leading zeros
2282 'D' => 'ddd', // A textual representation of a day, three letters
2283 'j' => 'D', // Day of the month without leading zeros
2284 'l' => 'dddd', // A full textual representation of the day of the week
2285 'N' => 'E', // ISO-8601 numeric representation of the day of the week
2286 'S' => 'o', // English ordinal suffix for the day of the month, 2 characters
2287 'w' => 'd', // Numeric representation of the day of the week
2288 'z' => 'DDD', // The day of the year (starting from 0)
2289
2290 // Week
2291 'W' => 'W', // ISO-8601 week number of year, weeks starting on Monday
2292
2293 // Month
2294 'F' => 'MMMM', // A full textual representation of a month
2295 'm' => 'MM', // Numeric representation of a month, with leading zeros
2296 'M' => 'MMM', // A short textual representation of a month, three letters
2297 'n' => 'M', // Numeric representation of a month, without leading zeros
2298 't' => '', // Not supported in Day.js (Number of days in the given month)
2299
2300 // Year
2301 'L' => '', // Not supported in Day.js (Whether it's a leap year)
2302 'o' => 'GGGG', // ISO-8601 week-numbering year
2303 'Y' => 'YYYY', // A full numeric representation of a year, 4 digits
2304 'y' => 'YY', // A two digit representation of a year
2305
2306 // Time
2307 'a' => 'a', // Lowercase Ante meridiem and Post meridiem
2308 'A' => 'A', // Uppercase Ante meridiem and Post meridiem
2309 'B' => '', // Not supported in Day.js (Swatch Internet time)
2310 'g' => 'h', // 12-hour format of an hour without leading zeros
2311 'G' => 'H', // 24-hour format of an hour without leading zeros
2312 'h' => 'hh', // 12-hour format of an hour with leading zeros
2313 'H' => 'HH', // 24-hour format of an hour with leading zeros
2314 'i' => 'mm', // Minutes with leading zeros
2315 's' => 'ss', // Seconds with leading zeros
2316 'u' => 'SSS', // Milliseconds (Day.js uses SSS for fractional seconds)
2317 'v' => 'SSS', // Milliseconds (Day.js uses SSS for fractional seconds)
2318
2319 // Timezone
2320 'e' => '', // Not supported in Day.js (Timezone identifier)
2321 'I' => '', // Not supported in Day.js (Whether or not the date is in daylight saving time)
2322 'O' => 'ZZ', // Difference to Greenwich time (GMT) in hours
2323 'P' => 'Z', // Difference to Greenwich time (GMT) with colon between hours and minutes
2324 'T' => '', // Not supported in Day.js (Timezone abbreviation)
2325 'Z' => '', // Not supported in Day.js (Timezone offset in seconds)
2326
2327 // Full Date/Time
2328 'c' => 'YYYY-MM-DDTHH:mm:ssZ', // ISO 8601 date
2329 'r' => 'ddd, DD MMM YYYY HH:mm:ss ZZ', // RFC 2822 formatted date
2330 'U' => 'X', // Seconds since the Unix Epoch (January 1 1970 00:00:00 GMT)
2331 ];
2332
2333 // Replace each PHP date format character with Day.js equivalent
2334 $dayjsFormat = "";
2335
2336 for ($i = 0; $i < strlen($phpFormat); $i++) {
2337 $char = $phpFormat[$i];
2338
2339 // Special handling for G\hi pattern
2340 if ($char === 'G' && $i + 2 < strlen($phpFormat) &&
2341 $phpFormat[$i + 1] === '\\' && $phpFormat[$i + 2] === 'h') {
2342 $dayjsFormat .= 'H[h]';
2343 $i += 2;
2344 continue;
2345 }
2346
2347 // Check if the character is escaped
2348 if ($char === "\\") {
2349 // Day.js escapes literal text with square brackets, not backslashes
2350 $i++;
2351 if ($i < strlen($phpFormat)) {
2352 $dayjsFormat .= "[" . $phpFormat[$i] . "]";
2353 }
2354 continue;
2355 }
2356
2357 // Add the mapped character or the character itself if not found in the mapping
2358 $dayjsFormat .= $replacements[$char] ?? $char;
2359 }
2360
2361 return $dayjsFormat;
2362 }
2363
2364 public static function getDateFormatter($isDayJs = false)
2365 {
2366 $format = get_option('date_format');
2367 if ($isDayJs) {
2368 return self::convertPhpDateToDayJSFormay($format);
2369 }
2370
2371 return $format;
2372 }
2373
2374 public static function getTimeFormatter($isDayJs = false)
2375 {
2376 $format = get_option('time_format');
2377
2378 if ($isDayJs) {
2379 return self::convertPhpDateToDayJSFormay($format);
2380 }
2381
2382 return $format;
2383 }
2384
2385 public static function normalizeToAscii($text)
2386 {
2387 if (function_exists('transliterator_transliterate')) {
2388 $result = transliterator_transliterate('Any-Latin; Latin-ASCII', $text);
2389 if ($result !== false) {
2390 return $result;
2391 }
2392 }
2393
2394 if (function_exists('iconv')) {
2395 $result = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text);
2396 if ($result !== false) {
2397 return $result;
2398 }
2399 }
2400
2401 return $text;
2402 }
2403
2404 public static function getPathFromUrl($url)
2405 {
2406 return rtrim((string) wp_parse_url($url, PHP_URL_PATH), '/');
2407 }
2408
2409 // Return the ID of the group that contains the current page
2410 public static function getActiveSidebarGroupId($groups)
2411 {
2412 if (empty($_SERVER['REQUEST_URI'])) {
2413 return '';
2414 }
2415 $url=esc_url_raw(wp_unslash($_SERVER['REQUEST_URI'])); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated
2416 $currentPath = self::getPathFromUrl($url);
2417 if ($currentPath === '') {
2418 return '';
2419 }
2420
2421 foreach ($groups as $group) {
2422 $items = isset($group['children']) ? $group['children'] : ($group['items'] ?? []);
2423 foreach ($items as $item) {
2424 $item_path = isset($item['permalink']) ? self::getPathFromUrl($item['permalink']) : '';
2425
2426 if (is_array($item) && !empty($item['permalink']) && $item_path === $currentPath) {
2427 return sanitize_key((string) ($group['id'] ?? $group['slug'] ?? ''));
2428 }
2429 }
2430 }
2431
2432 return '';
2433 }
2434
2435 // Explicit per-user choices from the cookie. Format: "c:1,2|e:3,4" (c = collapsed, e = expanded).
2436 public static function getSidebarGroupStates()
2437 {
2438 $collapsed = [];
2439 $expanded = [];
2440 $cookie = isset($_COOKIE['fcom_sidebar_group_states']) ? sanitize_text_field(wp_unslash($_COOKIE['fcom_sidebar_group_states'])) : '';
2441 foreach (explode('|', $cookie) as $section) {
2442 list($flag, $ids) = array_pad(explode(':', $section, 2), 2, '');
2443 $list = array_filter(array_map('sanitize_key', explode(',', $ids)));
2444
2445 if ($flag === 'c') {
2446 $collapsed = $list;
2447 } elseif ($flag === 'e') {
2448 $expanded = $list;
2449 }
2450 }
2451
2452 return [$collapsed, $expanded];
2453 }
2454
2455 public static function getCollapsedSidebarGroups($groups = [])
2456 {
2457 $isDefaultCollapse = Utility::isCustomizationEnabled('collapse_sidebar_groups');
2458 list($collapsedByUser, $expandedByUser) = self::getSidebarGroupStates();
2459 $activeGroupId = self::getActiveSidebarGroupId($groups);
2460
2461 /**
2462 * Precendence.
2463 * 1. No group id or slug => Fallback to expanded
2464 * 2. Has Active Link => Always expanded
2465 * 3. Explicitly expanded by user => Always expanded
2466 * 4. Explicitly collapsed by user => Collapsed
2467 * 5. Default Collapse by setting => Collapsed
2468 * 6. Otherwise => Expanded
2469 */
2470
2471 $collapsed = [];
2472 foreach ($groups as $group) {
2473 $id = sanitize_key((string) ($group['id'] ?? $group['slug'] ?? ''));
2474 // Matching expanded condition (1,2,3)
2475 if ($id === '' || $id === $activeGroupId || in_array($id, $expandedByUser, true)) {
2476 continue;
2477 }
2478 // Matching collapsed condition (4,5)
2479 if ($isDefaultCollapse || in_array($id, $collapsedByUser, true)) {
2480 $collapsed[] = $id;
2481 }
2482 // else (6) => expanded, do nothing
2483 }
2484
2485 return $collapsed;
2486 }
2487 }
2488