10 Commits

Author SHA1 Message Date
Krystie 97eb31bd9d [grade=B] v1.9.4: bird emblem bottom-left on beeps and replies (opposite wordmark), Sami's transparent icon, old top-right badge removed 2026-09-07 17:57:13 -07:00
Krystie 4b9a695f26 asset: use Sami's transparent bird icon for the emblem 2026-09-07 17:53:39 -07:00
Krystie a89ed3749e [pending-grade] v1.9.4: bird emblem bottom-left of beeps, badge removed from top-right 2026-09-07 17:48:04 -07:00
Krystie 7ca8a7d49f [grade=A] v1.9.3: Beep bird emblem in the feed header (transparent-bg PNG cropped from the brand logo, 36px) 2026-09-07 17:42:54 -07:00
Krystie ecb7f871f9 [grade=B] v1.9.2: hide raw YouTube URL above the video card (strip first YT link from displayed text, preserve trailing punctuation) 2026-09-07 17:09:48 -07:00
Krystie 05b9b4cc57 [grade=B] fix: PCRE delimiter collision (# in char class) broke YT extraction — switch to ~ delimiter, verified in PHP CLI 2026-09-07 16:28:07 -07:00
Krystie 2b65194cdb [grade=B] v1.9.1: YouTube links auto-render as video cards in beeps, replies, and quotes (click-to-play, nocookie embed) 2026-09-07 16:21:23 -07:00
Krystie 6eb128b780 v1.9.0: reply composer media support (images, GIF, voice, quotes, polls on replies) 2026-09-07 16:15:48 -07:00
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
git-fix-test 5bb39316b1 test: git.sami connectivity probe via Caddy proxy 2026-06-27 20:57:22 -07:00
12 changed files with 1118 additions and 125 deletions
+1
View File
@@ -0,0 +1 @@
connectivity test
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+128 -11
View File
@@ -94,6 +94,12 @@
-webkit-backdrop-filter: blur(12px);
}
.beep-embed .beep-feed-emblem {
width: 36px;
height: auto;
flex: 0 0 auto;
}
.beep-embed .beep-feed-header-title {
font-size: 17px;
font-weight: 800;
@@ -551,15 +557,6 @@
.beep-embed .beep-time:hover { text-decoration: underline; }
.beep-embed .beep-spacer { flex: 1; min-width: 8px; }
/* Per-post bird badge (brand corner mark) */
.beep-embed .beep-post-badge {
flex-shrink: 0;
opacity: 0.6;
transition: opacity 0.15s ease;
}
.beep-embed .beep-post:hover .beep-post-badge { opacity: 0.9; }
/* Post text */
.beep-embed .beep-text {
font-size: 15px;
@@ -736,6 +733,66 @@
position: relative;
}
/* YouTube card (v1.9.1) */
.beep-embed .beep-yt-card {
position: relative;
margin-top: 12px;
border-radius: 16px;
overflow: hidden;
border: 1px solid var(--beep-border);
cursor: pointer;
background: #000;
aspect-ratio: 16 / 9;
display: flex;
align-items: center;
justify-content: center;
}
.beep-embed .beep-yt-thumb {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
top: 0;
left: 0;
margin: 0;
}
.beep-embed .beep-yt-play {
position: relative;
z-index: 1;
width: 68px;
height: 48px;
border-radius: 12px;
background: rgba(255, 0, 0, 0.85);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
transition: transform 0.15s ease, background 0.15s ease;
}
.beep-embed .beep-yt-card:hover .beep-yt-play,
.beep-embed .beep-yt-card:focus-visible .beep-yt-play {
background: #f00;
transform: scale(1.08);
}
.beep-embed .beep-yt-play svg {
fill: #fff;
margin-left: 4px;
}
.beep-embed .beep-yt-card iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
.beep-embed .beep-gif-embed img {
width: 100%;
height: auto;
@@ -951,7 +1008,7 @@
font-variant-numeric: tabular-nums;
}
/* ---------- Bottom bar (brand wordmark + delete) ---------- */
/* ---------- Bottom bar (bird emblem + brand wordmark) ---------- */
.beep-embed .beep-bottom-bar {
display: flex;
align-items: center;
@@ -960,6 +1017,18 @@
margin-top: 2px;
}
.beep-embed .beep-post-emblem {
width: 20px;
height: auto;
flex: 0 0 auto;
margin-right: auto;
opacity: 0.55;
transition: opacity 0.15s ease;
user-select: none;
}
.beep-embed .beep-post:hover .beep-post-emblem { opacity: 0.9; }
.beep-embed .beep-post-wordmark {
opacity: 0.55;
flex: 0 0 auto;
@@ -971,6 +1040,7 @@
.beep-embed .beep-post:hover .beep-post-wordmark,
.beep-embed .beep-reply:hover .beep-post-wordmark { opacity: 0.85; }
.beep-embed .beep-reply:hover .beep-post-emblem { opacity: 0.9; }
.beep-embed .beep-delete-link {
background: transparent;
@@ -1047,12 +1117,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);
+273 -35
View File
@@ -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 ---------- */
@@ -88,26 +265,53 @@
var button = compose.querySelector('[data-beep-submit]');
if (!input || !button) return;
function hasAttachments() {
var pendingImages = compose._beepPendingImages;
var pendingVoice = compose._beepPendingVoice;
var hasGif = !!compose._beepGifUrl;
var hasQuote = !!(compose._beepQuotedPost && compose._beepQuotedPost.id);
var hasPoll = !!collectPollData(compose);
return (
(pendingImages && pendingImages.length) ||
pendingVoice ||
hasGif ||
hasQuote ||
hasPoll
);
}
function refresh() {
autosize(input);
var remaining = limit - countChars(input.value);
counter.textContent = remaining;
counter.classList.toggle('beep-counter-warn', remaining < 20 && remaining >= 0);
counter.classList.toggle('beep-counter-bad', remaining < 0);
button.disabled = input.value.trim() === '' || remaining < 0;
// Button is enabled when there's text OR any attachment (images, voice, GIF, quote, poll).
button.disabled = !(input.value.trim() !== '' || hasAttachments()) || remaining < 0;
}
input.addEventListener('input', refresh);
// Media / GIF / quote / poll changes also affect whether the button should be enabled.
compose.addEventListener('beep:attachments-changed', refresh);
refresh();
button.addEventListener('click', function () {
var content = input.value.trim();
if (!content) return;
// Allow image-only / media-only posts. Gate is "must have text OR an attachment".
if (!content && !hasAttachments()) return;
button.disabled = true;
var quotedPost = compose._beepQuotedPost;
var gifUrl = compose._beepGifUrl;
var pending = compose._beepPendingImages;
var pendingVoice = compose._beepPendingVoice;
var poll = collectPollData(compose);
var payload = { content: content };
if (quotedPost && quotedPost.id) payload.quote_id = quotedPost.id;
if (gifUrl) payload.gif_url = gifUrl;
// Tell the server what attachments are coming so it can accept empty content.
if (pending && pending.length) payload.has_images = 1;
if (pendingVoice) payload.has_voice = 1;
if (poll) payload.has_poll = 1;
fetch(config.restUrl + 'post', {
method: 'POST',
@@ -128,6 +332,7 @@
clearQuotePreview(compose);
if (compose._beepGifUrl) {
compose._beepGifUrl = null;
compose.dispatchEvent(new CustomEvent('beep:attachments-changed'));
var gifPrev = compose.querySelector('[data-beep-gif-preview]');
if (gifPrev) { gifPrev.innerHTML = ''; gifPrev.hidden = true; }
}
@@ -223,10 +428,30 @@
return '<svg viewBox="0 0 24 24" width="64" height="64" fill="#4a90c4" aria-hidden="true"><path d="M17.2 1.4c-.8-.1-1.5.2-2 .6C14.3.9 13.2.7 12.1 1c-1.4.4-2.3 1.7-2.1 3.2-2.1-.2-4.2-.9-5.7-2.5-.3-.3-.8-.3-1 0-.6.8-.9 1.7-.9 2.6 0 1.2.5 2.2 1.3 2.9-.3-.1-.6-.1-.8-.3-.4-.2-.9 0-.8.5.3 1.6 1.3 2.8 2.7 3.4-.3 0-.5 0-.8-.1-.4-.1-.8.3-.5.7.8 1.4 2.2 2.3 3.8 2.5-1.3.8-2.8 1.2-4.4 1.2-.5 0-.8.5-.5.9 1.1 1.1 2.9 1.6 5.5 1.6 5.4 0 9.8-4.4 9.8-9.8v-.2c.9-.6 1.6-1.4 2.1-2.4.2-.4-.2-.8-.6-.6-.5.2-1 .3-1.5.4.5-.5.9-1.1 1-1.8.1-.4-.4-.7-.7-.5-.7.4-1.5.6-2.3.7-.8-.7-1.8-1.1-2.9-1.1zm-.8 1.6c.7-.1 1.4.2 1.9.7.2.2.5.3.8.2.3-.1.6-.1.9-.3-.2.3-.5.5-.8.6-.3.1-.4.5-.3.7.2.3.1.5 0 .8v.2c0 4.7-3.8 8.5-8.5 8.5-1.6 0-2.9-.2-3.8-.6 1.7-.3 3.2-1 4.3-2.2.3-.3.1-.8-.3-.8-1.4 0-2.7-.6-3.5-1.7.4 0 .9-.1 1.3-.2.4-.1.4-.7 0-.8-1.5-.3-2.6-1.3-3-2.6.5.1 1 .2 1.5.1.4 0 .5-.6.1-.8C5.2 5.5 4.5 4.5 4.5 3.4c0-.4.1-.9.3-1.3 1.8 1.5 4 2.4 6.4 2.5.3 0 .5-.2.5-.5-.2-1.1.4-2.1 1.4-2.4.7-.2 1.4 0 1.9.4.3.2.7.1.9-.1.3-.4.8-.7 1.3-.7.4.1.8.2 1.2.4-.3.3-.5.6-.5 1 0 .3.3.5.5.4z"/></svg>';
}
/* ---------- YouTube cards (v1.9.1) ---------- */
function loadYtPlayer(card) {
if (!card || card.getAttribute('data-beep-yt-loaded') === '1') return;
card.setAttribute('data-beep-yt-loaded', '1');
var vid = card.getAttribute('data-beep-yt');
if (!vid) return;
var iframe = document.createElement('iframe');
iframe.src = 'https://www.youtube-nocookie.com/embed/' + encodeURIComponent(vid) + '?autoplay=1&rel=0';
iframe.title = 'YouTube video player';
iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share';
iframe.allowFullscreen = true;
iframe.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin');
card.innerHTML = '';
card.appendChild(iframe);
}
/* ---------- Click delegation ---------- */
function onClick(e) {
var t = e.target;
// YouTube card: click swaps the thumbnail for the inline player
var ytCard = t.closest('.beep-yt-card');
if (ytCard) { e.preventDefault(); loadYtPlayer(ytCard); return; }
var likePost = t.closest('[data-beep-like-post]');
if (likePost) { e.preventDefault(); if (requireLogin()) handleLike(likePost, 'like/' + likePost.dataset.beepLikePost); return; }
@@ -250,7 +475,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; }
@@ -285,7 +510,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;
@@ -295,7 +520,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;
@@ -308,6 +533,7 @@
if (compose) {
compose._beepQuotedPost = { id: quoted.id, data: quoted };
showQuotePreview(compose, quoted);
compose.dispatchEvent(new CustomEvent('beep:attachments-changed'));
}
})
.catch(function() { showToast('Beep not found.', 'error'); });
@@ -316,11 +542,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]');
@@ -331,7 +557,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;
@@ -341,9 +567,10 @@
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'));
var preview = compose.querySelector('[data-beep-gif-preview]');
if (preview) { preview.innerHTML = ''; preview.hidden = true; }
}
@@ -352,39 +579,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]');
@@ -415,7 +622,7 @@
// Click anywhere else on a feed post opens the conversation
var openPost = t.closest('[data-beep-open-thread]');
if (openPost && !t.closest('a, button, label, input, textarea, audio, video, .beep-media-grid-item, .beep-gif-embed, [data-beep-reply-form]')) {
if (openPost && !t.closest('a, button, label, input, textarea, audio, video, .beep-media-grid-item, .beep-gif-embed, .beep-yt-card, [data-beep-reply-form]')) {
// Don't hijack text selection
var sel = window.getSelection && window.getSelection();
if (sel && sel.toString()) return;
@@ -449,10 +656,16 @@
}
function onKeydown(e) {
// Enter/Space on a focused YouTube card plays it (card has tabindex="0")
if ((e.key === 'Enter' || e.key === ' ') && e.target.matches && e.target.matches('.beep-yt-card')) {
e.preventDefault();
loadYtPlayer(e.target);
return;
}
// 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;
}
@@ -695,6 +908,7 @@
compose._beepPendingVoice = pendingVoice;
voicePreview.innerHTML = '<div class="beep-voice-preview-card"><svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08C16.39 17.43 19 14.53 19 11h-2z"/></svg><span>Voice note ready</span><button class="beep-voice-remove" data-beep-voice-remove type="button" title="Remove">&times;</button></div>';
voicePreview.hidden = false;
if (compose) compose.dispatchEvent(new CustomEvent('beep:attachments-changed'));
};
reader.readAsDataURL(file);
voiceInput.value = '';
@@ -710,6 +924,7 @@
if (compose) compose._beepPendingVoice = null;
voicePreview.innerHTML = '';
voicePreview.hidden = true;
if (compose) compose.dispatchEvent(new CustomEvent('beep:attachments-changed'));
});
}
@@ -731,6 +946,7 @@
var id = 'pending-' + Date.now() + '-' + Math.random().toString(36).substr(2, 6);
pendingImages.push({ file: file, previewUrl: url, id: id });
renderPreviews();
if (compose) compose.dispatchEvent(new CustomEvent('beep:attachments-changed'));
};
reader.readAsDataURL(file);
});
@@ -766,6 +982,7 @@
pendingImages = pendingImages.filter(function (img) { return img.id !== id; });
compose._beepPendingImages = pendingImages;
renderPreviews();
if (compose) compose.dispatchEvent(new CustomEvent('beep:attachments-changed'));
});
}
@@ -773,6 +990,15 @@
compose._beepPendingImages = pendingImages;
compose._beepPendingVoice = pendingVoice;
}
// Poll input changes also affect "has attachments" (poll becomes valid mid-typing).
if (compose) {
compose.addEventListener('input', function (e) {
if (e.target.matches('[data-beep-poll-question], [data-beep-poll-option]')) {
compose.dispatchEvent(new CustomEvent('beep:attachments-changed'));
}
});
}
}
function uploadPendingImages(postId, pendingImages, compose) {
@@ -847,6 +1073,11 @@
.then(function(data) {
if (loading) loading.hidden = true;
content.innerHTML = data.html || '<div class="beep-thread-error">Nothing to show.</div>';
// 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]');
@@ -886,6 +1117,7 @@
.then(function(quoted) {
compose._beepQuotedPost = { id: quotedId, data: quoted };
showQuotePreview(compose, quoted);
compose.dispatchEvent(new CustomEvent('beep:attachments-changed'));
})
.catch(function(err) { console.warn('Could not load quoted beep:', err); });
}
@@ -919,6 +1151,7 @@
var removeBtn = compose.querySelector('[data-beep-quote-remove]');
if (preview) { preview.innerHTML = ''; preview.hidden = true; }
if (removeBtn) removeBtn.hidden = true;
compose.dispatchEvent(new CustomEvent('beep:attachments-changed'));
}
/* ---------- GIF Picker (Giphy) ---------- */
@@ -989,6 +1222,7 @@
preview.hidden = false;
}
closeGifPicker();
compose.dispatchEvent(new CustomEvent('beep:attachments-changed'));
}
/* ---------- Poll helpers ---------- */
@@ -1005,6 +1239,7 @@
options = pollSection.querySelectorAll('[data-beep-poll-option]');
}
}
compose.dispatchEvent(new CustomEvent('beep:attachments-changed'));
}
function addPollOption(compose) {
@@ -1019,6 +1254,7 @@
newRow.innerHTML = '<input type="text" class="beep-poll-option-input" data-beep-poll-option placeholder="Option ' + (optionCount + 1) + '" maxlength="100">';
optionsContainer.appendChild(newRow);
updatePollButtons(compose);
compose.dispatchEvent(new CustomEvent('beep:attachments-changed'));
}
function removePollOption(compose) {
@@ -1029,6 +1265,7 @@
var lastOption = options[options.length - 1];
if (lastOption) lastOption.parentElement.remove();
updatePollButtons(compose);
compose.dispatchEvent(new CustomEvent('beep:attachments-changed'));
}
function updatePollButtons(compose) {
@@ -1060,6 +1297,7 @@
options[j].value = '';
}
}
compose.dispatchEvent(new CustomEvent('beep:attachments-changed'));
}
function collectPollData(compose) {
+2 -2
View File
@@ -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.0
* Version: 1.9.4
* 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.0');
define('BEEP_VERSION', '1.9.4');
define('BEEP_FILE', __FILE__);
define('BEEP_DIR', plugin_dir_path(__FILE__));
define('BEEP_URL', plugin_dir_url(__FILE__));
+1
View File
@@ -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();
}
+181 -6
View File
@@ -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.');
+2 -1
View File
@@ -71,7 +71,7 @@ class Beep_Quotes {
$avatar = esc_url($author['avatar']);
$name = esc_html($author['name']);
$handle = esc_html('@' . $author['username']);
$content = make_clickable(wp_kses_post($quoted['content']));
$content = make_clickable(wp_kses_post(beep_strip_youtube_link($quoted['content'])));
$time = esc_html($quoted['time_ago']);
$qid = esc_attr($quoted['id']);
@@ -87,6 +87,7 @@ class Beep_Quotes {
'<span class="beep-quoted-time">' . $time . '</span>' .
'</div>' .
'<div class="beep-quoted-text">' . $content . '</div>' .
beep_render_youtube_card($quoted['content']) .
'</div></div>';
}
+248 -4
View File
@@ -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,13 +64,42 @@ 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<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 (empty($content)) {
return new WP_Error('empty_content', 'Beep cannot be empty.', ['status' => 400]);
}
if (mb_strlen($content) > BEEP_CHAR_LIMIT) {
return new WP_Error('content_too_long', 'Beep exceeds ' . BEEP_CHAR_LIMIT . ' character limit.', ['status' => 400]);
}
@@ -48,6 +108,21 @@ class Beep_Replies {
$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,
@@ -76,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,
@@ -89,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);
+1
View File
@@ -100,6 +100,7 @@ class Beep_Shortcode {
?>
<div class="beep-feed beep-embed beep-theme-<?php echo esc_attr($theme); ?>" data-beep-feed>
<header class="beep-feed-header">
<img class="beep-feed-emblem" src="<?php echo esc_url(BEEP_URL . 'assets/beep-bird-emblem.png?v=' . BEEP_VERSION); ?>" alt="Beep!" width="36" height="37">
<?php if ($atts['header'] !== '') : ?>
<span class="beep-feed-header-title"><?php echo esc_html($atts['header']); ?></span>
<?php endif; ?>
+256 -65
View File
@@ -146,62 +146,11 @@ function beep_render_composer() {
placeholder="What's happening?"
maxlength="<?php echo (int) BEEP_CHAR_LIMIT; ?>"
rows="1"></textarea>
<div class="beep-compose-media-preview" data-beep-media-preview hidden></div>
<div class="beep-compose-voice-preview" data-beep-voice-preview hidden>
<div class="beep-voice-preview-card">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08C16.39 17.43 19 14.53 19 11h-2z"/></svg>
<span>Voice note</span>
<button class="beep-voice-remove" data-beep-voice-remove type="button" title="Remove">&times;</button>
</div>
</div>
<div class="beep-compose-gif" data-beep-gif-preview hidden></div>
<div class="beep-compose-quote" data-beep-compose-quote hidden>
<div class="beep-quote-input-row">
<input type="text" class="beep-quote-lookup" placeholder="Paste beep URL or ID..." data-beep-quote-lookup>
<button type="button" class="beep-quote-lookup-btn" data-beep-quote-lookup-btn>Add</button>
</div>
<div class="beep-quote-preview" data-beep-quote-preview hidden></div>
<button type="button" class="beep-quote-remove" data-beep-quote-remove hidden>Remove quote</button>
</div>
<div class="beep-compose-poll" data-beep-compose-poll hidden>
<div class="beep-poll-input-row">
<input type="text" class="beep-poll-question" data-beep-poll-question placeholder="Ask a question...">
</div>
<div class="beep-poll-options" data-beep-poll-options>
<div class="beep-poll-option-row">
<input type="text" class="beep-poll-option" data-beep-poll-option placeholder="Option 1" maxlength="100">
</div>
<div class="beep-poll-option-row">
<input type="text" class="beep-poll-option" data-beep-poll-option placeholder="Option 2" maxlength="100">
</div>
</div>
<div class="beep-poll-actions">
<button type="button" class="beep-poll-add-option" data-beep-poll-add-option>+ Add option</button>
<button type="button" class="beep-poll-remove-option" data-beep-poll-remove-option hidden>&minus; Remove</button>
<button type="button" class="beep-poll-remove" data-beep-poll-remove>Remove poll</button>
</div>
</div>
<?php echo beep_render_media_previews('compose'); ?>
<?php echo beep_render_quote_section('compose'); ?>
<?php echo beep_render_poll_section('compose'); ?>
<div class="beep-compose-footer">
<div class="beep-compose-tools">
<label class="beep-compose-tool beep-media-btn" title="Add up to 4 images">
<input type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple data-beep-media-input hidden>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M3 5.5C3 4.119 4.119 3 5.5 3h13C19.881 3 21 4.119 21 5.5v13c0 1.381-1.119 2.5-2.5 2.5h-13C4.119 21 3 19.881 3 18.5v-13zM5.5 5c-.276 0-.5.224-.5.5v9.086l3-3 3 3 5-5 3 3V5.5c0-.276-.224-.5-.5-.5h-13zM19 15.414l-3-3-5 5-3-3-3 3V18.5c0 .276.224.5.5.5h13c.276 0 .5-.224.5-.5v-3.086zM9.75 7C8.784 7 8 7.784 8 8.75s.784 1.75 1.75 1.75 1.75-.784 1.75-1.75S10.716 7 9.75 7z"/></svg>
</label>
<span class="beep-media-count" data-beep-media-count hidden>0/4</span>
<button type="button" class="beep-compose-tool beep-gif-btn" data-beep-gif-btn title="Add GIF">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M3 5.5C3 4.119 4.119 3 5.5 3h13C19.881 3 21 4.119 21 5.5v13c0 1.381-1.119 2.5-2.5 2.5h-13C4.119 21 3 19.881 3 18.5v-13zM5.5 5c-.276 0-.5.224-.5.5v13c0 .276.224.5.5.5h13c.276 0 .5-.224.5-.5v-13c0-.276-.224-.5-.5-.5h-13zM18 10.711V9.25h-3.74v5.5h1.44v-1.719h1.7V11.57h-1.7v-.859H18zM11.79 9.25h1.44v5.5h-1.44v-5.5zm-3.07 1.375c.34 0 .77.172 1.02.43l1.03-.86c-.51-.601-1.28-.945-2.05-.945C7.19 9.25 6 10.453 6 12s1.19 2.75 2.72 2.75c.85 0 1.54-.344 2.05-.945v-2.149H8.38v1.032H9.4v.515c-.17.086-.42.172-.68.172-.76 0-1.36-.602-1.36-1.375 0-.688.6-1.375 1.36-1.375z"/></svg>
</button>
<label class="beep-compose-tool beep-voice-btn" title="Add voice note">
<input type="file" accept="audio/mpeg,audio/ogg,audio/wav,audio/webm,audio/mp4,audio/x-m4a" data-beep-voice-input hidden>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08C16.39 17.43 19 14.53 19 11h-2z"/></svg>
</label>
<button type="button" class="beep-compose-tool beep-poll-btn" data-beep-poll-btn title="Add poll">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M6 5c-1.1 0-2 .895-2 2s.9 2 2 2 2-.895 2-2-.9-2-2-2zM2 7c0-2.209 1.79-4 4-4s4 1.791 4 4-1.79 4-4 4-4-1.791-4-4zm20 1H12V6h10v2zM6 15c-1.1 0-2 .895-2 2s.9 2 2 2 2-.895 2-2-.9-2-2-2zm-4 2c0-2.209 1.79-4 4-4s4 1.791 4 4-1.79 4-4 4-4-1.791-4-4zm20 1H12v-2h10v2z"/></svg>
</button>
<button type="button" class="beep-compose-tool beep-quote-composer-btn" data-beep-quote-composer-btn title="Quote a beep">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M14.23 2.854c.98-.977 2.56-.977 3.54 0l3.38 3.378c.97.977.97 2.559 0 3.536L9.91 21H3v-6.914L14.23 2.854zm2.12 1.414c-.19-.195-.51-.195-.7 0L5 14.914V19h4.09L19.73 8.354c.2-.196.2-.512 0-.708l-3.38-3.378z"/></svg>
</button>
</div>
<?php echo beep_render_compose_toolbar('compose'); ?>
<div class="beep-compose-submit-row">
<span class="beep-char-counter" data-beep-counter><?php echo (int) BEEP_CHAR_LIMIT; ?></span>
<button class="beep-button beep-post-button" data-beep-submit disabled>Beep</button>
@@ -213,6 +162,136 @@ function beep_render_composer() {
return ob_get_clean();
}
/**
* Render the media previews block (image grid + voice card + GIF card).
* Shared between compose and reply forms.
*
* @param string $scope 'compose' or 'reply'
*/
function beep_render_media_previews($scope = 'compose') {
$cls = $scope === 'reply' ? 'beep-reply' : 'beep-compose';
?>
<div class="<?php echo esc_attr($cls); ?>-media-preview" data-beep-media-preview hidden></div>
<div class="<?php echo esc_attr($cls); ?>-voice-preview" data-beep-voice-preview hidden>
<div class="beep-voice-preview-card">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08C16.39 17.43 19 14.53 19 11h-2z"/></svg>
<span>Voice note</span>
<button class="beep-voice-remove" data-beep-voice-remove type="button" title="Remove">&times;</button>
</div>
</div>
<div class="<?php echo esc_attr($cls); ?>-gif" data-beep-gif-preview hidden></div>
<?php
}
/**
* Render the quote-lookup section (input + preview).
* Shared between compose and reply forms.
*/
function beep_render_quote_section($scope = 'compose') {
$cls = $scope === 'reply' ? 'beep-reply' : 'beep-compose';
?>
<div class="<?php echo esc_attr($cls); ?>-quote" data-beep-compose-quote hidden>
<div class="beep-quote-input-row">
<input type="text" class="beep-quote-lookup" placeholder="Paste beep URL or ID..." data-beep-quote-lookup>
<button type="button" class="beep-quote-lookup-btn" data-beep-quote-lookup-btn>Add</button>
</div>
<div class="beep-quote-preview" data-beep-quote-preview hidden></div>
<button type="button" class="beep-quote-remove" data-beep-quote-remove hidden>Remove quote</button>
</div>
<?php
}
/**
* Render the poll-creation section.
* Shared between compose and reply forms.
*/
function beep_render_poll_section($scope = 'compose') {
$cls = $scope === 'reply' ? 'beep-reply' : 'beep-compose';
?>
<div class="<?php echo esc_attr($cls); ?>-poll" data-beep-compose-poll hidden>
<div class="beep-poll-input-row">
<input type="text" class="beep-poll-question" data-beep-poll-question placeholder="Ask a question...">
</div>
<div class="beep-poll-options" data-beep-poll-options>
<div class="beep-poll-option-row">
<input type="text" class="beep-poll-option" data-beep-poll-option placeholder="Option 1" maxlength="100">
</div>
<div class="beep-poll-option-row">
<input type="text" class="beep-poll-option" data-beep-poll-option placeholder="Option 2" maxlength="100">
</div>
</div>
<div class="beep-poll-actions">
<button type="button" class="beep-poll-add-option" data-beep-poll-add-option>+ Add option</button>
<button type="button" class="beep-poll-remove-option" data-beep-poll-remove-option hidden>&minus; Remove</button>
<button type="button" class="beep-poll-remove" data-beep-poll-remove>Remove poll</button>
</div>
</div>
<?php
}
/**
* Render the compose toolbar (image / GIF / voice / poll / quote buttons).
* Shared between compose and reply forms.
*/
function beep_render_compose_toolbar($scope = 'compose') {
$cls = $scope === 'reply' ? 'beep-reply' : 'beep-compose';
?>
<div class="<?php echo esc_attr($cls); ?>-tools">
<label class="<?php echo esc_attr($cls); ?>-tool <?php echo esc_attr($cls); ?>-media-btn" title="Add up to 4 images">
<input type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple data-beep-media-input hidden>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M3 5.5C3 4.119 4.119 3 5.5 3h13C19.881 3 21 4.119 21 5.5v13c0 1.381-1.119 2.5-2.5 2.5h-13C4.119 21 3 19.881 3 18.5v-13zM5.5 5c-.276 0-.5.224-.5.5v9.086l3-3 3 3 5-5 3 3V5.5c0-.276-.224-.5-.5-.5h-13zM19 15.414l-3-3-5 5-3-3-3 3V18.5c0 .276.224.5.5.5h13c.276 0 .5-.224.5-.5v-3.086zM9.75 7C8.784 7 8 7.784 8 8.75s.784 1.75 1.75 1.75 1.75-.784 1.75-1.75S10.716 7 9.75 7z"/></svg>
</label>
<span class="beep-media-count" data-beep-media-count hidden>0/4</span>
<button type="button" class="<?php echo esc_attr($cls); ?>-tool <?php echo esc_attr($cls); ?>-gif-btn" data-beep-gif-btn title="Add GIF">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M3 5.5C3 4.119 4.119 3 5.5 3h13C19.881 3 21 4.119 21 5.5v13c0 1.381-1.119 2.5-2.5 2.5h-13C4.119 21 3 19.881 3 18.5v-13zM5.5 5c-.276 0-.5.224-.5.5v13c0 .276.224.5.5.5h13c.276 0 .5-.224.5-.5v-13c0-.276-.224-.5-.5-.5h-13zM18 10.711V9.25h-3.74v5.5h1.44v-1.719h1.7V11.57h-1.7v-.859H18zM11.79 9.25h1.44v5.5h-1.44v-5.5zm-3.07 1.375c.34 0 .77.172 1.02.43l1.03-.86c-.51-.601-1.28-.945-2.05-.945C7.19 9.25 6 10.453 6 12s1.19 2.75 2.72 2.75c.85 0 1.54-.344 2.05-.945v-2.149H8.38v1.032H9.4v.515c-.17.086-.42.172-.68.172-.76 0-1.36-.602-1.36-1.375 0-.688.6-1.375 1.36-1.375z"/></svg>
</button>
<label class="<?php echo esc_attr($cls); ?>-tool <?php echo esc_attr($cls); ?>-voice-btn" title="Add voice note">
<input type="file" accept="audio/mpeg,audio/ogg,audio/wav,audio/webm,audio/mp4,audio/x-m4a" data-beep-voice-input hidden>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08C16.39 17.43 19 14.53 19 11h-2z"/></svg>
</label>
<button type="button" class="<?php echo esc_attr($cls); ?>-tool <?php echo esc_attr($cls); ?>-poll-btn" data-beep-poll-btn title="Add poll">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M6 5c-1.1 0-2 .895-2 2s.9 2 2 2 2-.895 2-2-.9-2-2-2zM2 7c0-2.209 1.79-4 4-4s4 1.791 4 4-1.79 4-4 4-4-1.791-4-4zm20 1H12V6h10v2zM6 15c-1.1 0-2 .895-2 2s.9 2 2 2 2-.895 2-2-.9-2-2-2zm-4 2c0-2.209 1.79-4 4-4s4 1.791 4 4-1.79 4-4 4-4-1.791-4-4zm20 1H12v-2h10v2z"/></svg>
</button>
<button type="button" class="<?php echo esc_attr($cls); ?>-tool <?php echo esc_attr($cls); ?>-quote-composer-btn" data-beep-quote-composer-btn title="Quote a beep">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M14.23 2.854c.98-.977 2.56-.977 3.54 0l3.38 3.378c.97.977.97 2.559 0 3.536L9.91 21H3v-6.914L14.23 2.854zm2.12 1.414c-.19-.195-.51-.195-.7 0L5 14.914V19h4.09L19.73 8.354c.2-.196.2-.512 0-.708l-3.38-3.378z"/></svg>
</button>
</div>
<?php
}
/**
* Render a reply poll (v1.9.0).
* Mirrors beep_render_poll() for posts.
*/
function beep_render_reply_poll($comment_id) {
$poll = Beep_Replies::get_reply_poll($comment_id);
if (!$poll) return '';
$total = (int) $poll['total_votes'];
$opts = $poll['options'];
$votes = $poll['votes'];
$has_ended = $poll['ends_at'] && strtotime($poll['ends_at']) < time();
?>
<div class="beep-poll-container" data-beep-reply-poll data-beep-reply-poll-id="<?php echo (int) $poll['id']; ?>">
<div class="beep-poll-question"><?php echo esc_html($poll['question']); ?></div>
<div class="beep-poll-options">
<?php foreach ($opts as $i => $opt) :
$pct = $total > 0 ? round(((int) ($votes[$i] ?? 0)) * 100 / $total) : 0;
$has_voted = false; // kept simple for v1.9.0; full voting comes in v1.9.1
?>
<div class="beep-poll-result<?php echo $has_voted ? ' is-voted' : ''; ?>">
<div class="beep-poll-result-bar" style="width:<?php echo (int) $pct; ?>%"></div>
<span class="beep-poll-result-text"><?php echo esc_html($opt); ?></span>
<span class="beep-poll-result-percent"><?php echo (int) $pct; ?>%</span>
</div>
<?php endforeach; ?>
</div>
<div class="beep-poll-meta">
<span class="beep-poll-votes"><?php echo $total; ?> <?php echo $total === 1 ? 'vote' : 'votes'; ?></span>
</div>
</div>
<?php
}
/**
* Render a single beep post.
*
@@ -239,7 +318,7 @@ function beep_render_post($post, $args = []) {
$time_iso = gmdate('c', $ts);
$time_full = mysql2date('F j, Y g:i a', $post->post_date);
$content = beep_format_content($post->post_content);
$content = beep_format_content(beep_strip_youtube_link($post->post_content));
$like_count = Beep_Likes::get_count($post->ID, 'post');
$reply_count = Beep_Replies::reply_count($post->ID);
$user_liked = Beep_Likes::user_has_liked($post->ID, 'post');
@@ -264,7 +343,6 @@ function beep_render_post($post, $args = []) {
datetime="<?php echo esc_attr($time_iso); ?>"
title="<?php echo esc_attr($time_full); ?>"><?php echo esc_html($time); ?></time>
<span class="beep-spacer"></span>
<?php echo beep_bird_svg( ['size' => 18, 'class' => 'beep-post-badge'] ); ?>
<?php if ($can_delete) : ?>
<button class="beep-delete-link"
data-beep-delete="<?php echo (int) $post->ID; ?>"
@@ -274,6 +352,7 @@ function beep_render_post($post, $args = []) {
<?php endif; ?>
</div>
<div class="beep-text"><?php echo $content; ?></div>
<?php echo beep_render_youtube_card($post->post_content); ?>
<?php if (!empty($beep_images)) : ?>
<div class="beep-media-attachments">
<?php echo Beep_Media::render_image_gallery($beep_images); ?>
@@ -342,6 +421,7 @@ function beep_render_post($post, $args = []) {
</button>
</div>
<div class="beep-bottom-bar">
<img class="beep-post-emblem" src="<?php echo esc_url(BEEP_URL . 'assets/beep-bird-emblem.png?v=' . BEEP_VERSION); ?>" alt="" width="20" height="21" aria-hidden="true">
<?php echo beep_post_wordmark(); ?>
</div>
@@ -429,10 +509,13 @@ function beep_render_poll($post_id) {
* @param int $parent The parent comment ID (0 for top-level reply).
*/
function beep_render_reply_form($post_id, $parent) {
if ( ! is_user_logged_in() || beep_user_is_banned( get_current_user_id() ) ) {
return '';
}
$hidden = $parent ? 'hidden' : '';
ob_start();
?>
<div class="beep-reply-form" data-beep-reply-form data-beep-parent="<?php echo (int) $parent; ?>" <?php echo $hidden; ?>>
<div class="beep-reply-form beep-reply-form-compact" data-beep-reply-form data-beep-parent="<?php echo (int) $parent; ?>" <?php echo $hidden; ?>>
<?php echo beep_avatar_img(get_current_user_id(), 'beep-avatar-sm'); ?>
<div class="beep-reply-form-body">
<textarea
@@ -440,13 +523,19 @@ function beep_render_reply_form($post_id, $parent) {
placeholder="Post your reply"
maxlength="<?php echo (int) BEEP_CHAR_LIMIT; ?>"
rows="1"></textarea>
<?php echo beep_render_media_previews('reply'); ?>
<?php echo beep_render_quote_section('reply'); ?>
<?php echo beep_render_poll_section('reply'); ?>
<div class="beep-reply-footer">
<span class="beep-char-counter"><?php echo (int) BEEP_CHAR_LIMIT; ?></span>
<button class="beep-button beep-reply-submit"
data-beep-reply-submit="<?php echo (int) $post_id; ?>"
data-beep-reply-parent="<?php echo (int) $parent; ?>"
type="button"
disabled>Reply</button>
<?php echo beep_render_compose_toolbar('reply'); ?>
<div class="beep-reply-submit-row">
<span class="beep-char-counter"><?php echo (int) BEEP_CHAR_LIMIT; ?></span>
<button class="beep-button beep-reply-submit"
data-beep-reply-submit="<?php echo (int) $post_id; ?>"
data-beep-reply-parent="<?php echo (int) $parent; ?>"
type="button"
disabled>Reply</button>
</div>
</div>
</div>
</div>
@@ -487,11 +576,18 @@ function beep_render_reply($comment, $all_comments = [], $depth = 0, $post_id =
$time = beep_relative_time($ts);
$time_iso = gmdate('c', $ts);
$content = beep_format_content($comment->comment_content);
$content = beep_format_content(beep_strip_youtube_link($comment->comment_content));
$can_delete = current_user_can('moderate_comments') || ($author_id && $author_id === get_current_user_id());
$like_count = Beep_Likes::get_count($comment->comment_ID, 'reply');
$user_liked = Beep_Likes::user_has_liked($comment->comment_ID, 'reply');
// Reply media (v1.9.0)
$reply_images = Beep_Replies::get_reply_images($comment->comment_ID);
$reply_voice = Beep_Replies::get_reply_voice_url($comment->comment_ID);
$reply_gif = Beep_Replies::get_reply_gif_url($comment->comment_ID);
$reply_quote_id = Beep_Replies::get_reply_quoted_post_id($comment->comment_ID);
$reply_quote = $reply_quote_id ? get_post($reply_quote_id) : null;
$depth_class = 'beep-depth-' . min((int) $depth, 2);
ob_start();
@@ -516,6 +612,41 @@ function beep_render_reply($comment, $all_comments = [], $depth = 0, $post_id =
<?php endif; ?>
</div>
<div class="beep-text"><?php echo $content; ?></div>
<?php echo beep_render_youtube_card($comment->comment_content); ?>
<?php if (!empty($reply_images)) : ?>
<div class="beep-media-attachments">
<?php echo Beep_Media::render_image_gallery($reply_images); ?>
</div>
<?php endif; ?>
<?php if ($reply_voice) : ?>
<div class="beep-media-attachments">
<div class="beep-voice-note">
<audio controls preload="none">
<source src="<?php echo esc_url($reply_voice); ?>">
Your browser does not support audio playback.
</audio>
<div class="beep-voice-label">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08C16.39 17.43 19 14.53 19 11h-2z"/></svg>
Voice note
</div>
</div>
</div>
<?php endif; ?>
<?php if ($reply_quote && $reply_quote->post_type === 'beep') : ?>
<div class="beep-quoted-container">
<?php
$quoted_data = Beep_Quotes::get_quoted_post($reply_quote->ID);
if ($quoted_data) echo Beep_Quotes::render_quoted_post($reply_quote->ID);
?>
</div>
<?php endif; ?>
<?php if ($reply_gif) : ?>
<div class="beep-gif-embed">
<img src="<?php echo esc_url($reply_gif); ?>" alt="GIF" loading="lazy">
</div>
<?php endif; ?>
<?php echo beep_render_reply_poll($comment->comment_ID); ?>
<?php if ($can_interact) : ?>
<div class="beep-actions beep-actions-reply">
@@ -537,6 +668,11 @@ function beep_render_reply($comment, $all_comments = [], $depth = 0, $post_id =
<?php echo beep_render_reply_form($post_id, $comment->comment_ID); ?>
<?php endif; ?>
<div class="beep-bottom-bar">
<img class="beep-post-emblem" src="<?php echo esc_url(BEEP_URL . 'assets/beep-bird-emblem.png?v=' . BEEP_VERSION); ?>" alt="" width="20" height="21" aria-hidden="true">
<?php echo beep_post_wordmark(); ?>
</div>
<?php
// Recursively render children
$children = beep_render_replies_tree($all_comments, $comment->comment_ID, $depth + 1, $post_id, $can_interact);
@@ -581,6 +717,61 @@ function beep_format_content($text) {
return $text;
}
/**
* Extract the first YouTube video ID from a text string, if any.
* Supports youtube.com/watch?v=, youtu.be/, shorts/, embed/, live/.
* Returns the 11-char video ID or ''.
*/
function beep_youtube_id_from_text($text) {
if (!preg_match_all('~(?<![\w.-])(?:https?://)?(?:www\.|m\.)?(?:youtube\.com/(?:watch\?[^#\s]*v=|shorts/|embed/|live/)|youtu\.be/)([A-Za-z0-9_-]{11})(?![A-Za-z0-9_-])~i', (string) $text, $m)) {
return '';
}
return $m[1][0];
}
/**
* Render a click-to-play YouTube card for the first YouTube link in $text.
* Thumbnail (hqdefault) + play button; JS swaps in the iframe on click.
* Returns '' when no YouTube link is present.
*/
function beep_render_youtube_card($text) {
$vid = beep_youtube_id_from_text($text);
if ($vid === '') {
return '';
}
return '<div class="beep-yt-card" data-beep-yt="' . esc_attr($vid) . '" role="button" tabindex="0" aria-label="Play embedded YouTube video">'
. '<img class="beep-yt-thumb" src="https://i.ytimg.com/vi/' . esc_attr($vid) . '/hqdefault.jpg" alt="" loading="lazy">'
. '<span class="beep-yt-play"><svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg></span>'
. '</div>';
}
/**
* Remove the first YouTube URL from text (the one that gets a video card),
* so the raw link doesn't display above the card. Returns text unchanged
* when no YouTube link is present.
*/
function beep_strip_youtube_link($text) {
$vid = beep_youtube_id_from_text($text);
if ($vid === '') {
return $text;
}
$stripped = preg_replace_callback(
'~(?<![\w.-])(?:https?://)?(?:www\.|m\.)?(?:youtube\.com/(?:watch\?[^#\s]*v=|shorts/|embed/|live/)|youtu\.be/)[A-Za-z0-9_-]{11}(?![A-Za-z0-9_-])[^\s]*~i',
function ($m) {
// Preserve trailing sentence punctuation that the URL regex grabbed
return preg_match('/([.,;:!?]+)$/', $m[0], $t) ? $t[1] : '';
},
(string) $text,
1
);
if ($stripped === null) {
return $text;
}
$stripped = preg_replace('~[ \t]{2,}~', ' ', $stripped);
$stripped = preg_replace('~[ \t]+([.,;:!?])~', '$1', $stripped);
return trim($stripped);
}
function beep_relative_time($timestamp) {
$now = time();
$diff = $now - $timestamp;
+25 -1
View File
@@ -4,7 +4,7 @@ Tags: microblog, twitter, tweets, social, comments, polls, elementor
Requires at least: 6.0
Tested up to: 6.9
Requires PHP: 7.4
Stable tag: 1.8.0
Stable tag: 1.9.4
License: GPLv2 or later
A Twitter-style microblog for WordPress. Posts, likes, replies, polls, quotes, GIFs, and Gravatars. Use [beep_feed] shortcode or Elementor widget.
@@ -46,6 +46,30 @@ Features:
== Changelog ==
= 1.9.4 =
* Moved the bird badge from the top-right of beeps to a small emblem at the bottom-left, opposite the Beep! wordmark.
= 1.9.3 =
* New header emblem: the Beep bird now marks the feed header where the X mark sits on X.
= 1.9.2 =
* YouTube links no longer display as raw hyperlinks above the video card - the card replaces the URL text (posts, replies, and quoted beeps).
= 1.9.1 =
* YouTube links in beeps, replies, and quoted beeps automatically render as a video card (thumbnail + play button; click to play inline). Supports youtube.com/watch, youtu.be, shorts, embed, and live URLs.
= 1.9.0 =
* Reply composer now supports full media: images (up to 4), GIFs, voice notes, quotes, and polls. The reply form has the same toolbar as the top-level composer.
* Replies can be posted with media only — no text required (server gate mirrors the v1.8.1 post rule).
* Reply polls live in a new {prefix}beep_reply_polls table; reply images/voice/GIF/quote-id live in comment meta (parallel to post meta).
* Refactored the top-level composer into shared helpers (beep_render_media_previews, beep_render_quote_section, beep_render_poll_section, beep_render_compose_toolbar) used by both compose and reply forms.
* New REST routes: POST /reply/{id}/media, DELETE /reply/{id}/media/{attachment_id}, POST /reply/{id}/voice, POST /reply/{id}/gif, POST /reply/{id}/quote, POST /reply/{id}/poll.
* Beep_Media got parent-agnostic wrappers (handle_upload_for_parent, handle_voice_upload_for_parent, remove_image_from_parent) — upload pipeline now reused for posts and comments.
= 1.8.1 =
* Image-only / media-only posts: you can now post images, GIFs, voice notes, quotes, or polls without typing any words. The Beep button enables automatically when any attachment is present (server-side too — empty content + no attachments still returns empty_content).
* The composer button refreshes whenever media is added or removed (images, voice, GIF, quote, poll).
= 1.8.0 =
* Twitter-style feed: the timeline now shows only top-level beeps; clicking a beep (or its reply count) opens the full conversation in a modal with working reply forms, likes, and nested threads.
* Light theme + auto mode: theme="auto|dark|light" shortcode attribute; auto follows the visitor's system preference.