PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.10.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.10.0
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 +957 -237 1.0.972.10.0 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,29 +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
287 + $data['status'] = apply_filters('fluent_community/feed/save_status', $data['status'], $requestData, null);
237 288
238 - if ($isDulicate = $this->checkForDuplicatePost($user->ID, $data['message'])) {
239 - return $isDulicate;
240 - }
241 -
242 289 $feed = new Feed();
243 290 $feed->user_id = $user->ID;
291 + $space = null;
244 292
245 293 if ($spaceSlug = $request->get('space')) {
246 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 +
247 325 } else if (!Helper::hasGlobalPost()) {
248 326 return $this->sendError([
249 327 'message' => __('Please select a valid space to post in.', 'fluent-community')
250 328 ]);
@@ -249,32 +327,91 @@
249 327 'message' => __('Please select a valid space to post in.', 'fluent-community')
250 328 ]);
251 329 }
252 330
253 - $message = $data['message'];
254 - $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);
255 337 if ($mentions) {
256 338 $data['message'] = $message;
257 339 $message = $mentions['text'];
258 340 }
259 341
342 + [$message, $inlineMedias] = FeedsHelper::replaceImageUrlsWithRealMediaArchive($message);
343 +
260 344 // replace new line with br
261 345 $data['message_rendered'] = wp_kses_post(FeedsHelper::mdToHtml($message));
262 346
347 + $requestData['is_admin'] = $user->hasPermissionOrInCurrentSpace('community_moderator', $space);
348 +
263 349 [$data, $mediaItems] = FeedsHelper::processFeedMetaData($data, $requestData);
264 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 +
265 365 $data = apply_filters('fluent_community/feed/new_feed_data', $data, $requestData);
266 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 +
267 380 $feed->fill($data);
268 381
269 - $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();
270 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 +
271 410 if ($mediaItems) {
272 411 $this->saveMediaItems($feed, $mediaItems);
273 412 }
274 413
275 - $this->handleMentions($feed, $mentions ?? []);
276 -
277 414 $feed->load(['xprofile', 'comments.xprofile']);
278 415 if ($feed->space_id) {
279 416 $feed->load(['space']);
280 417 $topicIds = (array)$request->get('topic_ids', []);
@@ -282,21 +419,51 @@
282 419 if ($topicIds) {
283 420 $topicsConfig = Helper::getTopicsConfig();
284 421 $topicIds = array_slice($topicIds, 0, $topicsConfig['max_topics_per_post']);
285 422 $feed->attachTopics($topicIds, false);
423 + $feed->load(['terms']);
286 424 }
287 425 }
288 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 +
289 451 do_action('fluent_community/feed/created', $feed);
452 +
290 453 if ($feed->space_id) {
291 454 do_action('fluent_community/space_feed/created', $feed);
455 + } else {
456 + do_action('fluent_community/profile_feed/created', $feed);
292 457 }
293 458
294 - return [
295 - 'feed' => $this->transformFeed($feed),
296 - '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,
297 464 'last_fetched_timestamp' => current_time('timestamp')
298 - ];
465 + ], $feed, $request->all());
299 466 }
300 467
301 468 public function update(Request $request, $feedId)
302 469 {
@@ -303,10 +470,40 @@
303 470 $requestData = $request->all();
304 471 $data = $this->sanitizeAndValidateData($requestData);
305 472 $user = $this->getUser(true);
306 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 +
307 484 $user->canEditFeed($existingFeed, true);
308 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 +
309 506 $message = $data['message'];
310 507 $mentions = FeedsHelper::getMentions($data['message'], Arr::get($data, 'space_id'));
311 508 if ($mentions) {
312 509 $data['message'] = $message;
@@ -312,15 +509,60 @@
312 509 $data['message'] = $message;
313 510 $message = $mentions['text'];
314 511 }
315 512
513 + [$message, $inlineMedias] = FeedsHelper::replaceImageUrlsWithRealMediaArchive($message, $existingFeed);
514 +
316 515 // replace new line with br
317 516 $data['message_rendered'] = wp_kses_post(FeedsHelper::mdToHtml($message));
318 517
319 518 [$data, $mediaItems] = FeedsHelper::processFeedMetaData($data, $requestData, $existingFeed);
320 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 +
321 534 $data = apply_filters('fluent_community/feed/update_feed_data', $data, $requestData);
322 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 +
323 565 if ($message != $existingFeed->message) {
324 566 $data['meta']['last_edited'] = [
325 567 'user_id' => $user->ID,
326 568 'time' => current_time('mysql')
@@ -326,8 +568,44 @@
326 568 'time' => current_time('mysql')
327 569 ];
328 570 }
329 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 +
330 608 $data = apply_filters('fluent_community/feed/update_data', $data, $existingFeed);
331 609 $existingFeed->fill($data);
332 610 $dirty = $existingFeed->getDirty();
333 611
@@ -351,8 +629,25 @@
351 629 $editHistory = array_slice($editHistory, -5);
352 630 $existingFeed->updateCustomMeta('_edit_history', $editHistory);
353 631 }
354 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 +
355 650 if ($mediaItems) {
356 651 $this->saveMediaItems($existingFeed, $mediaItems);
357 652 }
358 653
@@ -359,15 +654,23 @@
359 654 $existingFeed->load(['xprofile', 'comments.xprofile']);
360 655
361 656 if ($existingFeed->space_id) {
362 657 $existingFeed->load(['space']);
658 + $space = $existingFeed->space;
363 659 $topicIds = (array)Arr::get($requestData, 'topic_ids', []);
660 + $topicsConfig = Helper::getTopicsConfig();
364 661 // take only max topics per post
365 662 if ($topicIds) {
366 - $topicsConfig = Helper::getTopicsConfig();
367 663 $topicIds = array_slice($topicIds, 0, $topicsConfig['max_topics_per_post']);
368 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 + }
369 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();
370 673 }
371 674
372 675 if ($dirty) {
373 676 do_action('fluent_community/feed/updated', $existingFeed, $dirty);
@@ -375,12 +678,14 @@
375 678 do_action('fluent_community/space_feed/updated', $existingFeed);
376 679 }
377 680 }
378 681
379 - return [
380 - 'feed' => $this->transformFeed($existingFeed),
682 + $data = [
683 + 'feed' => FeedsHelper::transformFeed($existingFeed),
381 684 'message' => __('Your post has been updated', 'fluent-community')
382 685 ];
686 +
687 + return apply_filters('fluent_community/feed/update_feed_response', $data, $request->all());
383 688 }
384 689
385 690 public function patchFeed(Request $request, $feedId)
386 691 {
@@ -386,12 +691,13 @@
386 691 {
387 692 $feed = Feed::findOrFail($feedId);
388 693 $user = $this->getUser(true);
389 694
390 - $isMod = $user->isCommunityModerator();
391 695 $isAuthor = $feed->user_id == $user->ID;
696 + $isMod = $user->hasPermissionOrInCurrentSpace('community_moderator', $feed->space);
697 + $isAdmin = $user->hasPermissionOrInCurrentSpace('community_admin', $feed->space);
392 698
393 - if (!$isMod && !$isAuthor) {
699 + if (!$isMod && !$isAuthor && !$isAdmin) {
394 700 return $this->sendError([
395 701 'message' => __('You do not have permission to perform this action', 'fluent-community')
396 702 ]);
397 703 }
@@ -406,13 +712,26 @@
406 712 $data = Arr::only($allData, $validKeys);
407 713
408 714 $data = array_map('intval', $data);
409 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 +
410 726 if (isset($data['is_sticky'])) {
411 727 $data['is_sticky'] = $data['is_sticky'] ? 1 : 0;
412 728 if ($data['is_sticky'] && $feed->space_id) {
413 - // remove all the sticky posts from the space
414 - 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]);
415 734 }
416 735 }
417 736
418 737 if (isset($data['comments_disabled'])) {
@@ -420,29 +739,56 @@
420 739 $meta['comments_disabled'] = $data['comments_disabled'] ? 'yes' : 'no';
421 740 $data['meta'] = $meta;
422 741 }
423 742
424 -
425 743 if ($data) {
426 744 $feed->fill($data);
427 745 $dirty = $feed->getDirty();
428 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 +
429 752 $feed->save();
430 753 do_action('fluent_community/feed/updated', $feed, $dirty);
431 754 }
432 755 }
433 756
434 - return [
757 + return apply_filters('fluent_community/feed/patch_feed_response', [
435 758 'feed' => $feed,
436 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)
437 769 ];
770 +
771 + return apply_filters('fluent_community/welcome_banner_api_response', $data, $request->all());
438 772 }
439 773
440 774 public function getLinks(Request $request)
441 775 {
442 - 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 = [
443 787 'links' => Helper::getFeedLinks()
444 788 ];
789 +
790 + return apply_filters('fluent_community/feed_links_api_response', $data, $request->all());
445 791 }
446 792
447 793 public function updateLinks(Request $request)
448 794 {
@@ -469,38 +815,39 @@
469 815 $media->save();
470 816 }
471 817 }
472 818
473 - private function handleMentions($feed, $mentions)
474 - {
475 - if ($mentions) {
476 - do_action('fluent_community/feed_mentioned', $feed, $mentions['users']);
477 - }
478 - }
479 -
480 819 private function sanitizeAndValidateData($data)
481 820 {
482 821 $data['type'] = 'text';
483 822
484 823 $this->validate($data, [
485 - 'message' => 'required',
486 - 'type' => 'required'
824 + 'message' => 'required'
825 + ], [
826 + 'message.required' => __('Message is required', 'fluent-community'),
487 827 ]);
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,52 +909,128 @@
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,
581 - // 'source' => 'required|in:feed,avatar,comment,cover,space'
960 + 'file' => 'required|mimetypes:' . $allowedTypes . '|max:' . $allowedFileSize,
582 961 ], [
583 - '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)
584 966 ]);
585 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']);
586 977 $uploadedFiles = FileSystem::put($files);
978 + remove_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']);
587 979
588 - $file = $uploadedFiles[0];
980 + $file = Arr::get($uploadedFiles, 0);
589 981
590 - $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 + }
591 987
592 - 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) {
593 1015 $upload_dir = wp_upload_dir();
594 1016 $fileUrl = str_replace($upload_dir['baseurl'], $upload_dir['basedir'], $file['url']);
1017 +
595 1018 $editor = wp_get_image_editor($fileUrl);
1019 +
596 1020 if (!is_wp_error($editor) && $editor->get_size()['width'] > $maxWidth) {
597 1021 // Current file extension
598 1022 $ext = pathinfo($file['url'], PATHINFO_EXTENSION);
599 - $imageExtensions = ['jpg', 'jpeg', 'png', 'gif'];
1023 + $willConvert = in_array($ext, $convertibleExtensions) && $willWebPConvert;
600 1024
601 - $willConvert = in_array($ext, $imageExtensions) && $willWebPConvert;
602 -
603 1025 if ($willConvert) {
604 - $imageExtensions = array_map(function ($ext) {
1026 + $dottedExtensions = array_map(function ($ext) {
605 1027 return '.' . $ext;
606 - }, $imageExtensions);
607 - $fileUrl = str_replace($imageExtensions, '.webp', $fileUrl);
608 - $file['file'] = str_replace($imageExtensions, '.webp', $file['file']);
609 - $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']);
610 1033 $file['type'] = 'image/webp';
611 1034 }
612 1035
613 1036 // resize the image
@@ -613,16 +1036,24 @@
613 1036 // resize the image
614 1037 $editor->resize($maxWidth, null, false);
615 1038 $editor->set_quality(90);
616 1039 if ($willConvert) {
617 - $editor->save($fileUrl, 'image/webp');
618 - // remove original file now
619 - 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 + }
620 1045 $file['is_converted'] = true;
621 1046 } else {
622 - $editor->save($fileUrl);
1047 + $result = $editor->save($fileUrl);
623 1048 }
624 1049
1050 + if ($result['mime-type'] != 'image/webp') {
1051 + $file['file'] = $originalFileName;
1052 + $file['url'] = $originalUrl;
1053 + $file['type'] = $result['mime-type'];
1054 + }
1055 +
625 1056 $file['meta'] = [
626 1057 'width' => $editor->get_size()['width'],
627 1058 'height' => $editor->get_size()['height']
628 1059 ];
@@ -636,20 +1067,25 @@
636 1067 if ($willWebPConvert && empty($file['is_converted']) && !$request->get('skip_convert')) {
637 1068 $path = $file['path'];
638 1069 $extension = pathinfo($path, PATHINFO_EXTENSION);
639 1070
640 - $convertFromExtensions = ['png', 'jpg', 'jpeg', 'gif'];
641 - if ($extension != 'webp' && in_array($extension, $convertFromExtensions)) {
1071 + if ($extension != 'webp' && in_array($extension, $convertibleExtensions)) {
642 1072 // Let's convert to webp
643 1073 $editor = wp_get_image_editor($file['path']);
644 1074 if (!is_wp_error($editor)) {
645 - $orginalPath = $file['path'];
646 1075 $file['path'] = str_replace('.' . $extension, '.webp', $file['path']);
647 1076 $file['url'] = str_replace('.' . $extension, '.webp', $file['url']);
648 1077 $file['type'] = 'image/webp';
649 - $editor->save($file['path'], 'image/webp');
650 - wp_delete_file($orginalPath);
1078 + $result = $editor->save($file['path'], 'image/webp');
651 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 +
652 1088 $file['meta'] = [
653 1089 'width' => $editor->get_size()['width'],
654 1090 'height' => $editor->get_size()['height']
655 1091 ];
@@ -675,9 +1111,9 @@
675 1111 }
676 1112
677 1113 if (!$mediaData) {
678 1114 return $this->sendError([
679 - 'message' => 'Error while uploading the media'
1115 + 'message' => __('Error while uploading the media', 'fluent-community')
680 1116 ]);
681 1117 }
682 1118
683 1119 // Let's create the media now
@@ -701,56 +1137,366 @@
701 1137 }
702 1138
703 1139 public function getTicker(Request $request)
704 1140 {
705 - do_action('fluent_communit/track_activity');
706 - $lastLoadedTimeStamp = $request->get('last_fetched_timestamp');
1141 + $start = microtime(true);
707 1142
708 - //check if $lastLoadedTimeStamp is valid date
709 - if (!$lastLoadedTimeStamp || (current_time('timestamp') - $lastLoadedTimeStamp) > HOUR_IN_SECONDS) {
1143 + $userId = get_current_user_id();
1144 + if (!$userId) {
710 1145 return [
711 - 'last_fetched_timestamp' => current_time('timestamp'),
712 - 'error' => 'Invalid timestamp',
713 - 'given' => $lastLoadedTimeStamp
1146 + 'timestamp' => current_time('mysql', true),
1147 + 'has_changes' => false,
1148 + 'error' => __('User not authenticated', 'fluent-community'),
1149 + 'feeds' => []
714 1150 ];
715 1151 }
716 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 +
717 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();
718 1396 if (!$userId) {
719 1397 return [
720 - 'last_fetched_timestamp' => current_time('timestamp'),
721 - 'error' => 'Invalid user'
1398 + 'updates' => [],
1399 + 'timestamp' => current_time('mysql', true),
1400 + 'has_changes' => false,
1401 + 'error' => __('User not authenticated', 'fluent-community')
722 1402 ];
723 1403 }
724 1404
725 - $newItemsCount = Feed::where('created_at', '>', date('Y-m-d H:i:s', $lastLoadedTimeStamp))
726 - ->where('status', 'published')
727 - ->byUserAccess(get_current_user_id())
728 - ->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 + }
729 1416
730 - $notificationCount = NotificationSubscriber::unread()->where('user_id', $userId)->count();
1417 + // Build query based on context
1418 + $query = Feed::query();
731 1419
732 - return apply_filters('fluent_community/feed_ticker', [
733 - 'last_fetched_timestamp' => current_time('timestamp'),
734 - 'new_items_count' => $newItemsCount > 10 ? 10 : $newItemsCount,
735 - 'unread_notification_count' => $notificationCount
736 - ]);
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 + ];
737 1478 }
738 1479
739 1480 public function getOembed(Request $request)
740 1481 {
741 - $url = $request->get('url');
742 - // 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 +
743 1488 $metaData = RemoteUrlParser::parse($url);
744 1489
745 1490 if ($metaData && !is_wp_error($metaData)) {
746 - return [
1491 + $data = [
747 1492 'oembed' => $metaData
748 1493 ];
1494 + return apply_filters('fluent_community/feed_oembed_api_response', $data, $request->all());
749 1495 }
750 1496
751 - return $this->send([
752 - 'message' => 'No oembed data found',
1497 + return $this->sendError([
1498 + 'message' => __('No oembed data found', 'fluent-community'),
753 1499 'url' => $url
754 1500 ]);
755 1501 }
756 1502
@@ -755,47 +1501,21 @@
755 1501 }
756 1502
757 1503 public function markdownToHtml(Request $request)
758 1504 {
759 - $message = trim(sanitize_textarea_field($request->get('text', '')));
1505 + $message = CustomSanitizer::unslashMarkdown($request->get('text', ''));
760 1506
761 - $html = FeedsHelper::mdToHtml($message);
1507 + $html = wp_kses_post(FeedsHelper::mdToHtml($message));
762 1508
763 - return [
1509 + $data = [
764 1510 'html' => $html
765 1511 ];
766 - }
767 1512
768 - private function transformFeed(Feed $feed)
769 - {
770 - $userId = $this->getUserId();
771 - if ($userId) {
772 - $feed->has_user_react = $feed->hasUserReact($userId, 'like');
773 - $feed->bookmarked = $feed->hasUserReact($userId, 'bookmark');
1513 + $data['message_rendered'] = $html;
774 1514
775 - $likedIds = FeedsHelper::getLikedIdsByUserFeedId($feed->id, get_current_user_id());
776 - $feed->comments->each(function ($comment) use ($likedIds) {
777 - if ($likedIds && in_array($comment->id, $likedIds)) {
778 - $comment->liked = 1;
779 - }
780 - });
781 -
782 - if ($feed->content_type == 'survey') {
783 - $votedOptions = $feed->getSurveyCastsByUserId($userId);
784 -
785 - if ($votedOptions) {
786 - $surveyConfig = Arr::get($feed->meta, 'survey_config', []);
787 - foreach ($surveyConfig['options'] as $index => $option) {
788 - if (in_array($option['slug'], $votedOptions)) {
789 - $surveyConfig['options'][$index]['voted'] = true;
790 - }
791 - }
792 - $meta = $feed->meta;
793 - $meta['survey_config'] = $surveyConfig;
794 - $feed->meta = $meta;
795 - }
796 - }
1515 + if (in_array('meta', $request->get('with', [])) && $request->get('feed')) {
1516 + [$data,] = FeedsHelper::processFeedMetaData($data, $request->get('feed'));
797 1517 }
798 1518
799 - return $feed;
1519 + return $data;
800 1520 }
801 1521 }