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/Http/Controllers/ProfileController.php +645 -98 1.0.952.10.0 View file →
@@ -3,12 +3,12 @@
3 3 namespace FluentCommunity\App\Http\Controllers;
4 4
5 5 use FluentCommunity\App\Functions\Utility;
6 6 use FluentCommunity\App\Models\Comment;
7 -use FluentCommunity\App\Models\NotificationSubscription;
7 +use FluentCommunity\App\Models\Feed;
8 8 use FluentCommunity\App\Models\Space;
9 9 use FluentCommunity\App\Models\SpaceGroup;
10 -use FluentCommunity\App\Models\User;
10 +use FluentCommunity\App\Models\SpaceUserPivot;
11 11 use FluentCommunity\App\Models\XProfile;
12 12 use FluentCommunity\App\Services\CustomSanitizer;
13 13 use FluentCommunity\App\Services\FeedsHelper;
14 14 use FluentCommunity\App\Services\Helper;
@@ -15,45 +15,142 @@
15 15 use FluentCommunity\App\Services\NotificationPref;
16 16 use FluentCommunity\App\Services\ProfileHelper;
17 17 use FluentCommunity\Framework\Http\Request\Request;
18 18 use FluentCommunity\Framework\Support\Arr;
19 +use FluentCommunity\Modules\Course\Model\CourseLesson;
20 +use FluentCommunity\Modules\Course\Model\CourseTopic;
21 +use FluentCommunity\Modules\Course\Services\CourseHelper;
22 +use FluentCommunity\Modules\PushNotification\PushNotificationModule;
23 +use FluentCommunity\Framework\Foundation\Exceptions\HttpException;
19 24
20 25 class ProfileController extends Controller
21 26 {
22 27 public function getProfile(Request $request, $userName)
23 28 {
24 - $xprofile = XProfile::where('username', $userName)->firstOrFail();
29 + /** @var XProfile $xprofile */
30 + $xprofile = XProfile::where('username', $userName)
31 + ->firstOrFail();
25 32
33 + if ($xprofile->status != 'active' && !Helper::isModerator()) {
34 + return $this->sendError([
35 + 'message' => __('This profile is not active', 'fluent-community')
36 + ], 403);
37 + }
38 +
39 + $canViewProfile = Utility::canViewUserProfile($xprofile->user_id);
40 +
26 41 $user = get_user_by('ID', $xprofile->user_id);
27 42
28 43 $profile = [
29 - 'user_id' => $xprofile->user_id,
30 - 'is_verified' => $xprofile->is_verified,
31 - 'display_name' => $xprofile->display_name,
32 - 'username' => $xprofile->username,
33 - 'avatar' => $xprofile->avatar,
34 - 'created_at' => $xprofile->created_at->format('Y-m-d H:i:s'),
35 - 'last_activity' => $xprofile->last_activity,
36 - 'short_description_rendered' => FeedsHelper::mdToHtml($xprofile->short_description),
37 - 'cover_photo' => Arr::get($xprofile->meta, 'cover_photo'),
38 - 'website' => Arr::get($xprofile->meta, 'website'),
39 - 'social_links' => (object)Arr::get($xprofile->meta, 'social_links', []),
40 - 'status' => $xprofile->status,
41 - 'badge_slug' => Arr::get($xprofile->meta, 'badge_slug'),
42 - 'compilation_score' => $xprofile->getCompletionScore(),
43 - 'total_points' => $xprofile->total_points,
44 - 'canViewUserSpaces' => ProfileHelper::canViewUserSpaces($xprofile->user_id, $this->getUser())
44 + 'user_id' => $xprofile->user_id,
45 + 'is_verified' => $xprofile->is_verified,
46 + 'display_name' => $xprofile->display_name,
47 + 'username' => $xprofile->username,
48 + 'avatar' => $xprofile->avatar,
49 + 'has_custom_avatar' => $xprofile->hasCustomAvatar(),
50 + 'cover_photo' => Arr::get($xprofile->meta, 'cover_photo'),
51 + 'headline' => Arr::get($xprofile->meta, 'headline', ''),
52 + 'total_points' => $xprofile->total_points,
53 + 'badge_slugs' => (array)Arr::get($xprofile->meta, 'badge_slug', []),
54 + 'status' => $xprofile->status,
55 + 'is_restricted' => !$canViewProfile,
56 + 'canViewUserSpaces' => ProfileHelper::canViewUserSpaces($xprofile->user_id, $this->getUser())
45 57 ];
46 58
47 - $isAdmin = Helper::isSiteAdmin();
48 - if ($xprofile->user_id == get_current_user_id() || $isAdmin) {
59 + if (Utility::showLastActivity()) {
60 + $profile['last_activity'] = $xprofile->last_activity;
61 + }
62 +
63 + if ($canViewProfile) {
64 + $profile['website'] = Arr::get($xprofile->meta, 'website');
65 + $profile['created_at'] = $xprofile->created_at->format('Y-m-d H:i:s');
66 + $profile['social_links'] = (object) Arr::get($xprofile->meta, 'social_links', []);
67 + $profile['compilation_score'] = $xprofile->getCompletionScore();
68 + $profile['short_description_rendered'] = wp_kses_post(FeedsHelper::mdToHtml($xprofile->short_description));
69 + }
70 +
71 + $currentUserId = get_current_user_id();
72 +
73 + $isOwn = $xprofile->user_id == $currentUserId;
74 +
75 + $isAdmin = Helper::isSiteAdmin($currentUserId);
76 +
77 + if ($isOwn || $isAdmin) {
78 + $enableUserSync = Utility::getPrivacySetting('enable_user_sync') === 'yes';
79 + $nameArray = explode(' ', trim((string) $xprofile->display_name));
80 + $xprofileFirstName = array_shift($nameArray);
81 + $xprofileLastName = implode(' ', $nameArray);
82 +
49 83 $profile['email'] = $user->user_email;
50 - $profile['first_name'] = $user->first_name;
51 - $profile['last_name'] = $user->last_name;
84 + $profile['first_name'] = $enableUserSync ? $user->first_name : $xprofileFirstName;
85 + $profile['last_name'] = $enableUserSync ? $user->last_name : $xprofileLastName;
52 86 $profile['short_description'] = $xprofile->short_description;
53 87 $profile['can_change_username'] = $isAdmin || Utility::getPrivacySetting('can_customize_username') === 'yes';
88 + $profile['can_change_email'] = current_user_can('edit_users') || (Utility::getPrivacySetting('can_change_email') === 'yes' && $isOwn);
89 + $profile['can_change_password'] = $isOwn && Utility::getPrivacySetting('can_change_password') === 'yes';
54 90 }
55 91
92 + $profileBaseUrl = Helper::baseUrl('u/' . $xprofile->username . '/');
93 +
94 + $profile['profile_navs'] = [
95 + [
96 + 'slug' => 'user_profile',
97 + 'title' => __('About', 'fluent-community'),
98 + 'url' => $profileBaseUrl,
99 + 'wrapper_class' => 'fcom_profile_about',
100 + 'route' => [
101 + 'name' => 'user_profile'
102 + ]
103 + ],
104 + [
105 + 'slug' => 'user_profile_feeds',
106 + 'title' => __('Posts', 'fluent-community'),
107 + 'wrapper_class' => 'fcom_profile_posts',
108 + 'url' => $profileBaseUrl . 'posts',
109 + 'route' => [
110 + 'name' => 'user_profile_feeds'
111 + ]
112 + ]
113 + ];
114 +
115 + if ($profile['canViewUserSpaces']) {
116 + $profile['profile_navs'][] = [
117 + 'slug' => 'user_spaces',
118 + 'wrapper_class' => 'fcom_profile_spaces',
119 + 'title' => __('Spaces', 'fluent-community'),
120 + 'url' => $profileBaseUrl . 'spaces',
121 + 'route' => [
122 + 'name' => 'user_spaces'
123 + ]
124 + ];
125 +
126 + if (Helper::isFeatureEnabled('course_module')) {
127 + $profile['profile_navs'][] = [
128 + 'slug' => 'user_courses',
129 + 'wrapper_class' => 'fcom_profile_courses',
130 + 'title' => __('Courses', 'fluent-community'),
131 + 'url' => $profileBaseUrl . 'courses',
132 + 'route' => [
133 + 'name' => 'user_courses'
134 + ]
135 + ];
136 + }
137 + }
138 +
139 + $profile['profile_navs'][] = [
140 + 'slug' => 'user_comments',
141 + 'wrapper_class' => 'fcom_profile_comments',
142 + 'title' => __('Comments', 'fluent-community'),
143 + 'url' => $profileBaseUrl . 'comments',
144 + 'route' => [
145 + 'name' => 'user_comments'
146 + ]
147 + ];
148 +
149 + $profile['profile_nav_actions'] = [];
150 +
151 + $profile = apply_filters('fluent_community/profile_view_data', $profile, $xprofile, $isAdmin);
152 +
56 153 return [
57 154 'profile' => $profile
58 155 ];
59 156 }
@@ -59,12 +156,31 @@
59 156 }
60 157
61 158 public function patchProfile(Request $request, $userName)
62 159 {
63 - $xprofile = $this->verfifyAndGetProfile($userName);
160 + $xprofile = $this->verifyAndGetProfile($userName);
64 161
65 - $updateData = $request->get('data');
162 + $updateData = $request->get('data', []);
66 163
164 + if (!empty($updateData['status']) && $updateData['status'] === 'deactivated' && $xprofile->status === 'active') {
165 + // handle deactivation
166 + $canDeactivate = Utility::getPrivacySetting('can_deactive_account') === 'yes' || Helper::isSiteAdmin();
167 + if (!$canDeactivate) {
168 + return $this->sendError([
169 + 'message' => __('You are not allowed to deactivate this account.', 'fluent-community')
170 + ]);
171 + }
172 +
173 + $xprofile->status = '';
174 + $xprofile->save();
175 + update_user_meta($xprofile->user_id, '_fcom_deactivated_at', current_time('mysql'));
176 + do_action('fluent_community/profile_deactivated', $xprofile);
177 +
178 + return [
179 + 'message' => __('Your profile has been deactivated successfully.', 'fluent-community')
180 + ];
181 + }
182 +
67 183 $mediaTypes = ['cover_photo', 'avatar'];
68 184
69 185 foreach ($mediaTypes as $type) {
70 186 if (!empty($updateData[$type])) {
@@ -70,9 +186,9 @@
70 186 if (!empty($updateData[$type])) {
71 187 $media = Helper::getMediaFromUrl($updateData[$type]);
72 188 if (!$media || $media->is_active) {
73 189 return $this->sendError([
74 - 'message' => 'Invalid media image. Please upload a new one.'
190 + 'message' => __('Invalid media image. Please upload a new one.', 'fluent-community')
75 191 ]);
76 192 }
77 193
78 194 $updateData[$type] = $media->public_url;
@@ -86,11 +202,13 @@
86 202 }
87 203
88 204 $deletedMedias = [];
89 205
90 - if (!empty($updateData['avatar'])) {
206 + if (isset($updateData['avatar'])) {
91 207
92 - $deletedMedias[] = $xprofile->avatar;
208 + if ($xprofile->hasCustomAvatar()) {
209 + $deletedMedias[] = Arr::get($xprofile->getAttributes(), 'avatar');
210 + }
93 211
94 212 $xprofile->avatar = $updateData['avatar'];
95 213
96 214 if (defined('FLUENTCRM')) {
@@ -97,13 +215,16 @@
97 215 $contact = $xprofile->contact;
98 216
99 217 if ($contact) {
100 218 $contact->update([
101 - 'avatar' => $updateData['avatar']
219 + 'avatar' => $updateData['avatar'] ?: null
102 220 ]);
103 221 }
104 222 }
105 223
224 + if (empty($updateData['avatar'])) {
225 + Utility::forgetCache('user_avatar_' . $xprofile->user_id);
226 + }
106 227 }
107 228
108 229 if (isset($updateData['cover_photo'])) {
109 230 $deletedMedias[] = Arr::get($xprofile->meta, 'cover_photo');
@@ -128,15 +249,15 @@
128 249 {
129 250 $currentUser = $this->getUser(true);
130 251 $data = $request->get('data', []);
131 252
132 - if ($currentUser->isCommunityModerator()) {
133 - $xProfile = XProfile::where('user_id', $data['user_id'])->firstOrFail();
134 - } else {
135 - $xProfile = XProfile::where('username', $userName)->firstOrFail();
136 - if ($xProfile->user_id != get_current_user_id()) {
253 + /** @var XProfile $xProfile */
254 + $xProfile = XProfile::where('username', $userName)->firstOrFail();
255 +
256 + if ($xProfile->user_id != get_current_user_id()) {
257 + if(!$currentUser->isCommunityModerator()) {
137 258 return $this->sendError([
138 - 'message' => 'You are not allowed to update this profile'
259 + 'message' => __('You are not allowed to update this profile', 'fluent-community')
139 260 ]);
140 261 }
141 262 }
142 263
@@ -147,9 +268,10 @@
147 268 ]);
148 269
149 270 $updateData = Arr::only($data, ['first_name', 'last_name', 'short_description', 'website']);
150 271
151 - $currentUser = User::findOrFail(get_current_user_id());
272 + $updateData = apply_filters('fluent_community/update_profile_data', $updateData, $data, $xProfile, $currentUser);
273 +
152 274 $meta = $xProfile->meta;
153 275
154 276 $userNameChanged = false;
155 277
@@ -167,9 +289,9 @@
167 289 $userName = CustomSanitizer::sanitizeUserName($userName);
168 290
169 291 if (!$userName) {
170 292 return $this->sendError([
171 - 'message' => __('Invalid username. Only latin chars with _ & - is allowed', 'fluent-community')
293 + 'message' => __('Invalid username. Only Latin characters with _ & - are allowed.', 'fluent-community')
172 294 ]);
173 295 }
174 296
175 297 if (XProfile::where('username', $userName)->where('user_id', '!=', $xProfile->user_id)->exists()) {
@@ -190,20 +312,22 @@
190 312 $userNameChanged = $userName != $xProfile->username;
191 313 }
192 314
193 315 if (Helper::isFeatureEnabled('user_badge')) {
194 - $badgeSlug = Arr::get($data, 'badge_slug');
195 - $meta['badge_slug'] = $badgeSlug;
316 + $badgeSlug = array_filter((array) Arr::get($data, 'badge_slugs', []), 'is_scalar');
317 + $badgeSlug = array_map('sanitize_text_field', $badgeSlug);
318 +
319 + $definedBadges = (array) Utility::getOption('user_badges', []);
320 + $meta['badge_slug'] = array_values(array_intersect($badgeSlug, array_keys($definedBadges)));
196 321 }
197 322 } else if (Utility::getPrivacySetting('can_customize_username')) {
198 323 $userName = Arr::get($data, 'username');
199 324
200 -
201 325 if ($xProfile->username != $userName) {
202 326 $userName = strtolower(CustomSanitizer::sanitizeUserName($userName));
203 327 if (!$userName) {
204 328 return $this->sendError([
205 - 'message' => __('Invalid username. Only latin chars with _ & - is allowed', 'fluent-community')
329 + 'message' => __('Invalid username. Only Latin characters with _ & - are allowed.', 'fluent-community')
206 330 ]);
207 331 }
208 332
209 333 if (XProfile::where('username', $userName)->where('user_id', '!=', $xProfile->user_id)->exists()) {
@@ -211,8 +335,14 @@
211 335 'message' => __('Community Username already taken by someone else', 'fluent-community')
212 336 ]);
213 337 }
214 338
339 + if (strlen($userName) < 3) {
340 + return $this->sendError([
341 + 'message' => __('Username should be at least 3 characters long.', 'fluent-community')
342 + ]);
343 + }
344 +
215 345 $reservedUserNames = ProfileHelper::getReservedUserNames();
216 346 if (in_array($userName, $reservedUserNames)) {
217 347 return $this->sendError([
218 348 'message' => __('Please use another username. This username is reserved', 'fluent-community')
@@ -221,27 +351,43 @@
221 351
222 352 $updateData['username'] = $userName;
223 353 $userNameChanged = true;
224 354 }
225 -
226 355 }
227 356
228 357 $updateData['display_name'] = trim(sanitize_text_field(Arr::get($data, 'first_name') . ' ' . Arr::get($data, 'last_name')));
229 - $updateData['short_description'] = CustomSanitizer::unslashMarkdown(sanitize_textarea_field(trim(Arr::get($data, 'short_description'))));
230 - $meta['website'] = sanitize_url(Arr::get($data, 'website'));
358 +
359 + $updateData['short_description'] = CustomSanitizer::unslashMarkdown(sanitize_textarea_field(trim((string) Arr::get($data, 'short_description', ''))));
360 + $meta['website'] = sanitize_url((string) Arr::get($data, 'website', ''));
361 + $meta['headline'] = sanitize_text_field(trim(Arr::get($data, 'headline', '')));
231 362 $socialLinks = Arr::get($data, 'social_links', []);
232 363
233 364 $maxDescriptionLength = apply_filters('fluent_community/max_profile_description_length', 5000);
234 365 if ($updateData['short_description'] && strlen($updateData['short_description']) > $maxDescriptionLength) {
235 366 return $this->sendError([
236 - 'message' => sprintf(__('Profile Bio should not be more than %d characters', 'fluent-community'), $maxDescriptionLength)
367 + 'message' => sprintf(
368 + /* translators: %d: Maximum number of characters allowed in the profile bio. */
369 + __('Profile bio should not exceed %d characters.', 'fluent-community'),
370 + $maxDescriptionLength
371 + )
237 372 ]);
238 373 }
239 374
375 + $maxHeadlineLength = apply_filters('fluent_community/max_profile_headline_length', 60);
376 + if ($meta['headline'] && mb_strlen($meta['headline']) > $maxHeadlineLength) {
377 + return $this->sendError([
378 + 'message' => sprintf(
379 + /* translators: %d: Maximum number of characters allowed in the profile headline. */
380 + __('Headline should not exceed %d characters.', 'fluent-community'),
381 + $maxHeadlineLength
382 + )
383 + ]);
384 + }
385 +
240 386 if ($socialLinks) {
241 387 $socialLinks = array_filter($socialLinks);
242 388 $formattedSocialLinkes = [];
243 - $socialLinkProviders = ProfileHelper::socialLinkProviders();
389 + $socialLinkProviders = ProfileHelper::socialLinkProviders(true);
244 390 foreach ($socialLinks as $linkName => $socialLink) {
245 391 if (isset($socialLinkProviders[$linkName])) {
246 392 $formattedSocialLinkes[$linkName] = sanitize_text_field(trim($socialLink));
247 393 }
@@ -248,14 +394,15 @@
248 394 }
249 395 $meta['social_links'] = $formattedSocialLinkes;
250 396 }
251 397
398 + $meta['short_description_rendered'] = wp_kses_post(FeedsHelper::mdToHtml($updateData['short_description']));
399 +
252 400 $updateData['meta'] = $meta;
253 401
254 402 $xProfile->fill($updateData);
255 403 $xProfile->save();
256 404
257 -
258 405 // Let's update the user's details
259 406 $xProfile->user->updateCustomData($updateData);
260 407 $xProfile->compilation_score = $xProfile->getCompletionScore();
261 408
@@ -266,8 +413,39 @@
266 413 'redirect_url' => Helper::baseUrl('u/' . $xProfile->username . '/update')
267 414 ];
268 415 }
269 416
417 + $isOwn = $xProfile->user_id == get_current_user_id();
418 + $canEditUsers = current_user_can('edit_users');
419 + if ($canEditUsers || (Utility::getPrivacySetting('can_change_email') === 'yes' && $isOwn)) {
420 + $emailAddress = Arr::get($data, 'email');
421 +
422 + if ($emailAddress && is_email($emailAddress) && $emailAddress != $xProfile->user->user_email) {
423 + $owner_id = email_exists($emailAddress);
424 + if ($owner_id && $owner_id != $xProfile->user_id) {
425 + return $this->sendError([
426 + 'message' => __('Email address already taken by someone else. Please use a different email address.', 'fluent-community')
427 + ]);
428 + }
429 +
430 + // Let's check if it's their own
431 + $requireVerification = $isOwn && !$canEditUsers;
432 + if ($requireVerification) {
433 + $currentUser = get_user_by('ID', $xProfile->user_id);
434 + ProfileHelper::sendConfirmationOnProfileEmailChange($currentUser, $emailAddress);
435 + return [
436 + 'message' => __('Email address change is pending. Please check your inbox to verify the new email address.', 'fluent-community'),
437 + 'profile' => $xProfile
438 + ];
439 + }
440 +
441 + wp_update_user([
442 + 'user_email' => $emailAddress,
443 + 'ID' => $xProfile->user_id
444 + ]);
445 + }
446 + }
447 +
270 448 return [
271 449 'message' => __('Profile has been updated', 'fluent-community'),
272 450 'profile' => $xProfile
273 451 ];
@@ -272,16 +450,136 @@
272 450 'profile' => $xProfile
273 451 ];
274 452 }
275 453
454 + public function changePassword(Request $request, $userName)
455 + {
456 + $xProfile = XProfile::where('username', $userName)->firstOrFail();
457 +
458 + // Password can only be changed by the account owner, never by moderators/admins here.
459 + if ($xProfile->user_id != get_current_user_id()) {
460 + return $this->sendError([
461 + 'message' => __('You are not allowed to change this password', 'fluent-community')
462 + ]);
463 + }
464 +
465 + if (Utility::getPrivacySetting('can_change_password') !== 'yes') {
466 + return $this->sendError([
467 + 'message' => __('Password change is disabled', 'fluent-community')
468 + ]);
469 + }
470 +
471 + $data = $request->get('data', []);
472 +
473 + $this->validate($data, [
474 + 'current_password' => 'required',
475 + 'new_password' => 'required',
476 + 'confirm_password' => 'required',
477 + ], [
478 + 'current_password.required' => __('Current password is required', 'fluent-community'),
479 + 'new_password.required' => __('New password is required', 'fluent-community'),
480 + 'confirm_password.required' => __('Please confirm your new password', 'fluent-community'),
481 + ]);
482 +
483 + // Passwords are used verbatim; sanitizing would corrupt valid characters.
484 + $currentPassword = (string) Arr::get($data, 'current_password');
485 + $newPassword = (string) Arr::get($data, 'new_password');
486 + $confirmPassword = (string) Arr::get($data, 'confirm_password');
487 +
488 + if (strlen($newPassword) < 4) {
489 + return $this->sendError([
490 + 'message' => __('New password must be at least 4 characters long', 'fluent-community')
491 + ]);
492 + }
493 +
494 + if ($newPassword !== $confirmPassword) {
495 + return $this->sendError([
496 + 'message' => __('New password and confirmation do not match', 'fluent-community')
497 + ]);
498 + }
499 +
500 + if ($newPassword === $currentPassword) {
501 + return $this->sendError([
502 + 'message' => __('New password must be different from your current password', 'fluent-community')
503 + ]);
504 + }
505 +
506 + $user = get_user_by('id', $xProfile->user_id);
507 +
508 + if (!$user || !wp_check_password($currentPassword, $user->user_pass, $user->ID)) {
509 + return $this->sendError([
510 + 'message' => __('Your current password is incorrect', 'fluent-community')
511 + ]);
512 + }
513 +
514 + wp_set_password($newPassword, $user->ID);
515 +
516 + // wp_set_password destroys every session for the user, which also invalidates the
517 + // REST nonce the SPA holds. Re-issue the cookie to keep the session, capturing the
518 + // fresh logged-in cookie so the nonces we mint below bind to the new session token.
519 + $newLoggedInCookie = '';
520 + $captureLoggedInCookie = function ($loggedInCookie) use (&$newLoggedInCookie) {
521 + $newLoggedInCookie = $loggedInCookie;
522 + };
523 + add_action('set_logged_in_cookie', $captureLoggedInCookie);
524 +
525 + wp_set_current_user($user->ID);
526 + wp_set_auth_cookie($user->ID, true);
527 +
528 + remove_action('set_logged_in_cookie', $captureLoggedInCookie);
529 +
530 + if ($newLoggedInCookie) {
531 + $_COOKIE[LOGGED_IN_COOKIE] = $newLoggedInCookie;
532 + }
533 +
534 + do_action('fluent_community/user/password_changed', $user->ID);
535 +
536 + return [
537 + 'message' => __('Your password has been changed successfully', 'fluent-community'),
538 + 'rest_nonce' => wp_create_nonce('wp_rest'),
539 + 'ajax_nonce' => wp_create_nonce('fluent_community_ajax_nonce'),
540 + ];
541 + }
542 +
543 + public function getAllMemberships(Request $request, $userName)
544 + {
545 + /** @var XProfile $xProfile */
546 + $xProfile = XProfile::where('username', $userName)->firstOrFail();
547 +
548 + $currentUser = $this->getUser();
549 +
550 + if (!ProfileHelper::canViewUserSpaces($xProfile->user_id, $currentUser)) {
551 + return $this->sendError([
552 + 'message' => __('You are not allowed to view this profile\'s membership.', 'fluent-community'),
553 + 'permission_failed' => true
554 + ]);
555 + }
556 +
557 + $canSeeSecret = $xProfile->user_id == get_current_user_id()
558 + || ($currentUser && $currentUser->isCommunityModerator());
559 +
560 + $memberships = $xProfile->spaces()
561 + ->wherePivot('status', 'active')
562 + ->when(!$canSeeSecret, function ($q) {
563 + $q->whereIn('privacy', ['public', 'private']);
564 + })
565 + ->get()
566 + ->pluck('id');
567 +
568 + return apply_filters('fluent_community/profile_all_memberships_api_response', [
569 + 'memberships' => $memberships
570 + ], $request->all());
571 + }
572 +
276 573 public function getSpaces(Request $request, $userName)
277 574 {
575 + /** @var XProfile $xProfile */
278 576 $xProfile = XProfile::where('username', $userName)->firstOrFail();
279 577 $currentUser = $this->getUser();
280 578
281 - if(!ProfileHelper::canViewUserSpaces($xProfile->user_id, $currentUser)) {
579 + if (!ProfileHelper::canViewUserSpaces($xProfile->user_id, $currentUser)) {
282 580 return $this->sendError([
283 - 'message' => __('You are not allowed to view this profile spaces', 'fluent-community'),
581 + 'message' => __('You are not allowed to view this profile\'s spaces.', 'fluent-community'),
284 582 'permission_failed' => true
285 583 ]);
286 584 }
287 585
@@ -296,14 +594,79 @@
296 594 ->get();
297 595 }
298 596
299 597 foreach ($spaces as $space) {
598 + $shouldHideMembersCount = Arr::get($space->settings, 'hide_members_count') == 'yes';
599 + $canViewMembers = $currentUser && $space->verifyUserPermisson($currentUser, 'can_view_members', false);
600 + if ($shouldHideMembersCount && !$canViewMembers) {
601 + $space->members_count = 0;
602 + continue;
603 + }
300 604 $space->members_count = $space->members()->count();
301 605 }
302 606
303 - return [
304 - 'spaces' => $spaces
607 + $data = [
608 + 'spaces' => $spaces
609 + ];
610 +
611 + return apply_filters('fluent_community/profile_spaces_api_response', $data, $request->all());
612 + }
613 +
614 + public function getCourses(Request $request, $userName)
615 + {
616 + if (!Helper::isFeatureEnabled('course_module')) {
617 + return $this->sendError([
618 + 'message' => __('Course module is disabled.', 'fluent-community')
619 + ]);
620 + }
621 +
622 + /** @var XProfile $xProfile */
623 + $xProfile = XProfile::where('username', $userName)->firstOrFail();
624 + $currentUser = $this->getUser();
625 +
626 + if (!ProfileHelper::canViewUserSpaces($xProfile->user_id, $currentUser)) {
627 + return $this->sendError([
628 + 'message' => __('You are not allowed to view this profile\'s courses.', 'fluent-community'),
629 + 'permission_failed' => true
630 + ]);
631 + }
632 +
633 + $hasAllAccess = $xProfile->user_id == get_current_user_id() || ($currentUser && $currentUser->isCommunityModerator());
634 +
635 + $courses = $xProfile->courses()
636 + ->wherePivot('status', 'active')
637 + ->where('fcom_spaces.status', 'published')
638 + ->when(!$hasAllAccess, function ($q) {
639 + $q->whereIn('fcom_spaces.privacy', ['public', 'private']);
640 + })
641 + ->get();
642 +
643 + foreach ($courses as $course) {
644 + $course->isEnrolled = CourseHelper::isEnrolled($course->id, $xProfile->user_id);
645 + if ($course->isEnrolled) {
646 + $course->progress = CourseHelper::getCourseProgress($course->id, $xProfile->user_id);
647 + }
648 +
649 + if (!$course->cover_photo) {
650 + $course->cover_photo = FLUENT_COMMUNITY_PLUGIN_URL . 'assets/images/course-placeholder.jpg';
651 + }
652 +
653 + $course->sectionsCount = CourseTopic::where('space_id', $course->id)->count();
654 + $course->lessonsCount = CourseLesson::where('space_id', $course->id)->count();
655 + if (Arr::get($course->settings, 'hide_members_count') != 'yes') {
656 + $course->studentsCount = SpaceUserPivot::where('space_id', $course->id)->count();
657 + } else {
658 + $course->studentsCount = 0;
659 + }
660 +
661 + do_action_ref_array('fluent_community/course', [&$course]);
662 + }
663 +
664 + $data = [
665 + 'courses' => $courses
305 666 ];
667 +
668 + return apply_filters('fluent_community/profile_courses_api_response', $data, $request->all());
306 669 }
307 670
308 671 public function getComments(Request $request, $userName)
309 672 {
@@ -310,9 +673,9 @@
310 673 $xProfile = XProfile::where('username', $userName)->first();
311 674
312 675 if (!$xProfile) {
313 676 return $this->sendError([
314 - 'message' => 'Profile not found'
677 + 'message' => __('Profile not found', 'fluent-community')
315 678 ]);
316 679 }
317 680
318 681 $currentUser = $this->getUser();
@@ -318,20 +681,18 @@
318 681 $currentUser = $this->getUser();
319 682 $hasAllAccess = $xProfile->user_id == get_current_user_id() || ($currentUser && $currentUser->isCommunityModerator());
320 683
321 684 $comments = Comment::where('user_id', $xProfile->user_id)
685 + ->where('status', 'published')
322 686 ->with([
323 - 'post' => function ($q) {
324 - $q->select(['id', 'title', 'message', 'type', 'space_id', 'slug', 'created_at'])
325 - ->with([
326 - 'space' => function ($q) {
327 - $q->select(['id', 'title', 'slug', 'type']);
328 - }
329 - ]);
687 + 'post' => function ($q) use ($currentUser) {
688 + // Eager load the full feed so the post opens in the modal without a per-click fetch.
689 + $q->select(array_merge(Feed::$publicColumns, ['message']))
690 + ->with(Feed::withPublicRelations($currentUser));
330 691 }
331 692 ])
332 - ->when(!$hasAllAccess, function ($q) use ($xProfile) {
333 - $q->whereHas('post', function ($query) use ($xProfile) {
693 + ->when(!$hasAllAccess, function ($q) {
694 + $q->whereHas('post', function ($query) {
334 695 $query->byUserAccess(get_current_user_id());
335 696 $query->where('type', 'text');
336 697 });
337 698 })
@@ -337,46 +698,97 @@
337 698 })
338 699 ->orderBy('id', 'desc')
339 700 ->paginate();
340 701
341 - return [
342 - 'comments' => $comments,
343 - 'xprofile' => $xProfile
344 - ];
702 + $posts = $comments->getCollection()
703 + ->pluck('post')
704 + ->filter()
705 + ->unique('id')
706 + ->values();
707 +
708 + if ($posts->isNotEmpty()) {
709 + FeedsHelper::transformFeedsCollection($posts);
710 + }
711 +
712 + $data = [
713 + 'comments' => $comments,
714 + 'xprofile' => $xProfile
715 + ];
716 +
717 + return apply_filters('fluent_community/profile_comments_api_response', $data, $request->all());
345 718 }
346 719
347 720 public function getNotificationPreferance(Request $request, $userName)
348 721 {
349 - $xProfile = $this->verfifyAndGetProfile($userName);
722 + $emailPref = Utility::getEmailNotificationSettings();
350 723
724 + $xProfile = $this->verifyAndGetProfile($userName);
725 +
351 726 $globalPreferances = NotificationPref::getGlobalPrefs();
352 - $userPrefs = NotificationSubscription::where('user_id', $xProfile->user_id)
353 - ->select(['notification_type', 'is_read', 'object_id'])
354 - ->get();
355 727
728 + // Read through the same service the save path writes through. These rows
729 + // live in fcom_notification_prefs, keyed by flat keys - space-scoped ones
730 + // carry an '_<space id>' suffix.
731 + $userPrefs = NotificationPref::getUserPrefs($xProfile->user_id);
732 +
733 + $frequencyMaps = [
734 + 0 => 'disabled',
735 + 1 => 'hourly',
736 + 2 => 'daily',
737 + 3 => 'weekly'
738 + ];
739 +
356 740 $userGlobalPrefs = [];
357 741 $spaceWisePrefs = [];
358 - foreach ($userPrefs as $pref) {
359 - if (!$pref->object_id) {
360 - $userGlobalPrefs[$pref->notification_type] = $pref->is_read ? 'yes' : 'no';
361 - } else {
362 - if (empty($spaceWisePrefs[$pref->object_id])) {
363 - $spaceWisePrefs[$pref->object_id] = [];
742 + foreach ($userPrefs as $prefKey => $prefValue) {
743 + if ($prefKey === 'message_email_frequency') {
744 + $userGlobalPrefs[$prefKey] = isset($frequencyMaps[$prefValue]) ? $frequencyMaps[$prefValue] : 'default';
745 + continue;
746 + }
747 +
748 + if (preg_match('/^(np_by_(?:member|admin)_mail)_(\d+)$/', $prefKey, $matches)) {
749 + $spaceId = (int)$matches[2];
750 +
751 + if (empty($spaceWisePrefs[$spaceId])) {
752 + $spaceWisePrefs[$spaceId] = [];
364 753 }
365 - $spaceWisePrefs[$pref->object_id][$pref->notification_type] = $pref->is_read;
754 +
755 + $spaceWisePrefs[$spaceId][$matches[1]] = $prefValue;
756 + continue;
366 757 }
758 +
759 + $userGlobalPrefs[$prefKey] = $prefValue ? 'yes' : 'no';
367 760 }
368 761
369 - if (empty($userGlobalPrefs)) {
370 - $userGlobalPrefs = $globalPreferances;
371 - $userGlobalPrefs = array_map(function ($item) {
372 - return $item ? 'yes' : 'no';
373 - }, $userGlobalPrefs);
762 + $messagingConfig = Utility::getOption('_messaging_settings', []);
763 + $isGlobalPerUser = Arr::get($messagingConfig, 'messaging_email_frequency') == 'disabled';
764 +
765 + $pushAvailable = PushNotificationModule::isAvailable();
766 +
767 + $userGlobalPrefsDefaults = [
768 + 'digest_mail' => Arr::get($globalPreferances, 'digest_email_status') ? 'yes' : 'no',
769 + 'mention_mail' => Arr::get($globalPreferances, 'mention_mail') ? 'yes' : 'no',
770 + 'reply_my_com_mail' => Arr::get($globalPreferances, 'reply_my_com_mail') ? 'yes' : 'no',
771 + 'com_my_post_mail' => Arr::get($globalPreferances, 'com_my_post_mail') ? 'yes' : 'no',
772 + 'message_email_frequency' => $isGlobalPerUser ? 'disabled' : 'default'
773 + ];
774 +
775 + if ($pushAvailable) {
776 + $pushPreferances = NotificationPref::getGlobalPrefs('push');
777 +
778 + $userGlobalPrefsDefaults['com_my_post_push'] = Arr::get($pushPreferances, 'com_my_post_push') ? 'yes' : 'no';
779 + $userGlobalPrefsDefaults['reply_my_com_push'] = Arr::get($pushPreferances, 'reply_my_com_push') ? 'yes' : 'no';
780 + $userGlobalPrefsDefaults['mention_push'] = Arr::get($pushPreferances, 'mention_push') ? 'yes' : 'no';
781 + $userGlobalPrefsDefaults['co_com_push'] = Arr::get($pushPreferances, 'co_com_push') ? 'yes' : 'no';
374 782 }
375 783
376 - $spaceGroups = SpaceGroup::with(['spaces' => function ($query) {
377 - $query->whereHas('members', function ($q) {
378 - $q->where('user_id', get_current_user_id());
784 + $userGlobalPrefs = wp_parse_args($userGlobalPrefs, $userGlobalPrefsDefaults);
785 +
786 + $profileUserId = $xProfile->user_id;
787 + $spaceGroups = SpaceGroup::with(['spaces' => function ($query) use ($profileUserId) {
788 + $query->whereHas('members', function ($q) use ($profileUserId) {
789 + $q->where('user_id', $profileUserId)
790 + ->where('status', 'active');
379 791 })
380 792 ->where('type', 'community');
381 793 }])
382 794 ->orderBy('serial', 'ASC')
@@ -382,14 +794,12 @@
382 794 ->orderBy('serial', 'ASC')
383 795 ->get();
384 796
385 797 $formattedSpaceGroups = [];
386 -
387 798 foreach ($spaceGroups as $group) {
388 799 if ($group->spaces->isEmpty()) {
389 800 continue;
390 801 }
391 -
392 802 $formattedSpaces = [];
393 803 foreach ($group->spaces as $space) {
394 804
395 805 $pref = '';
@@ -408,9 +818,8 @@
408 818 'icon' => $space->getIconMark(),
409 819 'pref' => $pref
410 820 ];
411 821 }
412 -
413 822 if ($formattedSpaces) {
414 823 $formattedSpaceGroups[] = [
415 824 'id' => $group->id,
416 825 'title' => $group->title,
@@ -418,27 +827,106 @@
418 827 ];
419 828 }
420 829 }
421 830
422 - return [
423 - 'user_globals' => (object)$userGlobalPrefs,
424 - 'spaceGroups' => $formattedSpaceGroups,
425 - 'space_prefs' => $spaceWisePrefs,
426 - 'digestEmailDay' => 'Monday'
831 + // let's find the other spaces
832 + $otherSpaces = Space::whereHas('members', function ($q) use ($xProfile) {
833 + $q->where('user_id', $xProfile->user_id);
834 + })
835 + ->whereNull('parent_id')
836 + ->orderBy('title', 'ASC')
837 + ->get();
838 +
839 + if (!$otherSpaces->isEmpty()) {
840 + $formattedSpaces = [];
841 + foreach ($otherSpaces as $space) {
842 + $pref = '';
843 + if (isset($spaceWisePrefs[$space->id])) {
844 + $perfs = (array)$spaceWisePrefs[$space->id];
845 + if (!empty($perfs['np_by_member_mail'])) {
846 + $pref = 'all_member_posts';
847 + } else if (!empty($perfs['np_by_admin_mail'])) {
848 + $pref = 'admin_only_posts';
849 + }
850 + }
851 +
852 + $formattedSpaces[] = [
853 + 'id' => $space->id,
854 + 'title' => $space->title,
855 + 'icon' => $space->getIconMark(),
856 + 'pref' => $pref
857 + ];
858 + }
859 +
860 + $formattedSpaceGroups[] = [
861 + 'id' => 'other_space_group',
862 + 'title' => __('Other Spaces', 'fluent-community'),
863 + 'spaces' => $formattedSpaces
864 + ];
865 + }
866 +
867 + $digestDay = (string)Arr::get($emailPref, 'digest_mail_day', 'tue');
868 + if ($digestDay) {
869 + $maps = [
870 + 'mon' => __('Monday', 'fluent-community'),
871 + 'tue' => __('Tuesday', 'fluent-community'),
872 + 'wed' => __('Wednesday', 'fluent-community'),
873 + 'thu' => __('Thursday', 'fluent-community'),
874 + 'fri' => __('Friday', 'fluent-community'),
875 + 'sat' => __('Saturday', 'fluent-community'),
876 + 'sun' => __('Sunday', 'fluent-community'),
877 + ];
878 + if (isset($maps[$digestDay])) {
879 + $digestDay = $maps[$digestDay];
880 + }
881 + }
882 +
883 + $crmEmailStatus = '';
884 + if ($xProfile->user_id == get_current_user_id()) {
885 + $profileUser = get_user_by('ID', $xProfile->user_id);
886 + if ($profileUser && $profileUser->user_email) {
887 + $crmEmailStatus = Helper::getCrmUndeliverableStatus($profileUser->user_email);
888 + }
889 + }
890 +
891 + $data = [
892 + 'user_globals' => (object)$userGlobalPrefs,
893 + 'spaceGroups' => $formattedSpaceGroups,
894 + 'space_prefs' => $spaceWisePrefs,
895 + 'digestEmailDay' => $digestDay,
896 + 'default_messaging_email_frequency' => Arr::get($messagingConfig, 'messaging_email_status') !== 'yes' ? 'no' : Arr::get($messagingConfig, 'messaging_email_frequency'),
897 + 'crm_email_status' => $crmEmailStatus,
898 + 'push_available' => $pushAvailable,
427 899 ];
900 +
901 + return apply_filters('fluent_community/profile_notification_pref_api_response', $data, $request->all());
428 902 }
429 903
430 904 public function saveNotificationPreferance(Request $request, $userName)
431 905 {
432 - $xProfile = $this->verfifyAndGetProfile($userName);
906 + $xProfile = $this->verifyAndGetOwnProfile($userName);
433 907
434 908 $userPrefs = $request->get('user_globals', []);
435 909 $sapcePrefs = $request->get('space_prefs', []);
436 910
911 + $messagingPref = Arr::get($userPrefs, 'message_email_frequency');
912 +
437 913 $userPrefs = array_map(function ($item) {
438 914 return $item == 'yes' ? 1 : 0;
439 915 }, $userPrefs);
440 916
917 + if ($messagingPref == 'hourly') {
918 + $userPrefs['message_email_frequency'] = 1;
919 + } else if ($messagingPref == 'daily') {
920 + $userPrefs['message_email_frequency'] = 2;
921 + } else if ($messagingPref == 'disabled') {
922 + $userPrefs['message_email_frequency'] = 0;
923 + } else if ($messagingPref == 'weekly') {
924 + $userPrefs['message_email_frequency'] = 3;
925 + } else {
926 + unset($userPrefs['message_email_frequency']);
927 + }
928 +
441 929 foreach ($sapcePrefs as $spaceId => $pref) {
442 930 $spaceId = (int)$spaceId;
443 931 if (!$pref || !$spaceId) {
444 932 continue;
@@ -451,24 +939,83 @@
451 939 $userPrefs['np_by_admin_mail_' . $spaceId] = 1;
452 940 }
453 941 }
454 942
455 -
456 943 NotificationPref::updateUserPrefs($xProfile->user_id, $userPrefs);
457 944
458 945 return [
459 946 'prefs' => $userPrefs,
460 - 'message' => __('Email Notification preferences has been updated', 'fluent-community')
947 + 'message' => __('Email Notification preferences have been updated', 'fluent-community')
461 948 ];
462 949 }
463 950
464 - private function verfifyAndGetProfile($userName)
951 + public function reconfirmEmail(Request $request, $userName)
465 952 {
953 + if (!defined('FLUENTCRM')) {
954 + return $this->sendError([
955 + 'message' => __('FluentCRM is not available on this site', 'fluent-community')
956 + ]);
957 + }
958 +
466 959 $xProfile = XProfile::where('username', $userName)->firstOrFail();
467 960
468 - $currentUser = $this->getUser();
469 - if ($xProfile->user_id != get_current_user_id() && (!$currentUser || !$currentUser->isCommunityModerator())) {
961 + if ($xProfile->user_id != get_current_user_id()) {
962 + return $this->sendError([
963 + 'message' => __('You can only re-confirm your own email address', 'fluent-community')
964 + ]);
965 + }
966 +
967 + $profileUser = get_user_by('ID', $xProfile->user_id);
968 + $email = $profileUser ? $profileUser->user_email : '';
969 +
970 + if (!$email || !Helper::getCrmUndeliverableStatus($email)) {
971 + return $this->sendError([
972 + 'message' => __('Your email address does not need re-confirmation', 'fluent-community')
973 + ]);
974 + }
975 +
976 + $subscriber = \FluentCrm\App\Models\Subscriber::where('email', $email)->first();
977 +
978 + if (!$subscriber) {
979 + return $this->sendError([
980 + 'message' => __('Your email address does not need re-confirmation', 'fluent-community')
981 + ]);
982 + }
983 +
984 + // In-memory only, never saved: the opt-in sender is gated on status == 'pending'
985 + // and does not persist the subscriber, so the stored status stays untouched
986 + // and FluentCommunity keeps pausing emails until the confirmation link is clicked.
987 + $subscriber->status = 'pending';
988 +
989 + if (!$subscriber->sendDoubleOptinEmail()) {
990 + return $this->sendError([
991 + 'message' => __('The confirmation email could not be sent right now. Please try again after a few minutes.', 'fluent-community')
992 + ]);
993 + }
994 +
995 + return [
996 + 'message' => __('A confirmation email has been sent. Please check your inbox and click the confirmation link to resume email notifications.', 'fluent-community')
997 + ];
998 + }
999 +
1000 + private function verifyAndGetProfile($userName)
1001 + {
1002 + $xProfile = XProfile::where('username', $userName)->firstOrFail();
1003 +
1004 + $currentUserId = get_current_user_id();
1005 + if ($xProfile->user_id != $currentUserId && !Helper::isSuperAdmin($currentUserId)) {
470 1006 throw new \Exception('You are not allowed to update this profile');
1007 + }
1008 +
1009 + return $xProfile;
1010 + }
1011 +
1012 + private function verifyAndGetOwnProfile($userName)
1013 + {
1014 + $xProfile = XProfile::where('username', $userName)->firstOrFail();
1015 +
1016 + if (!get_current_user_id() || $xProfile->user_id != get_current_user_id()) {
1017 + throw new HttpException(403, esc_html__('You are not allowed to access these notification preferences.', 'fluent-community'));
471 1018 }
472 1019
473 1020 return $xProfile;
474 1021 }