Files
beep/includes/class-replies.php
T
Krystie 64344bfef6 v1.8.1: allow media-only beeps (image/GIF/voice/quote/poll without text)
- PHP: submit_beep accepts empty content when any attachment flag is set
- JS: composer button enables when text is empty but media is attached
- JS: button refreshes on every media state change via custom event
- Version bump + readme changelog
2026-07-04 00:41:46 -07:00

265 lines
11 KiB
PHP
Executable File

<?php
if (!defined('ABSPATH')) { exit; }
class Beep_Replies {
public static function init() {
add_action('rest_api_init', [__CLASS__, 'register_routes']);
}
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'],
]);
}
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);
}
}