'POST', 'callback' => [__CLASS__, 'submit_beep'], 'permission_callback' => ['Beep_Likes', 'can_interact'], ]); register_rest_route('beep/v1', '/reply/(?P\d+)', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'submit_reply'], 'permission_callback' => ['Beep_Likes', 'can_interact'], ]); register_rest_route('beep/v1', '/thread/(?P\d+)', [ 'methods' => 'GET', 'callback' => [__CLASS__, 'get_thread_api'], 'permission_callback' => '__return_true', ]); register_rest_route('beep/v1', '/delete/(?P\d+)', [ 'methods' => 'DELETE', 'callback' => [__CLASS__, 'delete_beep'], 'permission_callback' => ['Beep_Likes', 'can_interact'], ]); register_rest_route('beep/v1', '/delete-reply/(?P\d+)', [ 'methods' => 'DELETE', 'callback' => [__CLASS__, 'delete_reply'], 'permission_callback' => ['Beep_Likes', 'can_interact'], ]); } public static function submit_beep($request) { $content = sanitize_textarea_field($request->get_param('content')); if (mb_strlen($content) > BEEP_CHAR_LIMIT) { return new WP_Error('content_too_long', 'Beep exceeds ' . BEEP_CHAR_LIMIT . ' character limit.', ['status' => 400]); } // Handle quote post ID $quote_id = $request->get_param('quote_id'); $gif_url = $request->get_param('gif_url'); // Allow empty content when media / quote / gif / poll / voice / video is attached. // The composer is expected to gate this client-side too, but the server is the source of truth. $has_payload = ( $content !== '' || ($quote_id && is_numeric($quote_id)) || ($gif_url && filter_var($gif_url, FILTER_VALIDATE_URL)) || !empty($request->get_param('has_images')) || !empty($request->get_param('has_voice')) || !empty($request->get_param('has_video')) || !empty($request->get_param('has_poll')) ); if (!$has_payload) { return new WP_Error('empty_content', 'Beep cannot be empty.', ['status' => 400]); } $post_id = wp_insert_post([ 'post_type' => 'beep', 'post_content' => $content, 'post_status' => 'publish', 'post_author' => get_current_user_id(), ]); if (is_wp_error($post_id)) { return $post_id; } // Attach quote if provided if ($quote_id && is_numeric($quote_id)) { Beep_Quotes::create_quote($post_id, (int) $quote_id); } // Attach GIF URL if provided if ($gif_url && filter_var($gif_url, FILTER_VALIDATE_URL)) { update_post_meta($post_id, 'beep_gif_url', esc_url_raw($gif_url)); } return rest_ensure_response(['id' => $post_id, 'url' => get_permalink($post_id)]); } public static function submit_reply($request) { $post_id = (int) $request['id']; $parent = get_post($post_id); if (!$parent || $parent->post_type !== 'beep') { return new WP_Error('invalid_post', 'Invalid beep post.', ['status' => 400]); } $content = sanitize_textarea_field($request->get_param('content')); if (empty($content)) { return new WP_Error('empty_content', 'Reply cannot be empty.', ['status' => 400]); } $comment_id = wp_insert_comment([ 'comment_post_ID' => $post_id, 'comment_content' => $content, 'comment_author' => get_current_user_id() ? null : 'Anonymous', 'comment_type' => 'comment', 'user_id' => get_current_user_id(), ]); if (!$comment_id) { return new WP_Error('insert_failed', 'Could not save reply.', ['status' => 500]); } return rest_ensure_response(['id' => $comment_id]); } public static function delete_beep($request) { $post_id = (int) $request['id']; $post = get_post($post_id); if (!$post || $post->post_type !== 'beep') { return new WP_Error('invalid_post', 'Invalid beep.', ['status' => 400]); } if (!current_user_can('delete_post', $post_id)) { return new WP_Error('forbidden', 'You do not have permission to delete this beep.', ['status' => 403]); } // Clean up media Beep_Media::delete_media($post_id); $deleted = wp_delete_post($post_id, true); if (!$deleted) { return new WP_Error('delete_failed', 'Could not delete.', ['status' => 500]); } return rest_ensure_response(['deleted' => true]); } public static function delete_reply($request) { $comment_id = (int) $request['id']; $comment = get_comment($comment_id); if (!$comment) { return new WP_Error('invalid_reply', 'Reply not found.', ['status' => 404]); } // Only allow the reply author or a moderator to delete $can_delete = ( (int) $comment->user_id === get_current_user_id() || current_user_can('moderate_comments') ); if (!$can_delete) { return new WP_Error('forbidden', 'You do not have permission to delete this reply.', ['status' => 403]); } $deleted = wp_delete_comment($comment_id, true); if (!$deleted) { return new WP_Error('delete_failed', 'Could not delete.', ['status' => 500]); } return rest_ensure_response(['deleted' => true]); } public static function get_replies($post_id) { return get_comments([ 'post_id' => $post_id, 'status' => 'approve', 'order' => 'ASC', 'type' => 'comment', ]); } public static function reply_count($post_id) { return (int) get_comments_number($post_id); } public static function get_thread($post_id, $depth = 0, $max_depth = 10) { if ($depth > $max_depth) return []; $post = get_post($post_id); if (!$post || $post->post_type !== 'beep') return []; $thread = [self::format_beep_reply($post, $depth)]; $replies = get_comments([ 'post_id' => $post_id, 'status' => 'approve', 'order' => 'ASC', 'type' => 'comment', 'number' => 100, ]); foreach ($replies as $reply) { if ($depth < $max_depth) { $thread = array_merge($thread, self::get_comment_thread($reply->comment_ID, $depth + 1, $max_depth)); } } return $thread; } private static function get_comment_thread($comment_id, $depth, $max_depth) { if ($depth > $max_depth) return []; $comment = get_comment($comment_id); if (!$comment) return []; $result = [self::format_comment_reply($comment, $depth)]; $children = get_comments([ 'parent' => $comment_id, 'status' => 'approve', 'order' => 'ASC', 'type' => 'comment', 'number' => 100, ]); foreach ($children as $child) { $result = array_merge($result, self::get_comment_thread($child->comment_ID, $depth + 1, $max_depth)); } return $result; } public static function get_thread_api($request) { $post_id = (int) $request['post_id']; $thread = self::get_thread($post_id); return rest_ensure_response(['thread' => $thread]); } public static function format_beep_reply($post, $depth = 0) { $author_id = $post->post_author; $author = get_userdata($author_id); $avatar = get_user_meta($author_id, 'beep_avatar', true); if (!$avatar) { $avatar = 'https://www.gravatar.com/avatar/' . md5(strtolower($author->user_email ?? '')) . '?s=48&d=mp'; } return [ 'id' => $post->ID, 'parent_id' => $post->post_parent ?: null, 'is_post' => true, 'depth' => $depth, 'content' => $post->post_content, 'time_ago' => self::time_ago(strtotime($post->post_date)), 'like_count' => Beep_Likes::get_count($post->ID, 'post'), 'reply_count' => self::reply_count($post->ID), 'author' => [ 'id' => $author_id, 'name' => $author->display_name ?? 'Unknown', 'username' => $author->user_login ?? '', 'avatar' => $avatar, ], ]; } public static function format_comment_reply($comment, $depth = 0) { $author_id = (int) $comment->user_id; $author = $author_id ? get_userdata($author_id) : null; if ($author_id) { $avatar = get_user_meta($author_id, 'beep_avatar', true); if (!$avatar) { $avatar = 'https://www.gravatar.com/avatar/' . md5(strtolower($author->user_email ?? '')) . '?s=48&d=mp'; } $name = $author->display_name ?? 'Unknown'; $username = $author->user_login ?? ''; } else { $avatar = 'https://www.gravatar.com/avatar/?s=48&d=mp'; $name = esc_html($comment->comment_author); $username = ''; } return [ 'id' => $comment->comment_ID, 'parent_id' => (int) $comment->comment_parent ?: null, 'is_post' => false, 'depth' => $depth, 'content' => $comment->comment_content, 'time_ago' => self::time_ago(strtotime($comment->comment_date)), 'like_count' => Beep_Likes::get_count($comment->comment_ID, 'reply'), 'reply_count' => 0, 'author' => [ 'id' => $author_id, 'name' => $name, 'username' => $username, 'avatar' => $avatar, ], ]; } public static function time_ago($timestamp) { $diff = time() - $timestamp; if ($diff < 0) $diff = 0; if ($diff < 60) return $diff . 's'; if ($diff < 3600) return floor($diff / 60) . 'm'; if ($diff < 86400) return floor($diff / 3600) . 'h'; if ($diff < 604800) return floor($diff / 86400) . 'd'; return gmdate('M j', $timestamp); } }