PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.10.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.10.0
2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 1.1.0 All 77 releases
← All changes | app/Services/Helper.php +376 -42 2.4.012.10.0 View file →
@@ -5,8 +5,9 @@
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;
9 10 use FluentCommunity\App\Models\Feed;
10 11 use FluentCommunity\App\Models\Space;
11 12 use FluentCommunity\App\Models\Media;
12 13 use FluentCommunity\App\Models\Meta;
@@ -28,8 +29,30 @@
28 29 return apply_filters('fluent_community/is_rtl', is_rtl());
29 30 }
30 31
31 32 /**
33 + * Run a callback inside a database transaction.
34 + *
35 + * @param callable $callback
36 + * @return mixed
37 + * @throws \Exception
38 + */
39 + public static function dbTransaction($callback)
40 + {
41 + $db = App::make('db');
42 + $db->beginTransaction();
43 +
44 + try {
45 + $result = $callback();
46 + $db->commit();
47 + return $result;
48 + } catch (\Exception $e) {
49 + $db->rollBack();
50 + throw $e;
51 + }
52 + }
53 +
54 + /**
32 55 * Check if POST content length exceeds PHP limits
33 56 *
34 57 * @return array|false Error array if limit exceeded, false otherwise
35 58 */
@@ -38,8 +61,9 @@
38 61 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Server variable check
39 62 $contentLength = isset($_SERVER['CONTENT_LENGTH']) ? (int) $_SERVER['CONTENT_LENGTH'] : 0;
40 63 $postMaxSize = wp_convert_hr_to_bytes(ini_get('post_max_size'));
41 64
65 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only existence check on superglobals, no state mutation
42 66 if ($contentLength > 0 && ($contentLength > $postMaxSize || (empty($_FILES) && empty($_POST)))) {
43 67 return [
44 68 'message' => sprintf(
45 69 /* translators: %s: max size */
@@ -127,8 +151,81 @@
127 151 $status = Utility::isCustomizationEnabled('dark_mode');
128 152 return apply_filters('fluent_community/has_color_scheme', $status);
129 153 }
130 154
155 + /**
156 + * Admin default theme mode: 'light', 'dark', or 'system'.
157 + */
158 + public static function getDefaultThemeMode()
159 + {
160 + $settings = Utility::getCustomizationSettings();
161 + $mode = isset($settings['default_theme_mode']) ? $settings['default_theme_mode'] : 'light';
162 +
163 + if (!in_array($mode, ['light', 'dark', 'system'], true)) {
164 + $mode = 'light';
165 + }
166 +
167 + return apply_filters('fluent_community/default_theme_mode', $mode);
168 + }
169 +
170 + /**
171 + * Pre-paint script that sets the theme before first render (no flash).
172 + * Precedence mirrors runtime: host-theme cookie → user pick → admin default.
173 + * Not persisted. Gate on hasColorScheme().
174 + */
175 + public static function renderColorSchemePrePaintScript()
176 + {
177 + $defaultMode = self::getDefaultThemeMode();
178 + $portalVars = apply_filters('fluent_community/general_portal_vars', ['color_switch_cookie_name' => '']);
179 + $cookieName = isset($portalVars['color_switch_cookie_name']) ? $portalVars['color_switch_cookie_name'] : '';
180 + ?>
181 + <script>
182 + (function () {
183 + var root = document.documentElement;
184 + var cookieName = '<?php echo esc_js($cookieName); ?>';
185 + var mode = null;
186 +
187 + // host-theme integration cookie (Blocksy/Kadence) wins when present
188 + if (cookieName) {
189 + var safeName = cookieName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
190 + var match = document.cookie.match('(?:^|; )' + safeName + '=([^;]*)');
191 + if (match) {
192 + var cookieMode = decodeURIComponent(match[1]);
193 + if (cookieMode === 'dark' || cookieMode === 'light') {
194 + mode = cookieMode;
195 + }
196 + }
197 + }
198 +
199 + // explicit user pick
200 + if (!mode) {
201 + try {
202 + var stored = JSON.parse(localStorage.getItem('fcom_global_storage') || '{}').fcom_color_mode;
203 + if (stored === 'dark' || stored === 'light') {
204 + mode = stored;
205 + }
206 + } catch (error) {}
207 + }
208 +
209 + // admin default
210 + if (!mode) {
211 + var defaultMode = '<?php echo esc_js($defaultMode); ?>';
212 + if (defaultMode === 'system') {
213 + mode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
214 + } else {
215 + mode = defaultMode;
216 + }
217 + }
218 +
219 + root.setAttribute('data-color-mode', mode === 'dark' ? 'dark' : 'light');
220 + if (mode === 'dark') {
221 + root.classList.add('dark');
222 + }
223 + })();
224 + </script>
225 + <?php
226 + }
227 +
131 228 public static function isSuperAdmin($userId = null)
132 229 {
133 230 $capability = apply_filters('fluent_community/super_admin_capability', 'manage_options');
134 231
@@ -150,8 +247,9 @@
150 247 /**
151 248 * Check if the user is a site admin.
152 249 *
153 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.
154 252 * @return bool True if the user is a site admin, false otherwise.
155 253 */
156 254 public static function isSiteAdmin($userId = null, $user = null)
157 255 {
@@ -159,9 +257,11 @@
159 257 return true;
160 258 }
161 259
162 260 if (!$user) {
163 - $user = self::getCurrentUser();
261 + $user = ($userId && (int)$userId !== get_current_user_id())
262 + ? User::find($userId)
263 + : self::getCurrentUser();
164 264 }
165 265
166 266 return $user && Arr::get($user->getPermissions(), 'community_admin');
167 267 }
@@ -420,9 +520,9 @@
420 520 *
421 521 * @param int|null $userId The user ID. If null, uses the current user.
422 522 * @return bool True if the user can access the portal, false otherwise.
423 523 */
424 - public static function canAccessPortal($userId = null)
524 + public static function canAccessPortal($userId = null, $requireActiveProfile = true)
425 525 {
426 526 $settings = self::generalSettings();
427 527 $accessLevel = Arr::get($settings, 'access.acess_level');
428 528
@@ -459,8 +559,12 @@
459 559 if (!$result) {
460 560 return apply_filters('fluent_community/can_access_portal', false);
461 561 }
462 562
563 + if (!$requireActiveProfile) {
564 + return apply_filters('fluent_community/can_access_portal', true);
565 + }
566 +
463 567 $xProfile = Helper::getCurrentProfile();
464 568
465 569 $result = $xProfile && $xProfile->status == 'active';
466 570
@@ -530,16 +634,14 @@
530 634 if (!$userId) {
531 635 return false;
532 636 }
533 637
534 - static $user;
535 - if ($user && $cached) {
536 - return $user;
638 + static $users = [];
639 + if ($cached && isset($users[$userId])) {
640 + return $users[$userId];
537 641 }
538 642
539 - $user = User::find($userId);
540 -
541 - return $user;
643 + return $users[$userId] = User::find($userId);
542 644 }
543 645
544 646 /**
545 647 * Get the route paths for the community.
@@ -651,8 +753,31 @@
651 753 return false;
652 754 }
653 755
654 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 + /**
655 780 * Get a human-readable excerpt from content.
656 781 *
657 782 * @param string $content The content to extract from.
658 783 * @param int $length The maximum length of the excerpt.
@@ -673,12 +798,12 @@
673 798 // Blockquotes: remove '>' symbol
674 799 '/^\s*>\s?/m' => '',
675 800 // Horizontal rules: replace with empty line
676 801 '/^\s*([-*_])\1{2,}\s*$/m' => "\n",
802 + // Images: keep only the alt text (run before links)
803 + '/!\[([^\]]*)\]\([^\)]+\)/' => '$1',
677 804 // Links: keep only the link text
678 - '/\[([^\]]+)\]\([^\)]+\)/' => '$1',
679 - // Images: keep only the alt text
680 - '/!\[([^\]]+)\]\([^\)]+\)/' => '$1',
805 + '/\[([^\]]*)\]\([^\)]+\)/' => '$1',
681 806 // Strikethrough: remove '~~' symbols
682 807 '/~~(.*?)~~/' => '$1',
683 808 // Task lists: remove checkbox syntax
684 809 '/^\s*[-*+]\s+\[[ xX]\]\s+/m' => '',
@@ -685,8 +810,10 @@
685 810 ];
686 811
687 812 $content = preg_replace(array_keys($patterns), array_values($patterns), $content);
688 813
814 + $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
815 +
689 816 // remove all tags
690 817 $content = wp_strip_all_tags($content);
691 818 // remove new lines and tabs
692 819 $content = str_replace(["\r", "\n", "\t"], ' ', $content);
@@ -771,9 +898,9 @@
771 898 foreach ($communityGroups as $communityGroup) {
772 899 $validSpaces = [];
773 900 $spaces = $communityGroup->spaces;
774 901 $isShowAll = Arr::get($communityGroup->settings, 'always_show_spaces') === 'yes';
775 -
902 +
776 903 if (!$isShowAll && !$isSpaceModerator) {
777 904 $spaceIds = $spaces->pluck('id')->toArray();
778 905 $isNotMemberOfAnySpace = empty(array_intersect($spaceIds, $userSpaceIds));
779 906 if ($isNotMemberOfAnySpace) {
@@ -780,8 +907,12 @@
780 907 continue;
781 908 }
782 909 }
783 910
911 + if ($user) {
912 + BaseSpace::preloadMemberships($spaces, $user->ID);
913 + }
914 +
784 915 foreach ($spaces as $space) {
785 916 $validSpace = $view ? self::transformSpaceToLink($space, $user) : $space;
786 917 if (!$validSpace) {
787 918 continue;
@@ -1006,9 +1137,9 @@
1006 1137 if ($menuGroups && $context === 'view') {
1007 1138 return $menuGroups;
1008 1139 }
1009 1140
1010 - $menuGroups = Utility::getOption('fluent_community_menu_groups', []);
1141 + $menuGroups = (array) Utility::getOption('fluent_community_menu_groups', []);
1011 1142
1012 1143 $membersPageStatus = Utility::canViewMembersPage() ? 'yes' : 'no';
1013 1144
1014 1145 $leaderboardPageVisibility = (Utility::canViewLeaderboardMembers() || is_user_logged_in()) ? 'yes' : 'no';
@@ -1218,11 +1349,33 @@
1218 1349
1219 1350 return $menuGroups;
1220 1351 }
1221 1352
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 +
1222 1375 public static function isLinkAccessible($link, $currentUser = null)
1223 1376 {
1224 - $isEnabled = Arr::get($link, 'enabled') === 'yes';
1377 + $isEnabled = Arr::get($link, 'enabled', 'yes') === 'yes';
1225 1378 $isUnavailable = Arr::get($link, 'is_unavailable') === 'yes';
1226 1379
1227 1380 if (!$isEnabled || $isUnavailable) {
1228 1381 return false;
@@ -1250,14 +1403,16 @@
1250 1403 if (!$currentUser) {
1251 1404 return false;
1252 1405 }
1253 1406
1254 - static $userSpacesIds = null;
1255 - if ($userSpacesIds === null) {
1256 - $userSpacesIds = $currentUser->getJoinedSpaceIds();
1407 + static $userSpacesIds = [];
1408 + if (!isset($userSpacesIds[$currentUser->ID])) {
1409 + $userSpacesIds[$currentUser->ID] = $currentUser->getJoinedSpaceIds();
1257 1410 }
1258 1411
1259 - return $userSpacesIds && !!array_intersect($userSpacesIds, $membershipIds);
1412 + $ids = $userSpacesIds[$currentUser->ID];
1413 +
1414 + return $ids && !!array_intersect($ids, $membershipIds);
1260 1415 }
1261 1416
1262 1417 /**
1263 1418 * Get the meta data for a space.
@@ -1459,22 +1614,33 @@
1459 1614 public static function getMobileMenuItems($context = 'headless')
1460 1615 {
1461 1616 $xprofile = Helper::getCurrentProfile();
1462 1617
1463 - $mobileMenuItems = [
1464 - [
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[] = [
1465 1636 'route' => [
1466 - 'name' => 'all_feeds'
1637 + 'name' => $slug
1467 1638 ],
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 - ];
1639 + 'title' => Arr::get($menuItem, 'title'),
1640 + 'icon_svg' => $iconSvg ? CustomSanitizer::sanitizeSvg($iconSvg) : $defaultIcon
1641 + ];
1642 + }
1477 1643
1478 1644 if ($xprofile) {
1479 1645 $mobileMenuItems[] = [
1480 1646 'route' => [
@@ -1482,13 +1648,15 @@
1482 1648 'params' => [
1483 1649 'username' => $xprofile->username
1484 1650 ]
1485 1651 ],
1652 + 'title' => __('Profile', 'fluent-community'),
1486 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>'
1487 1654 ];
1488 1655 } else if (!get_current_user_id()) {
1489 1656 $mobileMenuItems[] = [
1490 1657 'name' => 'login',
1658 + 'title' => __('Login', 'fluent-community'),
1491 1659 'permalink' => Helper::getAuthUrl(),
1492 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>'
1493 1661 ];
1494 1662 }
@@ -1566,9 +1734,9 @@
1566 1734
1567 1735 /**
1568 1736 * Add a user to a space.
1569 1737 *
1570 - * @param Space | int $space space to add the user to.
1738 + * @param BaseSpace|int $space space to add the user to.
1571 1739 * @param int $userId The ID of the user to add.
1572 1740 * @param string $role The role of the user in the space.
1573 1741 * @param string $by The source of the action.
1574 1742 * @return bool True if the user was added, false otherwise.
@@ -1716,14 +1884,15 @@
1716 1884 'rel' => Arr::get($link, 'new_tab') === 'yes' ? 'noopener noreferrer' : '',
1717 1885 ]);
1718 1886
1719 1887 ?>
1720 - <a aria-label="Go to <?php echo esc_attr(Arr::get($link, 'title')); ?> page"
1888 + <a data-fcom-hint="<?php echo esc_attr(Arr::get($link, 'title')); ?>"
1721 1889 href="<?php echo esc_url($link['permalink']); ?>"<?php foreach ($linkAtts as $key => $value) {
1722 - echo esc_attr($key) . '="' . esc_attr($value) . '"';
1890 + echo ' ' . esc_attr($key) . '="' . esc_attr($value) . '"';
1723 1891 } ?>>
1724 1892 <?php $renderIcon && self::printLinkIcon($link, $fallback); ?>
1725 - <span class="community_name"><?php echo wp_kses_post(Arr::get($link, 'title')); ?></span>
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>
1726 1895 <?php if (Arr::get($link, 'show_lock')) : ?>
1727 1896 <span class="fcom_space_lock">
1728 1897 <i class="el-icon">
1729 1898 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
@@ -1962,8 +2131,9 @@
1962 2131 {
1963 2132 $portalSlug = self::getPortalSlug();
1964 2133
1965 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
1966 2136 if ($portalSlug === '' && $requestUri === '' && !empty($_GET) && !self::hasSupportedQueryParam()) {
1967 2137 return false;
1968 2138 }
1969 2139
@@ -2035,14 +2205,17 @@
2035 2205 {
2036 2206 $config = Utility::getOption('moderation_config', []);
2037 2207
2038 2208 $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' => [],
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,
2045 2218 ];
2046 2219
2047 2220 return wp_parse_args($config, $default);
2048 2221 }
@@ -2200,12 +2373,12 @@
2200 2373 }
2201 2374
2202 2375 // Check if the character is escaped
2203 2376 if ($char === "\\") {
2204 - // Add the next character to the result as is, without mapping
2377 + // Day.js escapes literal text with square brackets, not backslashes
2205 2378 $i++;
2206 2379 if ($i < strlen($phpFormat)) {
2207 - $dayjsFormat .= "\\" . $phpFormat[$i];
2380 + $dayjsFormat .= "[" . $phpFormat[$i] . "]";
2208 2381 }
2209 2382 continue;
2210 2383 }
2211 2384
@@ -2234,6 +2407,167 @@
2234 2407 return self::convertPhpDateToDayJSFormay($format);
2235 2408 }
2236 2409
2237 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);
2238 2572 }
2239 2573 }