feat(v1.8.0): Twitter-style conversation modal, light theme, major CSS/JS refactor

This commit is contained in:
SamiAhmed7777
2026-06-09 18:18:55 -07:00
parent 955e443274
commit ba00d0d382
9 changed files with 1330 additions and 932 deletions
+10 -2
View File
@@ -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
+898 -619
View File
File diff suppressed because it is too large Load Diff
+76 -51
View File
@@ -10,6 +10,12 @@
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 +26,6 @@
initSortTabs(feed);
initMedia(compose, feed);
initInfiniteScroll(feed);
feed.addEventListener('click', onClick);
feed.addEventListener('input', onInput);
feed.addEventListener('keydown', onKeydown);
}
/* ---------- Infinite scroll ---------- */
@@ -119,7 +122,7 @@
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);
@@ -134,7 +137,7 @@
var pending = compose._beepPendingImages;
var pendingVoice = compose._beepPendingVoice;
var uploadDone = function () {
reloadFeed(feed, 'latest');
reloadFeed(feed, feedState.currentSort);
};
var tasks = [];
@@ -405,6 +408,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, [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 +449,20 @@
}
function onKeydown(e) {
// Enter in the GIF search box runs the search
if (e.key === 'Enter' && e.target.matches && e.target.matches('[data-beep-gif-search]')) {
e.preventDefault();
var compose = document.querySelector('[data-beep-compose]');
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 +520,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 () {
@@ -786,66 +826,48 @@
}
/* ---------- 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>';
// 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 ---------- */
@@ -1085,10 +1107,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 +1148,4 @@
return res.json();
}
})();
/* Beep frontend — end */
+2 -2
View File
@@ -2,7 +2,7 @@
/**
* Plugin Name: Beep
* Description: A Twitter-style microblog for WordPress, with likes, replies, and Gravatars. Use the [beep_feed] shortcode on any page.
* Version: 1.7.4
* Version: 1.8.0
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: Sami Ahmed
@@ -14,7 +14,7 @@ if (!defined('ABSPATH')) {
exit;
}
define('BEEP_VERSION', '1.7.4');
define('BEEP_VERSION', '1.8.0');
define('BEEP_FILE', __FILE__);
define('BEEP_DIR', plugin_dir_path(__FILE__));
define('BEEP_URL', plugin_dir_url(__FILE__));
+3 -8
View File
@@ -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 () {
add_action('elementor/widgets/register', function ($widgets_manager) {
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);
+4 -2
View File
@@ -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);
}
}
+52 -15
View File
@@ -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,37 @@ 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="<?php echo esc_url( BEEP_URL . 'assets/beep-wordmark.png?v=' . BEEP_VERSION ); ?>" alt="Beep!" style="height:30px;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">
<?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 +126,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 +236,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) {
+264 -231
View File
@@ -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,29 +35,22 @@ 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. Stays sharp at
* any size and ships as ~1KB of markup (no image request). The matching raster
* version lives at assets/beep-wordmark.png (used in the feed header).
* 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="...">');
*/
@@ -79,7 +69,6 @@ function beep_post_wordmark() {
. '<g font-family="&quot;Arial Black&quot;,&quot;Arial Bold&quot;,Arial,Helvetica,sans-serif"'
. ' font-weight="900" font-size="52" letter-spacing="-1">'
. $extrude
// Front face: blue-violet fill with a navy outline drawn behind it.
. '<text x="9" y="49" fill="#5238E6" stroke="#15155C" stroke-width="1.8"'
. ' stroke-linejoin="round" paint-order="stroke">Beep!</text>'
. '</g></svg>';
@@ -87,6 +76,45 @@ function beep_post_wordmark() {
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 . '>';
}
/**
* Is this user banned from posting/liking/replying?
*/
@@ -101,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();
?>
@@ -126,65 +146,7 @@ function beep_render_composer() {
placeholder="What's happening?"
maxlength="<?php echo (int) BEEP_CHAR_LIMIT; ?>"
rows="1"></textarea>
<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>
</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">&times;</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-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>
@@ -192,44 +154,96 @@ function beep_render_composer() {
<button class="beep-voice-remove" data-beep-voice-remove type="button" title="Remove">&times;</button>
</div>
</div>
<div class="beep-compose-gif" data-beep-gif-preview hidden></div>
<div class="beep-compose-quote" data-beep-compose-quote hidden>
<div class="beep-quote-input-row">
<input type="text" class="beep-quote-lookup" placeholder="Paste beep URL or ID..." data-beep-quote-lookup>
<button type="button" class="beep-quote-lookup-btn" data-beep-quote-lookup-btn>Add</button>
</div>
<div class="beep-quote-preview" data-beep-quote-preview hidden></div>
<button type="button" class="beep-quote-remove" data-beep-quote-remove hidden>Remove quote</button>
</div>
<div class="beep-compose-poll" data-beep-compose-poll hidden>
<div class="beep-poll-input-row">
<input type="text" class="beep-poll-question" data-beep-poll-question placeholder="Ask a question...">
</div>
<div class="beep-poll-options" data-beep-poll-options>
<div class="beep-poll-option-row">
<input type="text" class="beep-poll-option" data-beep-poll-option placeholder="Option 1" maxlength="100">
</div>
<div class="beep-poll-option-row">
<input type="text" class="beep-poll-option" data-beep-poll-option placeholder="Option 2" maxlength="100">
</div>
</div>
<div class="beep-poll-actions">
<button type="button" class="beep-poll-add-option" data-beep-poll-add-option>+ Add option</button>
<button type="button" class="beep-poll-remove-option" data-beep-poll-remove-option hidden>&minus; Remove</button>
<button type="button" class="beep-poll-remove" data-beep-poll-remove>Remove poll</button>
</div>
</div>
<div class="beep-compose-footer">
<div class="beep-compose-tools">
<label class="beep-compose-tool beep-media-btn" title="Add up to 4 images">
<input type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple data-beep-media-input hidden>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M3 5.5C3 4.119 4.119 3 5.5 3h13C19.881 3 21 4.119 21 5.5v13c0 1.381-1.119 2.5-2.5 2.5h-13C4.119 21 3 19.881 3 18.5v-13zM5.5 5c-.276 0-.5.224-.5.5v9.086l3-3 3 3 5-5 3 3V5.5c0-.276-.224-.5-.5-.5h-13zM19 15.414l-3-3-5 5-3-3-3 3V18.5c0 .276.224.5.5.5h13c.276 0 .5-.224.5-.5v-3.086zM9.75 7C8.784 7 8 7.784 8 8.75s.784 1.75 1.75 1.75 1.75-.784 1.75-1.75S10.716 7 9.75 7z"/></svg>
</label>
<span class="beep-media-count" data-beep-media-count hidden>0/4</span>
<button type="button" class="beep-compose-tool beep-gif-btn" data-beep-gif-btn title="Add GIF">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M3 5.5C3 4.119 4.119 3 5.5 3h13C19.881 3 21 4.119 21 5.5v13c0 1.381-1.119 2.5-2.5 2.5h-13C4.119 21 3 19.881 3 18.5v-13zM5.5 5c-.276 0-.5.224-.5.5v13c0 .276.224.5.5.5h13c.276 0 .5-.224.5-.5v-13c0-.276-.224-.5-.5-.5h-13zM18 10.711V9.25h-3.74v5.5h1.44v-1.719h1.7V11.57h-1.7v-.859H18zM11.79 9.25h1.44v5.5h-1.44v-5.5zm-3.07 1.375c.34 0 .77.172 1.02.43l1.03-.86c-.51-.601-1.28-.945-2.05-.945C7.19 9.25 6 10.453 6 12s1.19 2.75 2.72 2.75c.85 0 1.54-.344 2.05-.945v-2.149H8.38v1.032H9.4v.515c-.17.086-.42.172-.68.172-.76 0-1.36-.602-1.36-1.375 0-.688.6-1.375 1.36-1.375z"/></svg>
</button>
<label class="beep-compose-tool beep-voice-btn" title="Add voice note">
<input type="file" accept="audio/mpeg,audio/ogg,audio/wav,audio/webm,audio/mp4,audio/x-m4a" data-beep-voice-input hidden>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08C16.39 17.43 19 14.53 19 11h-2z"/></svg>
</label>
<button type="button" class="beep-compose-tool beep-poll-btn" data-beep-poll-btn title="Add poll">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M6 5c-1.1 0-2 .895-2 2s.9 2 2 2 2-.895 2-2-.9-2-2-2zM2 7c0-2.209 1.79-4 4-4s4 1.791 4 4-1.79 4-4 4-4-1.791-4-4zm20 1H12V6h10v2zM6 15c-1.1 0-2 .895-2 2s.9 2 2 2 2-.895 2-2-.9-2-2-2zm-4 2c0-2.209 1.79-4 4-4s4 1.791 4 4-1.79 4-4 4-4-1.791-4-4zm20 1H12v-2h10v2z"/></svg>
</button>
<button type="button" class="beep-compose-tool beep-quote-composer-btn" data-beep-quote-composer-btn title="Quote a beep">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M14.23 2.854c.98-.977 2.56-.977 3.54 0l3.38 3.378c.97.977.97 2.559 0 3.536L9.91 21H3v-6.914L14.23 2.854zm2.12 1.414c-.19-.195-.51-.195-.7 0L5 14.914V19h4.09L19.73 8.354c.2-.196.2-.512 0-.708l-3.38-3.378z"/></svg>
</button>
</div>
<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>
</div>
</div>
<?php
return ob_get_clean();
}
/**
* Render a single beep post + its replies tree.
* 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) {
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($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);
@@ -237,11 +251,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>
@@ -251,6 +264,14 @@ 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 echo beep_bird_svg( ['size' => 18, 'class' => 'beep-post-badge'] ); ?>
<?php if ($can_delete) : ?>
<button class="beep-delete-link"
data-beep-delete="<?php echo (int) $post->ID; ?>"
type="button"
aria-label="Delete this beep"
title="Delete">&times;</button>
<?php endif; ?>
</div>
<div class="beep-text"><?php echo $content; ?></div>
<?php if (!empty($beep_images)) : ?>
@@ -268,76 +289,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; ?>"
@@ -347,11 +334,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"
@@ -359,59 +341,113 @@ 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 echo beep_post_wordmark(); ?>
<?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">&times;</button>
<?php endif; ?>
</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>
<?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) {
$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" 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>
<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>
</div>
</div>
<?php
@@ -440,10 +476,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);
@@ -454,7 +488,7 @@ function beep_render_reply($comment, $all_comments = [], $depth = 0, $post_id =
$time_iso = gmdate('c', $ts);
$content = beep_format_content($comment->comment_content);
$can_delete = current_user_can('moderate_comments');
$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');
@@ -466,7 +500,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>
@@ -474,7 +507,13 @@ 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">&times;</button>
<?php endif; ?>
</div>
<div class="beep-text"><?php echo $content; ?></div>
@@ -497,16 +536,6 @@ 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; ?>
<div class="beep-bottom-bar beep-bottom-bar-reply">
<?php echo beep_post_wordmark(); ?>
<?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">&times;</button>
<?php endif; ?>
</div>
<?php
// Recursively render children
@@ -575,11 +604,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] : '';
}
@@ -590,14 +619,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>';
}
@@ -607,8 +638,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>' .
@@ -618,5 +650,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>';
}
+21 -2
View File
@@ -4,7 +4,7 @@ Tags: microblog, twitter, tweets, social, comments, polls, elementor
Requires at least: 6.0
Tested up to: 6.9
Requires PHP: 7.4
Stable tag: 1.7.4
Stable tag: 1.8.0
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,29 @@ 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.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).