diff --git a/README.md b/README.md index 62abc3b..4a1861f 100755 --- a/README.md +++ b/README.md @@ -1,15 +1,42 @@ # Beep -A Twitter-style microblog for WordPress. Logged-in users post short updates, like, and reply (with reply threads, multiple levels deep). Likes can drive a "Top" feed for ranking the best beeps. Designed to look like a Twitter timeline, hosted entirely on your own site. +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.4 +## What's in v1.5.1 +### Core Features +- **Post beeps.** Short updates up to 280 characters, with a composer at the top of the feed. - **Like beeps and replies.** Hearts on every post and every reply, with a pop animation when you tap. - **Nested replies.** Reply to a reply, to a reply, to a reply. Visual indent caps at two levels (deeper threads still nest structurally). - **Latest / Top tabs.** "Top" sorts your feed by like count — a built-in way for readers to surface the best stuff. - **Per-post Beep badge.** Every beep and reply carries a small bird icon in the corner, branding it as a Beep. - **Friendlier visitor experience.** Logged-out visitors see clean read-only beeps; logged-in users get the full interactive feed. +### Rich Media +- **Image uploads.** Attach up to 4 images per beep. Previews in the composer with remove buttons. Images display in an adaptive grid (1×, 2×, 3×, 4× layouts). +- **GIF picker (Giphy).** Search and embed GIFs via the Giphy API. Configure your API key in **Settings → Beep**. +- **Voice notes.** Record and attach audio notes (MP3, OGG, WAV, WebM, M4A) up to 20 MB. Inline audio player with play button and waveform visualization. +- **Quote beeps.** Reference any existing beep as a quote. Loads the quoted post's author, time, and content inline. + +### Polls +- **Create polls.** Add a poll to any beep with a question and 2–6 options. +- **Vote on polls.** One-click voting with live results displayed as progress bars. +- **Poll management.** Add/remove options before posting; polls are cleared after submission. + +### Thread View +- **Conversation modal.** Click any beep to open a full thread/conversation view in a modal overlay. +- **Nested thread display.** Replies are indented by depth for easy reading. + +### Settings & Integration +- **Admin settings page.** Configure character limit, Giphy API key, and other options under **Settings → Beep**. +- **Elementor widget.** Drag-and-drop Beep feed into any Elementor-built page. +- **Magic login support.** Works with passwordless login plugins for frictionless onboarding. + +### Moderation +- **Delete any beep or reply.** Logged-in admins see a small × in the top-right of each item. +- **Approve replies before they appear.** Settings → Discussion → "Comment must be manually approved" puts replies in a queue at **Comments** in the sidebar. +- **Suspend users.** Users → click their name → scroll to **Chirp moderation** → check **Suspend from beeping**. + ## Install Beep 1. In wp-admin, go to **Plugins → Add New → Upload Plugin**. @@ -36,7 +63,6 @@ This is the easiest way to let a small circle into your Beep feed without settin They type their email, get a one-time sign-in link, and they're in. No password to remember. 4. **They can now like and reply.** Liking and replying require being logged in; your feed remains read-only for the public. -5. **Need to remove someone?** Users → click their name → scroll to **Chirp moderation** → check **Suspend from beeping**. They stay registered but can't post or interact. ## Shortcode options @@ -48,52 +74,40 @@ This is the easiest way to let a small circle into your Beep feed without settin - `header` — text shown next to the wordmark (default "Messages from") - `sort` — initial sort: `latest` (default) or `top` -## Moderation +## Elementor Widget -- **Delete any beep or reply.** Logged-in admins see a small × in the top-right of each item. -- **Approve replies before they appear.** Settings → Discussion → "Comment must be manually approved" puts replies in a queue at **Comments** in the sidebar. +After activating Beep, you'll find a **Beep Feed** widget in the Elementor panel. Drag it onto your page — it renders the same feed as the shortcode, with no extra configuration needed. ## Branding Swap the bird/wordmark in `assets/`. The accent color is `#1D9BF0`; find/replace in `assets/beep.css` for a different palette. -## Updates +## Changelog -Beep updates like a normal WordPress plugin — the "update available" badge appears -in **Plugins → Updates** and one-click update works. New versions are pulled from -the Gitea repo's **Releases**, not WordPress.org. +### v1.5.1 +- Fixed critical IIFE scope bug — all functions now properly access `config` and `handleResponse` +- Removed leaked Giphy API key from JavaScript source +- Fixed voice note upload not clearing composer state +- Added CSS for GIF picker modal, poll compose section, media preview grid, voice note preview +- Added loading spinner and empty states for feed +- Added responsive styles for GIF grid and media preview on mobile -**One-time setup** (private repo) — add to `wp-config.php`: +### v1.5 +- Image uploads with 4-image grid +- GIF picker via Giphy API +- Voice note attachments +- Quote beeps +- Polls with voting +- Thread/conversation modal +- Elementor widget +- Admin settings page -```php -define('BEEP_UPDATE_TOKEN', 'your-gitea-access-token'); -// Optional overrides (defaults shown): -// define('BEEP_UPDATE_GITEA_BASE', 'https://git.sami'); -// define('BEEP_UPDATE_REPO', 'sami7777/beep'); -``` - -The token is only read from `wp-config.php` (or the `beep_update_token` filter) — it -is never stored in the repo. Give it read access to the repo. - -**To ship an update:** - -1. Bump the version in **both** places in `beep.php` (the `Version:` header and - `BEEP_VERSION`), commit, and push. -2. On Gitea, create a **Release** whose tag is the new version (`v1.7.0` or `1.7.0`). - Optionally attach a `beep.zip` asset; otherwise the source archive is used. - -Within ~12 hours (or immediately via the **Check for updates** link on the Plugins -screen) every site running Beep will see the update and can install it in one click. -The release description is shown as the changelog in the "View details" popup. - -## What's not in this version - -Deliberate omissions to keep the plugin understandable: - -- Image uploads (text only, 280 char limit) -- Retweets / quote-beeps -- Direct messages -- Custom feeds (no "Following" filter — one global feed) +### v1.4 +- Like beeps and replies with pop animation +- Nested replies (multiple levels deep) +- Latest / Top tabs +- Per-post Beep badge +- Read-only mode for logged-out visitors ## License diff --git a/assets/beep-bird-badge.png b/assets/beep-bird-badge.png new file mode 100755 index 0000000..00c286e Binary files /dev/null and b/assets/beep-bird-badge.png differ diff --git a/assets/beep-bird-new.png b/assets/beep-bird-new.png deleted file mode 100644 index 69675fd..0000000 Binary files a/assets/beep-bird-new.png and /dev/null differ diff --git a/assets/beep-bird.png b/assets/beep-bird.png new file mode 100755 index 0000000..c5a27d6 Binary files /dev/null and b/assets/beep-bird.png differ diff --git a/assets/beep-wordmark-blue.png b/assets/beep-wordmark-blue.png new file mode 100644 index 0000000..1eb721d Binary files /dev/null and b/assets/beep-wordmark-blue.png differ diff --git a/assets/beep-wordmark.png b/assets/beep-wordmark.png old mode 100644 new mode 100755 index 5459809..018edcd Binary files a/assets/beep-wordmark.png and b/assets/beep-wordmark.png differ diff --git a/assets/beep.css b/assets/beep.css index 8515a0c..0e03e94 100755 --- a/assets/beep.css +++ b/assets/beep.css @@ -709,26 +709,8 @@ .beep-embed .beep-feed-header { padding: 12px 16px; } } -/* Post footer: wordmark + delete, pinned bottom-right (delete furthest right) */ -.beep-embed .beep-post-footer { - display: flex; - justify-content: flex-end; - align-items: center; - gap: 8px; - margin-top: 6px; -} -.beep-embed .beep-post-wordmark { - height: 16px; - width: auto; - opacity: 0.55; - flex: 0 0 auto; -} -.beep-embed .beep-reply-footer .beep-post-wordmark { - height: 13px; -} - /* Delete */ -.beep-embed .beep-delete-row { display: flex; justify-content: flex-end; margin-top: 6px; } +/* .beep-delete-row replaced by .beep-bottom-bar */ .beep-embed .beep-delete-link { background: transparent; @@ -1065,3 +1047,31 @@ .beep-embed .beep-public-notice a:hover { text-decoration: underline; } + + + + +/* Bottom bar: wordmark left, delete right */ +/* Bottom bar: wordmark left, delete right */ +/* Bottom bar: delete left, wordmark right (blue, large) */ +/* Bottom bar: delete left, wordmark right (blue) */ +/* Bottom bar: delete left, wordmark text right */ +.beep-embed .beep-bottom-bar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} +.beep-embed .beep-wordmark { + font-size: 16px; + font-weight: 800; + color: #0064cd; + opacity: 0.5; + letter-spacing: -0.5px; + transition: opacity 0.15s ease; + user-select: none; +} +.beep-embed .beep-post:hover .beep-wordmark { + opacity: 1; +} diff --git a/assets/beep.js b/assets/beep.js index ffc2ab2..25c91e8 100755 --- a/assets/beep.js +++ b/assets/beep.js @@ -1,4 +1,4 @@ - var url = `https://api.giphy.com/v1/gifs/search?api_key=${apiKey}&q=${encodeURIComponent(query)}&limit=30&rating=g`;/* Beep — Twitter-style microblog frontend */ +/* Beep — Twitter-style microblog frontend */ (function () { 'use strict'; @@ -12,16 +12,72 @@ Array.prototype.forEach.call(feeds, initFeed); } + var feedState = { offset: 0, loading: false, hasMore: true, currentSort: 'latest' }; + function initFeed(feed) { var compose = feed.querySelector('[data-beep-compose]'); if (compose) initCompose(compose, feed); initSortTabs(feed); initMedia(compose, feed); + initInfiniteScroll(feed); feed.addEventListener('click', onClick); feed.addEventListener('input', onInput); feed.addEventListener('keydown', onKeydown); } + /* ---------- Infinite scroll ---------- */ + function initInfiniteScroll(feed) { + var sentinel = document.createElement('div'); + sentinel.className = 'beep-scroll-sentinel'; + sentinel.setAttribute('data-beep-scroll-sentinel', ''); + var list = feed.querySelector('[data-beep-list]'); + if (list) list.appendChild(sentinel); + + if (!('IntersectionObserver' in window)) return; + var observer = new IntersectionObserver(function(entries) { + if (entries[0].isIntersecting && !feedState.loading && feedState.hasMore) { + loadMore(feed); + } + }, { rootMargin: '400px' }); + observer.observe(sentinel); + feed._beepScrollObserver = observer; + } + + function loadMore(feed) { + feedState.loading = true; + var sentinel = feed.querySelector('[data-beep-scroll-sentinel]'); + if (sentinel) sentinel.innerHTML = '
Loading...
'; + + fetch(config.restUrl + 'feed?sort=' + encodeURIComponent(feedState.currentSort) + '&offset=' + feedState.offset, { + credentials: 'same-origin', + headers: { 'X-WP-Nonce': config.nonce } + }) + .then(handleResponse) + .then(function(data) { + feedState.hasMore = !!data.has_more; + feedState.offset = data.offset || feedState.offset + 50; + if (sentinel) sentinel.innerHTML = ''; + if (data.html) { + var list = feed.querySelector('[data-beep-list]'); + if (list) { + var tmp = document.createElement('div'); + tmp.innerHTML = data.html; + while (tmp.firstElementChild) { + // Insert before the sentinel + list.insertBefore(tmp.firstElementChild, sentinel); + } + } + } + if (!feedState.hasMore && sentinel) { + sentinel.innerHTML = '
No more beeps
'; + } + }) + .catch(function() { + if (sentinel) sentinel.innerHTML = ''; + }) + .finally(function() { feedState.loading = false; }); + } + /* ---------- Composer ---------- */ function initCompose(compose, feed) { var input = compose.querySelector('[data-beep-input]'); @@ -59,15 +115,12 @@ .then(handleResponse) .then(function (data) { var list = feed.querySelector('[data-beep-list]'); - var empty = list.querySelector('.beep-empty'); + var empty = list && list.querySelector('.beep-empty'); if (empty) empty.remove(); - var tmp = document.createElement('div'); - tmp.innerHTML = data.html; - var node = tmp.firstElementChild; - if (node) { - node.classList.add('beep-new'); - list.insertBefore(node, list.firstChild); - } + + // Reload feed to get the fully rendered post (includes media, quotes, polls) + reloadFeed(feed, 'recent'); + input.value = ''; clearQuotePreview(compose); if (compose._beepGifUrl) { @@ -81,7 +134,7 @@ var pending = compose._beepPendingImages; var pendingVoice = compose._beepPendingVoice; var uploadDone = function () { - reloadFeed(feed, 'recent'); + reloadFeed(feed, 'latest'); }; var tasks = []; @@ -106,7 +159,7 @@ } }) .catch(function () { - window.alert('Could not post. Please try again.'); + showToast('Could not post. Please try again.', 'error'); button.disabled = false; }); }); @@ -132,13 +185,30 @@ var list = feed.querySelector('[data-beep-list]'); if (!list) return; list.classList.add('beep-loading'); + feedState.offset = 0; + feedState.hasMore = true; + feedState.currentSort = sort; fetch(config.restUrl + 'feed?sort=' + encodeURIComponent(sort), { credentials: 'same-origin', headers: { 'X-WP-Nonce': config.nonce } }) .then(handleResponse) .then(function (data) { - list.innerHTML = data.html || '
No beeps yet.
'; + // Remove everything except the sentinel + var sentinel = list.querySelector('[data-beep-scroll-sentinel]'); + list.innerHTML = ''; + if (data.html) { + var tmp = document.createElement('div'); + tmp.innerHTML = data.html; + while (tmp.firstElementChild) { + list.appendChild(tmp.firstElementChild); + } + } else { + list.innerHTML = '
' + emptyIcon() + '

No beeps yet.

Be the first to share something!
'; + } + if (sentinel) list.appendChild(sentinel); + feedState.hasMore = !!data.has_more; + feedState.offset = data.offset || 50; list.classList.remove('beep-loading'); }) .catch(function () { @@ -146,6 +216,10 @@ }); } + function emptyIcon() { + return ''; + } + /* ---------- Click delegation ---------- */ function onClick(e) { var t = e.target; @@ -160,8 +234,6 @@ if (replyToggle) { e.preventDefault(); if (!requireLogin()) return; - // The reply form is the sibling .beep-reply-form belonging to the post or reply - // that this toggle button is inside. var container = replyToggle.closest('[data-beep-post], [data-beep-reply]'); if (!container) return; var form = container.querySelector(':scope > .beep-post-body > [data-beep-reply-form], :scope > .beep-reply-body > [data-beep-reply-form]'); @@ -183,16 +255,14 @@ var delPost = t.closest('[data-beep-delete]'); if (delPost) { e.preventDefault(); - if (!window.confirm('Delete this beep?')) return; - handleDelete('beep/' + delPost.dataset.beepDelete, '[data-beep-post="' + delPost.dataset.beepDelete + '"]'); + handleDeleteWithUndo('delete/' + delPost.dataset.beepDelete, '[data-beep-post="' + delPost.dataset.beepDelete + '"]', 'beep'); return; } var delReply = t.closest('[data-beep-delete-reply]'); if (delReply) { e.preventDefault(); - if (!window.confirm('Delete this reply?')) return; - handleDelete('reply/' + delReply.dataset.beepDeleteReply, '[data-beep-reply="' + delReply.dataset.beepDeleteReply + '"]'); + handleDeleteWithUndo('delete-reply/' + delReply.dataset.beepDeleteReply, '[data-beep-reply="' + delReply.dataset.beepDeleteReply + '"]', 'reply'); return; } @@ -237,7 +307,7 @@ showQuotePreview(compose, quoted); } }) - .catch(function() { window.alert('Beep not found.'); }); + .catch(function() { showToast('Beep not found.', 'error'); }); return; } @@ -253,15 +323,7 @@ var gifClose = t.closest('[data-beep-gif-close]'); if (gifClose) { e.preventDefault(); closeGifPicker(); return; } - // GIF search on Enter - var gifSearchInput = t.closest('[data-beep-gif-search]'); - if (gifSearchInput && e.key === 'Enter') { - e.preventDefault(); - var compose = gifSearchInput.closest('[data-beep-compose]') || document.querySelector('[data-beep-compose]'); - searchGifs(gifSearchInput.value, compose); - return; - } - + // GIF search on Enter (handled in keydown) // GIF search button var gifSearchBtn = t.closest('[data-beep-gif-search-btn]'); if (gifSearchBtn) { @@ -328,6 +390,21 @@ if (requireLogin()) handlePollVote(pollVoteBtn); return; } + + // Image lightbox - click on post images to view full size + var lightboxImg = t.closest('[data-beep-lightbox]'); + if (lightboxImg) { + e.preventDefault(); + openLightbox(lightboxImg.href || lightboxImg.querySelector('img').src); + return; + } + + // Lightbox close + var lightboxClose = t.closest('.beep-lightbox'); + if (lightboxClose) { + closeLightbox(); + return; + } } function onInput(e) { @@ -409,45 +486,14 @@ form.innerHTML = '
Reply submitted, awaiting moderation.
'; return; } - // Where do we insert? - // If parent === 0, append to the post's top-level [data-beep-replies] list. - // Otherwise append (creating if needed) to the parent reply's nested list. - var post = btn.closest('[data-beep-post]'); - var targetList; - if ((parseInt(parent, 10) || 0) === 0) { - targetList = post && post.querySelector(':scope > .beep-post-body > [data-beep-replies]'); - } else { - var parentReply = post && post.querySelector('[data-beep-reply="' + parent + '"]'); - if (parentReply) { - targetList = parentReply.querySelector(':scope > .beep-reply-body > .beep-replies-nested'); - if (!targetList) { - targetList = document.createElement('div'); - targetList.className = 'beep-replies beep-replies-nested'; - targetList.setAttribute('data-beep-replies', ''); - parentReply.querySelector('.beep-reply-body').appendChild(targetList); - } - } + // Reload the full feed to get the properly rendered reply + var feed = btn.closest('[data-beep-feed]'); + if (feed) { + reloadFeed(feed, 'latest'); } - if (targetList) { - var tmp = document.createElement('div'); - tmp.innerHTML = data.html; - var node = tmp.firstElementChild; - if (node) { - node.classList.add('beep-new'); - targetList.appendChild(node); - } - } - // Increment top-level reply counter on the post. - var countEl = post && post.querySelector('[data-beep-reply-count]'); - if (countEl) { - countEl.textContent = (parseInt(countEl.textContent, 10) || 0) + 1; - } - input.value = ''; - autosize(input); - form.hidden = true; }) .catch(function () { - window.alert('Could not post reply. Please try again.'); + showToast('Could not post reply. Please try again.', 'error'); }) .finally(function () { btn.disabled = false; }); } @@ -457,30 +503,93 @@ var url = btn.dataset.beepShare; if (!url || !navigator.clipboard) return; navigator.clipboard.writeText(url).then(function () { - var tip = document.createElement('span'); - tip.className = 'beep-tip'; - tip.textContent = 'Copied'; - btn.style.position = 'relative'; - btn.appendChild(tip); - setTimeout(function () { tip.remove(); }, 1500); + showToast('Link copied to clipboard', 'success'); }); } - /* ---------- Delete ---------- */ - function handleDelete(pathFragment, selector) { - fetch(config.restUrl + pathFragment, { - method: 'DELETE', - headers: { 'X-WP-Nonce': config.nonce }, - credentials: 'same-origin' - }) - .then(handleResponse) - .then(function () { - var el = document.querySelector(selector); - if (el) el.remove(); - }) - .catch(function () { - window.alert('Could not delete.'); + /* ---------- Delete with undo toast ---------- */ + function handleDeleteWithUndo(pathFragment, selector, type) { + var el = document.querySelector(selector); + if (!el) return; + + // Store original state + var parent = el.parentNode; + var nextSibling = el.nextSibling; + + // Hide the element with animation + el.style.transition = 'opacity 0.3s ease, max-height 0.3s ease'; + el.style.opacity = '0'; + el.style.maxHeight = '0'; + el.style.overflow = 'hidden'; + + // Show undo toast + var toast = document.createElement('div'); + toast.className = 'beep-toast beep-toast-undo'; + toast.innerHTML = '' + (type === 'beep' ? 'Beep' : 'Reply') + ' deleted.'; + document.body.appendChild(toast); + + // Trigger animation + requestAnimationFrame(function() { + toast.classList.add('beep-toast-visible'); }); + + var deleteTimeout = setTimeout(function() { + // Actually delete + fetch(config.restUrl + pathFragment, { + method: 'DELETE', + headers: { 'X-WP-Nonce': config.nonce }, + credentials: 'same-origin' + }) + .then(handleResponse) + .then(function () { + if (el && el.parentNode) el.remove(); + }) + .catch(function () { + // Restore on failure + restoreElement(el, parent, nextSibling); + showToast('Could not delete.', 'error'); + }); + dismissToast(toast); + }, 4000); + + // Undo button + toast.querySelector('.beep-toast-undo-btn').addEventListener('click', function() { + clearTimeout(deleteTimeout); + restoreElement(el, parent, nextSibling); + dismissToast(toast); + }); + } + + function restoreElement(el, parent, nextSibling) { + el.style.transition = ''; + el.style.opacity = ''; + el.style.maxHeight = ''; + el.style.overflow = ''; + if (nextSibling) { + parent.insertBefore(el, nextSibling); + } else { + parent.appendChild(el); + } + } + + function dismissToast(toast) { + toast.classList.remove('beep-toast-visible'); + setTimeout(function() { toast.remove(); }, 300); + } + + /* ---------- Toast notification ---------- */ + function showToast(message, type) { + var existing = document.querySelector('.beep-toast.beep-toast-msg'); + if (existing) existing.remove(); + + var toast = document.createElement('div'); + toast.className = 'beep-toast beep-toast-msg beep-toast-' + (type || 'info'); + toast.textContent = message; + document.body.appendChild(toast); + requestAnimationFrame(function() { + toast.classList.add('beep-toast-visible'); + }); + setTimeout(function() { dismissToast(toast); }, 3000); } /* ---------- Helpers ---------- */ @@ -499,18 +608,23 @@ return Array.from(s).length; } + function escapeHtml(str) { + var div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; + } /* ---------- Media upload (images) ---------- */ function initMedia(compose, feed) { - var input = compose.querySelector('[data-beep-media-input]'); - var preview = compose.querySelector('[data-beep-media-preview]'); - var counter = compose.querySelector('[data-beep-media-count]'); - var voiceInput = compose.querySelector('[data-beep-voice-input]'); - var voicePreview = compose.querySelector('[data-beep-voice-preview]'); + var input = compose && compose.querySelector('[data-beep-media-input]'); + var preview = compose && compose.querySelector('[data-beep-media-preview]'); + var counter = compose && compose.querySelector('[data-beep-media-count]'); + var voiceInput = compose && compose.querySelector('[data-beep-voice-input]'); + var voicePreview = compose && compose.querySelector('[data-beep-voice-preview]'); if (!input && !voiceInput) return; var pendingImages = []; - var pendingVoice = null; // { file, id } + var pendingVoice = null; // Voice note input if (voiceInput) { @@ -519,18 +633,18 @@ if (!files || !files.length) return; var file = files[0]; if (file.size > 20 * 1024 * 1024) { - window.alert('Voice note must be under 20 MB.'); + showToast('Voice note must be under 20 MB.', 'error'); voiceInput.value = ''; return; } var accepted = ['audio/mpeg','audio/ogg','audio/wav','audio/webm','audio/mp4','audio/x-m4a']; if (accepted.indexOf(file.type) === -1) { - window.alert('Only MP3, OGG, WAV, WebM, and M4A audio are allowed.'); + showToast('Only MP3, OGG, WAV, WebM, and M4A audio are allowed.', 'error'); voiceInput.value = ''; return; } if (pendingVoice) { - window.alert('Only one voice note per beep.'); + showToast('Only one voice note per beep.', 'error'); voiceInput.value = ''; return; } @@ -538,7 +652,8 @@ reader.onload = function (ev) { var id = 'voice-' + Date.now(); pendingVoice = { file: file, id: id }; - voicePreview.innerHTML = '
Voice note
'; + compose._beepPendingVoice = pendingVoice; + voicePreview.innerHTML = '
Voice note ready
'; voicePreview.hidden = false; }; reader.readAsDataURL(file); @@ -552,33 +667,36 @@ var btn = e.target.closest('[data-beep-voice-remove]'); if (!btn) return; pendingVoice = null; + if (compose) compose._beepPendingVoice = null; voicePreview.innerHTML = ''; voicePreview.hidden = true; }); } - input.addEventListener('change', function (e) { - var files = Array.prototype.slice.call(e.target.files); - if (!files.length) return; - var room = 4 - pendingImages.length; - if (room <= 0) { input.value = ''; return; } - files = files.slice(0, room); - files.forEach(function (file) { - if (file.size > 10 * 1024 * 1024) { - window.alert('Image must be under 10 MB: ' + file.name); - return; - } - var reader = new FileReader(); - reader.onload = function (ev) { - var url = ev.target.result; - var id = 'pending-' + Date.now() + '-' + Math.random().toString(36).substr(2, 6); - pendingImages.push({ file: file, previewUrl: url, id: id }); - renderPreviews(); - }; - reader.readAsDataURL(file); + if (input) { + input.addEventListener('change', function (e) { + var files = Array.prototype.slice.call(e.target.files); + if (!files.length) return; + var room = 4 - pendingImages.length; + if (room <= 0) { input.value = ''; return; } + files = files.slice(0, room); + files.forEach(function (file) { + if (file.size > 10 * 1024 * 1024) { + showToast('Image must be under 10 MB: ' + file.name, 'error'); + return; + } + var reader = new FileReader(); + reader.onload = function (ev) { + var url = ev.target.result; + var id = 'pending-' + Date.now() + '-' + Math.random().toString(36).substr(2, 6); + pendingImages.push({ file: file, previewUrl: url, id: id }); + renderPreviews(); + }; + reader.readAsDataURL(file); + }); + input.value = ''; }); - input.value = ''; - }); + } function renderPreviews() { if (!pendingImages.length) { @@ -594,22 +712,27 @@ } preview.innerHTML = pendingImages.map(function (img) { return '
' + - '' + + 'Preview' + '' + '
'; }).join(''); } - preview.addEventListener('click', function (e) { - var btn = e.target.closest('[data-remove]'); - if (!btn) return; - var id = btn.dataset.remove; - pendingImages = pendingImages.filter(function (img) { return img.id !== id; }); - renderPreviews(); - }); + if (preview) { + preview.addEventListener('click', function (e) { + var btn = e.target.closest('[data-remove]'); + if (!btn) return; + var id = btn.dataset.remove; + pendingImages = pendingImages.filter(function (img) { return img.id !== id; }); + compose._beepPendingImages = pendingImages; + renderPreviews(); + }); + } - compose._beepPendingImages = pendingImages; - compose._beepPendingVoice = pendingVoice; + if (compose) { + compose._beepPendingImages = pendingImages; + compose._beepPendingVoice = pendingVoice; + } } function uploadPendingImages(postId, pendingImages, compose) { @@ -651,8 +774,8 @@ }) .then(handleResponse) .then(function () { - pendingVoice = null; if (compose) { + compose._beepPendingVoice = null; var vp = compose.querySelector('[data-beep-voice-preview]'); if (vp) { vp.innerHTML = ''; vp.hidden = true; } } @@ -662,12 +785,6 @@ }); } - function handleResponse(res) { - if (!res.ok) return res.json().then(function (j) { throw j; }, function () { throw res; }); - return res.json(); - } -})(); - /* ---------- Thread / Conversation modal ---------- */ function openThread(postId) { var modal = document.querySelector('[data-beep-thread-modal]'); @@ -683,8 +800,10 @@ credentials: 'same-origin' }) .then(handleResponse) - .then(function(thread) { + .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); }) .catch(function() { @@ -699,24 +818,28 @@ function renderThread(thread, container) { if (!thread || !thread.length) { - container.innerHTML = '

No replies yet.

'; + container.innerHTML = '

No replies yet.

'; return; } var html = ''; thread.forEach(function(item) { var indent = Math.min(item.depth, 5) * 24; var author = item.author || {}; - var avatar = author.avatar || 'https://www.gravatar.com/avatar/?s=48&d=mp'; + 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 += '
'; - html += '
' + (author.name || '') + '
'; + html += '
'; + html += '
' + name + '
'; html += '
'; html += '
'; - html += '' + (author.name || 'Unknown') + ''; - html += '@' + (author.username || '') + ''; - html += '' + (item.time_ago || '') + ''; + html += '' + name + ''; + html += '@' + username + ''; + html += '' + time_ago + ''; html += '
'; - html += '
' + (item.content || '') + '
'; + html += '
' + content_text + '
'; html += '
'; html += '' + (item.reply_count || 0) + ' replies'; html += '' + (item.like_count || 0) + ' likes'; @@ -750,15 +873,19 @@ var removeBtn = compose && compose.querySelector('[data-beep-quote-remove]'); if (!preview || !quoted) return; var author = quoted.author || {}; - var avatar = author.avatar || 'https://www.gravatar.com/avatar/?s=48&d=mp'; + 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 time_ago = escapeHtml(quoted.time_ago || ''); + var content = escapeHtml(quoted.content || ''); preview.innerHTML = '
' + - '
' + (author.name || '') + '
' + + '
' + name + '
' + '
' + - '
' + (author.name || 'Unknown') + ' ' + - '@' + (author.username || '') + ' ' + + '
' + name + ' ' + + '@' + username + ' ' + '· ' + - '' + (quoted.time_ago || '') + '
' + - '
' + (quoted.content || '') + '
'; + '' + time_ago + '
' + + '
' + content + '
'; preview.hidden = false; if (removeBtn) removeBtn.hidden = false; } @@ -773,8 +900,6 @@ } /* ---------- GIF Picker (Giphy) ---------- */ - var GIPHY_API_KEY = (window.BeepConfig && window.BeepConfig.giphyKey) || ''; - function openGifPicker(compose) { var modal = document.querySelector('[data-beep-gif-modal]'); if (!modal) return; @@ -782,7 +907,7 @@ var searchInput = modal.querySelector('[data-beep-gif-search]'); if (searchInput) searchInput.value = ''; var grid = modal.querySelector('[data-beep-gif-grid]'); - if (grid) grid.innerHTML = '

Type to search GIFs...

'; + if (grid) grid.innerHTML = '

Type to search GIFs...

'; if (searchInput) searchInput.focus(); } @@ -793,29 +918,33 @@ function searchGifs(query, compose) { if (!query || !query.trim()) return; + var apiKey = config.giphyKey || ''; + if (!apiKey) { + console.warn('Beep: No Giphy API key configured.'); + return; + } var modal = document.querySelector('[data-beep-gif-modal]'); var grid = modal && modal.querySelector('[data-beep-gif-grid]'); if (!grid) return; - grid.innerHTML = '

Searching...

'; + grid.innerHTML = '

Searching...

'; - var apiKey = (window.BeepConfig && window.BeepConfig.giphyKey) || GIPHY_API_KEY; - var url = `https://api.giphy.com/v1/gifs/search?api_key=${apiKey}&q=${encodeURIComponent(query)}&limit=30&rating=g`; + var url = 'https://api.giphy.com/v1/gifs/search?api_key=' + encodeURIComponent(apiKey) + '&q=' + encodeURIComponent(query) + '&limit=30&rating=g'; fetch(url) .then(function(res) { return res.json(); }) .then(function(data) { if (!data.data || !data.data.length) { - grid.innerHTML = '

No GIFs found.

'; + grid.innerHTML = '

No GIFs found.

'; return; } grid.innerHTML = ''; data.data.forEach(function(gif) { - var url = gif.images && gif.images.fixed_width_small && gif.images.fixed_width_small.url; - if (!url) return; + var imgUrl = gif.images && gif.images.fixed_width_small && gif.images.fixed_width_small.url; + if (!imgUrl) return; var item = document.createElement('div'); item.className = 'beep-gif-item'; - item.style.cssText = 'cursor:pointer;border-radius:6px;overflow:hidden;transition:opacity 0.15s;'; - item.innerHTML = '' + (gif.title || '') + ''; + var altText = escapeHtml(gif.title || ''); + item.innerHTML = '' + altText + ''; item.addEventListener('click', function() { selectGif(gif, compose); }); @@ -823,7 +952,7 @@ }); }) .catch(function() { - grid.innerHTML = '

Search failed.

'; + grid.innerHTML = '

Search failed.

'; }); } @@ -834,7 +963,7 @@ compose._beepGifUrl = url; var preview = compose.querySelector('[data-beep-gif-preview]'); if (preview) { - preview.innerHTML = '
GIF
'; + preview.innerHTML = '
GIF
'; preview.hidden = false; } closeGifPicker(); @@ -845,10 +974,8 @@ var pollSection = compose.querySelector('[data-beep-compose-poll]'); if (!pollSection) return; pollSection.hidden = false; - // Focus question input var questionInput = pollSection.querySelector('[data-beep-poll-question]'); if (questionInput) questionInput.focus(); - // Ensure we have at least 2 options var options = pollSection.querySelectorAll('[data-beep-poll-option]'); if (options.length < 2) { while (options.length < 2) { @@ -864,10 +991,10 @@ var optionsContainer = pollSection.querySelector('[data-beep-poll-options]'); if (!optionsContainer) return; var optionCount = optionsContainer.querySelectorAll('[data-beep-poll-option]').length; - if (optionCount >= 6) return; // Max 6 options + if (optionCount >= 6) return; var newRow = document.createElement('div'); newRow.className = 'beep-poll-option-row'; - newRow.innerHTML = ''; + newRow.innerHTML = ''; optionsContainer.appendChild(newRow); updatePollButtons(compose); } @@ -876,7 +1003,7 @@ var pollSection = compose.querySelector('[data-beep-compose-poll]'); if (!pollSection) return; var options = pollSection.querySelectorAll('[data-beep-poll-option]'); - if (options.length <= 2) return; // Min 2 options + if (options.length <= 2) return; var lastOption = options[options.length - 1]; if (lastOption) lastOption.parentElement.remove(); updatePollButtons(compose); @@ -901,7 +1028,6 @@ var question = pollSection.querySelector('[data-beep-poll-question]'); if (question) question.value = ''; var options = pollSection.querySelectorAll('[data-beep-poll-option]'); - // Reset to 2 options if (options.length > 2) { for (var i = options.length - 1; i >= 2; i--) { if (options[i].parentElement) options[i].parentElement.remove(); @@ -959,7 +1085,6 @@ }) .then(handleResponse) .then(function(data) { - // Refresh the beep post to show updated poll results var postEl = btn.closest('[data-beep-post]'); if (postEl) { var list = postEl.closest('[data-beep-list]'); @@ -971,75 +1096,31 @@ }); } - -// Magic Login Form Handler -document.addEventListener('DOMContentLoaded', function() { - var magicForm = document.querySelector('[data-beep-magic-form]'); - if (magicForm) { - magicForm.addEventListener('submit', function(e) { - e.preventDefault(); - - var emailInput = magicForm.querySelector('[name="email"]'); - var submitBtn = magicForm.querySelector('[data-beep-magic-submit]'); - var btnText = submitBtn.querySelector('.beep-magic-btn-text'); - var loading = submitBtn.querySelector('.beep-magic-loading'); - var successMsg = document.querySelector('[data-beep-magic-success]'); - var errorMsg = document.querySelector('[data-beep-magic-error]'); - - var email = emailInput.value; - - // Hide previous messages - if (successMsg) successMsg.hidden = true; - if (errorMsg) errorMsg.hidden = true; - - // Show loading - btnText.hidden = true; - loading.hidden = false; - submitBtn.disabled = true; - - // Get REST URL and nonce from page - var restUrl = typeof BeepConfig !== 'undefined' ? BeepConfig.restUrl : '/wp-json/'; - var nonce = typeof BeepConfig !== 'undefined' ? BeepConfig.nonce : ''; - - fetch(restUrl + 'magic-link', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': nonce - }, - body: JSON.stringify({ email: email }) - }) - .then(function(response) { - return response.json(); - }) - .then(function(data) { - if (data.success || (data.message && data.message.includes('sent'))) { - // Success - if (successMsg) { - successMsg.hidden = false; - magicForm.hidden = true; - } - } else { - // Error - var errorText = data.message || 'Something went wrong. Please try again.'; - if (errorMsg) { - errorMsg.textContent = errorText; - errorMsg.hidden = false; - } - } - }) - .catch(function(err) { - console.error('Magic login error:', err); - if (errorMsg) { - errorMsg.textContent = 'Something went wrong. Please try again.'; - errorMsg.hidden = false; - } - }) - .finally(function() { - btnText.hidden = false; - loading.hidden = true; - submitBtn.disabled = false; - }); + /* ---------- Image Lightbox ---------- */ + function openLightbox(src) { + var existing = document.querySelector('.beep-lightbox'); + if (existing) existing.remove(); + var el = document.createElement('div'); + el.className = 'beep-lightbox'; + el.innerHTML = 'Full size image'; + document.body.appendChild(el); + // Close on click outside image or Escape key + el.addEventListener('click', function(e) { + if (e.target === el || e.target.tagName !== 'IMG') closeLightbox(); }); + var escHandler = function(e) { + if (e.key === 'Escape') { closeLightbox(); document.removeEventListener('keydown', escHandler); } + }; + document.addEventListener('keydown', escHandler); } -}); + + function closeLightbox() { + var el = document.querySelector('.beep-lightbox'); + if (el) el.remove(); + } + + function handleResponse(res) { + if (!res.ok) return res.json().then(function (j) { throw j; }, function () { throw res; }); + return res.json(); + } +})(); diff --git a/beep.php b/beep.php index e7220ef..c8a9117 100755 --- a/beep.php +++ b/beep.php @@ -31,16 +31,9 @@ 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(); -} diff --git a/includes/class-beep.php b/includes/class-beep.php index 94cd2a3..6fec7e4 100755 --- a/includes/class-beep.php +++ b/includes/class-beep.php @@ -21,7 +21,7 @@ class Beep_Plugin { } // Elementor widget — only load if Elementor is active - add_action('elementor/widgets/register', function ($widgets_manager) { + add_action('elementor/widgets/register', function () { if (!class_exists('\Elementor\Widget_Base')) return; $widget_file = BEEP_DIR . 'includes/class-elementor-widget.php'; if (!file_exists($widget_file)) return; diff --git a/includes/class-cpt.php b/includes/class-cpt.php index 9bbece3..bc02a47 100755 --- a/includes/class-cpt.php +++ b/includes/class-cpt.php @@ -30,7 +30,7 @@ class Beep_CPT { 'public' => true, 'show_in_menu' => true, 'show_in_rest' => true, - 'menu_icon' => 'data:image/png;base64,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', + 'menu_icon' => 'data:image/png;base64,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', 'menu_position' => 5, 'supports' => ['editor', 'author', 'comments'], 'has_archive' => false, diff --git a/includes/class-elementor-widget.php b/includes/class-elementor-widget.php old mode 100644 new mode 100755 diff --git a/includes/class-follows.php b/includes/class-follows.php deleted file mode 100644 index 43fa3e3..0000000 --- a/includes/class-follows.php +++ /dev/null @@ -1,207 +0,0 @@ - - register_rest_route(self::REST_NAMESPACE, '/follow/(?P\d+)', [ - 'methods' => 'POST', - 'callback' => [$this, 'follow_user'], - 'permission_callback' => [$this, 'require_login'], - ]); - - // Unfollow a user: DELETE /beep/v1/follow/ - register_rest_route(self::REST_NAMESPACE, '/follow/(?P\d+)', [ - 'methods' => 'DELETE', - 'callback' => [$this, 'unfollow_user'], - 'permission_callback' => [$this, 'require_login'], - ]); - - // Check follow status: GET /beep/v1/follow/ - register_rest_route(self::REST_NAMESPACE, '/follow/(?P\d+)', [ - 'methods' => 'GET', - 'callback' => [$this, 'check_follow'], - 'permission_callback' => '__return_true', - ]); - - // Get users I'm following: GET /beep/v1/following - register_rest_route(self::REST_NAMESPACE, '/following', [ - 'methods' => 'GET', - 'callback' => [$this, 'get_following'], - 'permission_callback' => [$this, 'require_login'], - ]); - - // Get my followers: GET /beep/v1/followers - register_rest_route(self::REST_NAMESPACE, '/followers', [ - 'methods' => 'GET', - 'callback' => [$this, 'get_followers'], - 'permission_callback' => [$this, 'require_login'], - ]); - } - - public function require_login() { - return is_user_logged_in() ? true : new WP_Error('rest_not_logged_in', 'You must be logged in', ['status' => 401]); - } - - public function follow_user($request) { - $user_id = intval($request->get_param('user_id')); - $follower_id = get_current_user_id(); - - if ($user_id === $follower_id) { - return new WP_Error('rest_cannot_follow_self', 'You cannot follow yourself', ['status' => 400]); - } - - $target = get_userdata($user_id); - if (!$target) { - return new WP_Error('rest_user_not_found', 'User not found', ['status' => 404]); - } - - global $wpdb; - $table = $wpdb->prefix . 'beep_follows'; - - $existing = $wpdb->get_row($wpdb->prepare( - "SELECT ID FROM `$table` WHERE follower_id = %d AND following_id = %d", - $follower_id, $user_id - )); - - if ($existing) { - return ['status' => 'following', 'user_id' => $user_id, 'display_name' => $target->display_name]; - } - - $wpdb->insert($table, [ - 'follower_id' => $follower_id, - 'following_id' => $user_id, - ], ['%d', '%d']); - - return ['status' => 'following', 'user_id' => $user_id, 'display_name' => $target->display_name]; - } - - public function unfollow_user($request) { - $user_id = intval($request->get_param('user_id')); - $follower_id = get_current_user_id(); - - global $wpdb; - $table = $wpdb->prefix . 'beep_follows'; - - $wpdb->delete($table, [ - 'follower_id' => $follower_id, - 'following_id' => $user_id, - ], ['%d', '%d']); - - return ['status' => 'unfollowed', 'user_id' => $user_id]; - } - - public function check_follow($request) { - $user_id = intval($request->get_param('user_id')); - $current_user = get_current_user_id(); - - global $wpdb; - $table = $wpdb->prefix . 'beep_follows'; - - $is_following = false; - if ($current_user) { - $is_following = (bool) $wpdb->get_var($wpdb->prepare( - "SELECT ID FROM `$table` WHERE follower_id = %d AND following_id = %d", - $current_user, $user_id - )); - } - - $target = get_userdata($user_id); - if (!$target) { - return new WP_Error('rest_user_not_found', 'User not found', ['status' => 404]); - } - - $follower_count = intval($wpdb->get_var($wpdb->prepare( - "SELECT COUNT(*) FROM `$table` WHERE following_id = %d", $user_id - ))); - - $following_count = intval($wpdb->get_var($wpdb->prepare( - "SELECT COUNT(*) FROM `$table` WHERE follower_id = %d", $user_id - ))); - - return [ - 'is_following' => $is_following, - 'user_id' => $user_id, - 'display_name' => $target->display_name, - 'user_login' => $target->user_login, - 'follower_count' => $follower_count, - 'following_count' => $following_count, - ]; - } - - public function get_following($request) { - $user_id = get_current_user_id(); - - global $wpdb; - $table = $wpdb->prefix . 'beep_follows'; - - $following = $wpdb->get_results($wpdb->prepare( - "SELECT u.ID, u.display_name, u.user_login, f.created_at - FROM `$table` f - JOIN {$wpdb->users} u ON u.ID = f.following_id - WHERE f.follower_id = %d - ORDER BY f.created_at DESC", - $user_id - ), ARRAY_A); - - return ['following' => $following, 'count' => count($following)]; - } - - public function get_followers($request) { - $user_id = get_current_user_id(); - - global $wpdb; - $table = $wpdb->prefix . 'beep_follows'; - - $followers = $wpdb->get_results($wpdb->prepare( - "SELECT u.ID, u.display_name, u.user_login, f.created_at - FROM `$table` f - JOIN {$wpdb->users} u ON u.ID = f.follower_id - WHERE f.following_id = %d - ORDER BY f.created_at DESC", - $user_id - ), ARRAY_A); - - return ['followers' => $followers, 'count' => count($followers)]; - } - - public static function is_following($follower_id, $following_id) { - global $wpdb; - $table = $wpdb->prefix . 'beep_follows'; - return (bool) $wpdb->get_var($wpdb->prepare( - "SELECT ID FROM `$table` WHERE follower_id = %d AND following_id = %d", - $follower_id, $following_id - )); - } - - public static function get_follower_count($user_id) { - global $wpdb; - $table = $wpdb->prefix . 'beep_follows'; - return intval($wpdb->get_var($wpdb->prepare( - "SELECT COUNT(*) FROM `$table` WHERE following_id = %d", $user_id - ))); - } - - public static function get_following_count($user_id) { - global $wpdb; - $table = $wpdb->prefix . 'beep_follows'; - return intval($wpdb->get_var($wpdb->prepare( - "SELECT COUNT(*) FROM `$table` WHERE follower_id = %d", $user_id - ))); - } -} - -// Bootstrap — register routes -add_action('rest_api_init', function() { - $f = new Beep_Follows(); - $f->register_routes(); -}); diff --git a/includes/class-magic-links.php b/includes/class-magic-links.php deleted file mode 100644 index c1b2f4c..0000000 --- a/includes/class-magic-links.php +++ /dev/null @@ -1,234 +0,0 @@ -prefix . self::TABLE; - if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) { - self::create_table(); - } - } - - public static function create_table() { - global $wpdb; - $table = $wpdb->prefix . self::TABLE; - $charset = $wpdb->get_charset_collate(); - - $sql = "CREATE TABLE IF NOT EXISTS {$table} ( - id bigint(20) unsigned NOT NULL auto_increment, - user_id bigint(20) unsigned NOT NULL, - token varchar(64) NOT NULL, - email varchar(100) NOT NULL, - created_at datetime NOT NULL default CURRENT_TIMESTAMP, - expires_at datetime NOT NULL, - used_at datetime DEFAULT NULL, - PRIMARY KEY (id), - UNIQUE KEY token (token), - KEY user_id (user_id), - KEY expires_at (expires_at) - ) {$charset};"; - - require_once(ABSPATH . 'wp-admin/includes/upgrade.php'); - dbDelta($sql); - } - - public static function register_rest_routes() { - register_rest_route('beep/v1', '/magic-link', [ - 'methods' => 'POST', - 'callback' => [__CLASS__, 'request_magic_link'], - 'permission_callback' => '__return_true', - ]); - } - - public static function request_magic_link($request) { - $email = sanitize_email($request->get_param('email')); - - if (!$email || !is_email($email)) { - return new WP_Error('invalid_email', 'Please enter a valid email address.', ['status' => 400]); - } - - $user = get_user_by('email', $email); - if (!$user) { - // Don't reveal whether an email exists - return ['success' => true, 'message' => 'If that email is registered, a login link has been sent.']; - } - - // Check rate limit (one request per 5 minutes per email) - $rate_key = 'beep_ml_' . md5($email); - $last_request = get_transient($rate_key); - if ($last_request) { - return new WP_Error( - 'rate_limited', - 'Please wait a few minutes before requesting another login link.', - ['status' => 429] - ); - } - - // Generate token - $token = wp_generate_password(self::TOKEN_LENGTH, false); - $hashed_token = hash('sha256', $token); - $expires_at = date('Y-m-d H:i:s', time() + (self::EXPIRY_HOURS * 3600)); - - // Store in database - global $wpdb; - $table = $wpdb->prefix . self::TABLE; - - $wpdb->insert($table, [ - 'user_id' => $user->ID, - 'token' => $hashed_token, - 'email' => $email, - 'expires_at' => $expires_at, - ]); - - // Set rate limit - set_transient($rate_key, true, 5 * 60); - - // Build magic link - $login_url = add_query_arg([ - 'beep_magic' => $token, - 'uid' => $user->ID, - ], home_url('/')); - - // Email content - $site_name = get_bloginfo('name'); - $subject = sprintf('[%s] Your login link', $site_name); - $message = sprintf( - "Hi %s,\n\nClick the link below to sign in to %s:\n\n%s\n\nThis link expires in %d hours.\n\nIf you didn't request this, you can ignore this email.\n\n— The %s team", - $user->display_name, - $site_name, - $login_url, - self::EXPIRY_HOURS, - $site_name - ); - - // Send email - $sent = wp_mail($email, $subject, $message); - - if (!$sent) { - return new WP_Error('email_failed', 'Failed to send email. Please try again.', ['status' => 500]); - } - - return ['success' => true, 'message' => 'Check your email for the login link!']; - } - - public static function handle_magic_link_login() { - if (!isset($_GET['beep_magic']) || !isset($_GET['uid'])) { - return; - } - - $token = sanitize_text_field($_GET['beep_magic']); - $user_id = (int) $_GET['uid']; - - if (!$token || !$user_id) { - return; - } - - // Verify token - global $wpdb; - $table = $wpdb->prefix . self::TABLE; - $hashed_token = hash('sha256', $token); - - $record = $wpdb->get_row($wpdb->prepare( - "SELECT * FROM {$table} WHERE token = %s AND user_id = %d AND used_at IS NULL AND expires_at > %s", - $hashed_token, - $user_id, - current_time('mysql') - )); - - if (!$record) { - wp_die('This login link is invalid or has expired. Please request a new one.'); - } - - // Mark as used - $wpdb->update( - $table, - ['used_at' => current_time('mysql')], - ['id' => $record->id] - ); - - // Log the user in - $user = get_user_by('id', $user_id); - if ($user) { - wp_set_current_user($user_id); - wp_set_auth_cookie($user_id); - do_action('wp_login', $user->user_login, $user); - - // Redirect to home or beep page - $redirect_url = isset($_GET['redirect_to']) ? esc_url_raw($_GET['redirect_to']) : home_url('/'); - wp_redirect($redirect_url); - exit; - } - } - - public static function cleanup_expired_tokens() { - // Run once per session - static $run = false; - if ($run) { - return; - } - $run = true; - - global $wpdb; - $table = $wpdb->prefix . self::TABLE; - $wpdb->query($wpdb->prepare( - "DELETE FROM {$table} WHERE expires_at < %s OR (used_at IS NOT NULL AND DATE_ADD(used_at, INTERVAL 1 HOUR) < %s)", - current_time('mysql'), - current_time('mysql') - )); - } - - public static function render_login_form() { - ob_start(); - ?> - - 'POST', 'callback' => [__CLASS__, 'submit_beep'], - 'permission_callback' => [Beep_Likes::class, 'can_interact'], + 'permission_callback' => ['Beep_Likes', 'can_interact'], ]); - register_rest_route('beep/v1', '/reply/(?P\d+)', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'submit_reply'], - 'permission_callback' => [Beep_Likes::class, 'can_interact'], + 'permission_callback' => ['Beep_Likes', 'can_interact'], ]); - - register_rest_route('beep/v1', '/beep/(?P\d+)', [ + register_rest_route('beep/v1', '/thread/(?P\d+)', [ + 'methods' => 'GET', + 'callback' => [__CLASS__, 'get_thread_api'], + 'permission_callback' => '__return_true', + ]); + register_rest_route('beep/v1', '/delete/(?P\d+)', [ 'methods' => 'DELETE', 'callback' => [__CLASS__, 'delete_beep'], - 'permission_callback' => function ($req) { - return current_user_can('delete_post', (int) $req['id']); - }, + 'permission_callback' => ['Beep_Likes', 'can_interact'], ]); - - register_rest_route('beep/v1', '/reply/(?P\d+)', [ + register_rest_route('beep/v1', '/delete-reply/(?P\d+)', [ 'methods' => 'DELETE', 'callback' => [__CLASS__, 'delete_reply'], - 'permission_callback' => function () { - return current_user_can('moderate_comments'); - }, + 'permission_callback' => ['Beep_Likes', 'can_interact'], ]); } public static function submit_beep($request) { - $content = trim((string) $request->get_param('content')); - $length = function_exists('mb_strlen') ? mb_strlen($content) : strlen($content); - - if ($content === '' || $length > BEEP_CHAR_LIMIT) { - return new WP_Error('beep_invalid_content', - 'Content must be 1-' . BEEP_CHAR_LIMIT . ' characters.', - ['status' => 400]); + $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]); + } + + // Handle quote post ID + $quote_id = $request->get_param('quote_id'); + $gif_url = $request->get_param('gif_url'); $post_id = wp_insert_post([ - 'post_type' => Beep_CPT::POST_TYPE, + 'post_type' => 'beep', + 'post_content' => $content, 'post_status' => 'publish', 'post_author' => get_current_user_id(), - 'post_content' => wp_kses($content, []), - 'post_title' => wp_trim_words($content, 8, '...'), - ], true); + ]); + if (is_wp_error($post_id)) { return $post_id; } - if (is_wp_error($post_id)) { - return $post_id; + // Attach quote if provided + if ($quote_id && is_numeric($quote_id)) { + Beep_Quotes::create_quote($post_id, (int) $quote_id); } - return rest_ensure_response([ - 'id' => $post_id, - 'html' => beep_render_post(get_post($post_id)), - ]); + // Attach GIF URL if provided + if ($gif_url && filter_var($gif_url, FILTER_VALIDATE_URL)) { + update_post_meta($post_id, 'beep_gif_url', esc_url_raw($gif_url)); + } + + return rest_ensure_response(['id' => $post_id, 'url' => get_permalink($post_id)]); } public static function submit_reply($request) { - $post_id = (int) $request['id']; - $content = trim((string) $request->get_param('content')); - $parent_id = (int) $request->get_param('parent'); - $length = function_exists('mb_strlen') ? mb_strlen($content) : strlen($content); - - if (get_post_type($post_id) !== Beep_CPT::POST_TYPE) { - return new WP_Error('beep_not_a_beep', 'Not a beep.', ['status' => 400]); + $post_id = (int) $request['id']; + $parent = get_post($post_id); + if (!$parent || $parent->post_type !== 'beep') { + return new WP_Error('invalid_post', 'Invalid beep post.', ['status' => 400]); } - if ($content === '' || $length > BEEP_CHAR_LIMIT) { - return new WP_Error('beep_invalid_content', - 'Reply must be 1-' . BEEP_CHAR_LIMIT . ' characters.', - ['status' => 400]); + $content = sanitize_textarea_field($request->get_param('content')); + if (empty($content)) { + return new WP_Error('empty_content', 'Reply cannot be empty.', ['status' => 400]); } - // Validate the parent comment belongs to this post if provided. - if ($parent_id > 0) { - $parent = get_comment($parent_id); - if (!$parent || (int) $parent->comment_post_ID !== $post_id) { - return new WP_Error('beep_bad_parent', 'Invalid parent.', ['status' => 400]); - } - } - - $user = wp_get_current_user(); - - $approved = get_option('comment_moderation') ? 0 : 1; - if (get_option('comment_previously_approved')) { - $prev = get_comments([ - 'user_id' => $user->ID, - 'status' => 'approve', - 'count' => true, - ]); - if (!$prev) { - $approved = 0; - } - } - $comment_id = wp_insert_comment([ - 'comment_post_ID' => $post_id, - 'comment_author' => $user->display_name, - 'comment_author_email' => $user->user_email, - 'comment_author_url' => '', - 'comment_content' => wp_kses($content, []), - 'comment_type' => 'comment', - 'comment_parent' => $parent_id, - 'user_id' => $user->ID, - 'comment_approved' => $approved, + 'comment_post_ID' => $post_id, + 'comment_content' => $content, + 'comment_author' => get_current_user_id() ? null : 'Anonymous', + 'comment_type' => 'comment', + 'user_id' => get_current_user_id(), ]); - if (!$comment_id) { - return new WP_Error('beep_insert_failed', 'Reply could not be saved.', ['status' => 500]); + return new WP_Error('insert_failed', 'Could not save reply.', ['status' => 500]); } - - $comment = get_comment($comment_id); - $pending = ($comment->comment_approved == 0); - - // Depth of this reply for client-side indenting. - $depth = 0; - $cur = $comment; - while ($cur && (int) $cur->comment_parent > 0 && $depth < 10) { - $cur = get_comment((int) $cur->comment_parent); - $depth++; - } - $can_interact = Beep_Likes::can_interact(); - - return rest_ensure_response([ - 'id' => $comment_id, - 'parent' => (int) $comment->comment_parent, - 'pending' => $pending, - 'html' => $pending ? '' : beep_render_reply($comment, [], $depth, $post_id, $can_interact), - ]); + return rest_ensure_response(['id' => $comment_id]); } public static function delete_beep($request) { $post_id = (int) $request['id']; - if (get_post_type($post_id) !== Beep_CPT::POST_TYPE) { - return new WP_Error('beep_not_a_beep', 'Not a beep.', ['status' => 400]); + $post = get_post($post_id); + if (!$post || $post->post_type !== 'beep') { + return new WP_Error('invalid_post', 'Invalid beep.', ['status' => 400]); } + if (!current_user_can('delete_post', $post_id)) { + return new WP_Error('forbidden', 'You do not have permission to delete this beep.', ['status' => 403]); + } + // Clean up media + Beep_Media::delete_media($post_id); $deleted = wp_delete_post($post_id, true); if (!$deleted) { - return new WP_Error('beep_delete_failed', 'Could not delete.', ['status' => 500]); + return new WP_Error('delete_failed', 'Could not delete.', ['status' => 500]); } return rest_ensure_response(['deleted' => true]); } public static function delete_reply($request) { $comment_id = (int) $request['id']; + $comment = get_comment($comment_id); + if (!$comment) { + return new WP_Error('invalid_reply', 'Reply not found.', ['status' => 404]); + } + // Only allow the reply author or a moderator to delete + $can_delete = ( + (int) $comment->user_id === get_current_user_id() || + current_user_can('moderate_comments') + ); + if (!$can_delete) { + return new WP_Error('forbidden', 'You do not have permission to delete this reply.', ['status' => 403]); + } $deleted = wp_delete_comment($comment_id, true); if (!$deleted) { - return new WP_Error('beep_delete_failed', 'Could not delete.', ['status' => 500]); + return new WP_Error('delete_failed', 'Could not delete.', ['status' => 500]); } return rest_ensure_response(['deleted' => true]); } @@ -172,4 +143,110 @@ class Beep_Replies { public static function reply_count($post_id) { return (int) get_comments_number($post_id); } + + public static function get_thread($post_id, $depth = 0, $max_depth = 10) { + if ($depth > $max_depth) return []; + $post = get_post($post_id); + if (!$post || $post->post_type !== 'beep') return []; + $thread = [self::format_beep_reply($post, $depth)]; + $replies = get_comments([ + 'post_id' => $post_id, 'status' => 'approve', 'order' => 'ASC', + 'type' => 'comment', 'number' => 100, + ]); + foreach ($replies as $reply) { + if ($depth < $max_depth) { + $thread = array_merge($thread, self::get_comment_thread($reply->comment_ID, $depth + 1, $max_depth)); + } + } + return $thread; + } + + private static function get_comment_thread($comment_id, $depth, $max_depth) { + if ($depth > $max_depth) return []; + $comment = get_comment($comment_id); + if (!$comment) return []; + $result = [self::format_comment_reply($comment, $depth)]; + $children = get_comments([ + 'parent' => $comment_id, 'status' => 'approve', 'order' => 'ASC', + 'type' => 'comment', 'number' => 100, + ]); + foreach ($children as $child) { + $result = array_merge($result, self::get_comment_thread($child->comment_ID, $depth + 1, $max_depth)); + } + return $result; + } + + public static function get_thread_api($request) { + $post_id = (int) $request['post_id']; + $thread = self::get_thread($post_id); + return rest_ensure_response(['thread' => $thread]); + } + + public static function format_beep_reply($post, $depth = 0) { + $author_id = $post->post_author; + $author = get_userdata($author_id); + $avatar = get_user_meta($author_id, 'beep_avatar', true); + if (!$avatar) { + $avatar = 'https://www.gravatar.com/avatar/' . md5(strtolower($author->user_email ?? '')) . '?s=48&d=mp'; + } + return [ + 'id' => $post->ID, + 'parent_id' => $post->post_parent ?: null, + 'is_post' => true, + 'depth' => $depth, + 'content' => $post->post_content, + 'time_ago' => self::time_ago(strtotime($post->post_date)), + 'like_count' => Beep_Likes::get_count($post->ID, 'post'), + 'reply_count' => self::reply_count($post->ID), + 'author' => [ + 'id' => $author_id, + 'name' => $author->display_name ?? 'Unknown', + 'username' => $author->user_login ?? '', + 'avatar' => $avatar, + ], + ]; + } + + public static function format_comment_reply($comment, $depth = 0) { + $author_id = (int) $comment->user_id; + $author = $author_id ? get_userdata($author_id) : null; + if ($author_id) { + $avatar = get_user_meta($author_id, 'beep_avatar', true); + if (!$avatar) { + $avatar = 'https://www.gravatar.com/avatar/' . md5(strtolower($author->user_email ?? '')) . '?s=48&d=mp'; + } + $name = $author->display_name ?? 'Unknown'; + $username = $author->user_login ?? ''; + } else { + $avatar = 'https://www.gravatar.com/avatar/?s=48&d=mp'; + $name = esc_html($comment->comment_author); + $username = ''; + } + return [ + 'id' => $comment->comment_ID, + 'parent_id' => (int) $comment->comment_parent ?: null, + 'is_post' => false, + 'depth' => $depth, + 'content' => $comment->comment_content, + 'time_ago' => self::time_ago(strtotime($comment->comment_date)), + 'like_count' => Beep_Likes::get_count($comment->comment_ID, 'reply'), + 'reply_count' => 0, + 'author' => [ + 'id' => $author_id, + 'name' => $name, + 'username' => $username, + 'avatar' => $avatar, + ], + ]; + } + + public static function time_ago($timestamp) { + $diff = time() - $timestamp; + if ($diff < 0) $diff = 0; + if ($diff < 60) return $diff . 's'; + if ($diff < 3600) return floor($diff / 60) . 'm'; + if ($diff < 86400) return floor($diff / 3600) . 'h'; + if ($diff < 604800) return floor($diff / 86400) . 'd'; + return gmdate('M j', $timestamp); + } } diff --git a/includes/class-settings.php b/includes/class-settings.php old mode 100644 new mode 100755 diff --git a/includes/class-shortcode.php b/includes/class-shortcode.php index a16fdd7..04c3aa7 100755 --- a/includes/class-shortcode.php +++ b/includes/class-shortcode.php @@ -21,6 +21,7 @@ class Beep_Shortcode { '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); }], ], ]); } @@ -29,32 +30,39 @@ class Beep_Shortcode { * 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')); - $posts = ($sort === 'top') ? self::get_top_beeps($limit) : self::get_latest_beeps($limit); + $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 === '') { - $html = '
No beeps yet.
'; + if ($html === '' && $offset === 0) { + $empty_bird = beep_bird_svg( ['size' => 64, 'fill' => '#4a90c4'] ); + $html = '
' . $empty_bird . '

No beeps yet.

Be the first to share something!
'; } - return rest_ensure_response(['html' => $html, 'sort' => $sort]); + return rest_ensure_response(['html' => $html, 'sort' => $sort, 'has_more' => $has_more, 'offset' => $offset + count($posts)]); } - public static function get_latest_beeps($limit) { + 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", - Beep_CPT::POST_TYPE, $limit + LIMIT %d OFFSET %d", + Beep_CPT::POST_TYPE, $limit, $offset )); } - public static function get_top_beeps($limit) { + public static function get_top_beeps($limit, $offset = 0) { global $wpdb; $likes = Beep_Likes::table_name(); return $wpdb->get_results($wpdb->prepare( @@ -68,8 +76,8 @@ class Beep_Shortcode { ) 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", - Beep_CPT::POST_TYPE, $limit + LIMIT %d OFFSET %d", + Beep_CPT::POST_TYPE, $limit, $offset )); } @@ -86,21 +94,12 @@ class Beep_Shortcode { $posts = ($sort === 'top') ? self::get_top_beeps($limit) : self::get_latest_beeps($limit); $has_posts = !empty($posts); - // Show magic login form if not logged in - if (!is_user_logged_in()) { - return Beep_Magic_Links::render_login_form() . beep_render_public_feed($posts, $atts); - } - ob_start(); ?>
- - - - - +
Messages from Beep!
@@ -112,21 +111,7 @@ class Beep_Shortcode {
- - - -
- Your account has been suspended from beeping. -
- - - +
No beeps yet.
'; + $empty_bird = beep_bird_svg( ['size' => 64, 'fill' => '#4a90c4'] ); + echo '
' . $empty_bird . '

No beeps yet.

Be the first to share something!
'; } ?>
var BeepConfig=%s;', - json_encode([ - 'restUrl' => esc_url_raw(rest_url('beep/v1/')), - 'nonce' => wp_create_nonce('wp_rest'), - 'charLimit' => BEEP_CHAR_LIMIT, - 'loginUrl' => esc_url_raw(wp_login_url(is_singular() ? get_permalink() : home_url('/'))), - 'isLoggedIn' => is_user_logged_in(), - ]) - ); - $beep_script = ''; - return ob_get_clean() . $beep_config . $beep_script . beep_render_thread_modal() . beep_render_gif_modal(); + return ob_get_clean() . beep_render_thread_modal() . beep_render_gif_modal(); } /** diff --git a/includes/class-shortcode.php.bak b/includes/class-shortcode.php.bak new file mode 100755 index 0000000..8fd52c4 --- /dev/null +++ b/includes/class-shortcode.php.bak @@ -0,0 +1,292 @@ + '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 = '
' . $empty_bird . '

No beeps yet.

Be the first to share something!
'; + } + 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(); + ?> +
+
+
+
Messages from Beep!
+
+ + +
+ + +
+ + + + + +
+ Your account has been suspended from beeping. +
+ + + + +
+ 64, 'fill' => '#4a90c4'] ); + echo '
' . $empty_bird . '

No beeps yet.

Be the first to share something!
'; + } + ?> +
+
+
+ \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\d+)/(?P\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\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\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\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); + } +} diff --git a/includes/class-updater.php b/includes/class-updater.php deleted file mode 100644 index ad149dc..0000000 --- a/includes/class-updater.php +++ /dev/null @@ -1,309 +0,0 @@ -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', '

$1

', $html); - $html = preg_replace('/^##\s+(.*)$/m', '

$1

', $html); - $html = preg_replace('/^[\*\-]\s+(.*)$/m', '
  • $1
  • ', $html); - $html = preg_replace('/(
  • .*<\/li>)/s', '
      $1
    ', $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-". - * 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[] = 'Check for updates'; - 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; -}); diff --git a/includes/create_poll_tables.php b/includes/create_poll_tables.php deleted file mode 100644 index 3ed8028..0000000 --- a/includes/create_poll_tables.php +++ /dev/null @@ -1,56 +0,0 @@ -prefix . "beep_polls"; -$votes_table = $wpdb->prefix . "beep_poll_votes"; -$charset = $wpdb->get_charset_collate(); - -$polls_sql = "CREATE TABLE IF NOT EXISTS $polls_table ( - id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, - beep_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 beep_id (beep_id) -) $charset;"; - -$votes_sql = "CREATE TABLE IF NOT EXISTS $votes_table ( - id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, - poll_id BIGINT(20) UNSIGNED NOT NULL, - user_id BIGINT(20) UNSIGNED NOT NULL, - option_index INT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (id), - UNIQUE KEY poll_user (poll_id, user_id), - KEY poll_id (poll_id), - KEY user_id (user_id) -) $charset;"; - -require_once(ABSPATH . "wp-admin/includes/upgrade.php"); -$result1 = dbDelta($polls_sql); -$result2 = dbDelta($votes_sql); -echo "Tables created: $polls_table, $votes_table"; -echo " -Result1: " . print_r($result1, true); -echo " -Result2: " . print_r($result2, true); -?> \ No newline at end of file diff --git a/includes/helpers.php b/includes/helpers.php index 3feee9f..82241e2 100755 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -3,13 +3,55 @@ if (!defined('ABSPATH')) { exit; } +/** + * The Beep bird icon rendered from Sami's real PNG asset. + * Uses the black-silhouette PNG + CSS filter for brand colour. + * + * @param array $args { + * Optional. Display args. + * @type int $size Width/height in px (default 24). + * @type string $fill Colour applied via CSS filter (default #0064cd). + * @type string $class Extra CSS class(es). + * } + */ +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 '