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

2,574 lines 94.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\Contact;
10 use FluentCommunity\App\Models\Feed;
11 use FluentCommunity\App\Models\Space;
12 use FluentCommunity\App\Models\Media;
13 use FluentCommunity\App\Models\Meta;
14 use FluentCommunity\App\Models\SpaceUserPivot;
15 use FluentCommunity\App\Models\User;
16 use FluentCommunity\App\Models\XProfile;
17 use FluentCommunity\Framework\Support\Arr;
18 use FluentCommunity\App\Models\SpaceGroup;
19 use FluentCommunity\Modules\Course\Model\Course;
20
21 /**
22 * Helper class for various utility functions.
23 */
24 class Helper
25 {
26
27 public static function isRtl()
28 {
29 return apply_filters('fluent_community/is_rtl', is_rtl());
30 }
31
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 /**
80 * Get the portal slug.
81 *
82 * @return string The portal slug.
83 */
84 /**
85 * Get the portal slug.
86 *
87 * @return string The portal slug.
88 */
89 public static function getPortalSlug($forRoute = false)
90 {
91 $settings = get_option('fluent_community_settings', []);
92 if (isset($settings['slug'])) {
93 $slug = $settings['slug'];
94 } else {
95 $slug = 'portal';
96 }
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
104 if (!$forRoute) {
105 return $slug;
106 }
107
108 $siteUrl = get_home_url();
109
110 $poralUrl = self::baseUrl('/');
111
112 $urlPath = wp_parse_url($siteUrl, PHP_URL_PATH);
113
114 if ($urlPath) {
115 // get the url without path
116 $siteUrl = str_replace($urlPath, '', $siteUrl);
117 }
118
119 $slug = str_replace($siteUrl, '', $poralUrl);
120 // remove the first and last slashes
121 return trim($slug, '/');
122 }
123
124 /**
125 * Get the portal route type.
126 *
127 * @return string The portal route type.
128 */
129 public static function getPortalRouteType()
130 {
131 return apply_filters('fluent_community/portal_route_type', 'WebHistory');
132 }
133
134 /**
135 * Check if the portal is headless.
136 *
137 * @return bool True if headless, false otherwise.
138 */
139 public static function isHeadless()
140 {
141 return apply_filters('fluent_community/portal_page_headless', false);
142 }
143
144 /**
145 * Check if the portal has a color scheme.
146 *
147 * @return bool True if has color scheme, false otherwise.
148 */
149 public static function hasColorScheme()
150 {
151 $status = Utility::isCustomizationEnabled('dark_mode');
152 return apply_filters('fluent_community/has_color_scheme', $status);
153 }
154
155 /**
156 * Admin default theme mode: 'light', 'dark', or 'system'.
157 */
158 public static function getDefaultThemeMode()
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 {
230 $capability = apply_filters('fluent_community/super_admin_capability', 'manage_options');
231
232 if (!$capability) {
233 return false;
234 }
235
236 if ($userId === null) {
237 $userId = get_current_user_id();
238 }
239
240 if (!$userId) {
241 return false;
242 }
243
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;
258 }
259
260 if (!$user) {
261 $user = ($userId && (int)$userId !== get_current_user_id())
262 ? User::find($userId)
263 : self::getCurrentUser();
264 }
265
266 return $user && Arr::get($user->getPermissions(), 'community_admin');
267 }
268
269 public static function isModerator($user = null)
270 {
271 if (!$user) {
272 $user = self::getCurrentUser();
273 }
274
275 return $user && $user->hasCommunityModeratorAccess();
276 }
277
278 /**
279 * Get the URL for an asset file.
280 *
281 * @param string $file The file name.
282 * @return string The full URL to the asset.
283 */
284 public static function assetUrl($file = '')
285 {
286 return FLUENT_COMMUNITY_PLUGIN_URL . 'assets/' . $file;
287 }
288
289 /**
290 * Get the base URL for the portal.
291 *
292 * @param string $path The path to append to the base URL.
293 * @return string The full base URL.
294 */
295 public static function baseUrl($path = '')
296 {
297 $baseUrl = apply_filters('fluent_community/base_url', home_url(self::getPortalSlug()));
298 $baseUrl = rtrim($baseUrl, '/');
299
300 if (self::getPortalRouteType() != 'hash') {
301 return $baseUrl . '/' . ltrim($path, '/');
302 }
303
304 if (!$path) {
305 return $baseUrl . '/';
306 }
307
308 return $baseUrl . '/#/' . ltrim($path, '/');
309 }
310
311 public static function getAuthUrl()
312 {
313 $settings = self::generalSettings();
314
315 return Arr::get($settings, 'cutsom_auth_url', '');
316 }
317
318 /**
319 * Get the space IDs for a user.
320 *
321 * @param int|null $userId The user ID. If null, uses the current user.
322 * @return array An array of space IDs.
323 */
324 public static function getUserSpaceIds($userId = null)
325 {
326 if (!$userId) {
327 $userId = get_current_user_id();
328 }
329
330 return SpaceUserPivot::where('user_id', $userId)
331 ->where('status', 'active')
332 ->pluck('space_id')
333 ->toArray();
334 }
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
347 /**
348 * Check if a user is in a specific space.
349 *
350 * @param int $userId The user ID.
351 * @param int $spaceId The space ID.
352 * @return bool True if the user is in the space, false otherwise.
353 */
354 public static function isUserInSpace($userId, $spaceId)
355 {
356 if (!$userId || !$spaceId) {
357 return false;
358 }
359
360 return SpaceUserPivot::where('user_id', $userId)
361 ->where('space_id', $spaceId)
362 ->where('status', 'active')
363 ->exists();
364 }
365
366 /**
367 * Generate HTML attributes from an array.
368 *
369 * @param array $atts An array of attribute key-value pairs.
370 * @return string The generated HTML attributes string.
371 */
372 public static function attrs($atts = [])
373 {
374 $text = '';
375
376 foreach ($atts as $key => $value) {
377 $text .= "$key=\"$value\" ";
378 }
379
380 return $text;
381 }
382
383 /**
384 * Get media from a URL.
385 *
386 * @param string|array $url The URL or an array containing URL information.
387 * @return Media|null The Media object if found, null otherwise.
388 */
389 public static function getMediaFromUrl($url)
390 {
391 if (is_array($url) && isset($url['provider'])) {
392 $provider = Arr::get($url, 'provider');
393
394 if ($provider == 'giphy') {
395 return null;
396 }
397
398 $url = Arr::get($url, 'url');
399 }
400
401 if (!$url) {
402 return null;
403 }
404
405 $parsedUrl = wp_parse_url($url, PHP_URL_QUERY);
406
407 if (!$parsedUrl) {
408 return null;
409 }
410
411 // Parse the query string to get the media_key value
412 parse_str($parsedUrl, $queryParams);
413
414 $key = Arr::get($queryParams, 'media_key');
415
416 if (!$key) {
417 return null;
418 }
419
420 return Media::where('media_key', $key)->first();
421 }
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
434 /**
435 * Get media items from multiple URLs.
436 *
437 * @param array $urls An array of URLs.
438 * @return array An array of Media objects.
439 */
440 public static function getMediaItemsFromUrl($urls)
441 {
442 $mediaItems = [];
443
444 foreach ($urls as $url) {
445 $media = self::getMediaFromUrl($url);
446
447 if ($media) {
448 $mediaItems[] = $media;
449 }
450 }
451
452 return $mediaItems;
453 }
454
455 /**
456 * Get general settings for the community.
457 *
458 * @param bool $cached Whether to use cached settings.
459 * @return array The general settings.
460 */
461 public static function generalSettings($cached = true)
462 {
463 static $settings = null;
464
465 if ($cached && $settings) {
466 return $settings;
467 }
468
469 $settings = get_option('fluent_community_settings', []);
470
471 $defaults = [
472 'site_title' => get_bloginfo('name'),
473 'slug' => 'portal',
474 'logo' => '',
475 'white_logo' => '',
476 'logo_permalink_type' => 'default',
477 'logo_permalink' => '',
478 'featured_image' => '',
479 'access' => [
480 'acess_level' => 'public', // logged_in, public, role_based
481 'access_roles' => []
482 ],
483 'auth_form_type' => 'default',
484 'explicit_registration' => 'no',
485 'disable_global_posts' => 'yes',
486 'auth_content' => 'Please login first to access this page',
487 'auth_redirect' => '',
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' => ''
493 ];
494
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 }
499
500 if (defined('FLUENT_COMMUNITY_PORTAL_SLUG')) {
501 $settings['slug'] = \FLUENT_COMMUNITY_PORTAL_SLUG;
502 $settings['is_slug_defined'] = true;
503 } else {
504 unset($settings['is_slug_defined']);
505 }
506
507 return $settings;
508 }
509
510 public static function hasGlobalPost()
511 {
512 $settings = self::generalSettings();
513 $status = Arr::get($settings, 'disable_global_posts', '') != 'yes';
514
515 return apply_filters('fluent_community/has_global_post', $status);
516 }
517
518 /**
519 * Check if a user can access the portal.
520 *
521 * @param int|null $userId The user ID. If null, uses the current user.
522 * @return bool True if the user can access the portal, false otherwise.
523 */
524 public static function canAccessPortal($userId = null, $requireActiveProfile = true)
525 {
526 $settings = self::generalSettings();
527 $accessLevel = Arr::get($settings, 'access.acess_level');
528
529 if ($accessLevel == 'public') {
530 return apply_filters('fluent_community/can_access_portal', true);
531 }
532
533 if (!$userId) {
534 $userId = get_current_user_id();
535 }
536
537 if (!$userId) {
538 return apply_filters('fluent_community/can_access_portal', false);
539 }
540
541 if ($accessLevel == 'logged_in') {
542 return apply_filters('fluent_community/can_access_portal', true);
543 }
544
545 if (user_can($userId, 'edit_pages')) {
546 return apply_filters('fluent_community/can_access_portal', true);
547 }
548
549 $roles = Arr::get($settings, 'access.access_roles', []);
550
551 $user = get_user_by('ID', $userId);
552
553 if (!$user) {
554 return apply_filters('fluent_community/can_access_portal', false);
555 }
556
557 $result = !!array_intersect(array_values($user->roles), $roles);
558
559 if (!$result) {
560 return apply_filters('fluent_community/can_access_portal', false);
561 }
562
563 if (!$requireActiveProfile) {
564 return apply_filters('fluent_community/can_access_portal', true);
565 }
566
567 $xProfile = Helper::getCurrentProfile();
568
569 $result = $xProfile && $xProfile->status == 'active';
570
571 return apply_filters('fluent_community/can_access_portal', $result);
572 }
573
574 /**
575 * Get the portal route paths.
576 *
577 * @return array An array of portal route paths.
578 */
579 public static function portalRoutePaths()
580 {
581 return apply_filters('fluent_community/app_route_paths', [
582 'portal_home',
583 'members',
584 'bookmarks',
585 'chat',
586 'dashboard',
587 'leaderboards',
588 'notifications',
589 'space',
590 'discover',
591 'courses',
592 'u',
593 'post',
594 'admin',
595 'course',
596 'site-maps'
597 ]);
598 }
599
600 /**
601 * Get the current user's profile.
602 *
603 * @param bool $cached Whether to use cached profile.
604 * @return XProfile|null The user's profile or null if not found.
605 */
606 public static function getCurrentProfile($cached = true)
607 {
608 static $profile;
609 if ($profile && $cached) {
610 return $profile;
611 }
612
613 $userId = get_current_user_id();
614
615 if (!$userId) {
616 $profile = null;
617 return $profile;
618 }
619
620 $profile = XProfile::where('user_id', $userId)->first();
621
622 return $profile;
623 }
624
625 /**
626 * Get the current user Model.
627 *
628 * @param bool $cached Whether to use cached user.
629 * @return User|false The User model or false if not found.
630 */
631 public static function getCurrentUser($cached = true)
632 {
633 $userId = get_current_user_id();
634 if (!$userId) {
635 return false;
636 }
637
638 static $users = [];
639 if ($cached && isset($users[$userId])) {
640 return $users[$userId];
641 }
642
643 return $users[$userId] = User::find($userId);
644 }
645
646 /**
647 * Get the route paths for the community.
648 *
649 * @return array An array of route paths.
650 */
651 private static function getRoutePaths()
652 {
653 return [
654 'dashboard' => '/dashboard',
655 'all_feeds' => '/',
656 'single_feed' => '/post/:feed_slug',
657 'space_feeds' => '/space/:space/home',
658 'space_feed' => '/space/:space/post/:feed_slug',
659 'space_members' => '/space/:space/members',
660 'spaces' => '/discover/spaces',
661 'settings' => '/admin/settings',
662 'admin_moderators' => '/admin/settings/moderators',
663 'all_members' => '/members',
664 'user_profile' => '/u/:username/',
665 'user_communities' => '/u/:username/spaces',
666 'update_profile' => '/u/:username/update',
667 'discussions' => '/discussions',
668 'create_topic' => '/discussions/create-topic',
669 'topic' => '/discussions/topic/:slug',
670 'notifications' => '/notifications',
671 'bookmarks' => '/bookmarks',
672 'courses' => '/courses',
673 'view_course' => '/courses/view/:course_id/lessons',
674 'view_lesson' => '/courses/view/:course_id/lessons/:lesson_slug/view',
675 'manage_courses' => '/admin/manage-courses',
676 'edit_lessons' => '/admin/manage-courses/edit/:course_id/lessons',
677 'course_students' => '/admin/manage-courses/edit/:course_id/students',
678 'course_overview' => '/admin/manage-courses/edit/:course_id/overview',
679 'manage_leaderboard' => '/admin/manage-leaderboard',
680 ];
681 }
682
683 /**
684 * Get the URL for a JavaScript route.
685 *
686 * @param array $route The route information.
687 * @return string The URL for the route.
688 */
689 public static function getUrlByJsRoute($route = [])
690 {
691 $routePaths = self::getRoutePaths();
692
693 $routeName = Arr::get($route, 'name', '');
694
695 if (!$routeName || !isset($routePaths[$routeName])) {
696 return self::baseUrl();
697 }
698
699 $path = $routePaths[$routeName];
700
701 $params = (array)Arr::get($route, 'params', []);
702
703 if (!$params) {
704 return self::baseUrl($path);
705 }
706
707 $replaces = [];
708
709 foreach ($params as $paramKey => $paramValue) {
710 $replaces[':' . $paramKey] = $paramValue;
711 }
712
713 $path = str_replace(array_keys($replaces), array_values($replaces), $path);
714
715 return self::baseUrl($path);
716
717 }
718
719 /**
720 * Get the route name from a request path.
721 *
722 * @param string $path The request path.
723 * @return string|false The route name or false if not found.
724 */
725 public static function getRouteNameByRequestPath($path)
726 {
727 $path = '//' . $path;
728
729 if (strpos($path, '/u/')) {
730 return 'user_profile';
731 }
732
733 if (strpos($path, '/post/')) {
734 return 'feed_view';
735 }
736
737 if (strpos($path, '/lessons/')) {
738 return 'lesson_view';
739 }
740
741 if (strpos($path, '/course/')) {
742 return 'course_view';
743 }
744
745 if (strpos($path, '/space/') && !strpos($path, '/discover/spaces')) {
746 return 'community_view';
747 }
748
749 if (strpos($path, '/admin')) {
750 return 'admin';
751 }
752
753 return false;
754 }
755
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 /**
780 * Get a human-readable excerpt from content.
781 *
782 * @param string $content The content to extract from.
783 * @param int $length The maximum length of the excerpt.
784 * @return string The human-readable excerpt.
785 */
786 public static function getHumanExcerpt($content, $length = 100)
787 {
788 if ($content) {
789 $patterns = [
790 '/^#{1,6}\s+/m' => '',
791 // Bold and Italic: remove '*' and '_' symbols
792 '/(\*\*|__)(.*?)\1/' => '$2',
793 '/(\*|_)(.*?)\1/' => '$2',
794 // Code blocks: remove triple backticks
795 '/^```\s*\w*\s*\n([\s\S]*?)\n```\s*$/m' => '$1',
796 // Inline code: remove single backticks
797 '/`([^`]+)`/' => '$1',
798 // Blockquotes: remove '>' symbol
799 '/^\s*>\s?/m' => '',
800 // Horizontal rules: replace with empty line
801 '/^\s*([-*_])\1{2,}\s*$/m' => "\n",
802 // Images: keep only the alt text (run before links)
803 '/!\[([^\]]*)\]\([^\)]+\)/' => '$1',
804 // Links: keep only the link text
805 '/\[([^\]]*)\]\([^\)]+\)/' => '$1',
806 // Strikethrough: remove '~~' symbols
807 '/~~(.*?)~~/' => '$1',
808 // Task lists: remove checkbox syntax
809 '/^\s*[-*+]\s+\[[ xX]\]\s+/m' => '',
810 ];
811
812 $content = preg_replace(array_keys($patterns), array_values($patterns), $content);
813
814 $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
815
816 // remove all tags
817 $content = wp_strip_all_tags($content);
818 // remove new lines and tabs
819 $content = str_replace(["\r", "\n", "\t"], ' ', $content);
820 // remove multiple spaces
821 $content = preg_replace('/\s+/', ' ', $content);
822
823 // trim
824 $content = trim($content);
825 }
826
827 if (!$content) {
828 return '';
829 }
830
831 if (mb_strlen($content) <= $length) {
832 return $content;
833 }
834
835 // return the first $length chars of the content with ... at the end
836 return mb_substr($content, 0, $length) . '...';
837 }
838
839 /**
840 * Check if the portal is publicly accessible.
841 *
842 * @return bool True if publicly accessible, false otherwise.
843 */
844 public static function isPublicAccessible()
845 {
846 $settings = self::generalSettings();
847 return Arr::get($settings, 'access.acess_level') == 'public';
848 }
849
850 /**
851 * Get media by provider.
852 *
853 * @param array $images The array of images.
854 * @param string $provider The provider to filter by.
855 * @return array The filtered array of images.
856 */
857 public static function getMediaByProvider($images, $provider = 'uploader')
858 {
859 if (is_array($images)) {
860 return array_filter($images, function ($image) use ($provider) {
861 if (is_array($image)) {
862 if (isset($image['provider'])) {
863 return Arr::get($image, 'provider') == $provider;
864 }
865
866 return $provider === 'uploader'; // for existing images when no provider was set.
867 }
868 });
869 }
870
871 return [];
872 }
873
874 /**
875 * Get the community menu groups.
876 *
877 * @param User|null $user The user to get menu groups for.
878 * @return array The community menu groups.
879 */
880 public static function getCommunityMenuGroups($user = null, $view = true)
881 {
882 if (!$user) {
883 $user = self::getCurrentUser();
884 }
885
886 $communityGroups = self::getAllCommunityGroups($user);
887
888 if ($communityGroups->isEmpty()) {
889 return [];
890 }
891
892 $userSpaceIds = $user ? self::getUserSpaceIds($user->ID) : [];
893 $isComModerator = $user && $user->hasCommunityModeratorAccess();
894 $isCourseCreator = $user && $user->hasCourseCreatorAccess();
895 $isSpaceModerator = $user && $user->isSpaceModerator();
896
897 $formattedGroups = [];
898 foreach ($communityGroups as $communityGroup) {
899 $validSpaces = [];
900 $spaces = $communityGroup->spaces;
901 $isShowAll = Arr::get($communityGroup->settings, 'always_show_spaces') === 'yes';
902
903 if (!$isShowAll && !$isSpaceModerator) {
904 $spaceIds = $spaces->pluck('id')->toArray();
905 $isNotMemberOfAnySpace = empty(array_intersect($spaceIds, $userSpaceIds));
906 if ($isNotMemberOfAnySpace) {
907 continue;
908 }
909 }
910
911 if ($user) {
912 BaseSpace::preloadMemberships($spaces, $user->ID);
913 }
914
915 foreach ($spaces as $space) {
916 $validSpace = $view ? self::transformSpaceToLink($space, $user) : $space;
917 if (!$validSpace) {
918 continue;
919 }
920
921 if ($user && $space->isContentSpace()) {
922 $validSpace['unread_badge'] = self::getUnreadFeedsCounts($space->id);
923 }
924
925 if ($isComModerator && $space->type != 'course') {
926 $validSpaces[] = $validSpace;
927 continue;
928 }
929
930 if ($isCourseCreator && $space->type == 'course') {
931 $validSpaces[] = $validSpace;
932 continue;
933 }
934
935 if ($space->privacy == 'public') {
936 $validSpaces[] = $validSpace;
937 continue;
938 }
939
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;
955 }
956
957 if (!$validSpaces && !$isSpaceModerator) {
958 continue;
959 }
960
961 $formattedGroups[] = [
962 'id' => $communityGroup->id,
963 'title' => $communityGroup->title,
964 'slug' => $communityGroup->slug,
965 'logo' => $communityGroup->logo,
966 'children' => $validSpaces
967 ];
968 }
969
970 return apply_filters('fluent_community/menu_groups_for_user', $formattedGroups, $user);
971 }
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
1005 /**
1006 * Transform a space to a link array.
1007 *
1008 * @param Space $space The space to transform.
1009 * @param User|null $user The user to check permissions for.
1010 * @return array|null The transformed space link array.
1011 */
1012 private static function transformSpaceToLink($space, $user = null)
1013 {
1014 $isCustomLink = $space->type == 'sidebar_link';
1015 if ($isCustomLink && !self::canViewSideLinkLink($space, $user)) {
1016 return null;
1017 }
1018
1019 $logo = $space->logo;
1020 $title = $space->title;
1021
1022 if ($space->status == 'draft') {
1023 $title = $title . ' ' . __('(Draft)', 'fluent-community');
1024 }
1025
1026 return [
1027 'title' => $title,
1028 'icon_image' => $logo,
1029 'shape_svg' => !$logo ? Arr::get($space->settings, 'shape_svg', '') : '',
1030 'emoji' => !$logo ? Arr::get($space->settings, 'emoji', '') : '',
1031 'permalink' => $space->getPermalink(),
1032 'is_custom' => $isCustomLink ? 'yes' : 'no',
1033 'new_tab' => ($isCustomLink && Arr::get($space, 'settings.new_tab', 'no') === 'yes') ? 'yes' : 'no',
1034 'link_classes' => 'space_menu_item route_url fcom_space_id_' . $space->id . ' fcom_space_' . $space->slug
1035 ];
1036 }
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
1068 public static function isAlreadyOnboarded()
1069 {
1070 $communitySettings = get_option('fluent_community_settings', []);
1071
1072 return !empty($communitySettings);
1073 }
1074
1075 /**
1076 * Get all community groups.
1077 *
1078 * @param User $user The user to get groups for.
1079 * @param bool $willCreate Whether to create a default group if none exist.
1080 * @return \FluentCommunity\Framework\Support\Collection Collection of Groups
1081 */
1082 public static function getAllCommunityGroups($user, $willCreate = true)
1083 {
1084 $isModerator = $user && $user->isCommunityModerator();
1085
1086 $communityGroups = SpaceGroup::query()->orderBy('serial', 'ASC')
1087 ->with([
1088 'spaces' => function ($query) use ($isModerator) {
1089 if ($isModerator) {
1090 $query->orderBy('serial', 'ASC');
1091 } else {
1092 $query->where('status', 'published')
1093 ->orderBy('serial', 'ASC');
1094 }
1095 }
1096 ])
1097 ->get();
1098
1099 if ($communityGroups->isEmpty() && $willCreate) {
1100 $createdSpace = (new ActivationHandler(App::make()))->maybeCreateDefaultSpaceGroup();
1101
1102 if ($createdSpace) {
1103 Space::where('type', 'community')->update([
1104 'parent_id' => $createdSpace->id
1105 ]);
1106 }
1107
1108 return self::getAllCommunityGroups($user, false);
1109 }
1110
1111 return $communityGroups;
1112 }
1113
1114 /**
1115 * Check if a feature is enabled.
1116 *
1117 * @param string $feature The feature to check.
1118 * @return bool True if the feature is enabled, false otherwise.
1119 */
1120 public static function isFeatureEnabled($feature)
1121 {
1122 $features = Utility::getFeaturesConfig();
1123
1124 return isset($features[$feature]) && $features[$feature] === 'yes';
1125 }
1126
1127 /**
1128 * Get the menu items group.
1129 *
1130 * @param string $context The context for getting menu items.
1131 * @return array The menu items group.
1132 */
1133 public static function getMenuItemsGroup($context = 'view')
1134 {
1135 static $menuGroups;
1136
1137 if ($menuGroups && $context === 'view') {
1138 return $menuGroups;
1139 }
1140
1141 $menuGroups = (array) Utility::getOption('fluent_community_menu_groups', []);
1142
1143 $membersPageStatus = Utility::canViewMembersPage() ? 'yes' : 'no';
1144
1145 $leaderboardPageVisibility = (Utility::canViewLeaderboardMembers() || is_user_logged_in()) ? 'yes' : 'no';
1146
1147 $defaultMainMenuItems = [
1148 'all_feeds' => [
1149 'slug' => 'all_feeds',
1150 'title' => __('Feed', 'fluent-community'),
1151 'is_system' => 'yes',
1152 'is_locked' => 'yes',
1153 'enabled' => 'yes',
1154 'permalink' => self::baseUrl('/'),
1155 'link_classes' => 'fcom_dashboard route_url',
1156 '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>'
1157 ],
1158 'spaces' => [
1159 'slug' => 'spaces',
1160 'title' => __('Spaces', 'fluent-community'),
1161 'is_system' => 'yes',
1162 'is_locked' => 'yes',
1163 'enabled' => 'yes',
1164 'permalink' => self::baseUrl('discover/spaces'),
1165 'link_classes' => 'fcom_spaces route_url',
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>'
1167 ],
1168 'all_courses' => [
1169 'slug' => 'all_courses',
1170 'title' => __('Courses', 'fluent-community'),
1171 'link_classes' => 'fcom_courses route_url',
1172 'is_system' => 'yes',
1173 'is_locked' => 'yes',
1174 'enabled' => 'yes',
1175 'is_unavailable' => self::isFeatureEnabled('course_module') ? 'no' : 'yes',
1176 'permalink' => self::baseUrl('courses'),
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>'
1178 ],
1179 'all_members' => [
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>',
1189 ],
1190 'leaderboard' => [
1191 'slug' => 'leaderboard',
1192 'is_system' => 'yes',
1193 'is_locked' => 'yes',
1194 'enabled' => $leaderboardPageVisibility,
1195 'is_unavailable' => self::isFeatureEnabled('leader_board_module') && $leaderboardPageVisibility == 'yes' ? 'no' : 'yes',
1196 'title' => __('Leaderboard', 'fluent-community'),
1197 'link_classes' => 'fcom_leaderboards route_url',
1198 'permalink' => self::baseUrl('leaderboards'),
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>'
1200 ]
1201 ];
1202
1203 $mainItems = Arr::get($menuGroups, 'mainMenuItems', []);
1204
1205 if ($mainItems && is_array($mainItems)) {
1206 if (isset($mainItems['all_communities'])) {
1207 $mainItems['spaces'] = $defaultMainMenuItems['spaces'];
1208 unset($mainItems['all_communities']);
1209 }
1210
1211 foreach ($mainItems as $index => &$item) {
1212 if (empty($item['slug'])) {
1213 unset($mainItems[$index]);
1214 continue;
1215 }
1216 $defaultItem = Arr::get($defaultMainMenuItems, $item['slug'], []);
1217 if ($defaultItem) {
1218 $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug'];
1219 foreach ($preservedKeys as $key) {
1220 if (isset($defaultItem[$key])) {
1221 $item[$key] = Arr::get($defaultItem, $key);
1222 }
1223 }
1224 if (Arr::get($defaultItem, 'is_system') === 'yes') {
1225 $item['permalink'] = $defaultItem['permalink'];
1226 $item['link_classes'] = $defaultItem['link_classes'];
1227 if (empty($item['shape_svg'])) {
1228 $item['shape_svg'] = $defaultItem['shape_svg'];
1229 }
1230 }
1231 }
1232 }
1233 } else {
1234 $mainItems = $defaultMainMenuItems;
1235 }
1236
1237 $defaultProfileDropDownItems = [
1238 'my_spaces' => [
1239 'slug' => 'my_spaces',
1240 'title' => __('My Spaces', 'fluent-community'),
1241 'is_system' => 'yes',
1242 'is_locked' => 'yes',
1243 'enabled' => 'yes',
1244 'permalink' => '#{{user_url}}/spaces',
1245 '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>'
1246 ],
1247 'bookmarks' => [
1248 'slug' => 'bookmarks',
1249 'title' => __('Bookmarks', 'fluent-community'),
1250 'is_system' => 'yes',
1251 'is_locked' => 'yes',
1252 'enabled' => 'yes',
1253 'permalink' => self::baseUrl('bookmarks'),
1254 '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>'
1255 ],
1256 'logout' => [
1257 'slug' => 'logout',
1258 'title' => __('Logout', 'fluent-community'),
1259 'is_system' => 'yes',
1260 'is_locked' => 'yes',
1261 'enabled' => 'yes',
1262 'permalink' => '#{{logout_url}}',
1263 '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>'
1264 ]
1265 ];
1266
1267 $profileDropDownItems = Arr::get($menuGroups, 'profileDropdownItems', []);
1268
1269 if ($profileDropDownItems && is_array($profileDropDownItems)) {
1270 unset($profileDropDownItems['profile']);
1271 foreach ($profileDropDownItems as $index => &$item) {
1272 if (empty($item['slug'])) {
1273 unset($profileDropDownItems[$index]);
1274 continue;
1275 }
1276 $defaultItem = Arr::get($defaultProfileDropDownItems, $item['slug'], []);
1277 if ($defaultItem) {
1278 $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug'];
1279 foreach ($preservedKeys as $key) {
1280 if (isset($defaultItem[$key])) {
1281 $item[$key] = Arr::get($defaultItem, $key);
1282 }
1283 }
1284 if (Arr::get($defaultItem, 'is_system') === 'yes') {
1285 $item['permalink'] = $defaultItem['permalink'];
1286 if (empty($item['shape_svg'])) {
1287 $item['shape_svg'] = $defaultItem['shape_svg'];
1288 }
1289 }
1290 }
1291 }
1292 } else {
1293 $profileDropDownItems = $defaultProfileDropDownItems;
1294 }
1295
1296 $beforeCommunityMenuItems = Arr::get($menuGroups, 'beforeCommunityMenuItems', []);
1297 $afterCommunityMenuGroups = Arr::get($menuGroups, 'afterCommunityLinkGroups', []);
1298
1299 if (!is_array($beforeCommunityMenuItems)) {
1300 $beforeCommunityMenuItems = [];
1301 }
1302
1303 if (!is_array($afterCommunityMenuGroups)) {
1304 $afterCommunityMenuGroups = [];
1305 }
1306
1307 if ($context == 'view') {
1308
1309 $currentUser = self::getCurrentUser();
1310
1311 $mainItems = array_filter($mainItems, function ($item) use ($currentUser) {
1312 return self::isLinkAccessible($item, $currentUser);
1313 });
1314
1315 $profileDropDownItems = array_filter($profileDropDownItems, function ($item) use ($currentUser) {
1316 return self::isLinkAccessible($item, $currentUser);
1317 });
1318
1319 $beforeCommunityMenuItems = array_filter($beforeCommunityMenuItems, function ($item) use ($currentUser) {
1320 return self::isLinkAccessible($item, $currentUser);
1321 });
1322
1323 $validGroups = [];
1324 foreach ($afterCommunityMenuGroups as $group) {
1325 if (empty($group['items']) || !is_array($group['items'])) {
1326 continue;
1327 }
1328
1329 $group['items'] = array_filter($group['items'], function ($item) use ($currentUser) {
1330 return self::isLinkAccessible($item, $currentUser);
1331 });
1332
1333 if ($group['items']) {
1334 $validGroups[] = $group;
1335 }
1336 }
1337
1338 $afterCommunityMenuGroups = $validGroups;
1339 }
1340
1341 $menuGroups['mainMenuItems'] = $mainItems;
1342 $menuGroups['profileDropdownItems'] = $profileDropDownItems;
1343 $menuGroups['beforeCommunityMenuItems'] = $beforeCommunityMenuItems;
1344 $menuGroups['afterCommunityLinkGroups'] = $afterCommunityMenuGroups;
1345
1346 if ($context == 'view') {
1347 $menuGroups = apply_filters('fluent_community/menu_groups', $menuGroups);
1348 }
1349
1350 return $menuGroups;
1351 }
1352
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 /**
1418 * Get the meta data for a space.
1419 *
1420 * @param int $spaceId The ID of the space.
1421 * @param string $key The meta key.
1422 * @param mixed $default The default value if the meta key is not found.
1423 * @return mixed The meta value or the default value if not found.
1424 */
1425 public static function getSpaceMeta($spaceId, $key, $default = null)
1426 {
1427 $meta = Meta::where('object_type', 'space')
1428 ->where('meta_key', $key)
1429 ->where('object_id', $spaceId)
1430 ->first();
1431
1432 if (!$meta) {
1433 return $default;
1434 }
1435
1436 return $meta->value;
1437 }
1438
1439 /**
1440 * Update the meta data for a space.
1441 *
1442 * @param int $spaceId The ID of the space.
1443 * @param string $key The meta key.
1444 * @param mixed $value The meta value.
1445 * @return Meta The updated meta object.
1446 */
1447 public static function updateSpaceMeta($spaceId, $key, $value)
1448 {
1449 $meta = Meta::where('object_type', 'space')
1450 ->where('meta_key', $key)
1451 ->where('object_id', $spaceId)
1452 ->first();
1453
1454 if ($meta) {
1455 $meta->value = $value;
1456 $meta->save();
1457 } else {
1458 $meta = Meta::create([
1459 'object_type' => 'space',
1460 'object_id' => $spaceId,
1461 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1462 'value' => $value
1463 ]);
1464 }
1465
1466 return $meta;
1467 }
1468
1469
1470 /**
1471 * Encrypt or decrypt a value.
1472 *
1473 * @param string $value The value to encrypt or decrypt.
1474 * @param string $type The type of operation ('e' for encrypt, 'd' for decrypt).
1475 * @return string|false The encrypted or decrypted value or false if an error occurs.
1476 */
1477 public static function encryptDecrypt($value, $type = 'e')
1478 {
1479 if (!$value) {
1480 return $value;
1481 }
1482
1483 if (!extension_loaded('openssl')) {
1484 return $value;
1485 }
1486
1487 if (defined('FLUENT_COM_ENCRYPT_SALT')) {
1488 $salt = FLUENT_COM_ENCRYPT_SALT;
1489 } else {
1490 $salt = (defined('LOGGED_IN_SALT') && '' !== LOGGED_IN_SALT) ? LOGGED_IN_SALT : 'this-is-a-fallback-salt-but-not-secure';
1491 }
1492
1493 if (defined('FLUENT_COM__ENCRYPT_KEY')) {
1494 $key = FLUENT_COM__ENCRYPT_KEY;
1495 } else {
1496 $key = (defined('LOGGED_IN_KEY') && '' !== LOGGED_IN_KEY) ? LOGGED_IN_KEY : 'this-is-a-fallback-key-but-not-secure';
1497 }
1498
1499 if ($type == 'e') {
1500 $method = 'aes-256-ctr';
1501 $ivlen = openssl_cipher_iv_length($method);
1502 $iv = openssl_random_pseudo_bytes($ivlen);
1503
1504 $raw_value = openssl_encrypt($value . $salt, $method, $key, 0, $iv);
1505 if (!$raw_value) {
1506 return false;
1507 }
1508
1509 return base64_encode($iv . $raw_value); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
1510 }
1511
1512 $raw_value = base64_decode($value, true); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
1513
1514 $method = 'aes-256-ctr';
1515 $ivlen = openssl_cipher_iv_length($method);
1516 $iv = substr($raw_value, 0, $ivlen);
1517
1518 $raw_value = substr($raw_value, $ivlen);
1519
1520 $newValue = openssl_decrypt($raw_value, $method, $key, 0, $iv);
1521 if (!$newValue || substr($newValue, -strlen($salt)) !== $salt) {
1522 return false;
1523 }
1524
1525 return substr($newValue, 0, -strlen($salt));
1526 }
1527
1528
1529 /**
1530 * Get the welcome banner configuration.
1531 *
1532 * @return array The welcome banner configuration.
1533 */
1534 public static function getWelcomeBannerSettings()
1535 {
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' => ''
1551 ],
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;
1586 }
1587
1588 public static function getWelcomeBanner($view = 'login')
1589 {
1590 $settings = self::getWelcomeBannerSettings();
1591 $welcomeBanner = Arr::get($settings, $view, []);
1592 if (Arr::get($welcomeBanner, 'enabled') != 'yes') {
1593 return null;
1594 }
1595
1596 unset($welcomeBanner['description']);
1597
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);
1603 }
1604
1605 public static function getEnabledFeedLinks()
1606 {
1607 $links = array_filter(self::getFeedLinks(), function ($item) {
1608 return self::isLinkAccessible($item);
1609 });
1610
1611 return array_values($links);
1612 }
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
1667 public static function getFeedLinks()
1668 {
1669 return Utility::getOption('feed_links', []);
1670 }
1671
1672 public static function updateFeedLinks($links)
1673 {
1674 Utility::updateOption('feed_links', $links);
1675 }
1676
1677 /**
1678 * Get the full name of a WordPress user.
1679 *
1680 * @param int|null $id The ID of the user.
1681 * @return string The full name of the user.
1682 */
1683 public static function getWpUserFullName($id = null)
1684 {
1685 $id = $id ?: get_current_user_id();
1686 $user = get_user_by('ID', $id);
1687
1688 $fullName = $user->display_name;
1689 if ($user->first_name && $user->last_name) {
1690 $fullName = $user->first_name . ' ' . $user->last_name;
1691 }
1692
1693 return $fullName;
1694 }
1695
1696 /**
1697 * Get the onboarding settings.
1698 *
1699 * @return array The onboarding settings.
1700 */
1701 public static function getOnboardingSettings()
1702 {
1703 $default = [
1704 'is_onboarding_enabled' => 'no',
1705 'registration_page_url' => '',
1706 ];
1707
1708 $settings = Utility::getOption('onboarding_settings', $default);
1709
1710 return wp_parse_args($settings, $default);
1711 }
1712
1713 /**
1714 * Get all WordPress published pages.
1715 *
1716 * @return array An array of page data.
1717 */
1718 public static function getAllWpPublishedPage()
1719 {
1720 $posts = get_posts(array(
1721 'post_status' => 'publish',
1722 'numberposts' => -1,
1723 'post_type' => 'any',
1724 ));
1725
1726 return array_map(function ($post) {
1727 return array(
1728 'id' => $post->ID,
1729 'permalink' => get_permalink($post),
1730 'title' => get_the_title($post),
1731 );
1732 }, $posts);
1733 }
1734
1735 /**
1736 * Add a user to a space.
1737 *
1738 * @param BaseSpace|int $space space to add the user to.
1739 * @param int $userId The ID of the user to add.
1740 * @param string $role The role of the user in the space.
1741 * @param string $by The source of the action.
1742 * @return bool True if the user was added, false otherwise.
1743 */
1744 public static function addToSpace($space, $userId, $role = 'member', $by = 'self', $skipSync = false)
1745 {
1746 if (is_numeric($space)) {
1747 $space = BaseSpace::onlyMain()->find($space);
1748 }
1749
1750 if (!$space || !$space instanceof BaseSpace) {
1751 return false;
1752 }
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
1768 $exist = SpaceUserPivot::where('user_id', $userId)
1769 ->where('space_id', $space->id)
1770 ->first();
1771
1772 if ($exist) {
1773 if ($exist->status != 'active') {
1774 $exist->status = 'active';
1775
1776 if (!in_array($exist->role, ['admin', 'moderator'])) {
1777 $exist->role = $role;
1778 }
1779
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
1788 return true;
1789 }
1790
1791 return false;
1792 }
1793
1794 $created = SpaceUserPivot::create([
1795 'space_id' => $space->id,
1796 'role' => $role,
1797 'user_id' => $userId
1798 ]);
1799
1800 if ($space->type == 'course') {
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);
1805 } else {
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);
1810 }
1811 return true;
1812 }
1813
1814 /**
1815 * Remove a user from a space if exist.
1816 *
1817 * @param int $userId The ID of the user.
1818 * @param int $spaceId The ID of the space.
1819 * @param string $by The source of the action. self | by_admin
1820 * @return bool True if the user is in the space, false otherwise.
1821 */
1822 public static function removeFromSpace($space, $userId, $by = 'self')
1823 {
1824 $user = User::find($userId);
1825 if (!$user) {
1826 return false;
1827 }
1828
1829 if (is_numeric($space)) {
1830 $space = BaseSpace::query()->onlyMain()->find($space);
1831 }
1832
1833 if (!$space || !$space instanceof BaseSpace) {
1834 return false;
1835 }
1836
1837
1838 if (!self::isUserInSpace($userId, $space->id)) {
1839 return false;
1840 }
1841
1842 SpaceUserPivot::where('space_id', $space->id)
1843 ->where('user_id', $userId)
1844 ->delete();
1845
1846 $user->cacheAccessSpaces();
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
1862 return true;
1863 }
1864
1865 /**
1866 * Render a link with icon.
1867 *
1868 * @param array $link The link data.
1869 * @param string $linkClass Additional classes for the link.
1870 * @param string $fallback The fallback content if no icon is found.
1871 * @param bool $renderIcon Whether to render the icon or not.
1872 */
1873 public static function renderLink($link, $linkClass = '', $fallback = '<span class="fcom_no_avatar"></span>', $renderIcon = true)
1874 {
1875 if (!$link || empty($link['permalink'])) {
1876 return;
1877 }
1878
1879 $isCustom = Arr::get($link, 'is_custom') == 'yes';
1880
1881 $linkAtts = array_filter([
1882 'class' => trim($linkClass . ' ' . Arr::get($link, 'link_classes')) . ' fcom_compt_link' . ($isCustom ? ' fcom_custom_link' : ''),
1883 'target' => Arr::get($link, 'new_tab') === 'yes' ? '_blank' : '',
1884 'rel' => Arr::get($link, 'new_tab') === 'yes' ? 'noopener noreferrer' : '',
1885 ]);
1886
1887 ?>
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) . '"';
1891 } ?>>
1892 <?php $renderIcon && self::printLinkIcon($link, $fallback); ?>
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')) : ?>
1896 <span class="fcom_space_lock">
1897 <i class="el-icon">
1898 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
1899 <path fill="currentColor"
1900 d="M224 448a32 32 0 0 0-32 32v384a32 32 0 0 0 32 32h576a32 32 0 0 0 32-32V480a32 32 0 0 0-32-32zm0-64h576a96 96 0 0 1 96 96v384a96 96 0 0 1-96 96H224a96 96 0 0 1-96-96V480a96 96 0 0 1 96-96"></path>
1901 <path fill="currentColor"
1902 d="M512 544a32 32 0 0 1 32 32v192a32 32 0 1 1-64 0V576a32 32 0 0 1 32-32m192-160v-64a192 192 0 1 0-384 0v64zM512 64a256 256 0 0 1 256 256v128H256V320A256 256 0 0 1 512 64"></path>
1903 </svg>
1904 </i>
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>
1910 <?php endif; ?>
1911 </a>
1912 <?php
1913 }
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
1947 /**
1948 * Print a link icon.
1949 *
1950 * @param array $link The link data.
1951 * @param string $fallback The fallback content if no icon is found.
1952 */
1953 public static function printLinkIcon($link, $fallback = '<span class="fcom_no_avatar"></span>')
1954 {
1955 ?>
1956 <?php if ($img = Arr::get($link, 'icon_image')): ?>
1957 <div class="community_avatar">
1958 <img alt="" src="<?php echo esc_url($img); ?>"/>
1959 </div>
1960 <?php elseif ($emoji = Arr::get($link, 'emoji')): ?>
1961 <div class="community_avatar">
1962 <span class="fcom_emoji"><?php echo esc_html($emoji); ?></span>
1963 </div>
1964 <?php elseif ($svg = Arr::get($link, 'shape_svg')): ?>
1965 <div class="community_avatar">
1966 <span class="fcom_shape"><i
1967 class="el-icon"><?php echo \FluentCommunity\App\Services\CustomSanitizer::sanitizeSvg($svg); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></i></span>
1968 </div>
1969 <?php else:
1970 echo '<div class="community_avatar">' . wp_kses_post($fallback) . '</div>';
1971 endif;
1972 }
1973
1974 /**
1975 * Get the IP address of the user.
1976 *
1977 * @param bool $anonymize Whether to anonymize the IP address.
1978 * @return string The IP address.
1979 */
1980 public static function getIp($anonymize = false)
1981 {
1982 static $ipAddress;
1983
1984 if ($ipAddress) {
1985 return $ipAddress;
1986 }
1987
1988 if (empty($_SERVER['REMOTE_ADDR'])) {
1989 // It's a local cli request
1990 return '127.0.0.1';
1991 }
1992
1993 $ipAddress = '';
1994 if (isset($_SERVER["HTTP_CF_CONNECTING_IP"])) {
1995 $ipAddress = sanitize_text_field(wp_unslash($_SERVER["REMOTE_ADDR"]));
1996 //If it's a valid Cloudflare request
1997 if (self::isCfIp($ipAddress)) {
1998 //Use the CF-Connecting-IP header.
1999 $ipAddress = sanitize_text_field(wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP']));
2000 }
2001 } else if ($_SERVER['REMOTE_ADDR'] == '127.0.0.1') {
2002 // most probably it's local reverse proxy
2003 if (isset($_SERVER["HTTP_CLIENT_IP"])) {
2004 $ipAddress = sanitize_text_field(wp_unslash($_SERVER["HTTP_CLIENT_IP"]));
2005 } else if (isset($_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 }
2010 }
2011 }
2012
2013 if (!$ipAddress) {
2014 $ipAddress = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR']));
2015 }
2016
2017 $ipAddress = preg_replace('/^(\d+\.\d+\.\d+\.\d+):\d+$/', '\1', $ipAddress);
2018
2019 $ipAddress = apply_filters('fluent_auth/user_ip', $ipAddress);
2020
2021 if ($anonymize) {
2022 return wp_privacy_anonymize_ip($ipAddress);
2023 }
2024
2025 return $ipAddress;
2026 }
2027
2028 /**
2029 * Check if the IP address is from Cloudflare.
2030 *
2031 * @param string $ip The IP address to check.
2032 * @return bool True if the IP is from Cloudflare, false otherwise.
2033 */
2034 public static function isCfIp($ip = '')
2035 {
2036 if (!$ip && isset($_SERVER["REMOTE_ADDR"])) {
2037 $ip = sanitize_text_field(wp_unslash($_SERVER["REMOTE_ADDR"]));
2038 }
2039
2040 if (!$ip) {
2041 return false;
2042 }
2043
2044 $cloudflareIPRanges = array(
2045 '173.245.48.0/20',
2046 '103.21.244.0/22',
2047 '103.22.200.0/22',
2048 '103.31.4.0/22',
2049 '141.101.64.0/18',
2050 '108.162.192.0/18',
2051 '190.93.240.0/20',
2052 '188.114.96.0/20',
2053 '197.234.240.0/22',
2054 '198.41.128.0/17',
2055 '162.158.0.0/15',
2056 '104.16.0.0/13',
2057 '104.24.0.0/14',
2058 '172.64.0.0/13',
2059 '131.0.72.0/22',
2060 );
2061
2062 //Make sure that the request came via Cloudflare.
2063 foreach ($cloudflareIPRanges as $range) {
2064 //Use the ip_in_range function from Joomla.
2065 if (self::ipInRange($ip, $range)) {
2066 //IP is valid. Belongs to Cloudflare.
2067 return true;
2068 }
2069 }
2070
2071 return false;
2072 }
2073
2074 /**
2075 * Check if the IP address is in the given range.
2076 *
2077 * @param string $ip The IP address to check.
2078 * @param string $range The range to check against.
2079 * @return bool True if the IP is in the range, false otherwise.
2080 */
2081 private static function ipInRange($ip, $range)
2082 {
2083 if (strpos($range, '/') !== false) {
2084 // $range is in IP/NETMASK format
2085 list($range, $netmask) = explode('/', $range, 2);
2086 if (strpos($netmask, '.') !== false) {
2087 // $netmask is a 255.255.0.0 format
2088 $netmask = str_replace('*', '0', $netmask);
2089 $netmask_dec = ip2long($netmask);
2090 return ((ip2long($ip) & $netmask_dec) == (ip2long($range) & $netmask_dec));
2091 } else {
2092 // $netmask is a CIDR size block
2093 // fix the range argument
2094 $x = explode('.', $range);
2095 while (count($x) < 4) $x[] = '0';
2096 list($a, $b, $c, $d) = $x;
2097 $range = sprintf("%u.%u.%u.%u", empty($a) ? '0' : $a, empty($b) ? '0' : $b, empty($c) ? '0' : $c, empty($d) ? '0' : $d);
2098 $range_dec = ip2long($range);
2099 $ip_dec = ip2long($ip);
2100
2101 # Strategy 1 - Create the netmask with 'netmask' 1s and then fill it to 32 with 0s
2102 #$netmask_dec = bindec(str_pad('', $netmask, '1') . str_pad('', 32-$netmask, '0'));
2103
2104 # Strategy 2 - Use math to create it
2105 $wildcard_dec = pow(2, (32 - $netmask)) - 1;
2106 $netmask_dec = ~$wildcard_dec;
2107
2108 return (($ip_dec & $netmask_dec) == ($range_dec & $netmask_dec));
2109 }
2110 } else {
2111 // range might be 255.255.*.* or 1.2.3.0-1.2.3.255
2112 if (strpos($range, '*') !== false) { // a.b.*.* format
2113 // Just convert to A-B format by setting * to 0 for A and 255 for B
2114 $lower = str_replace('*', '0', $range);
2115 $upper = str_replace('*', '255', $range);
2116 $range = "$lower-$upper";
2117 }
2118
2119 if (strpos($range, '-') !== false) { // A-B format
2120 list($lower, $upper) = explode('-', $range, 2);
2121 $lower_dec = (float)sprintf("%u", ip2long($lower));
2122 $upper_dec = (float)sprintf("%u", ip2long($upper));
2123 $ip_dec = (float)sprintf("%u", ip2long($ip));
2124 return (($ip_dec >= $lower_dec) && ($ip_dec <= $upper_dec));
2125 }
2126 return false;
2127 }
2128 }
2129
2130 public static function getPortalRequestPath($requestUri)
2131 {
2132 $portalSlug = self::getPortalSlug();
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
2140 if ($portalSlug == $requestUri) {
2141 return 'portal_home';
2142 }
2143
2144 if (!$requestUri) {
2145 return false;
2146 }
2147
2148 if ($portalSlug) {
2149 // remove the portal slug from the request uri. Don't use str_replace as it will replace all occurrences
2150 $requestUri = substr($requestUri, strlen($portalSlug));
2151 }
2152
2153 $parts = explode('/', $requestUri);
2154 $start = $parts[0];
2155
2156 if (!$portalSlug && $start == 'fcom_route') {
2157 return $start;
2158 }
2159
2160 $routeStats = self::portalRoutePaths();
2161
2162 if (in_array($start, $routeStats)) {
2163 return $requestUri;
2164 }
2165
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);
2572 }
2573 }
2574