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

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

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