PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.9.1
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.9.1
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 2.9.1, at app/Http/Controllers/FeedsController.php

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