prefix . 'beep_reply_polls'; $charset = $wpdb->get_charset_collate(); $sql = "CREATE TABLE $table ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, comment_id BIGINT(20) UNSIGNED NOT NULL, question TEXT NOT NULL, options TEXT NOT NULL, votes TEXT NOT NULL DEFAULT '[]', total_votes BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, ends_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY comment_id (comment_id) ) $charset;"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta($sql); } public static function register_routes() { register_rest_route('beep/v1', '/post', [ 'methods' => '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'], ]); // Reply media routes (v1.9.0) register_rest_route('beep/v1', '/reply/(?P\d+)/media', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'upload_reply_media'], 'permission_callback' => ['Beep_Likes', 'can_interact'], ]); register_rest_route('beep/v1', '/reply/(?P\d+)/media/(?P\d+)', [ 'methods' => 'DELETE', 'callback' => [__CLASS__, 'delete_reply_media'], 'permission_callback' => ['Beep_Likes', 'can_interact'], ]); register_rest_route('beep/v1', '/reply/(?P\d+)/voice', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'upload_reply_voice'], 'permission_callback' => ['Beep_Likes', 'can_interact'], ]); register_rest_route('beep/v1', '/reply/(?P\d+)/gif', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'set_reply_gif'], 'permission_callback' => ['Beep_Likes', 'can_interact'], ]); register_rest_route('beep/v1', '/reply/(?P\d+)/quote', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'set_reply_quote'], 'permission_callback' => ['Beep_Likes', 'can_interact'], ]); register_rest_route('beep/v1', '/reply/(?P\d+)/poll', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'create_reply_poll'], '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 (mb_strlen($content) > BEEP_CHAR_LIMIT) { return new WP_Error('content_too_long', 'Reply exceeds ' . BEEP_CHAR_LIMIT . ' character limit.', ['status' => 400]); } // Allow empty content when media / quote / gif / poll is attached. $has_payload = ( $content !== '' || !empty($request->get_param('quote_id')) || !empty($request->get_param('gif_url')) || !empty($request->get_param('has_images')) || !empty($request->get_param('has_voice')) || !empty($request->get_param('has_poll')) ); if (!$has_payload) { 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]); } // Pre-attach quote + gif URL (uploads happen via the new reply-media routes after insert). if ($request->get_param('quote_id') && is_numeric($request->get_param('quote_id'))) { $quoted = get_post((int) $request->get_param('quote_id')); if ($quoted && $quoted->post_type === 'beep') { update_comment_meta($comment_id, self::META_QUOTE_ID, (int) $quoted->ID); } } if ($gif_url = $request->get_param('gif_url')) { if (filter_var($gif_url, FILTER_VALIDATE_URL)) { update_comment_meta($comment_id, self::META_GIF, esc_url_raw($gif_url)); } } return rest_ensure_response(['id' => $comment_id]); } /* ============================================================ * Reply media endpoints (v1.9.0) * Mirror Beep_Media / Beep_Polls but store on comment_meta. * ============================================================ */ private static function validate_reply_owner($comment_id, $require_auth = true) { $comment = get_comment($comment_id); if (!$comment) return new WP_Error('not_found', 'Reply not found.', ['status' => 404]); if ($require_auth) { $uid = get_current_user_id(); if (!$uid || (int) $comment->user_id !== $uid) { if (!current_user_can('moderate_comments')) { return new WP_Error('forbidden', 'Cannot edit this reply.', ['status' => 403]); } } } return $comment; } public static function upload_reply_media($request) { $comment_id = (int) $request['id']; $check = self::validate_reply_owner($comment_id); if (is_wp_error($check)) return $check; if (empty($_FILES['file'])) { return new WP_Error('no_file', 'No file uploaded.'); } $result = Beep_Media::handle_upload_for_parent($comment_id, $_FILES['file'], 'comment'); if (is_wp_error($result)) return $result; return rest_ensure_response($result); } public static function delete_reply_media($request) { $comment_id = (int) $request['id']; $attachment_id = (int) $request['attachment_id']; $check = self::validate_reply_owner($comment_id); if (is_wp_error($check)) return $check; Beep_Media::remove_image_from_parent($comment_id, $attachment_id, 'comment'); return rest_ensure_response(['ok' => true]); } public static function upload_reply_voice($request) { $comment_id = (int) $request['id']; $check = self::validate_reply_owner($comment_id); if (is_wp_error($check)) return $check; if (empty($_FILES['file'])) { return new WP_Error('no_file', 'No file uploaded.'); } $result = Beep_Media::handle_voice_upload_for_parent($comment_id, $_FILES['file'], 'comment'); if (is_wp_error($result)) return $result; return rest_ensure_response($result); } public static function set_reply_gif($request) { $comment_id = (int) $request['id']; $check = self::validate_reply_owner($comment_id); if (is_wp_error($check)) return $check; $body = $request->get_json_params(); $url = isset($body['gif_url']) ? esc_url_raw($body['gif_url']) : ''; if (!filter_var($url, FILTER_VALIDATE_URL)) { return new WP_Error('invalid_url', 'Invalid GIF URL.'); } update_comment_meta($comment_id, self::META_GIF, $url); return rest_ensure_response(['gif_url' => $url]); } public static function set_reply_quote($request) { $comment_id = (int) $request['id']; $check = self::validate_reply_owner($comment_id); if (is_wp_error($check)) return $check; $body = $request->get_json_params(); $quote_id = isset($body['quote_id']) ? (int) $body['quote_id'] : 0; $quoted = $quote_id ? get_post($quote_id) : null; if (!$quoted || $quoted->post_type !== 'beep') { return new WP_Error('invalid_quote', 'Cannot quote that beep.'); } update_comment_meta($comment_id, self::META_QUOTE_ID, $quote_id); return rest_ensure_response(['quote_id' => $quote_id]); } public static function create_reply_poll($request) { $comment_id = (int) $request['id']; $check = self::validate_reply_owner($comment_id); if (is_wp_error($check)) return $check; $body = $request->get_json_params(); $question = sanitize_text_field($body['question'] ?? ''); $options = isset($body['options']) ? (array) $body['options'] : []; if (empty($question) || count($options) < 2 || count($options) > 6) { return new WP_Error('invalid_poll', 'Poll needs a question and 2-6 options.', ['status' => 400]); } $options = array_map('sanitize_text_field', $options); global $wpdb; $table = $wpdb->prefix . 'beep_reply_polls'; $votes = array_fill(0, count($options), 0); $wpdb->insert($table, [ 'comment_id' => $comment_id, 'question' => $question, 'options' => json_encode($options), 'votes' => json_encode($votes), 'total_votes' => 0, 'created_at' => current_time('mysql'), ], ['%d', '%s', '%s', '%s', '%d', '%s']); if ($wpdb->last_error) { return new WP_Error('db_error', 'Could not save poll: ' . $wpdb->last_error, ['status' => 500]); } $poll_id = $wpdb->insert_id; update_comment_meta($comment_id, 'beep_reply_poll_id', $poll_id); return rest_ensure_response([ 'id' => $poll_id, 'question' => $question, 'options' => $options, 'votes' => $votes, ]); } /* Reply media getters (used by renderer). */ public static function get_reply_images($comment_id) { $ids = get_comment_meta($comment_id, self::META_IMAGES, false); return array_filter(array_map('intval', (array) $ids)); } public static function get_reply_voice_url($comment_id) { return esc_url_raw(get_comment_meta($comment_id, self::META_VOICE, true)); } public static function get_reply_gif_url($comment_id) { return esc_url_raw(get_comment_meta($comment_id, self::META_GIF, true)); } public static function get_reply_quoted_post_id($comment_id) { return (int) get_comment_meta($comment_id, self::META_QUOTE_ID, true); } public static function get_reply_poll($comment_id) { global $wpdb; $table = $wpdb->prefix . 'beep_reply_polls'; $row = $wpdb->get_row($wpdb->prepare( "SELECT * FROM $table WHERE comment_id = %d ORDER BY id DESC LIMIT 1", $comment_id ), ARRAY_A); if (!$row) return null; $row['options'] = json_decode($row['options'], true) ?: []; $row['votes'] = json_decode($row['votes'], true) ?: []; return $row; } 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); } }