diff --git a/assets/beep.css b/assets/beep.css index 740c268..9d8d899 100755 --- a/assets/beep.css +++ b/assets/beep.css @@ -1047,12 +1047,59 @@ .beep-embed .beep-reply-footer { display: flex; - justify-content: flex-end; + justify-content: space-between; align-items: center; gap: 12px; margin-top: 4px; + flex-wrap: wrap; } +.beep-embed .beep-reply-submit-row { + display: flex; + align-items: center; + gap: 10px; + margin-left: auto; +} + +/* Reply composer toolbar (v1.9.0) — mirrors beep-compose styles. */ +.beep-embed .beep-reply-tools { + display: flex; + align-items: center; + gap: 4px; +} + +.beep-embed .beep-reply-tool { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 50%; + cursor: pointer; + color: var(--beep-accent); + transition: background 0.15s; +} +.beep-embed .beep-reply-tool:hover { background: var(--beep-accent-soft); } +.beep-embed .beep-reply-tool svg { display: block; } + +/* Reply media previews + sections share styling with the composer. */ +.beep-embed .beep-reply-media-preview:not([hidden]) { display: grid; } +.beep-embed .beep-reply-voice-preview, +.beep-embed .beep-reply-gif, +.beep-embed .beep-reply-quote, +.beep-embed .beep-reply-poll { + margin-top: 8px; +} +.beep-embed .beep-reply-quote[data-beep-compose-quote], +.beep-embed .beep-reply-poll[data-beep-compose-poll] { /* hide via hidden attr */ } + +/* Compact reply form (top-level reply under a post) — tighten the toolbar a bit. */ +.beep-embed .beep-reply-form-compact .beep-reply-tool { + width: 30px; + height: 30px; +} +.beep-embed .beep-reply-form-compact .beep-reply-tools { gap: 2px; } + .beep-embed .beep-pending { padding: 10px 0; color: var(--beep-muted); diff --git a/assets/beep.js b/assets/beep.js index 60483c8..8804465 100755 --- a/assets/beep.js +++ b/assets/beep.js @@ -7,6 +7,12 @@ document.addEventListener('DOMContentLoaded', init); + // Returns the closest compose/reply-form root for an element. + // (Used throughout to handle buttons that live inside either kind of form.) + function formRoot(el) { + return el && el.closest && el.closest('[data-beep-compose], [data-beep-reply-form]'); + } + function init() { var feeds = document.querySelectorAll('[data-beep-feed]'); Array.prototype.forEach.call(feeds, initFeed); @@ -26,6 +32,177 @@ initSortTabs(feed); initMedia(compose, feed); initInfiniteScroll(feed); + // v1.9.0: also init every reply form (they have their own media + composer state). + var replyForms = feed.querySelectorAll('[data-beep-reply-form]'); + Array.prototype.forEach.call(replyForms, function (form) { + initReplyForm(form, feed); + }); + } + + /* ---------- Reply form (v1.9.0) ---------- */ + function initReplyForm(form, feed) { + var textarea = form.querySelector('.beep-reply-input'); + var counter = form.querySelector('.beep-char-counter'); + var button = form.querySelector('[data-beep-reply-submit]'); + if (!textarea || !button) return; + + initMedia(form, feed); + + function hasAttachments() { + var pendingImages = form._beepPendingImages; + var pendingVoice = form._beepPendingVoice; + var hasGif = !!form._beepGifUrl; + var hasQuote = !!(form._beepQuotedPost && form._beepQuotedPost.id); + var hasPoll = !!collectPollData(form); + return ( + (pendingImages && pendingImages.length) || + pendingVoice || + hasGif || + hasQuote || + hasPoll + ); + } + + function refresh() { + var remaining = limit - countChars(textarea.value); + if (counter) { + counter.textContent = remaining; + counter.classList.toggle('beep-counter-warn', remaining < 20 && remaining >= 0); + counter.classList.toggle('beep-counter-bad', remaining < 0); + } + button.disabled = !(textarea.value.trim() !== '' || hasAttachments()) || remaining < 0; + } + textarea.addEventListener('input', refresh); + form.addEventListener('beep:attachments-changed', refresh); + form._beepRefresh = refresh; + refresh(); + + button.addEventListener('click', function () { + var content = textarea.value.trim(); + if (!content && !hasAttachments()) return; + button.disabled = true; + var quotedPost = form._beepQuotedPost; + var gifUrl = form._beepGifUrl; + var pending = form._beepPendingImages; + var pendingVoice = form._beepPendingVoice; + var poll = collectPollData(form); + + var postId = parseInt(button.dataset.beepReplySubmit, 10) || 0; + var parent = parseInt(button.dataset.beepReplyParent, 10) || 0; + var payload = { content: content, parent: parent }; + if (quotedPost && quotedPost.id) payload.quote_id = quotedPost.id; + if (gifUrl) payload.gif_url = gifUrl; + if (pending && pending.length) payload.has_images = 1; + if (pendingVoice) payload.has_voice = 1; + if (poll) payload.has_poll = 1; + + fetch(config.restUrl + 'reply/' + postId, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.nonce }, + credentials: 'same-origin', + body: JSON.stringify(payload) + }) + .then(handleResponse) + .then(function (data) { + var replyId = data && data.id; + if (!replyId) throw new Error('No reply id returned'); + + // Upload pending images + voice + poll to the new comment. + var tasks = []; + if (pending && pending.length) tasks.push(uploadReplyMedia(replyId, pending, form)); + if (pendingVoice) tasks.push(uploadReplyVoice(replyId, pendingVoice, form)); + if (poll) tasks.push(createReplyPoll(replyId, form)); + + return Promise.all(tasks).then(function () { + clearReplyForm(form); + // If the reply was posted from inside the thread modal, refresh that. + var modal = document.querySelector('[data-beep-thread-modal]:not([hidden])'); + if (modal && modal._beepThreadId && typeof refreshThread === 'function') { + refreshThread(); + return; + } + // Otherwise reload the feed so the new reply shows up. + var feed = form.closest('[data-beep-feed]'); + if (feed && typeof reloadFeed === 'function') { + reloadFeed(feed, feedState.currentSort); + } + }); + }) + .catch(function () { + showToast('Could not post reply. Please try again.', 'error'); + button.disabled = false; + }); + }); + } + + function clearReplyForm(form) { + var textarea = form.querySelector('.beep-reply-input'); + if (textarea) textarea.value = ''; + if (form._beepPendingImages) form._beepPendingImages.length = 0; + form._beepPendingVoice = null; + form._beepGifUrl = null; + form._beepQuotedPost = null; + var preview = form.querySelector('[data-beep-media-preview]'); + if (preview) { preview.innerHTML = ''; preview.hidden = true; } + var voicePrev = form.querySelector('[data-beep-voice-preview]'); + if (voicePrev) { voicePrev.innerHTML = ''; voicePrev.hidden = true; } + var gifPrev = form.querySelector('[data-beep-gif-preview]'); + if (gifPrev) { gifPrev.innerHTML = ''; gifPrev.hidden = true; } + var quotePrev = form.querySelector('[data-beep-quote-preview]'); + if (quotePrev) { quotePrev.innerHTML = ''; quotePrev.hidden = true; } + var pollSection = form.querySelector('[data-beep-compose-poll]'); + if (pollSection) { + pollSection.hidden = true; + var q = pollSection.querySelector('[data-beep-poll-question]'); + if (q) q.value = ''; + var opts = pollSection.querySelectorAll('[data-beep-poll-option]'); + for (var i = 0; i < opts.length; i++) opts[i].value = ''; + } + if (typeof form._beepRefresh === 'function') form._beepRefresh(); + } + + function uploadReplyMedia(replyId, pendingImages, form) { + if (!pendingImages || !pendingImages.length) return Promise.resolve(); + var promises = pendingImages.map(function (img) { + var fd = new FormData(); + fd.append('file', img.file); + return fetch(config.restUrl + 'reply/' + replyId + '/media', { + method: 'POST', + headers: { 'X-WP-Nonce': config.nonce }, + credentials: 'same-origin', + body: fd + }) + .then(handleResponse) + .catch(function (err) { console.warn('Reply image upload failed:', err); }); + }); + return Promise.all(promises); + } + + function uploadReplyVoice(replyId, voice, form) { + if (!voice) return Promise.resolve(); + var fd = new FormData(); + fd.append('file', voice.file); + return fetch(config.restUrl + 'reply/' + replyId + '/voice', { + method: 'POST', + headers: { 'X-WP-Nonce': config.nonce }, + credentials: 'same-origin', + body: fd + }) + .then(handleResponse) + .catch(function (err) { console.warn('Reply voice upload failed:', err); }); + } + + function createReplyPoll(replyId, form) { + var pollData = collectPollData(form); + if (!pollData) return Promise.resolve(); + return fetch(config.restUrl + 'reply/' + replyId + '/poll', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.nonce }, + credentials: 'same-origin', + body: JSON.stringify(pollData) + }) + .then(handleResponse) + .catch(function (err) { console.warn('Reply poll creation failed:', err); }); } /* ---------- Infinite scroll ---------- */ @@ -278,7 +455,7 @@ } var replyBtn = t.closest('[data-beep-reply-submit]'); - if (replyBtn) { e.preventDefault(); handleReplySubmit(replyBtn); return; } + if (replyBtn) { return; } // handled by initReplyForm var shareBtn = t.closest('[data-beep-share]'); if (shareBtn) { e.preventDefault(); handleShare(shareBtn); return; } @@ -313,7 +490,7 @@ var quoteComposerBtn = t.closest('[data-beep-quote-composer-btn]'); if (quoteComposerBtn) { e.preventDefault(); - var compose = quoteComposerBtn.closest('[data-beep-compose]'); + var compose = formRoot(quoteComposerBtn); var quoteSection = compose && compose.querySelector('[data-beep-compose-quote]'); if (quoteSection) quoteSection.hidden = !quoteSection.hidden; return; @@ -323,7 +500,7 @@ var quoteLookupBtn = t.closest('[data-beep-quote-lookup-btn]'); if (quoteLookupBtn) { e.preventDefault(); - var compose = quoteLookupBtn.closest('[data-beep-compose]'); + var compose = formRoot(quoteLookupBtn); var input = compose && compose.querySelector('[data-beep-quote-lookup]'); var query = input ? input.value.trim() : ''; if (!query) return; @@ -345,11 +522,11 @@ // Quote remove button var quoteRemoveBtn = t.closest('[data-beep-quote-remove]'); - if (quoteRemoveBtn) { e.preventDefault(); var compose = quoteRemoveBtn.closest('[data-beep-compose]'); clearQuotePreview(compose); return; } + if (quoteRemoveBtn) { e.preventDefault(); var compose = formRoot(quoteRemoveBtn); clearQuotePreview(compose); return; } // GIF picker button var gifBtn = t.closest('[data-beep-gif-btn]'); - if (gifBtn) { e.preventDefault(); var compose = gifBtn.closest('[data-beep-compose]'); openGifPicker(compose); return; } + if (gifBtn) { e.preventDefault(); openGifPicker(formRoot(gifBtn)); return; } // GIF modal close var gifClose = t.closest('[data-beep-gif-close]'); @@ -360,7 +537,7 @@ var gifSearchBtn = t.closest('[data-beep-gif-search-btn]'); if (gifSearchBtn) { e.preventDefault(); - var compose = gifSearchBtn.closest('[data-beep-compose]') || document.querySelector('[data-beep-compose]'); + var compose = formRoot(gifSearchBtn) || document.querySelector('[data-beep-compose]'); var input = gifSearchBtn.closest('.beep-gif-search-bar') && gifSearchBtn.closest('.beep-gif-search-bar').querySelector('[data-beep-gif-search]'); if (input) searchGifs(input.value, compose); return; @@ -370,7 +547,7 @@ var gifRemoveBtn = t.closest('[data-beep-gif-remove]'); if (gifRemoveBtn) { e.preventDefault(); - var compose = gifRemoveBtn.closest('[data-beep-compose]'); + var compose = formRoot(gifRemoveBtn); if (compose) { compose._beepGifUrl = null; compose.dispatchEvent(new CustomEvent('beep:attachments-changed')); @@ -382,39 +559,19 @@ // Poll button - toggle poll section var pollBtn = t.closest('[data-beep-poll-btn]'); - if (pollBtn) { - e.preventDefault(); - var compose = pollBtn.closest('[data-beep-compose]'); - if (compose) openPollSection(compose); - return; - } + if (pollBtn) { e.preventDefault(); openPollSection(formRoot(pollBtn)); return; } // Poll add option var pollAddOptionBtn = t.closest('[data-beep-poll-add-option]'); - if (pollAddOptionBtn) { - e.preventDefault(); - var compose = pollAddOptionBtn.closest('[data-beep-compose]'); - if (compose) addPollOption(compose); - return; - } + if (pollAddOptionBtn) { e.preventDefault(); addPollOption(formRoot(pollAddOptionBtn)); return; } // Poll remove option var pollRemoveOptionBtn = t.closest('[data-beep-poll-remove-option]'); - if (pollRemoveOptionBtn) { - e.preventDefault(); - var compose = pollRemoveOptionBtn.closest('[data-beep-compose]'); - if (compose) removePollOption(compose); - return; - } + if (pollRemoveOptionBtn) { e.preventDefault(); removePollOption(formRoot(pollRemoveOptionBtn)); return; } // Poll remove var pollRemoveBtn = t.closest('[data-beep-poll-remove]'); - if (pollRemoveBtn) { - e.preventDefault(); - var compose = pollRemoveBtn.closest('[data-beep-compose]'); - if (compose) clearPoll(compose); - return; - } + if (pollRemoveBtn) { e.preventDefault(); clearPoll(formRoot(pollRemoveBtn)); return; } // Poll vote button var pollVoteBtn = t.closest('[data-beep-poll-vote]'); @@ -482,7 +639,7 @@ // Enter in the GIF search box runs the search if (e.key === 'Enter' && e.target.matches && e.target.matches('[data-beep-gif-search]')) { e.preventDefault(); - var compose = document.querySelector('[data-beep-compose]'); + var compose = formRoot(e.target) || document.querySelector('[data-beep-compose]'); searchGifs(e.target.value, compose); return; } @@ -890,6 +1047,11 @@ .then(function(data) { if (loading) loading.hidden = true; content.innerHTML = data.html || '
Nothing to show.
'; + // Init reply forms inside the thread modal so they get media + composer logic. + var threadForms = content.querySelectorAll('[data-beep-reply-form]'); + Array.prototype.forEach.call(threadForms, function (form) { + initReplyForm(form, /* feed */ null); + }); // Sync the reply count back to the feed card if (typeof data.reply_count !== 'undefined') { var feedPost = document.querySelector('.beep-list [data-beep-post="' + postId + '"] [data-beep-reply-count]'); diff --git a/beep.php b/beep.php index 3fd2dd5..b54350a 100755 --- a/beep.php +++ b/beep.php @@ -2,7 +2,7 @@ /** * Plugin Name: Beep * Description: A Twitter-style microblog for WordPress, with likes, replies, and Gravatars. Use the [beep_feed] shortcode on any page. - * Version: 1.8.1 + * Version: 1.9.0 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: Sami Ahmed @@ -14,7 +14,7 @@ if (!defined('ABSPATH')) { exit; } -define('BEEP_VERSION', '1.8.1'); +define('BEEP_VERSION', '1.9.0'); define('BEEP_FILE', __FILE__); define('BEEP_DIR', plugin_dir_path(__FILE__)); define('BEEP_URL', plugin_dir_url(__FILE__)); diff --git a/includes/class-beep.php b/includes/class-beep.php index 636c968..4b104c6 100755 --- a/includes/class-beep.php +++ b/includes/class-beep.php @@ -76,6 +76,7 @@ class Beep_Plugin { Beep_CPT::register(); Beep_Likes::create_table(); Beep_Polls::create_tables(); + Beep_Replies::create_tables(); flush_rewrite_rules(); } diff --git a/includes/class-media.php b/includes/class-media.php index f2f97d3..5cc9d89 100755 --- a/includes/class-media.php +++ b/includes/class-media.php @@ -15,6 +15,10 @@ class Beep_Media { private static $image_types = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; private static $max_images = 4; private static $max_file_size = 10 * 1024 * 1024; // 10 MB + private static $audio_types = ['audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/webm', 'audio/mp4', 'audio/x-m4a']; + private static $max_audio_size = 20 * 1024 * 1024; // 20 MB + private static $video_types = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime']; + private static $max_video_size = 100 * 1024 * 1024; // 100 MB public static function init() {} @@ -217,9 +221,183 @@ class Beep_Media { delete_post_meta($post_id, self::META_KEY_IMAGES); delete_post_meta($post_id, self::META_KEY_VIDEO); } - // Voice note support - private static $audio_types = ['audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/webm', 'audio/mp4', 'audio/x-m4a']; - private static $max_audio_size = 20 * 1024 * 1024; // 20 MB + + /* ============================================================ + * Parent-agnostic wrappers (v1.9.0) + * $parent_type = 'post' (default) or 'comment'. + * Routes to post_meta or comment_meta automatically. + * ============================================================ */ + + public static function images_meta_key() { + return self::META_KEY_IMAGES; + } + + /** + * Resolve the right meta storage for a parent (post or comment). + * + * @return array{get:string,add:string,del:string}|WP_Error + */ + private static function meta_store($parent_id, $parent_type = 'post') { + if ($parent_type === 'comment') { + return [ + 'get' => 'get_comment_meta', + 'add' => 'add_comment_meta', + 'del' => 'delete_comment_meta', + ]; + } + if ($parent_type === 'post') { + return [ + 'get' => 'get_post_meta', + 'add' => 'add_post_meta', + 'del' => 'delete_post_meta', + ]; + } + return new WP_Error('invalid_parent', 'parent_type must be post or comment.'); + } + + /** Image upload wrapper that works for posts or comments. */ + public static function handle_upload_for_parent($parent_id, $file, $parent_type = 'post') { + $store = self::meta_store($parent_id, $parent_type); + if (is_wp_error($store)) return $store; + + if (!is_array($file) || $file['error'] !== UPLOAD_ERR_OK) { + return new WP_Error('upload_error', 'Upload failed.'); + } + if ($file['size'] > self::$max_file_size) { + return new WP_Error('file_too_large', 'File exceeds 10 MB limit.'); + } + $mime = mime_content_type($file['tmp_name']); + if (!in_array($mime, self::$image_types, true)) { + return new WP_Error('invalid_type', 'Only JPEG, PNG, GIF, and WebP are allowed.'); + } + + $existing = call_user_func($store['get'], $parent_id, self::META_KEY_IMAGES, false); + $existing = array_filter(array_map('intval', (array) $existing)); + if (count($existing) >= self::$max_images) { + return new WP_Error('too_many', 'Maximum ' . self::$max_images . ' images per beep.'); + } + + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + + $file_return = wp_handle_sideload($file, [ + 'test_form' => false, + 'mimes' => [ + 'jpg|jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + ], + ]); + + if (isset($file_return['error'])) { + return new WP_Error('sideload_error', $file_return['error']); + } + + $attachment = [ + 'post_mime_type' => $file_return['type'], + 'post_title' => preg_replace('/\.[^.]+$/', '', basename($file_return['file'])), + 'post_content' => '', + 'post_status' => 'inherit', + 'guid' => $file_return['url'], + ]; + + // Attachments still need a post parent for WP core; use the comment's post ID when on a comment. + $attach_parent = $parent_type === 'comment' + ? (int) get_comment($parent_id)->comment_post_ID + : $parent_id; + $attachment_id = wp_insert_attachment($attachment, $file_return['file'], $attach_parent); + if (is_wp_error($attachment_id)) { + return $attachment_id; + } + + $attach_data = wp_generate_attachment_metadata($attachment_id, $file_return['file']); + wp_update_attachment_metadata($attachment_id, $attach_data); + + call_user_func($store['add'], $parent_id, self::META_KEY_IMAGES, $attachment_id); + + return [ + 'id' => $attachment_id, + 'url' => wp_get_attachment_url($attachment_id), + 'thumb' => self::get_image_url($attachment_id, 'thumbnail'), + 'sizes' => self::get_image_sizes($attachment_id), + ]; + } + + /** Image removal wrapper. */ + public static function remove_image_from_parent($parent_id, $attachment_id, $parent_type = 'post') { + $store = self::meta_store($parent_id, $parent_type); + if (is_wp_error($store)) return $store; + $ids = call_user_func($store['get'], $parent_id, self::META_KEY_IMAGES, false); + $ids = array_values(array_filter((array) $ids, fn($id) => (int) $id !== (int) $attachment_id)); + call_user_func($store['del'], $parent_id, self::META_KEY_IMAGES); + foreach ($ids as $id) { + call_user_func($store['add'], $parent_id, self::META_KEY_IMAGES, $id); + } + wp_delete_attachment($attachment_id, true); + return true; + } + + /** Voice upload wrapper. */ + public static function handle_voice_upload_for_parent($parent_id, $file, $parent_type = 'post') { + $store = self::meta_store($parent_id, $parent_type); + if (is_wp_error($store)) return $store; + + if (!is_array($file) || $file['error'] !== UPLOAD_ERR_OK) { + return new WP_Error('upload_error', 'Upload failed.'); + } + if ($file['size'] > self::$max_audio_size) { + return new WP_Error('file_too_large', 'Voice note exceeds 20 MB limit.'); + } + $mime = mime_content_type($file['tmp_name']); + if (!in_array($mime, self::$audio_types, true)) { + return new WP_Error('invalid_type', 'Only MP3, OGG, WAV, WebM, and M4A audio are allowed.'); + } + + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + + $file_return = wp_handle_upload($file, [ + 'test_form' => false, + 'mimes' => [ + 'mp3' => 'audio/mpeg', + 'ogg' => 'audio/ogg', + 'wav' => 'audio/wav', + 'webm' => 'audio/webm', + 'm4a' => 'audio/mp4', + ], + ]); + + if (isset($file_return['error'])) { + return new WP_Error('upload_error', $file_return['error']); + } + + $attachment = [ + 'post_mime_type' => $file_return['type'], + 'post_title' => preg_replace('/\.[^.]+$/', '', basename($file_return['file'])), + 'post_content' => '', + 'post_status' => 'inherit', + 'guid' => $file_return['url'], + ]; + + $attach_parent = $parent_type === 'comment' + ? (int) get_comment($parent_id)->comment_post_ID + : $parent_id; + $attachment_id = wp_insert_attachment($attachment, $file_return['file'], $attach_parent); + if (is_wp_error($attachment_id)) { + return $attachment_id; + } + + $attach_data = wp_generate_attachment_metadata($attachment_id, $file_return['file']); + wp_update_attachment_metadata($attachment_id, $attach_data); + + $url = wp_get_attachment_url($attachment_id); + call_user_func($store['add'], $parent_id, 'beep_reply_voice_url', $url); + call_user_func($store['add'], $parent_id, 'beep_reply_voice_id', $attachment_id); + + return ['id' => $attachment_id, 'url' => $url]; + } public static function handle_voice_upload($post_id, $file) { if (!is_array($file) || $file['error'] !== UPLOAD_ERR_OK) { @@ -312,9 +490,6 @@ class Beep_Media { } // Video file upload support - private static $video_types = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime']; - private static $max_video_size = 100 * 1024 * 1024; // 100 MB - public static function handle_video_upload($post_id, $file) { if (!is_array($file) || $file['error'] !== UPLOAD_ERR_OK) { return new WP_Error('upload_error', 'Upload failed.'); diff --git a/includes/class-replies.php b/includes/class-replies.php index 122d14e..95fe75d 100755 --- a/includes/class-replies.php +++ b/includes/class-replies.php @@ -3,10 +3,41 @@ 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', @@ -33,6 +64,38 @@ class Beep_Replies { '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) { @@ -88,9 +151,23 @@ class Beep_Replies { return new WP_Error('invalid_post', 'Invalid beep post.', ['status' => 400]); } $content = sanitize_textarea_field($request->get_param('content')); - if (empty($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, @@ -101,9 +178,164 @@ class Beep_Replies { 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); diff --git a/includes/helpers.php b/includes/helpers.php index 55ee6b0..4a27acf 100755 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -146,62 +146,11 @@ function beep_render_composer() { placeholder="What's happening?" maxlength="" rows="1"> - - - - - + + +