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