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

FeedsController.php in FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses trunk, at app/Http/Controllers/FeedsController.php

1,522 lines 55.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCommunity\App\Http\Controllers;
4
5 use FluentCommunity\App\Functions\Utility;
6 use FluentCommunity\App\Models\Media;
7 use FluentCommunity\App\Models\Notification;
8 use FluentCommunity\App\Models\NotificationSubscriber;
9 use FluentCommunity\App\Models\Space;
10 use FluentCommunity\App\Models\User;
11 use FluentCommunity\App\Services\CustomSanitizer;
12 use FluentCommunity\App\Services\FeedsHelper;
13 use FluentCommunity\App\Services\Helper;
14 use FluentCommunity\App\Services\Libs\FileSystem;
15 use FluentCommunity\App\Services\UploadHelper;
16 use FluentCommunity\App\Services\RemoteUrlParser;
17 use FluentCommunity\Framework\Http\Request\Request;
18 use FluentCommunity\App\Models\Feed;
19 use FluentCommunity\App\Models\BaseSpace;
20 use FluentCommunity\App\Models\XProfile;
21 use FluentCommunity\Framework\Support\Arr;
22 use FluentCommunity\Modules\PushNotification\PushNotificationModule;
23
24 class FeedsController extends Controller
25 {
26 public function get(Request $request)
27 {
28 $start = microtime(true);
29 $space = null;
30 $bySpace = $request->get('space');
31 $userId = $request->getSafe('user_id', 'intval', '');
32 $selectedTopic = $request->getSafe('topic_slug', 'sanitize_text_field', '');
33 $search = $request->getSafe('search', 'sanitize_text_field', '');
34 if ($bySpace) {
35 // just for validation
36 $space = BaseSpace::where('slug', $bySpace)->first();
37 if (!$space) {
38 return $this->sendError(['message' => __('Invalid space slug', 'fluent-community')]);
39 }
40 }
41
42 $currentUserModel = $this->getUser();
43 $currentUserId = get_current_user_id();
44
45 $isOwnProfile = $userId && (int)$userId === (int)$currentUserId;
46
47 $filterableStatuses = apply_filters('fluent_community/feed/filterable_statuses', []);
48
49 $statusFilter = $request->getSafe('status', 'sanitize_text_field', '');
50
51 $applyStatusFilter = $statusFilter
52 && in_array($statusFilter, $filterableStatuses, true)
53 && (Helper::isModerator() || $isOwnProfile);
54
55 $maxPerPage = (int) apply_filters('fluent_community/max_per_page', 100) ?: 100;
56
57 $queryArgs = [
58 'selected_topic' => $selectedTopic,
59 'per_page' => min($maxPerPage, max(1, (int)$request->get('per_page', 10))),
60 'page' => max(1, (int)$request->get('page', 1)),
61 'search' => $search,
62 ];
63
64 $feedsQuery = Feed::select(Feed::$publicColumns)
65 ->with(Feed::withPublicRelations($currentUserModel, $space))
66 ->searchBy($search, (array)$request->get('search_in', ['post_content']))
67 ->byTopicSlug($selectedTopic)
68 ->customOrderBy($request->getSafe('order_by_type'));
69
70 if ($applyStatusFilter) {
71 $feedsQuery->byStatus($statusFilter);
72 } else {
73 $feedsQuery->byContentModerationAccessStatus($currentUserModel, $space);
74 }
75
76 $stickyFeed = null;
77
78 $disableSticky = $request->get('disable_sticky', '') == 'yes' || !!$search || !!$selectedTopic;
79
80 if ($bySpace) {
81 $feedsQuery = $feedsQuery->filterBySpaceSlug($bySpace);
82 $queryArgs['space_slug'] = $bySpace;
83 }
84
85 if ($bySpace && !$disableSticky) {
86 $feedsQuery = $feedsQuery->where('is_sticky', 0);
87 if ($queryArgs['page'] === 1) {
88 $stickyFeed = Feed::where('space_id', $space->id)
89 ->where('is_sticky', 1)
90 ->byUserAccess($currentUserId)
91 ->byContentModerationAccessStatus($currentUserModel, $space)
92 ->with(Feed::withPublicRelations($this->getUser(), $space))
93 ->first();
94 }
95 }
96
97 if ($userId) {
98 $feedsQuery = $feedsQuery->where('user_id', $userId);
99
100 if (!Helper::isModerator()) {
101 $feedsQuery = $feedsQuery->whereHas('xprofile', function ($q) {
102 $q->where('status', 'active');
103 });
104 }
105
106 if ($userId != $currentUserId) {
107 $feedsQuery = $feedsQuery->byUserAccess($currentUserId);
108 }
109
110 $queryArgs['user_id'] = $userId;
111 } else {
112 $feedsQuery->byUserAccess($currentUserId)->whereHas('xprofile', function ($q) {
113 $q->where('status', 'active');
114 });
115 }
116
117 $queryArgs = array_filter($queryArgs);
118 $queryArgs['is_main_query'] = empty($queryArgs['space_slug']) && empty($queryArgs['user_id']) && empty($queryArgs['search']);
119
120 do_action_ref_array('fluent_community/feeds_query', [&$feedsQuery, $request->all(), $queryArgs]);
121
122 $feedsQuery->limit($queryArgs['per_page'])->offset(($queryArgs['page'] - 1) * $queryArgs['per_page']);
123 $feeds = $feedsQuery->get();
124
125 // add $stickyFeed to the first page
126 if ($stickyFeed) {
127 $stickyFeed = FeedsHelper::transformFeed($stickyFeed);
128 }
129
130 $feeds = FeedsHelper::transformFeedsCollection($feeds);
131
132 $currentCount = $feeds->count();
133 $to = ($queryArgs['page'] - 1) * $queryArgs['per_page'] + $currentCount;
134
135 $hasMore = $currentCount == $queryArgs['per_page'];
136
137 $data = [
138 'feeds' => [
139 'data' => $feeds,
140 'current_page' => $queryArgs['page'],
141 'per_page' => $queryArgs['per_page'],
142 'from' => $currentCount ? ($queryArgs['page'] - 1) * $queryArgs['per_page'] + 1 : 0,
143 'to' => $to,
144 'has_more' => $hasMore,
145 'total' => $currentCount == $queryArgs['per_page'] ? $to + $currentCount : $to
146 ],
147 'sticky' => $stickyFeed
148 ];
149
150 $isMainFeed = $queryArgs['page'] === 1 && !$search && !$userId;
151 if ($isMainFeed && $currentUserId) {
152 $data['last_fetched_timestamp'] = current_time('timestamp');
153 }
154
155 $data['execution_time'] = microtime(true) - $start;
156
157 $data = apply_filters('fluent_community/feeds_api_response', $data, $request->all());
158
159 return $data;
160 }
161
162 public function getFeedBySlug(Request $request, $feed_slug)
163 {
164 $start = microtime(true);
165 if ($request->get('context') == 'edit') {
166 $feed = Feed::where('slug', $feed_slug)->first();
167
168 if (!$feed || !$feed->hasEditAccess(get_current_user_id())) {
169 return $this->sendError([
170 'message' => __('You do not have permission to edit this feed', 'fluent-community')
171 ]);
172 }
173
174 $data = [
175 'feed' => FeedsHelper::transformForEdit($feed)
176 ];
177
178 return apply_filters('fluent_community/feed_api_response', $data, $request->all());
179 }
180
181 $feed = Feed::where('slug', $feed_slug)
182 ->select(Feed::$publicColumns)
183 ->with(Feed::withPublicRelations($this->getUser()))
184 ->whereHas('xprofile', function ($q) {
185 $q->where('status', 'active');
186 })
187 ->byUserAccess($this->getUserId())
188 ->first();
189
190 if (!$feed) {
191 return $this->sendError([
192 'message' => __('The feed could not be found', 'fluent-community')
193 ], 404);
194 }
195
196 $viewableByLinkStatuses = FeedsHelper::getViewableByLinkStatuses();
197
198 if (!in_array($feed->status, $viewableByLinkStatuses, true) && !$feed->hasEditAccess($this->getUserId())) {
199 return $this->sendError([
200 'message' => __('Sorry, you do not have permission to view this post', 'fluent-community')
201 ], 404);
202 }
203
204 $feed = FeedsHelper::transformFeed($feed);
205
206 return apply_filters('fluent_community/feed_api_response', [
207 'feed' => $feed,
208 'execution_time' => microtime(true) - $start
209 ], $request->all());
210
211 }
212
213 public function getFeedById(Request $request, $feedId)
214 {
215 $feed = Feed::findOrFail($feedId);
216 return $this->getFeedBySlug($request, $feed->slug);
217 }
218
219 public function getBookmarks(Request $request)
220 {
221 $userId = $this->getUserId();
222
223 $feedsQuery = Feed::where('status', 'published')
224 ->select(Feed::$publicColumns)
225 ->with(Feed::withPublicRelations($this->getUser()))
226 ->byBookMarked($userId)
227 ->byUserAccess($userId)
228 ->byTopicSlug($request->getSafe('topic_slug'))
229 ->customOrderBy($request->getSafe('order_by_type'))
230 ->searchBy($request->getSafe('search'));
231
232 if ($type = $request->get('type')) {
233 $feedsQuery = $feedsQuery->where('type', $type);
234 }
235
236 $queryArgs = [
237 'per_page' => (int)$request->get('per_page', 10),
238 'page' => (int)$request->get('page', 1)
239 ];
240
241 $feeds = $feedsQuery->orderBy('id', 'DESC')
242 ->limit($queryArgs['per_page'])
243 ->offset(($queryArgs['page'] - 1) * $queryArgs['per_page'])
244 ->get();
245
246 $currentCount = $feeds->count();
247 $to = ($queryArgs['page'] - 1) * $queryArgs['per_page'] + $currentCount;
248
249 $hasMore = $currentCount == $queryArgs['per_page'];
250
251 $feeds = FeedsHelper::transformFeedsCollection($feeds);
252
253 $data = [
254 'feeds' => [
255 'data' => $feeds,
256 'current_page' => $queryArgs['page'],
257 'per_page' => $queryArgs['per_page'],
258 'from' => $currentCount ? ($queryArgs['page'] - 1) * $queryArgs['per_page'] + 1 : 0,
259 'to' => $to,
260 'has_more' => $hasMore,
261 'total' => $currentCount == $queryArgs['per_page'] ? $to + $currentCount : $to
262 ]
263 ];
264
265 if ($queryArgs['page'] === 1) {
266 $lastItem = FeedsHelper::getLastFeedId();
267 if ($lastItem) {
268 $data['last_id'] = $lastItem;
269 }
270 }
271
272 return apply_filters('fluent_community/bookmarks_api_response', $data, $request->all());
273 }
274
275 public function store(Request $request)
276 {
277 $user = $this->getUser(true);
278
279 do_action('fluent_community/check_rate_limit/create_post', $user);
280
281 $requestData = $request->all();
282
283 $data = $this->sanitizeAndValidateData($requestData);
284 $data['user_id'] = $user->ID;
285 $data['status'] = 'published';
286
287 $data['status'] = apply_filters('fluent_community/feed/save_status', $data['status'], $requestData, null);
288
289 $feed = new Feed();
290 $feed->user_id = $user->ID;
291 $space = null;
292
293 if ($spaceSlug = $request->get('space')) {
294 $data['space_id'] = $this->validateAndSetSpace($spaceSlug, $user);
295 if ($data['space_id']) {
296 $space = Space::where('id', $data['space_id'])->first();
297 if (!$space) {
298 return $this->sendError([
299 'message' => __('Please select a valid space to post in.', 'fluent-community')
300 ]);
301 }
302 }
303
304 if ($space && Arr::get($space->settings, 'topic_required') == 'yes') {
305 $topicIds = (array)$request->get('topic_ids', []);
306 $spaceTopics = Utility::getTopicsBySpaceId($space->id);
307 $spaceTopicsIds = [];
308
309 foreach ($spaceTopics as $topic) {
310 $spaceTopicsIds[] = $topic['id'];
311 }
312
313 $validTopicIds = array_intersect($topicIds, $spaceTopicsIds);
314
315 if (!$validTopicIds) {
316 return $this->sendError([
317 'message' => __('Please select at least one topic to post in this space.', 'fluent-community'),
318 'shakes' => [
319 'topic_ids' => true
320 ]
321 ]);
322 }
323 }
324
325 } else if (!Helper::hasGlobalPost()) {
326 return $this->sendError([
327 'message' => __('Please select a valid space to post in.', 'fluent-community')
328 ]);
329 }
330
331 $spaceId = Arr::get($data, 'space_id');
332 $message = Arr::get($data, 'message');
333
334 $duplicateCheckMessage = $message;
335
336 $mentions = FeedsHelper::getMentions($data['message'], Arr::get($data, 'space_id'), true);
337 if ($mentions) {
338 $data['message'] = $message;
339 $message = $mentions['text'];
340 }
341
342 [$message, $inlineMedias] = FeedsHelper::replaceImageUrlsWithRealMediaArchive($message);
343
344 // replace new line with br
345 $data['message_rendered'] = wp_kses_post(FeedsHelper::mdToHtml($message));
346
347 $requestData['is_admin'] = $user->hasPermissionOrInCurrentSpace('community_moderator', $space);
348
349 [$data, $mediaItems] = FeedsHelper::processFeedMetaData($data, $requestData);
350
351 if ($inlineMedias) {
352 $mediaItems = array_merge($mediaItems, $inlineMedias);
353 }
354
355 if (Arr::get($requestData, 'send_announcement_email') == 'yes' && $requestData['is_admin']) {
356 $data['meta']['send_announcement_email'] = 'yes';
357 } else if (isset($data['meta']['send_announcement_email'])) {
358 $data['meta']['send_announcement_email'] = 'no';
359 }
360
361 if ($mentions) {
362 $data['meta']['mentioned_user_ids'] = Arr::get($mentions, 'user_ids', []);
363 }
364
365 $data = apply_filters('fluent_community/feed/new_feed_data', $data, $requestData);
366
367 $formContentType = (string)Arr::get($requestData, 'content_type', '');
368
369 if ($formContentType) {
370 $data = apply_filters('fluent_community/feed/new_feed_data_type_' . $formContentType, $data, $requestData);
371 }
372
373 if (is_wp_error($data)) {
374 return $this->sendError([
375 'message' => $data->get_error_message(),
376 'errors' => $data->get_error_data()
377 ]);
378 }
379
380 $feed->fill($data);
381
382 // Serialize a member's concurrent submissions by locking their profile row,
383 // so parallel matching requests cannot pass the duplicate check and both insert.
384 $isDuplicate = Helper::dbTransaction(function () use ($feed, $user, $spaceId, $duplicateCheckMessage) {
385 XProfile::where('user_id', $user->ID)->lockForUpdate()->first();
386
387 if ($duplicate = $this->checkForDuplicatePost($user->ID, $duplicateCheckMessage, $spaceId)) {
388 return $duplicate;
389 }
390
391 $feed->save();
392
393 return null;
394 });
395
396 if ($isDuplicate) {
397 return $isDuplicate;
398 }
399
400 $feed = Feed::find($feed->id); // just renewing the feed
401
402 if ($mentions) {
403 do_action('fluent_community/feed_mentioned', $feed, Arr::get($mentions, 'users'));
404 }
405
406 if ($formContentType) {
407 do_action('fluent_community/feed/just_created_type_' . $formContentType, $feed, $requestData);
408 }
409
410 if ($mediaItems) {
411 $this->saveMediaItems($feed, $mediaItems);
412 }
413
414 $feed->load(['xprofile', 'comments.xprofile']);
415 if ($feed->space_id) {
416 $feed->load(['space']);
417 $topicIds = (array)$request->get('topic_ids', []);
418 // take only max topics per post
419 if ($topicIds) {
420 $topicsConfig = Helper::getTopicsConfig();
421 $topicIds = array_slice($topicIds, 0, $topicsConfig['max_topics_per_post']);
422 $feed->attachTopics($topicIds, false);
423 $feed->load(['terms']);
424 }
425 }
426
427
428 if ($feed->status == 'scheduled') {
429 do_action('fluent_community/feed/scheduled', $feed);
430 /* translators: %s: The scheduled date and time for the post */
431 $message = sprintf(__('Your post has been scheduled for %s', 'fluent-community'), $feed->scheduled_at);
432 return [
433 'feed' => FeedsHelper::transformFeed($feed),
434 'scheduled_at' => $feed->scheduled_at,
435 'message' => $message,
436 'last_fetched_timestamp' => current_time('timestamp')
437 ];
438 }
439
440 if (!in_array($feed->status, ['published', 'unlisted'])) {
441 do_action('fluent_community/feed/new_feed_' . $feed->status, $feed);
442 /* translators: %s: The status of the post */
443 $message = sprintf(__('Your post has been marked as %s', 'fluent-community'), $feed->status);
444 return apply_filters('fluent_community/feed/new_feed_response', [
445 'feed' => FeedsHelper::transformFeed($feed),
446 'message' => $message,
447 'last_fetched_timestamp' => current_time('timestamp')
448 ], $feed, $request->all());
449 }
450
451 do_action('fluent_community/feed/created', $feed);
452
453 if ($feed->space_id) {
454 do_action('fluent_community/space_feed/created', $feed);
455 } else {
456 do_action('fluent_community/profile_feed/created', $feed);
457 }
458
459 $message = __('Your post has been published', 'fluent-community');
460
461 return apply_filters('fluent_community/feed/new_feed_response', [
462 'feed' => FeedsHelper::transformFeed($feed),
463 'message' => $message,
464 'last_fetched_timestamp' => current_time('timestamp')
465 ], $feed, $request->all());
466 }
467
468 public function update(Request $request, $feedId)
469 {
470 $requestData = $request->all();
471 $data = $this->sanitizeAndValidateData($requestData);
472 $user = $this->getUser(true);
473 $existingFeed = Feed::findOrFail($feedId);
474 /** @var Feed $existingFeed */
475
476 $editableStatuses = ['published', 'unlisted', 'scheduled', 'pending'];
477
478 if (!in_array($existingFeed->status, $editableStatuses)) {
479 return $this->sendError([
480 'message' => __('Sorry, this post is not in an editable state.', 'fluent-community')
481 ]);
482 }
483
484 $user->canEditFeed($existingFeed, true);
485
486 // Must resolve before processFeedMetaData() reads it.
487 $isModerator = $user->hasPermissionOrInCurrentSpace('community_moderator', $existingFeed->space);
488 $requestData['is_admin'] = $isModerator;
489
490 if ($surveyOptionError = FeedsHelper::getSurveyOptionsUpdateError(
491 Arr::get($existingFeed->meta, 'survey_config.options', []),
492 Arr::get($requestData, 'survey', [])
493 )) {
494 return $this->sendError([
495 'message' => $surveyOptionError
496 ]);
497 }
498
499 if ($isModerator && ($status = Arr::get($requestData, 'status'))) {
500 if (in_array($status, $editableStatuses, true)) {
501 $fallbackStatus = $status === 'unlisted' ? $existingFeed->status : $status;
502 $data['status'] = apply_filters('fluent_community/feed/save_status', $fallbackStatus, $requestData, $existingFeed);
503 }
504 }
505
506 $message = $data['message'];
507 $mentions = FeedsHelper::getMentions($data['message'], Arr::get($data, 'space_id'));
508 if ($mentions) {
509 $data['message'] = $message;
510 $message = $mentions['text'];
511 }
512
513 [$message, $inlineMedias] = FeedsHelper::replaceImageUrlsWithRealMediaArchive($message, $existingFeed);
514
515 // replace new line with br
516 $data['message_rendered'] = wp_kses_post(FeedsHelper::mdToHtml($message));
517
518 [$data, $mediaItems] = FeedsHelper::processFeedMetaData($data, $requestData, $existingFeed);
519
520 if($inlineMedias) {
521 $mediaItems = array_merge($mediaItems, $inlineMedias);
522 }
523
524 if (isset($existingFeed->meta['comments_disabled'])) {
525 $data['meta']['comments_disabled'] = $existingFeed->meta['comments_disabled'];
526 }
527
528 if (Arr::get($requestData, 'send_announcement_email') == 'yes' && $requestData['is_admin']) {
529 $data['meta']['send_announcement_email'] = 'yes';
530 } else if (Arr::get($existingFeed->meta, 'send_announcement_email')) {
531 $data['meta']['send_announcement_email'] = Arr::get($existingFeed->meta, 'send_announcement_email');
532 }
533
534 $data = apply_filters('fluent_community/feed/update_feed_data', $data, $requestData);
535
536 if (is_wp_error($data)) {
537 return $this->sendError([
538 'message' => $data->get_error_message(),
539 'errors' => $data->get_error_data()
540 ]);
541 }
542
543 $newContentType = Arr::get($requestData, 'content_type', '');
544 $existingContentType = $existingFeed->content_type;
545
546 if (($newContentType === 'document' && empty($requestData['document_ids'])) || ($newContentType === '' && $existingContentType === 'document' && empty($requestData['survey']))) {
547 $newContentType = $data['content_type'] = 'text';
548 }
549
550 if ($newContentType != $existingContentType) {
551 // Content Type Changed
552 do_action('fluent_community/feed/updating_content_type_old_' . $existingContentType, $existingFeed, $newContentType, $requestData);
553 }
554
555 if ($newContentType != 'text') {
556 $data = apply_filters('fluent_community/feed/update_feed_data_type_' . $newContentType, $data, $requestData, $existingFeed);
557 if (is_wp_error($data)) {
558 return $this->sendError([
559 'message' => $data->get_error_message(),
560 'errors' => $data->get_error_data()
561 ]);
562 }
563 }
564
565 if ($message != $existingFeed->message) {
566 $data['meta']['last_edited'] = [
567 'user_id' => $user->ID,
568 'time' => current_time('mysql')
569 ];
570 }
571
572 $movingToProfile = false;
573
574 if ($newSpaceId = $request->get('new_space_id')) {
575 if (!Helper::isUserInSpace($existingFeed->user_id, $newSpaceId)) {
576 return $this->sendError([
577 'message' => __('The author is not a member of the selected space', 'fluent-community')
578 ]);
579 }
580
581 $newSpace = Space::findOrFail($newSpaceId);
582
583 // check if the current user is admin
584 if (!$user->hasPermissionOrInCurrentSpace('community_admin', $newSpace)) {
585 return $this->sendError([
586 'message' => __('Sorry, you do not have permission to change the space for this post', 'fluent-community')
587 ]);
588 }
589
590 $data['space_id'] = $newSpaceId;
591
592 \FluentCommunity\App\Models\Activity::where('feed_id', $existingFeed->id)
593 ->update(['space_id' => $newSpaceId]);
594 } else if ($request->get('move_to_profile')) {
595 if (!$user->hasPermissionOrInCurrentSpace('community_admin', $existingFeed->space)) {
596 return $this->sendError([
597 'message' => __('Sorry, you do not have permission to move this post to a profile', 'fluent-community')
598 ]);
599 }
600
601 $data['space_id'] = null;
602 $movingToProfile = true;
603
604 \FluentCommunity\App\Models\Activity::where('feed_id', $existingFeed->id)
605 ->update(['space_id' => null]);
606 }
607
608 $data = apply_filters('fluent_community/feed/update_data', $data, $existingFeed);
609 $existingFeed->fill($data);
610 $dirty = $existingFeed->getDirty();
611
612 $existingFeed->fill($data);
613 $existingFeed->save();
614
615 if ($message != $existingFeed->message) {
616 $editHistory = $existingFeed->getCustomMeta('_edit_history', []);
617 if (!$editHistory) {
618 $editHistory = [];
619 }
620
621 $editHistory[] = array_filter([
622 'user_id' => $user->ID,
623 'time' => current_time('mysql'),
624 'prev_message' => $existingFeed->message,
625 'prev_title' => $existingFeed->title
626 ]);
627
628 // get last 5 edit history
629 $editHistory = array_slice($editHistory, -5);
630 $existingFeed->updateCustomMeta('_edit_history', $editHistory);
631 }
632
633 $mediaItemIds = [];
634 foreach ($mediaItems as $mediaItem) {
635 $mediaItemIds[] = $mediaItem->id;
636 }
637
638 if (Arr::has($requestData, 'media_images')) {
639 $deactivateQuery = Media::where('object_source', 'feed')
640 ->where('feed_id', $existingFeed->id)
641 ->whereNotIn('id', $mediaItemIds);
642
643 if (empty(Arr::get($requestData, 'media_images'))) {
644 $deactivateQuery->where('media_type', '!=', 'fluent_player');
645 }
646
647 $deactivateQuery->update(['is_active' => 0]);
648 }
649
650 if ($mediaItems) {
651 $this->saveMediaItems($existingFeed, $mediaItems);
652 }
653
654 $existingFeed->load(['xprofile', 'comments.xprofile']);
655
656 if ($existingFeed->space_id) {
657 $existingFeed->load(['space']);
658 $space = $existingFeed->space;
659 $topicIds = (array)Arr::get($requestData, 'topic_ids', []);
660 $topicsConfig = Helper::getTopicsConfig();
661 // take only max topics per post
662 if ($topicIds) {
663 $topicIds = array_slice($topicIds, 0, $topicsConfig['max_topics_per_post']);
664 $existingFeed->attachTopics($topicIds, true);
665 } else {
666 if ($space && Arr::get($space->settings, 'topic_required') != 'yes') {
667 $existingFeed->terms()->where('taxonomy_name', 'post_topic')->detach();
668 }
669 }
670 } else if ($movingToProfile) {
671 // Topics are space-scoped; a post moved to the profile must not keep them.
672 $existingFeed->terms()->where('taxonomy_name', 'post_topic')->detach();
673 }
674
675 if ($dirty) {
676 do_action('fluent_community/feed/updated', $existingFeed, $dirty);
677 if ($existingFeed->space_id) {
678 do_action('fluent_community/space_feed/updated', $existingFeed);
679 }
680 }
681
682 $data = [
683 'feed' => FeedsHelper::transformFeed($existingFeed),
684 'message' => __('Your post has been updated', 'fluent-community')
685 ];
686
687 return apply_filters('fluent_community/feed/update_feed_response', $data, $request->all());
688 }
689
690 public function patchFeed(Request $request, $feedId)
691 {
692 $feed = Feed::findOrFail($feedId);
693 $user = $this->getUser(true);
694
695 $isAuthor = $feed->user_id == $user->ID;
696 $isMod = $user->hasPermissionOrInCurrentSpace('community_moderator', $feed->space);
697 $isAdmin = $user->hasPermissionOrInCurrentSpace('community_admin', $feed->space);
698
699 if (!$isMod && !$isAuthor && !$isAdmin) {
700 return $this->sendError([
701 'message' => __('You do not have permission to perform this action', 'fluent-community')
702 ]);
703 }
704
705 $allData = $request->all();
706 $validKeys = ['is_sticky', 'priority', 'comments_disabled'];
707
708 if (!$isMod) {
709 $validKeys = ['comments_disabled'];
710 }
711
712 $data = Arr::only($allData, $validKeys);
713
714 $data = array_map('intval', $data);
715
716 // List/unlist toggle — community-moderator only, routed through the shared save_status filter.
717 if (Helper::isModerator($user)
718 && ($reqStatus = Arr::get($allData, 'status'))
719 && in_array($reqStatus, ['published', 'unlisted'], true)
720 && in_array($feed->status, ['published', 'unlisted'], true)
721 ) {
722 $fallbackStatus = $reqStatus === 'unlisted' ? $feed->status : $reqStatus;
723 $data['status'] = apply_filters('fluent_community/feed/save_status', $fallbackStatus, $allData, $feed);
724 }
725
726 if (isset($data['is_sticky'])) {
727 $data['is_sticky'] = $data['is_sticky'] ? 1 : 0;
728 if ($data['is_sticky'] && $feed->space_id) {
729 // toBase() keeps the type scope but skips the Orm update()'s updated_at stamp, which would bump the post being un-stuck.
730 Feed::where('space_id', $feed->space_id)
731 ->where('is_sticky', 1)
732 ->toBase()
733 ->update(['is_sticky' => 0]);
734 }
735 }
736
737 if (isset($data['comments_disabled'])) {
738 $meta = $feed->meta;
739 $meta['comments_disabled'] = $data['comments_disabled'] ? 'yes' : 'no';
740 $data['meta'] = $meta;
741 }
742
743 if ($data) {
744 $feed->fill($data);
745 $dirty = $feed->getDirty();
746 if ($dirty) {
747 // Only a real list/unlist transition is activity, so read $dirty, not the request.
748 if (!array_key_exists('status', $dirty)) {
749 $feed->timestamps = false;
750 }
751
752 $feed->save();
753 do_action('fluent_community/feed/updated', $feed, $dirty);
754 }
755 }
756
757 return apply_filters('fluent_community/feed/patch_feed_response', [
758 'feed' => $feed,
759 'message' => __('Feed updated', 'fluent-community')
760 ], $feed, $request->all());
761 }
762
763 public function getWelcomeBanner(Request $request)
764 {
765 $scope = get_current_user_id() ? 'login' : 'logout';
766
767 $data = [
768 'welcome_banner' => Helper::getWelcomeBanner($scope)
769 ];
770
771 return apply_filters('fluent_community/welcome_banner_api_response', $data, $request->all());
772 }
773
774 public function getLinks(Request $request)
775 {
776 $scope = $request->getSafe('scope');
777
778 if ($scope == 'view') {
779 $data = [
780 'links' => Helper::getEnabledFeedLinks()
781 ];
782
783 return apply_filters('fluent_community/feed_links_api_response', $data, $request->all());
784 }
785
786 $data = [
787 'links' => Helper::getFeedLinks()
788 ];
789
790 return apply_filters('fluent_community/feed_links_api_response', $data, $request->all());
791 }
792
793 public function updateLinks(Request $request)
794 {
795 $links = $request->get('links', []);
796
797 $links = array_map(function ($link) {
798 return CustomSanitizer::santizeLinkItem($link);
799 }, $links);
800
801 Helper::updateFeedLinks($links);
802
803 return [
804 'message' => __('Links have been updated.', 'fluent-community'),
805 'links' => $links
806 ];
807 }
808
809 private function saveMediaItems($feed, $mediaItems)
810 {
811 foreach ($mediaItems as $media) {
812 $media->feed_id = $feed->id;
813 $media->is_active = 1;
814 $media->object_source = 'feed';
815 $media->save();
816 }
817 }
818
819 private function sanitizeAndValidateData($data)
820 {
821 $data['type'] = 'text';
822
823 $this->validate($data, [
824 'message' => 'required'
825 ], [
826 'message.required' => __('Message is required', 'fluent-community'),
827 ]);
828
829 return FeedsHelper::sanitizeAndValidateData($data);
830 }
831
832 private function checkForDuplicatePost($userId, $message, $spaceId = null)
833 {
834 if (apply_filters('fluent_community/disable_duplicate_post_check', false, $userId, $spaceId)) {
835 return false;
836 }
837
838 $message = trim($message);
839
840 $exist = Feed::where('user_id', $userId)
841 ->where('message', $message)
842 ->where('created_at', '>', gmdate('Y-m-d H:i:s', current_time('timestamp') - 7 * 24 * 60 * 60))
843 ->when($spaceId, function ($query) use ($spaceId) {
844 $query->where('space_id', $spaceId);
845 })
846 ->first();
847
848 if ($exist) {
849 return $this->sendError(['message' => __('No duplicate post please!', 'fluent-community')]);
850 }
851
852 return false;
853 }
854
855 private function validateAndSetSpace($spaceSlug, $user)
856 {
857 if ($spaceSlug == '__self__post__') {
858 if (!Helper::hasGlobalPost()) {
859 throw new \Exception(esc_html__('Please select a valid space to post in', 'fluent-community'));
860 }
861
862 return null;
863 }
864
865 $space = Space::where('slug', $spaceSlug)->first();
866
867 if (!$space) {
868 throw new \Exception(esc_html__('Please select a valid space to post in', 'fluent-community'));
869 }
870
871 $user->verifySpacePermission('can_create_post', $space);
872
873 return $space->id;
874 }
875
876 public function deleteFeed(Request $request, $feed_id)
877 {
878 $feed = Feed::findOrFail($feed_id);
879
880 $user = User::find(get_current_user_id());
881 $user->canDeleteFeed($feed, true);
882 do_action('fluent_community/feed/before_deleted', $feed);
883 $feed->delete();
884
885 do_action('fluent_community/feed/deleted', $feed_id);
886
887 return [
888 'message' => __('Feed has been deleted successfully', 'fluent-community')
889 ];
890 }
891
892 public function deleteMediaPreview(Request $request, $feed_id)
893 {
894 $feed = Feed::findOrFail($feed_id);
895 $user = User::find(get_current_user_id());
896 $user->canDeleteFeed($feed, true);
897
898 //do_action('fluent_community/feed/media_deleted', $feed->media);
899
900 $meta = $feed->meta;
901 $meta['media_preview'] = null;
902
903 $feed->meta = $meta;
904 $feed->save();
905
906 return [
907 'message' => __('Media preview image has been removed successfully.', 'fluent-community')
908 ];
909 }
910
911 public function handleMediaUpload(Request $request)
912 {
913 if ($error = Helper::checkUploadSizeError()) {
914 return $this->sendError($error, 413);
915 }
916
917 $user = $this->getUser(true);
918
919 do_action('fluent_community/check_rate_limit/media_upload', $user);
920
921 $allowedMimeTypesArray = apply_filters('fluent_community/support_attachment_types', [
922 'image/jpeg',
923 'image/pjpeg',
924 'image/png',
925 'image/gif',
926 'image/webp',
927 'image/heic',
928 ]);
929
930 $allowedTypes = implode(',', $allowedMimeTypesArray);
931
932 // Extensions eligible for WebP conversion (from allowed MIME types, excluding webp)
933 $convertibleExtensions = [];
934 foreach ($allowedMimeTypesArray as $mime) {
935 $element = explode('/', $mime);
936 $ext = end($element);
937 if ($ext === 'pjpeg') {
938 $ext = 'jpeg';
939 }
940 if ($ext && $ext !== 'webp' && !in_array($ext, $convertibleExtensions)) {
941 $convertibleExtensions[] = $ext;
942 }
943 }
944 // jpg is a common alias for jpeg — add only if jpeg is allowed
945 if (in_array('jpeg', $convertibleExtensions)) {
946 $convertibleExtensions[] = 'jpg';
947 }
948
949 $maxFileUnit = apply_filters('fluent_community/media_upload_max_file_unit', 'MB');
950 $maxFileSize = apply_filters('fluent_community/media_upload_max_file_size', 100);
951
952 $allowedFileSize = $maxFileSize;
953 if (strtoupper($maxFileUnit) == 'MB') {
954 $allowedFileSize = $maxFileSize * 1024;
955 } else if (strtoupper($maxFileUnit) == 'GB') {
956 $allowedFileSize = $maxFileSize * 1024 * 1024;
957 }
958
959 $files = $this->validate($this->request->files(), [
960 'file' => 'required|mimetypes:' . $allowedTypes . '|max:' . $allowedFileSize,
961 ], [
962 'file.required' => __('No upload file was received. Please try again.', 'fluent-community'),
963 'file.mimetypes' => __('The file must be an image type.', 'fluent-community'),
964 /* translators: %$1s is replaced by the maximum allowed file size, %2$s is replaced by the file size unit (e.g. MB) */
965 'file.max' => sprintf(__('The file size must be less than %1$s%2$s.', 'fluent-community'), $maxFileSize, $maxFileUnit)
966 ]);
967
968 if (Arr::get($files, 'file.type') === 'image/heic'
969 && (!extension_loaded('imagick') || !class_exists('Imagick') || !in_array('HEIC', \Imagick::queryFormats('HEIC')))
970 ) {
971 return $this->sendError([
972 'message' => __('HEIC image format is not supported on this system.', 'fluent-community')
973 ]);
974 }
975
976 add_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']);
977 $uploadedFiles = FileSystem::put($files);
978 remove_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']);
979
980 $file = Arr::get($uploadedFiles, 0);
981
982 if (is_wp_error($file)) {
983 return $this->sendError([
984 'message' => $file->get_error_message()
985 ]);
986 }
987
988 // an empty request body reaches here with nothing uploaded; never build media data from it
989 if (!is_array($file) || empty($file['url']) || empty($file['file']) || empty($file['type'])) {
990 return $this->sendError([
991 'message' => __('No upload file was received. Please try again.', 'fluent-community')
992 ]);
993 }
994
995 $upload_dir = wp_upload_dir();
996
997 $originalUrl = $file['url'];
998 $orginalPath = $upload_dir['basedir'] . '/fluent-community/' . $file['file'];
999 $originalFileType = $file['type'];
1000 $originalFileName = $file['file'];
1001
1002 $willWebPConvert = $request->get('disable_convert') != 'yes';
1003
1004 $willWebPConvert = apply_filters('fluent_community/convert_image_to_webp', $willWebPConvert, $file);
1005 $willResize = $request->get('resize');
1006 $maxWidth = $request->get('max_width');
1007
1008 $willResize = apply_filters('fluent_community/media_upload_resize', $willResize, $file);
1009
1010 if ($context = $request->get('context')) {
1011 $maxWidth = apply_filters('fluent_community/media_upload_max_width_' . $context, $maxWidth, $file);
1012 }
1013
1014 if ($willResize && $maxWidth) {
1015 $upload_dir = wp_upload_dir();
1016 $fileUrl = str_replace($upload_dir['baseurl'], $upload_dir['basedir'], $file['url']);
1017
1018 $editor = wp_get_image_editor($fileUrl);
1019
1020 if (!is_wp_error($editor) && $editor->get_size()['width'] > $maxWidth) {
1021 // Current file extension
1022 $ext = pathinfo($file['url'], PATHINFO_EXTENSION);
1023 $willConvert = in_array($ext, $convertibleExtensions) && $willWebPConvert;
1024
1025 if ($willConvert) {
1026 $dottedExtensions = array_map(function ($ext) {
1027 return '.' . $ext;
1028 }, $convertibleExtensions);
1029
1030 $fileUrl = str_replace($dottedExtensions, '.webp', $fileUrl);
1031 $file['file'] = str_replace($dottedExtensions, '.webp', $file['file']);
1032 $file['url'] = str_replace($dottedExtensions, '.webp', $file['url']);
1033 $file['type'] = 'image/webp';
1034 }
1035
1036 // resize the image
1037 $editor->resize($maxWidth, null, false);
1038 $editor->set_quality(90);
1039 if ($willConvert) {
1040 $result = $editor->save($fileUrl, 'image/webp');
1041 if ($result['mime-type'] == 'image/webp') {
1042 // remove original file now
1043 wp_delete_file(str_replace('.webp', '.' . $ext, $fileUrl));
1044 }
1045 $file['is_converted'] = true;
1046 } else {
1047 $result = $editor->save($fileUrl);
1048 }
1049
1050 if ($result['mime-type'] != 'image/webp') {
1051 $file['file'] = $originalFileName;
1052 $file['url'] = $originalUrl;
1053 $file['type'] = $result['mime-type'];
1054 }
1055
1056 $file['meta'] = [
1057 'width' => $editor->get_size()['width'],
1058 'height' => $editor->get_size()['height']
1059 ];
1060 }
1061 $file['path'] = $upload_dir['basedir'] . '/fluent-community/' . $file['file'];
1062 } else {
1063 $upload_dir = wp_upload_dir();
1064 $file['path'] = $upload_dir['basedir'] . '/fluent-community/' . $file['file'];
1065 }
1066
1067 if ($willWebPConvert && empty($file['is_converted']) && !$request->get('skip_convert')) {
1068 $path = $file['path'];
1069 $extension = pathinfo($path, PATHINFO_EXTENSION);
1070
1071 if ($extension != 'webp' && in_array($extension, $convertibleExtensions)) {
1072 // Let's convert to webp
1073 $editor = wp_get_image_editor($file['path']);
1074 if (!is_wp_error($editor)) {
1075 $file['path'] = str_replace('.' . $extension, '.webp', $file['path']);
1076 $file['url'] = str_replace('.' . $extension, '.webp', $file['url']);
1077 $file['type'] = 'image/webp';
1078 $result = $editor->save($file['path'], 'image/webp');
1079
1080 if ($result['mime-type'] != 'image/webp') {
1081 $file['path'] = $orginalPath;
1082 $file['url'] = $originalUrl;
1083 $file['type'] = $result['mime-type'];
1084 } else {
1085 wp_delete_file($orginalPath);
1086 }
1087
1088 $file['meta'] = [
1089 'width' => $editor->get_size()['width'],
1090 'height' => $editor->get_size()['height']
1091 ];
1092 }
1093 }
1094 }
1095
1096 $mediaData = [
1097 'media_type' => $file['type'],
1098 'driver' => 'local',
1099 'media_path' => $file['path'],
1100 'media_url' => $file['url'],
1101 'settings' => Arr::get($file, 'meta', [])
1102 ];
1103
1104 $mediaData = apply_filters('fluent_community/media_upload_data', $mediaData, $file);
1105
1106 if (is_wp_error($mediaData)) {
1107 return $this->sendError([
1108 'message' => $mediaData->get_error_message(),
1109 'errors' => $mediaData->get_error_data()
1110 ]);
1111 }
1112
1113 if (!$mediaData) {
1114 return $this->sendError([
1115 'message' => __('Error while uploading the media', 'fluent-community')
1116 ]);
1117 }
1118
1119 // Let's create the media now
1120 $media = Media::create($mediaData);
1121
1122 $mediaUrl = $media->public_url;
1123
1124 $mediaUrl = add_query_arg([
1125 'media_key' => $media->media_key,
1126 ], $mediaUrl);
1127
1128 return [
1129 'media' => [
1130 'url' => $mediaUrl,
1131 'media_key' => $media->media_key,
1132 'type' => $media->media_type,
1133 'width' => Arr::get($media->settings, 'width'),
1134 'height' => Arr::get($media->settings, 'height')
1135 ]
1136 ];
1137 }
1138
1139 public function getTicker(Request $request)
1140 {
1141 $start = microtime(true);
1142
1143 $userId = get_current_user_id();
1144 if (!$userId) {
1145 return [
1146 'timestamp' => current_time('mysql', true),
1147 'has_changes' => false,
1148 'error' => __('User not authenticated', 'fluent-community'),
1149 'feeds' => []
1150 ];
1151 }
1152
1153 do_action('fluent_community/track_activity');
1154
1155
1156 // Support both old and new format
1157 $since = $request->get('since');
1158 if (!$since) {
1159 $since = date('Y-m-d H:i:s', current_time('timestamp') - 60); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
1160 } else {
1161 $timestamp = strtotime($since);
1162 if (current_time('timestamp') - $timestamp > 300) {
1163 $since = date('Y-m-d H:i:s', current_time('timestamp') - 60); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
1164 }
1165 }
1166
1167 $feedUpdates = [];
1168 $hasChanges = false;
1169
1170 // Get feed updates if since timestamp provided
1171 if ($since) {
1172 // Get all updated/created feeds with full data (including relationships)
1173 $currentUserModel = Helper::getCurrentUser();
1174 $updatedFeeds = Feed::where('updated_at', '>', $since)
1175 ->where('status', 'published')
1176 ->byUserAccess($userId)
1177 ->with(Feed::withPublicRelations($currentUserModel, null))
1178 ->orderBy('updated_at', 'desc')
1179 ->limit(20) // Reduced limit since we're sending full data
1180 ->get();
1181
1182 // Transform feeds to include all necessary data
1183 $transformedFeeds = FeedsHelper::transformFeedsCollection($updatedFeeds);
1184
1185 foreach ($transformedFeeds as $feed) {
1186 $isNew = $feed->created_at >= $since;
1187
1188 // Determine context (primary context)
1189 $context = 'global';
1190 if ($feed->space_id && $feed->space) {
1191 $context = 'space-' . $feed->space->slug;
1192 }
1193
1194 $feedUpdates[] = [
1195 'id' => $feed->id,
1196 'updated_at' => $feed->updated_at,
1197 'action' => $isNew ? 'created' : 'updated',
1198 'context' => $context,
1199 'user_id' => $feed->user_id,
1200 'feed_data' => $feed // Include full feed data
1201 ];
1202 }
1203
1204 $hasChanges = !empty($feedUpdates);
1205 }
1206
1207 // Get notification count
1208 $notificationCount = NotificationSubscriber::unread()->where('user_id', $userId)->count();
1209
1210 $newNotifications = $this->getToastNotifications($userId, $since, $notificationCount);
1211
1212 $response = [
1213 'timestamp' => current_time('mysql'),
1214 'has_changes' => $hasChanges,
1215 'feeds' => $feedUpdates,
1216 'notifications' => [
1217 'unread_count' => $notificationCount,
1218 'new_count' => count($newNotifications),
1219 'new_items' => $newNotifications
1220 ],
1221 'spaces' => [], // For future use
1222 'execution_time' => microtime(true) - $start
1223 ];
1224
1225 return apply_filters('fluent_community/feed_ticker', $response, $request->all());
1226 }
1227
1228 /**
1229 * Unread notifications that landed since the previous ticker check, shaped for the
1230 * in-app toast. Deliberately cheap:
1231 *
1232 * - returns before touching the DB when the toast is filtered off or the user has
1233 * nothing unread, so the steady state costs zero extra queries
1234 * - the predicate is answered by the (user_id, is_read, object_type, updated_at)
1235 * index added in NotificationUserMigrator, so this is a short range scan with
1236 * no filesort - on a 177k-row table it examines a single row instead of the
1237 * ~88k the single-column is_read index used to force
1238 * - the cursor is the subscriber `updated_at`, not `created_at`: a re-notification
1239 * ("X and 3 others reacted to your post") bumps the existing subscriber row in
1240 * place instead of inserting a new one - see NotificationEventHandler
1241 * - the xprofile eager load only fires when at least one row came back
1242 *
1243 * @param int $userId
1244 * @param string $since MySQL datetime in site local time
1245 * @param int $unreadCount
1246 * @return array
1247 */
1248 protected function getToastNotifications($userId, $since, $unreadCount)
1249 {
1250 if (!$unreadCount || !$since) {
1251 return [];
1252 }
1253
1254 if (!apply_filters('fluent_community/enable_notification_toast', true, $userId)) {
1255 return [];
1256 }
1257
1258 $limit = (int)apply_filters('fluent_community/notification_toast_limit', 3, $userId);
1259
1260 if ($limit < 1) {
1261 return [];
1262 }
1263
1264 $notifications = Notification::query()
1265 ->select([
1266 'fcom_notifications.id',
1267 'fcom_notifications.feed_id',
1268 'fcom_notifications.object_id',
1269 'fcom_notifications.src_user_id',
1270 'fcom_notifications.action',
1271 'fcom_notifications.content',
1272 'fcom_notifications.route',
1273 'fcom_notification_users.updated_at as notified_at'
1274 ])
1275 ->join('fcom_notification_users', 'fcom_notification_users.object_id', '=', 'fcom_notifications.id')
1276 ->where('fcom_notification_users.user_id', $userId)
1277 ->where('fcom_notification_users.is_read', 0)
1278 ->where('fcom_notification_users.object_type', 'notification')
1279 ->where('fcom_notification_users.updated_at', '>', $since)
1280 ->with(['xprofile' => function ($q) {
1281 return $q->select(['user_id', 'display_name', 'username', 'avatar']);
1282 }])
1283 ->orderBy('fcom_notification_users.updated_at', 'DESC')
1284 ->limit($limit)
1285 ->get();
1286
1287 $commentIds = [];
1288 foreach ($notifications as $notification) {
1289 if (!in_array($notification->action, PushNotificationModule::PUSHED_ACTIONS, true)) {
1290 continue;
1291 }
1292
1293 $commentIds[] = (int)$notification->object_id;
1294 $commentIds[] = (int)Arr::get((array)$notification->route, 'query.comment_id');
1295 }
1296
1297 $pushedCommentIds = PushNotificationModule::getPushedCommentIds(
1298 $userId,
1299 array_values(array_filter(array_unique($commentIds)))
1300 );
1301
1302 $items = [];
1303
1304 foreach ($notifications as $notification) {
1305 $wasPushed = in_array($notification->action, PushNotificationModule::PUSHED_ACTIONS, true)
1306 && (in_array((int)$notification->object_id, $pushedCommentIds, true)
1307 || in_array((int)Arr::get((array)$notification->route, 'query.comment_id'), $pushedCommentIds, true));
1308
1309 // The push already told this member; a toast would say it twice.
1310 if ($wasPushed) {
1311 continue;
1312 }
1313
1314 $xprofile = $notification->xprofile;
1315
1316 $items[] = [
1317 'id' => (int)$notification->id,
1318 'feed_id' => $notification->feed_id ? (int)$notification->feed_id : null,
1319 'object_id' => $notification->object_id ? (int)$notification->object_id : null,
1320 'action' => $notification->action,
1321 'route' => $notification->route,
1322 'text' => $this->getToastText($notification->content),
1323 'notified_at' => $notification->notified_at,
1324 'avatar' => $xprofile ? $xprofile->avatar : '',
1325 'name' => $xprofile ? $xprofile->display_name : ''
1326 ];
1327 }
1328
1329 return apply_filters('fluent_community/notification_toast_items', $items, $userId);
1330 }
1331
1332 /**
1333 * Flatten stored notification HTML to a single line of plain text. The toast renders
1334 * this with v-text, so it must never carry markup back to the client.
1335 *
1336 * @param string $content
1337 * @return string
1338 */
1339 protected function getToastText($content)
1340 {
1341 if (!$content) {
1342 return '';
1343 }
1344
1345 $text = wp_specialchars_decode(wp_strip_all_tags($content), ENT_QUOTES);
1346 $text = trim(preg_replace('/\s+/', ' ', $text));
1347
1348 if (mb_strlen($text) > 140) {
1349 $text = mb_substr($text, 0, 140) . '...';
1350 }
1351
1352 return $text;
1353 }
1354
1355 public function batchFetch(Request $request)
1356 {
1357 $feedIds = $request->get('feed_ids', []);
1358
1359 if (empty($feedIds) || !is_array($feedIds)) {
1360 return [
1361 'feeds' => [],
1362 'error' => __('No feed IDs provided', 'fluent-community')
1363 ];
1364 }
1365
1366 $userId = get_current_user_id();
1367
1368 // Limit to 20 feeds per batch to prevent abuse
1369 $feedIds = array_slice($feedIds, 0, 20);
1370
1371 // Build query based on context
1372 $query = Feed::whereIn('id', $feedIds)
1373 ->where('status', 'published')
1374 ->byUserAccess($userId);
1375
1376 $currentUserModel = $this->getUser();
1377
1378 $feeds = $query
1379 ->with(Feed::withPublicRelations($currentUserModel))
1380 ->get();
1381
1382 $feeds = FeedsHelper::transformFeedsCollection($feeds);
1383
1384 return [
1385 'feeds' => $feeds,
1386 'count' => $feeds->count()
1387 ];
1388 }
1389
1390 public function getTickerUpdates(Request $request)
1391 {
1392 $context = $request->get('context', 'global');
1393 $since = $request->get('since'); // ISO 8601 timestamp
1394
1395 $userId = get_current_user_id();
1396 if (!$userId) {
1397 return [
1398 'updates' => [],
1399 'timestamp' => current_time('mysql', true),
1400 'has_changes' => false,
1401 'error' => __('User not authenticated', 'fluent-community')
1402 ];
1403 }
1404
1405 // Parse since timestamp
1406 try {
1407 $sinceDate = $since ? new \DateTime($since) : null;
1408 } catch (\Exception $e) {
1409 return [
1410 'updates' => [],
1411 'timestamp' => current_time('mysql', true),
1412 'has_changes' => false,
1413 'error' => __('Invalid timestamp format', 'fluent-community')
1414 ];
1415 }
1416
1417 // Build query based on context
1418 $query = Feed::query();
1419
1420 if (strpos($context, 'space-') === 0) {
1421 $spaceSlug = str_replace('space-', '', $context);
1422 $space = Space::where('slug', $spaceSlug)->first();
1423 if ($space) {
1424 $query->where('space_id', $space->id);
1425 }
1426 } elseif (strpos($context, 'user-') === 0) {
1427 $targetUserId = str_replace('user-', '', $context);
1428 $query->where('user_id', $targetUserId);
1429 }
1430
1431 // Apply access control
1432 $query->byUserAccess($userId);
1433
1434 $updates = [];
1435
1436 // Get updated feeds (updated_at changed)
1437 if ($sinceDate) {
1438 $updatedFeeds = (clone $query)
1439 ->where('updated_at', '>', $sinceDate->format('Y-m-d H:i:s'))
1440 ->where('status', 'published')
1441 ->select(['id', 'updated_at', 'created_at'])
1442 ->orderBy('updated_at', 'desc')
1443 ->limit(100)
1444 ->get();
1445
1446 foreach ($updatedFeeds as $feed) {
1447 $isNew = $feed->created_at >= $sinceDate->format('Y-m-d H:i:s');
1448
1449 $updates[] = [
1450 'id' => $feed->id,
1451 'updated_at' => gmdate('c', strtotime($feed->updated_at)),
1452 'action' => $isNew ? 'created' : 'updated'
1453 ];
1454 }
1455
1456 // Check for deleted feeds (status changed to deleted)
1457 $deletedFeeds = (clone $query)
1458 ->where('updated_at', '>', $sinceDate->format('Y-m-d H:i:s'))
1459 ->whereIn('status', ['deleted', 'draft'])
1460 ->select(['id', 'updated_at'])
1461 ->limit(50)
1462 ->get();
1463
1464 foreach ($deletedFeeds as $feed) {
1465 $updates[] = [
1466 'id' => $feed->id,
1467 'updated_at' => gmdate('c', strtotime($feed->updated_at)),
1468 'action' => 'deleted'
1469 ];
1470 }
1471 }
1472
1473 return [
1474 'updates' => $updates,
1475 'timestamp' => current_time('mysql', true),
1476 'has_changes' => !empty($updates)
1477 ];
1478 }
1479
1480 public function getOembed(Request $request)
1481 {
1482 $currentUser = $this->getUser(true);
1483
1484 do_action('fluent_community/check_rate_limit/oembed', $currentUser);
1485
1486 $url = $request->getSafe('url', 'sanitize_url');
1487
1488 $metaData = RemoteUrlParser::parse($url);
1489
1490 if ($metaData && !is_wp_error($metaData)) {
1491 $data = [
1492 'oembed' => $metaData
1493 ];
1494 return apply_filters('fluent_community/feed_oembed_api_response', $data, $request->all());
1495 }
1496
1497 return $this->sendError([
1498 'message' => __('No oembed data found', 'fluent-community'),
1499 'url' => $url
1500 ]);
1501 }
1502
1503 public function markdownToHtml(Request $request)
1504 {
1505 $message = CustomSanitizer::unslashMarkdown($request->get('text', ''));
1506
1507 $html = wp_kses_post(FeedsHelper::mdToHtml($message));
1508
1509 $data = [
1510 'html' => $html
1511 ];
1512
1513 $data['message_rendered'] = $html;
1514
1515 if (in_array('meta', $request->get('with', [])) && $request->get('feed')) {
1516 [$data,] = FeedsHelper::processFeedMetaData($data, $request->get('feed'));
1517 }
1518
1519 return $data;
1520 }
1521 }
1522