PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.10.01
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.10.01
2.11.0 2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 All 78 releases
← All changes | app/Services/Helper.php +354 -42 2.6.012.10.01 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 */
@@ -128,8 +151,81 @@
128 151 $status = Utility::isCustomizationEnabled('dark_mode');
129 152 return apply_filters('fluent_community/has_color_scheme', $status);
130 153 }
131 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 +
132 228 public static function isSuperAdmin($userId = null)
133 229 {
134 230 $capability = apply_filters('fluent_community/super_admin_capability', 'manage_options');
135 231
@@ -151,8 +247,9 @@
151 247 /**
152 248 * Check if the user is a site admin.
153 249 *
154 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.
155 252 * @return bool True if the user is a site admin, false otherwise.
156 253 */
157 254 public static function isSiteAdmin($userId = null, $user = null)
158 255 {
@@ -160,9 +257,11 @@
160 257 return true;
161 258 }
162 259
163 260 if (!$user) {
164 - $user = self::getCurrentUser();
261 + $user = ($userId && (int)$userId !== get_current_user_id())
262 + ? User::find($userId)
263 + : self::getCurrentUser();
165 264 }
166 265
167 266 return $user && Arr::get($user->getPermissions(), 'community_admin');
168 267 }
@@ -421,9 +520,9 @@
421 520 *
422 521 * @param int|null $userId The user ID. If null, uses the current user.
423 522 * @return bool True if the user can access the portal, false otherwise.
424 523 */
425 - public static function canAccessPortal($userId = null)
524 + public static function canAccessPortal($userId = null, $requireActiveProfile = true)
426 525 {
427 526 $settings = self::generalSettings();
428 527 $accessLevel = Arr::get($settings, 'access.acess_level');
429 528
@@ -460,8 +559,12 @@
460 559 if (!$result) {
461 560 return apply_filters('fluent_community/can_access_portal', false);
462 561 }
463 562
563 + if (!$requireActiveProfile) {
564 + return apply_filters('fluent_community/can_access_portal', true);
565 + }
566 +
464 567 $xProfile = Helper::getCurrentProfile();
465 568
466 569 $result = $xProfile && $xProfile->status == 'active';
467 570
@@ -531,16 +634,14 @@
531 634 if (!$userId) {
532 635 return false;
533 636 }
534 637
535 - static $user;
536 - if ($user && $cached) {
537 - return $user;
638 + static $users = [];
639 + if ($cached && isset($users[$userId])) {
640 + return $users[$userId];
538 641 }
539 642
540 - $user = User::find($userId);
541 -
542 - return $user;
643 + return $users[$userId] = User::find($userId);
543 644 }
544 645
545 646 /**
546 647 * Get the route paths for the community.
@@ -652,8 +753,31 @@
652 753 return false;
653 754 }
654 755
655 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 + /**
656 780 * Get a human-readable excerpt from content.
657 781 *
658 782 * @param string $content The content to extract from.
659 783 * @param int $length The maximum length of the excerpt.
@@ -674,12 +798,12 @@
674 798 // Blockquotes: remove '>' symbol
675 799 '/^\s*>\s?/m' => '',
676 800 // Horizontal rules: replace with empty line
677 801 '/^\s*([-*_])\1{2,}\s*$/m' => "\n",
802 + // Images: keep only the alt text (run before links)
803 + '/!\[([^\]]*)\]\([^\)]+\)/' => '$1',
678 804 // Links: keep only the link text
679 - '/\[([^\]]+)\]\([^\)]+\)/' => '$1',
680 - // Images: keep only the alt text
681 - '/!\[([^\]]+)\]\([^\)]+\)/' => '$1',
805 + '/\[([^\]]*)\]\([^\)]+\)/' => '$1',
682 806 // Strikethrough: remove '~~' symbols
683 807 '/~~(.*?)~~/' => '$1',
684 808 // Task lists: remove checkbox syntax
685 809 '/^\s*[-*+]\s+\[[ xX]\]\s+/m' => '',
@@ -686,8 +810,10 @@
686 810 ];
687 811
688 812 $content = preg_replace(array_keys($patterns), array_values($patterns), $content);
689 813
814 + $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
815 +
690 816 // remove all tags
691 817 $content = wp_strip_all_tags($content);
692 818 // remove new lines and tabs
693 819 $content = str_replace(["\r", "\n", "\t"], ' ', $content);
@@ -772,9 +898,9 @@
772 898 foreach ($communityGroups as $communityGroup) {
773 899 $validSpaces = [];
774 900 $spaces = $communityGroup->spaces;
775 901 $isShowAll = Arr::get($communityGroup->settings, 'always_show_spaces') === 'yes';
776 -
902 +
777 903 if (!$isShowAll && !$isSpaceModerator) {
778 904 $spaceIds = $spaces->pluck('id')->toArray();
779 905 $isNotMemberOfAnySpace = empty(array_intersect($spaceIds, $userSpaceIds));
780 906 if ($isNotMemberOfAnySpace) {
@@ -781,8 +907,12 @@
781 907 continue;
782 908 }
783 909 }
784 910
911 + if ($user) {
912 + BaseSpace::preloadMemberships($spaces, $user->ID);
913 + }
914 +
785 915 foreach ($spaces as $space) {
786 916 $validSpace = $view ? self::transformSpaceToLink($space, $user) : $space;
787 917 if (!$validSpace) {
788 918 continue;
@@ -1219,11 +1349,33 @@
1219 1349
1220 1350 return $menuGroups;
1221 1351 }
1222 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 +
1223 1375 public static function isLinkAccessible($link, $currentUser = null)
1224 1376 {
1225 - $isEnabled = Arr::get($link, 'enabled') === 'yes';
1377 + $isEnabled = Arr::get($link, 'enabled', 'yes') === 'yes';
1226 1378 $isUnavailable = Arr::get($link, 'is_unavailable') === 'yes';
1227 1379
1228 1380 if (!$isEnabled || $isUnavailable) {
1229 1381 return false;
@@ -1251,14 +1403,16 @@
1251 1403 if (!$currentUser) {
1252 1404 return false;
1253 1405 }
1254 1406
1255 - static $userSpacesIds = null;
1256 - if ($userSpacesIds === null) {
1257 - $userSpacesIds = $currentUser->getJoinedSpaceIds();
1407 + static $userSpacesIds = [];
1408 + if (!isset($userSpacesIds[$currentUser->ID])) {
1409 + $userSpacesIds[$currentUser->ID] = $currentUser->getJoinedSpaceIds();
1258 1410 }
1259 1411
1260 - return $userSpacesIds && !!array_intersect($userSpacesIds, $membershipIds);
1412 + $ids = $userSpacesIds[$currentUser->ID];
1413 +
1414 + return $ids && !!array_intersect($ids, $membershipIds);
1261 1415 }
1262 1416
1263 1417 /**
1264 1418 * Get the meta data for a space.
@@ -1460,22 +1614,33 @@
1460 1614 public static function getMobileMenuItems($context = 'headless')
1461 1615 {
1462 1616 $xprofile = Helper::getCurrentProfile();
1463 1617
1464 - $mobileMenuItems = [
1465 - [
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[] = [
1466 1636 'route' => [
1467 - 'name' => 'all_feeds'
1637 + 'name' => $slug
1468 1638 ],
1469 - '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>'
1470 - ],
1471 - [
1472 - 'route' => [
1473 - 'name' => 'spaces'
1474 - ],
1475 - '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>'
1476 - ]
1477 - ];
1639 + 'title' => Arr::get($menuItem, 'title'),
1640 + 'icon_svg' => $iconSvg ? CustomSanitizer::sanitizeSvg($iconSvg) : $defaultIcon
1641 + ];
1642 + }
1478 1643
1479 1644 if ($xprofile) {
1480 1645 $mobileMenuItems[] = [
1481 1646 'route' => [
@@ -1483,13 +1648,15 @@
1483 1648 'params' => [
1484 1649 'username' => $xprofile->username
1485 1650 ]
1486 1651 ],
1652 + 'title' => __('Profile', 'fluent-community'),
1487 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>'
1488 1654 ];
1489 1655 } else if (!get_current_user_id()) {
1490 1656 $mobileMenuItems[] = [
1491 1657 'name' => 'login',
1658 + 'title' => __('Login', 'fluent-community'),
1492 1659 'permalink' => Helper::getAuthUrl(),
1493 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>'
1494 1661 ];
1495 1662 }
@@ -1567,9 +1734,9 @@
1567 1734
1568 1735 /**
1569 1736 * Add a user to a space.
1570 1737 *
1571 - * @param Space | int $space space to add the user to.
1738 + * @param BaseSpace|int $space space to add the user to.
1572 1739 * @param int $userId The ID of the user to add.
1573 1740 * @param string $role The role of the user in the space.
1574 1741 * @param string $by The source of the action.
1575 1742 * @return bool True if the user was added, false otherwise.
@@ -1717,14 +1884,15 @@
1717 1884 'rel' => Arr::get($link, 'new_tab') === 'yes' ? 'noopener noreferrer' : '',
1718 1885 ]);
1719 1886
1720 1887 ?>
1721 - <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')); ?>"
1722 1889 href="<?php echo esc_url($link['permalink']); ?>"<?php foreach ($linkAtts as $key => $value) {
1723 - echo esc_attr($key) . '="' . esc_attr($value) . '"';
1890 + echo ' ' . esc_attr($key) . '="' . esc_attr($value) . '"';
1724 1891 } ?>>
1725 1892 <?php $renderIcon && self::printLinkIcon($link, $fallback); ?>
1726 - <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>
1727 1895 <?php if (Arr::get($link, 'show_lock')) : ?>
1728 1896 <span class="fcom_space_lock">
1729 1897 <i class="el-icon">
1730 1898 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
@@ -2037,15 +2205,17 @@
2037 2205 {
2038 2206 $config = Utility::getOption('moderation_config', []);
2039 2207
2040 2208 $default = [
2041 - 'is_enabled' => 'no',
2042 - 'profanity_filter' => "",
2043 - 'flag_after_threshold' => 0,
2044 - 'flag_all_new_posts' => 'no',
2045 - 'first_post_approval' => 'no',
2046 - 'first_comment_approval' => 'no',
2047 - '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,
2048 2218 ];
2049 2219
2050 2220 return wp_parse_args($config, $default);
2051 2221 }
@@ -2203,12 +2373,12 @@
2203 2373 }
2204 2374
2205 2375 // Check if the character is escaped
2206 2376 if ($char === "\\") {
2207 - // Add the next character to the result as is, without mapping
2377 + // Day.js escapes literal text with square brackets, not backslashes
2208 2378 $i++;
2209 2379 if ($i < strlen($phpFormat)) {
2210 - $dayjsFormat .= "\\" . $phpFormat[$i];
2380 + $dayjsFormat .= "[" . $phpFormat[$i] . "]";
2211 2381 }
2212 2382 continue;
2213 2383 }
2214 2384
@@ -2256,6 +2426,148 @@
2256 2426 }
2257 2427 }
2258 2428
2259 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);
2260 2572 }
2261 2573 }