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 +952 -233 1.0.982.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,82 +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
80 + if ($bySpace) {
81 + $feedsQuery = $feedsQuery->filterBySpaceSlug($bySpace);
82 + $queryArgs['space_slug'] = $bySpace;
83 + }
84 +
72 85 if ($bySpace && !$disableSticky) {
73 - $feedsQuery = $feedsQuery->filterBySpaceSlug($bySpace)
74 - ->where('is_sticky', 0);
75 - if ($request->page == 1) {
86 + $feedsQuery = $feedsQuery->where('is_sticky', 0);
87 + if ($queryArgs['page'] === 1) {
76 88 $stickyFeed = Feed::where('space_id', $space->id)
77 89 ->where('is_sticky', 1)
78 - ->with([
79 - 'xprofile' => function ($q) {
80 - $q->select(ProfileHelper::getXProfilePublicFields());
81 - },
82 - 'comments.xprofile' => function ($q) {
83 - $q->select(ProfileHelper::getXProfilePublicFields());
84 - },
85 - 'space'
86 - ]
87 - )
90 + ->byUserAccess($currentUserId)
91 + ->byContentModerationAccessStatus($currentUserModel, $space)
92 + ->with(Feed::withPublicRelations($this->getUser(), $space))
88 93 ->first();
89 94 }
90 95 }
91 96
@@ -90,134 +95,175 @@
90 95 }
91 96
92 97 if ($userId) {
93 98 $feedsQuery = $feedsQuery->where('user_id', $userId);
94 - if ($userId != get_current_user_id()) {
95 - $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 + });
96 104 }
105 +
106 + if ($userId != $currentUserId) {
107 + $feedsQuery = $feedsQuery->byUserAccess($currentUserId);
108 + }
109 +
110 + $queryArgs['user_id'] = $userId;
97 111 } else {
98 - $feedsQuery->byUserAccess(get_current_user_id());
112 + $feedsQuery->byUserAccess($currentUserId)->whereHas('xprofile', function ($q) {
113 + $q->where('status', 'active');
114 + });
99 115 }
100 116
101 - 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']);
102 119
103 - $feeds = $feedsQuery->paginate();
120 + do_action_ref_array('fluent_community/feeds_query', [&$feedsQuery, $request->all(), $queryArgs]);
104 121
122 + $feedsQuery->limit($queryArgs['per_page'])->offset(($queryArgs['page'] - 1) * $queryArgs['per_page']);
123 + $feeds = $feedsQuery->get();
124 +
105 125 // add $stickyFeed to the first page
106 126 if ($stickyFeed) {
107 - $stickyFeed = $this->transformFeed($stickyFeed);
127 + $stickyFeed = FeedsHelper::transformFeed($stickyFeed);
108 128 }
109 129
110 - $feeds->getCollection()->each(function ($feed) {
111 - $this->transformFeed($feed);
112 - });
130 + $feeds = FeedsHelper::transformFeedsCollection($feeds);
113 131
132 + $currentCount = $feeds->count();
133 + $to = ($queryArgs['page'] - 1) * $queryArgs['per_page'] + $currentCount;
134 +
135 + $hasMore = $currentCount == $queryArgs['per_page'];
136 +
114 137 $data = [
115 - '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 + ],
116 147 'sticky' => $stickyFeed
117 148 ];
118 149
119 - $isMainFeed = $request->get('page') == 1 && !$search && !$userId;
120 - if ($isMainFeed && get_current_user_id()) {
150 + $isMainFeed = $queryArgs['page'] === 1 && !$search && !$userId;
151 + if ($isMainFeed && $currentUserId) {
121 152 $data['last_fetched_timestamp'] = current_time('timestamp');
122 153 }
123 154
155 + $data['execution_time'] = microtime(true) - $start;
156 +
157 + $data = apply_filters('fluent_community/feeds_api_response', $data, $request->all());
158 +
124 159 return $data;
125 160 }
126 161
127 162 public function getFeedBySlug(Request $request, $feed_slug)
128 163 {
164 + $start = microtime(true);
129 165 if ($request->get('context') == 'edit') {
130 166 $feed = Feed::where('slug', $feed_slug)->first();
131 167
132 168 if (!$feed || !$feed->hasEditAccess(get_current_user_id())) {
133 169 return $this->sendError([
134 - 'message' => 'You do not have permission to edit this feed'
170 + 'message' => __('You do not have permission to edit this feed', 'fluent-community')
135 171 ]);
136 172 }
137 173
138 - return [
174 + $data = [
139 175 'feed' => FeedsHelper::transformForEdit($feed)
140 176 ];
177 +
178 + return apply_filters('fluent_community/feed_api_response', $data, $request->all());
141 179 }
142 180
143 181 $feed = Feed::where('slug', $feed_slug)
144 182 ->select(Feed::$publicColumns)
145 - ->with([
146 - 'xprofile' => function ($q) {
147 - $q->select(ProfileHelper::getXProfilePublicFields());
148 - },
149 - 'space',
150 - 'comments.xprofile' => function ($q) {
151 - $q->select(ProfileHelper::getXProfilePublicFields());
152 - },
153 - 'reactions' => function ($q) {
154 - $q->with([
155 - 'xprofile' => function ($query) {
156 - $query->select(['user_id', 'avatar']);
157 - }
158 - ])
159 - ->where('type', 'like')
160 - ->limit(3);
161 - },
162 - 'terms' => function ($q) {
163 - $q->select(['title', 'slug'])
164 - ->where('taxonomy_name', 'post_topic');
165 - }
166 - ])
183 + ->with(Feed::withPublicRelations($this->getUser()))
184 + ->whereHas('xprofile', function ($q) {
185 + $q->where('status', 'active');
186 + })
167 187 ->byUserAccess($this->getUserId())
168 188 ->first();
169 189
170 190 if (!$feed) {
171 191 return $this->sendError([
172 - 'message' => __('The feed could not be found', 'fluent-commuity')
192 + 'message' => __('The feed could not be found', 'fluent-community')
173 193 ], 404);
174 194 }
175 195
176 - $this->transformFeed($feed);
196 + $viewableByLinkStatuses = FeedsHelper::getViewableByLinkStatuses();
177 197
178 - return [
179 - 'feed' => $feed
180 - ];
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 +
181 211 }
182 212
213 + public function getFeedById(Request $request, $feedId)
214 + {
215 + $feed = Feed::findOrFail($feedId);
216 + return $this->getFeedBySlug($request, $feed->slug);
217 + }
218 +
183 219 public function getBookmarks(Request $request)
184 220 {
185 - $userId = get_current_user_id();
221 + $userId = $this->getUserId();
186 222
187 223 $feedsQuery = Feed::where('status', 'published')
188 224 ->select(Feed::$publicColumns)
189 - ->with([
190 - 'xprofile' => function ($q) {
191 - $q->select(ProfileHelper::getXProfilePublicFields());
192 - },
193 - 'comments.xprofile' => function ($q) {
194 - $q->select(ProfileHelper::getXProfilePublicFields());
195 - },
196 - 'space'
197 - ]
198 - )
225 + ->with(Feed::withPublicRelations($this->getUser()))
199 226 ->byBookMarked($userId)
200 227 ->byUserAccess($userId)
201 - ->searchBy($request->get('search'));
228 + ->byTopicSlug($request->getSafe('topic_slug'))
229 + ->customOrderBy($request->getSafe('order_by_type'))
230 + ->searchBy($request->getSafe('search'));
202 231
203 -
204 232 if ($type = $request->get('type')) {
205 233 $feedsQuery = $feedsQuery->where('type', $type);
206 234 }
207 235
236 + $queryArgs = [
237 + 'per_page' => (int)$request->get('per_page', 10),
238 + 'page' => (int)$request->get('page', 1)
239 + ];
240 +
208 241 $feeds = $feedsQuery->orderBy('id', 'DESC')
209 - ->paginate();
242 + ->limit($queryArgs['per_page'])
243 + ->offset(($queryArgs['page'] - 1) * $queryArgs['per_page'])
244 + ->get();
210 245
211 - $feeds->getCollection()->each(function ($feed) {
212 - $this->transformFeed($feed);
213 - });
246 + $currentCount = $feeds->count();
247 + $to = ($queryArgs['page'] - 1) * $queryArgs['per_page'] + $currentCount;
214 248
249 + $hasMore = $currentCount == $queryArgs['per_page'];
250 +
251 + $feeds = FeedsHelper::transformFeedsCollection($feeds);
252 +
215 253 $data = [
216 - '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 + ]
217 263 ];
218 264
219 - if ($request->get('page') == 1) {
265 + if ($queryArgs['page'] === 1) {
220 266 $lastItem = FeedsHelper::getLastFeedId();
221 267 if ($lastItem) {
222 268 $data['last_id'] = $lastItem;
223 269 }
@@ -222,28 +268,61 @@
222 268 $data['last_id'] = $lastItem;
223 269 }
224 270 }
225 271
226 - return $data;
272 + return apply_filters('fluent_community/bookmarks_api_response', $data, $request->all());
227 273 }
228 274
229 275 public function store(Request $request)
230 276 {
231 277 $user = $this->getUser(true);
278 +
232 279 do_action('fluent_community/check_rate_limit/create_post', $user);
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($user->ID, $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 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);
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 +
246 325 } else if (!Helper::hasGlobalPost()) {
247 326 return $this->sendError([
248 327 'message' => __('Please select a valid space to post in.', 'fluent-community')
249 328 ]);
@@ -248,32 +327,91 @@
248 327 'message' => __('Please select a valid space to post in.', 'fluent-community')
249 328 ]);
250 329 }
251 330
252 - $message = $data['message'];
253 - $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);
254 337 if ($mentions) {
255 338 $data['message'] = $message;
256 339 $message = $mentions['text'];
257 340 }
258 341
342 + [$message, $inlineMedias] = FeedsHelper::replaceImageUrlsWithRealMediaArchive($message);
343 +
259 344 // replace new line with br
260 345 $data['message_rendered'] = wp_kses_post(FeedsHelper::mdToHtml($message));
261 346
347 + $requestData['is_admin'] = $user->hasPermissionOrInCurrentSpace('community_moderator', $space);
348 +
262 349 [$data, $mediaItems] = FeedsHelper::processFeedMetaData($data, $requestData);
263 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 +
264 365 $data = apply_filters('fluent_community/feed/new_feed_data', $data, $requestData);
265 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 +
266 380 $feed->fill($data);
267 381
268 - $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();
269 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 +
270 410 if ($mediaItems) {
271 411 $this->saveMediaItems($feed, $mediaItems);
272 412 }
273 413
274 - $this->handleMentions($feed, $mentions ?? []);
275 -
276 414 $feed->load(['xprofile', 'comments.xprofile']);
277 415 if ($feed->space_id) {
278 416 $feed->load(['space']);
279 417 $topicIds = (array)$request->get('topic_ids', []);
@@ -281,21 +419,51 @@
281 419 if ($topicIds) {
282 420 $topicsConfig = Helper::getTopicsConfig();
283 421 $topicIds = array_slice($topicIds, 0, $topicsConfig['max_topics_per_post']);
284 422 $feed->attachTopics($topicIds, false);
423 + $feed->load(['terms']);
285 424 }
286 425 }
287 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 +
288 451 do_action('fluent_community/feed/created', $feed);
452 +
289 453 if ($feed->space_id) {
290 454 do_action('fluent_community/space_feed/created', $feed);
455 + } else {
456 + do_action('fluent_community/profile_feed/created', $feed);
291 457 }
292 458
293 - return [
294 - 'feed' => $this->transformFeed($feed),
295 - '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,
296 464 'last_fetched_timestamp' => current_time('timestamp')
297 - ];
465 + ], $feed, $request->all());
298 466 }
299 467
300 468 public function update(Request $request, $feedId)
301 469 {
@@ -302,10 +470,40 @@
302 470 $requestData = $request->all();
303 471 $data = $this->sanitizeAndValidateData($requestData);
304 472 $user = $this->getUser(true);
305 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 +
306 484 $user->canEditFeed($existingFeed, true);
307 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 +
308 506 $message = $data['message'];
309 507 $mentions = FeedsHelper::getMentions($data['message'], Arr::get($data, 'space_id'));
310 508 if ($mentions) {
311 509 $data['message'] = $message;
@@ -311,15 +509,60 @@
311 509 $data['message'] = $message;
312 510 $message = $mentions['text'];
313 511 }
314 512
513 + [$message, $inlineMedias] = FeedsHelper::replaceImageUrlsWithRealMediaArchive($message, $existingFeed);
514 +
315 515 // replace new line with br
316 516 $data['message_rendered'] = wp_kses_post(FeedsHelper::mdToHtml($message));
317 517
318 518 [$data, $mediaItems] = FeedsHelper::processFeedMetaData($data, $requestData, $existingFeed);
319 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 +
320 534 $data = apply_filters('fluent_community/feed/update_feed_data', $data, $requestData);
321 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 +
322 565 if ($message != $existingFeed->message) {
323 566 $data['meta']['last_edited'] = [
324 567 'user_id' => $user->ID,
325 568 'time' => current_time('mysql')
@@ -325,8 +568,44 @@
325 568 'time' => current_time('mysql')
326 569 ];
327 570 }
328 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 +
329 608 $data = apply_filters('fluent_community/feed/update_data', $data, $existingFeed);
330 609 $existingFeed->fill($data);
331 610 $dirty = $existingFeed->getDirty();
332 611
@@ -350,8 +629,25 @@
350 629 $editHistory = array_slice($editHistory, -5);
351 630 $existingFeed->updateCustomMeta('_edit_history', $editHistory);
352 631 }
353 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 +
354 650 if ($mediaItems) {
355 651 $this->saveMediaItems($existingFeed, $mediaItems);
356 652 }
357 653
@@ -358,15 +654,23 @@
358 654 $existingFeed->load(['xprofile', 'comments.xprofile']);
359 655
360 656 if ($existingFeed->space_id) {
361 657 $existingFeed->load(['space']);
658 + $space = $existingFeed->space;
362 659 $topicIds = (array)Arr::get($requestData, 'topic_ids', []);
660 + $topicsConfig = Helper::getTopicsConfig();
363 661 // take only max topics per post
364 662 if ($topicIds) {
365 - $topicsConfig = Helper::getTopicsConfig();
366 663 $topicIds = array_slice($topicIds, 0, $topicsConfig['max_topics_per_post']);
367 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 + }
368 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();
369 673 }
370 674
371 675 if ($dirty) {
372 676 do_action('fluent_community/feed/updated', $existingFeed, $dirty);
@@ -374,12 +678,14 @@
374 678 do_action('fluent_community/space_feed/updated', $existingFeed);
375 679 }
376 680 }
377 681
378 - return [
379 - 'feed' => $this->transformFeed($existingFeed),
682 + $data = [
683 + 'feed' => FeedsHelper::transformFeed($existingFeed),
380 684 'message' => __('Your post has been updated', 'fluent-community')
381 685 ];
686 +
687 + return apply_filters('fluent_community/feed/update_feed_response', $data, $request->all());
382 688 }
383 689
384 690 public function patchFeed(Request $request, $feedId)
385 691 {
@@ -385,12 +691,13 @@
385 691 {
386 692 $feed = Feed::findOrFail($feedId);
387 693 $user = $this->getUser(true);
388 694
389 - $isMod = $user->isCommunityModerator();
390 695 $isAuthor = $feed->user_id == $user->ID;
696 + $isMod = $user->hasPermissionOrInCurrentSpace('community_moderator', $feed->space);
697 + $isAdmin = $user->hasPermissionOrInCurrentSpace('community_admin', $feed->space);
391 698
392 - if (!$isMod && !$isAuthor) {
699 + if (!$isMod && !$isAuthor && !$isAdmin) {
393 700 return $this->sendError([
394 701 'message' => __('You do not have permission to perform this action', 'fluent-community')
395 702 ]);
396 703 }
@@ -405,13 +712,26 @@
405 712 $data = Arr::only($allData, $validKeys);
406 713
407 714 $data = array_map('intval', $data);
408 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 +
409 726 if (isset($data['is_sticky'])) {
410 727 $data['is_sticky'] = $data['is_sticky'] ? 1 : 0;
411 728 if ($data['is_sticky'] && $feed->space_id) {
412 - // remove all the sticky posts from the space
413 - 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]);
414 734 }
415 735 }
416 736
417 737 if (isset($data['comments_disabled'])) {
@@ -419,29 +739,56 @@
419 739 $meta['comments_disabled'] = $data['comments_disabled'] ? 'yes' : 'no';
420 740 $data['meta'] = $meta;
421 741 }
422 742
423 -
424 743 if ($data) {
425 744 $feed->fill($data);
426 745 $dirty = $feed->getDirty();
427 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 +
428 752 $feed->save();
429 753 do_action('fluent_community/feed/updated', $feed, $dirty);
430 754 }
431 755 }
432 756
433 - return [
757 + return apply_filters('fluent_community/feed/patch_feed_response', [
434 758 'feed' => $feed,
435 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)
436 769 ];
770 +
771 + return apply_filters('fluent_community/welcome_banner_api_response', $data, $request->all());
437 772 }
438 773
439 774 public function getLinks(Request $request)
440 775 {
441 - 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 = [
442 787 'links' => Helper::getFeedLinks()
443 788 ];
789 +
790 + return apply_filters('fluent_community/feed_links_api_response', $data, $request->all());
444 791 }
445 792
446 793 public function updateLinks(Request $request)
447 794 {
@@ -468,15 +815,8 @@
468 815 $media->save();
469 816 }
470 817 }
471 818
472 - private function handleMentions($feed, $mentions)
473 - {
474 - if ($mentions) {
475 - do_action('fluent_community/feed_mentioned', $feed, $mentions['users']);
476 - }
477 - }
478 -
479 819 private function sanitizeAndValidateData($data)
480 820 {
481 821 $data['type'] = 'text';
482 822
@@ -488,19 +828,26 @@
488 828
489 829 return FeedsHelper::sanitizeAndValidateData($data);
490 830 }
491 831
492 - private function checkForDuplicatePost($userId, $message)
832 + private function checkForDuplicatePost($userId, $message, $spaceId = null)
493 833 {
834 + if (apply_filters('fluent_community/disable_duplicate_post_check', false, $userId, $spaceId)) {
835 + return false;
836 + }
837 +
494 838 $message = trim($message);
495 839
496 840 $exist = Feed::where('user_id', $userId)
497 841 ->where('message', $message)
498 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 + })
499 846 ->first();
500 847
501 848 if ($exist) {
502 - return $this->sendError(['message' => 'No duplicate post please!']);
849 + return $this->sendError(['message' => __('No duplicate post please!', 'fluent-community')]);
503 850 }
504 851
505 852 return false;
506 853 }
@@ -508,9 +855,9 @@
508 855 private function validateAndSetSpace($spaceSlug, $user)
509 856 {
510 857 if ($spaceSlug == '__self__post__') {
511 858 if (!Helper::hasGlobalPost()) {
512 - 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'));
513 860 }
514 861
515 862 return null;
516 863 }
@@ -517,9 +864,9 @@
517 864
518 865 $space = Space::where('slug', $spaceSlug)->first();
519 866
520 867 if (!$space) {
521 - 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'));
522 869 }
523 870
524 871 $user->verifySpacePermission('can_create_post', $space);
525 872
@@ -537,9 +884,9 @@
537 884
538 885 do_action('fluent_community/feed/deleted', $feed_id);
539 886
540 887 return [
541 - 'message' => 'Feed has been deleted successfully'
888 + 'message' => __('Feed has been deleted successfully', 'fluent-community')
542 889 ];
543 890 }
544 891
545 892 public function deleteMediaPreview(Request $request, $feed_id)
@@ -547,9 +894,9 @@
547 894 $feed = Feed::findOrFail($feed_id);
548 895 $user = User::find(get_current_user_id());
549 896 $user->canDeleteFeed($feed, true);
550 897
551 - do_action('fluent_community/feed/media_deleted', $feed->media);
898 + //do_action('fluent_community/feed/media_deleted', $feed->media);
552 899
553 900 $meta = $feed->meta;
554 901 $meta['media_preview'] = null;
555 902
@@ -562,34 +909,110 @@
562 909 }
563 910
564 911 public function handleMediaUpload(Request $request)
565 912 {
566 - $allowedTypes = implode(
567 - ',',
568 - apply_filters('fluent_community/support_attachment_types', [
569 - 'image/jpeg',
570 - 'image/pjpeg',
571 - 'image/jpeg',
572 - 'image/pjpeg',
573 - 'image/png',
574 - 'image/gif',
575 - 'image/webp'
576 - ])
577 - );
913 + if ($error = Helper::checkUploadSizeError()) {
914 + return $this->sendError($error, 413);
915 + }
578 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 +
579 959 $files = $this->validate($this->request->files(), [
580 - 'file' => 'mimetypes:' . $allowedTypes,
960 + 'file' => 'required|mimetypes:' . $allowedTypes . '|max:' . $allowedFileSize,
581 961 ], [
582 - '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)
583 966 ]);
584 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']);
585 977 $uploadedFiles = FileSystem::put($files);
978 + remove_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']);
586 979
587 - $file = $uploadedFiles[0];
980 + $file = Arr::get($uploadedFiles, 0);
588 981
589 - $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 + }
590 987
591 - 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) {
592 1015 $upload_dir = wp_upload_dir();
593 1016 $fileUrl = str_replace($upload_dir['baseurl'], $upload_dir['basedir'], $file['url']);
594 1017
595 1018 $editor = wp_get_image_editor($fileUrl);
@@ -596,19 +1019,18 @@
596 1019
597 1020 if (!is_wp_error($editor) && $editor->get_size()['width'] > $maxWidth) {
598 1021 // Current file extension
599 1022 $ext = pathinfo($file['url'], PATHINFO_EXTENSION);
600 - $imageExtensions = ['jpg', 'jpeg', 'png', 'gif'];
1023 + $willConvert = in_array($ext, $convertibleExtensions) && $willWebPConvert;
601 1024
602 - $willConvert = in_array($ext, $imageExtensions) && $willWebPConvert;
603 -
604 1025 if ($willConvert) {
605 - $imageExtensions = array_map(function ($ext) {
1026 + $dottedExtensions = array_map(function ($ext) {
606 1027 return '.' . $ext;
607 - }, $imageExtensions);
608 - $fileUrl = str_replace($imageExtensions, '.webp', $fileUrl);
609 - $file['file'] = str_replace($imageExtensions, '.webp', $file['file']);
610 - $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']);
611 1033 $file['type'] = 'image/webp';
612 1034 }
613 1035
614 1036 // resize the image
@@ -614,16 +1036,24 @@
614 1036 // resize the image
615 1037 $editor->resize($maxWidth, null, false);
616 1038 $editor->set_quality(90);
617 1039 if ($willConvert) {
618 - $editor->save($fileUrl, 'image/webp');
619 - // remove original file now
620 - 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 + }
621 1045 $file['is_converted'] = true;
622 1046 } else {
623 - $editor->save($fileUrl);
1047 + $result = $editor->save($fileUrl);
624 1048 }
625 1049
1050 + if ($result['mime-type'] != 'image/webp') {
1051 + $file['file'] = $originalFileName;
1052 + $file['url'] = $originalUrl;
1053 + $file['type'] = $result['mime-type'];
1054 + }
1055 +
626 1056 $file['meta'] = [
627 1057 'width' => $editor->get_size()['width'],
628 1058 'height' => $editor->get_size()['height']
629 1059 ];
@@ -637,20 +1067,25 @@
637 1067 if ($willWebPConvert && empty($file['is_converted']) && !$request->get('skip_convert')) {
638 1068 $path = $file['path'];
639 1069 $extension = pathinfo($path, PATHINFO_EXTENSION);
640 1070
641 - $convertFromExtensions = ['png', 'jpg', 'jpeg', 'gif'];
642 - if ($extension != 'webp' && in_array($extension, $convertFromExtensions)) {
1071 + if ($extension != 'webp' && in_array($extension, $convertibleExtensions)) {
643 1072 // Let's convert to webp
644 1073 $editor = wp_get_image_editor($file['path']);
645 1074 if (!is_wp_error($editor)) {
646 - $orginalPath = $file['path'];
647 1075 $file['path'] = str_replace('.' . $extension, '.webp', $file['path']);
648 1076 $file['url'] = str_replace('.' . $extension, '.webp', $file['url']);
649 1077 $file['type'] = 'image/webp';
650 - $editor->save($file['path'], 'image/webp');
651 - wp_delete_file($orginalPath);
1078 + $result = $editor->save($file['path'], 'image/webp');
652 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 +
653 1088 $file['meta'] = [
654 1089 'width' => $editor->get_size()['width'],
655 1090 'height' => $editor->get_size()['height']
656 1091 ];
@@ -676,9 +1111,9 @@
676 1111 }
677 1112
678 1113 if (!$mediaData) {
679 1114 return $this->sendError([
680 - 'message' => 'Error while uploading the media'
1115 + 'message' => __('Error while uploading the media', 'fluent-community')
681 1116 ]);
682 1117 }
683 1118
684 1119 // Let's create the media now
@@ -702,56 +1137,366 @@
702 1137 }
703 1138
704 1139 public function getTicker(Request $request)
705 1140 {
706 - do_action('fluent_communit/track_activity');
707 - $lastLoadedTimeStamp = $request->get('last_fetched_timestamp');
1141 + $start = microtime(true);
708 1142
709 - //check if $lastLoadedTimeStamp is valid date
710 - if (!$lastLoadedTimeStamp || (current_time('timestamp') - $lastLoadedTimeStamp) > HOUR_IN_SECONDS) {
1143 + $userId = get_current_user_id();
1144 + if (!$userId) {
711 1145 return [
712 - 'last_fetched_timestamp' => current_time('timestamp'),
713 - 'error' => 'Invalid timestamp',
714 - 'given' => $lastLoadedTimeStamp
1146 + 'timestamp' => current_time('mysql', true),
1147 + 'has_changes' => false,
1148 + 'error' => __('User not authenticated', 'fluent-community'),
1149 + 'feeds' => []
715 1150 ];
716 1151 }
717 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 +
718 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();
719 1396 if (!$userId) {
720 1397 return [
721 - 'last_fetched_timestamp' => current_time('timestamp'),
722 - 'error' => 'Invalid user'
1398 + 'updates' => [],
1399 + 'timestamp' => current_time('mysql', true),
1400 + 'has_changes' => false,
1401 + 'error' => __('User not authenticated', 'fluent-community')
723 1402 ];
724 1403 }
725 1404
726 - $newItemsCount = Feed::where('created_at', '>', date('Y-m-d H:i:s', $lastLoadedTimeStamp))
727 - ->where('status', 'published')
728 - ->byUserAccess(get_current_user_id())
729 - ->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 + }
730 1416
731 - $notificationCount = NotificationSubscriber::unread()->where('user_id', $userId)->count();
1417 + // Build query based on context
1418 + $query = Feed::query();
732 1419
733 - return apply_filters('fluent_community/feed_ticker', [
734 - 'last_fetched_timestamp' => current_time('timestamp'),
735 - 'new_items_count' => $newItemsCount > 10 ? 10 : $newItemsCount,
736 - 'unread_notification_count' => $notificationCount
737 - ]);
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 + ];
738 1478 }
739 1479
740 1480 public function getOembed(Request $request)
741 1481 {
742 - $url = $request->get('url');
743 - // 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 +
744 1488 $metaData = RemoteUrlParser::parse($url);
745 1489
746 1490 if ($metaData && !is_wp_error($metaData)) {
747 - return [
1491 + $data = [
748 1492 'oembed' => $metaData
749 1493 ];
1494 + return apply_filters('fluent_community/feed_oembed_api_response', $data, $request->all());
750 1495 }
751 1496
752 - return $this->send([
753 - 'message' => 'No oembed data found',
1497 + return $this->sendError([
1498 + 'message' => __('No oembed data found', 'fluent-community'),
754 1499 'url' => $url
755 1500 ]);
756 1501 }
757 1502
@@ -756,47 +1501,21 @@
756 1501 }
757 1502
758 1503 public function markdownToHtml(Request $request)
759 1504 {
760 - $message = trim(sanitize_textarea_field($request->get('text', '')));
1505 + $message = CustomSanitizer::unslashMarkdown($request->get('text', ''));
761 1506
762 - $html = FeedsHelper::mdToHtml($message);
1507 + $html = wp_kses_post(FeedsHelper::mdToHtml($message));
763 1508
764 - return [
1509 + $data = [
765 1510 'html' => $html
766 1511 ];
767 - }
768 1512
769 - private function transformFeed(Feed $feed)
770 - {
771 - $userId = $this->getUserId();
772 - if ($userId) {
773 - $feed->has_user_react = $feed->hasUserReact($userId, 'like');
774 - $feed->bookmarked = $feed->hasUserReact($userId, 'bookmark');
1513 + $data['message_rendered'] = $html;
775 1514
776 - $likedIds = FeedsHelper::getLikedIdsByUserFeedId($feed->id, get_current_user_id());
777 - $feed->comments->each(function ($comment) use ($likedIds) {
778 - if ($likedIds && in_array($comment->id, $likedIds)) {
779 - $comment->liked = 1;
780 - }
781 - });
782 -
783 - if ($feed->content_type == 'survey') {
784 - $votedOptions = $feed->getSurveyCastsByUserId($userId);
785 -
786 - if ($votedOptions) {
787 - $surveyConfig = Arr::get($feed->meta, 'survey_config', []);
788 - foreach ($surveyConfig['options'] as $index => $option) {
789 - if (in_array($option['slug'], $votedOptions)) {
790 - $surveyConfig['options'][$index]['voted'] = true;
791 - }
792 - }
793 - $meta = $feed->meta;
794 - $meta['survey_config'] = $surveyConfig;
795 - $feed->meta = $meta;
796 - }
797 - }
1515 + if (in_array('meta', $request->get('with', [])) && $request->get('feed')) {
1516 + [$data,] = FeedsHelper::processFeedMetaData($data, $request->get('feed'));
798 1517 }
799 1518
800 - return $feed;
1519 + return $data;
801 1520 }
802 1521 }