PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.8.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.8.0
2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 1.1.0 All 77 releases
fluent-community / app / Services / Helper.php

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

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