Files
beep/includes/helpers.php
T

815 lines
40 KiB
PHP
Executable File

<?php
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;
$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.
$filter = 'brightness(0) saturate(100%) invert(22%) sepia(100%) saturate(10000%) hue-rotate(190deg)';
return '<svg' . $extra
. ' viewBox="0 0 24 24" width="' . $size . '" height="' . $size . '"'
. ' xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'
. '<foreignObject x="0" y="0" width="24" height="24">'
. '<img xmlns="http://www.w3.org/1999/xhtml"'
. ' src="' . esc_url( BEEP_URL . 'assets/beep-bird.png' ) . '"'
. ' alt="" style="width:100%;height:100%;object-fit:contain;filter:' . esc_attr( $filter ) . ';"/>'
. '</foreignObject>'
. '</svg>';
}
/**
* Wordmark (bird + "Beep!") shown at the top of the feed.
*/
function beep_logo_svg() {
// Allow site-owners to swap in a custom logo via settings.
$custom = class_exists('Beep_Settings') ? Beep_Settings::get( 'logo_url' ) : '';
if ( $custom ) {
return '<img class="beep-logo" src="' . esc_url( $custom ) . '" alt="Beep">';
}
return '<img class="beep-logo" src="' . esc_url( BEEP_URL . 'assets/beep-wordmark.png?v=' . BEEP_VERSION ) . '" alt="Beep!">';
}
/**
* The "Beep!" brand wordmark shown at the bottom-right of every beep and reply.
*
* Rendered as a crisp inline SVG recreation of the 3D extruded brand logo —
* a blue-violet face with a darker-blue depth and navy outline.
* To swap in the PNG here instead, hook the filter:
* add_filter('beep_post_wordmark', fn() => '<img class="beep-post-wordmark" src="...">');
*/
function beep_post_wordmark() {
// Build the 3D extrusion: stacked copies offset down-left, deepest first.
$depth = 7;
$extrude = '';
for ( $i = $depth; $i >= 1; $i-- ) {
$x = 9 - $i;
$y = 49 + $i;
$extrude .= '<text x="' . $x . '" y="' . $y . '" fill="#2A2AAE">Beep!</text>';
}
$svg = '<svg class="beep-post-wordmark" viewBox="0 0 168 72"'
. ' role="img" aria-label="Beep!" xmlns="http://www.w3.org/2000/svg">'
. '<g font-family="&quot;Arial Black&quot;,&quot;Arial Bold&quot;,Arial,Helvetica,sans-serif"'
. ' font-weight="900" font-size="52" letter-spacing="-1">'
. $extrude
. '<text x="9" y="49" fill="#5238E6" stroke="#15155C" stroke-width="1.8"'
. ' stroke-linejoin="round" paint-order="stroke">Beep!</text>'
. '</g></svg>';
return apply_filters('beep_post_wordmark', $svg);
}
/**
* Resolve the avatar URL for a user: custom beep_avatar meta, then Gravatar,
* then the brand bird as a last resort.
*
* @return array [ $url, $css_filter ]
*/
function beep_avatar_url( $user_id ) {
$user_id = (int) $user_id;
if ( $user_id ) {
$custom = get_user_meta( $user_id, 'beep_avatar', true );
if ( $custom ) {
return [ $custom, '' ];
}
$user = get_userdata( $user_id );
if ( $user && ! empty( $user->user_email ) ) {
return [ 'https://www.gravatar.com/avatar/' . md5( strtolower( $user->user_email ) ) . '?s=96&d=mp', '' ];
}
}
return [
BEEP_URL . 'assets/beep-bird.png',
'brightness(0) saturate(100%) invert(22%) sepia(100%) saturate(10000%) hue-rotate(190deg)',
];
}
/**
* Render a circular avatar <img> for a user.
*
* @param int $user_id User ID (0 = anonymous → brand bird).
* @param string $class Extra class, e.g. 'beep-avatar-sm'.
*/
function beep_avatar_img( $user_id, $class = '' ) {
list( $url, $filter ) = beep_avatar_url( $user_id );
$classes = trim( 'beep-avatar ' . $class );
$style = $filter ? ' style="filter:' . esc_attr( $filter ) . ';"' : '';
return '<img class="' . esc_attr( $classes ) . '" src="' . esc_url( $url ) . '" alt="" loading="lazy" decoding="async"' . $style . '>';
}
/**
* Is this user banned from posting/liking/replying?
*/
function beep_user_is_banned($user_id) {
if (!$user_id) {
return false;
}
return get_user_meta($user_id, 'beep_banned', true) === '1';
}
/**
* Render the composer textarea (for logged-in, non-banned users).
*/
function beep_render_composer() {
if ( ! is_user_logged_in() || beep_user_is_banned( get_current_user_id() ) ) {
return '';
}
$avatar = beep_avatar_img( get_current_user_id() );
ob_start();
?>
<div class="beep-compose" data-beep-compose>
<?php echo $avatar; ?>
<div class="beep-compose-body">
<textarea
class="beep-input beep-compose-input"
data-beep-input
placeholder="What's happening?"
maxlength="<?php echo (int) BEEP_CHAR_LIMIT; ?>"
rows="1"></textarea>
<?php echo beep_render_media_previews('compose'); ?>
<?php echo beep_render_quote_section('compose'); ?>
<?php echo beep_render_poll_section('compose'); ?>
<div class="beep-compose-footer">
<?php echo beep_render_compose_toolbar('compose'); ?>
<div class="beep-compose-submit-row">
<span class="beep-char-counter" data-beep-counter><?php echo (int) BEEP_CHAR_LIMIT; ?></span>
<button class="beep-button beep-post-button" data-beep-submit disabled>Beep</button>
</div>
</div>
</div>
</div>
<?php
return ob_get_clean();
}
/**
* Render the media previews block (image grid + voice card + GIF card).
* Shared between compose and reply forms.
*
* @param string $scope 'compose' or 'reply'
*/
function beep_render_media_previews($scope = 'compose') {
$cls = $scope === 'reply' ? 'beep-reply' : 'beep-compose';
?>
<div class="<?php echo esc_attr($cls); ?>-media-preview" data-beep-media-preview hidden></div>
<div class="<?php echo esc_attr($cls); ?>-voice-preview" data-beep-voice-preview hidden>
<div class="beep-voice-preview-card">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08C16.39 17.43 19 14.53 19 11h-2z"/></svg>
<span>Voice note</span>
<button class="beep-voice-remove" data-beep-voice-remove type="button" title="Remove">&times;</button>
</div>
</div>
<div class="<?php echo esc_attr($cls); ?>-gif" data-beep-gif-preview hidden></div>
<?php
}
/**
* Render the quote-lookup section (input + preview).
* Shared between compose and reply forms.
*/
function beep_render_quote_section($scope = 'compose') {
$cls = $scope === 'reply' ? 'beep-reply' : 'beep-compose';
?>
<div class="<?php echo esc_attr($cls); ?>-quote" data-beep-compose-quote hidden>
<div class="beep-quote-input-row">
<input type="text" class="beep-quote-lookup" placeholder="Paste beep URL or ID..." data-beep-quote-lookup>
<button type="button" class="beep-quote-lookup-btn" data-beep-quote-lookup-btn>Add</button>
</div>
<div class="beep-quote-preview" data-beep-quote-preview hidden></div>
<button type="button" class="beep-quote-remove" data-beep-quote-remove hidden>Remove quote</button>
</div>
<?php
}
/**
* Render the poll-creation section.
* Shared between compose and reply forms.
*/
function beep_render_poll_section($scope = 'compose') {
$cls = $scope === 'reply' ? 'beep-reply' : 'beep-compose';
?>
<div class="<?php echo esc_attr($cls); ?>-poll" data-beep-compose-poll hidden>
<div class="beep-poll-input-row">
<input type="text" class="beep-poll-question" data-beep-poll-question placeholder="Ask a question...">
</div>
<div class="beep-poll-options" data-beep-poll-options>
<div class="beep-poll-option-row">
<input type="text" class="beep-poll-option" data-beep-poll-option placeholder="Option 1" maxlength="100">
</div>
<div class="beep-poll-option-row">
<input type="text" class="beep-poll-option" data-beep-poll-option placeholder="Option 2" maxlength="100">
</div>
</div>
<div class="beep-poll-actions">
<button type="button" class="beep-poll-add-option" data-beep-poll-add-option>+ Add option</button>
<button type="button" class="beep-poll-remove-option" data-beep-poll-remove-option hidden>&minus; Remove</button>
<button type="button" class="beep-poll-remove" data-beep-poll-remove>Remove poll</button>
</div>
</div>
<?php
}
/**
* Render the compose toolbar (image / GIF / voice / poll / quote buttons).
* Shared between compose and reply forms.
*/
function beep_render_compose_toolbar($scope = 'compose') {
$cls = $scope === 'reply' ? 'beep-reply' : 'beep-compose';
?>
<div class="<?php echo esc_attr($cls); ?>-tools">
<label class="<?php echo esc_attr($cls); ?>-tool <?php echo esc_attr($cls); ?>-media-btn" title="Add up to 4 images">
<input type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple data-beep-media-input hidden>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M3 5.5C3 4.119 4.119 3 5.5 3h13C19.881 3 21 4.119 21 5.5v13c0 1.381-1.119 2.5-2.5 2.5h-13C4.119 21 3 19.881 3 18.5v-13zM5.5 5c-.276 0-.5.224-.5.5v9.086l3-3 3 3 5-5 3 3V5.5c0-.276-.224-.5-.5-.5h-13zM19 15.414l-3-3-5 5-3-3-3 3V18.5c0 .276.224.5.5.5h13c.276 0 .5-.224.5-.5v-3.086zM9.75 7C8.784 7 8 7.784 8 8.75s.784 1.75 1.75 1.75 1.75-.784 1.75-1.75S10.716 7 9.75 7z"/></svg>
</label>
<span class="beep-media-count" data-beep-media-count hidden>0/4</span>
<button type="button" class="<?php echo esc_attr($cls); ?>-tool <?php echo esc_attr($cls); ?>-gif-btn" data-beep-gif-btn title="Add GIF">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M3 5.5C3 4.119 4.119 3 5.5 3h13C19.881 3 21 4.119 21 5.5v13c0 1.381-1.119 2.5-2.5 2.5h-13C4.119 21 3 19.881 3 18.5v-13zM5.5 5c-.276 0-.5.224-.5.5v13c0 .276.224.5.5.5h13c.276 0 .5-.224.5-.5v-13c0-.276-.224-.5-.5-.5h-13zM18 10.711V9.25h-3.74v5.5h1.44v-1.719h1.7V11.57h-1.7v-.859H18zM11.79 9.25h1.44v5.5h-1.44v-5.5zm-3.07 1.375c.34 0 .77.172 1.02.43l1.03-.86c-.51-.601-1.28-.945-2.05-.945C7.19 9.25 6 10.453 6 12s1.19 2.75 2.72 2.75c.85 0 1.54-.344 2.05-.945v-2.149H8.38v1.032H9.4v.515c-.17.086-.42.172-.68.172-.76 0-1.36-.602-1.36-1.375 0-.688.6-1.375 1.36-1.375z"/></svg>
</button>
<label class="<?php echo esc_attr($cls); ?>-tool <?php echo esc_attr($cls); ?>-voice-btn" title="Add voice note">
<input type="file" accept="audio/mpeg,audio/ogg,audio/wav,audio/webm,audio/mp4,audio/x-m4a" data-beep-voice-input hidden>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08C16.39 17.43 19 14.53 19 11h-2z"/></svg>
</label>
<button type="button" class="<?php echo esc_attr($cls); ?>-tool <?php echo esc_attr($cls); ?>-poll-btn" data-beep-poll-btn title="Add poll">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M6 5c-1.1 0-2 .895-2 2s.9 2 2 2 2-.895 2-2-.9-2-2-2zM2 7c0-2.209 1.79-4 4-4s4 1.791 4 4-1.79 4-4 4-4-1.791-4-4zm20 1H12V6h10v2zM6 15c-1.1 0-2 .895-2 2s.9 2 2 2 2-.895 2-2-.9-2-2-2zm-4 2c0-2.209 1.79-4 4-4s4 1.791 4 4-1.79 4-4 4-4-1.791-4-4zm20 1H12v-2h10v2z"/></svg>
</button>
<button type="button" class="<?php echo esc_attr($cls); ?>-tool <?php echo esc_attr($cls); ?>-quote-composer-btn" data-beep-quote-composer-btn title="Quote a beep">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M14.23 2.854c.98-.977 2.56-.977 3.54 0l3.38 3.378c.97.977.97 2.559 0 3.536L9.91 21H3v-6.914L14.23 2.854zm2.12 1.414c-.19-.195-.51-.195-.7 0L5 14.914V19h4.09L19.73 8.354c.2-.196.2-.512 0-.708l-3.38-3.378z"/></svg>
</button>
</div>
<?php
}
/**
* Render a reply poll (v1.9.0).
* Mirrors beep_render_poll() for posts.
*/
function beep_render_reply_poll($comment_id) {
$poll = Beep_Replies::get_reply_poll($comment_id);
if (!$poll) return '';
$total = (int) $poll['total_votes'];
$opts = $poll['options'];
$votes = $poll['votes'];
$has_ended = $poll['ends_at'] && strtotime($poll['ends_at']) < time();
?>
<div class="beep-poll-container" data-beep-reply-poll data-beep-reply-poll-id="<?php echo (int) $poll['id']; ?>">
<div class="beep-poll-question"><?php echo esc_html($poll['question']); ?></div>
<div class="beep-poll-options">
<?php foreach ($opts as $i => $opt) :
$pct = $total > 0 ? round(((int) ($votes[$i] ?? 0)) * 100 / $total) : 0;
$has_voted = false; // kept simple for v1.9.0; full voting comes in v1.9.1
?>
<div class="beep-poll-result<?php echo $has_voted ? ' is-voted' : ''; ?>">
<div class="beep-poll-result-bar" style="width:<?php echo (int) $pct; ?>%"></div>
<span class="beep-poll-result-text"><?php echo esc_html($opt); ?></span>
<span class="beep-poll-result-percent"><?php echo (int) $pct; ?>%</span>
</div>
<?php endforeach; ?>
</div>
<div class="beep-poll-meta">
<span class="beep-poll-votes"><?php echo $total; ?> <?php echo $total === 1 ? 'vote' : 'votes'; ?></span>
</div>
</div>
<?php
}
/**
* Render a single beep post.
*
* @param WP_Post $post
* @param array $args {
* @type bool $show_replies Render the nested replies tree (thread view). Default false.
* @type bool $in_thread Rendering inside the conversation modal. Default false.
* }
*/
function beep_render_post($post, $args = []) {
if (!$post) {
return '';
}
$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);
$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);
$beep_voice = Beep_Media::get_voice_url($post->ID);
ob_start();
?>
<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">
<span class="beep-name"><?php echo $name; ?></span>
<?php if ($handle) : ?>
<span class="beep-handle"><?php echo $handle; ?></span>
<?php endif; ?>
<span class="beep-dot">·</span>
<time class="beep-time"
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 echo beep_render_youtube_card($post->post_content); ?>
<?php if (!empty($beep_images)) : ?>
<div class="beep-media-attachments">
<?php echo Beep_Media::render_image_gallery($beep_images); ?>
</div>
<?php endif; ?>
<?php if ($beep_video) : ?>
<div class="beep-media-attachments">
<?php echo Beep_Media::render_video_embed($beep_video); ?>
</div>
<?php endif; ?>
<?php if ($beep_voice) : ?>
<div class="beep-media-attachments">
<?php echo Beep_Media::render_voice_note($post->ID); ?>
</div>
<?php endif; ?>
<?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 if ($beep_gif_url) : ?>
<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">
<?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="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; ?>"
type="button"
title="Like">
<span class="beep-icon-outline"><?php echo beep_icon('heart'); ?></span>
<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>
<button class="beep-action beep-action-share"
data-beep-share="<?php echo esc_url(get_permalink($post->ID)); ?>"
type="button"
title="Copy link">
<?php echo beep_icon('share'); ?>
</button>
</div>
<div class="beep-bottom-bar">
<?php echo beep_post_wordmark(); ?>
</div>
<?php if ($in_thread && $can_interact) : ?>
<?php echo beep_render_reply_form($post->ID, 0); ?>
<?php endif; ?>
<?php if ($show_replies) : ?>
<div class="beep-replies" data-beep-replies>
<?php
$all = Beep_Replies::get_replies($post->ID);
echo beep_render_replies_tree($all, 0, 0, $post->ID, $can_interact && $in_thread);
?>
</div>
<?php endif; ?>
</div>
</article>
<?php
return ob_get_clean();
}
/**
* Render the poll block for a beep (empty string when no poll).
*/
function beep_render_poll($post_id) {
$poll = Beep_Polls::get_poll_by_beep_id($post_id);
if (!$poll) {
return '';
}
ob_start();
?>
<div class="beep-poll-container" data-beep-poll data-beep-poll-id="<?php echo (int) $poll['id']; ?>">
<div class="beep-poll-question"><?php echo esc_html($poll['question']); ?></div>
<div class="beep-poll-options">
<?php
$user_id = get_current_user_id();
$user_vote = $user_id ? Beep_Polls::get_user_vote($poll['id'], $user_id) : null;
$has_ended = $poll['ends_at'] && strtotime($poll['ends_at']) < time();
$show_results = $user_vote !== null || $has_ended || !is_user_logged_in();
$results = Beep_Polls::get_results_with_percentages($poll);
foreach ($results as $result) :
$is_voted = $user_vote === $result['index'];
?>
<?php if ($show_results) : ?>
<div class="beep-poll-result<?php echo $is_voted ? ' is-voted' : ''; ?>">
<div class="beep-poll-result-bar" style="width:<?php echo (int) $result['percentage']; ?>%"></div>
<span class="beep-poll-result-text">
<?php echo esc_html($result['option']); ?>
<?php if ($is_voted) : ?>
<svg class="beep-poll-check" viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
<?php endif; ?>
</span>
<span class="beep-poll-result-percent"><?php echo (int) $result['percentage']; ?>%</span>
</div>
<?php else : ?>
<button class="beep-poll-vote-btn"
type="button"
data-beep-poll-vote="<?php echo (int) $result['index']; ?>"
data-beep-poll-beep="<?php echo (int) $post_id; ?>">
<?php echo esc_html($result['option']); ?>
</button>
<?php endif; ?>
<?php endforeach; ?>
</div>
<div class="beep-poll-meta">
<span class="beep-poll-votes"><?php echo (int) $poll['total_votes']; ?> <?php echo (int) $poll['total_votes'] === 1 ? 'vote' : 'votes'; ?></span>
<?php if ($poll['ends_at']) : ?>
<span class="beep-poll-dot">·</span>
<?php if ($has_ended) : ?>
<span class="beep-poll-end">Final results</span>
<?php else : ?>
<span class="beep-poll-end">Ends <?php echo esc_html(date('M j', strtotime($poll['ends_at']))); ?></span>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<?php
return ob_get_clean();
}
/**
* Render a reply form (used both on top-level posts and individual replies).
* @param int $post_id The beep post ID this reply belongs to.
* @param int $parent The parent comment ID (0 for top-level reply).
*/
function beep_render_reply_form($post_id, $parent) {
if ( ! is_user_logged_in() || beep_user_is_banned( get_current_user_id() ) ) {
return '';
}
$hidden = $parent ? 'hidden' : '';
ob_start();
?>
<div class="beep-reply-form beep-reply-form-compact" data-beep-reply-form data-beep-parent="<?php echo (int) $parent; ?>" <?php echo $hidden; ?>>
<?php echo beep_avatar_img(get_current_user_id(), 'beep-avatar-sm'); ?>
<div class="beep-reply-form-body">
<textarea
class="beep-input beep-reply-input"
placeholder="Post your reply"
maxlength="<?php echo (int) BEEP_CHAR_LIMIT; ?>"
rows="1"></textarea>
<?php echo beep_render_media_previews('reply'); ?>
<?php echo beep_render_quote_section('reply'); ?>
<?php echo beep_render_poll_section('reply'); ?>
<div class="beep-reply-footer">
<?php echo beep_render_compose_toolbar('reply'); ?>
<div class="beep-reply-submit-row">
<span class="beep-char-counter"><?php echo (int) BEEP_CHAR_LIMIT; ?></span>
<button class="beep-button beep-reply-submit"
data-beep-reply-submit="<?php echo (int) $post_id; ?>"
data-beep-reply-parent="<?php echo (int) $parent; ?>"
type="button"
disabled>Reply</button>
</div>
</div>
</div>
</div>
<?php
return ob_get_clean();
}
/**
* Recursively render replies. Visual indent caps at depth 2.
*/
function beep_render_replies_tree($comments, $parent_id, $depth, $post_id, $can_interact) {
$out = '';
foreach ($comments as $c) {
if ((int) $c->comment_parent !== (int) $parent_id) {
continue;
}
$out .= beep_render_reply($c, $comments, $depth, $post_id, $can_interact);
}
return $out;
}
/**
* Render a single reply, plus its nested children.
*/
function beep_render_reply($comment, $all_comments = [], $depth = 0, $post_id = 0, $can_interact = false) {
if (!$comment) {
return '';
}
$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);
$handle = $author ? '@' . esc_html($author->user_login) : '';
$ts = strtotime($comment->comment_date_gmt . ' UTC');
$time = beep_relative_time($ts);
$time_iso = gmdate('c', $ts);
$content = beep_format_content($comment->comment_content);
$can_delete = current_user_can('moderate_comments') || ($author_id && $author_id === get_current_user_id());
$like_count = Beep_Likes::get_count($comment->comment_ID, 'reply');
$user_liked = Beep_Likes::user_has_liked($comment->comment_ID, 'reply');
// Reply media (v1.9.0)
$reply_images = Beep_Replies::get_reply_images($comment->comment_ID);
$reply_voice = Beep_Replies::get_reply_voice_url($comment->comment_ID);
$reply_gif = Beep_Replies::get_reply_gif_url($comment->comment_ID);
$reply_quote_id = Beep_Replies::get_reply_quoted_post_id($comment->comment_ID);
$reply_quote = $reply_quote_id ? get_post($reply_quote_id) : null;
$depth_class = 'beep-depth-' . min((int) $depth, 2);
ob_start();
?>
<div class="beep-reply <?php echo esc_attr($depth_class); ?>" data-beep-reply="<?php echo (int) $comment->comment_ID; ?>">
<?php echo $avatar; ?>
<div class="beep-reply-body">
<div class="beep-meta">
<span class="beep-name"><?php echo $name; ?></span>
<?php if ($handle) : ?>
<span class="beep-handle"><?php echo $handle; ?></span>
<?php endif; ?>
<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 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>
<?php echo beep_render_youtube_card($comment->comment_content); ?>
<?php if (!empty($reply_images)) : ?>
<div class="beep-media-attachments">
<?php echo Beep_Media::render_image_gallery($reply_images); ?>
</div>
<?php endif; ?>
<?php if ($reply_voice) : ?>
<div class="beep-media-attachments">
<div class="beep-voice-note">
<audio controls preload="none">
<source src="<?php echo esc_url($reply_voice); ?>">
Your browser does not support audio playback.
</audio>
<div class="beep-voice-label">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08C16.39 17.43 19 14.53 19 11h-2z"/></svg>
Voice note
</div>
</div>
</div>
<?php endif; ?>
<?php if ($reply_quote && $reply_quote->post_type === 'beep') : ?>
<div class="beep-quoted-container">
<?php
$quoted_data = Beep_Quotes::get_quoted_post($reply_quote->ID);
if ($quoted_data) echo Beep_Quotes::render_quoted_post($reply_quote->ID);
?>
</div>
<?php endif; ?>
<?php if ($reply_gif) : ?>
<div class="beep-gif-embed">
<img src="<?php echo esc_url($reply_gif); ?>" alt="GIF" loading="lazy">
</div>
<?php endif; ?>
<?php echo beep_render_reply_poll($comment->comment_ID); ?>
<?php if ($can_interact) : ?>
<div class="beep-actions beep-actions-reply">
<button class="beep-action beep-action-reply"
data-beep-toggle-reply
type="button"
title="Reply">
<?php echo beep_icon('reply'); ?>
</button>
<button class="beep-action beep-action-like<?php echo $user_liked ? ' is-liked' : ''; ?>"
data-beep-like-reply="<?php echo (int) $comment->comment_ID; ?>"
type="button"
title="Like">
<span class="beep-icon-outline"><?php echo beep_icon('heart'); ?></span>
<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>
</div>
<?php echo beep_render_reply_form($post_id, $comment->comment_ID); ?>
<?php endif; ?>
<?php
// Recursively render children
$children = beep_render_replies_tree($all_comments, $comment->comment_ID, $depth + 1, $post_id, $can_interact);
if ($children) {
echo '<div class="beep-replies beep-replies-nested" data-beep-replies>' . $children . '</div>';
}
?>
</div>
</div>
<?php
return ob_get_clean();
}
/**
* Format beep text: linkify URLs, @mentions, #hashtags.
*/
function beep_format_content($text) {
$text = wp_kses($text, []);
// Hashtags
$text = preg_replace_callback('/(^|\s)#([\p{L}0-9_]+)/u', function ($m) {
return $m[1] . '<a href="#" class="beep-tag">#' . esc_html($m[2]) . '</a>';
}, $text);
// @mentions
$text = preg_replace_callback('/(^|\s)@([a-zA-Z0-9_]+)/', function ($m) {
$user = get_user_by('login', $m[2]);
if ($user) {
return $m[1] . '<a href="' . esc_url(get_author_posts_url($user->ID)) . '" class="beep-mention">@' . esc_html($m[2]) . '</a>';
}
return $m[1] . '<span class="beep-mention beep-mention-stale">@' . esc_html($m[2]) . '</span>';
}, $text);
// URLs
$text = preg_replace_callback('#(https?://[^\s<]+)#i', function ($m) {
$url = esc_url($m[1]);
$display = parse_url($url, PHP_URL_HOST);
if (!$display) {
$display = $url;
}
return '<a href="' . $url . '" class="beep-link" target="_blank" rel="noopener nofollow">' . esc_html($display) . '</a>';
}, $text);
// Line breaks
$text = nl2br($text);
return $text;
}
/**
* Extract the first YouTube video ID from a text string, if any.
* Supports youtube.com/watch?v=, youtu.be/, shorts/, embed/, live/.
* Returns the 11-char video ID or ''.
*/
function beep_youtube_id_from_text($text) {
if (!preg_match_all('#(?<![\w.-])(?:https?://)?(?:www\.|m\.)?(?:youtube\.com/(?:watch\?[^#\s]*v=|shorts/|embed/|live/)|youtu\.be/)([A-Za-z0-9_-]{11})(?![A-Za-z0-9_-])#i', (string) $text, $m)) {
return '';
}
return $m[1][0];
}
/**
* Render a click-to-play YouTube card for the first YouTube link in $text.
* Thumbnail (hqdefault) + play button; JS swaps in the iframe on click.
* Returns '' when no YouTube link is present.
*/
function beep_render_youtube_card($text) {
$vid = beep_youtube_id_from_text($text);
if ($vid === '') {
return '';
}
return '<div class="beep-yt-card" data-beep-yt="' . esc_attr($vid) . '" role="button" tabindex="0" aria-label="Play embedded YouTube video">'
. '<img class="beep-yt-thumb" src="https://i.ytimg.com/vi/' . esc_attr($vid) . '/hqdefault.jpg" alt="" loading="lazy">'
. '<span class="beep-yt-play"><svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg></span>'
. '</div>';
}
function beep_relative_time($timestamp) {
$now = time();
$diff = $now - $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);
}
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.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>',
];
return isset($icons[$name]) ? $icons[$name] : '';
}
/**
* Render the thread/conversation modal (sits outside individual posts).
*/
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" 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" data-beep-thread-loading hidden><span class="beep-spinner"></span></div>' .
'<div class="beep-thread-content" data-beep-thread-content></div>' .
'</div>' .
'</div>';
}
/**
* Render the GIF picker 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" 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>' .
'</div>' .
'<div class="beep-gif-search-bar">' .
'<input type="text" class="beep-gif-search" data-beep-gif-search placeholder="Search GIPHY...">' .
'<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>';
}