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 +986 -537 1.0.952.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 - $feed = Feed::where('slug', $feed_slug)->with(['space'])->first();
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 [
139 - 'feed' => $feed
174 + $data = [
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,14 +268,13 @@
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 - $userId = get_current_user_id();
232 277 $user = $this->getUser(true);
233 278
234 279 do_action('fluent_community/check_rate_limit/create_post', $user);
235 280
@@ -235,63 +280,139 @@
235 280
236 281 $requestData = $request->all();
237 282
238 283 $data = $this->sanitizeAndValidateData($requestData);
284 + $data['user_id'] = $user->ID;
285 + $data['status'] = 'published';
239 286
240 - if ($isDulicate = $this->checkForDuplicatePost($userId, $data['message'])) {
241 - return $isDulicate;
242 - }
287 + $data['status'] = apply_filters('fluent_community/feed/save_status', $data['status'], $requestData, null);
243 288
244 289 $feed = new Feed();
245 - $feed->user_id = $userId;
290 + $feed->user_id = $user->ID;
291 + $space = null;
246 292
247 293 if ($spaceSlug = $request->get('space')) {
248 294 $data['space_id'] = $this->validateAndSetSpace($spaceSlug, $user);
249 - } else {
250 - // Check if the user has global post permission
251 - if (!Helper::hasGlobalPost()) {
252 - return $this->sendError([
253 - 'message' => __('Please select a valid space to post in', 'fluent-community')
254 - ]);
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 + }
255 302 }
303 +
304 + if ($space && Arr::get($space->settings, 'topic_required') == 'yes') {
305 + $topicIds = (array)$request->get('topic_ids', []);
306 + $spaceTopics = Utility::getTopicsBySpaceId($space->id);
307 + $spaceTopicsIds = [];
308 +
309 + foreach ($spaceTopics as $topic) {
310 + $spaceTopicsIds[] = $topic['id'];
311 + }
312 +
313 + $validTopicIds = array_intersect($topicIds, $spaceTopicsIds);
314 +
315 + if (!$validTopicIds) {
316 + return $this->sendError([
317 + 'message' => __('Please select at least one topic to post in this space.', 'fluent-community'),
318 + 'shakes' => [
319 + 'topic_ids' => true
320 + ]
321 + ]);
322 + }
323 + }
324 +
325 + } else if (!Helper::hasGlobalPost()) {
326 + return $this->sendError([
327 + 'message' => __('Please select a valid space to post in.', 'fluent-community')
328 + ]);
256 329 }
257 330
258 - $message = $data['message'];
331 + $spaceId = Arr::get($data, 'space_id');
332 + $message = Arr::get($data, 'message');
259 333
260 - $mentions = FeedsHelper::getMentions($data['message'], Arr::get($data, 'space_id'));
334 + $duplicateCheckMessage = $message;
261 335
336 + $mentions = FeedsHelper::getMentions($data['message'], Arr::get($data, 'space_id'), true);
262 337 if ($mentions) {
263 338 $data['message'] = $message;
264 339 $message = $mentions['text'];
265 340 }
266 341
342 + [$message, $inlineMedias] = FeedsHelper::replaceImageUrlsWithRealMediaArchive($message);
343 +
267 344 // replace new line with br
268 345 $data['message_rendered'] = wp_kses_post(FeedsHelper::mdToHtml($message));
269 346
270 - $mediaItems = null;
347 + $requestData['is_admin'] = $user->hasPermissionOrInCurrentSpace('community_moderator', $space);
271 348
272 - if (!empty($data['survey'])) {
273 - $this->handleSurveyConfig($data);
274 - } elseif (Arr::get($requestData, 'meta.media_preview.provider') == 'giphy') {
275 - $this->setGiphyMediaPreview($data, $requestData);
276 - } else {
277 - $mediaItems = $this->processNewMedia($requestData, $data);
349 + [$data, $mediaItems] = FeedsHelper::processFeedMetaData($data, $requestData);
350 +
351 + if ($inlineMedias) {
352 + $mediaItems = array_merge($mediaItems, $inlineMedias);
278 353 }
279 354
280 - $data = apply_filters('fluent_community/feed/new_feed_data', $data, $request->all());
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 + }
281 360
361 + if ($mentions) {
362 + $data['meta']['mentioned_user_ids'] = Arr::get($mentions, 'user_ids', []);
363 + }
364 +
365 + $data = apply_filters('fluent_community/feed/new_feed_data', $data, $requestData);
366 +
367 + $formContentType = (string)Arr::get($requestData, 'content_type', '');
368 +
369 + if ($formContentType) {
370 + $data = apply_filters('fluent_community/feed/new_feed_data_type_' . $formContentType, $data, $requestData);
371 + }
372 +
373 + if (is_wp_error($data)) {
374 + return $this->sendError([
375 + 'message' => $data->get_error_message(),
376 + 'errors' => $data->get_error_data()
377 + ]);
378 + }
379 +
282 380 $feed->fill($data);
283 381
284 - $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();
285 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 +
286 410 if ($mediaItems) {
287 411 $this->saveMediaItems($feed, $mediaItems);
288 412 }
289 413
290 - $this->handleMentions($feed, $mentions ?? []);
291 -
292 414 $feed->load(['xprofile', 'comments.xprofile']);
293 -
294 415 if ($feed->space_id) {
295 416 $feed->load(['space']);
296 417 $topicIds = (array)$request->get('topic_ids', []);
297 418 // take only max topics per post
@@ -298,22 +419,51 @@
298 419 if ($topicIds) {
299 420 $topicsConfig = Helper::getTopicsConfig();
300 421 $topicIds = array_slice($topicIds, 0, $topicsConfig['max_topics_per_post']);
301 422 $feed->attachTopics($topicIds, false);
423 + $feed->load(['terms']);
302 424 }
303 425 }
304 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 +
305 451 do_action('fluent_community/feed/created', $feed);
306 452
307 453 if ($feed->space_id) {
308 454 do_action('fluent_community/space_feed/created', $feed);
455 + } else {
456 + do_action('fluent_community/profile_feed/created', $feed);
309 457 }
310 458
311 - return [
312 - 'feed' => $feed,
313 - 'message' => __('Feed added', '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,
314 464 'last_fetched_timestamp' => current_time('timestamp')
315 - ];
465 + ], $feed, $request->all());
316 466 }
317 467
318 468 public function update(Request $request, $feedId)
319 469 {
@@ -318,102 +468,224 @@
318 468 public function update(Request $request, $feedId)
319 469 {
320 470 $requestData = $request->all();
321 471 $data = $this->sanitizeAndValidateData($requestData);
472 + $user = $this->getUser(true);
473 + $existingFeed = Feed::findOrFail($feedId);
474 + /** @var Feed $existingFeed */
322 475
323 - $userId = get_current_user_id();
324 - $user = User::findOrFail($userId);
476 + $editableStatuses = ['published', 'unlisted', 'scheduled', 'pending'];
325 477
326 - $feed = Feed::find($feedId);
478 + if (!in_array($existingFeed->status, $editableStatuses)) {
479 + return $this->sendError([
480 + 'message' => __('Sorry, this post is not in an editable state.', 'fluent-community')
481 + ]);
482 + }
327 483
328 - if (!$feed) {
329 - return $this->sendError(['message' => __('Feed not found', 'fluent-community')]);
330 - }
484 + $user->canEditFeed($existingFeed, true);
331 485
332 - $user->canEditFeed($feed, false);
486 + // Must resolve before processFeedMetaData() reads it.
487 + $isModerator = $user->hasPermissionOrInCurrentSpace('community_moderator', $existingFeed->space);
488 + $requestData['is_admin'] = $isModerator;
333 489
334 - if (!$feed->hasEditAccess($userId)) {
335 - return $this->send('You do not have permission to edit this feed', 403);
490 + if ($surveyOptionError = FeedsHelper::getSurveyOptionsUpdateError(
491 + Arr::get($existingFeed->meta, 'survey_config.options', []),
492 + Arr::get($requestData, 'survey', [])
493 + )) {
494 + return $this->sendError([
495 + 'message' => $surveyOptionError
496 + ]);
336 497 }
337 498
338 - if ($spaceSlug = $request->get('space')) {
339 - $data['space_id'] = $this->validateAndSetSpace($spaceSlug, $user);
499 + if ($isModerator && ($status = Arr::get($requestData, 'status'))) {
500 + if (in_array($status, $editableStatuses, true)) {
501 + $fallbackStatus = $status === 'unlisted' ? $existingFeed->status : $status;
502 + $data['status'] = apply_filters('fluent_community/feed/save_status', $fallbackStatus, $requestData, $existingFeed);
503 + }
340 504 }
341 505
342 506 $message = $data['message'];
343 -
344 507 $mentions = FeedsHelper::getMentions($data['message'], Arr::get($data, 'space_id'));
345 -
346 508 if ($mentions) {
347 509 $data['message'] = $message;
348 510 $message = $mentions['text'];
349 511 }
350 512
513 + [$message, $inlineMedias] = FeedsHelper::replaceImageUrlsWithRealMediaArchive($message, $existingFeed);
514 +
351 515 // replace new line with br
352 516 $data['message_rendered'] = wp_kses_post(FeedsHelper::mdToHtml($message));
353 - $mediaItems = null;
354 517
355 - if (!empty($data['survey'])) {
356 - $this->handleSurveyConfig($data);
357 - } elseif (Arr::get($requestData, 'meta.media_preview.provider') == 'giphy') {
358 - $this->setGiphyMediaPreview($data, $requestData);
359 - } else {
360 - $mediaItems = $this->processExistingMedia($feed, $requestData, $data);
518 + [$data, $mediaItems] = FeedsHelper::processFeedMetaData($data, $requestData, $existingFeed);
519 +
520 + if($inlineMedias) {
521 + $mediaItems = array_merge($mediaItems, $inlineMedias);
361 522 }
362 523
363 - if ($message != $feed->message) {
364 - $meta = $feed->meta;
365 - $meta['last_edited'] = [
366 - 'user_id' => $userId,
524 + if (isset($existingFeed->meta['comments_disabled'])) {
525 + $data['meta']['comments_disabled'] = $existingFeed->meta['comments_disabled'];
526 + }
527 +
528 + if (Arr::get($requestData, 'send_announcement_email') == 'yes' && $requestData['is_admin']) {
529 + $data['meta']['send_announcement_email'] = 'yes';
530 + } else if (Arr::get($existingFeed->meta, 'send_announcement_email')) {
531 + $data['meta']['send_announcement_email'] = Arr::get($existingFeed->meta, 'send_announcement_email');
532 + }
533 +
534 + $data = apply_filters('fluent_community/feed/update_feed_data', $data, $requestData);
535 +
536 + if (is_wp_error($data)) {
537 + return $this->sendError([
538 + 'message' => $data->get_error_message(),
539 + 'errors' => $data->get_error_data()
540 + ]);
541 + }
542 +
543 + $newContentType = Arr::get($requestData, 'content_type', '');
544 + $existingContentType = $existingFeed->content_type;
545 +
546 + if (($newContentType === 'document' && empty($requestData['document_ids'])) || ($newContentType === '' && $existingContentType === 'document' && empty($requestData['survey']))) {
547 + $newContentType = $data['content_type'] = 'text';
548 + }
549 +
550 + if ($newContentType != $existingContentType) {
551 + // Content Type Changed
552 + do_action('fluent_community/feed/updating_content_type_old_' . $existingContentType, $existingFeed, $newContentType, $requestData);
553 + }
554 +
555 + if ($newContentType != 'text') {
556 + $data = apply_filters('fluent_community/feed/update_feed_data_type_' . $newContentType, $data, $requestData, $existingFeed);
557 + if (is_wp_error($data)) {
558 + return $this->sendError([
559 + 'message' => $data->get_error_message(),
560 + 'errors' => $data->get_error_data()
561 + ]);
562 + }
563 + }
564 +
565 + if ($message != $existingFeed->message) {
566 + $data['meta']['last_edited'] = [
567 + 'user_id' => $user->ID,
367 568 'time' => current_time('mysql')
368 569 ];
570 + }
369 571
370 - $editHistory = $feed->getCustomMeta('_edit_history', []);
572 + $movingToProfile = false;
371 573
574 + if ($newSpaceId = $request->get('new_space_id')) {
575 + if (!Helper::isUserInSpace($existingFeed->user_id, $newSpaceId)) {
576 + return $this->sendError([
577 + 'message' => __('The author is not a member of the selected space', 'fluent-community')
578 + ]);
579 + }
580 +
581 + $newSpace = Space::findOrFail($newSpaceId);
582 +
583 + // check if the current user is admin
584 + if (!$user->hasPermissionOrInCurrentSpace('community_admin', $newSpace)) {
585 + return $this->sendError([
586 + 'message' => __('Sorry, you do not have permission to change the space for this post', 'fluent-community')
587 + ]);
588 + }
589 +
590 + $data['space_id'] = $newSpaceId;
591 +
592 + \FluentCommunity\App\Models\Activity::where('feed_id', $existingFeed->id)
593 + ->update(['space_id' => $newSpaceId]);
594 + } else if ($request->get('move_to_profile')) {
595 + if (!$user->hasPermissionOrInCurrentSpace('community_admin', $existingFeed->space)) {
596 + return $this->sendError([
597 + 'message' => __('Sorry, you do not have permission to move this post to a profile', 'fluent-community')
598 + ]);
599 + }
600 +
601 + $data['space_id'] = null;
602 + $movingToProfile = true;
603 +
604 + \FluentCommunity\App\Models\Activity::where('feed_id', $existingFeed->id)
605 + ->update(['space_id' => null]);
606 + }
607 +
608 + $data = apply_filters('fluent_community/feed/update_data', $data, $existingFeed);
609 + $existingFeed->fill($data);
610 + $dirty = $existingFeed->getDirty();
611 +
612 + $existingFeed->fill($data);
613 + $existingFeed->save();
614 +
615 + if ($message != $existingFeed->message) {
616 + $editHistory = $existingFeed->getCustomMeta('_edit_history', []);
372 617 if (!$editHistory) {
373 618 $editHistory = [];
374 619 }
375 620
376 621 $editHistory[] = array_filter([
377 - 'user_id' => $userId,
622 + 'user_id' => $user->ID,
378 623 'time' => current_time('mysql'),
379 - 'prev_message' => $feed->message,
380 - 'prev_title' => $feed->title
624 + 'prev_message' => $existingFeed->message,
625 + 'prev_title' => $existingFeed->title
381 626 ]);
382 627
383 628 // get last 5 edit history
384 629 $editHistory = array_slice($editHistory, -5);
385 - $feed->updateCustomMeta('_edit_history', $editHistory);
386 - $data['meta'] = $meta;
630 + $existingFeed->updateCustomMeta('_edit_history', $editHistory);
387 631 }
388 632
389 - $data = apply_filters('fluent_community/feed/update_data', $data, $feed);
390 - $feed->fill($data);
391 - $dirty = $feed->getDirty();
633 + $mediaItemIds = [];
634 + foreach ($mediaItems as $mediaItem) {
635 + $mediaItemIds[] = $mediaItem->id;
636 + }
392 637
393 - if ($dirty) {
394 - $feed->save();
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]);
395 648 }
396 649
397 650 if ($mediaItems) {
398 - $this->saveMediaItems($feed, $mediaItems);
651 + $this->saveMediaItems($existingFeed, $mediaItems);
399 652 }
400 653
654 + $existingFeed->load(['xprofile', 'comments.xprofile']);
401 655
402 - $feed->load(['xprofile', 'comments.xprofile']);
403 -
404 - if ($feed->space_id) {
405 - $feed->load(['space']);
656 + if ($existingFeed->space_id) {
657 + $existingFeed->load(['space']);
658 + $space = $existingFeed->space;
659 + $topicIds = (array)Arr::get($requestData, 'topic_ids', []);
660 + $topicsConfig = Helper::getTopicsConfig();
661 + // take only max topics per post
662 + if ($topicIds) {
663 + $topicIds = array_slice($topicIds, 0, $topicsConfig['max_topics_per_post']);
664 + $existingFeed->attachTopics($topicIds, true);
665 + } else {
666 + if ($space && Arr::get($space->settings, 'topic_required') != 'yes') {
667 + $existingFeed->terms()->where('taxonomy_name', 'post_topic')->detach();
668 + }
669 + }
670 + } else if ($movingToProfile) {
671 + // Topics are space-scoped; a post moved to the profile must not keep them.
672 + $existingFeed->terms()->where('taxonomy_name', 'post_topic')->detach();
406 673 }
407 674
408 675 if ($dirty) {
409 - do_action('fluent_community/feed/updated', $feed, $dirty);
676 + do_action('fluent_community/feed/updated', $existingFeed, $dirty);
677 + if ($existingFeed->space_id) {
678 + do_action('fluent_community/space_feed/updated', $existingFeed);
679 + }
410 680 }
411 681
412 - return [
413 - 'feed' => $feed,
414 - 'message' => __('Feed updated', 'fluent-community')
682 + $data = [
683 + 'feed' => FeedsHelper::transformFeed($existingFeed),
684 + 'message' => __('Your post has been updated', 'fluent-community')
415 685 ];
686 +
687 + return apply_filters('fluent_community/feed/update_feed_response', $data, $request->all());
416 688 }
417 689
418 690 public function patchFeed(Request $request, $feedId)
419 691 {
@@ -419,12 +691,13 @@
419 691 {
420 692 $feed = Feed::findOrFail($feedId);
421 693 $user = $this->getUser(true);
422 694
423 - $isMod = $user->isCommunityModerator();
424 695 $isAuthor = $feed->user_id == $user->ID;
696 + $isMod = $user->hasPermissionOrInCurrentSpace('community_moderator', $feed->space);
697 + $isAdmin = $user->hasPermissionOrInCurrentSpace('community_admin', $feed->space);
425 698
426 - if (!$isMod && !$isAuthor) {
699 + if (!$isMod && !$isAuthor && !$isAdmin) {
427 700 return $this->sendError([
428 701 'message' => __('You do not have permission to perform this action', 'fluent-community')
429 702 ]);
430 703 }
@@ -439,13 +712,26 @@
439 712 $data = Arr::only($allData, $validKeys);
440 713
441 714 $data = array_map('intval', $data);
442 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 +
443 726 if (isset($data['is_sticky'])) {
444 727 $data['is_sticky'] = $data['is_sticky'] ? 1 : 0;
445 728 if ($data['is_sticky'] && $feed->space_id) {
446 - // remove all the sticky posts from the space
447 - 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]);
448 734 }
449 735 }
450 736
451 737 if (isset($data['comments_disabled'])) {
@@ -453,29 +739,56 @@
453 739 $meta['comments_disabled'] = $data['comments_disabled'] ? 'yes' : 'no';
454 740 $data['meta'] = $meta;
455 741 }
456 742
457 -
458 743 if ($data) {
459 744 $feed->fill($data);
460 745 $dirty = $feed->getDirty();
461 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 +
462 752 $feed->save();
463 753 do_action('fluent_community/feed/updated', $feed, $dirty);
464 754 }
465 755 }
466 756
467 - return [
757 + return apply_filters('fluent_community/feed/patch_feed_response', [
468 758 'feed' => $feed,
469 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)
470 769 ];
770 +
771 + return apply_filters('fluent_community/welcome_banner_api_response', $data, $request->all());
471 772 }
472 773
473 774 public function getLinks(Request $request)
474 775 {
475 - 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 = [
476 787 'links' => Helper::getFeedLinks()
477 788 ];
789 +
790 + return apply_filters('fluent_community/feed_links_api_response', $data, $request->all());
478 791 }
479 792
480 793 public function updateLinks(Request $request)
481 794 {
@@ -487,245 +800,13 @@
487 800
488 801 Helper::updateFeedLinks($links);
489 802
490 803 return [
491 - 'message' => __('Links has been updated', 'fluent-community'),
804 + 'message' => __('Links have been updated.', 'fluent-community'),
492 805 'links' => $links
493 806 ];
494 807 }
495 808
496 - private function setGiphyMediaPreview(&$data, $requestData)
497 - {
498 - if (empty(Arr::get($requestData, 'meta.media_preview.image'))) {
499 - return;
500 - }
501 -
502 - $data['meta']['media_preview'] = array_filter([
503 - 'image' => sanitize_url($requestData['meta']['media_preview']['image']),
504 - 'type' => Arr::get($requestData, 'meta.media_preview.type', 'image'),
505 - 'provider' => Arr::get($requestData, 'meta.media_preview.provider', ''),
506 - 'height' => Arr::get($requestData, 'meta.media_preview.height', 0),
507 - 'width' => Arr::get($requestData, 'meta.media_preview.width', 0),
508 - ]);
509 - }
510 -
511 - private function handleSurveyConfig(&$data)
512 - {
513 - if (empty($data['meta'])) {
514 - $data['meta'] = [];
515 - }
516 -
517 - $data['meta']['survey_config'] = $data['survey'];
518 - $data['content_type'] = 'survey';
519 - }
520 -
521 - private function processNewMedia($requestData, &$data)
522 - {
523 - if ($mediaImages = Arr::get($requestData, 'media_images')) {
524 - $uploadedImages = Helper::getMediaByProvider($mediaImages);
525 - $uploadedMediaItems = Helper::getMediaItemsFromUrl($uploadedImages);
526 - $mediaPreviews = $this->generateMediaPreviews($uploadedMediaItems);
527 - $this->formatMediaMeta($mediaPreviews, $data, $mediaImages);
528 - return $uploadedMediaItems;
529 - }
530 -
531 - if ($media = Arr::get($requestData, 'media')) {
532 - $type = Arr::get($media, 'type', 'oembed');
533 - if ($type == 'oembed') {
534 - $url = Arr::get($media, 'url');
535 - $metaData = RemoteUrlParser::parse($url);
536 - if ($metaData && !is_wp_error($metaData) && (!empty($metaData['image']) || !empty($metaData['html']))) {
537 - $data['meta']['media_preview'] = $metaData;
538 - return [];
539 - }
540 - }
541 - }
542 -
543 - $urlMeta = $this->parseFirstUrl($data['message_rendered']);
544 -
545 - if ($urlMeta) {
546 - $data['meta'] = $urlMeta;
547 - return [];
548 - }
549 -
550 - // Let's give option to the user to check if there is any fallback
551 - do_action_ref_array('fluent_community/feed/meta_fallback', [&$data]);
552 -
553 - return [];
554 - }
555 -
556 - private function processExistingMedia($feed, $requestData, &$data)
557 - {
558 - $images = (array)Arr::get($requestData, 'media_images', []);
559 - $mediaImages = Helper::getMediaByProvider($images);
560 - $metaMediaMetaItems = Helper::getMediaByProvider((array)Arr::get($requestData, 'meta.media_items', []));
561 - $metaMediaPreview = array_filter((array)Arr::get($requestData, 'meta.media_preview', []));
562 - $requestMediaIds = array_column($metaMediaMetaItems, 'media_id');
563 -
564 - if (count($mediaImages) == 0 && count($metaMediaMetaItems) == 0) {
565 - if (count($metaMediaPreview) === 0) {
566 - do_action('fluent_community/feed/media_deleted', $feed->media);
567 - $data['meta']['media_preview'] = null;
568 - }
569 -
570 - $previewMeta = $this->parseFirstUrl($data['message_rendered']);
571 - if (count($metaMediaPreview) > 0) {
572 - $data['meta']['media_preview'] = $metaMediaPreview;
573 - } elseif (count($previewMeta) > 0) {
574 - $data['meta'] = $previewMeta;
575 - }
576 -
577 - return [];
578 - }
579 -
580 - if (count($mediaImages) == 1 && (count($metaMediaMetaItems) == 0 || count($metaMediaPreview) > 0)) {
581 - if (Arr::get($metaMediaPreview, 'is_uploaded')) {
582 - $mediaImages[] = $metaMediaPreview['image'] . '?media_key=' . $feed->media[0]->media_key;
583 - unset($data['meta']['media_preview']);
584 - } elseif (count($metaMediaPreview) > 0) {
585 - do_action('fluent_community/feed/media_deleted', $feed->media);
586 - }
587 -
588 - $mediaItems = Helper::getMediaItemsFromUrl($mediaImages);
589 - $mediaPreviews = $this->generateMediaPreviews($mediaItems);
590 -
591 - $this->formatMediaMeta($mediaPreviews, $data, $images);
592 -
593 - return $mediaItems;
594 - }
595 -
596 - $message = $data['message'];
597 -
598 - if ($mentions = FeedsHelper::getMentions($message, Arr::get($data, 'space_id'))) {
599 - $message = $mentions['text'];
600 - }
601 -
602 - $data['message_rendered'] = FeedsHelper::mdToHtml($message);
603 -
604 - if (count($mediaImages) > 1) {
605 - $mediaItems = $this->processNewMedia($requestData, $data);
606 - }
607 -
608 - $deletedMediaItems = $feed->media()->whereNotIn('id', $requestMediaIds)->get();
609 - do_action('fluent_community/feed/media_deleted', $deletedMediaItems);
610 -
611 -
612 - if (!isset($data['meta']['media_items'])) {
613 - $data['meta']['media_items'] = [];
614 - }
615 -
616 - if (!isset($data['meta']['media_preview'])) {
617 - $data['meta']['media_preview'] = null;
618 - }
619 -
620 - if ($metaMediaMetaItems) {
621 - $filteredData = array_filter($metaMediaMetaItems, function ($item) use ($requestMediaIds) {
622 - return in_array($item['media_id'], $requestMediaIds);
623 - });
624 -
625 - if (count($mediaImages) == 1 && count($filteredData) > 0) {
626 - $mediaItems = Helper::getMediaItemsFromUrl($mediaImages);
627 - $newMediaItems = $this->generateMediaPreviews($mediaItems);
628 - $filteredData = array_merge($filteredData, $newMediaItems);
629 - }
630 -
631 - $data['meta']['media_items'] = array_merge($filteredData, $data['meta']['media_items']);
632 - }
633 -
634 - if (isset($feed->meta['media_preview'])) {
635 - if (count($mediaImages) > 1) {
636 - $data['meta']['media_preview'] = null;
637 - }
638 - }
639 -
640 - return $mediaItems ?? [];
641 - }
642 -
643 - private function generateMediaPreviews($mediaItems)
644 - {
645 - $mediaPreviews = [];
646 - foreach ($mediaItems as $media) {
647 - if (!$media || !$media->is_active) {
648 - $this->sendError(['message' => 'Invalid media image. Please upload a new one.']);
649 - }
650 -
651 - $data = [
652 - 'media_id' => $media->id,
653 - 'url' => $media->public_url,
654 - 'type' => 'image',
655 - 'width' => Arr::get($media->settings, 'width'),
656 - 'height' => Arr::get($media->settings, 'height'),
657 - 'provider' => Arr::get($media->settings, 'provider', 'uploader')
658 - ];
659 -
660 - $mediaPreviews[] = array_filter($data);
661 - }
662 -
663 - return $mediaPreviews;
664 - }
665 -
666 - private function formatMediaMeta($mediaPreviews, &$data, $mediaImages)
667 - {
668 - $giphyImages = Helper::getMediaByProvider($mediaImages, 'giphy');
669 - $metaMediaItems = Helper::getMediaByProvider($this->request->get('meta.media_items', []), 'giphy');
670 -
671 - if (count($mediaPreviews) === 1 && empty($giphyImages) && empty($metaMediaItems)) {
672 - $mediaPreview = array_filter([
673 - 'is_uploaded' => true,
674 - 'image' => $mediaPreviews[0]['url'],
675 - 'type' => 'meta_data',
676 - 'width' => Arr::get($mediaPreviews[0], 'width'),
677 - 'height' => Arr::get($mediaPreviews[0], 'height')
678 - ]);
679 -
680 - $data['meta']['media_preview'] = $mediaPreview;
681 - } elseif ($mediaPreviews) {
682 - $data['meta']['media_items'] = $mediaPreviews;
683 - }
684 - }
685 -
686 - private function processGiphyImages($requestData, &$data)
687 - {
688 - if (!isset($data['meta']['media_items'])) {
689 - $data['meta']['media_items'] = null;
690 - }
691 -
692 - if ($metaMediaItems = Arr::get($requestData, 'meta.media_items', [])) {
693 - $giphyMediaItems = Helper::getMediaByProvider($metaMediaItems, 'giphy');
694 -
695 - if ($giphyMediaItems) {
696 - $data['meta']['media_items'] = array_merge($giphyMediaItems, (array)$data['meta']['media_items']);
697 - }
698 - }
699 -
700 - if ($giphyImages = Helper::getMediaByProvider(Arr::get($requestData, 'media_images', []), 'giphy')) {
701 -
702 - foreach ($giphyImages as $giphy) {
703 - $data['meta']['media_items'][] = [
704 - 'url' => $giphy['url'],
705 - 'type' => 'image',
706 - 'provider' => 'giphy'
707 - ];
708 - }
709 - }
710 - }
711 -
712 - private function parseFirstUrl($messageRendered)
713 - {
714 - $firstUrl = FeedsHelper::findFirstUrl($messageRendered);
715 -
716 - if ($firstUrl) {
717 - $metaData = RemoteUrlParser::parse($firstUrl);
718 - if ($metaData && !is_wp_error($metaData) && (!empty($metaData['image']) || !empty($metaData['html']))) {
719 - return [
720 - 'media_preview' => $metaData
721 - ];
722 - }
723 - }
724 -
725 - return [];
726 - }
727 -
728 809 private function saveMediaItems($feed, $mediaItems)
729 810 {
730 811 foreach ($mediaItems as $media) {
731 812 $media->feed_id = $feed->id;
@@ -734,42 +815,39 @@
734 815 $media->save();
735 816 }
736 817 }
737 818
738 - private function handleMentions($feed, $mentions)
739 - {
740 - if ($mentions) {
741 - do_action('fluent_community/feed_mentioned', $feed, $mentions['users']);
742 - }
743 - }
744 -
745 819 private function sanitizeAndValidateData($data)
746 820 {
747 - if (empty($data['type'])) {
748 - $data['type'] = 'text';
749 - } else {
750 - $data['type'] = sanitize_text_field($data['type']);
751 - }
821 + $data['type'] = 'text';
752 822
753 823 $this->validate($data, [
754 - 'message' => 'required',
755 - 'type' => 'required'
824 + 'message' => 'required'
825 + ], [
826 + 'message.required' => __('Message is required', 'fluent-community'),
756 827 ]);
757 828
758 829 return FeedsHelper::sanitizeAndValidateData($data);
759 830 }
760 831
761 - private function checkForDuplicatePost($userId, $message)
832 + private function checkForDuplicatePost($userId, $message, $spaceId = null)
762 833 {
834 + if (apply_filters('fluent_community/disable_duplicate_post_check', false, $userId, $spaceId)) {
835 + return false;
836 + }
837 +
763 838 $message = trim($message);
764 839
765 840 $exist = Feed::where('user_id', $userId)
766 841 ->where('message', $message)
767 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 + })
768 846 ->first();
769 847
770 848 if ($exist) {
771 - return $this->sendError(['message' => 'No duplicate post please!']);
849 + return $this->sendError(['message' => __('No duplicate post please!', 'fluent-community')]);
772 850 }
773 851
774 852 return false;
775 853 }
@@ -777,9 +855,9 @@
777 855 private function validateAndSetSpace($spaceSlug, $user)
778 856 {
779 857 if ($spaceSlug == '__self__post__') {
780 858 if (!Helper::hasGlobalPost()) {
781 - 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'));
782 860 }
783 861
784 862 return null;
785 863 }
@@ -786,9 +864,9 @@
786 864
787 865 $space = Space::where('slug', $spaceSlug)->first();
788 866
789 867 if (!$space) {
790 - 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'));
791 869 }
792 870
793 871 $user->verifySpacePermission('can_create_post', $space);
794 872
@@ -806,9 +884,9 @@
806 884
807 885 do_action('fluent_community/feed/deleted', $feed_id);
808 886
809 887 return [
810 - 'message' => 'Feed has been deleted successfully'
888 + 'message' => __('Feed has been deleted successfully', 'fluent-community')
811 889 ];
812 890 }
813 891
814 892 public function deleteMediaPreview(Request $request, $feed_id)
@@ -813,16 +891,14 @@
813 891
814 892 public function deleteMediaPreview(Request $request, $feed_id)
815 893 {
816 894 $feed = Feed::findOrFail($feed_id);
817 -
818 895 $user = User::find(get_current_user_id());
819 896 $user->canDeleteFeed($feed, true);
820 897
821 - do_action('fluent_community/feed/media_deleted', $feed->media);
898 + //do_action('fluent_community/feed/media_deleted', $feed->media);
822 899
823 900 $meta = $feed->meta;
824 -
825 901 $meta['media_preview'] = null;
826 902
827 903 $feed->meta = $meta;
828 904 $feed->save();
@@ -827,58 +903,134 @@
827 903 $feed->meta = $meta;
828 904 $feed->save();
829 905
830 906 return [
831 - 'message' => __('Media preview has been removed successfully', 'fluent-community')
907 + 'message' => __('Media preview image has been removed successfully.', 'fluent-community')
832 908 ];
833 909 }
834 910
835 911 public function handleMediaUpload(Request $request)
836 912 {
837 - $allowedTypes = implode(
838 - ',',
839 - apply_filters('fluent_community/support_attachment_types', [
840 - 'image/jpeg',
841 - 'image/pjpeg',
842 - 'image/jpeg',
843 - 'image/pjpeg',
844 - 'image/png',
845 - 'image/gif',
846 - 'image/webp'
847 - ])
848 - );
913 + if ($error = Helper::checkUploadSizeError()) {
914 + return $this->sendError($error, 413);
915 + }
849 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 +
850 959 $files = $this->validate($this->request->files(), [
851 - 'file' => 'mimetypes:' . $allowedTypes,
852 - // 'source' => 'required|in:feed,avatar,comment,cover,space'
960 + 'file' => 'required|mimetypes:' . $allowedTypes . '|max:' . $allowedFileSize,
853 961 ], [
854 - 'file.mimetypes' => __('The file must be a 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)
855 966 ]);
856 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']);
857 977 $uploadedFiles = FileSystem::put($files);
978 + remove_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']);
858 979
859 - $file = $uploadedFiles[0];
980 + $file = Arr::get($uploadedFiles, 0);
860 981
861 - $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 + }
862 987
863 - 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) {
864 1015 $upload_dir = wp_upload_dir();
865 1016 $fileUrl = str_replace($upload_dir['baseurl'], $upload_dir['basedir'], $file['url']);
1017 +
866 1018 $editor = wp_get_image_editor($fileUrl);
1019 +
867 1020 if (!is_wp_error($editor) && $editor->get_size()['width'] > $maxWidth) {
868 1021 // Current file extension
869 1022 $ext = pathinfo($file['url'], PATHINFO_EXTENSION);
870 - $imageExtensions = ['jpg', 'jpeg', 'png', 'gif'];
1023 + $willConvert = in_array($ext, $convertibleExtensions) && $willWebPConvert;
871 1024
872 - $willConvert = in_array($ext, $imageExtensions) && $willWebPConvert;
873 -
874 1025 if ($willConvert) {
875 - $imageExtensions = array_map(function ($ext) {
1026 + $dottedExtensions = array_map(function ($ext) {
876 1027 return '.' . $ext;
877 - }, $imageExtensions);
878 - $fileUrl = str_replace($imageExtensions, '.webp', $fileUrl);
879 - $file['file'] = str_replace($imageExtensions, '.webp', $file['file']);
880 - $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']);
881 1033 $file['type'] = 'image/webp';
882 1034 }
883 1035
884 1036 // resize the image
@@ -884,16 +1036,24 @@
884 1036 // resize the image
885 1037 $editor->resize($maxWidth, null, false);
886 1038 $editor->set_quality(90);
887 1039 if ($willConvert) {
888 - $editor->save($fileUrl, 'image/webp');
889 - // remove original file now
890 - 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 + }
891 1045 $file['is_converted'] = true;
892 1046 } else {
893 - $editor->save($fileUrl);
1047 + $result = $editor->save($fileUrl);
894 1048 }
895 1049
1050 + if ($result['mime-type'] != 'image/webp') {
1051 + $file['file'] = $originalFileName;
1052 + $file['url'] = $originalUrl;
1053 + $file['type'] = $result['mime-type'];
1054 + }
1055 +
896 1056 $file['meta'] = [
897 1057 'width' => $editor->get_size()['width'],
898 1058 'height' => $editor->get_size()['height']
899 1059 ];
@@ -907,20 +1067,25 @@
907 1067 if ($willWebPConvert && empty($file['is_converted']) && !$request->get('skip_convert')) {
908 1068 $path = $file['path'];
909 1069 $extension = pathinfo($path, PATHINFO_EXTENSION);
910 1070
911 - $convertFromExtensions = ['png', 'jpg', 'jpeg', 'gif'];
912 - if ($extension != 'webp' && in_array($extension, $convertFromExtensions)) {
1071 + if ($extension != 'webp' && in_array($extension, $convertibleExtensions)) {
913 1072 // Let's convert to webp
914 1073 $editor = wp_get_image_editor($file['path']);
915 1074 if (!is_wp_error($editor)) {
916 - $orginalPath = $file['path'];
917 1075 $file['path'] = str_replace('.' . $extension, '.webp', $file['path']);
918 1076 $file['url'] = str_replace('.' . $extension, '.webp', $file['url']);
919 1077 $file['type'] = 'image/webp';
920 - $editor->save($file['path'], 'image/webp');
921 - wp_delete_file($orginalPath);
1078 + $result = $editor->save($file['path'], 'image/webp');
922 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 +
923 1088 $file['meta'] = [
924 1089 'width' => $editor->get_size()['width'],
925 1090 'height' => $editor->get_size()['height']
926 1091 ];
@@ -946,9 +1111,9 @@
946 1111 }
947 1112
948 1113 if (!$mediaData) {
949 1114 return $this->sendError([
950 - 'message' => 'Error while uploading the media'
1115 + 'message' => __('Error while uploading the media', 'fluent-community')
951 1116 ]);
952 1117 }
953 1118
954 1119 // Let's create the media now
@@ -972,56 +1137,366 @@
972 1137 }
973 1138
974 1139 public function getTicker(Request $request)
975 1140 {
976 - do_action('fluent_communit/track_activity');
977 - $lastLoadedTimeStamp = $request->get('last_fetched_timestamp');
1141 + $start = microtime(true);
978 1142
979 - //check if $lastLoadedTimeStamp is valid date
980 - if (!$lastLoadedTimeStamp || (current_time('timestamp') - $lastLoadedTimeStamp) > HOUR_IN_SECONDS) {
1143 + $userId = get_current_user_id();
1144 + if (!$userId) {
981 1145 return [
982 - 'last_fetched_timestamp' => current_time('timestamp'),
983 - 'error' => 'Invalid timestamp',
984 - 'given' => $lastLoadedTimeStamp
1146 + 'timestamp' => current_time('mysql', true),
1147 + 'has_changes' => false,
1148 + 'error' => __('User not authenticated', 'fluent-community'),
1149 + 'feeds' => []
985 1150 ];
986 1151 }
987 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 +
988 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();
989 1396 if (!$userId) {
990 1397 return [
991 - 'last_fetched_timestamp' => current_time('timestamp'),
992 - 'error' => 'Invalid user'
1398 + 'updates' => [],
1399 + 'timestamp' => current_time('mysql', true),
1400 + 'has_changes' => false,
1401 + 'error' => __('User not authenticated', 'fluent-community')
993 1402 ];
994 1403 }
995 1404
996 - $newItemsCount = Feed::where('created_at', '>', date('Y-m-d H:i:s', $lastLoadedTimeStamp))
997 - ->where('status', 'published')
998 - ->byUserAccess(get_current_user_id())
999 - ->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 + }
1000 1416
1001 - $notificationCount = NotificationSubscriber::unread()->where('user_id', $userId)->count();
1417 + // Build query based on context
1418 + $query = Feed::query();
1002 1419
1003 - return apply_filters('fluent_community/feed_ticker', [
1004 - 'last_fetched_timestamp' => current_time('timestamp'),
1005 - 'new_items_count' => $newItemsCount > 10 ? 10 : $newItemsCount,
1006 - 'unread_notification_count' => $notificationCount
1007 - ]);
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 + ];
1008 1478 }
1009 1479
1010 1480 public function getOembed(Request $request)
1011 1481 {
1012 - $url = $request->get('url');
1013 - // 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 +
1014 1488 $metaData = RemoteUrlParser::parse($url);
1015 1489
1016 1490 if ($metaData && !is_wp_error($metaData)) {
1017 - return [
1491 + $data = [
1018 1492 'oembed' => $metaData
1019 1493 ];
1494 + return apply_filters('fluent_community/feed_oembed_api_response', $data, $request->all());
1020 1495 }
1021 1496
1022 - return $this->send([
1023 - 'message' => 'No oembed data found',
1497 + return $this->sendError([
1498 + 'message' => __('No oembed data found', 'fluent-community'),
1024 1499 'url' => $url
1025 1500 ]);
1026 1501 }
1027 1502
@@ -1026,47 +1501,21 @@
1026 1501 }
1027 1502
1028 1503 public function markdownToHtml(Request $request)
1029 1504 {
1030 - $message = trim(sanitize_textarea_field($request->get('text', '')));
1505 + $message = CustomSanitizer::unslashMarkdown($request->get('text', ''));
1031 1506
1032 - $html = FeedsHelper::mdToHtml($message);
1507 + $html = wp_kses_post(FeedsHelper::mdToHtml($message));
1033 1508
1034 - return [
1509 + $data = [
1035 1510 'html' => $html
1036 1511 ];
1037 - }
1038 1512
1039 - private function transformFeed(Feed $feed)
1040 - {
1041 - $userId = $this->getUserId();
1042 - if ($userId) {
1043 - $feed->has_user_react = $feed->hasUserReact($userId, 'like');
1044 - $feed->bookmarked = $feed->hasUserReact($userId, 'bookmark');
1513 + $data['message_rendered'] = $html;
1045 1514
1046 - $likedIds = FeedsHelper::getLikedIdsByUserFeedId($feed->id, get_current_user_id());
1047 - $feed->comments->each(function ($comment) use ($likedIds) {
1048 - if ($likedIds && in_array($comment->id, $likedIds)) {
1049 - $comment->liked = 1;
1050 - }
1051 - });
1052 -
1053 - if ($feed->content_type == 'survey') {
1054 - $votedOptions = $feed->getSurveyCastsByUserId($userId);
1055 -
1056 - if ($votedOptions) {
1057 - $surveyConfig = Arr::get($feed->meta, 'survey_config', []);
1058 - foreach ($surveyConfig['options'] as $index => $option) {
1059 - if (in_array($option['slug'], $votedOptions)) {
1060 - $surveyConfig['options'][$index]['voted'] = true;
1061 - }
1062 - }
1063 - $meta = $feed->meta;
1064 - $meta['survey_config'] = $surveyConfig;
1065 - $feed->meta = $meta;
1066 - }
1067 - }
1515 + if (in_array('meta', $request->get('with', [])) && $request->get('feed')) {
1516 + [$data,] = FeedsHelper::processFeedMetaData($data, $request->get('feed'));
1068 1517 }
1069 1518
1070 - return $feed;
1519 + return $data;
1071 1520 }
1072 1521 }