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

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

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