PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.10.01
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.10.01
2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 1.1.0 All 77 releases
← All changes | app/Http/Controllers/FeedsController.php +1009 -687 1.0.902.10.01 View file →
@@ -3,8 +3,9 @@
3 3 namespace FluentCommunity\App\Http\Controllers;
4 4
5 5 use FluentCommunity\App\Functions\Utility;
6 6 use FluentCommunity\App\Models\Media;
7 +use FluentCommunity\App\Models\Notification;
7 8 use FluentCommunity\App\Models\NotificationSubscriber;
8 9 use FluentCommunity\App\Models\Space;
9 10 use FluentCommunity\App\Models\User;
10 11 use FluentCommunity\App\Services\CustomSanitizer;
@@ -10,81 +11,86 @@
10 11 use FluentCommunity\App\Services\CustomSanitizer;
11 12 use FluentCommunity\App\Services\FeedsHelper;
12 13 use FluentCommunity\App\Services\Helper;
13 14 use FluentCommunity\App\Services\Libs\FileSystem;
14 -use FluentCommunity\App\Services\ProfileHelper;
15 +use FluentCommunity\App\Services\UploadHelper;
15 16 use FluentCommunity\App\Services\RemoteUrlParser;
16 17 use FluentCommunity\Framework\Http\Request\Request;
17 -use FluentCommunity\App\Models\Comment;
18 18 use FluentCommunity\App\Models\Feed;
19 -use FluentCommunity\App\Models\Reaction;
20 19 use FluentCommunity\App\Models\BaseSpace;
20 +use FluentCommunity\App\Models\XProfile;
21 21 use FluentCommunity\Framework\Support\Arr;
22 +use FluentCommunity\Modules\PushNotification\PushNotificationModule;
22 23
23 24 class FeedsController extends Controller
24 25 {
25 26 public function get(Request $request)
26 27 {
28 + $start = microtime(true);
29 + $space = null;
27 30 $bySpace = $request->get('space');
28 31 $userId = $request->getSafe('user_id', 'intval', '');
29 32 $selectedTopic = $request->getSafe('topic_slug', 'sanitize_text_field', '');
30 -
31 - $search = $request->get('search');
32 -
33 + $search = $request->getSafe('search', 'sanitize_text_field', '');
33 34 if ($bySpace) {
34 35 // just for validation
35 36 $space = BaseSpace::where('slug', $bySpace)->first();
36 37 if (!$space) {
37 - return $this->sendError('Invalid space slug');
38 + return $this->sendError(['message' => __('Invalid space slug', 'fluent-community')]);
38 39 }
39 40 }
40 41
41 - $feedsQuery = Feed::where('status', 'published')
42 - ->select(Feed::$publicColumns)
43 - ->with([
44 - 'xprofile' => function ($q) {
45 - $q->select(ProfileHelper::getXProfilePublicFields());
46 - },
47 - 'comments.xprofile' => function ($q) {
48 - $q->select(ProfileHelper::getXProfilePublicFields());
49 - },
50 - 'space',
51 - 'reactions' => function ($q) {
52 - $q->with([
53 - 'xprofile' => function ($query) {
54 - $query->select(['user_id', 'avatar']);
55 - }
56 - ])
57 - ->where('type', 'like')
58 - ->limit(3);
59 - }
60 - ]
61 - )
62 - ->searchBy($search)
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']))
63 67 ->byTopicSlug($selectedTopic)
64 - ->customOrderBy($request->get('type', ''));
68 + ->customOrderBy($request->getSafe('order_by_type'));
65 69
70 + if ($applyStatusFilter) {
71 + $feedsQuery->byStatus($statusFilter);
72 + } else {
73 + $feedsQuery->byContentModerationAccessStatus($currentUserModel, $space);
74 + }
75 +
66 76 $stickyFeed = null;
67 77
68 78 $disableSticky = $request->get('disable_sticky', '') == 'yes' || !!$search || !!$selectedTopic;
69 79
80 + if ($bySpace) {
81 + $feedsQuery = $feedsQuery->filterBySpaceSlug($bySpace);
82 + $queryArgs['space_slug'] = $bySpace;
83 + }
84 +
70 85 if ($bySpace && !$disableSticky) {
71 - $feedsQuery = $feedsQuery->filterBySpaceSlug($bySpace)
72 - ->where('is_sticky', 0);
73 -
74 - if ($request->page == 1) {
86 + $feedsQuery = $feedsQuery->where('is_sticky', 0);
87 + if ($queryArgs['page'] === 1) {
75 88 $stickyFeed = Feed::where('space_id', $space->id)
76 89 ->where('is_sticky', 1)
77 - ->with([
78 - 'xprofile' => function ($q) {
79 - $q->select(ProfileHelper::getXProfilePublicFields());
80 - },
81 - 'comments.xprofile' => function ($q) {
82 - $q->select(ProfileHelper::getXProfilePublicFields());
83 - },
84 - 'space'
85 - ]
86 - )
90 + ->byUserAccess($currentUserId)
91 + ->byContentModerationAccessStatus($currentUserModel, $space)
92 + ->with(Feed::withPublicRelations($this->getUser(), $space))
87 93 ->first();
88 94 }
89 95 }
90 96
@@ -89,132 +95,175 @@
89 95 }
90 96
91 97 if ($userId) {
92 98 $feedsQuery = $feedsQuery->where('user_id', $userId);
93 - if ($userId != get_current_user_id()) {
94 - $feedsQuery = $feedsQuery->byUserAccess(get_current_user_id());
99 +
100 + if (!Helper::isModerator()) {
101 + $feedsQuery = $feedsQuery->whereHas('xprofile', function ($q) {
102 + $q->where('status', 'active');
103 + });
95 104 }
105 +
106 + if ($userId != $currentUserId) {
107 + $feedsQuery = $feedsQuery->byUserAccess($currentUserId);
108 + }
109 +
110 + $queryArgs['user_id'] = $userId;
96 111 } else {
97 - $feedsQuery->byUserAccess(get_current_user_id());
112 + $feedsQuery->byUserAccess($currentUserId)->whereHas('xprofile', function ($q) {
113 + $q->where('status', 'active');
114 + });
98 115 }
99 116
100 - do_action_ref_array('fluent_community/feeds_query', [&$feedsQuery, $request->all()]);
117 + $queryArgs = array_filter($queryArgs);
118 + $queryArgs['is_main_query'] = empty($queryArgs['space_slug']) && empty($queryArgs['user_id']) && empty($queryArgs['search']);
101 119
102 - $feeds = $feedsQuery->paginate();
120 + do_action_ref_array('fluent_community/feeds_query', [&$feedsQuery, $request->all(), $queryArgs]);
103 121
122 + $feedsQuery->limit($queryArgs['per_page'])->offset(($queryArgs['page'] - 1) * $queryArgs['per_page']);
123 + $feeds = $feedsQuery->get();
124 +
104 125 // add $stickyFeed to the first page
105 126 if ($stickyFeed) {
106 - $stickyFeed = $this->transformFeed($stickyFeed);
127 + $stickyFeed = FeedsHelper::transformFeed($stickyFeed);
107 128 }
108 129
109 - $feeds->getCollection()->each(function ($feed) {
110 - $this->transformFeed($feed);
111 - });
130 + $feeds = FeedsHelper::transformFeedsCollection($feeds);
112 131
132 + $currentCount = $feeds->count();
133 + $to = ($queryArgs['page'] - 1) * $queryArgs['per_page'] + $currentCount;
134 +
135 + $hasMore = $currentCount == $queryArgs['per_page'];
136 +
113 137 $data = [
114 - 'feeds' => $feeds,
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 + ],
115 147 'sticky' => $stickyFeed
116 148 ];
117 149
118 - if ($request->get('page') == 1) {
119 - $lastItem = FeedsHelper::getLastFeedId();
120 - if ($lastItem) {
121 - $data['last_id'] = $lastItem;
122 - }
150 + $isMainFeed = $queryArgs['page'] === 1 && !$search && !$userId;
151 + if ($isMainFeed && $currentUserId) {
152 + $data['last_fetched_timestamp'] = current_time('timestamp');
123 153 }
124 154
155 + $data['execution_time'] = microtime(true) - $start;
156 +
157 + $data = apply_filters('fluent_community/feeds_api_response', $data, $request->all());
158 +
125 159 return $data;
126 160 }
127 161
128 162 public function getFeedBySlug(Request $request, $feed_slug)
129 163 {
164 + $start = microtime(true);
130 165 if ($request->get('context') == 'edit') {
131 - $feed = Feed::where('slug', $feed_slug)->with(['space'])->first();
166 + $feed = Feed::where('slug', $feed_slug)->first();
132 167
133 168 if (!$feed || !$feed->hasEditAccess(get_current_user_id())) {
134 169 return $this->sendError([
135 - 'message' => 'You do not have permission to edit this feed'
170 + 'message' => __('You do not have permission to edit this feed', 'fluent-community')
136 171 ]);
137 172 }
138 173
139 - return [
140 - 'feed' => $feed
174 + $data = [
175 + 'feed' => FeedsHelper::transformForEdit($feed)
141 176 ];
177 +
178 + return apply_filters('fluent_community/feed_api_response', $data, $request->all());
142 179 }
143 180
144 181 $feed = Feed::where('slug', $feed_slug)
145 182 ->select(Feed::$publicColumns)
146 - ->with([
147 - 'xprofile' => function ($q) {
148 - $q->select(ProfileHelper::getXProfilePublicFields());
149 - },
150 - 'space',
151 - 'comments.xprofile' => function ($q) {
152 - $q->select(ProfileHelper::getXProfilePublicFields());
153 - },
154 - 'reactions' => function ($q) {
155 - $q->with([
156 - 'xprofile' => function ($query) {
157 - $query->select(['user_id', 'avatar']);
158 - }
159 - ])
160 - ->where('type', 'like')
161 - ->limit(3);
162 - }
163 - ])
183 + ->with(Feed::withPublicRelations($this->getUser()))
184 + ->whereHas('xprofile', function ($q) {
185 + $q->where('status', 'active');
186 + })
164 187 ->byUserAccess($this->getUserId())
165 188 ->first();
166 189
167 190 if (!$feed) {
168 191 return $this->sendError([
169 - 'message' => __('The feed could not be found', 'fluent-commuity')
192 + 'message' => __('The feed could not be found', 'fluent-community')
170 193 ], 404);
171 194 }
172 195
173 - $this->transformFeed($feed);
196 + $viewableByLinkStatuses = FeedsHelper::getViewableByLinkStatuses();
174 197
175 - return [
176 - 'feed' => $feed
177 - ];
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 +
178 211 }
179 212
213 + public function getFeedById(Request $request, $feedId)
214 + {
215 + $feed = Feed::findOrFail($feedId);
216 + return $this->getFeedBySlug($request, $feed->slug);
217 + }
218 +
180 219 public function getBookmarks(Request $request)
181 220 {
182 - $userId = get_current_user_id();
221 + $userId = $this->getUserId();
183 222
184 223 $feedsQuery = Feed::where('status', 'published')
185 224 ->select(Feed::$publicColumns)
186 - ->with([
187 - 'xprofile' => function ($q) {
188 - $q->select(ProfileHelper::getXProfilePublicFields());
189 - },
190 - 'comments.xprofile' => function ($q) {
191 - $q->select(ProfileHelper::getXProfilePublicFields());
192 - },
193 - 'space'
194 - ]
195 - )
225 + ->with(Feed::withPublicRelations($this->getUser()))
196 226 ->byBookMarked($userId)
197 227 ->byUserAccess($userId)
198 - ->searchBy($request->get('search'));
228 + ->byTopicSlug($request->getSafe('topic_slug'))
229 + ->customOrderBy($request->getSafe('order_by_type'))
230 + ->searchBy($request->getSafe('search'));
199 231
200 -
201 232 if ($type = $request->get('type')) {
202 233 $feedsQuery = $feedsQuery->where('type', $type);
203 234 }
204 235
236 + $queryArgs = [
237 + 'per_page' => (int)$request->get('per_page', 10),
238 + 'page' => (int)$request->get('page', 1)
239 + ];
240 +
205 241 $feeds = $feedsQuery->orderBy('id', 'DESC')
206 - ->paginate();
242 + ->limit($queryArgs['per_page'])
243 + ->offset(($queryArgs['page'] - 1) * $queryArgs['per_page'])
244 + ->get();
207 245
208 - $feeds->getCollection()->each(function ($feed) {
209 - $this->transformFeed($feed);
210 - });
246 + $currentCount = $feeds->count();
247 + $to = ($queryArgs['page'] - 1) * $queryArgs['per_page'] + $currentCount;
211 248
249 + $hasMore = $currentCount == $queryArgs['per_page'];
250 +
251 + $feeds = FeedsHelper::transformFeedsCollection($feeds);
252 +
212 253 $data = [
213 - 'feeds' => $feeds
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 + ]
214 263 ];
215 264
216 - if ($request->get('page') == 1) {
265 + if ($queryArgs['page'] === 1) {
217 266 $lastItem = FeedsHelper::getLastFeedId();
218 267 if ($lastItem) {
219 268 $data['last_id'] = $lastItem;
220 269 }
@@ -219,14 +268,13 @@
219 268 $data['last_id'] = $lastItem;
220 269 }
221 270 }
222 271
223 - return $data;
272 + return apply_filters('fluent_community/bookmarks_api_response', $data, $request->all());
224 273 }
225 274
226 275 public function store(Request $request)
227 276 {
228 - $userId = get_current_user_id();
229 277 $user = $this->getUser(true);
230 278
231 279 do_action('fluent_community/check_rate_limit/create_post', $user);
232 280
@@ -232,84 +280,190 @@
232 280
233 281 $requestData = $request->all();
234 282
235 283 $data = $this->sanitizeAndValidateData($requestData);
284 + $data['user_id'] = $user->ID;
285 + $data['status'] = 'published';
236 286
237 - if ($isDulicate = $this->checkForDuplicatePost($userId, $data['message'])) {
238 - return $isDulicate;
239 - }
287 + $data['status'] = apply_filters('fluent_community/feed/save_status', $data['status'], $requestData, null);
240 288
241 289 $feed = new Feed();
242 - $feed->user_id = $userId;
290 + $feed->user_id = $user->ID;
291 + $space = null;
243 292
244 293 if ($spaceSlug = $request->get('space')) {
245 294 $data['space_id'] = $this->validateAndSetSpace($spaceSlug, $user);
246 - } else {
247 - // Check if the user has global post permission
248 - if (!Helper::hasGlobalPost()) {
249 - return $this->sendError([
250 - 'message' => __('Please select a valid space to post in', 'fluent-community')
251 - ]);
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 + }
252 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 + ]);
253 329 }
254 330
255 - $message = $data['message'];
331 + $spaceId = Arr::get($data, 'space_id');
332 + $message = Arr::get($data, 'message');
256 333
257 - $mentions = FeedsHelper::getMentions($data['message'], Arr::get($data, 'space_id'));
334 + $duplicateCheckMessage = $message;
258 335
336 + $mentions = FeedsHelper::getMentions($data['message'], Arr::get($data, 'space_id'), true);
259 337 if ($mentions) {
260 338 $data['message'] = $message;
261 339 $message = $mentions['text'];
262 340 }
263 341
342 + [$message, $inlineMedias] = FeedsHelper::replaceImageUrlsWithRealMediaArchive($message);
343 +
264 344 // replace new line with br
265 345 $data['message_rendered'] = wp_kses_post(FeedsHelper::mdToHtml($message));
266 346
267 - $mediaItems = null;
347 + $requestData['is_admin'] = $user->hasPermissionOrInCurrentSpace('community_moderator', $space);
268 348
269 - if (!empty($data['survey'])) {
270 - $this->handleSurveyConfig($data);
271 - } elseif (Arr::get($requestData, 'meta.media_preview.provider') == 'giphy') {
272 - $this->setGiphyMediaPreview($data, $requestData);
273 - } else {
274 - $mediaItems = $this->processNewMedia($requestData, $data);
349 + [$data, $mediaItems] = FeedsHelper::processFeedMetaData($data, $requestData);
350 +
351 + if ($inlineMedias) {
352 + $mediaItems = array_merge($mediaItems, $inlineMedias);
275 353 }
276 354
277 - $data = apply_filters('fluent_community/feed_data/new', $data);
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 + }
278 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 +
279 380 $feed->fill($data);
280 381
281 - $feed->save();
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();
282 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 +
283 410 if ($mediaItems) {
284 411 $this->saveMediaItems($feed, $mediaItems);
285 412 }
286 413
287 - $this->handleMentions($feed, $mentions ?? []);
288 - $this->syncHashTags($feed, $data['message']);
289 -
290 414 $feed->load(['xprofile', 'comments.xprofile']);
291 -
292 415 if ($feed->space_id) {
293 416 $feed->load(['space']);
294 - $topicIds = $request->get('topic_ids', []);
295 - // take only first 5 topics
417 + $topicIds = (array)$request->get('topic_ids', []);
418 + // take only max topics per post
296 419 if ($topicIds) {
297 - $topicIds = array_slice($topicIds, 0, apply_filters('fluent_community/max_topic_per_post', 5));
420 + $topicsConfig = Helper::getTopicsConfig();
421 + $topicIds = array_slice($topicIds, 0, $topicsConfig['max_topics_per_post']);
298 422 $feed->attachTopics($topicIds, false);
423 + $feed->load(['terms']);
299 424 }
300 425 }
301 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 +
302 451 do_action('fluent_community/feed/created', $feed);
303 452
304 453 if ($feed->space_id) {
305 454 do_action('fluent_community/space_feed/created', $feed);
455 + } else {
456 + do_action('fluent_community/profile_feed/created', $feed);
306 457 }
307 458
308 - return [
309 - 'feed' => $feed,
310 - 'message' => __('Feed added', 'fluent-community')
311 - ];
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());
312 466 }
313 467
314 468 public function update(Request $request, $feedId)
315 469 {
@@ -314,95 +468,224 @@
314 468 public function update(Request $request, $feedId)
315 469 {
316 470 $requestData = $request->all();
317 471 $data = $this->sanitizeAndValidateData($requestData);
472 + $user = $this->getUser(true);
473 + $existingFeed = Feed::findOrFail($feedId);
474 + /** @var Feed $existingFeed */
318 475
319 - $userId = get_current_user_id();
320 - $user = User::findOrFail($userId);
476 + $editableStatuses = ['published', 'unlisted', 'scheduled', 'pending'];
321 477
322 - $feed = Feed::find($feedId);
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 + }
323 483
324 - if (!$feed) {
325 - return $this->sendError(['message' => __('Feed not found', 'fluent-community')]);
326 - }
484 + $user->canEditFeed($existingFeed, true);
327 485
328 - $user->canEditFeed($feed, false);
486 + // Must resolve before processFeedMetaData() reads it.
487 + $isModerator = $user->hasPermissionOrInCurrentSpace('community_moderator', $existingFeed->space);
488 + $requestData['is_admin'] = $isModerator;
329 489
330 - if (!$feed->hasEditAccess($userId)) {
331 - return $this->send('You do not have permission to edit this feed', 403);
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 + ]);
332 497 }
333 498
334 - if ($spaceSlug = $request->get('space')) {
335 - $data['space_id'] = $this->validateAndSetSpace($spaceSlug, $user);
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 + }
336 504 }
337 505
338 506 $message = $data['message'];
339 -
340 507 $mentions = FeedsHelper::getMentions($data['message'], Arr::get($data, 'space_id'));
341 -
342 508 if ($mentions) {
343 509 $data['message'] = $message;
344 510 $message = $mentions['text'];
345 511 }
346 512
513 + [$message, $inlineMedias] = FeedsHelper::replaceImageUrlsWithRealMediaArchive($message, $existingFeed);
514 +
347 515 // replace new line with br
348 516 $data['message_rendered'] = wp_kses_post(FeedsHelper::mdToHtml($message));
349 - $mediaItems = null;
350 517
351 - if (!empty($data['survey'])) {
352 - $this->handleSurveyConfig($data);
353 - } elseif (Arr::get($requestData, 'meta.media_preview.provider') == 'giphy') {
354 - $this->setGiphyMediaPreview($data, $requestData);
355 - } else {
356 - $mediaItems = $this->processExistingMedia($feed, $requestData, $data);
518 + [$data, $mediaItems] = FeedsHelper::processFeedMetaData($data, $requestData, $existingFeed);
519 +
520 + if($inlineMedias) {
521 + $mediaItems = array_merge($mediaItems, $inlineMedias);
357 522 }
358 523
359 - if ($message != $feed->message) {
360 - $meta = $feed->meta;
361 - $meta['last_edited'] = [
362 - 'user_id' => $userId,
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,
363 568 'time' => current_time('mysql')
364 569 ];
570 + }
365 571
366 - $editHistory = $feed->getCustomMeta('_edit_history', []);
572 + $movingToProfile = false;
367 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', []);
368 617 if (!$editHistory) {
369 618 $editHistory = [];
370 619 }
371 620
372 621 $editHistory[] = array_filter([
373 - 'user_id' => $userId,
622 + 'user_id' => $user->ID,
374 623 'time' => current_time('mysql'),
375 - 'prev_message' => $feed->message,
376 - 'prev_title' => $feed->title
624 + 'prev_message' => $existingFeed->message,
625 + 'prev_title' => $existingFeed->title
377 626 ]);
378 627
379 628 // get last 5 edit history
380 629 $editHistory = array_slice($editHistory, -5);
381 - $feed->updateCustomMeta('_edit_history', $editHistory);
382 - $data['meta'] = $meta;
630 + $existingFeed->updateCustomMeta('_edit_history', $editHistory);
383 631 }
384 632
385 - $data = apply_filters('fluent_community/feed_data/update', $data, $feed);
386 - $feed->fill($data);
387 - $feed->save();
633 + $mediaItemIds = [];
634 + foreach ($mediaItems as $mediaItem) {
635 + $mediaItemIds[] = $mediaItem->id;
636 + }
388 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 +
389 650 if ($mediaItems) {
390 - $this->saveMediaItems($feed, $mediaItems);
651 + $this->saveMediaItems($existingFeed, $mediaItems);
391 652 }
392 653
393 - $this->syncHashTags($feed, $data['message']);
654 + $existingFeed->load(['xprofile', 'comments.xprofile']);
394 655
395 - $feed->load(['xprofile', 'comments.xprofile']);
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 + }
396 674
397 - if ($feed->space_id) {
398 - $feed->load(['space']);
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 + }
399 680 }
400 681
401 - return [
402 - 'feed' => $feed,
403 - 'message' => __('Feed updated', 'fluent-community')
682 + $data = [
683 + 'feed' => FeedsHelper::transformFeed($existingFeed),
684 + 'message' => __('Your post has been updated', 'fluent-community')
404 685 ];
686 +
687 + return apply_filters('fluent_community/feed/update_feed_response', $data, $request->all());
405 688 }
406 689
407 690 public function patchFeed(Request $request, $feedId)
408 691 {
@@ -408,12 +691,13 @@
408 691 {
409 692 $feed = Feed::findOrFail($feedId);
410 693 $user = $this->getUser(true);
411 694
412 - $isMod = $user->isCommunityModerator();
413 695 $isAuthor = $feed->user_id == $user->ID;
696 + $isMod = $user->hasPermissionOrInCurrentSpace('community_moderator', $feed->space);
697 + $isAdmin = $user->hasPermissionOrInCurrentSpace('community_admin', $feed->space);
414 698
415 - if (!$isMod && !$isAuthor) {
699 + if (!$isMod && !$isAuthor && !$isAdmin) {
416 700 return $this->sendError([
417 701 'message' => __('You do not have permission to perform this action', 'fluent-community')
418 702 ]);
419 703 }
@@ -428,13 +712,26 @@
428 712 $data = Arr::only($allData, $validKeys);
429 713
430 714 $data = array_map('intval', $data);
431 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 +
432 726 if (isset($data['is_sticky'])) {
433 727 $data['is_sticky'] = $data['is_sticky'] ? 1 : 0;
434 728 if ($data['is_sticky'] && $feed->space_id) {
435 - // remove all the sticky posts from the space
436 - Feed::where('space_id', $feed->space_id)->update(['is_sticky' => 0]);
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]);
437 734 }
438 735 }
439 736
440 737 if (isset($data['comments_disabled'])) {
@@ -444,22 +741,54 @@
444 741 }
445 742
446 743 if ($data) {
447 744 $feed->fill($data);
448 - $feed->save();
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 + }
449 755 }
450 756
451 - return [
757 + return apply_filters('fluent_community/feed/patch_feed_response', [
452 758 'feed' => $feed,
453 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)
454 769 ];
770 +
771 + return apply_filters('fluent_community/welcome_banner_api_response', $data, $request->all());
455 772 }
456 773
457 774 public function getLinks(Request $request)
458 775 {
459 - return [
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 = [
460 787 'links' => Helper::getFeedLinks()
461 788 ];
789 +
790 + return apply_filters('fluent_community/feed_links_api_response', $data, $request->all());
462 791 }
463 792
464 793 public function updateLinks(Request $request)
465 794 {
@@ -471,245 +800,13 @@
471 800
472 801 Helper::updateFeedLinks($links);
473 802
474 803 return [
475 - 'message' => __('Links has been updated', 'fluent-community'),
804 + 'message' => __('Links have been updated.', 'fluent-community'),
476 805 'links' => $links
477 806 ];
478 807 }
479 808
480 - private function setGiphyMediaPreview(&$data, $requestData)
481 - {
482 - if (empty(Arr::get($requestData, 'meta.media_preview.image'))) {
483 - return;
484 - }
485 -
486 - $data['meta']['media_preview'] = array_filter([
487 - 'image' => sanitize_url($requestData['meta']['media_preview']['image']),
488 - 'type' => Arr::get($requestData, 'meta.media_preview.type', 'image'),
489 - 'provider' => Arr::get($requestData, 'meta.media_preview.provider', ''),
490 - 'height' => Arr::get($requestData, 'meta.media_preview.height', 0),
491 - 'width' => Arr::get($requestData, 'meta.media_preview.width', 0),
492 - ]);
493 - }
494 -
495 - private function handleSurveyConfig(&$data)
496 - {
497 - if (empty($data['meta'])) {
498 - $data['meta'] = [];
499 - }
500 -
501 - $data['meta']['survey_config'] = $data['survey'];
502 - $data['content_type'] = 'survey';
503 - }
504 -
505 - private function processNewMedia($requestData, &$data)
506 - {
507 - if ($mediaImages = Arr::get($requestData, 'media_images')) {
508 - $uploadedImages = Helper::getMediaByProvider($mediaImages);
509 - $uploadedMediaItems = Helper::getMediaItemsFromUrl($uploadedImages);
510 - $mediaPreviews = $this->generateMediaPreviews($uploadedMediaItems);
511 - $this->formatMediaMeta($mediaPreviews, $data, $mediaImages);
512 - return $uploadedMediaItems;
513 - }
514 -
515 - if ($media = Arr::get($requestData, 'media')) {
516 - $type = Arr::get($media, 'type', 'oembed');
517 - if ($type == 'oembed') {
518 - $url = Arr::get($media, 'url');
519 - $metaData = RemoteUrlParser::parse($url);
520 - if ($metaData && !is_wp_error($metaData) && (!empty($metaData['image']) || !empty($metaData['html']))) {
521 - $data['meta']['media_preview'] = $metaData;
522 - return [];
523 - }
524 - }
525 - }
526 -
527 - $urlMeta = $this->parseFirstUrl($data['message_rendered']);
528 -
529 - if ($urlMeta) {
530 - $data['meta'] = $urlMeta;
531 - return [];
532 - }
533 -
534 - // Let's give option to the user to check if there is any fallback
535 - do_action_ref_array('fluent_community/feed/meta_fallback', [&$data]);
536 -
537 - return [];
538 - }
539 -
540 - private function processExistingMedia($feed, $requestData, &$data)
541 - {
542 - $images = (array)Arr::get($requestData, 'media_images', []);
543 - $mediaImages = Helper::getMediaByProvider($images);
544 - $metaMediaMetaItems = Helper::getMediaByProvider((array)Arr::get($requestData, 'meta.media_items', []));
545 - $metaMediaPreview = array_filter((array)Arr::get($requestData, 'meta.media_preview', []));
546 - $requestMediaIds = array_column($metaMediaMetaItems, 'media_id');
547 -
548 - if (count($mediaImages) == 0 && count($metaMediaMetaItems) == 0) {
549 - if (count($metaMediaPreview) === 0) {
550 - do_action('fluent_community/feed/media_deleted', $feed->media);
551 - $data['meta']['media_preview'] = null;
552 - }
553 -
554 - $previewMeta = $this->parseFirstUrl($data['message_rendered']);
555 - if (count($metaMediaPreview) > 0) {
556 - $data['meta']['media_preview'] = $metaMediaPreview;
557 - } elseif (count($previewMeta) > 0) {
558 - $data['meta'] = $previewMeta;
559 - }
560 -
561 - return [];
562 - }
563 -
564 - if (count($mediaImages) == 1 && (count($metaMediaMetaItems) == 0 || count($metaMediaPreview) > 0)) {
565 - if (Arr::get($metaMediaPreview, 'is_uploaded')) {
566 - $mediaImages[] = $metaMediaPreview['image'] . '?media_key=' . $feed->media[0]->media_key;
567 - unset($data['meta']['media_preview']);
568 - } elseif (count($metaMediaPreview) > 0) {
569 - do_action('fluent_community/feed/media_deleted', $feed->media);
570 - }
571 -
572 - $mediaItems = Helper::getMediaItemsFromUrl($mediaImages);
573 - $mediaPreviews = $this->generateMediaPreviews($mediaItems);
574 -
575 - $this->formatMediaMeta($mediaPreviews, $data, $images);
576 -
577 - return $mediaItems;
578 - }
579 -
580 - $message = $data['message'];
581 -
582 - if ($mentions = FeedsHelper::getMentions($message, Arr::get($data, 'space_id'))) {
583 - $message = $mentions['text'];
584 - }
585 -
586 - $data['message_rendered'] = FeedsHelper::mdToHtml($message);
587 -
588 - if (count($mediaImages) > 1) {
589 - $mediaItems = $this->processNewMedia($requestData, $data);
590 - }
591 -
592 - $deletedMediaItems = $feed->media()->whereNotIn('id', $requestMediaIds)->get();
593 - do_action('fluent_community/feed/media_deleted', $deletedMediaItems);
594 -
595 -
596 - if (!isset($data['meta']['media_items'])) {
597 - $data['meta']['media_items'] = [];
598 - }
599 -
600 - if (!isset($data['meta']['media_preview'])) {
601 - $data['meta']['media_preview'] = null;
602 - }
603 -
604 - if ($metaMediaMetaItems) {
605 - $filteredData = array_filter($metaMediaMetaItems, function ($item) use ($requestMediaIds) {
606 - return in_array($item['media_id'], $requestMediaIds);
607 - });
608 -
609 - if (count($mediaImages) == 1 && count($filteredData) > 0) {
610 - $mediaItems = Helper::getMediaItemsFromUrl($mediaImages);
611 - $newMediaItems = $this->generateMediaPreviews($mediaItems);
612 - $filteredData = array_merge($filteredData, $newMediaItems);
613 - }
614 -
615 - $data['meta']['media_items'] = array_merge($filteredData, $data['meta']['media_items']);
616 - }
617 -
618 - if (isset($feed->meta['media_preview'])) {
619 - if (count($mediaImages) > 1) {
620 - $data['meta']['media_preview'] = null;
621 - }
622 - }
623 -
624 - return $mediaItems ?? [];
625 - }
626 -
627 - private function generateMediaPreviews($mediaItems)
628 - {
629 - $mediaPreviews = [];
630 - foreach ($mediaItems as $media) {
631 - if (!$media || !$media->is_active) {
632 - $this->sendError(['message' => 'Invalid media image. Please upload a new one.']);
633 - }
634 -
635 - $data = [
636 - 'media_id' => $media->id,
637 - 'url' => $media->public_url,
638 - 'type' => 'image',
639 - 'width' => Arr::get($media->settings, 'width'),
640 - 'height' => Arr::get($media->settings, 'height'),
641 - 'provider' => Arr::get($media->settings, 'provider', 'uploader')
642 - ];
643 -
644 - $mediaPreviews[] = array_filter($data);
645 - }
646 -
647 - return $mediaPreviews;
648 - }
649 -
650 - private function formatMediaMeta($mediaPreviews, &$data, $mediaImages)
651 - {
652 - $giphyImages = Helper::getMediaByProvider($mediaImages, 'giphy');
653 - $metaMediaItems = Helper::getMediaByProvider($this->request->get('meta.media_items', []), 'giphy');
654 -
655 - if (count($mediaPreviews) === 1 && empty($giphyImages) && empty($metaMediaItems)) {
656 - $mediaPreview = array_filter([
657 - 'is_uploaded' => true,
658 - 'image' => $mediaPreviews[0]['url'],
659 - 'type' => 'meta_data',
660 - 'width' => Arr::get($mediaPreviews[0], 'width'),
661 - 'height' => Arr::get($mediaPreviews[0], 'height')
662 - ]);
663 -
664 - $data['meta']['media_preview'] = $mediaPreview;
665 - } elseif ($mediaPreviews) {
666 - $data['meta']['media_items'] = $mediaPreviews;
667 - }
668 - }
669 -
670 - private function processGiphyImages($requestData, &$data)
671 - {
672 - if (!isset($data['meta']['media_items'])) {
673 - $data['meta']['media_items'] = null;
674 - }
675 -
676 - if ($metaMediaItems = Arr::get($requestData, 'meta.media_items', [])) {
677 - $giphyMediaItems = Helper::getMediaByProvider($metaMediaItems, 'giphy');
678 -
679 - if ($giphyMediaItems) {
680 - $data['meta']['media_items'] = array_merge($giphyMediaItems, (array)$data['meta']['media_items']);
681 - }
682 - }
683 -
684 - if ($giphyImages = Helper::getMediaByProvider(Arr::get($requestData, 'media_images', []), 'giphy')) {
685 -
686 - foreach ($giphyImages as $giphy) {
687 - $data['meta']['media_items'][] = [
688 - 'url' => $giphy['url'],
689 - 'type' => 'image',
690 - 'provider' => 'giphy'
691 - ];
692 - }
693 - }
694 - }
695 -
696 - private function parseFirstUrl($messageRendered)
697 - {
698 - $firstUrl = FeedsHelper::findFirstUrl($messageRendered);
699 -
700 - if ($firstUrl) {
701 - $metaData = RemoteUrlParser::parse($firstUrl);
702 - if ($metaData && !is_wp_error($metaData) && (!empty($metaData['image']) || !empty($metaData['html']))) {
703 - return [
704 - 'media_preview' => $metaData
705 - ];
706 - }
707 - }
708 -
709 - return [];
710 - }
711 -
712 809 private function saveMediaItems($feed, $mediaItems)
713 810 {
714 811 foreach ($mediaItems as $media) {
715 812 $media->feed_id = $feed->id;
@@ -718,98 +815,39 @@
718 815 $media->save();
719 816 }
720 817 }
721 818
722 - private function handleMentions($feed, $mentions)
819 + private function sanitizeAndValidateData($data)
723 820 {
724 - if ($mentions) {
725 - do_action('fluent_community/feed_mentioned', $feed, $mentions['users']);
726 - }
727 - }
821 + $data['type'] = 'text';
728 822
729 - private function syncHashTags($feed, $message)
730 - {
731 - if ($feed->id) {
732 - do_action('fluent_community/feed/hashtags_deleted', $feed->terms);
733 - }
823 + $this->validate($data, [
824 + 'message' => 'required'
825 + ], [
826 + 'message.required' => __('Message is required', 'fluent-community'),
827 + ]);
734 828
735 - $hashTags = FeedsHelper::extractHashTags($message);
736 - if ($hashTags) {
737 - $feed->terms()->sync($hashTags);
738 - }
829 + return FeedsHelper::sanitizeAndValidateData($data);
739 830 }
740 831
741 - private function sanitizeAndValidateData($data)
832 + private function checkForDuplicatePost($userId, $message, $spaceId = null)
742 833 {
743 - $message = CustomSanitizer::unslashMarkdown(trim(Arr::get($data, 'message')));
744 - $type = sanitize_text_field(Arr::get($data, 'type', 'text'));
745 -
746 - $processedData = [
747 - 'message' => $message,
748 - 'type' => $type
749 - ];
750 -
751 - $survey = Arr::get($data, 'survey', []);
752 -
753 - if ($survey) {
754 - $options = Arr::get($survey, 'options', []);
755 - $formattedOptions = [];
756 - foreach ($options as $index => $option) {
757 - if (empty($option['label'])) {
758 - continue;
759 - }
760 -
761 - $formattedOptions[] = [
762 - 'label' => sanitize_text_field($option['label']),
763 - 'slug' => 'opt_' . ($index + 1)
764 - ];
765 - }
766 -
767 - if ($formattedOptions) {
768 - $processedData['survey'] = [
769 - 'type' => Arr::get($survey, 'type') == 'single_choice' ? 'single_choice' : 'multi_choice',
770 - 'options' => $formattedOptions
771 - ];
772 - }
834 + if (apply_filters('fluent_community/disable_duplicate_post_check', false, $userId, $spaceId)) {
835 + return false;
773 836 }
774 837
775 - $this->validate($processedData, [
776 - 'message' => 'required|min:10',
777 - 'type' => 'required'
778 - ]);
779 -
780 - $maxlen = apply_filters('fluent_community/max_post_length', 15000);
781 - if (\strlen($message) > $maxlen) {
782 - throw new \Exception(esc_html__('Post message is too long', 'fluent-community'));
783 - }
784 -
785 - $titlePref = Utility::postTitlePref();
786 -
787 - if ($titlePref) {
788 - $processedData['title'] = sanitize_text_field(Arr::get($data, 'title'));
789 - if ($titlePref == 'required' && empty($processedData['title'])) {
790 - throw new \Exception(esc_html__('Title is required. Please provide a title', 'fluent-community'));
791 - }
792 - // trim the title if it's too long to 150 char
793 - if (\strlen($processedData['title']) > 192) {
794 - $processedData['title'] = substr($processedData['title'], 0, 192);
795 - }
796 - }
797 -
798 - return $processedData;
799 - }
800 -
801 - private function checkForDuplicatePost($userId, $message)
802 - {
803 838 $message = trim($message);
804 839
805 840 $exist = Feed::where('user_id', $userId)
806 841 ->where('message', $message)
807 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 + })
808 846 ->first();
809 847
810 848 if ($exist) {
811 - return $this->sendError(['message' => 'No duplicate post please!']);
849 + return $this->sendError(['message' => __('No duplicate post please!', 'fluent-community')]);
812 850 }
813 851
814 852 return false;
815 853 }
@@ -817,9 +855,9 @@
817 855 private function validateAndSetSpace($spaceSlug, $user)
818 856 {
819 857 if ($spaceSlug == '__self__post__') {
820 858 if (!Helper::hasGlobalPost()) {
821 - throw new \Exception(__('Please select a valid space to post in', 'fluent-community'));
859 + throw new \Exception(esc_html__('Please select a valid space to post in', 'fluent-community'));
822 860 }
823 861
824 862 return null;
825 863 }
@@ -826,9 +864,9 @@
826 864
827 865 $space = Space::where('slug', $spaceSlug)->first();
828 866
829 867 if (!$space) {
830 - throw new \Exception(__('Please select a valid space to post in', 'fluent-community'));
868 + throw new \Exception(esc_html__('Please select a valid space to post in', 'fluent-community'));
831 869 }
832 870
833 871 $user->verifySpacePermission('can_create_post', $space);
834 872
@@ -846,9 +884,9 @@
846 884
847 885 do_action('fluent_community/feed/deleted', $feed_id);
848 886
849 887 return [
850 - 'message' => 'Feed has been deleted successfully'
888 + 'message' => __('Feed has been deleted successfully', 'fluent-community')
851 889 ];
852 890 }
853 891
854 892 public function deleteMediaPreview(Request $request, $feed_id)
@@ -853,16 +891,14 @@
853 891
854 892 public function deleteMediaPreview(Request $request, $feed_id)
855 893 {
856 894 $feed = Feed::findOrFail($feed_id);
857 -
858 895 $user = User::find(get_current_user_id());
859 896 $user->canDeleteFeed($feed, true);
860 897
861 - do_action('fluent_community/feed/media_deleted', $feed->media);
898 + //do_action('fluent_community/feed/media_deleted', $feed->media);
862 899
863 900 $meta = $feed->meta;
864 -
865 901 $meta['media_preview'] = null;
866 902
867 903 $feed->meta = $meta;
868 904 $feed->save();
@@ -867,176 +903,134 @@
867 903 $feed->meta = $meta;
868 904 $feed->save();
869 905
870 906 return [
871 - 'message' => 'Media preview has been removed successfully'
907 + 'message' => __('Media preview image has been removed successfully.', 'fluent-community')
872 908 ];
873 909 }
874 910
875 - public function addComment(Request $request, $feed_id)
911 + public function handleMediaUpload(Request $request)
876 912 {
877 - $feed = Feed::findOrFail($feed_id);
878 - $text = trim($request->getSafe('comment', 'sanitize_textarea_field', ''));
879 -
880 - if (!$text) {
881 - return $this->sendError([
882 - 'message' => 'Please provide your reply text'
883 - ]);
913 + if ($error = Helper::checkUploadSizeError()) {
914 + return $this->sendError($error, 413);
884 915 }
885 916
886 - // check for duplicate
887 - $exist = Comment::where('user_id', get_current_user_id())
888 - ->where('message', $text)
889 - ->where('post_id', $feed->id)
890 - ->first();
917 + $user = $this->getUser(true);
891 918
892 - if ($exist) {
893 - return $this->sendError([
894 - 'message' => 'No duplicate comment please!'
895 - ]);
896 - }
919 + do_action('fluent_community/check_rate_limit/media_upload', $user);
897 920
898 - if ($feed->space_id) {
899 - $user = User::find(get_current_user_id());
900 - $user->verifySpacePermission('registered', $feed->space);
901 - }
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 + ]);
902 929
903 - $commentData = [
904 - 'post_id' => $feed->id,
905 - 'message' => $text,
906 - 'message_rendered' => wp_kses_post(FeedsHelper::mdToHtml($text)),
907 - 'type' => 'comment'
908 - ];
930 + $allowedTypes = implode(',', $allowedMimeTypesArray);
909 931
910 - if ($request->get('parent_id')) {
911 - $parentId = (int)$request->get('parent_id');
912 -
913 - // verify the parent id
914 - $parentComment = Comment::where('id', $parentId)
915 - ->where('post_id', $feed->id)
916 - ->first();
917 -
918 - if (!$parentComment || $parentComment->post_id != $feed->id) {
919 - return $this->sendError([
920 - 'message' => 'Invalid parent comment'
921 - ]);
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';
922 939 }
923 -
924 - $commentData['parent_id'] = $parentId;
940 + if ($ext && $ext !== 'webp' && !in_array($ext, $convertibleExtensions)) {
941 + $convertibleExtensions[] = $ext;
942 + }
925 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 + }
926 948
927 - $comment = Comment::create($commentData);
949 + $maxFileUnit = apply_filters('fluent_community/media_upload_max_file_unit', 'MB');
950 + $maxFileSize = apply_filters('fluent_community/media_upload_max_file_size', 100);
928 951
929 - $feed->comments_count = $feed->comments_count + 1;
930 - $feed->save();
952 + $allowedFileSize = $maxFileSize;
953 + if (strtoupper($maxFileUnit) == 'MB') {
954 + $allowedFileSize = $maxFileSize * 1024;
955 + } else if (strtoupper($maxFileUnit) == 'GB') {
956 + $allowedFileSize = $maxFileSize * 1024 * 1024;
957 + }
931 958
932 - $comment->load([
933 - 'xprofile' => function ($q) {
934 - $q->select(ProfileHelper::getXProfilePublicFields());
935 - }
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)
936 966 ]);
937 967
938 - return [
939 - 'comment' => $comment,
940 - 'message' => 'Comment has been added'
941 - ];
942 - }
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 + }
943 975
944 - public function addOrRemovePostReact(Request $request, $feed_id)
945 - {
976 + add_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']);
977 + $uploadedFiles = FileSystem::put($files);
978 + remove_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']);
946 979
947 - $feed = Feed::byUserAccess(get_current_user_id())->findOrFail($feed_id);
980 + $file = Arr::get($uploadedFiles, 0);
948 981
949 - $type = $request->get('react_type', 'like');
950 - $willRemove = $request->get('remove');
951 - $react = Reaction::where('user_id', get_current_user_id())
952 - ->where('object_id', $feed->id)
953 - ->where('type', $type)
954 - ->objectType('feed')
955 - ->first();
956 -
957 - if ($willRemove) {
958 - if ($react) {
959 - $react->delete();
960 - $feed->reactions_count = $feed->reactions_count - 1;
961 - $feed->save();
962 - }
963 -
964 - return [
965 - 'message' => 'Reaction has been removed',
966 - 'new_count' => $feed->reactions_count
967 - ];
982 + if (is_wp_error($file)) {
983 + return $this->sendError([
984 + 'message' => $file->get_error_message()
985 + ]);
968 986 }
969 987
970 - if ($react) {
971 - return [
972 - 'message' => 'You have already reacted to this post',
973 - 'new_count' => $feed->reactions_count
974 - ];
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 + ]);
975 993 }
976 994
977 - $react = Reaction::create([
978 - 'user_id' => get_current_user_id(),
979 - 'object_id' => $feed->id,
980 - 'type' => $type,
981 - 'object_type' => 'feed'
982 - ]);
995 + $upload_dir = wp_upload_dir();
983 996
984 - $feed->reactions_count = $feed->reactions_count + 1;
985 - $feed->save();
997 + $originalUrl = $file['url'];
998 + $orginalPath = $upload_dir['basedir'] . '/fluent-community/' . $file['file'];
999 + $originalFileType = $file['type'];
1000 + $originalFileName = $file['file'];
986 1001
987 - return [
988 - 'message' => 'Reaction has been added',
989 - 'new_count' => $feed->reactions_count
990 - ];
991 - }
1002 + $willWebPConvert = $request->get('disable_convert') != 'yes';
992 1003
993 - public function handleMediaUpload(Request $request)
994 - {
995 - $allowedTypes = implode(
996 - ',',
997 - apply_filters('fluent_community/support_attachment_types', [
998 - 'image/jpeg',
999 - 'image/pjpeg',
1000 - 'image/jpeg',
1001 - 'image/pjpeg',
1002 - 'image/png',
1003 - 'image/gif',
1004 - 'image/webp'
1005 - ])
1006 - );
1004 + $willWebPConvert = apply_filters('fluent_community/convert_image_to_webp', $willWebPConvert, $file);
1005 + $willResize = $request->get('resize');
1006 + $maxWidth = $request->get('max_width');
1007 1007
1008 - $files = $this->validate($this->request->files(), [
1009 - 'file' => 'mimetypes:' . $allowedTypes,
1010 - // 'source' => 'required|in:feed,avatar,comment,cover,space'
1011 - ], [
1012 - 'file.mimetypes' => __('The file must be a image type.', 'fluent-community')
1013 - ]);
1008 + $willResize = apply_filters('fluent_community/media_upload_resize', $willResize, $file);
1014 1009
1015 - $uploadedFiles = FileSystem::put($files);
1010 + if ($context = $request->get('context')) {
1011 + $maxWidth = apply_filters('fluent_community/media_upload_max_width_' . $context, $maxWidth, $file);
1012 + }
1016 1013
1017 - $file = $uploadedFiles[0];
1018 -
1019 - $willWebPConvert = apply_filters('fluent_community/convert_image_to_webp', true, $file);
1020 -
1021 - if ($request->get('resize') && $maxWidth = $request->get('max_width')) {
1014 + if ($willResize && $maxWidth) {
1022 1015 $upload_dir = wp_upload_dir();
1023 1016 $fileUrl = str_replace($upload_dir['baseurl'], $upload_dir['basedir'], $file['url']);
1017 +
1024 1018 $editor = wp_get_image_editor($fileUrl);
1019 +
1025 1020 if (!is_wp_error($editor) && $editor->get_size()['width'] > $maxWidth) {
1026 1021 // Current file extension
1027 1022 $ext = pathinfo($file['url'], PATHINFO_EXTENSION);
1028 - $imageExtensions = ['jpg', 'jpeg', 'png', 'gif'];
1023 + $willConvert = in_array($ext, $convertibleExtensions) && $willWebPConvert;
1029 1024
1030 - $willConvert = in_array($ext, $imageExtensions) && $willWebPConvert;
1031 -
1032 1025 if ($willConvert) {
1033 - $imageExtensions = array_map(function ($ext) {
1026 + $dottedExtensions = array_map(function ($ext) {
1034 1027 return '.' . $ext;
1035 - }, $imageExtensions);
1036 - $fileUrl = str_replace($imageExtensions, '.webp', $fileUrl);
1037 - $file['file'] = str_replace($imageExtensions, '.webp', $file['file']);
1038 - $file['url'] = str_replace($imageExtensions, '.webp', $file['url']);
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']);
1039 1033 $file['type'] = 'image/webp';
1040 1034 }
1041 1035
1042 1036 // resize the image
@@ -1042,16 +1036,24 @@
1042 1036 // resize the image
1043 1037 $editor->resize($maxWidth, null, false);
1044 1038 $editor->set_quality(90);
1045 1039 if ($willConvert) {
1046 - $editor->save($fileUrl, 'image/webp');
1047 - // remove original file now
1048 - wp_delete_file(str_replace('.webp', '.' . $ext, $fileUrl));
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 + }
1049 1045 $file['is_converted'] = true;
1050 1046 } else {
1051 - $editor->save($fileUrl);
1047 + $result = $editor->save($fileUrl);
1052 1048 }
1053 1049
1050 + if ($result['mime-type'] != 'image/webp') {
1051 + $file['file'] = $originalFileName;
1052 + $file['url'] = $originalUrl;
1053 + $file['type'] = $result['mime-type'];
1054 + }
1055 +
1054 1056 $file['meta'] = [
1055 1057 'width' => $editor->get_size()['width'],
1056 1058 'height' => $editor->get_size()['height']
1057 1059 ];
@@ -1065,20 +1067,25 @@
1065 1067 if ($willWebPConvert && empty($file['is_converted']) && !$request->get('skip_convert')) {
1066 1068 $path = $file['path'];
1067 1069 $extension = pathinfo($path, PATHINFO_EXTENSION);
1068 1070
1069 - $convertFromExtensions = ['png', 'jpg', 'jpeg', 'gif'];
1070 - if ($extension != 'webp' && in_array($extension, $convertFromExtensions)) {
1071 + if ($extension != 'webp' && in_array($extension, $convertibleExtensions)) {
1071 1072 // Let's convert to webp
1072 1073 $editor = wp_get_image_editor($file['path']);
1073 1074 if (!is_wp_error($editor)) {
1074 - $orginalPath = $file['path'];
1075 1075 $file['path'] = str_replace('.' . $extension, '.webp', $file['path']);
1076 1076 $file['url'] = str_replace('.' . $extension, '.webp', $file['url']);
1077 1077 $file['type'] = 'image/webp';
1078 - $editor->save($file['path'], 'image/webp');
1079 - wp_delete_file($orginalPath);
1078 + $result = $editor->save($file['path'], 'image/webp');
1080 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 +
1081 1088 $file['meta'] = [
1082 1089 'width' => $editor->get_size()['width'],
1083 1090 'height' => $editor->get_size()['height']
1084 1091 ];
@@ -1085,16 +1092,31 @@
1085 1092 }
1086 1093 }
1087 1094 }
1088 1095
1089 - $mediaData = apply_filters('fluent_community/media_upload_data', [
1096 + $mediaData = [
1090 1097 'media_type' => $file['type'],
1091 1098 'driver' => 'local',
1092 1099 'media_path' => $file['path'],
1093 1100 'media_url' => $file['url'],
1094 1101 'settings' => Arr::get($file, 'meta', [])
1095 - ], $file);
1102 + ];
1096 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 +
1097 1119 // Let's create the media now
1098 1120 $media = Media::create($mediaData);
1099 1121
1100 1122 $mediaUrl = $media->public_url;
@@ -1115,40 +1137,366 @@
1115 1137 }
1116 1138
1117 1139 public function getTicker(Request $request)
1118 1140 {
1119 - do_action('fluent_communit/track_activity');
1141 + $start = microtime(true);
1120 1142
1121 - $lastId = $request->get('last_feed_id');
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 + }
1122 1152
1123 - $newItemsCount = Feed::where('id', '>', $lastId)
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)
1124 1373 ->where('status', 'published')
1125 - ->byUserAccess(get_current_user_id())
1126 - ->count();
1374 + ->byUserAccess($userId);
1127 1375
1128 - $notificationCount = NotificationSubscriber::unread()->where('user_id', get_current_user_id())->count();
1376 + $currentUserModel = $this->getUser();
1129 1377
1378 + $feeds = $query
1379 + ->with(Feed::withPublicRelations($currentUserModel))
1380 + ->get();
1381 +
1382 + $feeds = FeedsHelper::transformFeedsCollection($feeds);
1383 +
1130 1384 return [
1131 - 'new_items_count' => $newItemsCount,
1132 - 'last_checked' => current_time('mysql'),
1133 - 'unread_notification_count' => $notificationCount
1385 + 'feeds' => $feeds,
1386 + 'count' => $feeds->count()
1134 1387 ];
1135 1388 }
1136 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 +
1137 1480 public function getOembed(Request $request)
1138 1481 {
1139 - $url = $request->get('url');
1140 - // check if the url is valid
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 +
1141 1488 $metaData = RemoteUrlParser::parse($url);
1142 1489
1143 1490 if ($metaData && !is_wp_error($metaData)) {
1144 - return [
1491 + $data = [
1145 1492 'oembed' => $metaData
1146 1493 ];
1494 + return apply_filters('fluent_community/feed_oembed_api_response', $data, $request->all());
1147 1495 }
1148 1496
1149 - return $this->send([
1150 - 'message' => 'No oembed data found',
1497 + return $this->sendError([
1498 + 'message' => __('No oembed data found', 'fluent-community'),
1151 1499 'url' => $url
1152 1500 ]);
1153 1501 }
1154 1502
@@ -1153,47 +1501,21 @@
1153 1501 }
1154 1502
1155 1503 public function markdownToHtml(Request $request)
1156 1504 {
1157 - $message = trim(sanitize_textarea_field($request->get('text', '')));
1505 + $message = CustomSanitizer::unslashMarkdown($request->get('text', ''));
1158 1506
1159 - $html = FeedsHelper::mdToHtml($message);
1507 + $html = wp_kses_post(FeedsHelper::mdToHtml($message));
1160 1508
1161 - return [
1509 + $data = [
1162 1510 'html' => $html
1163 1511 ];
1164 - }
1165 1512
1166 - private function transformFeed(Feed $feed)
1167 - {
1168 - $userId = $this->getUserId();
1169 - if ($userId) {
1170 - $feed->has_user_react = $feed->hasUserReact($userId, 'like');
1171 - $feed->bookmarked = $feed->hasUserReact($userId, 'bookmark');
1513 + $data['message_rendered'] = $html;
1172 1514
1173 - $likedIds = FeedsHelper::getLikedIdsByUserFeedId($feed->id, get_current_user_id());
1174 - $feed->comments->each(function ($comment) use ($likedIds) {
1175 - if ($likedIds && in_array($comment->id, $likedIds)) {
1176 - $comment->liked = 1;
1177 - }
1178 - });
1179 -
1180 - if ($feed->content_type == 'survey') {
1181 - $votedOptions = $feed->getSurveyCastsByUserId($userId);
1182 -
1183 - if ($votedOptions) {
1184 - $surveyConfig = Arr::get($feed->meta, 'survey_config', []);
1185 - foreach ($surveyConfig['options'] as $index => $option) {
1186 - if (in_array($option['slug'], $votedOptions)) {
1187 - $surveyConfig['options'][$index]['voted'] = true;
1188 - }
1189 - }
1190 - $meta = $feed->meta;
1191 - $meta['survey_config'] = $surveyConfig;
1192 - $feed->meta = $meta;
1193 - }
1194 - }
1515 + if (in_array('meta', $request->get('with', [])) && $request->get('feed')) {
1516 + [$data,] = FeedsHelper::processFeedMetaData($data, $request->get('feed'));
1195 1517 }
1196 1518
1197 - return $feed;
1519 + return $data;
1198 1520 }
1199 1521 }