497 lines
21 KiB
PHP
Executable File
497 lines
21 KiB
PHP
Executable File
<?php
|
|
if (!defined('ABSPATH')) { exit; }
|
|
|
|
class Beep_Replies {
|
|
|
|
/** Comment meta keys (mirrors Beep_Media + Beep_Quotes for posts). */
|
|
const META_IMAGES = 'beep_reply_images';
|
|
const META_VOICE = 'beep_reply_voice_url';
|
|
const META_VOICE_ID = 'beep_reply_voice_id';
|
|
const META_GIF = 'beep_reply_gif_url';
|
|
const META_QUOTE_ID = 'beep_reply_quoted_post_id';
|
|
|
|
public static function init() {
|
|
add_action('rest_api_init', [__CLASS__, 'register_routes']);
|
|
}
|
|
|
|
/**
|
|
* Create the reply_polls table (v1.9.0).
|
|
* Mirrors Beep_Polls::create_tables() but keyed by comment_id.
|
|
*/
|
|
public static function create_tables() {
|
|
global $wpdb;
|
|
$table = $wpdb->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<id>\d+)', [
|
|
'methods' => 'POST',
|
|
'callback' => [__CLASS__, 'submit_reply'],
|
|
'permission_callback' => ['Beep_Likes', 'can_interact'],
|
|
]);
|
|
register_rest_route('beep/v1', '/thread/(?P<post_id>\d+)', [
|
|
'methods' => 'GET',
|
|
'callback' => [__CLASS__, 'get_thread_api'],
|
|
'permission_callback' => '__return_true',
|
|
]);
|
|
register_rest_route('beep/v1', '/delete/(?P<id>\d+)', [
|
|
'methods' => 'DELETE',
|
|
'callback' => [__CLASS__, 'delete_beep'],
|
|
'permission_callback' => ['Beep_Likes', 'can_interact'],
|
|
]);
|
|
register_rest_route('beep/v1', '/delete-reply/(?P<id>\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<id>\d+)/media', [
|
|
'methods' => 'POST',
|
|
'callback' => [__CLASS__, 'upload_reply_media'],
|
|
'permission_callback' => ['Beep_Likes', 'can_interact'],
|
|
]);
|
|
register_rest_route('beep/v1', '/reply/(?P<id>\d+)/media/(?P<attachment_id>\d+)', [
|
|
'methods' => 'DELETE',
|
|
'callback' => [__CLASS__, 'delete_reply_media'],
|
|
'permission_callback' => ['Beep_Likes', 'can_interact'],
|
|
]);
|
|
register_rest_route('beep/v1', '/reply/(?P<id>\d+)/voice', [
|
|
'methods' => 'POST',
|
|
'callback' => [__CLASS__, 'upload_reply_voice'],
|
|
'permission_callback' => ['Beep_Likes', 'can_interact'],
|
|
]);
|
|
register_rest_route('beep/v1', '/reply/(?P<id>\d+)/gif', [
|
|
'methods' => 'POST',
|
|
'callback' => [__CLASS__, 'set_reply_gif'],
|
|
'permission_callback' => ['Beep_Likes', 'can_interact'],
|
|
]);
|
|
register_rest_route('beep/v1', '/reply/(?P<id>\d+)/quote', [
|
|
'methods' => 'POST',
|
|
'callback' => [__CLASS__, 'set_reply_quote'],
|
|
'permission_callback' => ['Beep_Likes', 'can_interact'],
|
|
]);
|
|
register_rest_route('beep/v1', '/reply/(?P<id>\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);
|
|
}
|
|
}
|