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

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

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