Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 97eb31bd9d | |||
| 4b9a695f26 | |||
| a89ed3749e | |||
| 7ca8a7d49f | |||
| ecb7f871f9 | |||
| 05b9b4cc57 | |||
| 2b65194cdb | |||
| 6eb128b780 | |||
| 64344bfef6 | |||
| 5bb39316b1 | |||
| ba00d0d382 | |||
| 955e443274 | |||
| 1fb4085d64 | |||
| eb40212a79 | |||
| 1703a09319 | |||
| b7345c462d | |||
| 79e124f282 | |||
| 9ce1cff8b8 | |||
| 2788dc8d96 | |||
| d2c8ca6a47 | |||
| a94e8266d7 | |||
| 5c6eba625a | |||
| acef2a0f40 | |||
| ad062bc81b | |||
| abdd8893fe | |||
| 94f063f09a | |||
| 97ca5e1714 | |||
| b882092f5d | |||
| dfd621e350 | |||
| 237ddbfcc8 | |||
| 9f696771d8 | |||
| 996848e588 | |||
| 85d9063ea5 | |||
| 6390e9ac70 | |||
| aad4d8e21e | |||
| bd74e793ff | |||
| 5571188d76 | |||
| 9f91b0c544 | |||
| 07ac9f112d | |||
| 6c9b06ccb2 | |||
| 0807eb74d9 | |||
| ad339078ae | |||
| ccead50116 | |||
| 8066744731 |
@@ -0,0 +1 @@
|
||||
connectivity test
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
A Twitter/X-style microblog for WordPress. Logged-in users post short updates with rich media, polls, GIFs, voice notes, and quotes. Like, reply (with nested threads), and share beeps. Designed to look like a Twitter timeline, hosted entirely on your own site.
|
||||
|
||||
## What's in v1.8.0
|
||||
|
||||
- **Twitter-style timeline.** The feed shows only top-level beeps; click a beep (or its reply count) to open the conversation in a modal with reply forms, likes, and nested threads.
|
||||
- **Light theme + auto mode.** `theme="auto"` (default) follows the visitor's system preference; `dark` and `light` force a theme.
|
||||
- **Lots of fixes.** Composer avatar, modal close buttons, CSS scoping, Elementor widget registration (and Beep works fine without Elementor), real avatars on replies, action-bar polish. See `readme.txt` for the full changelog.
|
||||
|
||||
## What's in v1.5.1
|
||||
|
||||
### Core Features
|
||||
@@ -67,12 +73,14 @@ This is the easiest way to let a small circle into your Beep feed without settin
|
||||
## Shortcode options
|
||||
|
||||
```
|
||||
[beep_feed limit="50" header="Messages from" sort="latest"]
|
||||
[beep_feed limit="50" header="Messages from" sort="latest" theme="auto" show_wordmark="yes"]
|
||||
```
|
||||
|
||||
- `limit` — how many beeps to load on first render (default 50)
|
||||
- `header` — text shown next to the wordmark (default "Messages from")
|
||||
- `header` — text shown next to the wordmark (default "Messages from"; pass `""` to hide)
|
||||
- `sort` — initial sort: `latest` (default) or `top`
|
||||
- `theme` — `auto` (default, follows the visitor's system preference), `dark`, or `light`
|
||||
- `show_wordmark` — `yes` (default) or `no`
|
||||
|
||||
## Elementor Widget
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 23 KiB |
+1016
-617
File diff suppressed because it is too large
Load Diff
+347
-84
@@ -7,9 +7,21 @@
|
||||
|
||||
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);
|
||||
// Delegate on document so dynamically injected content (thread modal,
|
||||
// GIF picker, toasts) is handled too.
|
||||
document.addEventListener('click', onClick);
|
||||
document.addEventListener('input', onInput);
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
document.addEventListener('keydown', onEscape);
|
||||
}
|
||||
|
||||
var feedState = { offset: 0, loading: false, hasMore: true, currentSort: 'latest' };
|
||||
@@ -20,9 +32,177 @@
|
||||
initSortTabs(feed);
|
||||
initMedia(compose, feed);
|
||||
initInfiniteScroll(feed);
|
||||
feed.addEventListener('click', onClick);
|
||||
feed.addEventListener('input', onInput);
|
||||
feed.addEventListener('keydown', onKeydown);
|
||||
// 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 ---------- */
|
||||
@@ -85,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',
|
||||
@@ -119,12 +326,13 @@
|
||||
if (empty) empty.remove();
|
||||
|
||||
// Reload feed to get the fully rendered post (includes media, quotes, polls)
|
||||
reloadFeed(feed, 'recent');
|
||||
reloadFeed(feed, feedState.currentSort);
|
||||
|
||||
input.value = '';
|
||||
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; }
|
||||
}
|
||||
@@ -134,7 +342,7 @@
|
||||
var pending = compose._beepPendingImages;
|
||||
var pendingVoice = compose._beepPendingVoice;
|
||||
var uploadDone = function () {
|
||||
reloadFeed(feed, 'latest');
|
||||
reloadFeed(feed, feedState.currentSort);
|
||||
};
|
||||
|
||||
var tasks = [];
|
||||
@@ -220,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; }
|
||||
|
||||
@@ -247,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; }
|
||||
@@ -282,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;
|
||||
@@ -292,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;
|
||||
@@ -305,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'); });
|
||||
@@ -313,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]');
|
||||
@@ -328,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;
|
||||
@@ -338,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; }
|
||||
}
|
||||
@@ -349,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]');
|
||||
@@ -405,6 +615,29 @@
|
||||
closeLightbox();
|
||||
return;
|
||||
}
|
||||
|
||||
// Modal backdrop click closes
|
||||
if (t.matches && t.matches('[data-beep-thread-modal]')) { closeThread(); return; }
|
||||
if (t.matches && t.matches('[data-beep-gif-modal]')) { closeGifPicker(); return; }
|
||||
|
||||
// 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, .beep-yt-card, [data-beep-reply-form]')) {
|
||||
// Don't hijack text selection
|
||||
var sel = window.getSelection && window.getSelection();
|
||||
if (sel && sel.toString()) return;
|
||||
openThread(openPost.dataset.beepOpenThread);
|
||||
}
|
||||
}
|
||||
|
||||
function onEscape(e) {
|
||||
if (e.key !== 'Escape') return;
|
||||
var lightbox = document.querySelector('.beep-lightbox');
|
||||
if (lightbox) { closeLightbox(); return; }
|
||||
var gifModal = document.querySelector('[data-beep-gif-modal]:not([hidden])');
|
||||
if (gifModal) { closeGifPicker(); return; }
|
||||
var threadModal = document.querySelector('[data-beep-thread-modal]:not([hidden])');
|
||||
if (threadModal) closeThread();
|
||||
}
|
||||
|
||||
function onInput(e) {
|
||||
@@ -423,12 +656,26 @@
|
||||
}
|
||||
|
||||
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 = formRoot(e.target) || document.querySelector('[data-beep-compose]');
|
||||
searchGifs(e.target.value, compose);
|
||||
return;
|
||||
}
|
||||
// Cmd/Ctrl+Enter submits beeps and replies
|
||||
if (!((e.metaKey || e.ctrlKey) && e.key === 'Enter')) return;
|
||||
var input = e.target.closest('textarea');
|
||||
if (!input) return;
|
||||
var wrap = input.closest('.beep-compose, .beep-reply-form');
|
||||
if (!wrap) return;
|
||||
var btn = wrap.querySelector('button');
|
||||
var btn = wrap.querySelector('[data-beep-submit], [data-beep-reply-submit]');
|
||||
if (btn && !btn.disabled) btn.click();
|
||||
}
|
||||
|
||||
@@ -486,10 +733,16 @@
|
||||
form.innerHTML = '<div class="beep-pending">Reply submitted, awaiting moderation.</div>';
|
||||
return;
|
||||
}
|
||||
// Reload the full feed to get the properly rendered reply
|
||||
input.value = '';
|
||||
// Inside the conversation modal: refresh just the thread.
|
||||
if (btn.closest('[data-beep-thread-modal]')) {
|
||||
refreshThread();
|
||||
return;
|
||||
}
|
||||
// Otherwise reload the feed with the current sort.
|
||||
var feed = btn.closest('[data-beep-feed]');
|
||||
if (feed) {
|
||||
reloadFeed(feed, 'latest');
|
||||
reloadFeed(feed, feedState.currentSort);
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
@@ -655,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">×</button></div>';
|
||||
voicePreview.hidden = false;
|
||||
if (compose) compose.dispatchEvent(new CustomEvent('beep:attachments-changed'));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
voiceInput.value = '';
|
||||
@@ -670,6 +924,7 @@
|
||||
if (compose) compose._beepPendingVoice = null;
|
||||
voicePreview.innerHTML = '';
|
||||
voicePreview.hidden = true;
|
||||
if (compose) compose.dispatchEvent(new CustomEvent('beep:attachments-changed'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -691,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);
|
||||
});
|
||||
@@ -726,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'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -733,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) {
|
||||
@@ -786,66 +1052,53 @@
|
||||
}
|
||||
|
||||
/* ---------- Thread / Conversation modal ---------- */
|
||||
function openThread(postId) {
|
||||
function openThread(postId, keepScroll) {
|
||||
var modal = document.querySelector('[data-beep-thread-modal]');
|
||||
var content = modal && modal.querySelector('[data-beep-thread-content]');
|
||||
var loading = modal && modal.querySelector('.beep-thread-loading');
|
||||
var loading = modal && modal.querySelector('[data-beep-thread-loading]');
|
||||
if (!modal || !content) return;
|
||||
modal.hidden = false;
|
||||
if (loading) loading.style.display = 'block';
|
||||
content.innerHTML = '';
|
||||
modal._beepThreadId = postId;
|
||||
if (!keepScroll) {
|
||||
if (loading) loading.hidden = false;
|
||||
content.innerHTML = '';
|
||||
}
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
|
||||
fetch(config.restUrl + 'thread/' + postId, {
|
||||
fetch(config.restUrl + 'thread/' + postId + '/html', {
|
||||
headers: { 'X-WP-Nonce': config.nonce },
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(handleResponse)
|
||||
.then(function(data) {
|
||||
if (loading) loading.style.display = 'none';
|
||||
// data can be an array directly or { thread: [...] }
|
||||
var thread = Array.isArray(data) ? data : (data.thread || []);
|
||||
renderThread(thread, content);
|
||||
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]');
|
||||
if (feedPost) feedPost.textContent = data.reply_count > 0 ? data.reply_count : '';
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
if (loading) loading.textContent = 'Failed to load conversation.';
|
||||
if (loading) loading.hidden = true;
|
||||
content.innerHTML = '<div class="beep-thread-error">Failed to load conversation.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function refreshThread() {
|
||||
var modal = document.querySelector('[data-beep-thread-modal]:not([hidden])');
|
||||
if (modal && modal._beepThreadId) openThread(modal._beepThreadId, true);
|
||||
}
|
||||
|
||||
function closeThread() {
|
||||
var modal = document.querySelector('[data-beep-thread-modal]');
|
||||
if (modal) modal.hidden = true;
|
||||
}
|
||||
|
||||
function renderThread(thread, container) {
|
||||
if (!thread || !thread.length) {
|
||||
container.innerHTML = '<p style="padding:16px;color:var(--beep-muted);text-align:center;">No replies yet.</p>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
thread.forEach(function(item) {
|
||||
var indent = Math.min(item.depth, 5) * 24;
|
||||
var author = item.author || {};
|
||||
var avatar = escapeHtml(author.avatar || 'https://www.gravatar.com/avatar/?s=48&d=mp');
|
||||
var name = escapeHtml(author.name || 'Unknown');
|
||||
var username = escapeHtml(author.username || '');
|
||||
var content_text = escapeHtml(item.content || '');
|
||||
var time_ago = escapeHtml(item.time_ago || '');
|
||||
var replyClass = item.depth > 0 ? 'beep-thread-reply' : 'beep-thread-root';
|
||||
html += '<div class="beep-thread-item ' + replyClass + '" style="margin-left:' + indent + 'px" data-thread-id="' + (item.id || '') + '">';
|
||||
html += '<div class="beep-thread-avatar"><img src="' + avatar + '" alt="' + name + '" width="40" height="40" style="border-radius:50%;vertical-align:top;"></div>';
|
||||
html += '<div class="beep-thread-body">';
|
||||
html += '<div class="beep-thread-meta">';
|
||||
html += '<span class="beep-thread-name">' + name + '</span>';
|
||||
html += '<span class="beep-thread-username">@' + username + '</span>';
|
||||
html += '<span class="beep-thread-time">' + time_ago + '</span>';
|
||||
html += '</div>';
|
||||
html += '<div class="beep-thread-content-text">' + content_text + '</div>';
|
||||
html += '<div class="beep-thread-actions">';
|
||||
html += '<span class="beep-thread-reply-count">' + (item.reply_count || 0) + ' replies</span>';
|
||||
html += '<span class="beep-thread-like-count">' + (item.like_count || 0) + ' likes</span>';
|
||||
html += '</div></div></div>';
|
||||
});
|
||||
container.innerHTML = html;
|
||||
document.documentElement.style.overflow = '';
|
||||
}
|
||||
|
||||
/* ---------- Quote posts ---------- */
|
||||
@@ -864,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); });
|
||||
}
|
||||
@@ -897,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) ---------- */
|
||||
@@ -967,6 +1222,7 @@
|
||||
preview.hidden = false;
|
||||
}
|
||||
closeGifPicker();
|
||||
compose.dispatchEvent(new CustomEvent('beep:attachments-changed'));
|
||||
}
|
||||
|
||||
/* ---------- Poll helpers ---------- */
|
||||
@@ -983,6 +1239,7 @@
|
||||
options = pollSection.querySelectorAll('[data-beep-poll-option]');
|
||||
}
|
||||
}
|
||||
compose.dispatchEvent(new CustomEvent('beep:attachments-changed'));
|
||||
}
|
||||
|
||||
function addPollOption(compose) {
|
||||
@@ -997,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) {
|
||||
@@ -1007,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) {
|
||||
@@ -1038,6 +1297,7 @@
|
||||
options[j].value = '';
|
||||
}
|
||||
}
|
||||
compose.dispatchEvent(new CustomEvent('beep:attachments-changed'));
|
||||
}
|
||||
|
||||
function collectPollData(compose) {
|
||||
@@ -1085,10 +1345,12 @@
|
||||
})
|
||||
.then(handleResponse)
|
||||
.then(function(data) {
|
||||
// Inside the conversation modal: refresh just the thread.
|
||||
if (btn.closest('[data-beep-thread-modal]')) { refreshThread(); return; }
|
||||
var postEl = btn.closest('[data-beep-post]');
|
||||
if (postEl) {
|
||||
var list = postEl.closest('[data-beep-list]');
|
||||
if (list) reloadFeed(list.closest('[data-beep-feed]'), 'latest');
|
||||
if (list) reloadFeed(list.closest('[data-beep-feed]'), feedState.currentSort);
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
@@ -1124,3 +1386,4 @@
|
||||
return res.json();
|
||||
}
|
||||
})();
|
||||
/* Beep frontend — end */
|
||||
|
||||
@@ -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.7.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.7.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__));
|
||||
@@ -31,9 +31,16 @@ require_once BEEP_DIR . 'includes/class-quotes.php';
|
||||
require_once BEEP_DIR . 'includes/class-polls.php';
|
||||
require_once BEEP_DIR . 'includes/class-shortcode.php';
|
||||
require_once BEEP_DIR . 'includes/class-settings.php';
|
||||
require_once BEEP_DIR . 'includes/class-updater.php';
|
||||
require_once BEEP_DIR . 'includes/class-beep.php';
|
||||
|
||||
register_activation_hook(__FILE__, ['Beep_Plugin', 'activate']);
|
||||
register_deactivation_hook(__FILE__, ['Beep_Plugin', 'deactivate']);
|
||||
|
||||
add_action('plugins_loaded', ['Beep_Plugin', 'init']);
|
||||
|
||||
// Self-hosted updates via the Gitea releases API.
|
||||
// Register in admin and during wp-cron (where WordPress runs its update checks).
|
||||
if (is_admin() || (defined('DOING_CRON') && DOING_CRON)) {
|
||||
Beep_Updater::init();
|
||||
}
|
||||
|
||||
+4
-20
@@ -15,19 +15,14 @@ class Beep_Plugin {
|
||||
|
||||
add_action('wp_enqueue_scripts', [__CLASS__, 'enqueue_assets']);
|
||||
|
||||
// Set default Giphy API key if not already set
|
||||
if (!get_option('beep_giphy_key')) {
|
||||
update_option('beep_giphy_key', 'xuxYxuIvzPqw5tPB4laa7sQO8dFtSlNS');
|
||||
}
|
||||
|
||||
// Elementor widget — only load if Elementor is active
|
||||
add_action('elementor/widgets/register', function ($widgets_manager) {
|
||||
if (!class_exists('\\Elementor\\Widget_Base')) return;
|
||||
if (!class_exists('\Elementor\Widget_Base')) return;
|
||||
$widget_file = BEEP_DIR . 'includes/class-elementor-widget.php';
|
||||
if (!file_exists($widget_file)) return;
|
||||
require_once $widget_file;
|
||||
if (class_exists('\\Elementor\\Beep_Elementor_Widget')) {
|
||||
$widgets_manager->register(new \\Elementor\\Beep_Elementor_Widget());
|
||||
if (class_exists('Beep_Elementor_Widget')) {
|
||||
$widgets_manager->register(new Beep_Elementor_Widget());
|
||||
}
|
||||
}, 5);
|
||||
|
||||
@@ -35,18 +30,6 @@ class Beep_Plugin {
|
||||
add_action('admin_menu', [__CLASS__, 'add_admin_menu']);
|
||||
add_action('admin_init', [__CLASS__, 'register_settings']);
|
||||
}
|
||||
// Don't inject in feeds, REST, admin, etc.
|
||||
if (is_feed() || is_admin() || (defined('REST_REQUEST') && REST_REQUEST)) {
|
||||
return $content;
|
||||
}
|
||||
// Don't double-inject if content already has a beep feed
|
||||
if (strpos($content, 'data-beep-feed') !== false) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$feed = do_shortcode('[beep_feed limit="10"]');
|
||||
return $feed . $content;
|
||||
}
|
||||
|
||||
public static function add_admin_menu() {
|
||||
add_options_page(
|
||||
@@ -93,6 +76,7 @@ class Beep_Plugin {
|
||||
Beep_CPT::register();
|
||||
Beep_Likes::create_table();
|
||||
Beep_Polls::create_tables();
|
||||
Beep_Replies::create_tables();
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (did_action('elementor/core')) {
|
||||
// Beep works fine without Elementor: this file is only loaded from the
|
||||
// 'elementor/widgets/register' hook, and bails unless Elementor is present.
|
||||
if (!class_exists('\Elementor\Widget_Base')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -122,6 +124,6 @@ class Beep_Elementor_Widget extends \Elementor\Widget_Base {
|
||||
'limit' => (int) $settings['limit'],
|
||||
];
|
||||
|
||||
echo Beep_Shortcode::render_beep_feed($atts);
|
||||
echo Beep_Shortcode::render_feed($atts);
|
||||
}
|
||||
}
|
||||
+181
-6
@@ -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.');
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -45,8 +45,7 @@ class Beep_Shortcode {
|
||||
$html .= beep_render_post($p);
|
||||
}
|
||||
if ($html === '' && $offset === 0) {
|
||||
$empty_bird = beep_bird_svg( ['size' => 64, 'fill' => '#4a90c4'] );
|
||||
$html = '<div class="beep-empty"><div class="beep-empty-icon">' . $empty_bird . '</div><p>No beeps yet.</p><span>Be the first to share something!</span></div>';
|
||||
$html = self::empty_state_html();
|
||||
}
|
||||
return rest_ensure_response(['html' => $html, 'sort' => $sort, 'has_more' => $has_more, 'offset' => $offset + count($posts)]);
|
||||
}
|
||||
@@ -84,30 +83,38 @@ class Beep_Shortcode {
|
||||
public static function render_feed($atts) {
|
||||
$atts = shortcode_atts([
|
||||
'limit' => 50,
|
||||
'header' => 'Beep!',
|
||||
'show_wordmark' => true,
|
||||
'header' => 'Messages from',
|
||||
'show_wordmark' => 'yes',
|
||||
'sort' => 'latest',
|
||||
'theme' => 'auto', // auto | dark | light
|
||||
], $atts, 'beep_feed');
|
||||
|
||||
$limit = max(1, (int) $atts['limit']);
|
||||
$sort = ($atts['sort'] === 'top') ? 'top' : 'latest';
|
||||
$theme = in_array($atts['theme'], ['dark', 'light'], true) ? $atts['theme'] : 'auto';
|
||||
$show_wordmark = !in_array(strtolower((string) $atts['show_wordmark']), ['no', 'false', '0'], true);
|
||||
$posts = ($sort === 'top') ? self::get_top_beeps($limit) : self::get_latest_beeps($limit);
|
||||
$has_posts = !empty($posts);
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<div class="beep-feed" data-beep-feed>
|
||||
<div class="beep-embed">
|
||||
<div class="beep-feed-header">
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:8px;"><strong style="color:#FFFFFF;">Messages from </strong><img src="https://sami-ahmed.net/wp-content/plugins/beep/assets/beep-wordmark.png" alt="Beep!" style="height:22px;width:auto;vertical-align:middle;"></div>
|
||||
</div>
|
||||
<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; ?>
|
||||
<?php if ($show_wordmark) : ?>
|
||||
<img class="beep-feed-wordmark" src="<?php echo esc_url(BEEP_URL . 'assets/beep-wordmark.png?v=' . BEEP_VERSION); ?>" alt="Beep!">
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
|
||||
<?php if ($has_posts) : ?>
|
||||
<div class="beep-tabs" data-beep-tabs role="tablist">
|
||||
<button type="button" class="beep-tab <?php echo $sort === 'latest' ? 'is-active' : ''; ?>"
|
||||
data-beep-sort="latest" role="tab">Latest</button>
|
||||
data-beep-sort="latest" role="tab"><span>Latest</span></button>
|
||||
<button type="button" class="beep-tab <?php echo $sort === 'top' ? 'is-active' : ''; ?>"
|
||||
data-beep-sort="top" role="tab">Top</button>
|
||||
data-beep-sort="top" role="tab"><span>Top</span></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -120,15 +127,23 @@ class Beep_Shortcode {
|
||||
echo beep_render_post($p);
|
||||
}
|
||||
} else {
|
||||
$empty_bird = beep_bird_svg( ['size' => 64, 'fill' => '#4a90c4'] );
|
||||
echo '<div class="beep-empty"><div class="beep-empty-icon">' . $empty_bird . '</div><p>No beeps yet.</p><span>Be the first to share something!</span></div>';
|
||||
echo self::empty_state_html();
|
||||
}
|
||||
?>
|
||||
</div><!-- .beep-list -->
|
||||
</div><!-- .beep-embed -->
|
||||
|
||||
<?php echo beep_render_thread_modal() . beep_render_gif_modal(); ?>
|
||||
</div><!-- .beep-feed -->
|
||||
<?php
|
||||
return ob_get_clean() . beep_render_thread_modal() . beep_render_gif_modal();
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty-state markup shared by shortcode and REST.
|
||||
*/
|
||||
public static function empty_state_html() {
|
||||
$empty_bird = beep_bird_svg(['size' => 64]);
|
||||
return '<div class="beep-empty"><div class="beep-empty-icon">' . $empty_bird . '</div><p>No beeps yet.</p><span>Be the first to share something!</span></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,6 +237,29 @@ class Beep_Shortcode {
|
||||
'callback' => [__CLASS__, 'get_thread'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
register_rest_route('beep/v1', '/thread/(?P<post_id>\d+)/html', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_thread_html'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* REST endpoint: server-rendered conversation view (root post + replies tree).
|
||||
* Reuses the exact same markup/styles as the feed.
|
||||
*/
|
||||
public static function get_thread_html($request) {
|
||||
$post_id = (int) $request->get_param('post_id');
|
||||
$post = get_post($post_id);
|
||||
if (!$post || $post->post_type !== Beep_CPT::POST_TYPE || $post->post_status !== 'publish') {
|
||||
return new WP_Error('not_found', 'Beep not found.', 404);
|
||||
}
|
||||
$html = beep_render_post($post, ['in_thread' => true, 'show_replies' => true]);
|
||||
return rest_ensure_response([
|
||||
'html' => $html,
|
||||
'id' => $post_id,
|
||||
'reply_count' => Beep_Replies::reply_count($post_id),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function get_thread($request) {
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Beep_Shortcode {
|
||||
|
||||
public static function init() {
|
||||
add_shortcode('beep_feed', [__CLASS__, 'render_feed']);
|
||||
add_action('rest_api_init', [__CLASS__, 'register_routes']);
|
||||
add_action('rest_api_init', [__CLASS__, 'register_media_routes']);
|
||||
add_action('rest_api_init', [__CLASS__, 'register_thread_routes']);
|
||||
add_action('rest_api_init', [__CLASS__, 'register_quote_routes']);
|
||||
}
|
||||
|
||||
public static function register_routes() {
|
||||
register_rest_route('beep/v1', '/feed', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'fetch_feed'],
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => [
|
||||
'sort' => ['default' => 'latest'],
|
||||
'limit' => ['default' => 50, 'validate_callback' => function ($v) { return is_numeric($v); }],
|
||||
'offset' => ['default' => 0, 'validate_callback' => function ($v) { return is_numeric($v); }],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* REST endpoint: re-render the feed list with the given sort.
|
||||
*/
|
||||
public static function fetch_feed($request) {
|
||||
$sort = $request->get_param('sort') === 'top' ? 'top' : 'latest';
|
||||
$limit = max(1, (int) $request->get_param('limit'));
|
||||
$offset = max(0, (int) $request->get_param('offset'));
|
||||
$posts = ($sort === 'top') ? self::get_top_beeps($limit + 1, $offset) : self::get_latest_beeps($limit + 1, $offset);
|
||||
|
||||
$has_more = count($posts) > $limit;
|
||||
if ($has_more) {
|
||||
$posts = array_slice($posts, 0, $limit);
|
||||
}
|
||||
|
||||
$html = '';
|
||||
foreach ($posts as $p) {
|
||||
$html .= beep_render_post($p);
|
||||
}
|
||||
if ($html === '' && $offset === 0) {
|
||||
$empty_bird = beep_bird_svg( ['size' => 64, 'fill' => '#4a90c4'] );
|
||||
$html = '<div class="beep-empty"><div class="beep-empty-icon">' . $empty_bird . '</div><p>No beeps yet.</p><span>Be the first to share something!</span></div>';
|
||||
}
|
||||
return rest_ensure_response(['html' => $html, 'sort' => $sort, 'has_more' => $has_more, 'offset' => $offset + count($posts)]);
|
||||
}
|
||||
|
||||
public static function get_latest_beeps($limit, $offset = 0) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->posts}
|
||||
WHERE post_type = %s AND post_status = 'publish'
|
||||
ORDER BY post_date DESC
|
||||
LIMIT %d OFFSET %d",
|
||||
Beep_CPT::POST_TYPE, $limit, $offset
|
||||
));
|
||||
}
|
||||
|
||||
public static function get_top_beeps($limit, $offset = 0) {
|
||||
global $wpdb;
|
||||
$likes = Beep_Likes::table_name();
|
||||
return $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT p.*, COALESCE(l.cnt, 0) AS like_count
|
||||
FROM {$wpdb->posts} p
|
||||
LEFT JOIN (
|
||||
SELECT post_id, COUNT(*) AS cnt
|
||||
FROM $likes
|
||||
WHERE object_type = 'post'
|
||||
GROUP BY post_id
|
||||
) l ON l.post_id = p.ID
|
||||
WHERE p.post_type = %s AND p.post_status = 'publish'
|
||||
ORDER BY COALESCE(l.cnt, 0) DESC, p.post_date DESC
|
||||
LIMIT %d OFFSET %d",
|
||||
Beep_CPT::POST_TYPE, $limit, $offset
|
||||
));
|
||||
}
|
||||
|
||||
public static function render_feed($atts) {
|
||||
$atts = shortcode_atts([
|
||||
'limit' => 50,
|
||||
'header' => 'Beep!',
|
||||
'show_wordmark' => true,
|
||||
'sort' => 'latest',
|
||||
], $atts, 'beep_feed');
|
||||
|
||||
$limit = max(1, (int) $atts['limit']);
|
||||
$sort = ($atts['sort'] === 'top') ? 'top' : 'latest';
|
||||
$posts = ($sort === 'top') ? self::get_top_beeps($limit) : self::get_latest_beeps($limit);
|
||||
$has_posts = !empty($posts);
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<div class="beep-feed" data-beep-feed>
|
||||
<div class="beep-embed">
|
||||
<div class="beep-feed-header">
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:8px;"><strong style="color:#FFFFFF;">Messages from </strong><img src="https://sami-ahmed.net/wp-content/plugins/beep/assets/beep-wordmark.png" alt="Beep!" style="height:22px;width:auto;vertical-align:middle;"></div>
|
||||
</div>
|
||||
|
||||
<?php if ($has_posts) : ?>
|
||||
<div class="beep-tabs" data-beep-tabs role="tablist">
|
||||
<button type="button" class="beep-tab <?php echo $sort === 'latest' ? 'is-active' : ''; ?>"
|
||||
data-beep-sort="latest" role="tab">Latest</button>
|
||||
<button type="button" class="beep-tab <?php echo $sort === 'top' ? 'is-active' : ''; ?>"
|
||||
data-beep-sort="top" role="tab">Top</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (is_user_logged_in() && !beep_user_is_banned(get_current_user_id())) : ?>
|
||||
<?php echo beep_render_composer(); ?>
|
||||
<?php elseif (is_user_logged_in()) : ?>
|
||||
<div class="beep-notice beep-notice-banned">
|
||||
Your account has been suspended from beeping.
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<div class="beep-notice beep-notice-login">
|
||||
<p><strong>Join in.</strong> Sign in to like and reply.</p>
|
||||
<a class="beep-button" href="<?php echo esc_url(wp_login_url(get_permalink())); ?>">Log in</a>
|
||||
<?php if (get_option('users_can_register')) : ?>
|
||||
<a class="beep-button beep-button-secondary" href="<?php echo esc_url(wp_registration_url()); ?>">Sign up</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="beep-list" data-beep-list>
|
||||
<?php
|
||||
if ($has_posts) {
|
||||
foreach ($posts as $p) {
|
||||
echo beep_render_post($p);
|
||||
}
|
||||
} else {
|
||||
$empty_bird = beep_bird_svg( ['size' => 64, 'fill' => '#4a90c4'] );
|
||||
echo '<div class="beep-empty"><div class="beep-empty-icon">' . $empty_bird . '</div><p>No beeps yet.</p><span>Be the first to share something!</span></div>';
|
||||
}
|
||||
?>
|
||||
</div><!-- .beep-list -->
|
||||
</div><!-- .beep-embed -->
|
||||
</div><!-- .beep-feed -->
|
||||
<?php
|
||||
return ob_get_clean() . beep_render_thread_modal() . beep_render_gif_modal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register additional REST routes for media upload.
|
||||
*/
|
||||
public static function register_media_routes() {
|
||||
register_rest_route('beep/v1', '/media/(?P<post_id>\d+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'upload_media'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
|
||||
},
|
||||
]);
|
||||
register_rest_route('beep/v1', '/media/(?P<post_id>\d+)/(?P<attachment_id>\d+)', [
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [__CLASS__, 'delete_media'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
|
||||
},
|
||||
]);
|
||||
register_rest_route('beep/v1', '/voice/(?P<post_id>\d+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'upload_voice'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
public static function upload_media($request) {
|
||||
$post_id = (int) $request->get_param('post_id');
|
||||
$post = get_post($post_id);
|
||||
|
||||
if (!$post || $post->post_type !== Beep_CPT::POST_TYPE) {
|
||||
return new WP_Error('not_found', 'Beep not found.', 404);
|
||||
}
|
||||
if (!current_user_can('edit_post', $post_id)) {
|
||||
return new WP_Error('forbidden', 'Cannot edit this beep.', 403);
|
||||
}
|
||||
if (empty($_FILES['file'])) {
|
||||
return new WP_Error('no_file', 'No file uploaded.');
|
||||
}
|
||||
|
||||
$result = Beep_Media::handle_upload($post_id, $_FILES['file']);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return rest_ensure_response($result);
|
||||
}
|
||||
|
||||
public static function delete_media($request) {
|
||||
$post_id = (int) $request->get_param('post_id');
|
||||
$attachment_id = (int) $request->get_param('attachment_id');
|
||||
$post = get_post($post_id);
|
||||
|
||||
if (!$post || $post->post_type !== Beep_CPT::POST_TYPE) {
|
||||
return new WP_Error('not_found', 'Beep not found.', 404);
|
||||
}
|
||||
if (!current_user_can('edit_post', $post_id)) {
|
||||
return new WP_Error('forbidden', 'Cannot edit this beep.', 403);
|
||||
}
|
||||
|
||||
Beep_Media::remove_image($post_id, $attachment_id);
|
||||
return rest_ensure_response(['ok' => true]);
|
||||
}
|
||||
public static function upload_voice($request) {
|
||||
$post_id = (int) $request->get_param('post_id');
|
||||
$post = get_post($post_id);
|
||||
|
||||
if (!$post || $post->post_type !== Beep_CPT::POST_TYPE) {
|
||||
return new WP_Error('not_found', 'Beep not found.', 404);
|
||||
}
|
||||
if (!current_user_can('edit_post', $post_id)) {
|
||||
return new WP_Error('forbidden', 'Cannot edit this beep.', 403);
|
||||
}
|
||||
if (empty($_FILES['file'])) {
|
||||
return new WP_Error('no_file', 'No file uploaded.');
|
||||
}
|
||||
|
||||
$result = Beep_Media::handle_voice_upload($post_id, $_FILES['file']);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return rest_ensure_response($result);
|
||||
}
|
||||
|
||||
|
||||
public static function register_thread_routes() {
|
||||
register_rest_route('beep/v1', '/thread/(?P<post_id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_thread'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
}
|
||||
|
||||
public static function get_thread($request) {
|
||||
$post_id = (int) $request->get_param('post_id');
|
||||
if (get_post_type($post_id) !== Beep_CPT::POST_TYPE) {
|
||||
return new WP_Error('not_found', 'Beep not found.', 404);
|
||||
}
|
||||
$depth = (int) $request->get_param('depth') ?: 10;
|
||||
$thread = Beep_Replies::get_thread($post_id, 0, $depth);
|
||||
return rest_ensure_response($thread);
|
||||
}
|
||||
|
||||
public static function register_quote_routes() {
|
||||
register_rest_route('beep/v1', '/quote/(?P<post_id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_quoted_post'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
register_rest_route('beep/v1', '/lookup', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'lookup_beep'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
}
|
||||
|
||||
public static function get_quoted_post($request) {
|
||||
$post_id = (int) $request->get_param('post_id');
|
||||
$quoted = Beep_Quotes::get_quoted_post($post_id);
|
||||
if (!$quoted) {
|
||||
return new WP_Error('not_found', 'No quoted post found.', 404);
|
||||
}
|
||||
return rest_ensure_response($quoted);
|
||||
}
|
||||
|
||||
public static function lookup_beep($request) {
|
||||
$query = sanitize_text_field($request->get_param('q'));
|
||||
if (!$query) {
|
||||
return new WP_Error('missing_query', 'Query parameter q is required.', 400);
|
||||
}
|
||||
if (is_numeric($query)) {
|
||||
$post = get_post((int) $query);
|
||||
if ($post && $post->post_type === Beep_CPT::POST_TYPE) {
|
||||
return rest_ensure_response(Beep_Quotes::get_quoted_post($post->ID));
|
||||
}
|
||||
}
|
||||
if (preg_match('/\[beep:(\d+)\]/', $query, $m)) {
|
||||
$post = get_post((int) $m[1]);
|
||||
if ($post && $post->post_type === Beep_CPT::POST_TYPE) {
|
||||
return rest_ensure_response(Beep_Quotes::get_quoted_post($post->ID));
|
||||
}
|
||||
}
|
||||
return new WP_Error('not_found', 'Beep not found.', 404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-hosted plugin updater backed by the Gitea releases API.
|
||||
*
|
||||
* Makes Beep update like a normal WordPress plugin: the "update available"
|
||||
* badge appears in Plugins → Updates and one-click update works, pulling the
|
||||
* latest published Release from the Gitea repo.
|
||||
*
|
||||
* Configuration (define in wp-config.php):
|
||||
* define('BEEP_UPDATE_TOKEN', 'xxxxxxxx'); // required for a private repo
|
||||
* define('BEEP_UPDATE_GITEA_BASE', 'https://git.sami'); // optional override
|
||||
* define('BEEP_UPDATE_REPO', 'sami7777/beep'); // optional override
|
||||
*
|
||||
* To publish an update: bump the Version header in beep.php, push, then create
|
||||
* a Release on Gitea whose tag is the new version (e.g. "v1.7.0" or "1.7.0").
|
||||
* Optionally attach a "beep.zip" asset; otherwise the source archive is used.
|
||||
*/
|
||||
class Beep_Updater {
|
||||
|
||||
const TRANSIENT = 'beep_update_release';
|
||||
const CACHE_HOURS = 12;
|
||||
|
||||
/** @var string e.g. "beep/beep.php" */
|
||||
protected $plugin_file;
|
||||
/** @var string e.g. "beep" */
|
||||
protected $plugin_slug;
|
||||
/** @var string installed version */
|
||||
protected $version;
|
||||
/** @var string Gitea base URL, no trailing slash */
|
||||
protected $api_base;
|
||||
/** @var string "owner/repo" */
|
||||
protected $repo;
|
||||
|
||||
public static function init() {
|
||||
$self = new self();
|
||||
add_filter('pre_set_site_transient_update_plugins', [$self, 'check_for_update']);
|
||||
add_filter('plugins_api', [$self, 'plugin_info'], 20, 3);
|
||||
add_filter('http_request_args', [$self, 'authorize_request'], 10, 2);
|
||||
add_filter('upgrader_source_selection', [$self, 'fix_source_dir'], 10, 4);
|
||||
add_action('upgrader_process_complete', [$self, 'clear_cache'], 10, 2);
|
||||
add_filter('plugin_action_links_' . $self->plugin_file, [$self, 'action_links']);
|
||||
}
|
||||
|
||||
public function __construct() {
|
||||
$this->plugin_file = plugin_basename(BEEP_FILE);
|
||||
$this->plugin_slug = dirname($this->plugin_file);
|
||||
if ($this->plugin_slug === '.' || $this->plugin_slug === '') {
|
||||
$this->plugin_slug = 'beep';
|
||||
}
|
||||
$this->version = BEEP_VERSION;
|
||||
$base = defined('BEEP_UPDATE_GITEA_BASE') ? BEEP_UPDATE_GITEA_BASE : 'https://git.sami';
|
||||
$this->api_base = rtrim($base, '/');
|
||||
$this->repo = defined('BEEP_UPDATE_REPO') ? BEEP_UPDATE_REPO : 'sami7777/beep';
|
||||
}
|
||||
|
||||
/** Resolve the access token (constant first, then filter). */
|
||||
protected function token() {
|
||||
if (defined('BEEP_UPDATE_TOKEN') && BEEP_UPDATE_TOKEN) {
|
||||
return BEEP_UPDATE_TOKEN;
|
||||
}
|
||||
return (string) apply_filters('beep_update_token', '');
|
||||
}
|
||||
|
||||
protected function api_url($path) {
|
||||
return $this->api_base . '/api/v1/repos/' . $this->repo . $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch + cache the latest release. Returns an info array or null.
|
||||
*/
|
||||
protected function get_remote($force = false) {
|
||||
if (!$force) {
|
||||
$cached = get_site_transient(self::TRANSIENT);
|
||||
if ($cached !== false) {
|
||||
return is_array($cached) ? $cached : null; // '' means "checked, nothing"
|
||||
}
|
||||
}
|
||||
|
||||
$headers = ['Accept' => 'application/json'];
|
||||
$token = $this->token();
|
||||
if ($token) {
|
||||
$headers['Authorization'] = 'token ' . $token;
|
||||
}
|
||||
|
||||
$res = wp_remote_get($this->api_url('/releases/latest'), [
|
||||
'timeout' => 15,
|
||||
'headers' => $headers,
|
||||
]);
|
||||
|
||||
if (is_wp_error($res) || wp_remote_retrieve_response_code($res) !== 200) {
|
||||
// Brief negative cache so a flaky/unauthorized server doesn't stall every admin page.
|
||||
set_site_transient(self::TRANSIENT, '', HOUR_IN_SECONDS);
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode(wp_remote_retrieve_body($res), true);
|
||||
if (!is_array($data) || empty($data['tag_name'])) {
|
||||
set_site_transient(self::TRANSIENT, '', HOUR_IN_SECONDS);
|
||||
return null;
|
||||
}
|
||||
|
||||
$info = $this->parse_release($data);
|
||||
set_site_transient(self::TRANSIENT, $info, self::CACHE_HOURS * HOUR_IN_SECONDS);
|
||||
return $info;
|
||||
}
|
||||
|
||||
/** Normalize a Gitea release payload into the fields we need. */
|
||||
protected function parse_release($data) {
|
||||
$version = ltrim((string) $data['tag_name'], 'vV');
|
||||
|
||||
// Prefer an attached .zip asset; fall back to the source archive.
|
||||
$package = '';
|
||||
if (!empty($data['assets']) && is_array($data['assets'])) {
|
||||
foreach ($data['assets'] as $asset) {
|
||||
if (!empty($asset['browser_download_url'])
|
||||
&& strtolower(substr($asset['name'], -4)) === '.zip') {
|
||||
$package = $asset['browser_download_url'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$package) {
|
||||
$package = !empty($data['zipball_url'])
|
||||
? $data['zipball_url']
|
||||
: $this->api_base . '/' . $this->repo . '/archive/' . $data['tag_name'] . '.zip';
|
||||
}
|
||||
|
||||
return [
|
||||
'version' => $version,
|
||||
'package' => $package,
|
||||
'changelog' => isset($data['body']) ? (string) $data['body'] : '',
|
||||
'published_at' => isset($data['published_at']) ? $data['published_at'] : '',
|
||||
'name' => isset($data['name']) ? $data['name'] : '',
|
||||
'html_url' => isset($data['html_url']) ? $data['html_url'] : ($this->api_base . '/' . $this->repo),
|
||||
];
|
||||
}
|
||||
|
||||
/** Inject our plugin into the WordPress update transient. */
|
||||
public function check_for_update($transient) {
|
||||
if (!is_object($transient)) {
|
||||
return $transient;
|
||||
}
|
||||
|
||||
$info = $this->get_remote();
|
||||
if (!$info) {
|
||||
return $transient;
|
||||
}
|
||||
|
||||
$has_update = version_compare($info['version'], $this->version, '>');
|
||||
|
||||
$obj = (object) [
|
||||
'slug' => $this->plugin_slug,
|
||||
'plugin' => $this->plugin_file,
|
||||
'new_version' => $has_update ? $info['version'] : $this->version,
|
||||
'url' => $info['html_url'],
|
||||
'package' => $has_update ? $info['package'] : '',
|
||||
'icons' => [],
|
||||
'banners' => [],
|
||||
'tested' => '',
|
||||
'requires_php' => '',
|
||||
];
|
||||
|
||||
if ($has_update) {
|
||||
$transient->response[$this->plugin_file] = $obj;
|
||||
} else {
|
||||
// Listing it as no_update keeps "View details" / auto-update UI working.
|
||||
if (!isset($transient->no_update)) {
|
||||
$transient->no_update = [];
|
||||
}
|
||||
$transient->no_update[$this->plugin_file] = $obj;
|
||||
}
|
||||
|
||||
return $transient;
|
||||
}
|
||||
|
||||
/** Provide the "View details" popup content. */
|
||||
public function plugin_info($result, $action, $args) {
|
||||
if ($action !== 'plugin_information') {
|
||||
return $result;
|
||||
}
|
||||
if (empty($args->slug) || $args->slug !== $this->plugin_slug) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$info = $this->get_remote();
|
||||
if (!$info) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$meta = get_file_data(BEEP_FILE, [
|
||||
'Name' => 'Plugin Name',
|
||||
'Author' => 'Author',
|
||||
'RequiresWP' => 'Requires at least',
|
||||
'RequiresPHP' => 'Requires PHP',
|
||||
]);
|
||||
|
||||
return (object) [
|
||||
'name' => $meta['Name'] ?: 'Beep',
|
||||
'slug' => $this->plugin_slug,
|
||||
'version' => $info['version'],
|
||||
'author' => esc_html($meta['Author']),
|
||||
'homepage' => $info['html_url'],
|
||||
'download_link' => $info['package'],
|
||||
'requires' => $meta['RequiresWP'],
|
||||
'requires_php' => $meta['RequiresPHP'],
|
||||
'last_updated' => $info['published_at'],
|
||||
'sections' => [
|
||||
'changelog' => $this->format_changelog($info['changelog']),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** Very small Markdown → HTML pass for the changelog body. */
|
||||
protected function format_changelog($md) {
|
||||
if ($md === '') {
|
||||
return 'No changelog provided for this release.';
|
||||
}
|
||||
$html = esc_html($md);
|
||||
$html = preg_replace('/^###\s+(.*)$/m', '<h4>$1</h4>', $html);
|
||||
$html = preg_replace('/^##\s+(.*)$/m', '<h3>$1</h3>', $html);
|
||||
$html = preg_replace('/^[\*\-]\s+(.*)$/m', '<li>$1</li>', $html);
|
||||
$html = preg_replace('/(<li>.*<\/li>)/s', '<ul>$1</ul>', $html);
|
||||
return wpautop($html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the auth token to any request aimed at our Gitea host so the
|
||||
* API check AND the package download both authenticate on a private repo.
|
||||
*/
|
||||
public function authorize_request($args, $url) {
|
||||
$token = $this->token();
|
||||
if (!$token) {
|
||||
return $args;
|
||||
}
|
||||
$host = wp_parse_url($this->api_base, PHP_URL_HOST);
|
||||
$url_host = wp_parse_url($url, PHP_URL_HOST);
|
||||
if ($host && $url_host && strcasecmp($host, $url_host) === 0) {
|
||||
if (empty($args['headers']) || !is_array($args['headers'])) {
|
||||
$args['headers'] = [];
|
||||
}
|
||||
$args['headers']['Authorization'] = 'token ' . $token;
|
||||
}
|
||||
return $args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gitea archives unzip to a folder like "beep" or "sami7777-beep-<hash>".
|
||||
* Rename it to the plugin slug so WP updates the existing folder in place.
|
||||
*/
|
||||
public function fix_source_dir($source, $remote_source, $upgrader, $hook_extra = null) {
|
||||
if (!is_array($hook_extra)
|
||||
|| empty($hook_extra['plugin'])
|
||||
|| $hook_extra['plugin'] !== $this->plugin_file) {
|
||||
return $source;
|
||||
}
|
||||
|
||||
global $wp_filesystem;
|
||||
if (!$wp_filesystem) {
|
||||
return $source;
|
||||
}
|
||||
|
||||
$desired = trailingslashit($remote_source) . $this->plugin_slug . '/';
|
||||
if (untrailingslashit($source) === untrailingslashit($desired)) {
|
||||
return $source;
|
||||
}
|
||||
|
||||
if ($wp_filesystem->move($source, $desired, true)) {
|
||||
return $desired;
|
||||
}
|
||||
return $source;
|
||||
}
|
||||
|
||||
/** Drop the cache after an update so the new version is reflected at once. */
|
||||
public function clear_cache($upgrader = null, $options = []) {
|
||||
delete_site_transient(self::TRANSIENT);
|
||||
}
|
||||
|
||||
/** "Check for updates" link on the Plugins screen. */
|
||||
public function action_links($links) {
|
||||
$url = wp_nonce_url(
|
||||
self_admin_url('plugins.php?beep_force_update_check=1'),
|
||||
'beep_force_update_check'
|
||||
);
|
||||
$links[] = '<a href="' . esc_url($url) . '">Check for updates</a>';
|
||||
return $links;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the manual "Check for updates" click: clear cache + force a re-check.
|
||||
*/
|
||||
add_action('admin_init', function () {
|
||||
if (empty($_GET['beep_force_update_check'])) {
|
||||
return;
|
||||
}
|
||||
if (!current_user_can('update_plugins')
|
||||
|| !check_admin_referer('beep_force_update_check')) {
|
||||
return;
|
||||
}
|
||||
delete_site_transient(Beep_Updater::TRANSIENT);
|
||||
delete_site_transient('update_plugins');
|
||||
wp_update_plugins();
|
||||
wp_safe_redirect(self_admin_url('plugins.php'));
|
||||
exit;
|
||||
});
|
||||
+489
-232
@@ -16,13 +16,10 @@ if (!defined('ABSPATH')) {
|
||||
*/
|
||||
function beep_bird_svg( $args = [] ) {
|
||||
$size = isset( $args['size'] ) ? (int) $args['size'] : 24;
|
||||
$fill = isset( $args['fill'] ) ? $args['fill'] : '#0064cd';
|
||||
$class = isset( $args['class'] ) ? trim( $args['class'] ) : '';
|
||||
$extra = $class ? ' class="' . esc_attr( $class ) . '"' : '';
|
||||
|
||||
// Map brand colour to a CSS filter that turns black → target colour.
|
||||
// Hue-rotate value derived from the target hex.
|
||||
// #0064cd → hue-rotate ~190deg, #4a90c4 → hue-rotate ~200deg.
|
||||
$filter = 'brightness(0) saturate(100%) invert(22%) sepia(100%) saturate(10000%) hue-rotate(190deg)';
|
||||
|
||||
return '<svg' . $extra
|
||||
@@ -38,20 +35,84 @@ function beep_bird_svg( $args = [] ) {
|
||||
|
||||
/**
|
||||
* Wordmark (bird + "Beep!") shown at the top of the feed.
|
||||
* Uses the real Sami-designed bird PNG with CSS color filter
|
||||
* to match brand colours.
|
||||
*/
|
||||
function beep_logo_svg() {
|
||||
// Allow site-owners to swap in a custom logo via settings.
|
||||
$custom = Beep_Settings::get( 'logo_url' );
|
||||
$custom = class_exists('Beep_Settings') ? Beep_Settings::get( 'logo_url' ) : '';
|
||||
if ( $custom ) {
|
||||
return '<img class="beep-logo" src="' . esc_url( $custom ) . '" alt="Beep">';
|
||||
}
|
||||
|
||||
// Real bird PNG as <img> — CSS filter turns black silhouette → brand blue.
|
||||
$filter = 'brightness(0) saturate(100%) invert(30%) sepia(100%) saturate(10000%) hue-rotate(190deg)';
|
||||
return '<img class="beep-logo" src="' . esc_url( BEEP_URL . 'assets/beep-icon.png' ) . '"'
|
||||
. ' alt="Beep!" style="height:32px;width:auto;filter:' . esc_attr( $filter ) . ';">';
|
||||
return '<img class="beep-logo" src="' . esc_url( BEEP_URL . 'assets/beep-wordmark.png?v=' . BEEP_VERSION ) . '" alt="Beep!">';
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Beep!" brand wordmark shown at the bottom-right of every beep and reply.
|
||||
*
|
||||
* Rendered as a crisp inline SVG recreation of the 3D extruded brand logo —
|
||||
* a blue-violet face with a darker-blue depth and navy outline.
|
||||
* To swap in the PNG here instead, hook the filter:
|
||||
* add_filter('beep_post_wordmark', fn() => '<img class="beep-post-wordmark" src="...">');
|
||||
*/
|
||||
function beep_post_wordmark() {
|
||||
// Build the 3D extrusion: stacked copies offset down-left, deepest first.
|
||||
$depth = 7;
|
||||
$extrude = '';
|
||||
for ( $i = $depth; $i >= 1; $i-- ) {
|
||||
$x = 9 - $i;
|
||||
$y = 49 + $i;
|
||||
$extrude .= '<text x="' . $x . '" y="' . $y . '" fill="#2A2AAE">Beep!</text>';
|
||||
}
|
||||
|
||||
$svg = '<svg class="beep-post-wordmark" viewBox="0 0 168 72"'
|
||||
. ' role="img" aria-label="Beep!" xmlns="http://www.w3.org/2000/svg">'
|
||||
. '<g font-family=""Arial Black","Arial Bold",Arial,Helvetica,sans-serif"'
|
||||
. ' font-weight="900" font-size="52" letter-spacing="-1">'
|
||||
. $extrude
|
||||
. '<text x="9" y="49" fill="#5238E6" stroke="#15155C" stroke-width="1.8"'
|
||||
. ' stroke-linejoin="round" paint-order="stroke">Beep!</text>'
|
||||
. '</g></svg>';
|
||||
|
||||
return apply_filters('beep_post_wordmark', $svg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the avatar URL for a user: custom beep_avatar meta, then Gravatar,
|
||||
* then the brand bird as a last resort.
|
||||
*
|
||||
* @return array [ $url, $css_filter ]
|
||||
*/
|
||||
function beep_avatar_url( $user_id ) {
|
||||
$user_id = (int) $user_id;
|
||||
|
||||
if ( $user_id ) {
|
||||
$custom = get_user_meta( $user_id, 'beep_avatar', true );
|
||||
if ( $custom ) {
|
||||
return [ $custom, '' ];
|
||||
}
|
||||
$user = get_userdata( $user_id );
|
||||
if ( $user && ! empty( $user->user_email ) ) {
|
||||
return [ 'https://www.gravatar.com/avatar/' . md5( strtolower( $user->user_email ) ) . '?s=96&d=mp', '' ];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
BEEP_URL . 'assets/beep-bird.png',
|
||||
'brightness(0) saturate(100%) invert(22%) sepia(100%) saturate(10000%) hue-rotate(190deg)',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a circular avatar <img> for a user.
|
||||
*
|
||||
* @param int $user_id User ID (0 = anonymous → brand bird).
|
||||
* @param string $class Extra class, e.g. 'beep-avatar-sm'.
|
||||
*/
|
||||
function beep_avatar_img( $user_id, $class = '' ) {
|
||||
list( $url, $filter ) = beep_avatar_url( $user_id );
|
||||
$classes = trim( 'beep-avatar ' . $class );
|
||||
$style = $filter ? ' style="filter:' . esc_attr( $filter ) . ';"' : '';
|
||||
return '<img class="' . esc_attr( $classes ) . '" src="' . esc_url( $url ) . '" alt="" loading="lazy" decoding="async"' . $style . '>';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,19 +129,11 @@ function beep_user_is_banned($user_id) {
|
||||
* Render the composer textarea (for logged-in, non-banned users).
|
||||
*/
|
||||
function beep_render_composer() {
|
||||
$user = wp_get_current_user();
|
||||
$beep_avatar = get_user_meta($post->post_author, 'beep_avatar', true);
|
||||
if ($beep_avatar) {
|
||||
$avatar_url = $beep_avatar;
|
||||
$filter = '';
|
||||
} elseif ($author && !empty($author->user_email)) {
|
||||
$avatar_url = 'https://www.gravatar.com/avatar/' . md5(strtolower($author->user_email)) . '?s=96&d=mp';
|
||||
$filter = '';
|
||||
} else {
|
||||
$avatar_url = BEEP_URL . 'assets/beep-bird.png';
|
||||
$filter = 'brightness(0) saturate(100%) invert(22%) sepia(100%) saturate(10000%) hue-rotate(190deg)';
|
||||
if ( ! is_user_logged_in() || beep_user_is_banned( get_current_user_id() ) ) {
|
||||
return '';
|
||||
}
|
||||
$avatar = '<img class="beep-avatar" src="' . esc_url($avatar_url) . '" alt="" style="width:48px;height:48px;object-fit:contain;' . ($filter ? 'filter:' . esc_attr($filter) : '') . '">';
|
||||
|
||||
$avatar = beep_avatar_img( get_current_user_id() );
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
@@ -93,110 +146,183 @@ function beep_render_composer() {
|
||||
placeholder="What's happening?"
|
||||
maxlength="<?php echo (int) BEEP_CHAR_LIMIT; ?>"
|
||||
rows="1"></textarea>
|
||||
<?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-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="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
|
||||
</label>
|
||||
<span class="beep-media-count" data-beep-media-count hidden>0/4</span>
|
||||
<label class="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>
|
||||
<label class="beep-quote-composer-btn" title="Quote a beep">
|
||||
<input type="button" data-beep-quote-composer-btn hidden>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M8 5H6v2H4v12h2v-2h2v2h8V9h-2V7h-2v2H8V5zm10 0v2h2v12h-2v-2h-2v2H8V9h2v2h2V7h2V5h2z"/></svg>
|
||||
</label>
|
||||
<button type="button" class="beep-gif-btn" data-beep-gif-btn title="Add GIF">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M11.5 9H13v6h-1.5V9zM9 9H6c-.6 0-1 .5-1 1v4c0 .5.4 1 1 1h3c.6 0 1-.5 1-1v-2H8.5v1.5H7v-3c0-.6-.4-1-1-1zm0 4.5H8v-3h1v3zm10-4.5h-3v6h1.5v-2h1v2H19V9c0-.6-.4-1-1-1z"/></svg>
|
||||
</button>
|
||||
<button type="button" class="beep-poll-btn" data-beep-poll-btn title="Add poll">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM7 12h2v5H7v-5zm4-3h2v8h-2V9zm4-3h2v11h-2V6z"/></svg>
|
||||
</button>
|
||||
<?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>
|
||||
</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-gif" data-beep-gif-preview hidden>
|
||||
<div class="beep-gif-preview-card">
|
||||
<span>GIF selected</span>
|
||||
<button class="beep-gif-remove" data-beep-gif-remove type="button" title="Remove">×</button>
|
||||
</div>
|
||||
</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>- Remove</button>
|
||||
<button type="button" class="beep-poll-remove" data-beep-poll-remove>Remove poll</button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<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">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single beep post + its replies tree.
|
||||
* 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_post($post) {
|
||||
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">×</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>− 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.
|
||||
*
|
||||
* @param WP_Post $post
|
||||
* @param array $args {
|
||||
* @type bool $show_replies Render the nested replies tree (thread view). Default false.
|
||||
* @type bool $in_thread Rendering inside the conversation modal. Default false.
|
||||
* }
|
||||
*/
|
||||
function beep_render_post($post, $args = []) {
|
||||
if (!$post) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$author = get_userdata($post->post_author);
|
||||
$beep_avatar = get_user_meta($post->post_author, 'beep_avatar', true);
|
||||
if ($beep_avatar) {
|
||||
$avatar_url = $beep_avatar;
|
||||
$filter = '';
|
||||
} elseif ($author && !empty($author->user_email)) {
|
||||
$avatar_url = 'https://www.gravatar.com/avatar/' . md5(strtolower($author->user_email)) . '?s=96&d=mp';
|
||||
$filter = '';
|
||||
} else {
|
||||
$avatar_url = BEEP_URL . 'assets/beep-bird.png';
|
||||
$filter = 'brightness(0) saturate(100%) invert(22%) sepia(100%) saturate(10000%) hue-rotate(190deg)';
|
||||
}
|
||||
$avatar = '<img class="beep-avatar" src="' . esc_url($avatar_url) . '" alt="" style="width:48px;height:48px;object-fit:contain;' . ($filter ? 'filter:' . esc_attr($filter) : '') . '">';
|
||||
$name = $author ? esc_html($author->display_name) : 'Unknown';
|
||||
$handle = $author ? '@' . esc_html($author->user_login) : '';
|
||||
$ts = strtotime($post->post_date_gmt . ' UTC');
|
||||
$time = beep_relative_time($ts);
|
||||
$time_iso = gmdate('c', $ts);
|
||||
$show_replies = !empty($args['show_replies']);
|
||||
$in_thread = !empty($args['in_thread']);
|
||||
|
||||
$author = get_userdata($post->post_author);
|
||||
$avatar = beep_avatar_img($post->post_author);
|
||||
$name = $author ? esc_html($author->display_name) : 'Unknown';
|
||||
$handle = $author ? '@' . esc_html($author->user_login) : '';
|
||||
$ts = strtotime($post->post_date_gmt . ' UTC');
|
||||
$time = beep_relative_time($ts);
|
||||
$time_iso = gmdate('c', $ts);
|
||||
$time_full = mysql2date('F j, Y g:i a', $post->post_date);
|
||||
|
||||
$content = beep_format_content($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');
|
||||
$can_delete = current_user_can('delete_post', $post->ID);
|
||||
$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');
|
||||
$can_delete = current_user_can('delete_post', $post->ID);
|
||||
$can_interact = is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
|
||||
$beep_images = Beep_Media::get_images($post->ID);
|
||||
$beep_video = Beep_Media::get_video_url($post->ID);
|
||||
@@ -204,11 +330,10 @@ function beep_render_post($post) {
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<article class="beep-post" data-beep-post="<?php echo (int) $post->ID; ?>">
|
||||
<article class="beep-post<?php echo $in_thread ? ' beep-post-thread' : ''; ?>" data-beep-post="<?php echo (int) $post->ID; ?>" <?php echo $in_thread ? '' : 'data-beep-open-thread="' . (int) $post->ID . '"'; ?>>
|
||||
<?php echo $avatar; ?>
|
||||
<div class="beep-post-body">
|
||||
<div class="beep-meta">
|
||||
<?php echo beep_bird_svg( ['size' => 18, 'fill' => '#0064cd', 'class' => 'beep-post-badge'] ); ?>
|
||||
<span class="beep-name"><?php echo $name; ?></span>
|
||||
<?php if ($handle) : ?>
|
||||
<span class="beep-handle"><?php echo $handle; ?></span>
|
||||
@@ -218,8 +343,16 @@ function beep_render_post($post) {
|
||||
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 if ($can_delete) : ?>
|
||||
<button class="beep-delete-link"
|
||||
data-beep-delete="<?php echo (int) $post->ID; ?>"
|
||||
type="button"
|
||||
aria-label="Delete this beep"
|
||||
title="Delete">×</button>
|
||||
<?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); ?>
|
||||
@@ -235,76 +368,42 @@ function beep_render_post($post) {
|
||||
<?php echo Beep_Media::render_voice_note($post->ID); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$quoted_post = Beep_Quotes::get_quoted_post($post->ID);
|
||||
?>
|
||||
<?php $quoted_post = Beep_Quotes::get_quoted_post($post->ID); ?>
|
||||
<?php if ($quoted_post) : ?>
|
||||
<div class="beep-quoted-container">
|
||||
<?php echo Beep_Quotes::render_quoted_post($post->ID); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$beep_gif_url = get_post_meta($post->ID, 'beep_gif_url', true);
|
||||
?>
|
||||
<?php $beep_gif_url = get_post_meta($post->ID, 'beep_gif_url', true); ?>
|
||||
<?php if ($beep_gif_url) : ?>
|
||||
<div class="beep-media-attachments">
|
||||
<div class="beep-gif-embed">
|
||||
<img src="<?php echo esc_url($beep_gif_url); ?>" alt="GIF" style="max-width:100%;border-radius:12px;">
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$poll = Beep_Polls::get_poll_by_beep_id($post->ID);
|
||||
if ($poll) : ?>
|
||||
<div class="beep-poll-container" data-beep-poll data-beep-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
|
||||
$user_id = get_current_user_id();
|
||||
$user_vote = $user_id ? Beep_Polls::get_user_vote($poll['id'], $user_id) : null;
|
||||
$has_ended = $poll['ends_at'] && strtotime($poll['ends_at']) < time();
|
||||
$show_results = $user_vote !== null || $has_ended || !is_user_logged_in();
|
||||
$results = Beep_Polls::get_results_with_percentages($poll);
|
||||
foreach ($results as $result) :
|
||||
$is_voted = $user_vote === $result['index'];
|
||||
?>
|
||||
<?php if ($show_results) : ?>
|
||||
<div class="beep-poll-result<?php echo $is_voted ? ' is-voted' : ''; ?><?php echo ($is_voted || ($result['percentage'] >= 50)) ? ' is-winning' : ''; ?>">
|
||||
<div class="beep-poll-result-bar" style="width:<?php echo (int) $result['percentage']; ?>%"></div>
|
||||
<span class="beep-poll-result-text">
|
||||
<?php echo esc_html($result['option']); ?>
|
||||
<span class="beep-poll-result-percent"><?php echo (int) $result['percentage']; ?>%</span>
|
||||
</span>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<button class="beep-poll-vote-btn"
|
||||
type="button"
|
||||
data-beep-poll-vote="<?php echo (int) $result['index']; ?>"
|
||||
data-beep-poll-beep="<?php echo (int) $post->ID; ?>">
|
||||
<?php echo esc_html($result['option']); ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="beep-poll-meta">
|
||||
<span class="beep-poll-votes"><?php echo (int) $poll['total_votes']; ?> votes</span>
|
||||
<?php if ($poll['ends_at']) : ?>
|
||||
<?php if ($has_ended) : ?>
|
||||
<span class="beep-poll-end">Final results</span>
|
||||
<?php else : ?>
|
||||
<span class="beep-poll-end"><?php echo esc_html(date('M j', strtotime($poll['ends_at']))); ?> · Ends</span>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="beep-gif-embed">
|
||||
<img src="<?php echo esc_url($beep_gif_url); ?>" alt="GIF" loading="lazy">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php echo beep_render_poll($post->ID); ?>
|
||||
<div class="beep-actions">
|
||||
<button class="beep-action beep-action-reply"
|
||||
data-beep-toggle-reply
|
||||
<?php if ($in_thread) : ?>
|
||||
<button class="beep-action beep-action-reply"
|
||||
data-beep-toggle-reply
|
||||
type="button"
|
||||
title="Reply">
|
||||
<?php echo beep_icon('reply'); ?>
|
||||
<span class="beep-count" data-beep-reply-count><?php echo $reply_count > 0 ? esc_html($reply_count) : ''; ?></span>
|
||||
</button>
|
||||
<?php else : ?>
|
||||
<button class="beep-action beep-action-reply"
|
||||
data-beep-thread="<?php echo (int) $post->ID; ?>"
|
||||
type="button"
|
||||
title="Reply">
|
||||
<?php echo beep_icon('reply'); ?>
|
||||
<span class="beep-count" data-beep-reply-count><?php echo $reply_count > 0 ? esc_html($reply_count) : ''; ?></span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<button class="beep-action beep-action-retweet"
|
||||
data-beep-quote="<?php echo (int) $post->ID; ?>"
|
||||
type="button"
|
||||
title="Reply">
|
||||
<?php echo beep_icon('reply'); ?>
|
||||
<span class="beep-count" data-beep-reply-count><?php echo $reply_count > 0 ? esc_html($reply_count) : ''; ?></span>
|
||||
title="Quote">
|
||||
<?php echo beep_icon('retweet'); ?>
|
||||
</button>
|
||||
<button class="beep-action beep-action-like<?php echo $user_liked ? ' is-liked' : ''; ?>"
|
||||
data-beep-like-post="<?php echo (int) $post->ID; ?>"
|
||||
@@ -314,11 +413,6 @@ function beep_render_post($post) {
|
||||
<span class="beep-icon-filled"><?php echo beep_icon('heart_filled'); ?></span>
|
||||
<span class="beep-count" data-beep-like-count><?php echo $like_count > 0 ? esc_html($like_count) : ''; ?></span>
|
||||
</button>
|
||||
<a href="#" class="beep-action beep-action-conversation"
|
||||
data-beep-thread="<?php echo (int) $post->ID; ?>"
|
||||
title="View conversation">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M21 6h-2v9H6v2c0 .55.45 1 1 1h11l4 4V7c0-.55-.45-1-1-1zm-4 6V3c0-.55-.45-1-1-1H3c-.55 0-1 .45-1 1v14l4-4h10c.55 0 1-.45 1-1z"/></svg>
|
||||
</a>
|
||||
<button class="beep-action beep-action-share"
|
||||
data-beep-share="<?php echo esc_url(get_permalink($post->ID)); ?>"
|
||||
type="button"
|
||||
@@ -326,60 +420,123 @@ function beep_render_post($post) {
|
||||
<?php echo beep_icon('share'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<a href="#" class="beep-action beep-action-quote"
|
||||
data-beep-quote="<?php echo (int) $post->ID; ?>"
|
||||
title="Quote">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M8 5H6v2H4v12h2v-2h2v2h8V9h-2V7h-2v2H8V5zm10 0v2h2v12h-2v-2h-2v2H8V9h2v2h2V7h2V5h2z"/></svg>
|
||||
</a>
|
||||
<div class="beep-bottom-bar">
|
||||
<?php if ($can_delete) : ?>
|
||||
<button class="beep-delete-link"
|
||||
data-beep-delete="<?php echo (int) $post->ID; ?>"
|
||||
type="button"
|
||||
aria-label="Delete this beep"
|
||||
title="Delete">×</button>
|
||||
<?php endif; ?>
|
||||
<span class="beep-wordmark">Beep!</span>
|
||||
<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 if ($can_interact) : ?>
|
||||
<?php if ($in_thread && $can_interact) : ?>
|
||||
<?php echo beep_render_reply_form($post->ID, 0); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="beep-replies" data-beep-replies>
|
||||
<?php
|
||||
$all = Beep_Replies::get_replies($post->ID);
|
||||
echo beep_render_replies_tree($all, 0, 0, $post->ID, $can_interact);
|
||||
?>
|
||||
</div>
|
||||
<span class="beep-wordmark">Beep!</span>
|
||||
<?php if ($show_replies) : ?>
|
||||
<div class="beep-replies" data-beep-replies>
|
||||
<?php
|
||||
$all = Beep_Replies::get_replies($post->ID);
|
||||
echo beep_render_replies_tree($all, 0, 0, $post->ID, $can_interact && $in_thread);
|
||||
?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</article>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the poll block for a beep (empty string when no poll).
|
||||
*/
|
||||
function beep_render_poll($post_id) {
|
||||
$poll = Beep_Polls::get_poll_by_beep_id($post_id);
|
||||
if (!$poll) {
|
||||
return '';
|
||||
}
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<div class="beep-poll-container" data-beep-poll data-beep-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
|
||||
$user_id = get_current_user_id();
|
||||
$user_vote = $user_id ? Beep_Polls::get_user_vote($poll['id'], $user_id) : null;
|
||||
$has_ended = $poll['ends_at'] && strtotime($poll['ends_at']) < time();
|
||||
$show_results = $user_vote !== null || $has_ended || !is_user_logged_in();
|
||||
$results = Beep_Polls::get_results_with_percentages($poll);
|
||||
foreach ($results as $result) :
|
||||
$is_voted = $user_vote === $result['index'];
|
||||
?>
|
||||
<?php if ($show_results) : ?>
|
||||
<div class="beep-poll-result<?php echo $is_voted ? ' is-voted' : ''; ?>">
|
||||
<div class="beep-poll-result-bar" style="width:<?php echo (int) $result['percentage']; ?>%"></div>
|
||||
<span class="beep-poll-result-text">
|
||||
<?php echo esc_html($result['option']); ?>
|
||||
<?php if ($is_voted) : ?>
|
||||
<svg class="beep-poll-check" viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
<span class="beep-poll-result-percent"><?php echo (int) $result['percentage']; ?>%</span>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<button class="beep-poll-vote-btn"
|
||||
type="button"
|
||||
data-beep-poll-vote="<?php echo (int) $result['index']; ?>"
|
||||
data-beep-poll-beep="<?php echo (int) $post_id; ?>">
|
||||
<?php echo esc_html($result['option']); ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="beep-poll-meta">
|
||||
<span class="beep-poll-votes"><?php echo (int) $poll['total_votes']; ?> <?php echo (int) $poll['total_votes'] === 1 ? 'vote' : 'votes'; ?></span>
|
||||
<?php if ($poll['ends_at']) : ?>
|
||||
<span class="beep-poll-dot">·</span>
|
||||
<?php if ($has_ended) : ?>
|
||||
<span class="beep-poll-end">Final results</span>
|
||||
<?php else : ?>
|
||||
<span class="beep-poll-end">Ends <?php echo esc_html(date('M j', strtotime($poll['ends_at']))); ?></span>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a reply form (used both on top-level posts and individual replies).
|
||||
* @param int $post_id The beep post ID this reply belongs to.
|
||||
* @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; ?>" hidden>
|
||||
<textarea
|
||||
class="beep-input beep-reply-input"
|
||||
placeholder="Post your reply"
|
||||
maxlength="<?php echo (int) BEEP_CHAR_LIMIT; ?>"
|
||||
rows="1"></textarea>
|
||||
<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>
|
||||
<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
|
||||
class="beep-input beep-reply-input"
|
||||
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">
|
||||
<?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>
|
||||
<?php
|
||||
@@ -408,10 +565,8 @@ function beep_render_reply($comment, $all_comments = [], $depth = 0, $post_id =
|
||||
return '';
|
||||
}
|
||||
|
||||
$author_id = (int) $comment->user_id;
|
||||
$avatar_url = BEEP_URL . 'assets/beep-bird.png';
|
||||
$filter = 'brightness(0) saturate(100%) invert(22%) sepia(100%) saturate(10000%) hue-rotate(190deg)';
|
||||
$avatar = '<img class="beep-avatar beep-avatar-sm" src="' . esc_url($avatar_url) . '" alt="" style="width:36px;height:36px;object-fit:contain;filter:' . esc_attr($filter) . ';">';
|
||||
$author_id = (int) $comment->user_id;
|
||||
$avatar = beep_avatar_img($author_id, 'beep-avatar-sm');
|
||||
|
||||
$author = $author_id ? get_userdata($author_id) : null;
|
||||
$name = $author ? esc_html($author->display_name) : esc_html($comment->comment_author);
|
||||
@@ -421,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);
|
||||
$can_delete = current_user_can('moderate_comments');
|
||||
$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();
|
||||
@@ -434,7 +596,6 @@ function beep_render_reply($comment, $all_comments = [], $depth = 0, $post_id =
|
||||
<?php echo $avatar; ?>
|
||||
<div class="beep-reply-body">
|
||||
<div class="beep-meta">
|
||||
<?php echo beep_bird_svg( ['size' => 16, 'fill' => '#0064cd', 'class' => 'beep-post-badge beep-post-badge-sm'] ); ?>
|
||||
<span class="beep-name"><?php echo $name; ?></span>
|
||||
<?php if ($handle) : ?>
|
||||
<span class="beep-handle"><?php echo $handle; ?></span>
|
||||
@@ -442,9 +603,50 @@ function beep_render_reply($comment, $all_comments = [], $depth = 0, $post_id =
|
||||
<span class="beep-dot">·</span>
|
||||
<time class="beep-time" datetime="<?php echo esc_attr($time_iso); ?>"><?php echo esc_html($time); ?></time>
|
||||
<span class="beep-spacer"></span>
|
||||
<?php echo beep_bird_svg( ['size' => 16, 'fill' => '#0064cd', 'class' => 'beep-post-badge beep-post-badge-sm'] ); ?>
|
||||
<?php if ($can_delete) : ?>
|
||||
<button class="beep-delete-link"
|
||||
data-beep-delete-reply="<?php echo (int) $comment->comment_ID; ?>"
|
||||
type="button"
|
||||
aria-label="Delete this reply"
|
||||
title="Delete">×</button>
|
||||
<?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">
|
||||
@@ -465,15 +667,11 @@ function beep_render_reply($comment, $all_comments = [], $depth = 0, $post_id =
|
||||
</div>
|
||||
<?php echo beep_render_reply_form($post_id, $comment->comment_ID); ?>
|
||||
<?php endif; ?>
|
||||
<?php if ($can_delete) : ?>
|
||||
<div class="beep-delete-row">
|
||||
<button class="beep-delete-link"
|
||||
data-beep-delete-reply="<?php echo (int) $comment->comment_ID; ?>"
|
||||
type="button"
|
||||
aria-label="Delete this reply"
|
||||
title="Delete">×</button>
|
||||
</div>
|
||||
<?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
|
||||
@@ -519,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;
|
||||
@@ -542,11 +795,11 @@ function beep_relative_time($timestamp) {
|
||||
|
||||
function beep_icon($name) {
|
||||
$icons = [
|
||||
'reply' => '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01z"/></svg>',
|
||||
'reply' => '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z"/></svg>',
|
||||
'retweet' => '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z"/></svg>',
|
||||
'heart' => '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z"/></svg>',
|
||||
'heart_filled' => '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M20.884 13.19c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z"/></svg>',
|
||||
'share' => '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M12 2.59l5.7 5.7-1.41 1.42L13 6.41V16h-2V6.41l-3.3 3.3-1.41-1.42L12 2.59zM21 15l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z"/></svg>',
|
||||
'beep_bird' => '<svg viewBox="0 0 24 24" width="18" height="18" fill="#0064cd" 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>',
|
||||
];
|
||||
return isset($icons[$name]) ? $icons[$name] : '';
|
||||
}
|
||||
@@ -557,14 +810,16 @@ function beep_icon($name) {
|
||||
*/
|
||||
function beep_render_thread_modal() {
|
||||
return '<div class="beep-thread-modal" data-beep-thread-modal hidden>' .
|
||||
'<div class="beep-thread-dialog" role="dialog" aria-modal="true" aria-label="Conversation">' .
|
||||
'<div class="beep-thread-header">' .
|
||||
'<button class="beep-thread-close" data-beep-thread-close type="button">' .
|
||||
'<button class="beep-thread-close" data-beep-thread-close type="button" aria-label="Close">' .
|
||||
'<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>' .
|
||||
'</button>' .
|
||||
'<span class="beep-thread-title">Conversation</span>' .
|
||||
'</div>' .
|
||||
'<div class="beep-thread-loading">Loading conversation...</div>' .
|
||||
'<div class="beep-thread-loading" data-beep-thread-loading hidden><span class="beep-spinner"></span></div>' .
|
||||
'<div class="beep-thread-content" data-beep-thread-content></div>' .
|
||||
'</div>' .
|
||||
'</div>';
|
||||
}
|
||||
|
||||
@@ -574,8 +829,9 @@ function beep_render_thread_modal() {
|
||||
*/
|
||||
function beep_render_gif_modal() {
|
||||
return '<div class="beep-gif-modal" data-beep-gif-modal hidden>' .
|
||||
'<div class="beep-gif-dialog" role="dialog" aria-modal="true" aria-label="GIF picker">' .
|
||||
'<div class="beep-gif-header">' .
|
||||
'<button class="beep-gif-close" data-beep-gif-close type="button">' .
|
||||
'<button class="beep-gif-close" data-beep-gif-close type="button" aria-label="Close">' .
|
||||
'<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>' .
|
||||
'</button>' .
|
||||
'<span class="beep-gif-title">Pick a GIF</span>' .
|
||||
@@ -585,5 +841,6 @@ function beep_render_gif_modal() {
|
||||
'<button type="button" class="beep-gif-search-btn" data-beep-gif-search-btn>Search</button>' .
|
||||
'</div>' .
|
||||
'<div class="beep-gif-grid" data-beep-gif-grid></div>' .
|
||||
'</div>' .
|
||||
'</div>';
|
||||
}
|
||||
|
||||
+66
-2
@@ -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.6.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.
|
||||
@@ -36,10 +36,74 @@ Features:
|
||||
|
||||
== Shortcode ==
|
||||
|
||||
[beep_feed limit="50" header="Messages from" sort="latest"]
|
||||
[beep_feed limit="50" header="Messages from" sort="latest" theme="auto" show_wordmark="yes"]
|
||||
|
||||
* limit - beeps loaded on first render (default 50)
|
||||
* header - text next to the wordmark (default "Messages from"; "" hides it)
|
||||
* sort - latest (default) or top
|
||||
* theme - auto (default, follows visitor's system preference), dark, or light
|
||||
* show_wordmark - yes (default) or no
|
||||
|
||||
== 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.
|
||||
* Fixed composer avatar (was referencing an undefined post and could render broken); replies now show the author's real Gravatar/custom avatar instead of the placeholder bird.
|
||||
* Fixed the conversation and GIF modals never receiving clicks (close button was outside the event scope); added Escape-key and backdrop-click to close.
|
||||
* Fixed CSS scoping: container styles (600px column, background, borders) never actually applied due to inverted .beep-embed/.beep-feed nesting.
|
||||
* Fixed Elementor widget registration (undefined variable fatal, wrong class namespace, nonexistent render method). Beep continues to work fine without Elementor.
|
||||
* Removed bundled Giphy API key; configure your own in Settings -> Beep.
|
||||
* Quote button moved into the action bar with proper repost-green hover; like icon now correctly swaps outline/filled.
|
||||
* Header respects the header/show_wordmark shortcode attributes (was hardcoded).
|
||||
* Restyled to match Twitter: action-bar hover halos, poll results with leading-bar style, GIF badge overlay, media grid, empty state, toasts, spinner, composer toolbar.
|
||||
* Reply authors can delete their own replies; Enter searches in the GIF picker; new post/reply respects the active sort tab.
|
||||
|
||||
= 1.7.4 =
|
||||
* Feed-header wordmark now loads from the plugin's own URL (was hardcoded to sami-ahmed.net) with a version-based cache-buster, so the clean logo shows immediately instead of a stale/corrupted cached image.
|
||||
* Made the header wordmark a bit larger (30px).
|
||||
|
||||
= 1.7.3 =
|
||||
* Replaced the corrupted beep-wordmark.png assets with a clean, transparent render of the 3D "Beep!" logo (fixes the feed-header wordmark image).
|
||||
* Gave the inline-SVG wordmark extra descender room so the "p" never clips.
|
||||
|
||||
= 1.7.2 =
|
||||
* Beep wordmark on every beep + reply is now the 3D extruded brand logo (blue-violet face, navy outline, blue depth) instead of flat text, drawn as a sharp ~1KB inline SVG.
|
||||
* Bumped the wordmark size and opacity so the logo reads clearly on the dark theme.
|
||||
|
||||
= 1.7.1 =
|
||||
* Beep wordmark now shown bottom-right of every beep AND reply, with the delete button furthest right.
|
||||
* Wordmark rendered as crisp inline SVG (readable on the dark theme); removed a duplicate wordmark.
|
||||
* Restored self-hosted plugin updates via the Gitea releases API (update badge + one-click update).
|
||||
|
||||
= 1.7.0 =
|
||||
* Shortcode-only rendering ([beep_feed] where you place it); removed auto-inject.
|
||||
* Public compose form (posting still requires login).
|
||||
|
||||
= 1.6.0 =
|
||||
* Elementor drag-and-drop widget for feed placement.
|
||||
* Admin settings page (Settings > Beep) for Giphy API key and branding.
|
||||
|
||||
Reference in New Issue
Block a user