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">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+