| @@ -5,8 +5,10 @@ | ||
| 5 | 5 | use FluentCommunity\App\App; |
| 6 | 6 | use FluentCommunity\App\Functions\Utility; |
| 7 | 7 | use FluentCommunity\App\Hooks\Handlers\ActivationHandler; |
| 8 | 8 | use FluentCommunity\App\Models\BaseSpace; |
| 9 | +use FluentCommunity\App\Models\Contact; | |
| 10 | +use FluentCommunity\App\Models\Feed; | |
| 9 | 11 | use FluentCommunity\App\Models\Space; |
| 10 | 12 | use FluentCommunity\App\Models\Media; |
| 11 | 13 | use FluentCommunity\App\Models\Meta; |
| 12 | 14 | use FluentCommunity\App\Models\SpaceUserPivot; |
| @@ -13,8 +15,9 @@ | ||
| 13 | 15 | use FluentCommunity\App\Models\User; |
| 14 | 16 | use FluentCommunity\App\Models\XProfile; |
| 15 | 17 | use FluentCommunity\Framework\Support\Arr; |
| 16 | 18 | use FluentCommunity\App\Models\SpaceGroup; |
| 19 | +use FluentCommunity\Modules\Course\Model\Course; | |
| 17 | 20 | |
| 18 | 21 | /** |
| 19 | 22 | * Helper class for various utility functions. |
| 20 | 23 | */ |
| @@ -26,30 +29,95 @@ | ||
| 26 | 29 | return apply_filters('fluent_community/is_rtl', is_rtl()); |
| 27 | 30 | } |
| 28 | 31 | |
| 29 | 32 | /** |
| 33 | + * Run a callback inside a database transaction. | |
| 34 | + * | |
| 35 | + * @param callable $callback | |
| 36 | + * @return mixed | |
| 37 | + * @throws \Exception | |
| 38 | + */ | |
| 39 | + public static function dbTransaction($callback) | |
| 40 | + { | |
| 41 | + $db = App::make('db'); | |
| 42 | + $db->beginTransaction(); | |
| 43 | + | |
| 44 | + try { | |
| 45 | + $result = $callback(); | |
| 46 | + $db->commit(); | |
| 47 | + return $result; | |
| 48 | + } catch (\Exception $e) { | |
| 49 | + $db->rollBack(); | |
| 50 | + throw $e; | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + /** | |
| 55 | + * Check if POST content length exceeds PHP limits | |
| 56 | + * | |
| 57 | + * @return array|false Error array if limit exceeded, false otherwise | |
| 58 | + */ | |
| 59 | + public static function checkUploadSizeError() | |
| 60 | + { | |
| 61 | + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Server variable check | |
| 62 | + $contentLength = isset($_SERVER['CONTENT_LENGTH']) ? (int) $_SERVER['CONTENT_LENGTH'] : 0; | |
| 63 | + $postMaxSize = wp_convert_hr_to_bytes(ini_get('post_max_size')); | |
| 64 | + | |
| 65 | + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only existence check on superglobals, no state mutation | |
| 66 | + if ($contentLength > 0 && ($contentLength > $postMaxSize || (empty($_FILES) && empty($_POST)))) { | |
| 67 | + return [ | |
| 68 | + 'message' => sprintf( | |
| 69 | + /* translators: %s: max size */ | |
| 70 | + __('Upload failed: File exceeds server limit (%s). Please upload a smaller file.', 'fluent-community'), | |
| 71 | + size_format($postMaxSize) | |
| 72 | + ) | |
| 73 | + ]; | |
| 74 | + } | |
| 75 | + | |
| 76 | + return false; | |
| 77 | + } | |
| 78 | + | |
| 79 | + /** | |
| 30 | 80 | * Get the portal slug. |
| 31 | 81 | * |
| 32 | 82 | * @return string The portal slug. |
| 33 | 83 | */ |
| 84 | + /** | |
| 85 | + * Get the portal slug. | |
| 86 | + * | |
| 87 | + * @return string The portal slug. | |
| 88 | + */ | |
| 34 | 89 | public static function getPortalSlug($forRoute = false) |
| 35 | 90 | { |
| 36 | - $settings = self::generalSettings(); | |
| 37 | - $slug = apply_filters('fluent_community/portal_slug', $settings['slug']); | |
| 91 | + $settings = get_option('fluent_community_settings', []); | |
| 92 | + if (isset($settings['slug'])) { | |
| 93 | + $slug = $settings['slug']; | |
| 94 | + } else { | |
| 95 | + $slug = 'portal'; | |
| 96 | + } | |
| 38 | 97 | |
| 98 | + if (defined('FLUENT_COMMUNITY_PORTAL_SLUG')) { | |
| 99 | + $slug = \FLUENT_COMMUNITY_PORTAL_SLUG; | |
| 100 | + } | |
| 101 | + | |
| 102 | + $slug = apply_filters('fluent_community/portal_slug', $slug); | |
| 103 | + | |
| 39 | 104 | if (!$forRoute) { |
| 40 | 105 | return $slug; |
| 41 | 106 | } |
| 42 | 107 | |
| 43 | - $siteUrl = get_site_url(); | |
| 108 | + $siteUrl = get_home_url(); | |
| 44 | 109 | |
| 45 | 110 | $poralUrl = self::baseUrl('/'); |
| 46 | 111 | |
| 47 | - // get the url without path | |
| 48 | - $siteUrl = str_replace(parse_url($siteUrl, PHP_URL_PATH), '', $siteUrl); | |
| 112 | + $urlPath = wp_parse_url($siteUrl, PHP_URL_PATH); | |
| 49 | 113 | |
| 114 | + if ($urlPath) { | |
| 115 | + // get the url without path | |
| 116 | + $siteUrl = str_replace($urlPath, '', $siteUrl); | |
| 117 | + } | |
| 118 | + | |
| 50 | 119 | $slug = str_replace($siteUrl, '', $poralUrl); |
| 51 | - | |
| 52 | 120 | // remove the first and last slashes |
| 53 | 121 | return trim($slug, '/'); |
| 54 | 122 | } |
| 55 | 123 | |
| @@ -84,15 +152,82 @@ | ||
| 84 | 152 | return apply_filters('fluent_community/has_color_scheme', $status); |
| 85 | 153 | } |
| 86 | 154 | |
| 87 | 155 | /** |
| 88 | - * Check if the user is a site admin. | |
| 89 | - * | |
| 90 | - * @param int|null $userId The user ID to check. If null, checks the current user. | |
| 91 | - * @return bool True if the user is a site admin, false otherwise. | |
| 156 | + * Admin default theme mode: 'light', 'dark', or 'system'. | |
| 92 | 157 | */ |
| 93 | - public static function isSiteAdmin($userId = null) | |
| 158 | + public static function getDefaultThemeMode() | |
| 94 | 159 | { |
| 160 | + $settings = Utility::getCustomizationSettings(); | |
| 161 | + $mode = isset($settings['default_theme_mode']) ? $settings['default_theme_mode'] : 'light'; | |
| 162 | + | |
| 163 | + if (!in_array($mode, ['light', 'dark', 'system'], true)) { | |
| 164 | + $mode = 'light'; | |
| 165 | + } | |
| 166 | + | |
| 167 | + return apply_filters('fluent_community/default_theme_mode', $mode); | |
| 168 | + } | |
| 169 | + | |
| 170 | + /** | |
| 171 | + * Pre-paint script that sets the theme before first render (no flash). | |
| 172 | + * Precedence mirrors runtime: host-theme cookie → user pick → admin default. | |
| 173 | + * Not persisted. Gate on hasColorScheme(). | |
| 174 | + */ | |
| 175 | + public static function renderColorSchemePrePaintScript() | |
| 176 | + { | |
| 177 | + $defaultMode = self::getDefaultThemeMode(); | |
| 178 | + $portalVars = apply_filters('fluent_community/general_portal_vars', ['color_switch_cookie_name' => '']); | |
| 179 | + $cookieName = isset($portalVars['color_switch_cookie_name']) ? $portalVars['color_switch_cookie_name'] : ''; | |
| 180 | + ?> | |
| 181 | + <script> | |
| 182 | + (function () { | |
| 183 | + var root = document.documentElement; | |
| 184 | + var cookieName = '<?php echo esc_js($cookieName); ?>'; | |
| 185 | + var mode = null; | |
| 186 | + | |
| 187 | + // host-theme integration cookie (Blocksy/Kadence) wins when present | |
| 188 | + if (cookieName) { | |
| 189 | + var safeName = cookieName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| 190 | + var match = document.cookie.match('(?:^|; )' + safeName + '=([^;]*)'); | |
| 191 | + if (match) { | |
| 192 | + var cookieMode = decodeURIComponent(match[1]); | |
| 193 | + if (cookieMode === 'dark' || cookieMode === 'light') { | |
| 194 | + mode = cookieMode; | |
| 195 | + } | |
| 196 | + } | |
| 197 | + } | |
| 198 | + | |
| 199 | + // explicit user pick | |
| 200 | + if (!mode) { | |
| 201 | + try { | |
| 202 | + var stored = JSON.parse(localStorage.getItem('fcom_global_storage') || '{}').fcom_color_mode; | |
| 203 | + if (stored === 'dark' || stored === 'light') { | |
| 204 | + mode = stored; | |
| 205 | + } | |
| 206 | + } catch (error) {} | |
| 207 | + } | |
| 208 | + | |
| 209 | + // admin default | |
| 210 | + if (!mode) { | |
| 211 | + var defaultMode = '<?php echo esc_js($defaultMode); ?>'; | |
| 212 | + if (defaultMode === 'system') { | |
| 213 | + mode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; | |
| 214 | + } else { | |
| 215 | + mode = defaultMode; | |
| 216 | + } | |
| 217 | + } | |
| 218 | + | |
| 219 | + root.setAttribute('data-color-mode', mode === 'dark' ? 'dark' : 'light'); | |
| 220 | + if (mode === 'dark') { | |
| 221 | + root.classList.add('dark'); | |
| 222 | + } | |
| 223 | + })(); | |
| 224 | + </script> | |
| 225 | + <?php | |
| 226 | + } | |
| 227 | + | |
| 228 | + public static function isSuperAdmin($userId = null) | |
| 229 | + { | |
| 95 | 230 | $capability = apply_filters('fluent_community/super_admin_capability', 'manage_options'); |
| 96 | 231 | |
| 97 | 232 | if (!$capability) { |
| 98 | 233 | return false; |
| @@ -105,15 +240,42 @@ | ||
| 105 | 240 | if (!$userId) { |
| 106 | 241 | return false; |
| 107 | 242 | } |
| 108 | 243 | |
| 109 | - if (!$userId) { | |
| 110 | - return current_user_can($capability); | |
| 244 | + return user_can($userId, $capability); | |
| 245 | + } | |
| 246 | + | |
| 247 | + /** | |
| 248 | + * Check if the user is a site admin. | |
| 249 | + * | |
| 250 | + * @param int|null $userId The user ID to check. If null, checks the current user. | |
| 251 | + * @param \FluentCommunity\App\Models\User|null $user Resolved user model, to save a lookup. | |
| 252 | + * @return bool True if the user is a site admin, false otherwise. | |
| 253 | + */ | |
| 254 | + public static function isSiteAdmin($userId = null, $user = null) | |
| 255 | + { | |
| 256 | + if (self::isSuperAdmin($userId)) { | |
| 257 | + return true; | |
| 111 | 258 | } |
| 112 | 259 | |
| 113 | - return user_can($userId, $capability); | |
| 260 | + if (!$user) { | |
| 261 | + $user = ($userId && (int)$userId !== get_current_user_id()) | |
| 262 | + ? User::find($userId) | |
| 263 | + : self::getCurrentUser(); | |
| 264 | + } | |
| 265 | + | |
| 266 | + return $user && Arr::get($user->getPermissions(), 'community_admin'); | |
| 114 | 267 | } |
| 115 | 268 | |
| 269 | + public static function isModerator($user = null) | |
| 270 | + { | |
| 271 | + if (!$user) { | |
| 272 | + $user = self::getCurrentUser(); | |
| 273 | + } | |
| 274 | + | |
| 275 | + return $user && $user->hasCommunityModeratorAccess(); | |
| 276 | + } | |
| 277 | + | |
| 116 | 278 | /** |
| 117 | 279 | * Get the URL for an asset file. |
| 118 | 280 | * |
| 119 | 281 | * @param string $file The file name. |
| @@ -145,8 +307,15 @@ | ||
| 145 | 307 | |
| 146 | 308 | return $baseUrl . '/#/' . ltrim($path, '/'); |
| 147 | 309 | } |
| 148 | 310 | |
| 311 | + public static function getAuthUrl() | |
| 312 | + { | |
| 313 | + $settings = self::generalSettings(); | |
| 314 | + | |
| 315 | + return Arr::get($settings, 'cutsom_auth_url', ''); | |
| 316 | + } | |
| 317 | + | |
| 149 | 318 | /** |
| 150 | 319 | * Get the space IDs for a user. |
| 151 | 320 | * |
| 152 | 321 | * @param int|null $userId The user ID. If null, uses the current user. |
| @@ -163,8 +332,19 @@ | ||
| 163 | 332 | ->pluck('space_id') |
| 164 | 333 | ->toArray(); |
| 165 | 334 | } |
| 166 | 335 | |
| 336 | + public static function getUserSpaces($userId = null) | |
| 337 | + { | |
| 338 | + if (!$userId) { | |
| 339 | + $userId = get_current_user_id(); | |
| 340 | + } | |
| 341 | + | |
| 342 | + return Space::whereHas('members', function ($query) use ($userId) { | |
| 343 | + $query->where('user_id', $userId); | |
| 344 | + })->get(); | |
| 345 | + } | |
| 346 | + | |
| 167 | 347 | /** |
| 168 | 348 | * Check if a user is in a specific space. |
| 169 | 349 | * |
| 170 | 350 | * @param int $userId The user ID. |
| @@ -172,10 +352,15 @@ | ||
| 172 | 352 | * @return bool True if the user is in the space, false otherwise. |
| 173 | 353 | */ |
| 174 | 354 | public static function isUserInSpace($userId, $spaceId) |
| 175 | 355 | { |
| 356 | + if (!$userId || !$spaceId) { | |
| 357 | + return false; | |
| 358 | + } | |
| 359 | + | |
| 176 | 360 | return SpaceUserPivot::where('user_id', $userId) |
| 177 | 361 | ->where('space_id', $spaceId) |
| 362 | + ->where('status', 'active') | |
| 178 | 363 | ->exists(); |
| 179 | 364 | } |
| 180 | 365 | |
| 181 | 366 | /** |
| @@ -234,8 +419,19 @@ | ||
| 234 | 419 | |
| 235 | 420 | return Media::where('media_key', $key)->first(); |
| 236 | 421 | } |
| 237 | 422 | |
| 423 | + public static function removeMediaByUrl($url = '', $subObjectId = null) | |
| 424 | + { | |
| 425 | + if (!$url || !$subObjectId) { | |
| 426 | + return; | |
| 427 | + } | |
| 428 | + | |
| 429 | + do_action('fluent_community/remove_medias_by_url', [$url], [ | |
| 430 | + 'sub_object_id' => $subObjectId, | |
| 431 | + ]); | |
| 432 | + } | |
| 433 | + | |
| 238 | 434 | /** |
| 239 | 435 | * Get media items from multiple URLs. |
| 240 | 436 | * |
| 241 | 437 | * @param array $urls An array of URLs. |
| @@ -276,21 +472,31 @@ | ||
| 276 | 472 | 'site_title' => get_bloginfo('name'), |
| 277 | 473 | 'slug' => 'portal', |
| 278 | 474 | 'logo' => '', |
| 279 | 475 | 'white_logo' => '', |
| 476 | + 'logo_permalink_type' => 'default', | |
| 477 | + 'logo_permalink' => '', | |
| 280 | 478 | 'featured_image' => '', |
| 281 | 479 | 'access' => [ |
| 282 | - 'acess_level' => 'logged_in', // logged_in, public, role_based | |
| 480 | + 'acess_level' => 'public', // logged_in, public, role_based | |
| 283 | 481 | 'access_roles' => [] |
| 284 | 482 | ], |
| 483 | + 'auth_form_type' => 'default', | |
| 484 | + 'explicit_registration' => 'no', | |
| 285 | 485 | 'disable_global_posts' => 'yes', |
| 286 | 486 | 'auth_content' => 'Please login first to access this page', |
| 287 | 487 | 'auth_redirect' => '', |
| 288 | - 'restricted_role_content' => 'Sorry, you can not access to this page. Only authorized users can access this page.', | |
| 289 | - 'auth_url' => '' | |
| 488 | + 'restricted_role_content' => 'Sorry, you cannot access this page. Only authorized users can access this page.', | |
| 489 | + 'auth_url' => '', | |
| 490 | + 'cutsom_auth_url' => self::baseUrl('?fcom_action=auth'), | |
| 491 | + 'use_custom_signup_page' => 'no', | |
| 492 | + 'custom_signup_url' => '' | |
| 290 | 493 | ]; |
| 291 | 494 | |
| 292 | 495 | $settings = wp_parse_args($settings, $defaults); |
| 496 | + if ($settings['auth_form_type'] != 'custom' || empty($settings['auth_form_type'])) { | |
| 497 | + $settings['cutsom_auth_url'] = self::baseUrl('?fcom_action=auth'); | |
| 498 | + } | |
| 293 | 499 | |
| 294 | 500 | if (defined('FLUENT_COMMUNITY_PORTAL_SLUG')) { |
| 295 | 501 | $settings['slug'] = \FLUENT_COMMUNITY_PORTAL_SLUG; |
| 296 | 502 | $settings['is_slug_defined'] = true; |
| @@ -303,9 +509,11 @@ | ||
| 303 | 509 | |
| 304 | 510 | public static function hasGlobalPost() |
| 305 | 511 | { |
| 306 | 512 | $settings = self::generalSettings(); |
| 307 | - return Arr::get($settings, 'disable_global_posts', '') != 'yes'; | |
| 513 | + $status = Arr::get($settings, 'disable_global_posts', '') != 'yes'; | |
| 514 | + | |
| 515 | + return apply_filters('fluent_community/has_global_post', $status); | |
| 308 | 516 | } |
| 309 | 517 | |
| 310 | 518 | /** |
| 311 | 519 | * Check if a user can access the portal. |
| @@ -312,16 +520,15 @@ | ||
| 312 | 520 | * |
| 313 | 521 | * @param int|null $userId The user ID. If null, uses the current user. |
| 314 | 522 | * @return bool True if the user can access the portal, false otherwise. |
| 315 | 523 | */ |
| 316 | - public static function canAccessPortal($userId = null) | |
| 524 | + public static function canAccessPortal($userId = null, $requireActiveProfile = true) | |
| 317 | 525 | { |
| 318 | 526 | $settings = self::generalSettings(); |
| 319 | - | |
| 320 | 527 | $accessLevel = Arr::get($settings, 'access.acess_level'); |
| 321 | 528 | |
| 322 | 529 | if ($accessLevel == 'public') { |
| 323 | - return true; | |
| 530 | + return apply_filters('fluent_community/can_access_portal', true); | |
| 324 | 531 | } |
| 325 | 532 | |
| 326 | 533 | if (!$userId) { |
| 327 | 534 | $userId = get_current_user_id(); |
| @@ -327,17 +534,17 @@ | ||
| 327 | 534 | $userId = get_current_user_id(); |
| 328 | 535 | } |
| 329 | 536 | |
| 330 | 537 | if (!$userId) { |
| 331 | - return false; | |
| 538 | + return apply_filters('fluent_community/can_access_portal', false); | |
| 332 | 539 | } |
| 333 | 540 | |
| 334 | 541 | if ($accessLevel == 'logged_in') { |
| 335 | - return true; | |
| 542 | + return apply_filters('fluent_community/can_access_portal', true); | |
| 336 | 543 | } |
| 337 | 544 | |
| 338 | 545 | if (user_can($userId, 'edit_pages')) { |
| 339 | - return true; | |
| 546 | + return apply_filters('fluent_community/can_access_portal', true); | |
| 340 | 547 | } |
| 341 | 548 | |
| 342 | 549 | $roles = Arr::get($settings, 'access.access_roles', []); |
| 343 | 550 | |
| @@ -343,20 +550,26 @@ | ||
| 343 | 550 | |
| 344 | 551 | $user = get_user_by('ID', $userId); |
| 345 | 552 | |
| 346 | 553 | if (!$user) { |
| 347 | - return false; | |
| 554 | + return apply_filters('fluent_community/can_access_portal', false); | |
| 348 | 555 | } |
| 349 | 556 | |
| 350 | 557 | $result = !!array_intersect(array_values($user->roles), $roles); |
| 351 | 558 | |
| 352 | 559 | if (!$result) { |
| 353 | - return false; | |
| 560 | + return apply_filters('fluent_community/can_access_portal', false); | |
| 354 | 561 | } |
| 355 | 562 | |
| 563 | + if (!$requireActiveProfile) { | |
| 564 | + return apply_filters('fluent_community/can_access_portal', true); | |
| 565 | + } | |
| 566 | + | |
| 356 | 567 | $xProfile = Helper::getCurrentProfile(); |
| 357 | 568 | |
| 358 | - return $xProfile && $xProfile->status == 'active'; | |
| 569 | + $result = $xProfile && $xProfile->status == 'active'; | |
| 570 | + | |
| 571 | + return apply_filters('fluent_community/can_access_portal', $result); | |
| 359 | 572 | } |
| 360 | 573 | |
| 361 | 574 | /** |
| 362 | 575 | * Get the portal route paths. |
| @@ -366,16 +579,22 @@ | ||
| 366 | 579 | public static function portalRoutePaths() |
| 367 | 580 | { |
| 368 | 581 | return apply_filters('fluent_community/app_route_paths', [ |
| 369 | 582 | 'portal_home', |
| 370 | - 'community', | |
| 371 | - 'admin', | |
| 372 | 583 | 'members', |
| 584 | + 'bookmarks', | |
| 585 | + 'chat', | |
| 586 | + 'dashboard', | |
| 587 | + 'leaderboards', | |
| 588 | + 'notifications', | |
| 589 | + 'space', | |
| 590 | + 'discover', | |
| 591 | + 'courses', | |
| 373 | 592 | 'u', |
| 374 | - 'discussions', | |
| 375 | - 'notifications', | |
| 376 | - 'bookmarks', | |
| 377 | - 'courses' | |
| 593 | + 'post', | |
| 594 | + 'admin', | |
| 595 | + 'course', | |
| 596 | + 'site-maps' | |
| 378 | 597 | ]); |
| 379 | 598 | } |
| 380 | 599 | |
| 381 | 600 | /** |
| @@ -385,21 +604,18 @@ | ||
| 385 | 604 | * @return XProfile|null The user's profile or null if not found. |
| 386 | 605 | */ |
| 387 | 606 | public static function getCurrentProfile($cached = true) |
| 388 | 607 | { |
| 389 | - $userId = get_current_user_id(); | |
| 390 | - if (!$userId) { | |
| 391 | - return null; | |
| 392 | - } | |
| 393 | - | |
| 394 | 608 | static $profile; |
| 395 | - | |
| 396 | 609 | if ($profile && $cached) { |
| 397 | 610 | return $profile; |
| 398 | 611 | } |
| 399 | 612 | |
| 613 | + $userId = get_current_user_id(); | |
| 614 | + | |
| 400 | 615 | if (!$userId) { |
| 401 | - return null; | |
| 616 | + $profile = null; | |
| 617 | + return $profile; | |
| 402 | 618 | } |
| 403 | 619 | |
| 404 | 620 | $profile = XProfile::where('user_id', $userId)->first(); |
| 405 | 621 | |
| @@ -418,16 +634,14 @@ | ||
| 418 | 634 | if (!$userId) { |
| 419 | 635 | return false; |
| 420 | 636 | } |
| 421 | 637 | |
| 422 | - static $user; | |
| 423 | - if ($user && $cached) { | |
| 424 | - return $user; | |
| 638 | + static $users = []; | |
| 639 | + if ($cached && isset($users[$userId])) { | |
| 640 | + return $users[$userId]; | |
| 425 | 641 | } |
| 426 | 642 | |
| 427 | - $user = User::find($userId); | |
| 428 | - | |
| 429 | - return $user; | |
| 643 | + return $users[$userId] = User::find($userId); | |
| 430 | 644 | } |
| 431 | 645 | |
| 432 | 646 | /** |
| 433 | 647 | * Get the route paths for the community. |
| @@ -539,8 +753,31 @@ | ||
| 539 | 753 | return false; |
| 540 | 754 | } |
| 541 | 755 | |
| 542 | 756 | /** |
| 757 | + * Sanitize embed markup held in a feed/comment meta array on read. | |
| 758 | + * | |
| 759 | + * meta.media_preview.html is rendered with v-html in _MediaPreview.vue, so it is | |
| 760 | + * sanitized on write. Doing it on read as well neutralizes rows that were stored | |
| 761 | + * before the write-side fix landed, and covers any writer reaching the meta via | |
| 762 | + * the fluent_community/feed/* filters. The emptiness check keeps this free for the | |
| 763 | + * vast majority of rows, which carry no embed markup at all. | |
| 764 | + * | |
| 765 | + * @param array $meta The unserialized meta array. | |
| 766 | + * @return array The meta array with any embed markup passed through the allowlist. | |
| 767 | + */ | |
| 768 | + public static function sanitizeStoredMediaPreview($meta) | |
| 769 | + { | |
| 770 | + if (empty($meta['media_preview']['html'])) { | |
| 771 | + return $meta; | |
| 772 | + } | |
| 773 | + | |
| 774 | + $meta['media_preview']['html'] = RemoteUrlParser::sanitizeOembedHtml($meta['media_preview']['html']); | |
| 775 | + | |
| 776 | + return $meta; | |
| 777 | + } | |
| 778 | + | |
| 779 | + /** | |
| 543 | 780 | * Get a human-readable excerpt from content. |
| 544 | 781 | * |
| 545 | 782 | * @param string $content The content to extract from. |
| 546 | 783 | * @param int $length The maximum length of the excerpt. |
| @@ -561,12 +798,12 @@ | ||
| 561 | 798 | // Blockquotes: remove '>' symbol |
| 562 | 799 | '/^\s*>\s?/m' => '', |
| 563 | 800 | // Horizontal rules: replace with empty line |
| 564 | 801 | '/^\s*([-*_])\1{2,}\s*$/m' => "\n", |
| 802 | + // Images: keep only the alt text (run before links) | |
| 803 | + '/!\[([^\]]*)\]\([^\)]+\)/' => '$1', | |
| 565 | 804 | // Links: keep only the link text |
| 566 | - '/\[([^\]]+)\]\([^\)]+\)/' => '$1', | |
| 567 | - // Images: keep only the alt text | |
| 568 | - '/!\[([^\]]+)\]\([^\)]+\)/' => '$1', | |
| 805 | + '/\[([^\]]*)\]\([^\)]+\)/' => '$1', | |
| 569 | 806 | // Strikethrough: remove '~~' symbols |
| 570 | 807 | '/~~(.*?)~~/' => '$1', |
| 571 | 808 | // Task lists: remove checkbox syntax |
| 572 | 809 | '/^\s*[-*+]\s+\[[ xX]\]\s+/m' => '', |
| @@ -573,8 +810,10 @@ | ||
| 573 | 810 | ]; |
| 574 | 811 | |
| 575 | 812 | $content = preg_replace(array_keys($patterns), array_values($patterns), $content); |
| 576 | 813 | |
| 814 | + $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8'); | |
| 815 | + | |
| 577 | 816 | // remove all tags |
| 578 | 817 | $content = wp_strip_all_tags($content); |
| 579 | 818 | // remove new lines and tabs |
| 580 | 819 | $content = str_replace(["\r", "\n", "\t"], ' ', $content); |
| @@ -604,9 +843,8 @@ | ||
| 604 | 843 | */ |
| 605 | 844 | public static function isPublicAccessible() |
| 606 | 845 | { |
| 607 | 846 | $settings = self::generalSettings(); |
| 608 | - | |
| 609 | 847 | return Arr::get($settings, 'access.acess_level') == 'public'; |
| 610 | 848 | } |
| 611 | 849 | |
| 612 | 850 | /** |
| @@ -638,9 +876,9 @@ | ||
| 638 | 876 | * |
| 639 | 877 | * @param User|null $user The user to get menu groups for. |
| 640 | 878 | * @return array The community menu groups. |
| 641 | 879 | */ |
| 642 | - public static function getCommunityMenuGroups($user = null) | |
| 880 | + public static function getCommunityMenuGroups($user = null, $view = true) | |
| 643 | 881 | { |
| 644 | 882 | if (!$user) { |
| 645 | 883 | $user = self::getCurrentUser(); |
| 646 | 884 | } |
| @@ -650,52 +888,74 @@ | ||
| 650 | 888 | if ($communityGroups->isEmpty()) { |
| 651 | 889 | return []; |
| 652 | 890 | } |
| 653 | 891 | |
| 654 | - $isMod = $user && $user->isCommunityModerator(); | |
| 892 | + $userSpaceIds = $user ? self::getUserSpaceIds($user->ID) : []; | |
| 893 | + $isComModerator = $user && $user->hasCommunityModeratorAccess(); | |
| 894 | + $isCourseCreator = $user && $user->hasCourseCreatorAccess(); | |
| 895 | + $isSpaceModerator = $user && $user->isSpaceModerator(); | |
| 655 | 896 | |
| 656 | 897 | $formattedGroups = []; |
| 657 | - | |
| 658 | 898 | foreach ($communityGroups as $communityGroup) { |
| 899 | + $validSpaces = []; | |
| 659 | 900 | $spaces = $communityGroup->spaces; |
| 660 | - $validSpaces = []; | |
| 661 | 901 | $isShowAll = Arr::get($communityGroup->settings, 'always_show_spaces') === 'yes'; |
| 662 | 902 | |
| 663 | - foreach ($spaces as $space) { | |
| 664 | - if ($isMod) { | |
| 665 | - $validSpaces[] = self::transformSpaceToLink($space); | |
| 903 | + if (!$isShowAll && !$isSpaceModerator) { | |
| 904 | + $spaceIds = $spaces->pluck('id')->toArray(); | |
| 905 | + $isNotMemberOfAnySpace = empty(array_intersect($spaceIds, $userSpaceIds)); | |
| 906 | + if ($isNotMemberOfAnySpace) { | |
| 666 | 907 | continue; |
| 667 | 908 | } |
| 909 | + } | |
| 668 | 910 | |
| 669 | - if ($space->privacy === 'secret') { | |
| 670 | - if (!$user || !$space->getMembership($user->ID)) { | |
| 671 | - continue; | |
| 672 | - } | |
| 673 | - $validSpaces[] = self::transformSpaceToLink($space); | |
| 911 | + if ($user) { | |
| 912 | + BaseSpace::preloadMemberships($spaces, $user->ID); | |
| 913 | + } | |
| 914 | + | |
| 915 | + foreach ($spaces as $space) { | |
| 916 | + $validSpace = $view ? self::transformSpaceToLink($space, $user) : $space; | |
| 917 | + if (!$validSpace) { | |
| 674 | 918 | continue; |
| 675 | 919 | } |
| 676 | 920 | |
| 677 | - if ($isShowAll || $space->privacy = 'public') { | |
| 678 | - $validSpace = self::transformSpaceToLink($space); | |
| 921 | + if ($user && $space->isContentSpace()) { | |
| 922 | + $validSpace['unread_badge'] = self::getUnreadFeedsCounts($space->id); | |
| 923 | + } | |
| 679 | 924 | |
| 680 | - if ($space->privacy == 'private') { | |
| 681 | - if (!$user || !$space->getMembership($user->ID)) { | |
| 682 | - $validSpace['show_lock'] = true; | |
| 683 | - } | |
| 684 | - } | |
| 925 | + if ($isComModerator && $space->type != 'course') { | |
| 926 | + $validSpaces[] = $validSpace; | |
| 927 | + continue; | |
| 928 | + } | |
| 685 | 929 | |
| 930 | + if ($isCourseCreator && $space->type == 'course') { | |
| 686 | 931 | $validSpaces[] = $validSpace; |
| 687 | 932 | continue; |
| 688 | 933 | } |
| 689 | 934 | |
| 690 | - if (!$user || $space->getMembership($user->ID)) { | |
| 935 | + if ($space->privacy == 'public') { | |
| 936 | + $validSpaces[] = $validSpace; | |
| 691 | 937 | continue; |
| 692 | 938 | } |
| 693 | 939 | |
| 694 | - $validSpaces[] = self::transformSpaceToLink($space); | |
| 940 | + $hasMembership = $user && $space->getMembership($user->ID); | |
| 941 | + | |
| 942 | + if ($space->privacy == 'private') { | |
| 943 | + if (!$user || !$hasMembership) { | |
| 944 | + $validSpace['show_lock'] = true; | |
| 945 | + } | |
| 946 | + } | |
| 947 | + | |
| 948 | + if ($space->privacy == 'secret') { | |
| 949 | + if (!$user || !$hasMembership) { | |
| 950 | + continue; | |
| 951 | + } | |
| 952 | + } | |
| 953 | + | |
| 954 | + $validSpaces[] = $validSpace; | |
| 695 | 955 | } |
| 696 | 956 | |
| 697 | - if (!$validSpaces && !$isMod) { | |
| 957 | + if (!$validSpaces && !$isSpaceModerator) { | |
| 698 | 958 | continue; |
| 699 | 959 | } |
| 700 | 960 | |
| 701 | 961 | $formattedGroups[] = [ |
| @@ -706,22 +966,58 @@ | ||
| 706 | 966 | 'children' => $validSpaces |
| 707 | 967 | ]; |
| 708 | 968 | } |
| 709 | 969 | |
| 710 | - return $formattedGroups; | |
| 970 | + return apply_filters('fluent_community/menu_groups_for_user', $formattedGroups, $user); | |
| 711 | 971 | } |
| 712 | 972 | |
| 973 | + public static function getUnreadFeedsCounts($spaceId, $force = false) | |
| 974 | + { | |
| 975 | + static $coutsCache = null; | |
| 976 | + if ($coutsCache !== null && !$force) { | |
| 977 | + return Arr::get((array)$coutsCache, $spaceId); | |
| 978 | + } | |
| 979 | + | |
| 980 | + $xprofile = self::getCurrentProfile(); | |
| 981 | + | |
| 982 | + if (!$xprofile || !$xprofile->last_activity) { | |
| 983 | + return 0; | |
| 984 | + } | |
| 985 | + | |
| 986 | + $lastActivityDate = gmdate('Y-m-d H:i:s', strtotime($xprofile->last_activity) - 300); | |
| 987 | + | |
| 988 | + $lastActivityDate = apply_filters('fluent_community/last_activity_date_for_unread_feeds', $lastActivityDate, $xprofile); | |
| 989 | + | |
| 990 | + $unreadCounts = Feed::query() | |
| 991 | + ->select('space_id', Utility::getApp('db')->raw('COUNT(*) as feed_count')) | |
| 992 | + ->where('status', 'published') | |
| 993 | + ->where('created_at', '>', $lastActivityDate) | |
| 994 | + ->groupBy('space_id') | |
| 995 | + ->get(); | |
| 996 | + | |
| 997 | + $coutsCache = []; | |
| 998 | + foreach ($unreadCounts as $unreadCount) { | |
| 999 | + $coutsCache[$unreadCount->space_id] = $unreadCount->feed_count > 10 ? '10+' : $unreadCount->feed_count; | |
| 1000 | + } | |
| 1001 | + | |
| 1002 | + return Arr::get($coutsCache, $spaceId); | |
| 1003 | + } | |
| 1004 | + | |
| 713 | 1005 | /** |
| 714 | 1006 | * Transform a space to a link array. |
| 715 | 1007 | * |
| 716 | 1008 | * @param Space $space The space to transform. |
| 717 | - * @return array The transformed space link array. | |
| 1009 | + * @param User|null $user The user to check permissions for. | |
| 1010 | + * @return array|null The transformed space link array. | |
| 718 | 1011 | */ |
| 719 | - private static function transformSpaceToLink($space) | |
| 1012 | + private static function transformSpaceToLink($space, $user = null) | |
| 720 | 1013 | { |
| 1014 | + $isCustomLink = $space->type == 'sidebar_link'; | |
| 1015 | + if ($isCustomLink && !self::canViewSideLinkLink($space, $user)) { | |
| 1016 | + return null; | |
| 1017 | + } | |
| 721 | 1018 | |
| 722 | 1019 | $logo = $space->logo; |
| 723 | - | |
| 724 | 1020 | $title = $space->title; |
| 725 | 1021 | |
| 726 | 1022 | if ($space->status == 'draft') { |
| 727 | 1023 | $title = $title . ' ' . __('(Draft)', 'fluent-community'); |
| @@ -732,12 +1028,44 @@ | ||
| 732 | 1028 | 'icon_image' => $logo, |
| 733 | 1029 | 'shape_svg' => !$logo ? Arr::get($space->settings, 'shape_svg', '') : '', |
| 734 | 1030 | 'emoji' => !$logo ? Arr::get($space->settings, 'emoji', '') : '', |
| 735 | 1031 | 'permalink' => $space->getPermalink(), |
| 1032 | + 'is_custom' => $isCustomLink ? 'yes' : 'no', | |
| 1033 | + 'new_tab' => ($isCustomLink && Arr::get($space, 'settings.new_tab', 'no') === 'yes') ? 'yes' : 'no', | |
| 736 | 1034 | 'link_classes' => 'space_menu_item route_url fcom_space_id_' . $space->id . ' fcom_space_' . $space->slug |
| 737 | 1035 | ]; |
| 738 | 1036 | } |
| 739 | 1037 | |
| 1038 | + public static function canViewSideLinkLink($space, $user = null) | |
| 1039 | + { | |
| 1040 | + $privacy = $space->privacy; | |
| 1041 | + | |
| 1042 | + if ($privacy == 'public') { | |
| 1043 | + return true; | |
| 1044 | + } | |
| 1045 | + | |
| 1046 | + if ($privacy == 'logged_in') { | |
| 1047 | + return !!$user; | |
| 1048 | + } | |
| 1049 | + | |
| 1050 | + if ($privacy == 'logged_out_only') { | |
| 1051 | + return !$user; | |
| 1052 | + } | |
| 1053 | + | |
| 1054 | + if (!$user) { | |
| 1055 | + return false; | |
| 1056 | + } | |
| 1057 | + | |
| 1058 | + $accessIds = Arr::get($space->settings, 'membership_ids', []); | |
| 1059 | + | |
| 1060 | + if (!$accessIds) { | |
| 1061 | + return true; | |
| 1062 | + } | |
| 1063 | + | |
| 1064 | + $userSpaces = $user->getSpaceIds(); | |
| 1065 | + return !!array_intersect($userSpaces, $accessIds); | |
| 1066 | + } | |
| 1067 | + | |
| 740 | 1068 | public static function isAlreadyOnboarded() |
| 741 | 1069 | { |
| 742 | 1070 | $communitySettings = get_option('fluent_community_settings', []); |
| 743 | 1071 | |
| @@ -809,10 +1137,14 @@ | ||
| 809 | 1137 | if ($menuGroups && $context === 'view') { |
| 810 | 1138 | return $menuGroups; |
| 811 | 1139 | } |
| 812 | 1140 | |
| 813 | - $menuGroups = Utility::getOption('fluent_community_menu_groups', []); | |
| 1141 | + $menuGroups = (array) Utility::getOption('fluent_community_menu_groups', []); | |
| 814 | 1142 | |
| 1143 | + $membersPageStatus = Utility::canViewMembersPage() ? 'yes' : 'no'; | |
| 1144 | + | |
| 1145 | + $leaderboardPageVisibility = (Utility::canViewLeaderboardMembers() || is_user_logged_in()) ? 'yes' : 'no'; | |
| 1146 | + | |
| 815 | 1147 | $defaultMainMenuItems = [ |
| 816 | 1148 | 'all_feeds' => [ |
| 817 | 1149 | 'slug' => 'all_feeds', |
| 818 | 1150 | 'title' => __('Feed', 'fluent-community'), |
| @@ -834,9 +1166,9 @@ | ||
| 834 | 1166 | 'shape_svg' => '<svg version="1.1" viewBox="0 0 128 128" xml:space="preserve"><g><path d="M64,42c-13.2,0-24,10.8-24,24s10.8,24,24,24s24-10.8,24-24S77.2,42,64,42z M64,82c-8.8,0-16-7.2-16-16s7.2-16,16-16 s16,7.2,16,16S72.8,82,64,82z"/><path d="M64,100.8c-14.9,0-29.2,6.2-39.4,17.1l-2.7,2.9l5.8,5.5l2.7-2.9c8.8-9.4,20.7-14.6,33.6-14.6s24.8,5.2,33.6,14.6l2.7,2.9 l5.8-5.5l-2.7-2.9C93.2,107.1,78.9,100.8,64,100.8z"/><path d="M97,47.9v8c9.4,0,18.1,3.8,24.6,10.7l5.8-5.5C119.6,52.7,108.5,47.9,97,47.9z"/><path d="M116.1,20c0-10.5-8.6-19.1-19.1-19.1S77.9,9.5,77.9,20S86.5,39.1,97,39.1S116.1,30.5,116.1,20z M85.9,20 c0-6.1,5-11.1,11.1-11.1s11.1,5,11.1,11.1s-5,11.1-11.1,11.1S85.9,26.1,85.9,20z"/><path d="M31,47.9c-11.5,0-22.6,4.8-30.4,13.2l5.8,5.5c6.4-6.9,15.2-10.7,24.6-10.7V47.9z"/><path d="M50.1,20C50.1,9.5,41.5,0.9,31,0.9S11.9,9.5,11.9,20S20.5,39.1,31,39.1S50.1,30.5,50.1,20z M31,31.1 c-6.1,0-11.1-5-11.1-11.1S24.9,8.9,31,8.9s11.1,5,11.1,11.1S37.1,31.1,31,31.1z"/></g></svg>' |
| 835 | 1167 | ], |
| 836 | 1168 | 'all_courses' => [ |
| 837 | 1169 | 'slug' => 'all_courses', |
| 838 | - 'title' => 'Courses', | |
| 1170 | + 'title' => __('Courses', 'fluent-community'), | |
| 839 | 1171 | 'link_classes' => 'fcom_courses route_url', |
| 840 | 1172 | 'is_system' => 'yes', |
| 841 | 1173 | 'is_locked' => 'yes', |
| 842 | 1174 | 'enabled' => 'yes', |
| @@ -844,23 +1176,24 @@ | ||
| 844 | 1176 | 'permalink' => self::baseUrl('courses'), |
| 845 | 1177 | 'shape_svg' => '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10.734 5.84746L14.7114 6.9072M9.88139 9.01146L11.8701 9.54132M9.98031 14.9723L10.7758 15.1843C13.0258 15.7838 14.1508 16.0835 15.037 15.5747C15.9233 15.0659 16.2247 13.9473 16.8276 11.71L17.6802 8.54599C18.2831 6.3087 18.5845 5.19006 18.0728 4.30879C17.5611 3.42752 16.4362 3.12778 14.1862 2.52831L13.3907 2.31636C11.1407 1.71688 10.0157 1.41714 9.12948 1.92594C8.24322 2.43474 7.94178 3.55338 7.3389 5.79067L6.4863 8.95466C5.88342 11.1919 5.58198 12.3106 6.09367 13.1919C6.60536 14.0731 7.73034 14.3729 9.98031 14.9723Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M9.99996 17.4559L9.20634 17.672C6.96165 18.2832 5.83931 18.5889 4.95512 18.0701C4.07093 17.5513 3.7702 16.4107 3.16874 14.1295L2.31814 10.9035C1.71668 8.62232 1.41595 7.48174 1.92643 6.58318C2.36802 5.80591 3.33329 5.83421 4.58329 5.83411" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>' |
| 846 | 1178 | ], |
| 847 | 1179 | 'all_members' => [ |
| 848 | - 'slug' => 'all_members', | |
| 849 | - 'title' => __('Members', 'fluent-community'), | |
| 850 | - 'is_system' => 'yes', | |
| 851 | - 'is_locked' => 'yes', | |
| 852 | - 'enabled' => 'yes', | |
| 853 | - 'permalink' => self::baseUrl('members'), | |
| 854 | - 'link_classes' => 'fcom_all_members route_url', | |
| 855 | - '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>', | |
| 1180 | + 'slug' => 'all_members', | |
| 1181 | + 'title' => __('Members', 'fluent-community'), | |
| 1182 | + 'is_system' => 'yes', | |
| 1183 | + 'is_locked' => 'yes', | |
| 1184 | + 'is_unavailable' => $membersPageStatus == 'yes' ? 'no' : 'yes', | |
| 1185 | + 'enabled' => $membersPageStatus, | |
| 1186 | + 'permalink' => self::baseUrl('members'), | |
| 1187 | + 'link_classes' => 'fcom_all_members route_url', | |
| 1188 | + 'shape_svg' => '<svg width="20" height="16" viewBox="0 0 20 16" fill="none"><path d="M17.3116 13C17.936 13 18.4327 12.6071 18.8786 12.0576C19.7915 10.9329 18.2927 10.034 17.721 9.59383C17.1399 9.14635 16.4911 8.89285 15.8332 8.83333M14.9999 7.16667C16.1505 7.16667 17.0832 6.23393 17.0832 5.08333C17.0832 3.93274 16.1505 3 14.9999 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M2.68822 13C2.0638 13 1.56714 12.6071 1.12121 12.0576C0.208326 10.9329 1.70714 10.034 2.27879 9.59383C2.8599 9.14635 3.50874 8.89285 4.16659 8.83333M4.58325 7.16667C3.43266 7.16667 2.49992 6.23393 2.49992 5.08333C2.49992 3.93274 3.43266 3 4.58325 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M6.73642 10.592C5.88494 11.1185 3.65241 12.1936 5.01217 13.5389C5.6764 14.196 6.41619 14.666 7.34627 14.666H12.6536C13.5837 14.666 14.3234 14.196 14.9877 13.5389C16.3474 12.1936 14.1149 11.1185 13.2634 10.592C11.2667 9.35735 8.73313 9.35735 6.73642 10.592Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12.9166 4.24967C12.9166 5.86051 11.6107 7.16634 9.99992 7.16634C8.38909 7.16634 7.08325 5.86051 7.08325 4.24967C7.08325 2.63884 8.38909 1.33301 9.99992 1.33301C11.6107 1.33301 12.9166 2.63884 12.9166 4.24967Z" stroke="currentColor" stroke-width="1.5"/></svg>', | |
| 856 | 1189 | ], |
| 857 | 1190 | 'leaderboard' => [ |
| 858 | 1191 | 'slug' => 'leaderboard', |
| 859 | 1192 | 'is_system' => 'yes', |
| 860 | 1193 | 'is_locked' => 'yes', |
| 861 | - 'enabled' => 'yes', | |
| 862 | - 'is_unavailable' => self::isFeatureEnabled('leader_board_module') ? 'no' : 'yes', | |
| 1194 | + 'enabled' => $leaderboardPageVisibility, | |
| 1195 | + 'is_unavailable' => self::isFeatureEnabled('leader_board_module') && $leaderboardPageVisibility == 'yes' ? 'no' : 'yes', | |
| 863 | 1196 | 'title' => __('Leaderboard', 'fluent-community'), |
| 864 | 1197 | 'link_classes' => 'fcom_leaderboards route_url', |
| 865 | 1198 | 'permalink' => self::baseUrl('leaderboards'), |
| 866 | 1199 | 'shape_svg' => '<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor"><path d="M160-200h160v-320H160v320Zm240 0h160v-560H400v560Zm240 0h160v-240H640v240ZM80-120v-480h240v-240h320v320h240v400H80Z"/></svg>' |
| @@ -881,9 +1214,9 @@ | ||
| 881 | 1214 | continue; |
| 882 | 1215 | } |
| 883 | 1216 | $defaultItem = Arr::get($defaultMainMenuItems, $item['slug'], []); |
| 884 | 1217 | if ($defaultItem) { |
| 885 | - $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug', 'shape_svg']; | |
| 1218 | + $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug']; | |
| 886 | 1219 | foreach ($preservedKeys as $key) { |
| 887 | 1220 | if (isset($defaultItem[$key])) { |
| 888 | 1221 | $item[$key] = Arr::get($defaultItem, $key); |
| 889 | 1222 | } |
| @@ -889,11 +1222,12 @@ | ||
| 889 | 1222 | } |
| 890 | 1223 | } |
| 891 | 1224 | if (Arr::get($defaultItem, 'is_system') === 'yes') { |
| 892 | 1225 | $item['permalink'] = $defaultItem['permalink']; |
| 893 | - $item['emoji'] = ''; | |
| 894 | - $item['icon_image'] = ''; | |
| 895 | 1226 | $item['link_classes'] = $defaultItem['link_classes']; |
| 1227 | + if (empty($item['shape_svg'])) { | |
| 1228 | + $item['shape_svg'] = $defaultItem['shape_svg']; | |
| 1229 | + } | |
| 896 | 1230 | } |
| 897 | 1231 | } |
| 898 | 1232 | } |
| 899 | 1233 | } else { |
| @@ -932,11 +1266,9 @@ | ||
| 932 | 1266 | |
| 933 | 1267 | $profileDropDownItems = Arr::get($menuGroups, 'profileDropdownItems', []); |
| 934 | 1268 | |
| 935 | 1269 | if ($profileDropDownItems && is_array($profileDropDownItems)) { |
| 936 | - | |
| 937 | 1270 | unset($profileDropDownItems['profile']); |
| 938 | - | |
| 939 | 1271 | foreach ($profileDropDownItems as $index => &$item) { |
| 940 | 1272 | if (empty($item['slug'])) { |
| 941 | 1273 | unset($profileDropDownItems[$index]); |
| 942 | 1274 | continue; |
| @@ -942,9 +1274,9 @@ | ||
| 942 | 1274 | continue; |
| 943 | 1275 | } |
| 944 | 1276 | $defaultItem = Arr::get($defaultProfileDropDownItems, $item['slug'], []); |
| 945 | 1277 | if ($defaultItem) { |
| 946 | - $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug', 'svg_icon']; | |
| 1278 | + $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug']; | |
| 947 | 1279 | foreach ($preservedKeys as $key) { |
| 948 | 1280 | if (isset($defaultItem[$key])) { |
| 949 | 1281 | $item[$key] = Arr::get($defaultItem, $key); |
| 950 | 1282 | } |
| @@ -972,18 +1304,21 @@ | ||
| 972 | 1304 | $afterCommunityMenuGroups = []; |
| 973 | 1305 | } |
| 974 | 1306 | |
| 975 | 1307 | if ($context == 'view') { |
| 976 | - $mainItems = array_filter($mainItems, function ($item) { | |
| 977 | - return Arr::get($item, 'enabled') === 'yes' && Arr::get($item, 'is_unavailable') !== 'yes'; | |
| 1308 | + | |
| 1309 | + $currentUser = self::getCurrentUser(); | |
| 1310 | + | |
| 1311 | + $mainItems = array_filter($mainItems, function ($item) use ($currentUser) { | |
| 1312 | + return self::isLinkAccessible($item, $currentUser); | |
| 978 | 1313 | }); |
| 979 | 1314 | |
| 980 | - $profileDropDownItems = array_filter($profileDropDownItems, function ($item) { | |
| 981 | - return Arr::get($item, 'enabled') === 'yes' && Arr::get($item, 'is_unavailable') !== 'yes'; | |
| 1315 | + $profileDropDownItems = array_filter($profileDropDownItems, function ($item) use ($currentUser) { | |
| 1316 | + return self::isLinkAccessible($item, $currentUser); | |
| 982 | 1317 | }); |
| 983 | 1318 | |
| 984 | - $beforeCommunityMenuItems = array_filter($beforeCommunityMenuItems, function ($item) { | |
| 985 | - return Arr::get($item, 'enabled') === 'yes' && Arr::get($item, 'is_unavailable') !== 'yes'; | |
| 1319 | + $beforeCommunityMenuItems = array_filter($beforeCommunityMenuItems, function ($item) use ($currentUser) { | |
| 1320 | + return self::isLinkAccessible($item, $currentUser); | |
| 986 | 1321 | }); |
| 987 | 1322 | |
| 988 | 1323 | $validGroups = []; |
| 989 | 1324 | foreach ($afterCommunityMenuGroups as $group) { |
| @@ -990,10 +1325,10 @@ | ||
| 990 | 1325 | if (empty($group['items']) || !is_array($group['items'])) { |
| 991 | 1326 | continue; |
| 992 | 1327 | } |
| 993 | 1328 | |
| 994 | - $group['items'] = array_filter($group['items'], function ($item) { | |
| 995 | - return Arr::get($item, 'enabled') === 'yes' && Arr::get($item, 'is_unavailable') !== 'yes'; | |
| 1329 | + $group['items'] = array_filter($group['items'], function ($item) use ($currentUser) { | |
| 1330 | + return self::isLinkAccessible($item, $currentUser); | |
| 996 | 1331 | }); |
| 997 | 1332 | |
| 998 | 1333 | if ($group['items']) { |
| 999 | 1334 | $validGroups[] = $group; |
| @@ -1015,8 +1350,72 @@ | ||
| 1015 | 1350 | return $menuGroups; |
| 1016 | 1351 | } |
| 1017 | 1352 | |
| 1018 | 1353 | /** |
| 1354 | + * Drop the links the given user may not see. | |
| 1355 | + * | |
| 1356 | + * Space links carry their own privacy, so every place that hands a space's settings | |
| 1357 | + * to a client has to filter them. Doing that inline is how the feed endpoints came | |
| 1358 | + * to skip it, so both call sites go through here. | |
| 1359 | + * | |
| 1360 | + * @param array $links | |
| 1361 | + * @param \FluentCommunity\App\Models\User|null $currentUser | |
| 1362 | + * @return array | |
| 1363 | + */ | |
| 1364 | + public static function filterAccessibleLinks($links, $currentUser = null) | |
| 1365 | + { | |
| 1366 | + if (!$links || !is_array($links)) { | |
| 1367 | + return []; | |
| 1368 | + } | |
| 1369 | + | |
| 1370 | + return array_values(array_filter($links, function ($link) use ($currentUser) { | |
| 1371 | + return self::isLinkAccessible($link, $currentUser); | |
| 1372 | + })); | |
| 1373 | + } | |
| 1374 | + | |
| 1375 | + public static function isLinkAccessible($link, $currentUser = null) | |
| 1376 | + { | |
| 1377 | + $isEnabled = Arr::get($link, 'enabled', 'yes') === 'yes'; | |
| 1378 | + $isUnavailable = Arr::get($link, 'is_unavailable') === 'yes'; | |
| 1379 | + | |
| 1380 | + if (!$isEnabled || $isUnavailable) { | |
| 1381 | + return false; | |
| 1382 | + } | |
| 1383 | + | |
| 1384 | + $privacy = Arr::get($link, 'privacy', ''); | |
| 1385 | + | |
| 1386 | + if (!$privacy || $privacy === 'public') { | |
| 1387 | + return true; | |
| 1388 | + } | |
| 1389 | + | |
| 1390 | + if ($privacy == 'logged_in') { | |
| 1391 | + return !!$currentUser; | |
| 1392 | + } | |
| 1393 | + | |
| 1394 | + if ($privacy == 'logged_out_only') { | |
| 1395 | + return !$currentUser; | |
| 1396 | + } | |
| 1397 | + | |
| 1398 | + $membershipIds = Arr::get($link, 'membership_ids', []); | |
| 1399 | + if (!$membershipIds) { | |
| 1400 | + return true; | |
| 1401 | + } | |
| 1402 | + | |
| 1403 | + if (!$currentUser) { | |
| 1404 | + return false; | |
| 1405 | + } | |
| 1406 | + | |
| 1407 | + static $userSpacesIds = []; | |
| 1408 | + if (!isset($userSpacesIds[$currentUser->ID])) { | |
| 1409 | + $userSpacesIds[$currentUser->ID] = $currentUser->getJoinedSpaceIds(); | |
| 1410 | + } | |
| 1411 | + | |
| 1412 | + $ids = $userSpacesIds[$currentUser->ID]; | |
| 1413 | + | |
| 1414 | + return $ids && !!array_intersect($ids, $membershipIds); | |
| 1415 | + } | |
| 1416 | + | |
| 1417 | + /** | |
| 1019 | 1418 | * Get the meta data for a space. |
| 1020 | 1419 | * |
| 1021 | 1420 | * @param int $spaceId The ID of the space. |
| 1022 | 1421 | * @param string $key The meta key. |
| @@ -1058,9 +1457,9 @@ | ||
| 1058 | 1457 | } else { |
| 1059 | 1458 | $meta = Meta::create([ |
| 1060 | 1459 | 'object_type' => 'space', |
| 1061 | 1460 | 'object_id' => $spaceId, |
| 1062 | - 'meta_key' => $key, | |
| 1461 | + 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key | |
| 1063 | 1462 | 'value' => $value |
| 1064 | 1463 | ]); |
| 1065 | 1464 | } |
| 1066 | 1465 | |
| @@ -1133,49 +1532,58 @@ | ||
| 1133 | 1532 | * @return array The welcome banner configuration. |
| 1134 | 1533 | */ |
| 1135 | 1534 | public static function getWelcomeBannerSettings() |
| 1136 | 1535 | { |
| 1137 | - return Utility::getFromCache('welcome_banner_settings', function () { | |
| 1138 | - $settings = Utility::getOption('welcome_banner_settings', []); | |
| 1139 | - $defaults = [ | |
| 1140 | - 'login' => [ | |
| 1141 | - 'enabled' => 'no', | |
| 1142 | - 'description' => '', | |
| 1143 | - 'mediaType' => 'image', | |
| 1144 | - 'allowClose' => 'no', | |
| 1145 | - 'bannerImage' => '', | |
| 1146 | - 'bannerVideo' => [ | |
| 1147 | - 'type' => '', | |
| 1148 | - 'url' => '', | |
| 1149 | - 'content_type' => '', | |
| 1150 | - 'provider' => '', | |
| 1151 | - 'title' => '', | |
| 1152 | - 'author_name' => '', | |
| 1153 | - 'html' => '' | |
| 1154 | - ], | |
| 1155 | - 'ctaButtons' => [] | |
| 1536 | + $defaults = [ | |
| 1537 | + 'login' => [ | |
| 1538 | + 'enabled' => 'no', | |
| 1539 | + 'description' => '', | |
| 1540 | + 'mediaType' => 'image', | |
| 1541 | + 'allowClose' => 'no', | |
| 1542 | + 'bannerImage' => '', | |
| 1543 | + 'bannerVideo' => [ | |
| 1544 | + 'type' => 'oembed', | |
| 1545 | + 'url' => '', | |
| 1546 | + 'content_type' => '', | |
| 1547 | + 'provider' => '', | |
| 1548 | + 'title' => '', | |
| 1549 | + 'author_name' => '', | |
| 1550 | + 'html' => '' | |
| 1156 | 1551 | ], |
| 1157 | - 'logout' => [ | |
| 1158 | - 'enabled' => 'no', | |
| 1159 | - 'description' => '', | |
| 1160 | - 'mediaType' => 'image', | |
| 1161 | - 'useCustomUrl' => 'no', | |
| 1162 | - 'bannerImage' => '', | |
| 1163 | - 'bannerVideo' => [ | |
| 1164 | - 'type' => '', | |
| 1165 | - 'url' => '', | |
| 1166 | - 'content_type' => '', | |
| 1167 | - 'provider' => '', | |
| 1168 | - 'title' => '', | |
| 1169 | - 'author_name' => '', | |
| 1170 | - 'html' => '' | |
| 1171 | - ], | |
| 1172 | - 'ctaButtons' => [] | |
| 1173 | - ] | |
| 1174 | - ]; | |
| 1175 | - $settings = wp_parse_args($settings, $defaults); | |
| 1176 | - return $settings; | |
| 1177 | - }, WEEK_IN_SECONDS); | |
| 1552 | + 'ctaButtons' => [] | |
| 1553 | + ], | |
| 1554 | + 'logout' => [ | |
| 1555 | + 'enabled' => 'no', | |
| 1556 | + 'description' => '', | |
| 1557 | + 'mediaType' => 'image', | |
| 1558 | + 'useCustomUrl' => 'no', | |
| 1559 | + 'bannerImage' => '', | |
| 1560 | + 'bannerVideo' => [ | |
| 1561 | + 'type' => 'oembed', | |
| 1562 | + 'url' => '', | |
| 1563 | + 'content_type' => '', | |
| 1564 | + 'provider' => '', | |
| 1565 | + 'title' => '', | |
| 1566 | + 'author_name' => '', | |
| 1567 | + 'html' => '' | |
| 1568 | + ], | |
| 1569 | + 'ctaButtons' => [] | |
| 1570 | + ] | |
| 1571 | + ]; | |
| 1572 | + | |
| 1573 | + $settings = Utility::getOption('welcome_banner_settings', []); | |
| 1574 | + | |
| 1575 | + $settings = wp_parse_args($settings, $defaults); | |
| 1576 | + | |
| 1577 | + if (empty(Arr::get($settings, 'login.bannerVideo'))) { | |
| 1578 | + $settings['login']['bannerVideo'] = $defaults['login']['bannerVideo']; | |
| 1579 | + } | |
| 1580 | + | |
| 1581 | + if (empty(Arr::get($settings, 'logout.bannerVideo'))) { | |
| 1582 | + $settings['logout']['bannerVideo'] = $defaults['logout']['bannerVideo']; | |
| 1583 | + } | |
| 1584 | + | |
| 1585 | + return $settings; | |
| 1178 | 1586 | } |
| 1179 | 1587 | |
| 1180 | 1588 | public static function getWelcomeBanner($view = 'login') |
| 1181 | 1589 | { |
| @@ -1186,31 +1594,85 @@ | ||
| 1186 | 1594 | } |
| 1187 | 1595 | |
| 1188 | 1596 | unset($welcomeBanner['description']); |
| 1189 | 1597 | |
| 1190 | - return $welcomeBanner; | |
| 1598 | + if ($view == 'login') { | |
| 1599 | + return apply_filters('fluent_community/welcome_banner_for_logged_in', $welcomeBanner); | |
| 1600 | + } | |
| 1601 | + | |
| 1602 | + return apply_filters('fluent_community/welcome_banner_for_guests', $welcomeBanner); | |
| 1191 | 1603 | } |
| 1192 | 1604 | |
| 1193 | 1605 | public static function getEnabledFeedLinks() |
| 1194 | 1606 | { |
| 1195 | 1607 | $links = array_filter(self::getFeedLinks(), function ($item) { |
| 1196 | - return Arr::get($item, 'enabled') == 'yes' && Arr::get($item, 'is_unavailable') != 'yes'; | |
| 1608 | + return self::isLinkAccessible($item); | |
| 1197 | 1609 | }); |
| 1198 | 1610 | |
| 1199 | 1611 | return array_values($links); |
| 1200 | 1612 | } |
| 1201 | 1613 | |
| 1614 | + public static function getMobileMenuItems($context = 'headless') | |
| 1615 | + { | |
| 1616 | + $xprofile = Helper::getCurrentProfile(); | |
| 1617 | + | |
| 1618 | + $mainMenuItems = Arr::get(self::getMenuItemsGroup('view'), 'mainMenuItems', []); | |
| 1619 | + | |
| 1620 | + $defaultMobileIcons = [ | |
| 1621 | + 'all_feeds' => '<svg width="20" height="18" viewBox="0 0 20 18" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M10 13.166H10.0075H10Z" fill="currentColor"></path><path d="M10 13.166H10.0075" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path><path d="M16.6666 6.08301V10.2497C16.6666 13.3924 16.6666 14.9637 15.6903 15.94C14.714 16.9163 13.1426 16.9163 9.99992 16.9163C6.85722 16.9163 5.28587 16.9163 4.30956 15.94C3.33325 14.9637 3.33325 13.3924 3.33325 10.2497V6.08301" stroke="currentColor" stroke-width="1.5"></path><path d="M18.3333 7.74967L14.714 4.27925C12.4918 2.14842 11.3807 1.08301 9.99996 1.08301C8.61925 1.08301 7.50814 2.14842 5.28592 4.27924L1.66663 7.74967" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path></svg>', | |
| 1622 | + 'spaces' => '<svg version="1.1" viewBox="0 0 128 128" xml:space="preserve"><g><path d="M64,42c-13.2,0-24,10.8-24,24s10.8,24,24,24s24-10.8,24-24S77.2,42,64,42z M64,82c-8.8,0-16-7.2-16-16s7.2-16,16-16 s16,7.2,16,16S72.8,82,64,82z"></path><path d="M64,100.8c-14.9,0-29.2,6.2-39.4,17.1l-2.7,2.9l5.8,5.5l2.7-2.9c8.8-9.4,20.7-14.6,33.6-14.6s24.8,5.2,33.6,14.6l2.7,2.9 l5.8-5.5l-2.7-2.9C93.2,107.1,78.9,100.8,64,100.8z"></path><path d="M97,47.9v8c9.4,0,18.1,3.8,24.6,10.7l5.8-5.5C119.6,52.7,108.5,47.9,97,47.9z"></path><path d="M116.1,20c0-10.5-8.6-19.1-19.1-19.1S77.9,9.5,77.9,20S86.5,39.1,97,39.1S116.1,30.5,116.1,20z M85.9,20 c0-6.1,5-11.1,11.1-11.1s11.1,5,11.1,11.1s-5,11.1-11.1,11.1S85.9,26.1,85.9,20z"></path><path d="M31,47.9c-11.5,0-22.6,4.8-30.4,13.2l5.8,5.5c6.4-6.9,15.2-10.7,24.6-10.7V47.9z"></path><path d="M50.1,20C50.1,9.5,41.5,0.9,31,0.9S11.9,9.5,11.9,20S20.5,39.1,31,39.1S50.1,30.5,50.1,20z M31,31.1 c-6.1,0-11.1-5-11.1-11.1S24.9,8.9,31,8.9s11.1,5,11.1,11.1S37.1,31.1,31,31.1z"></path></g></svg>' | |
| 1623 | + ]; | |
| 1624 | + | |
| 1625 | + $mobileMenuItems = []; | |
| 1626 | + | |
| 1627 | + foreach ($defaultMobileIcons as $slug => $defaultIcon) { | |
| 1628 | + $menuItem = Arr::get($mainMenuItems, $slug); | |
| 1629 | + if (!$menuItem) { | |
| 1630 | + continue; | |
| 1631 | + } | |
| 1632 | + | |
| 1633 | + $iconSvg = Arr::get($menuItem, 'shape_svg'); | |
| 1634 | + | |
| 1635 | + $mobileMenuItems[] = [ | |
| 1636 | + 'route' => [ | |
| 1637 | + 'name' => $slug | |
| 1638 | + ], | |
| 1639 | + 'title' => Arr::get($menuItem, 'title'), | |
| 1640 | + 'icon_svg' => $iconSvg ? CustomSanitizer::sanitizeSvg($iconSvg) : $defaultIcon | |
| 1641 | + ]; | |
| 1642 | + } | |
| 1643 | + | |
| 1644 | + if ($xprofile) { | |
| 1645 | + $mobileMenuItems[] = [ | |
| 1646 | + 'route' => [ | |
| 1647 | + 'name' => 'user_profile', | |
| 1648 | + 'params' => [ | |
| 1649 | + 'username' => $xprofile->username | |
| 1650 | + ] | |
| 1651 | + ], | |
| 1652 | + 'title' => __('Profile', 'fluent-community'), | |
| 1653 | + 'icon_svg' => '<svg viewBox="0 0 1024 1024"><path fill="currentColor" d="M512 512a192 192 0 1 0 0-384 192 192 0 0 0 0 384m0 64a256 256 0 1 1 0-512 256 256 0 0 1 0 512m320 320v-96a96 96 0 0 0-96-96H288a96 96 0 0 0-96 96v96a32 32 0 1 1-64 0v-96a160 160 0 0 1 160-160h448a160 160 0 0 1 160 160v96a32 32 0 1 1-64 0"></path></svg>' | |
| 1654 | + ]; | |
| 1655 | + } else if (!get_current_user_id()) { | |
| 1656 | + $mobileMenuItems[] = [ | |
| 1657 | + 'name' => 'login', | |
| 1658 | + 'title' => __('Login', 'fluent-community'), | |
| 1659 | + 'permalink' => Helper::getAuthUrl(), | |
| 1660 | + 'icon_svg' => '<svg viewBox="0 0 1024 1024"><path fill="currentColor" d="M512 512a192 192 0 1 0 0-384 192 192 0 0 0 0 384m0 64a256 256 0 1 1 0-512 256 256 0 0 1 0 512m320 320v-96a96 96 0 0 0-96-96H288a96 96 0 0 0-96 96v96a32 32 0 1 1-64 0v-96a160 160 0 0 1 160-160h448a160 160 0 0 1 160 160v96a32 32 0 1 1-64 0"></path></svg>' | |
| 1661 | + ]; | |
| 1662 | + } | |
| 1663 | + | |
| 1664 | + return apply_filters('fluent_community/mobile_menu', $mobileMenuItems, $xprofile, $context); | |
| 1665 | + } | |
| 1666 | + | |
| 1202 | 1667 | public static function getFeedLinks() |
| 1203 | 1668 | { |
| 1204 | - return Utility::getFromCache('feed_links', function () { | |
| 1205 | - return Utility::getOption('feed_links', []); | |
| 1206 | - }, WEEK_IN_SECONDS); | |
| 1669 | + return Utility::getOption('feed_links', []); | |
| 1207 | 1670 | } |
| 1208 | 1671 | |
| 1209 | 1672 | public static function updateFeedLinks($links) |
| 1210 | 1673 | { |
| 1211 | 1674 | Utility::updateOption('feed_links', $links); |
| 1212 | - Utility::setCache('feed_links', $links, WEEK_IN_SECONDS); | |
| 1213 | 1675 | } |
| 1214 | 1676 | |
| 1215 | 1677 | /** |
| 1216 | 1678 | * Get the full name of a WordPress user. |
| @@ -1272,24 +1734,38 @@ | ||
| 1272 | 1734 | |
| 1273 | 1735 | /** |
| 1274 | 1736 | * Add a user to a space. |
| 1275 | 1737 | * |
| 1276 | - * @param Space | int $space space to add the user to. | |
| 1738 | + * @param BaseSpace|int $space space to add the user to. | |
| 1277 | 1739 | * @param int $userId The ID of the user to add. |
| 1278 | 1740 | * @param string $role The role of the user in the space. |
| 1279 | 1741 | * @param string $by The source of the action. |
| 1280 | 1742 | * @return bool True if the user was added, false otherwise. |
| 1281 | 1743 | */ |
| 1282 | - public static function addToSpace($space, $userId, $role = 'member', $by = 'self') | |
| 1744 | + public static function addToSpace($space, $userId, $role = 'member', $by = 'self', $skipSync = false) | |
| 1283 | 1745 | { |
| 1284 | 1746 | if (is_numeric($space)) { |
| 1285 | - $space = Space::find($space); | |
| 1747 | + $space = BaseSpace::onlyMain()->find($space); | |
| 1286 | 1748 | } |
| 1287 | 1749 | |
| 1288 | - if (!$space) { | |
| 1750 | + if (!$space || !$space instanceof BaseSpace) { | |
| 1289 | 1751 | return false; |
| 1290 | 1752 | } |
| 1291 | 1753 | |
| 1754 | + if (!$skipSync) { | |
| 1755 | + $user = User::find($userId); | |
| 1756 | + | |
| 1757 | + if (!$user) { | |
| 1758 | + return false; | |
| 1759 | + } | |
| 1760 | + | |
| 1761 | + $user->syncXProfile(); | |
| 1762 | + } | |
| 1763 | + | |
| 1764 | + if ($role == 'member' && $space->type == 'course') { | |
| 1765 | + $role = 'student'; | |
| 1766 | + } | |
| 1767 | + | |
| 1292 | 1768 | $exist = SpaceUserPivot::where('user_id', $userId) |
| 1293 | 1769 | ->where('space_id', $space->id) |
| 1294 | 1770 | ->first(); |
| 1295 | 1771 | |
| @@ -1295,10 +1771,21 @@ | ||
| 1295 | 1771 | |
| 1296 | 1772 | if ($exist) { |
| 1297 | 1773 | if ($exist->status != 'active') { |
| 1298 | 1774 | $exist->status = 'active'; |
| 1299 | - $exist->role = $role; | |
| 1775 | + | |
| 1776 | + if (!in_array($exist->role, ['admin', 'moderator'])) { | |
| 1777 | + $exist->role = $role; | |
| 1778 | + } | |
| 1779 | + | |
| 1300 | 1780 | $exist->save(); |
| 1781 | + | |
| 1782 | + if ($space->type == 'course') { | |
| 1783 | + do_action('fluent_community/course/enrolled', $space, $userId, $by); | |
| 1784 | + } else { | |
| 1785 | + do_action('fluent_community/space/joined', $space, $userId, $by); | |
| 1786 | + } | |
| 1787 | + | |
| 1301 | 1788 | return true; |
| 1302 | 1789 | } |
| 1303 | 1790 | |
| 1304 | 1791 | return false; |
| @@ -1303,9 +1790,9 @@ | ||
| 1303 | 1790 | |
| 1304 | 1791 | return false; |
| 1305 | 1792 | } |
| 1306 | 1793 | |
| 1307 | - SpaceUserPivot::create([ | |
| 1794 | + $created = SpaceUserPivot::create([ | |
| 1308 | 1795 | 'space_id' => $space->id, |
| 1309 | 1796 | 'role' => $role, |
| 1310 | 1797 | 'user_id' => $userId |
| 1311 | 1798 | ]); |
| @@ -1310,13 +1797,18 @@ | ||
| 1310 | 1797 | 'user_id' => $userId |
| 1311 | 1798 | ]); |
| 1312 | 1799 | |
| 1313 | 1800 | if ($space->type == 'course') { |
| 1314 | - do_action('fluent_community/course/enrolled', $space, $userId, $by); | |
| 1801 | + if (!$space instanceof Course) { | |
| 1802 | + $space = Course::find($space->id); // we are renewing the model to have access to course relations | |
| 1803 | + } | |
| 1804 | + do_action('fluent_community/course/enrolled', $space, $userId, $by, $created); | |
| 1315 | 1805 | } else { |
| 1316 | - do_action('fluent_community/space/joined', $space, $userId, $by); | |
| 1806 | + if (!$space instanceof Space) { | |
| 1807 | + $space = Space::find($space->id); // we are renewing the model to have access to space relations | |
| 1808 | + } | |
| 1809 | + do_action('fluent_community/space/joined', $space, $userId, $by, $created); | |
| 1317 | 1810 | } |
| 1318 | - | |
| 1319 | 1811 | return true; |
| 1320 | 1812 | } |
| 1321 | 1813 | |
| 1322 | 1814 | /** |
| @@ -1334,26 +1826,40 @@ | ||
| 1334 | 1826 | return false; |
| 1335 | 1827 | } |
| 1336 | 1828 | |
| 1337 | 1829 | if (is_numeric($space)) { |
| 1338 | - $space = Space::find($space); | |
| 1830 | + $space = BaseSpace::query()->onlyMain()->find($space); | |
| 1339 | 1831 | } |
| 1340 | 1832 | |
| 1341 | - if (!$space) { | |
| 1833 | + if (!$space || !$space instanceof BaseSpace) { | |
| 1342 | 1834 | return false; |
| 1343 | 1835 | } |
| 1344 | 1836 | |
| 1837 | + | |
| 1345 | 1838 | if (!self::isUserInSpace($userId, $space->id)) { |
| 1346 | 1839 | return false; |
| 1347 | 1840 | } |
| 1348 | 1841 | |
| 1349 | - SpaceUserPivot::bySpace($space->id) | |
| 1350 | - ->byUser(get_current_user_id()) | |
| 1842 | + SpaceUserPivot::where('space_id', $space->id) | |
| 1843 | + ->where('user_id', $userId) | |
| 1351 | 1844 | ->delete(); |
| 1352 | 1845 | |
| 1353 | 1846 | $user->cacheAccessSpaces(); |
| 1354 | - do_action('fluent_community/space/user_left', $space, get_current_user_id(), $by); | |
| 1355 | 1847 | |
| 1848 | + if ($space->type == 'course') { | |
| 1849 | + if (!$space instanceof Course) { | |
| 1850 | + $space = Course::find($space->id); // we are renewing the model to have access to course relations | |
| 1851 | + } | |
| 1852 | + | |
| 1853 | + do_action('fluent_community/course/student_left', $space, $userId, $by); | |
| 1854 | + } else { | |
| 1855 | + if (!$space instanceof Space) { | |
| 1856 | + $space = Space::find($space->id); | |
| 1857 | + } | |
| 1858 | + // we are renewing the model to have access to space relations | |
| 1859 | + do_action('fluent_community/space/user_left', $space, $userId, $by); | |
| 1860 | + } | |
| 1861 | + | |
| 1356 | 1862 | return true; |
| 1357 | 1863 | } |
| 1358 | 1864 | |
| 1359 | 1865 | /** |
| @@ -1365,21 +1871,29 @@ | ||
| 1365 | 1871 | * @param bool $renderIcon Whether to render the icon or not. |
| 1366 | 1872 | */ |
| 1367 | 1873 | public static function renderLink($link, $linkClass = '', $fallback = '<span class="fcom_no_avatar"></span>', $renderIcon = true) |
| 1368 | 1874 | { |
| 1875 | + if (!$link || empty($link['permalink'])) { | |
| 1876 | + return; | |
| 1877 | + } | |
| 1878 | + | |
| 1879 | + $isCustom = Arr::get($link, 'is_custom') == 'yes'; | |
| 1880 | + | |
| 1369 | 1881 | $linkAtts = array_filter([ |
| 1370 | - 'class' => trim($linkClass . ' ' . Arr::get($link, 'link_classes')) . ' fcom_compt_link', | |
| 1882 | + 'class' => trim($linkClass . ' ' . Arr::get($link, 'link_classes')) . ' fcom_compt_link' . ($isCustom ? ' fcom_custom_link' : ''), | |
| 1371 | 1883 | 'target' => Arr::get($link, 'new_tab') === 'yes' ? '_blank' : '', |
| 1372 | 1884 | 'rel' => Arr::get($link, 'new_tab') === 'yes' ? 'noopener noreferrer' : '', |
| 1373 | 1885 | ]); |
| 1886 | + | |
| 1374 | 1887 | ?> |
| 1375 | - <a aria-label="Go to <?php echo esc_attr(Arr::get($link, 'title')); ?> page" | |
| 1376 | - href="<?php echo esc_url($link['permalink']); ?>" <?php foreach ($linkAtts as $key => $value) { | |
| 1377 | - echo esc_attr($key) . '="' . esc_attr($value) . '"'; | |
| 1888 | + <a data-fcom-hint="<?php echo esc_attr(Arr::get($link, 'title')); ?>" | |
| 1889 | + href="<?php echo esc_url($link['permalink']); ?>"<?php foreach ($linkAtts as $key => $value) { | |
| 1890 | + echo ' ' . esc_attr($key) . '="' . esc_attr($value) . '"'; | |
| 1378 | 1891 | } ?>> |
| 1379 | 1892 | <?php $renderIcon && self::printLinkIcon($link, $fallback); ?> |
| 1380 | - <span class="community_name"><?php echo wp_kses_post(Arr::get($link, 'title')); ?></span> | |
| 1381 | - <?php if (Arr::get($link, 'show_lock')): ?> | |
| 1893 | + <?php // The native title sits on the label span (not the anchor) so it can not duplicate the link's accessible name for screen readers. ?> | |
| 1894 | + <span class="community_name" title="<?php echo esc_attr(Arr::get($link, 'title')); ?>"><?php echo wp_kses_post((string) Arr::get($link, 'title', '')); ?></span> | |
| 1895 | + <?php if (Arr::get($link, 'show_lock')) : ?> | |
| 1382 | 1896 | <span class="fcom_space_lock"> |
| 1383 | 1897 | <i class="el-icon"> |
| 1384 | 1898 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"> |
| 1385 | 1899 | <path fill="currentColor" |
| @@ -1388,14 +1902,49 @@ | ||
| 1388 | 1902 | d="M512 544a32 32 0 0 1 32 32v192a32 32 0 1 1-64 0V576a32 32 0 0 1 32-32m192-160v-64a192 192 0 1 0-384 0v64zM512 64a256 256 0 0 1 256 256v128H256V320A256 256 0 0 1 512 64"></path> |
| 1389 | 1903 | </svg> |
| 1390 | 1904 | </i> |
| 1391 | 1905 | </span> |
| 1906 | + <?php elseif ($unreadBardge = Arr::get($link, 'unread_badge')) : ?> | |
| 1907 | + <span class="fcom_space_lock fcom_unread_count"> | |
| 1908 | + <?php echo wp_kses_post($unreadBardge); ?> | |
| 1909 | + </span> | |
| 1392 | 1910 | <?php endif; ?> |
| 1393 | - | |
| 1394 | 1911 | </a> |
| 1395 | 1912 | <?php |
| 1396 | 1913 | } |
| 1397 | 1914 | |
| 1915 | + public static function renderMenuItems($menuItems, $linkClass, $fallback = '', $renderIcon = false) | |
| 1916 | + { | |
| 1917 | + if (!$menuItems) { | |
| 1918 | + return; | |
| 1919 | + } | |
| 1920 | + | |
| 1921 | + $renderIcon = $renderIcon || Utility::isCustomizationEnabled('icon_on_header_menu'); | |
| 1922 | + | |
| 1923 | + foreach ($menuItems as $itemKey => $item): ?> | |
| 1924 | + <li class="<?php echo esc_attr('fcom_menu_item_' . $itemKey); ?>"> | |
| 1925 | + <?php self::renderLink($item, $linkClass, $fallback, $renderIcon); ?> | |
| 1926 | + </li> | |
| 1927 | + <?php endforeach; | |
| 1928 | + } | |
| 1929 | + | |
| 1930 | + public static function renderSettingsItems($settingsItems = []) | |
| 1931 | + { | |
| 1932 | + foreach ($settingsItems as $itemKey => $item): ?> | |
| 1933 | + <li class="<?php echo esc_attr('fcom_menu_item_' . $itemKey); ?>"> | |
| 1934 | + <a class="fcom_menu_link <?php echo esc_attr(Arr::get($item, 'link_classes')); ?>" | |
| 1935 | + href="<?php echo esc_url($item['permalink']); ?>"> | |
| 1936 | + <?php if (!empty($item['icon_svg'])): ?> | |
| 1937 | + <i class="el-icon"> | |
| 1938 | + <?php echo CustomSanitizer::sanitizeSvg(Arr::get($item, 'el-icon', '')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> | |
| 1939 | + </i> | |
| 1940 | + <?php endif; ?> | |
| 1941 | + <span class="community_name"><?php echo wp_kses_post($item['title']); ?></span> | |
| 1942 | + </a> | |
| 1943 | + </li> | |
| 1944 | + <?php endforeach; | |
| 1945 | + } | |
| 1946 | + | |
| 1398 | 1947 | /** |
| 1399 | 1948 | * Print a link icon. |
| 1400 | 1949 | * |
| 1401 | 1950 | * @param array $link The link data. |
| @@ -1453,9 +2002,12 @@ | ||
| 1453 | 2002 | // most probably it's local reverse proxy |
| 1454 | 2003 | if (isset($_SERVER["HTTP_CLIENT_IP"])) { |
| 1455 | 2004 | $ipAddress = sanitize_text_field(wp_unslash($_SERVER["HTTP_CLIENT_IP"])); |
| 1456 | 2005 | } else if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) { |
| 1457 | - $ipAddress = (string)rest_is_ip_address(trim(current(preg_split('/,/', sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR'])))))); | |
| 2006 | + $forwardedIp = trim(current(preg_split('/,/', sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR']))))); | |
| 2007 | + if (rest_is_ip_address($forwardedIp)) { | |
| 2008 | + $ipAddress = $forwardedIp; | |
| 2009 | + } | |
| 1458 | 2010 | } |
| 1459 | 2011 | } |
| 1460 | 2012 | |
| 1461 | 2013 | if (!$ipAddress) { |
| @@ -1578,8 +2130,14 @@ | ||
| 1578 | 2130 | public static function getPortalRequestPath($requestUri) |
| 1579 | 2131 | { |
| 1580 | 2132 | $portalSlug = self::getPortalSlug(); |
| 1581 | 2133 | |
| 2134 | + // If portal is mounted at site root with empty requestUri, ignore query-only requests that do not relate to the community portal. | |
| 2135 | + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing check on $_GET, no state mutation | |
| 2136 | + if ($portalSlug === '' && $requestUri === '' && !empty($_GET) && !self::hasSupportedQueryParam()) { | |
| 2137 | + return false; | |
| 2138 | + } | |
| 2139 | + | |
| 1582 | 2140 | if ($portalSlug == $requestUri) { |
| 1583 | 2141 | return 'portal_home'; |
| 1584 | 2142 | } |
| 1585 | 2143 | |
| @@ -1594,26 +2152,422 @@ | ||
| 1594 | 2152 | |
| 1595 | 2153 | $parts = explode('/', $requestUri); |
| 1596 | 2154 | $start = $parts[0]; |
| 1597 | 2155 | |
| 1598 | - $routeStats = [ | |
| 1599 | - 'fcom_route', | |
| 1600 | - 'portal_home', | |
| 1601 | - 'admin', | |
| 1602 | - 'post', | |
| 1603 | - 'space', | |
| 1604 | - 'discover', | |
| 1605 | - 'members', | |
| 1606 | - 'u', | |
| 1607 | - 'notifications', | |
| 1608 | - 'bookmarks', | |
| 1609 | - 'courses', | |
| 1610 | - 'course', | |
| 1611 | - 'leaderboards' | |
| 1612 | - ]; | |
| 2156 | + if (!$portalSlug && $start == 'fcom_route') { | |
| 2157 | + return $start; | |
| 2158 | + } | |
| 1613 | 2159 | |
| 2160 | + $routeStats = self::portalRoutePaths(); | |
| 2161 | + | |
| 1614 | 2162 | if (in_array($start, $routeStats)) { |
| 1615 | 2163 | return $requestUri; |
| 1616 | 2164 | } |
| 2165 | + | |
| 1617 | 2166 | return false; |
| 2167 | + } | |
| 2168 | + | |
| 2169 | + /** | |
| 2170 | + * Check if the current request has any supported query parameter. | |
| 2171 | + * | |
| 2172 | + * @return bool | |
| 2173 | + */ | |
| 2174 | + private static function hasSupportedQueryParam() | |
| 2175 | + { | |
| 2176 | + $supportedParams = (array) apply_filters('fluent_community/portal_supported_query_params', [ | |
| 2177 | + 'customizer_panel', | |
| 2178 | + 'create_space' | |
| 2179 | + ]); | |
| 2180 | + | |
| 2181 | + // phpcs:ignore WordPress.Security.NonceVerification.Recommended | |
| 2182 | + foreach (array_keys($_GET) as $key) { | |
| 2183 | + if (strpos($key, 'fcom_') === 0) { | |
| 2184 | + return true; | |
| 2185 | + } | |
| 2186 | + if (in_array($key, $supportedParams, true)) { | |
| 2187 | + return true; | |
| 2188 | + } | |
| 2189 | + } | |
| 2190 | + return false; | |
| 2191 | + } | |
| 2192 | + | |
| 2193 | + public static function getTopicsConfig() | |
| 2194 | + { | |
| 2195 | + $config = Utility::getOption('topics_config', []); | |
| 2196 | + $default = [ | |
| 2197 | + 'max_topics_per_post' => 1, | |
| 2198 | + 'max_topics_per_space' => 20, | |
| 2199 | + 'show_on_post_card' => 'yes' | |
| 2200 | + ]; | |
| 2201 | + return wp_parse_args($config, $default); | |
| 2202 | + } | |
| 2203 | + | |
| 2204 | + public static function getModerationConfig() | |
| 2205 | + { | |
| 2206 | + $config = Utility::getOption('moderation_config', []); | |
| 2207 | + | |
| 2208 | + $default = [ | |
| 2209 | + 'is_enabled' => 'no', | |
| 2210 | + 'profanity_filter' => "", | |
| 2211 | + 'flag_after_threshold' => 0, | |
| 2212 | + 'flag_all_new_posts' => 'no', | |
| 2213 | + 'first_post_approval' => 'no', | |
| 2214 | + 'first_comment_approval' => 'no', | |
| 2215 | + 'flag_all_new_posts_spaces' => [], | |
| 2216 | + 'auto_flag_user_reject_threshold' => 0, | |
| 2217 | + 'auto_flag_user_report_threshold' => 0, | |
| 2218 | + ]; | |
| 2219 | + | |
| 2220 | + return wp_parse_args($config, $default); | |
| 2221 | + } | |
| 2222 | + | |
| 2223 | + public static function getReportReasons() | |
| 2224 | + { | |
| 2225 | + return apply_filters('fluent_community/report_reasons', [ | |
| 2226 | + 'harassment' => __('Harassment', 'fluent-community'), | |
| 2227 | + 'spam' => __('Spam', 'fluent-community'), | |
| 2228 | + 'offensive' => __('Offensive', 'fluent-community'), | |
| 2229 | + 'incorrect_space' => __('Incorrect Space', 'fluent-community'), | |
| 2230 | + 'against_community' => __('Against Community Rules', 'fluent-community'), | |
| 2231 | + 'other' => __('Other', 'fluent-community'), | |
| 2232 | + ]); | |
| 2233 | + } | |
| 2234 | + | |
| 2235 | + public static function htmlToMd($html) | |
| 2236 | + { | |
| 2237 | + return preg_replace('/<a.*?href="(.*?)".*?>(.*?)<\/a>/', '[$2]($1)', $html); | |
| 2238 | + } | |
| 2239 | + | |
| 2240 | + public static function isProfanity($profanity, $text) | |
| 2241 | + { | |
| 2242 | + $profanity = explode(',', $profanity); | |
| 2243 | + if (empty($profanity)) { | |
| 2244 | + return false; | |
| 2245 | + } | |
| 2246 | + $profanity = array_map('trim', $profanity); | |
| 2247 | + $profanity = array_map(function ($word) { | |
| 2248 | + return mb_strtolower($word, 'UTF-8'); | |
| 2249 | + }, $profanity); | |
| 2250 | + $text = mb_strtolower($text, 'UTF-8'); | |
| 2251 | + | |
| 2252 | + // Convert words into a regex pattern (ensuring whole-word matching) | |
| 2253 | + $pattern = '/(?<!\p{L})(' . implode('|', array_map('preg_quote', $profanity)) . ')(?!\p{L})/iu'; | |
| 2254 | + | |
| 2255 | + if (preg_match($pattern, $text, $matches)) { | |
| 2256 | + return $matches[0]; | |
| 2257 | + } | |
| 2258 | + | |
| 2259 | + return false; | |
| 2260 | + } | |
| 2261 | + | |
| 2262 | + public static function getFullDayName($day) | |
| 2263 | + { | |
| 2264 | + $dayMap = [ | |
| 2265 | + 'sun' => 'sunday', | |
| 2266 | + 'mon' => 'monday', | |
| 2267 | + 'tue' => 'tuesday', | |
| 2268 | + 'wed' => 'wednesday', | |
| 2269 | + 'thu' => 'thursday', | |
| 2270 | + 'fri' => 'friday', | |
| 2271 | + 'sat' => 'saturday' | |
| 2272 | + ]; | |
| 2273 | + | |
| 2274 | + return isset($dayMap[$day]) ? $dayMap[$day] : $day . 'day'; | |
| 2275 | + } | |
| 2276 | + | |
| 2277 | + public static function getPostOrderOptions($context = 'feed') | |
| 2278 | + { | |
| 2279 | + $options = [ | |
| 2280 | + 'new_activity' => __('New Activity', 'fluent-community'), | |
| 2281 | + 'latest' => __('Latest', 'fluent-community'), | |
| 2282 | + 'oldest' => __('Oldest', 'fluent-community'), | |
| 2283 | + 'popular' => __('Popular', 'fluent-community'), | |
| 2284 | + 'likes' => __('Likes', 'fluent-community'), | |
| 2285 | + 'alphabetical' => __('Alphabetical', 'fluent-community'), | |
| 2286 | + 'unanswered' => __('Unanswered', 'fluent-community'), | |
| 2287 | + ]; | |
| 2288 | + | |
| 2289 | + return apply_filters('fluent_community/post_order_options', $options, $context); | |
| 2290 | + } | |
| 2291 | + | |
| 2292 | + public static function getCommentOrderOptions($context = 'comment') | |
| 2293 | + { | |
| 2294 | + $options = [ | |
| 2295 | + 'oldest' => __('Earliest', 'fluent-community'), | |
| 2296 | + 'latest' => __('Latest', 'fluent-community'), | |
| 2297 | + 'popular' => __('Popular', 'fluent-community'), | |
| 2298 | + 'most_replied' => __('Most Replied', 'fluent-community'), | |
| 2299 | + ]; | |
| 2300 | + | |
| 2301 | + return apply_filters('fluent_community/comment_order_options', $options, $context); | |
| 2302 | + } | |
| 2303 | + | |
| 2304 | + public static function convertPhpDateToDayJSFormay($phpFormat) | |
| 2305 | + { | |
| 2306 | + // Mapping PHP date format characters to Day.js format characters | |
| 2307 | + $replacements = [ | |
| 2308 | + // Day | |
| 2309 | + 'd' => 'DD', // Day of the month, 2 digits with leading zeros | |
| 2310 | + 'D' => 'ddd', // A textual representation of a day, three letters | |
| 2311 | + 'j' => 'D', // Day of the month without leading zeros | |
| 2312 | + 'l' => 'dddd', // A full textual representation of the day of the week | |
| 2313 | + 'N' => 'E', // ISO-8601 numeric representation of the day of the week | |
| 2314 | + 'S' => 'o', // English ordinal suffix for the day of the month, 2 characters | |
| 2315 | + 'w' => 'd', // Numeric representation of the day of the week | |
| 2316 | + 'z' => 'DDD', // The day of the year (starting from 0) | |
| 2317 | + | |
| 2318 | + // Week | |
| 2319 | + 'W' => 'W', // ISO-8601 week number of year, weeks starting on Monday | |
| 2320 | + | |
| 2321 | + // Month | |
| 2322 | + 'F' => 'MMMM', // A full textual representation of a month | |
| 2323 | + 'm' => 'MM', // Numeric representation of a month, with leading zeros | |
| 2324 | + 'M' => 'MMM', // A short textual representation of a month, three letters | |
| 2325 | + 'n' => 'M', // Numeric representation of a month, without leading zeros | |
| 2326 | + 't' => '', // Not supported in Day.js (Number of days in the given month) | |
| 2327 | + | |
| 2328 | + // Year | |
| 2329 | + 'L' => '', // Not supported in Day.js (Whether it's a leap year) | |
| 2330 | + 'o' => 'GGGG', // ISO-8601 week-numbering year | |
| 2331 | + 'Y' => 'YYYY', // A full numeric representation of a year, 4 digits | |
| 2332 | + 'y' => 'YY', // A two digit representation of a year | |
| 2333 | + | |
| 2334 | + // Time | |
| 2335 | + 'a' => 'a', // Lowercase Ante meridiem and Post meridiem | |
| 2336 | + 'A' => 'A', // Uppercase Ante meridiem and Post meridiem | |
| 2337 | + 'B' => '', // Not supported in Day.js (Swatch Internet time) | |
| 2338 | + 'g' => 'h', // 12-hour format of an hour without leading zeros | |
| 2339 | + 'G' => 'H', // 24-hour format of an hour without leading zeros | |
| 2340 | + 'h' => 'hh', // 12-hour format of an hour with leading zeros | |
| 2341 | + 'H' => 'HH', // 24-hour format of an hour with leading zeros | |
| 2342 | + 'i' => 'mm', // Minutes with leading zeros | |
| 2343 | + 's' => 'ss', // Seconds with leading zeros | |
| 2344 | + 'u' => 'SSS', // Milliseconds (Day.js uses SSS for fractional seconds) | |
| 2345 | + 'v' => 'SSS', // Milliseconds (Day.js uses SSS for fractional seconds) | |
| 2346 | + | |
| 2347 | + // Timezone | |
| 2348 | + 'e' => '', // Not supported in Day.js (Timezone identifier) | |
| 2349 | + 'I' => '', // Not supported in Day.js (Whether or not the date is in daylight saving time) | |
| 2350 | + 'O' => 'ZZ', // Difference to Greenwich time (GMT) in hours | |
| 2351 | + 'P' => 'Z', // Difference to Greenwich time (GMT) with colon between hours and minutes | |
| 2352 | + 'T' => '', // Not supported in Day.js (Timezone abbreviation) | |
| 2353 | + 'Z' => '', // Not supported in Day.js (Timezone offset in seconds) | |
| 2354 | + | |
| 2355 | + // Full Date/Time | |
| 2356 | + 'c' => 'YYYY-MM-DDTHH:mm:ssZ', // ISO 8601 date | |
| 2357 | + 'r' => 'ddd, DD MMM YYYY HH:mm:ss ZZ', // RFC 2822 formatted date | |
| 2358 | + 'U' => 'X', // Seconds since the Unix Epoch (January 1 1970 00:00:00 GMT) | |
| 2359 | + ]; | |
| 2360 | + | |
| 2361 | + // Replace each PHP date format character with Day.js equivalent | |
| 2362 | + $dayjsFormat = ""; | |
| 2363 | + | |
| 2364 | + for ($i = 0; $i < strlen($phpFormat); $i++) { | |
| 2365 | + $char = $phpFormat[$i]; | |
| 2366 | + | |
| 2367 | + // Special handling for G\hi pattern | |
| 2368 | + if ($char === 'G' && $i + 2 < strlen($phpFormat) && | |
| 2369 | + $phpFormat[$i + 1] === '\\' && $phpFormat[$i + 2] === 'h') { | |
| 2370 | + $dayjsFormat .= 'H[h]'; | |
| 2371 | + $i += 2; | |
| 2372 | + continue; | |
| 2373 | + } | |
| 2374 | + | |
| 2375 | + // Check if the character is escaped | |
| 2376 | + if ($char === "\\") { | |
| 2377 | + // Day.js escapes literal text with square brackets, not backslashes | |
| 2378 | + $i++; | |
| 2379 | + if ($i < strlen($phpFormat)) { | |
| 2380 | + $dayjsFormat .= "[" . $phpFormat[$i] . "]"; | |
| 2381 | + } | |
| 2382 | + continue; | |
| 2383 | + } | |
| 2384 | + | |
| 2385 | + // Add the mapped character or the character itself if not found in the mapping | |
| 2386 | + $dayjsFormat .= $replacements[$char] ?? $char; | |
| 2387 | + } | |
| 2388 | + | |
| 2389 | + return $dayjsFormat; | |
| 2390 | + } | |
| 2391 | + | |
| 2392 | + public static function getDateFormatter($isDayJs = false) | |
| 2393 | + { | |
| 2394 | + $format = get_option('date_format'); | |
| 2395 | + if ($isDayJs) { | |
| 2396 | + return self::convertPhpDateToDayJSFormay($format); | |
| 2397 | + } | |
| 2398 | + | |
| 2399 | + return $format; | |
| 2400 | + } | |
| 2401 | + | |
| 2402 | + public static function getTimeFormatter($isDayJs = false) | |
| 2403 | + { | |
| 2404 | + $format = get_option('time_format'); | |
| 2405 | + | |
| 2406 | + if ($isDayJs) { | |
| 2407 | + return self::convertPhpDateToDayJSFormay($format); | |
| 2408 | + } | |
| 2409 | + | |
| 2410 | + return $format; | |
| 2411 | + } | |
| 2412 | + | |
| 2413 | + public static function normalizeToAscii($text) | |
| 2414 | + { | |
| 2415 | + if (function_exists('transliterator_transliterate')) { | |
| 2416 | + $result = transliterator_transliterate('Any-Latin; Latin-ASCII', $text); | |
| 2417 | + if ($result !== false) { | |
| 2418 | + return $result; | |
| 2419 | + } | |
| 2420 | + } | |
| 2421 | + | |
| 2422 | + if (function_exists('iconv')) { | |
| 2423 | + $result = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text); | |
| 2424 | + if ($result !== false) { | |
| 2425 | + return $result; | |
| 2426 | + } | |
| 2427 | + } | |
| 2428 | + | |
| 2429 | + return $text; | |
| 2430 | + } | |
| 2431 | + | |
| 2432 | + public static function getPathFromUrl($url) | |
| 2433 | + { | |
| 2434 | + return rtrim((string) wp_parse_url($url, PHP_URL_PATH), '/'); | |
| 2435 | + } | |
| 2436 | + | |
| 2437 | + // Return the ID of the group that contains the current page | |
| 2438 | + public static function getActiveSidebarGroupId($groups) | |
| 2439 | + { | |
| 2440 | + if (empty($_SERVER['REQUEST_URI'])) { | |
| 2441 | + return ''; | |
| 2442 | + } | |
| 2443 | + $url=esc_url_raw(wp_unslash($_SERVER['REQUEST_URI'])); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated | |
| 2444 | + $currentPath = self::getPathFromUrl($url); | |
| 2445 | + if ($currentPath === '') { | |
| 2446 | + return ''; | |
| 2447 | + } | |
| 2448 | + | |
| 2449 | + foreach ($groups as $group) { | |
| 2450 | + $items = isset($group['children']) ? $group['children'] : ($group['items'] ?? []); | |
| 2451 | + foreach ($items as $item) { | |
| 2452 | + $item_path = isset($item['permalink']) ? self::getPathFromUrl($item['permalink']) : ''; | |
| 2453 | + | |
| 2454 | + if (is_array($item) && !empty($item['permalink']) && $item_path === $currentPath) { | |
| 2455 | + return sanitize_key((string) ($group['id'] ?? $group['slug'] ?? '')); | |
| 2456 | + } | |
| 2457 | + } | |
| 2458 | + } | |
| 2459 | + | |
| 2460 | + return ''; | |
| 2461 | + } | |
| 2462 | + | |
| 2463 | + // Explicit per-user choices from the cookie. Format: "c:1,2|e:3,4" (c = collapsed, e = expanded). | |
| 2464 | + public static function getSidebarGroupStates() | |
| 2465 | + { | |
| 2466 | + $collapsed = []; | |
| 2467 | + $expanded = []; | |
| 2468 | + $cookie = isset($_COOKIE['fcom_sidebar_group_states']) ? sanitize_text_field(wp_unslash($_COOKIE['fcom_sidebar_group_states'])) : ''; | |
| 2469 | + foreach (explode('|', $cookie) as $section) { | |
| 2470 | + list($flag, $ids) = array_pad(explode(':', $section, 2), 2, ''); | |
| 2471 | + $list = array_filter(array_map('sanitize_key', explode(',', $ids))); | |
| 2472 | + | |
| 2473 | + if ($flag === 'c') { | |
| 2474 | + $collapsed = $list; | |
| 2475 | + } elseif ($flag === 'e') { | |
| 2476 | + $expanded = $list; | |
| 2477 | + } | |
| 2478 | + } | |
| 2479 | + | |
| 2480 | + return [$collapsed, $expanded]; | |
| 2481 | + } | |
| 2482 | + | |
| 2483 | + public static function getCollapsedSidebarGroups($groups = []) | |
| 2484 | + { | |
| 2485 | + $isDefaultCollapse = Utility::isCustomizationEnabled('collapse_sidebar_groups'); | |
| 2486 | + list($collapsedByUser, $expandedByUser) = self::getSidebarGroupStates(); | |
| 2487 | + $activeGroupId = self::getActiveSidebarGroupId($groups); | |
| 2488 | + | |
| 2489 | + /** | |
| 2490 | + * Precendence. | |
| 2491 | + * 1. No group id or slug => Fallback to expanded | |
| 2492 | + * 2. Has Active Link => Always expanded | |
| 2493 | + * 3. Explicitly expanded by user => Always expanded | |
| 2494 | + * 4. Explicitly collapsed by user => Collapsed | |
| 2495 | + * 5. Default Collapse by setting => Collapsed | |
| 2496 | + * 6. Otherwise => Expanded | |
| 2497 | + */ | |
| 2498 | + | |
| 2499 | + $collapsed = []; | |
| 2500 | + foreach ($groups as $group) { | |
| 2501 | + $id = sanitize_key((string) ($group['id'] ?? $group['slug'] ?? '')); | |
| 2502 | + // Matching expanded condition (1,2,3) | |
| 2503 | + if ($id === '' || $id === $activeGroupId || in_array($id, $expandedByUser, true)) { | |
| 2504 | + continue; | |
| 2505 | + } | |
| 2506 | + // Matching collapsed condition (4,5) | |
| 2507 | + if ($isDefaultCollapse || in_array($id, $collapsedByUser, true)) { | |
| 2508 | + $collapsed[] = $id; | |
| 2509 | + } | |
| 2510 | + // else (6) => expanded, do nothing | |
| 2511 | + } | |
| 2512 | + | |
| 2513 | + return $collapsed; | |
| 2514 | + } | |
| 2515 | + | |
| 2516 | + public static function getUndeliverableEmails($emails) | |
| 2517 | + { | |
| 2518 | + if (!$emails || !defined('FLUENTCRM')) { | |
| 2519 | + return []; | |
| 2520 | + } | |
| 2521 | + | |
| 2522 | + if (Utility::getPrivacySetting('skip_crm_undeliverable_emails') != 'yes') { | |
| 2523 | + return []; | |
| 2524 | + } | |
| 2525 | + | |
| 2526 | + /** | |
| 2527 | + * FluentCRM contact statuses that FluentCommunity treats as undeliverable. | |
| 2528 | + * Emails to contacts with these statuses are skipped for notification emails. | |
| 2529 | + * | |
| 2530 | + * @param array $statuses Contact statuses to skip. Default: bounced, complained, spammed. | |
| 2531 | + */ | |
| 2532 | + $skippableStatuses = apply_filters('fluent_community/undeliverable_crm_contact_statuses', ['bounced', 'complained', 'spammed']); | |
| 2533 | + | |
| 2534 | + if (!$skippableStatuses) { | |
| 2535 | + return []; | |
| 2536 | + } | |
| 2537 | + | |
| 2538 | + $undeliverableEmails = Contact::whereIn('email', $emails) | |
| 2539 | + ->whereIn('status', $skippableStatuses) | |
| 2540 | + ->pluck('email') | |
| 2541 | + ->toArray(); | |
| 2542 | + | |
| 2543 | + return array_map('strtolower', $undeliverableEmails); | |
| 2544 | + } | |
| 2545 | + | |
| 2546 | + public static function getCrmUndeliverableStatus($email) | |
| 2547 | + { | |
| 2548 | + if (!$email || !defined('FLUENTCRM')) { | |
| 2549 | + return ''; | |
| 2550 | + } | |
| 2551 | + | |
| 2552 | + if (Utility::getPrivacySetting('skip_crm_undeliverable_emails') != 'yes') { | |
| 2553 | + return ''; | |
| 2554 | + } | |
| 2555 | + | |
| 2556 | + $skippableStatuses = apply_filters('fluent_community/undeliverable_crm_contact_statuses', ['bounced', 'complained', 'spammed']); | |
| 2557 | + | |
| 2558 | + if (!$skippableStatuses) { | |
| 2559 | + return ''; | |
| 2560 | + } | |
| 2561 | + | |
| 2562 | + $contact = Contact::where('email', $email) | |
| 2563 | + ->whereIn('status', $skippableStatuses) | |
| 2564 | + ->first(); | |
| 2565 | + | |
| 2566 | + return $contact ? $contact->status : ''; | |
| 2567 | + } | |
| 2568 | + | |
| 2569 | + public static function isUndeliverableEmail($email) | |
| 2570 | + { | |
| 2571 | + return (bool)self::getCrmUndeliverableStatus($email); | |
| 1618 | 2572 | } |
| 1619 | 2573 | } |