| @@ -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; |
| @@ -15,9 +16,11 @@ | ||
| 15 | 16 | use FluentCommunity\App\Services\RemoteUrlParser; |
| 16 | 17 | use FluentCommunity\Framework\Http\Request\Request; |
| 17 | 18 | use FluentCommunity\App\Models\Feed; |
| 18 | 19 | use FluentCommunity\App\Models\BaseSpace; |
| 20 | +use FluentCommunity\App\Models\XProfile; | |
| 19 | 21 | use FluentCommunity\Framework\Support\Arr; |
| 22 | +use FluentCommunity\Modules\PushNotification\PushNotificationModule; | |
| 20 | 23 | |
| 21 | 24 | class FeedsController extends Controller |
| 22 | 25 | { |
| 23 | 26 | public function get(Request $request) |
| @@ -189,9 +192,9 @@ | ||
| 189 | 192 | 'message' => __('The feed could not be found', 'fluent-community') |
| 190 | 193 | ], 404); |
| 191 | 194 | } |
| 192 | 195 | |
| 193 | - $viewableByLinkStatuses = ['published', 'unlisted']; | |
| 196 | + $viewableByLinkStatuses = FeedsHelper::getViewableByLinkStatuses(); | |
| 194 | 197 | |
| 195 | 198 | if (!in_array($feed->status, $viewableByLinkStatuses, true) && !$feed->hasEditAccess($this->getUserId())) { |
| 196 | 199 | return $this->sendError([ |
| 197 | 200 | 'message' => __('Sorry, you do not have permission to view this post', 'fluent-community') |
| @@ -327,11 +330,9 @@ | ||
| 327 | 330 | |
| 328 | 331 | $spaceId = Arr::get($data, 'space_id'); |
| 329 | 332 | $message = Arr::get($data, 'message'); |
| 330 | 333 | |
| 331 | - if ($isDulicate = $this->checkForDuplicatePost($user->ID, $message, $spaceId)) { | |
| 332 | - return $isDulicate; | |
| 333 | - } | |
| 334 | + $duplicateCheckMessage = $message; | |
| 334 | 335 | |
| 335 | 336 | $mentions = FeedsHelper::getMentions($data['message'], Arr::get($data, 'space_id'), true); |
| 336 | 337 | if ($mentions) { |
| 337 | 338 | $data['message'] = $message; |
| @@ -376,12 +377,28 @@ | ||
| 376 | 377 | ]); |
| 377 | 378 | } |
| 378 | 379 | |
| 379 | 380 | $feed->fill($data); |
| 380 | - $feed->save(); | |
| 381 | 381 | |
| 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(); | |
| 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 | + | |
| 382 | 400 | $feed = Feed::find($feed->id); // just renewing the feed |
| 383 | - /** @var Feed $feed */ | |
| 384 | 401 | |
| 385 | 402 | if ($mentions) { |
| 386 | 403 | do_action('fluent_community/feed_mentioned', $feed, Arr::get($mentions, 'users')); |
| 387 | 404 | } |
| @@ -465,8 +482,12 @@ | ||
| 465 | 482 | } |
| 466 | 483 | |
| 467 | 484 | $user->canEditFeed($existingFeed, true); |
| 468 | 485 | |
| 486 | + // Must resolve before processFeedMetaData() reads it. | |
| 487 | + $isModerator = $user->hasPermissionOrInCurrentSpace('community_moderator', $existingFeed->space); | |
| 488 | + $requestData['is_admin'] = $isModerator; | |
| 489 | + | |
| 469 | 490 | if ($surveyOptionError = FeedsHelper::getSurveyOptionsUpdateError( |
| 470 | 491 | Arr::get($existingFeed->meta, 'survey_config.options', []), |
| 471 | 492 | Arr::get($requestData, 'survey', []) |
| 472 | 493 | )) { |
| @@ -474,9 +495,9 @@ | ||
| 474 | 495 | 'message' => $surveyOptionError |
| 475 | 496 | ]); |
| 476 | 497 | } |
| 477 | 498 | |
| 478 | - if ($status = Arr::get($requestData, 'status')) { | |
| 499 | + if ($isModerator && ($status = Arr::get($requestData, 'status'))) { | |
| 479 | 500 | if (in_array($status, $editableStatuses, true)) { |
| 480 | 501 | $fallbackStatus = $status === 'unlisted' ? $existingFeed->status : $status; |
| 481 | 502 | $data['status'] = apply_filters('fluent_community/feed/save_status', $fallbackStatus, $requestData, $existingFeed); |
| 482 | 503 | } |
| @@ -503,10 +524,8 @@ | ||
| 503 | 524 | if (isset($existingFeed->meta['comments_disabled'])) { |
| 504 | 525 | $data['meta']['comments_disabled'] = $existingFeed->meta['comments_disabled']; |
| 505 | 526 | } |
| 506 | 527 | |
| 507 | - $requestData['is_admin'] = $user->hasPermissionOrInCurrentSpace('community_moderator', $existingFeed->space); | |
| 508 | - | |
| 509 | 528 | if (Arr::get($requestData, 'send_announcement_email') == 'yes' && $requestData['is_admin']) { |
| 510 | 529 | $data['meta']['send_announcement_email'] = 'yes'; |
| 511 | 530 | } else if (Arr::get($existingFeed->meta, 'send_announcement_email')) { |
| 512 | 531 | $data['meta']['send_announcement_email'] = Arr::get($existingFeed->meta, 'send_announcement_email'); |
| @@ -549,8 +568,10 @@ | ||
| 549 | 568 | 'time' => current_time('mysql') |
| 550 | 569 | ]; |
| 551 | 570 | } |
| 552 | 571 | |
| 572 | + $movingToProfile = false; | |
| 573 | + | |
| 553 | 574 | if ($newSpaceId = $request->get('new_space_id')) { |
| 554 | 575 | if (!Helper::isUserInSpace($existingFeed->user_id, $newSpaceId)) { |
| 555 | 576 | return $this->sendError([ |
| 556 | 577 | 'message' => __('The author is not a member of the selected space', 'fluent-community') |
| @@ -577,8 +598,9 @@ | ||
| 577 | 598 | ]); |
| 578 | 599 | } |
| 579 | 600 | |
| 580 | 601 | $data['space_id'] = null; |
| 602 | + $movingToProfile = true; | |
| 581 | 603 | |
| 582 | 604 | \FluentCommunity\App\Models\Activity::where('feed_id', $existingFeed->id) |
| 583 | 605 | ->update(['space_id' => null]); |
| 584 | 606 | } |
| @@ -644,8 +666,11 @@ | ||
| 644 | 666 | if ($space && Arr::get($space->settings, 'topic_required') != 'yes') { |
| 645 | 667 | $existingFeed->terms()->where('taxonomy_name', 'post_topic')->detach(); |
| 646 | 668 | } |
| 647 | 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(); | |
| 648 | 673 | } |
| 649 | 674 | |
| 650 | 675 | if ($dirty) { |
| 651 | 676 | do_action('fluent_community/feed/updated', $existingFeed, $dirty); |
| @@ -700,11 +725,12 @@ | ||
| 700 | 725 | |
| 701 | 726 | if (isset($data['is_sticky'])) { |
| 702 | 727 | $data['is_sticky'] = $data['is_sticky'] ? 1 : 0; |
| 703 | 728 | if ($data['is_sticky'] && $feed->space_id) { |
| 704 | - // remove all the sticky posts from the space | |
| 729 | + // toBase() keeps the type scope but skips the Orm update()'s updated_at stamp, which would bump the post being un-stuck. | |
| 705 | 730 | Feed::where('space_id', $feed->space_id) |
| 706 | 731 | ->where('is_sticky', 1) |
| 732 | + ->toBase() | |
| 707 | 733 | ->update(['is_sticky' => 0]); |
| 708 | 734 | } |
| 709 | 735 | } |
| 710 | 736 | |
| @@ -717,8 +743,13 @@ | ||
| 717 | 743 | if ($data) { |
| 718 | 744 | $feed->fill($data); |
| 719 | 745 | $dirty = $feed->getDirty(); |
| 720 | 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 | + | |
| 721 | 752 | $feed->save(); |
| 722 | 753 | do_action('fluent_community/feed/updated', $feed, $dirty); |
| 723 | 754 | } |
| 724 | 755 | } |
| @@ -925,10 +956,11 @@ | ||
| 925 | 956 | $allowedFileSize = $maxFileSize * 1024 * 1024; |
| 926 | 957 | } |
| 927 | 958 | |
| 928 | 959 | $files = $this->validate($this->request->files(), [ |
| 929 | - 'file' => 'mimetypes:' . $allowedTypes . '|max:' . $allowedFileSize, | |
| 960 | + 'file' => 'required|mimetypes:' . $allowedTypes . '|max:' . $allowedFileSize, | |
| 930 | 961 | ], [ |
| 962 | + 'file.required' => __('No upload file was received. Please try again.', 'fluent-community'), | |
| 931 | 963 | 'file.mimetypes' => __('The file must be an image type.', 'fluent-community'), |
| 932 | 964 | /* translators: %$1s is replaced by the maximum allowed file size, %2$s is replaced by the file size unit (e.g. MB) */ |
| 933 | 965 | 'file.max' => sprintf(__('The file size must be less than %1$s%2$s.', 'fluent-community'), $maxFileSize, $maxFileUnit) |
| 934 | 966 | ]); |
| @@ -944,10 +976,23 @@ | ||
| 944 | 976 | add_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']); |
| 945 | 977 | $uploadedFiles = FileSystem::put($files); |
| 946 | 978 | remove_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']); |
| 947 | 979 | |
| 948 | - $file = $uploadedFiles[0]; | |
| 980 | + $file = Arr::get($uploadedFiles, 0); | |
| 949 | 981 | |
| 982 | + if (is_wp_error($file)) { | |
| 983 | + return $this->sendError([ | |
| 984 | + 'message' => $file->get_error_message() | |
| 985 | + ]); | |
| 986 | + } | |
| 987 | + | |
| 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 | + | |
| 950 | 995 | $upload_dir = wp_upload_dir(); |
| 951 | 996 | |
| 952 | 997 | $originalUrl = $file['url']; |
| 953 | 998 | $orginalPath = $upload_dir['basedir'] . '/fluent-community/' . $file['file']; |
| @@ -1161,8 +1206,10 @@ | ||
| 1161 | 1206 | |
| 1162 | 1207 | // Get notification count |
| 1163 | 1208 | $notificationCount = NotificationSubscriber::unread()->where('user_id', $userId)->count(); |
| 1164 | 1209 | |
| 1210 | + $newNotifications = $this->getToastNotifications($userId, $since, $notificationCount); | |
| 1211 | + | |
| 1165 | 1212 | $response = [ |
| 1166 | 1213 | 'timestamp' => current_time('mysql'), |
| 1167 | 1214 | 'has_changes' => $hasChanges, |
| 1168 | 1215 | 'feeds' => $feedUpdates, |
| @@ -1167,9 +1214,10 @@ | ||
| 1167 | 1214 | 'has_changes' => $hasChanges, |
| 1168 | 1215 | 'feeds' => $feedUpdates, |
| 1169 | 1216 | 'notifications' => [ |
| 1170 | 1217 | 'unread_count' => $notificationCount, |
| 1171 | - 'new_count' => 0 // Could track new since last check | |
| 1218 | + 'new_count' => count($newNotifications), | |
| 1219 | + 'new_items' => $newNotifications | |
| 1172 | 1220 | ], |
| 1173 | 1221 | 'spaces' => [], // For future use |
| 1174 | 1222 | 'execution_time' => microtime(true) - $start |
| 1175 | 1223 | ]; |
| @@ -1176,8 +1224,135 @@ | ||
| 1176 | 1224 | |
| 1177 | 1225 | return apply_filters('fluent_community/feed_ticker', $response, $request->all()); |
| 1178 | 1226 | } |
| 1179 | 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 | + | |
| 1180 | 1355 | public function batchFetch(Request $request) |
| 1181 | 1356 | { |
| 1182 | 1357 | $feedIds = $request->get('feed_ids', []); |
| 1183 | 1358 | |
| @@ -1303,10 +1478,14 @@ | ||
| 1303 | 1478 | } |
| 1304 | 1479 | |
| 1305 | 1480 | public function getOembed(Request $request) |
| 1306 | 1481 | { |
| 1307 | - $url = $request->get('url'); | |
| 1308 | - // 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 | + | |
| 1309 | 1488 | $metaData = RemoteUrlParser::parse($url); |
| 1310 | 1489 | |
| 1311 | 1490 | if ($metaData && !is_wp_error($metaData)) { |
| 1312 | 1491 | $data = [ |