v1.6.8: public composer, beep wordmark on posts
@@ -0,0 +1,114 @@
|
||||
# Beep
|
||||
|
||||
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.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**.
|
||||
2. Upload `beep.zip` and click **Install Now**, then **Activate**.
|
||||
3. On the page you want the feed (your one-page site, a dedicated "Feed" page, etc.), add the shortcode:
|
||||
|
||||
```
|
||||
[beep_feed]
|
||||
```
|
||||
|
||||
Save and visit the page. As an admin, you'll see a composer at the top — type a message and click "Beep" to post.
|
||||
|
||||
## Invite friends (with magic links — no passwords)
|
||||
|
||||
This is the easiest way to let a small circle into your Beep feed without setting up Google/Facebook developer apps. The flow:
|
||||
|
||||
1. **Open registration.** Go to **Settings → General**, check **Anyone can register**, and set **New User Default Role** to **Subscriber**. Save.
|
||||
2. **Install passwordless login.** Plugins → Add New → search for **Passwordless Login** (any well-reviewed one — Cozmoslabs or Magic Login both work). Install and activate.
|
||||
3. **Share the sign-up link.** Send your friends a direct link to:
|
||||
|
||||
```
|
||||
https://your-site.com/wp-login.php?action=register
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Shortcode options
|
||||
|
||||
```
|
||||
[beep_feed limit="50" header="Messages from" sort="latest"]
|
||||
```
|
||||
|
||||
- `limit` — how many beeps to load on first render (default 50)
|
||||
- `header` — text shown next to the wordmark (default "Messages from")
|
||||
- `sort` — initial sort: `latest` (default) or `top`
|
||||
|
||||
## Elementor Widget
|
||||
|
||||
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.
|
||||
|
||||
## Changelog
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
|
||||
GPLv2 or later, same as WordPress.
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
/**
|
||||
* 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.6.8
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: Sami Ahmed
|
||||
* License: GPL-2.0-or-later
|
||||
* Text Domain: beep
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define('BEEP_VERSION', '1.6.8');
|
||||
define('BEEP_FILE', __FILE__);
|
||||
define('BEEP_DIR', plugin_dir_path(__FILE__));
|
||||
define('BEEP_URL', plugin_dir_url(__FILE__));
|
||||
define('BEEP_LIKES_TABLE', 'beep_likes');
|
||||
define('BEEP_CHAR_LIMIT', 280);
|
||||
|
||||
require_once BEEP_DIR . 'includes/helpers.php';
|
||||
require_once BEEP_DIR . 'includes/class-cpt.php';
|
||||
require_once BEEP_DIR . 'includes/class-likes.php';
|
||||
require_once BEEP_DIR . 'includes/class-replies.php';
|
||||
require_once BEEP_DIR . 'includes/class-moderation.php';
|
||||
require_once BEEP_DIR . 'includes/class-media.php';
|
||||
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-beep.php';
|
||||
|
||||
register_activation_hook(__FILE__, ['Beep_Plugin', 'activate']);
|
||||
register_deactivation_hook(__FILE__, ['Beep_Plugin', 'deactivate']);
|
||||
|
||||
add_action('plugins_loaded', ['Beep_Plugin', 'init']);
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Beep_Plugin {
|
||||
|
||||
public static function init() {
|
||||
Beep_CPT::init();
|
||||
Beep_Likes::init();
|
||||
Beep_Replies::init();
|
||||
Beep_Moderation::init();
|
||||
Beep_Polls::init();
|
||||
Beep_Shortcode::init();
|
||||
|
||||
add_action('wp_enqueue_scripts', [__CLASS__, 'enqueue_assets']);
|
||||
|
||||
// Set default Giphy API key if not already set
|
||||
if (!get_option('beep_giphy_key')) {
|
||||
update_option('beep_giphy_key', 'xuxYxuIvzPqw5tPB4laa7sQO8dFtSlNS');
|
||||
}
|
||||
|
||||
// Elementor widget — only load if Elementor is active
|
||||
add_action('elementor/widgets/register', function ($widgets_manager) {
|
||||
if (!class_exists('\Elementor\Widget_Base')) return;
|
||||
$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());
|
||||
}
|
||||
}, 5);
|
||||
|
||||
// Admin settings (Giphy API key only — full settings via Beep_Settings)
|
||||
add_action('admin_menu', [__CLASS__, 'add_admin_menu']);
|
||||
add_action('admin_init', [__CLASS__, 'register_settings']);
|
||||
}
|
||||
|
||||
public static function add_admin_menu() {
|
||||
add_options_page(
|
||||
'Beep Settings',
|
||||
'Beep',
|
||||
'manage_options',
|
||||
'beep-settings',
|
||||
[__CLASS__, 'render_settings_page']
|
||||
);
|
||||
}
|
||||
|
||||
public static function register_settings() {
|
||||
register_setting('beep_settings_group', 'beep_giphy_key', [
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
]);
|
||||
add_settings_section('beep_main_section', 'API Configuration', null, 'beep-settings');
|
||||
add_settings_field(
|
||||
'beep_giphy_key',
|
||||
'Giphy API Key',
|
||||
[__CLASS__, 'giphy_key_field'],
|
||||
'beep-settings',
|
||||
'beep_main_section',
|
||||
['label_for' => 'beep_giphy_key']
|
||||
);
|
||||
}
|
||||
|
||||
public static function giphy_key_field() {
|
||||
$key = get_option('beep_giphy_key', '');
|
||||
echo '<input type="text" id="beep_giphy_key" name="beep_giphy_key" value="' . esc_attr($key) . '" class="regular-text" placeholder="Enter your Giphy API key">';
|
||||
echo '<p class="description">Get a free API key at <a href="https://developers.giphy.com/" target="_blank">developers.giphy.com</a></p>';
|
||||
}
|
||||
|
||||
public static function render_settings_page() {
|
||||
if (!current_user_can('manage_options')) return;
|
||||
echo '<div class="wrap"><h1>Beep Settings</h1>';
|
||||
echo '<form method="post" action="options.php">';
|
||||
settings_fields('beep_settings_group');
|
||||
do_settings_sections('beep-settings');
|
||||
submit_button();
|
||||
echo '</form></div>';
|
||||
}
|
||||
|
||||
public static function activate() {
|
||||
Beep_CPT::register();
|
||||
Beep_Likes::create_table();
|
||||
Beep_Polls::create_tables();
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
|
||||
public static function deactivate() {
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
|
||||
public static function enqueue_assets() {
|
||||
wp_enqueue_style(
|
||||
'beep',
|
||||
BEEP_URL . 'assets/beep.css',
|
||||
[],
|
||||
BEEP_VERSION
|
||||
);
|
||||
wp_enqueue_script(
|
||||
'beep',
|
||||
BEEP_URL . 'assets/beep.js',
|
||||
[],
|
||||
BEEP_VERSION,
|
||||
true
|
||||
);
|
||||
$giphy_key = get_option('beep_giphy_key', '');
|
||||
|
||||
wp_localize_script('beep', 'BeepConfig', [
|
||||
'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(),
|
||||
'giphyKey' => $giphy_key,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
/**
|
||||
* Beep Elementor Widget
|
||||
* Drag-and-drop Elementor widget for the Beep feed.
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (did_action('elementor/core')) {
|
||||
return;
|
||||
}
|
||||
|
||||
class Beep_Elementor_Widget extends \Elementor\Widget_Base {
|
||||
|
||||
public function get_name() {
|
||||
return 'beep-feed';
|
||||
}
|
||||
|
||||
public function get_title() {
|
||||
return 'Beep Feed';
|
||||
}
|
||||
|
||||
public function get_icon() {
|
||||
return 'eicon-social-icons';
|
||||
}
|
||||
|
||||
public function get_categories() {
|
||||
return ['general'];
|
||||
}
|
||||
|
||||
protected function register_controls() {
|
||||
$this->start_controls_section(
|
||||
'section_feed',
|
||||
['label' => 'Feed Settings']
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'header',
|
||||
[
|
||||
'label' => 'Header Title',
|
||||
'type' => \Elementor\Controls_Manager::TEXT,
|
||||
'default' => 'Beep',
|
||||
'description' => 'Text shown in the feed header. Leave empty to show the wordmark logo.',
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'show_wordmark',
|
||||
[
|
||||
'label' => 'Show Wordmark Logo',
|
||||
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||
'default' => 'yes',
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'sort',
|
||||
[
|
||||
'label' => 'Default Sort',
|
||||
'type' => \Elementor\Controls_Manager::SELECT,
|
||||
'default' => 'latest',
|
||||
'options' => [
|
||||
'latest' => 'Latest First',
|
||||
'top' => 'Top / Most Liked',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'limit',
|
||||
[
|
||||
'label' => 'Posts Per Page',
|
||||
'type' => \Elementor\Controls_Manager::NUMBER,
|
||||
'min' => 1,
|
||||
'max' => 100,
|
||||
'default' => 20,
|
||||
]
|
||||
);
|
||||
|
||||
$this->end_controls_section();
|
||||
|
||||
$this->start_controls_section(
|
||||
'section_style',
|
||||
['label' => 'Appearance']
|
||||
);
|
||||
|
||||
$this->add_group_control(
|
||||
\Elementor\Group_Control_Typography::get_type(),
|
||||
[
|
||||
'name' => 'feed_typography',
|
||||
'label' => 'Feed Typography',
|
||||
'selector' => '{{WRAPPER}} .beep-feed',
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'card_radius',
|
||||
[
|
||||
'label' => 'Card Border Radius',
|
||||
'type' => \Elementor\Controls_Manager::SLIDER,
|
||||
'size_units' => ['px'],
|
||||
'range' => ['px' => ['min' => 0, 'max' => 32]],
|
||||
'default' => ['unit' => 'px', 'size' => 0],
|
||||
'selectors' => [
|
||||
'{{WRAPPER}} .beep-post' => 'border-radius: {{SIZE}}{{UNIT}};',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->end_controls_section();
|
||||
}
|
||||
|
||||
protected function render() {
|
||||
$settings = $this->get_settings_for_display();
|
||||
|
||||
$atts = [
|
||||
'header' => $settings['header'] ?: 'Beep',
|
||||
'show_wordmark' => $settings['show_wordmark'] ? '1' : '0',
|
||||
'sort' => $settings['sort'],
|
||||
'limit' => (int) $settings['limit'],
|
||||
];
|
||||
|
||||
echo Beep_Shortcode::render_beep_feed($atts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Beep_Likes {
|
||||
|
||||
public static function init() {
|
||||
add_action('rest_api_init', [__CLASS__, 'register_routes']);
|
||||
}
|
||||
|
||||
public static function table_name() {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . BEEP_LIKES_TABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or migrate the likes table.
|
||||
* The 'post_id' column historically only stored beep IDs; we now also
|
||||
* use it for comment IDs (replies). 'object_type' distinguishes them.
|
||||
*/
|
||||
public static function create_table() {
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$charset = $wpdb->get_charset_collate();
|
||||
|
||||
$sql = "CREATE TABLE $table (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
object_type VARCHAR(20) NOT NULL DEFAULT 'post',
|
||||
post_id BIGINT(20) UNSIGNED NOT NULL,
|
||||
user_id BIGINT(20) UNSIGNED NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY object_user (object_type, post_id, user_id),
|
||||
KEY post_id (post_id),
|
||||
KEY user_id (user_id)
|
||||
) $charset;";
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
dbDelta($sql);
|
||||
|
||||
// Migration for installs from v1.0-1.3: add object_type column if missing.
|
||||
$col = $wpdb->get_var($wpdb->prepare(
|
||||
"SHOW COLUMNS FROM `$table` LIKE %s", 'object_type'
|
||||
));
|
||||
if (!$col) {
|
||||
$wpdb->query("ALTER TABLE `$table` ADD COLUMN object_type VARCHAR(20) NOT NULL DEFAULT 'post' AFTER id");
|
||||
}
|
||||
}
|
||||
|
||||
public static function register_routes() {
|
||||
// Like a beep (post).
|
||||
register_rest_route('beep/v1', '/like/(?P<id>\d+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'toggle_post_like'],
|
||||
'permission_callback' => [__CLASS__, 'can_interact'],
|
||||
]);
|
||||
|
||||
// Like a reply (comment).
|
||||
register_rest_route('beep/v1', '/reply-like/(?P<id>\d+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'toggle_reply_like'],
|
||||
'permission_callback' => [__CLASS__, 'can_interact'],
|
||||
]);
|
||||
}
|
||||
|
||||
public static function can_interact() {
|
||||
return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
|
||||
}
|
||||
|
||||
public static function toggle_post_like($request) {
|
||||
$id = (int) $request['id'];
|
||||
if (get_post_type($id) !== Beep_CPT::POST_TYPE) {
|
||||
return new WP_Error('beep_not_a_beep', 'Not a beep.', ['status' => 400]);
|
||||
}
|
||||
return rest_ensure_response(self::toggle($id, 'post'));
|
||||
}
|
||||
|
||||
public static function toggle_reply_like($request) {
|
||||
$id = (int) $request['id'];
|
||||
$c = get_comment($id);
|
||||
if (!$c || get_post_type($c->comment_post_ID) !== Beep_CPT::POST_TYPE) {
|
||||
return new WP_Error('beep_not_a_reply', 'Not a reply.', ['status' => 400]);
|
||||
}
|
||||
return rest_ensure_response(self::toggle($id, 'reply'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a like for current user on the given object.
|
||||
* @return array {liked: bool, count: int}
|
||||
*/
|
||||
public static function toggle($object_id, $object_type) {
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$user_id = get_current_user_id();
|
||||
|
||||
$existing = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT id FROM $table WHERE object_type = %s AND post_id = %d AND user_id = %d",
|
||||
$object_type, $object_id, $user_id
|
||||
));
|
||||
|
||||
if ($existing) {
|
||||
$wpdb->delete($table, ['id' => $existing], ['%d']);
|
||||
$liked = false;
|
||||
} else {
|
||||
$wpdb->insert($table, [
|
||||
'object_type' => $object_type,
|
||||
'post_id' => $object_id,
|
||||
'user_id' => $user_id,
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%s', '%d', '%d', '%s']);
|
||||
$liked = true;
|
||||
}
|
||||
|
||||
return [
|
||||
'liked' => $liked,
|
||||
'count' => self::get_count($object_id, $object_type),
|
||||
];
|
||||
}
|
||||
|
||||
public static function get_count($object_id, $object_type = 'post') {
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
return (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM $table WHERE object_type = %s AND post_id = %d",
|
||||
$object_type, $object_id
|
||||
));
|
||||
}
|
||||
|
||||
public static function user_has_liked($object_id, $object_type = 'post', $user_id = null) {
|
||||
global $wpdb;
|
||||
if (!$user_id) {
|
||||
$user_id = get_current_user_id();
|
||||
}
|
||||
if (!$user_id) {
|
||||
return false;
|
||||
}
|
||||
$table = self::table_name();
|
||||
return (bool) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT id FROM $table WHERE object_type = %s AND post_id = %d AND user_id = %d",
|
||||
$object_type, $object_id, $user_id
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beep_Media — handles image uploads, video URLs, and media rendering.
|
||||
* Uses WordPress built-in media library (no extra server requirements).
|
||||
*/
|
||||
class Beep_Media {
|
||||
|
||||
const META_KEY_IMAGES = 'beep_images';
|
||||
const META_KEY_VIDEO = 'beep_video_url';
|
||||
|
||||
private static $image_types = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
private static $max_images = 4;
|
||||
private static $max_file_size = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
public static function init() {}
|
||||
|
||||
/**
|
||||
* Handle a media upload from the REST API.
|
||||
*
|
||||
* @param int $post_id The beep post ID to attach media to.
|
||||
* @param array $file $_FILES entry.
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
public static function handle_upload($post_id, $file) {
|
||||
if (!is_array($file) || $file['error'] !== UPLOAD_ERR_OK) {
|
||||
return new WP_Error('upload_error', 'Upload failed.');
|
||||
}
|
||||
if ($file['size'] > self::$max_file_size) {
|
||||
return new WP_Error('file_too_large', 'File exceeds 10 MB limit.');
|
||||
}
|
||||
$mime = mime_content_type($file['tmp_name']);
|
||||
if (!in_array($mime, self::$image_types, true)) {
|
||||
return new WP_Error('invalid_type', 'Only JPEG, PNG, GIF, and WebP are allowed.');
|
||||
}
|
||||
|
||||
$existing = self::get_images($post_id);
|
||||
if (count($existing) >= self::$max_images) {
|
||||
return new WP_Error('too_many', 'Maximum ' . self::$max_images . ' images per beep.');
|
||||
}
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/image.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/media.php';
|
||||
|
||||
$file_return = wp_handle_sideload($file, [
|
||||
'test_form' => false,
|
||||
'mimes' => [
|
||||
'jpg|jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'gif' => 'image/gif',
|
||||
'webp' => 'image/webp',
|
||||
],
|
||||
]);
|
||||
|
||||
if (isset($file_return['error'])) {
|
||||
return new WP_Error('sideload_error', $file_return['error']);
|
||||
}
|
||||
|
||||
$attachment = [
|
||||
'post_mime_type' => $file_return['type'],
|
||||
'post_title' => preg_replace('/\.[^.]+$/', '', basename($file_return['file'])),
|
||||
'post_content' => '',
|
||||
'post_status' => 'inherit',
|
||||
'guid' => $file_return['url'],
|
||||
];
|
||||
|
||||
$attachment_id = wp_insert_attachment($attachment, $file_return['file'], $post_id);
|
||||
if (is_wp_error($attachment_id)) {
|
||||
return $attachment_id;
|
||||
}
|
||||
|
||||
$attach_data = wp_generate_attachment_metadata($attachment_id, $file_return['file']);
|
||||
wp_update_attachment_metadata($attachment_id, $attach_data);
|
||||
|
||||
add_post_meta($post_id, self::META_KEY_IMAGES, $attachment_id);
|
||||
|
||||
return [
|
||||
'id' => $attachment_id,
|
||||
'url' => wp_get_attachment_url($attachment_id),
|
||||
'thumb' => self::get_image_url($attachment_id, 'thumbnail'),
|
||||
'sizes' => self::get_image_sizes($attachment_id),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a single image from a beep post.
|
||||
*/
|
||||
public static function remove_image($post_id, $attachment_id) {
|
||||
$ids = self::get_images($post_id);
|
||||
$ids = array_values(array_filter($ids, fn($id) => (int) $id !== (int) $attachment_id));
|
||||
delete_post_meta($post_id, self::META_KEY_IMAGES);
|
||||
foreach ($ids as $id) {
|
||||
add_post_meta($post_id, self::META_KEY_IMAGES, $id);
|
||||
}
|
||||
wp_delete_attachment($attachment_id, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image attachment IDs for a beep post.
|
||||
*/
|
||||
public static function get_images($post_id) {
|
||||
$ids = get_post_meta($post_id, self::META_KEY_IMAGES, false);
|
||||
return array_filter(array_map('intval', (array) $ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image srcset-ready sizes for an attachment.
|
||||
*/
|
||||
public static function get_image_sizes($attachment_id) {
|
||||
$sizes = [];
|
||||
foreach (['thumbnail', 'medium', 'large', 'full'] as $size) {
|
||||
$img = wp_get_attachment_image_src($attachment_id, $size);
|
||||
if ($img) {
|
||||
$sizes[$size] = ['url' => $img[0], 'width' => $img[1], 'height' => $img[2]];
|
||||
}
|
||||
}
|
||||
return $sizes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image URL at a given size.
|
||||
*/
|
||||
public static function get_image_url($attachment_id, $size = 'medium') {
|
||||
$img = wp_get_attachment_image_src($attachment_id, $size);
|
||||
return $img ? $img[0] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a video URL for a beep post.
|
||||
*/
|
||||
public static function set_video_url($post_id, $url) {
|
||||
$url = esc_url_raw($url);
|
||||
if (!$url) return new WP_Error('invalid_url', 'Invalid video URL.');
|
||||
update_post_meta($post_id, self::META_KEY_VIDEO, $url);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the video URL for a beep post.
|
||||
*/
|
||||
public static function get_video_url($post_id) {
|
||||
return esc_url_raw(get_post_meta($post_id, self::META_KEY_VIDEO, true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a URL is a supported video oEmbed.
|
||||
*/
|
||||
public static function detect_video_url($url) {
|
||||
$patterns = [
|
||||
'youtube' => '#https?://(?:www\.)?youtube\.com/watch\?v=[^\s&#]+#',
|
||||
'yt_s' => '#https?://youtu\.be/[^\s&#]+#',
|
||||
'vimeo' => '#https?://vimeo\.com/\d+#',
|
||||
'twitter' => '#https?://(?:twitter\.com|x\.com)/[^\s#/]+/status/\d+#',
|
||||
];
|
||||
foreach ($patterns as $name => $pattern) {
|
||||
if (preg_match($pattern, $url)) return $name;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Twitter-style image gallery.
|
||||
*
|
||||
* @param int[] $image_ids
|
||||
* @return string HTML
|
||||
*/
|
||||
public static function render_image_gallery($image_ids) {
|
||||
if (empty($image_ids)) return '';
|
||||
|
||||
$count = count($image_ids);
|
||||
$grid_class = 'beep-gallery';
|
||||
if ($count === 1) $grid_class .= ' beep-gallery-1';
|
||||
elseif ($count === 2) $grid_class .= ' beep-gallery-2';
|
||||
elseif ($count === 3) $grid_class .= ' beep-gallery-3';
|
||||
else $grid_class .= ' beep-gallery-4';
|
||||
|
||||
$html = '<div class="' . esc_attr($grid_class) . '">';
|
||||
foreach ($image_ids as $i => $id) {
|
||||
$full = self::get_image_url($id, 'large');
|
||||
$thumb = self::get_image_url($id, 'medium');
|
||||
if (!$thumb) $thumb = $full;
|
||||
if (!$full) continue;
|
||||
$alt = get_post_meta($id, '_wp_attachment_image_alt', true);
|
||||
$html .= '<div class="beep-gallery-item">';
|
||||
$html .= '<a href="' . esc_url($full) . '" target="_blank" rel="noopener" data-beep-lightbox>';
|
||||
$html .= '<img src="' . esc_url($thumb) . '" alt="' . esc_attr($alt ?: "Beep image") . '" loading="lazy">';
|
||||
$html .= '</a>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render video embed via oEmbed.
|
||||
*/
|
||||
public static function render_video_embed($url) {
|
||||
if (!$url) return '';
|
||||
$embed = wp_oembed_get($url, ['width' => 560, 'height' => 315]);
|
||||
if ($embed) {
|
||||
return '<div class="beep-video-embed">' . $embed . '</div>';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all media for a beep post (when beep is deleted).
|
||||
*/
|
||||
public static function delete_media($post_id) {
|
||||
foreach (self::get_images($post_id) as $id) {
|
||||
wp_delete_attachment($id, true);
|
||||
}
|
||||
delete_post_meta($post_id, self::META_KEY_IMAGES);
|
||||
delete_post_meta($post_id, self::META_KEY_VIDEO);
|
||||
}
|
||||
// Voice note support
|
||||
private static $audio_types = ['audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/webm', 'audio/mp4', 'audio/x-m4a'];
|
||||
private static $max_audio_size = 20 * 1024 * 1024; // 20 MB
|
||||
|
||||
public static function handle_voice_upload($post_id, $file) {
|
||||
if (!is_array($file) || $file['error'] !== UPLOAD_ERR_OK) {
|
||||
return new WP_Error('upload_error', 'Upload failed.');
|
||||
}
|
||||
if ($file['size'] > self::$max_audio_size) {
|
||||
return new WP_Error('file_too_large', 'Voice note exceeds 20 MB limit.');
|
||||
}
|
||||
$mime = mime_content_type($file['tmp_name']);
|
||||
if (!in_array($mime, self::$audio_types, true)) {
|
||||
return new WP_Error('invalid_type', 'Only MP3, OGG, WAV, WebM, and M4A audio are allowed.');
|
||||
}
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/media.php';
|
||||
|
||||
$file_return = wp_handle_upload($file, [
|
||||
'test_form' => false,
|
||||
'mimes' => [
|
||||
'mp3' => 'audio/mpeg',
|
||||
'ogg' => 'audio/ogg',
|
||||
'wav' => 'audio/wav',
|
||||
'webm' => 'audio/webm',
|
||||
'm4a' => 'audio/mp4',
|
||||
],
|
||||
]);
|
||||
|
||||
if (isset($file_return['error'])) {
|
||||
return new WP_Error('upload_error', $file_return['error']);
|
||||
}
|
||||
|
||||
$attachment = [
|
||||
'post_mime_type' => $file_return['type'],
|
||||
'post_title' => preg_replace('/\.[^.]+$/', '', basename($file_return['file'])),
|
||||
'post_content' => '',
|
||||
'post_status' => 'inherit',
|
||||
'guid' => $file_return['url'],
|
||||
];
|
||||
|
||||
$attachment_id = wp_insert_attachment($attachment, $file_return['file'], $post_id);
|
||||
if (is_wp_error($attachment_id)) {
|
||||
return $attachment_id;
|
||||
}
|
||||
|
||||
$attach_data = wp_generate_attachment_metadata($attachment_id, $file_return['file']);
|
||||
wp_update_attachment_metadata($attachment_id, $attach_data);
|
||||
|
||||
update_post_meta($post_id, 'beep_voice_url', wp_get_attachment_url($attachment_id));
|
||||
update_post_meta($post_id, 'beep_voice_id', $attachment_id);
|
||||
|
||||
return [
|
||||
'id' => $attachment_id,
|
||||
'url' => wp_get_attachment_url($attachment_id),
|
||||
];
|
||||
}
|
||||
|
||||
public static function get_voice_url($post_id) {
|
||||
return esc_url_raw(get_post_meta($post_id, 'beep_voice_url', true));
|
||||
}
|
||||
|
||||
public static function remove_voice($post_id) {
|
||||
$id = (int) get_post_meta($post_id, 'beep_voice_id', true);
|
||||
if ($id) {
|
||||
wp_delete_attachment($id, true);
|
||||
}
|
||||
delete_post_meta($post_id, 'beep_voice_url');
|
||||
delete_post_meta($post_id, 'beep_voice_id');
|
||||
}
|
||||
|
||||
public static function render_voice_note($post_id) {
|
||||
$url = self::get_voice_url($post_id);
|
||||
if (!$url) return '';
|
||||
$duration = '';
|
||||
$meta = get_post_meta($post_id, 'beep_voice_id', true);
|
||||
if ($meta) {
|
||||
$metadata = wp_get_attachment_metadata($meta);
|
||||
if ($metadata && isset($metadata['length_formatted'])) {
|
||||
$duration = $metadata['length_formatted'];
|
||||
}
|
||||
}
|
||||
$dur_html = $duration ? '<span class="beep-voice-duration">' . esc_html($duration) . '</span>' : '';
|
||||
return '<div class="beep-voice-note">' .
|
||||
'<audio controls preload="none">' .
|
||||
'<source src="' . esc_url($url) . '">' .
|
||||
'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>' .
|
||||
$dur_html .
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Video file upload support
|
||||
private static $video_types = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'];
|
||||
private static $max_video_size = 100 * 1024 * 1024; // 100 MB
|
||||
|
||||
public static function handle_video_upload($post_id, $file) {
|
||||
if (!is_array($file) || $file['error'] !== UPLOAD_ERR_OK) {
|
||||
return new WP_Error('upload_error', 'Upload failed.');
|
||||
}
|
||||
if ($file['size'] > self::$max_video_size) {
|
||||
return new WP_Error('file_too_large', 'Video exceeds 100 MB limit.');
|
||||
}
|
||||
$mime = mime_content_type($file['tmp_name']);
|
||||
if (!in_array($mime, self::$video_types, true)) {
|
||||
return new WP_Error('invalid_type', 'Only MP4, WebM, OGG, and MOV are allowed.');
|
||||
}
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/media.php';
|
||||
|
||||
$file_return = wp_handle_upload($file, [
|
||||
'test_form' => false,
|
||||
'mimes' => [
|
||||
'mp4' => 'video/mp4',
|
||||
'webm' => 'video/webm',
|
||||
'ogg' => 'video/ogg',
|
||||
'mov' => 'video/quicktime',
|
||||
],
|
||||
]);
|
||||
|
||||
if (isset($file_return['error'])) {
|
||||
return new WP_Error('upload_error', $file_return['error']);
|
||||
}
|
||||
|
||||
$attachment = [
|
||||
'post_mime_type' => $file_return['type'],
|
||||
'post_title' => preg_replace('/\.[^.]+$/', '', basename($file_return['file'])),
|
||||
'post_content' => '',
|
||||
'post_status' => 'inherit',
|
||||
'guid' => $file_return['url'],
|
||||
];
|
||||
|
||||
$attachment_id = wp_insert_attachment($attachment, $file_return['file'], $post_id);
|
||||
if (is_wp_error($attachment_id)) {
|
||||
return $attachment_id;
|
||||
}
|
||||
|
||||
$attach_data = wp_generate_attachment_metadata($attachment_id, $file_return['file']);
|
||||
wp_update_attachment_metadata($attachment_id, $attach_data);
|
||||
|
||||
update_post_meta($post_id, 'beep_video_url', wp_get_attachment_url($attachment_id));
|
||||
update_post_meta($post_id, 'beep_video_id', $attachment_id);
|
||||
|
||||
return [
|
||||
'id' => $attachment_id,
|
||||
'url' => wp_get_attachment_url($attachment_id),
|
||||
];
|
||||
}
|
||||
|
||||
public static function get_upload_video_url($post_id) {
|
||||
return esc_url_raw(get_post_meta($post_id, 'beep_video_url', true));
|
||||
}
|
||||
|
||||
public static function remove_video($post_id) {
|
||||
$id = (int) get_post_meta($post_id, 'beep_video_id', true);
|
||||
if ($id) {
|
||||
wp_delete_attachment($id, true);
|
||||
}
|
||||
delete_post_meta($post_id, 'beep_video_url');
|
||||
delete_post_meta($post_id, 'beep_video_id');
|
||||
}
|
||||
|
||||
public static function render_video_player($post_id) {
|
||||
$url = self::get_upload_video_url($post_id);
|
||||
if (!$url) return '';
|
||||
return '<div class="beep-video-player">' .
|
||||
'<video controls preload="metadata" playsinline>' .
|
||||
'<source src="' . esc_url($url) . '">' .
|
||||
'Your browser does not support video playback.' .
|
||||
'</video></div>';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Beep_Moderation {
|
||||
|
||||
const BAN_META_KEY = 'beep_banned';
|
||||
|
||||
public static function init() {
|
||||
add_action('show_user_profile', [__CLASS__, 'profile_field']);
|
||||
add_action('edit_user_profile', [__CLASS__, 'profile_field']);
|
||||
add_action('personal_options_update', [__CLASS__, 'save_profile_field']);
|
||||
add_action('edit_user_profile_update', [__CLASS__, 'save_profile_field']);
|
||||
}
|
||||
|
||||
public static function profile_field($user) {
|
||||
if (!current_user_can('edit_users') && get_current_user_id() !== $user->ID) {
|
||||
return;
|
||||
}
|
||||
$can_ban = current_user_can('edit_users');
|
||||
$banned = get_user_meta($user->ID, self::BAN_META_KEY, true) === '1';
|
||||
?>
|
||||
<h2>Beep moderation</h2>
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th><label for="beep_banned">Beep status</label></th>
|
||||
<td>
|
||||
<?php if ($can_ban) : ?>
|
||||
<label>
|
||||
<input type="checkbox" name="beep_banned" id="beep_banned" value="1" <?php checked($banned); ?>>
|
||||
Suspend this user from beeping, liking, and replying
|
||||
</label>
|
||||
<?php else : ?>
|
||||
<?php echo $banned ? 'Your account is currently suspended.' : 'Active.'; ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<?php
|
||||
}
|
||||
|
||||
public static function save_profile_field($user_id) {
|
||||
if (!current_user_can('edit_users')) {
|
||||
return;
|
||||
}
|
||||
$val = isset($_POST['beep_banned']) ? '1' : '';
|
||||
update_user_meta($user_id, self::BAN_META_KEY, $val);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Beep_Polls {
|
||||
|
||||
const TABLE_NAME = 'beep_polls';
|
||||
const VOTES_TABLE = 'beep_poll_votes';
|
||||
|
||||
public static function init() {
|
||||
add_action('rest_api_init', [__CLASS__, 'register_routes']);
|
||||
}
|
||||
|
||||
public static function table_name() {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . self::TABLE_NAME;
|
||||
}
|
||||
|
||||
public static function votes_table_name() {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . self::VOTES_TABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the polls and poll_votes tables.
|
||||
*/
|
||||
public static function create_tables() {
|
||||
global $wpdb;
|
||||
$polls = self::table_name();
|
||||
$votes = self::votes_table_name();
|
||||
$charset = $wpdb->get_charset_collate();
|
||||
|
||||
$polls_sql = "CREATE TABLE $polls (
|
||||
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 $votes (
|
||||
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';
|
||||
dbDelta($polls_sql);
|
||||
dbDelta($votes_sql);
|
||||
}
|
||||
|
||||
public static function register_routes() {
|
||||
// Create a poll for a beep
|
||||
register_rest_route('beep/v1', '/poll/(?P<beep_id>\d+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'create_poll'],
|
||||
'permission_callback' => [__CLASS__, 'can_create_poll'],
|
||||
]);
|
||||
|
||||
// Get poll data for a beep
|
||||
register_rest_route('beep/v1', '/poll/(?P<beep_id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_poll'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
|
||||
// Vote on a poll
|
||||
register_rest_route('beep/v1', '/poll/(?P<beep_id>\d+)/vote', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'vote'],
|
||||
'permission_callback' => [__CLASS__, 'can_vote'],
|
||||
]);
|
||||
}
|
||||
|
||||
public static function can_create_poll() {
|
||||
return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
|
||||
}
|
||||
|
||||
public static function can_vote() {
|
||||
return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a poll for a beep post.
|
||||
* Expects JSON body: { "question": "...", "options": ["a", "b", ...], "ends_at": "YYYY-MM-DDTHH:MM:SS" }
|
||||
*/
|
||||
public static function create_poll($request) {
|
||||
$beep_id = (int) $request['beep_id'];
|
||||
$post = get_post($beep_id);
|
||||
|
||||
if (!$post || $post->post_type !== Beep_CPT::POST_TYPE) {
|
||||
return new WP_Error('not_found', 'Beep not found.', ['status' => 404]);
|
||||
}
|
||||
|
||||
if (!current_user_can('edit_post', $beep_id)) {
|
||||
return new WP_Error('forbidden', 'Cannot create poll for this beep.', ['status' => 403]);
|
||||
}
|
||||
|
||||
// Check if poll already exists
|
||||
if (self::poll_exists($beep_id)) {
|
||||
return new WP_Error('poll_exists', 'A poll already exists for this beep.', ['status' => 400]);
|
||||
}
|
||||
|
||||
$body = $request->get_json_params();
|
||||
$question = sanitize_text_field($body['question'] ?? '');
|
||||
$options = $body['options'] ?? [];
|
||||
$ends_at = isset($body['ends_at']) ? sanitize_text_field($body['ends_at']) : null;
|
||||
|
||||
if (empty($question)) {
|
||||
return new WP_Error('missing_question', 'Poll question is required.', ['status' => 400]);
|
||||
}
|
||||
|
||||
if (count($options) < 2) {
|
||||
return new WP_Error('too_few_options', 'Poll must have at least 2 options.', ['status' => 400]);
|
||||
}
|
||||
|
||||
if (count($options) > 6) {
|
||||
return new WP_Error('too_many_options', 'Poll can have at most 6 options.', ['status' => 400]);
|
||||
}
|
||||
|
||||
// Sanitize options
|
||||
$options = array_map('sanitize_text_field', $options);
|
||||
$votes = array_fill(0, count($options), 0);
|
||||
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
|
||||
$result = $wpdb->insert($table, [
|
||||
'beep_id' => $beep_id,
|
||||
'question' => $question,
|
||||
'options' => json_encode($options),
|
||||
'votes' => json_encode($votes),
|
||||
'total_votes' => 0,
|
||||
'ends_at' => $ends_at ? date('Y-m-d H:i:s', strtotime($ends_at)) : null,
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%d', '%s', '%s', '%s', '%d', '%s', '%s']);
|
||||
|
||||
if ($result === false) {
|
||||
return new WP_Error('db_error', 'Failed to create poll.', ['status' => 500]);
|
||||
}
|
||||
|
||||
$poll_id = $wpdb->insert_id;
|
||||
|
||||
// Store poll_id in post meta for easy lookup
|
||||
update_post_meta($beep_id, 'beep_poll_id', $poll_id);
|
||||
|
||||
return rest_ensure_response([
|
||||
'id' => $poll_id,
|
||||
'beep_id' => $beep_id,
|
||||
'question' => $question,
|
||||
'options' => $options,
|
||||
'votes' => $votes,
|
||||
'total_votes' => 0,
|
||||
'ends_at' => $ends_at,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get poll data for a beep.
|
||||
*/
|
||||
public static function get_poll($request) {
|
||||
$beep_id = (int) $request['beep_id'];
|
||||
$poll = self::get_poll_by_beep_id($beep_id);
|
||||
|
||||
if (!$poll) {
|
||||
return new WP_Error('not_found', 'Poll not found.', ['status' => 404]);
|
||||
}
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
$user_vote = null;
|
||||
if ($user_id) {
|
||||
$user_vote = self::get_user_vote($poll['id'], $user_id);
|
||||
}
|
||||
|
||||
return rest_ensure_response([
|
||||
'id' => $poll['id'],
|
||||
'beep_id' => $poll['beep_id'],
|
||||
'question' => $poll['question'],
|
||||
'options' => $poll['options'],
|
||||
'votes' => $poll['votes'],
|
||||
'total_votes' => $poll['total_votes'],
|
||||
'ends_at' => $poll['ends_at'],
|
||||
'has_ended' => $poll['ends_at'] ? (strtotime($poll['ends_at']) < time()) : false,
|
||||
'user_vote' => $user_vote,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vote on a poll.
|
||||
* Expects JSON body: { "option_index": 0 }
|
||||
*/
|
||||
public static function vote($request) {
|
||||
$beep_id = (int) $request['beep_id'];
|
||||
$poll = self::get_poll_by_beep_id($beep_id);
|
||||
|
||||
if (!$poll) {
|
||||
return new WP_Error('not_found', 'Poll not found.', ['status' => 404]);
|
||||
}
|
||||
|
||||
// Check if poll has ended
|
||||
if ($poll['ends_at'] && strtotime($poll['ends_at']) < time()) {
|
||||
return new WP_Error('poll_ended', 'This poll has ended.', ['status' => 400]);
|
||||
}
|
||||
|
||||
$body = $request->get_json_params();
|
||||
$option_index = (int) ($body['option_index'] ?? -1);
|
||||
|
||||
if ($option_index < 0 || $option_index >= count($poll['options'])) {
|
||||
return new WP_Error('invalid_option', 'Invalid option index.', ['status' => 400]);
|
||||
}
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
|
||||
// Check if user already voted
|
||||
$existing_vote = self::get_user_vote($poll['id'], $user_id);
|
||||
if ($existing_vote !== null) {
|
||||
// If same option, un-vote (toggle)
|
||||
if ($existing_vote === $option_index) {
|
||||
return rest_ensure_response(self::remove_vote($poll['id'], $user_id, $option_index, $beep_id));
|
||||
}
|
||||
// Different option - change vote
|
||||
return rest_ensure_response(self::change_vote($poll['id'], $user_id, $existing_vote, $option_index, $beep_id));
|
||||
}
|
||||
|
||||
// Cast new vote
|
||||
return rest_ensure_response(self::cast_vote($poll['id'], $user_id, $option_index, $beep_id));
|
||||
}
|
||||
|
||||
private static function cast_vote($poll_id, $user_id, $option_index, $beep_id) {
|
||||
global $wpdb;
|
||||
$polls_table = self::table_name();
|
||||
$votes_table = self::votes_table_name();
|
||||
|
||||
// Insert vote
|
||||
$wpdb->insert($votes_table, [
|
||||
'poll_id' => $poll_id,
|
||||
'user_id' => $user_id,
|
||||
'option_index' => $option_index,
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%d', '%d', '%d', '%s']);
|
||||
|
||||
// Update poll votes and total
|
||||
$poll = self::get_poll_by_id($poll_id);
|
||||
$votes = $poll['votes'];
|
||||
$votes[$option_index]++;
|
||||
$total_votes = $poll['total_votes'] + 1;
|
||||
|
||||
$wpdb->update(
|
||||
$polls_table,
|
||||
['votes' => json_encode($votes), 'total_votes' => $total_votes],
|
||||
['id' => $poll_id],
|
||||
['%s', '%d'],
|
||||
['%d']
|
||||
);
|
||||
|
||||
return [
|
||||
'voted' => true,
|
||||
'option_index' => $option_index,
|
||||
'votes' => $votes,
|
||||
'total_votes' => $total_votes,
|
||||
'user_vote' => $option_index,
|
||||
];
|
||||
}
|
||||
|
||||
private static function remove_vote($poll_id, $user_id, $option_index, $beep_id) {
|
||||
global $wpdb;
|
||||
$polls_table = self::table_name();
|
||||
$votes_table = self::votes_table_name();
|
||||
|
||||
// Delete vote
|
||||
$wpdb->delete($votes_table, [
|
||||
'poll_id' => $poll_id,
|
||||
'user_id' => $user_id,
|
||||
], ['%d', '%d']);
|
||||
|
||||
// Update poll votes and total
|
||||
$poll = self::get_poll_by_id($poll_id);
|
||||
$votes = $poll['votes'];
|
||||
$votes[$option_index] = max(0, $votes[$option_index] - 1);
|
||||
$total_votes = max(0, $poll['total_votes'] - 1);
|
||||
|
||||
$wpdb->update(
|
||||
$polls_table,
|
||||
['votes' => json_encode($votes), 'total_votes' => $total_votes],
|
||||
['id' => $poll_id],
|
||||
['%s', '%d'],
|
||||
['%d']
|
||||
);
|
||||
|
||||
return [
|
||||
'voted' => false,
|
||||
'option_index' => null,
|
||||
'votes' => $votes,
|
||||
'total_votes' => $total_votes,
|
||||
'user_vote' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private static function change_vote($poll_id, $user_id, $old_index, $new_index, $beep_id) {
|
||||
global $wpdb;
|
||||
$polls_table = self::table_name();
|
||||
$votes_table = self::votes_table_name();
|
||||
|
||||
// Update vote
|
||||
$wpdb->update(
|
||||
$votes_table,
|
||||
['option_index' => $new_index],
|
||||
['poll_id' => $poll_id, 'user_id' => $user_id],
|
||||
['%d'],
|
||||
['%d', '%d']
|
||||
);
|
||||
|
||||
// Update poll votes
|
||||
$poll = self::get_poll_by_id($poll_id);
|
||||
$votes = $poll['votes'];
|
||||
$votes[$old_index] = max(0, $votes[$old_index] - 1);
|
||||
$votes[$new_index]++;
|
||||
|
||||
$wpdb->update(
|
||||
$polls_table,
|
||||
['votes' => json_encode($votes)],
|
||||
['id' => $poll_id],
|
||||
['%s'],
|
||||
['%d']
|
||||
);
|
||||
|
||||
return [
|
||||
'voted' => true,
|
||||
'option_index' => $new_index,
|
||||
'votes' => $votes,
|
||||
'total_votes' => $poll['total_votes'],
|
||||
'user_vote' => $new_index,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get poll by beep ID.
|
||||
*/
|
||||
public static function get_poll_by_beep_id($beep_id) {
|
||||
// First check post meta for poll_id
|
||||
$poll_id = get_post_meta($beep_id, 'beep_poll_id', true);
|
||||
if ($poll_id) {
|
||||
return self::get_poll_by_id($poll_id);
|
||||
}
|
||||
|
||||
// Fallback to table lookup
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM $table WHERE beep_id = %d",
|
||||
$beep_id
|
||||
), ARRAY_A);
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::format_poll($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get poll by ID.
|
||||
*/
|
||||
public static function get_poll_by_id($poll_id) {
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM $table WHERE id = %d",
|
||||
$poll_id
|
||||
), ARRAY_A);
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::format_poll($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if poll exists for a beep.
|
||||
*/
|
||||
public static function poll_exists($beep_id) {
|
||||
return self::get_poll_by_beep_id($beep_id) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's vote for a poll.
|
||||
*/
|
||||
public static function get_user_vote($poll_id, $user_id) {
|
||||
global $wpdb;
|
||||
$table = self::votes_table_name();
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT option_index FROM $table WHERE poll_id = %d AND user_id = %d",
|
||||
$poll_id, $user_id
|
||||
));
|
||||
|
||||
return $row ? (int) $row->option_index : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format poll row from database.
|
||||
*/
|
||||
private static function format_poll($row) {
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'beep_id' => (int) $row['beep_id'],
|
||||
'question' => $row['question'],
|
||||
'options' => json_decode($row['options'], true),
|
||||
'votes' => json_decode($row['votes'], true),
|
||||
'total_votes' => (int) $row['total_votes'],
|
||||
'ends_at' => $row['ends_at'],
|
||||
'created_at' => $row['created_at'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get poll results with percentages (for display).
|
||||
*/
|
||||
public static function get_results_with_percentages($poll) {
|
||||
$total = $poll['total_votes'];
|
||||
$results = [];
|
||||
foreach ($poll['options'] as $i => $option) {
|
||||
$votes = $poll['votes'][$i] ?? 0;
|
||||
$percentage = $total > 0 ? round(($votes / $total) * 100) : 0;
|
||||
$results[] = [
|
||||
'index' => $i,
|
||||
'option' => $option,
|
||||
'votes' => $votes,
|
||||
'percentage' => $percentage,
|
||||
];
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
/**
|
||||
* Beep Quote Posts
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
class Beep_Quotes {
|
||||
|
||||
const QUOTED_META = 'beep_quoted_post_id';
|
||||
|
||||
/**
|
||||
* Quote a beep. Stores the quoted post ID in the new beep's meta.
|
||||
*/
|
||||
public static function create_quote($new_post_id, $quoted_post_id) {
|
||||
$quoted = get_post($quoted_post_id);
|
||||
if (!$quoted || $quoted->post_type !== Beep_CPT::POST_TYPE) {
|
||||
return new WP_Error('invalid_quoted', 'Cannot quote that beep.');
|
||||
}
|
||||
update_post_meta($new_post_id, self::QUOTED_META, (int) $quoted_post_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the quoted post ID for a beep.
|
||||
*/
|
||||
public static function get_quoted_post_id($post_id) {
|
||||
return (int) get_post_meta($post_id, self::QUOTED_META, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the quoted post data for rendering.
|
||||
*/
|
||||
public static function get_quoted_post($post_id) {
|
||||
$quoted_id = self::get_quoted_post_id($post_id);
|
||||
if (!$quoted_id) return null;
|
||||
|
||||
$post = get_post($quoted_id);
|
||||
if (!$post || $post->post_status !== 'publish') return null;
|
||||
|
||||
$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';
|
||||
}
|
||||
|
||||
$content = preg_replace('/\[beep:\d+\]/', '', $post->post_content);
|
||||
|
||||
return [
|
||||
'id' => $post->ID,
|
||||
'content' => $content,
|
||||
'time_ago'=> self::time_ago(strtotime($post->post_date)),
|
||||
'author' => [
|
||||
'id' => $author_id,
|
||||
'name' => $author->display_name ?? 'Unknown',
|
||||
'username' => $author->user_login ?? '',
|
||||
'avatar' => $avatar,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the quoted beep HTML (the embedded original beep).
|
||||
*/
|
||||
public static function render_quoted_post($post_id) {
|
||||
$quoted = self::get_quoted_post($post_id);
|
||||
if (!$quoted) return '';
|
||||
|
||||
$author = $quoted['author'];
|
||||
$avatar = esc_url($author['avatar']);
|
||||
$name = esc_html($author['name']);
|
||||
$handle = esc_html('@' . $author['username']);
|
||||
$content = make_clickable(wp_kses_post($quoted['content']));
|
||||
$time = esc_html($quoted['time_ago']);
|
||||
$qid = esc_attr($quoted['id']);
|
||||
|
||||
return '<div class="beep-quoted-post" data-quoted-id="' . $qid . '">' .
|
||||
'<div class="beep-quoted-avatar">' .
|
||||
'<img src="' . $avatar . '" alt="' . $name . '" width="36" height="36">' .
|
||||
'</div>' .
|
||||
'<div class="beep-quoted-body">' .
|
||||
'<div class="beep-quoted-meta">' .
|
||||
'<span class="beep-quoted-name">' . $name . '</span>' .
|
||||
'<span class="beep-quoted-handle">' . $handle . '</span>' .
|
||||
'<span class="beep-quoted-dot">·</span>' .
|
||||
'<span class="beep-quoted-time">' . $time . '</span>' .
|
||||
'</div>' .
|
||||
'<div class="beep-quoted-text">' . $content . '</div>' .
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
private 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) { exit; }
|
||||
|
||||
class Beep_Replies {
|
||||
|
||||
public static function init() {
|
||||
add_action('rest_api_init', [__CLASS__, 'register_routes']);
|
||||
}
|
||||
|
||||
public static function register_routes() {
|
||||
register_rest_route('beep/v1', '/post', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'submit_beep'],
|
||||
'permission_callback' => ['Beep_Likes', 'can_interact'],
|
||||
]);
|
||||
register_rest_route('beep/v1', '/reply/(?P<id>\d+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'submit_reply'],
|
||||
'permission_callback' => ['Beep_Likes', 'can_interact'],
|
||||
]);
|
||||
register_rest_route('beep/v1', '/thread/(?P<post_id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_thread_api'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
register_rest_route('beep/v1', '/delete/(?P<id>\d+)', [
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [__CLASS__, 'delete_beep'],
|
||||
'permission_callback' => ['Beep_Likes', 'can_interact'],
|
||||
]);
|
||||
register_rest_route('beep/v1', '/delete-reply/(?P<id>\d+)', [
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [__CLASS__, 'delete_reply'],
|
||||
'permission_callback' => ['Beep_Likes', 'can_interact'],
|
||||
]);
|
||||
}
|
||||
|
||||
public static function submit_beep($request) {
|
||||
$content = sanitize_textarea_field($request->get_param('content'));
|
||||
if (empty($content)) {
|
||||
return new WP_Error('empty_content', 'Beep cannot be empty.', ['status' => 400]);
|
||||
}
|
||||
if (mb_strlen($content) > BEEP_CHAR_LIMIT) {
|
||||
return new WP_Error('content_too_long', 'Beep exceeds ' . BEEP_CHAR_LIMIT . ' character limit.', ['status' => 400]);
|
||||
}
|
||||
|
||||
// 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',
|
||||
'post_content' => $content,
|
||||
'post_status' => 'publish',
|
||||
'post_author' => get_current_user_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);
|
||||
}
|
||||
|
||||
// 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'];
|
||||
$parent = get_post($post_id);
|
||||
if (!$parent || $parent->post_type !== 'beep') {
|
||||
return new WP_Error('invalid_post', 'Invalid beep post.', ['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]);
|
||||
}
|
||||
$comment_id = wp_insert_comment([
|
||||
'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('insert_failed', 'Could not save reply.', ['status' => 500]);
|
||||
}
|
||||
return rest_ensure_response(['id' => $comment_id]);
|
||||
}
|
||||
|
||||
public static function delete_beep($request) {
|
||||
$post_id = (int) $request['id'];
|
||||
$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('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('delete_failed', 'Could not delete.', ['status' => 500]);
|
||||
}
|
||||
return rest_ensure_response(['deleted' => true]);
|
||||
}
|
||||
|
||||
public static function get_replies($post_id) {
|
||||
return get_comments([
|
||||
'post_id' => $post_id,
|
||||
'status' => 'approve',
|
||||
'order' => 'ASC',
|
||||
'type' => 'comment',
|
||||
]);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Beep_Shortcode {
|
||||
|
||||
public static function init() {
|
||||
add_shortcode('beep_feed', [__CLASS__, 'render_feed']);
|
||||
add_action('rest_api_init', [__CLASS__, 'register_routes']);
|
||||
add_action('rest_api_init', [__CLASS__, 'register_media_routes']);
|
||||
add_action('rest_api_init', [__CLASS__, 'register_thread_routes']);
|
||||
add_action('rest_api_init', [__CLASS__, 'register_quote_routes']);
|
||||
}
|
||||
|
||||
public static function register_routes() {
|
||||
register_rest_route('beep/v1', '/feed', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'fetch_feed'],
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => [
|
||||
'sort' => ['default' => 'latest'],
|
||||
'limit' => ['default' => 50, 'validate_callback' => function ($v) { return is_numeric($v); }],
|
||||
'offset' => ['default' => 0, 'validate_callback' => function ($v) { return is_numeric($v); }],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* REST endpoint: re-render the feed list with the given sort.
|
||||
*/
|
||||
public static function fetch_feed($request) {
|
||||
$sort = $request->get_param('sort') === 'top' ? 'top' : 'latest';
|
||||
$limit = max(1, (int) $request->get_param('limit'));
|
||||
$offset = max(0, (int) $request->get_param('offset'));
|
||||
$posts = ($sort === 'top') ? self::get_top_beeps($limit + 1, $offset) : self::get_latest_beeps($limit + 1, $offset);
|
||||
|
||||
$has_more = count($posts) > $limit;
|
||||
if ($has_more) {
|
||||
$posts = array_slice($posts, 0, $limit);
|
||||
}
|
||||
|
||||
$html = '';
|
||||
foreach ($posts as $p) {
|
||||
$html .= beep_render_post($p);
|
||||
}
|
||||
if ($html === '' && $offset === 0) {
|
||||
$empty_bird = beep_bird_svg( ['size' => 64, 'fill' => '#4a90c4'] );
|
||||
$html = '<div class="beep-empty"><div class="beep-empty-icon">' . $empty_bird . '</div><p>No beeps yet.</p><span>Be the first to share something!</span></div>';
|
||||
}
|
||||
return rest_ensure_response(['html' => $html, 'sort' => $sort, 'has_more' => $has_more, 'offset' => $offset + count($posts)]);
|
||||
}
|
||||
|
||||
public static function get_latest_beeps($limit, $offset = 0) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->posts}
|
||||
WHERE post_type = %s AND post_status = 'publish'
|
||||
ORDER BY post_date DESC
|
||||
LIMIT %d OFFSET %d",
|
||||
Beep_CPT::POST_TYPE, $limit, $offset
|
||||
));
|
||||
}
|
||||
|
||||
public static function get_top_beeps($limit, $offset = 0) {
|
||||
global $wpdb;
|
||||
$likes = Beep_Likes::table_name();
|
||||
return $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT p.*, COALESCE(l.cnt, 0) AS like_count
|
||||
FROM {$wpdb->posts} p
|
||||
LEFT JOIN (
|
||||
SELECT post_id, COUNT(*) AS cnt
|
||||
FROM $likes
|
||||
WHERE object_type = 'post'
|
||||
GROUP BY post_id
|
||||
) l ON l.post_id = p.ID
|
||||
WHERE p.post_type = %s AND p.post_status = 'publish'
|
||||
ORDER BY COALESCE(l.cnt, 0) DESC, p.post_date DESC
|
||||
LIMIT %d OFFSET %d",
|
||||
Beep_CPT::POST_TYPE, $limit, $offset
|
||||
));
|
||||
}
|
||||
|
||||
public static function render_feed($atts) {
|
||||
$atts = shortcode_atts([
|
||||
'limit' => 50,
|
||||
'header' => 'Beep!',
|
||||
'show_wordmark' => true,
|
||||
'sort' => 'latest',
|
||||
], $atts, 'beep_feed');
|
||||
|
||||
$limit = max(1, (int) $atts['limit']);
|
||||
$sort = ($atts['sort'] === 'top') ? 'top' : 'latest';
|
||||
$posts = ($sort === 'top') ? self::get_top_beeps($limit) : self::get_latest_beeps($limit);
|
||||
$has_posts = !empty($posts);
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<div class="beep-feed" data-beep-feed>
|
||||
<div class="beep-embed">
|
||||
<div class="beep-feed-header">
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:8px;"><strong style="color:#FFFFFF;">Messages from </strong><img src="https://sami-ahmed.net/wp-content/plugins/beep/assets/beep-wordmark.png" alt="Beep!" style="height:22px;width:auto;vertical-align:middle;"></div>
|
||||
</div>
|
||||
|
||||
<?php if ($has_posts) : ?>
|
||||
<div class="beep-tabs" data-beep-tabs role="tablist">
|
||||
<button type="button" class="beep-tab <?php echo $sort === 'latest' ? 'is-active' : ''; ?>"
|
||||
data-beep-sort="latest" role="tab">Latest</button>
|
||||
<button type="button" class="beep-tab <?php echo $sort === 'top' ? 'is-active' : ''; ?>"
|
||||
data-beep-sort="top" role="tab">Top</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php echo beep_render_composer(); ?>
|
||||
|
||||
<div class="beep-list" data-beep-list>
|
||||
<?php
|
||||
if ($has_posts) {
|
||||
foreach ($posts as $p) {
|
||||
echo beep_render_post($p);
|
||||
}
|
||||
} else {
|
||||
$empty_bird = beep_bird_svg( ['size' => 64, 'fill' => '#4a90c4'] );
|
||||
echo '<div class="beep-empty"><div class="beep-empty-icon">' . $empty_bird . '</div><p>No beeps yet.</p><span>Be the first to share something!</span></div>';
|
||||
}
|
||||
?>
|
||||
</div><!-- .beep-list -->
|
||||
</div><!-- .beep-embed -->
|
||||
</div><!-- .beep-feed -->
|
||||
<?php
|
||||
return ob_get_clean() . beep_render_thread_modal() . beep_render_gif_modal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register additional REST routes for media upload.
|
||||
*/
|
||||
public static function register_media_routes() {
|
||||
register_rest_route('beep/v1', '/media/(?P<post_id>\d+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'upload_media'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
|
||||
},
|
||||
]);
|
||||
register_rest_route('beep/v1', '/media/(?P<post_id>\d+)/(?P<attachment_id>\d+)', [
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [__CLASS__, 'delete_media'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
|
||||
},
|
||||
]);
|
||||
register_rest_route('beep/v1', '/voice/(?P<post_id>\d+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'upload_voice'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
public static function upload_media($request) {
|
||||
$post_id = (int) $request->get_param('post_id');
|
||||
$post = get_post($post_id);
|
||||
|
||||
if (!$post || $post->post_type !== Beep_CPT::POST_TYPE) {
|
||||
return new WP_Error('not_found', 'Beep not found.', 404);
|
||||
}
|
||||
if (!current_user_can('edit_post', $post_id)) {
|
||||
return new WP_Error('forbidden', 'Cannot edit this beep.', 403);
|
||||
}
|
||||
if (empty($_FILES['file'])) {
|
||||
return new WP_Error('no_file', 'No file uploaded.');
|
||||
}
|
||||
|
||||
$result = Beep_Media::handle_upload($post_id, $_FILES['file']);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return rest_ensure_response($result);
|
||||
}
|
||||
|
||||
public static function delete_media($request) {
|
||||
$post_id = (int) $request->get_param('post_id');
|
||||
$attachment_id = (int) $request->get_param('attachment_id');
|
||||
$post = get_post($post_id);
|
||||
|
||||
if (!$post || $post->post_type !== Beep_CPT::POST_TYPE) {
|
||||
return new WP_Error('not_found', 'Beep not found.', 404);
|
||||
}
|
||||
if (!current_user_can('edit_post', $post_id)) {
|
||||
return new WP_Error('forbidden', 'Cannot edit this beep.', 403);
|
||||
}
|
||||
|
||||
Beep_Media::remove_image($post_id, $attachment_id);
|
||||
return rest_ensure_response(['ok' => true]);
|
||||
}
|
||||
public static function upload_voice($request) {
|
||||
$post_id = (int) $request->get_param('post_id');
|
||||
$post = get_post($post_id);
|
||||
|
||||
if (!$post || $post->post_type !== Beep_CPT::POST_TYPE) {
|
||||
return new WP_Error('not_found', 'Beep not found.', 404);
|
||||
}
|
||||
if (!current_user_can('edit_post', $post_id)) {
|
||||
return new WP_Error('forbidden', 'Cannot edit this beep.', 403);
|
||||
}
|
||||
if (empty($_FILES['file'])) {
|
||||
return new WP_Error('no_file', 'No file uploaded.');
|
||||
}
|
||||
|
||||
$result = Beep_Media::handle_voice_upload($post_id, $_FILES['file']);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return rest_ensure_response($result);
|
||||
}
|
||||
|
||||
|
||||
public static function register_thread_routes() {
|
||||
register_rest_route('beep/v1', '/thread/(?P<post_id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_thread'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
}
|
||||
|
||||
public static function get_thread($request) {
|
||||
$post_id = (int) $request->get_param('post_id');
|
||||
if (get_post_type($post_id) !== Beep_CPT::POST_TYPE) {
|
||||
return new WP_Error('not_found', 'Beep not found.', 404);
|
||||
}
|
||||
$depth = (int) $request->get_param('depth') ?: 10;
|
||||
$thread = Beep_Replies::get_thread($post_id, 0, $depth);
|
||||
return rest_ensure_response($thread);
|
||||
}
|
||||
|
||||
public static function register_quote_routes() {
|
||||
register_rest_route('beep/v1', '/quote/(?P<post_id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_quoted_post'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
register_rest_route('beep/v1', '/lookup', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'lookup_beep'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
}
|
||||
|
||||
public static function get_quoted_post($request) {
|
||||
$post_id = (int) $request->get_param('post_id');
|
||||
$quoted = Beep_Quotes::get_quoted_post($post_id);
|
||||
if (!$quoted) {
|
||||
return new WP_Error('not_found', 'No quoted post found.', 404);
|
||||
}
|
||||
return rest_ensure_response($quoted);
|
||||
}
|
||||
|
||||
public static function lookup_beep($request) {
|
||||
$query = sanitize_text_field($request->get_param('q'));
|
||||
if (!$query) {
|
||||
return new WP_Error('missing_query', 'Query parameter q is required.', 400);
|
||||
}
|
||||
if (is_numeric($query)) {
|
||||
$post = get_post((int) $query);
|
||||
if ($post && $post->post_type === Beep_CPT::POST_TYPE) {
|
||||
return rest_ensure_response(Beep_Quotes::get_quoted_post($post->ID));
|
||||
}
|
||||
}
|
||||
if (preg_match('/\[beep:(\d+)\]/', $query, $m)) {
|
||||
$post = get_post((int) $m[1]);
|
||||
if ($post && $post->post_type === Beep_CPT::POST_TYPE) {
|
||||
return rest_ensure_response(Beep_Quotes::get_quoted_post($post->ID));
|
||||
}
|
||||
}
|
||||
return new WP_Error('not_found', 'Beep not found.', 404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Beep_Shortcode {
|
||||
|
||||
public static function init() {
|
||||
add_shortcode('beep_feed', [__CLASS__, 'render_feed']);
|
||||
add_action('rest_api_init', [__CLASS__, 'register_routes']);
|
||||
add_action('rest_api_init', [__CLASS__, 'register_media_routes']);
|
||||
add_action('rest_api_init', [__CLASS__, 'register_thread_routes']);
|
||||
add_action('rest_api_init', [__CLASS__, 'register_quote_routes']);
|
||||
}
|
||||
|
||||
public static function register_routes() {
|
||||
register_rest_route('beep/v1', '/feed', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'fetch_feed'],
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => [
|
||||
'sort' => ['default' => 'latest'],
|
||||
'limit' => ['default' => 50, 'validate_callback' => function ($v) { return is_numeric($v); }],
|
||||
'offset' => ['default' => 0, 'validate_callback' => function ($v) { return is_numeric($v); }],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* REST endpoint: re-render the feed list with the given sort.
|
||||
*/
|
||||
public static function fetch_feed($request) {
|
||||
$sort = $request->get_param('sort') === 'top' ? 'top' : 'latest';
|
||||
$limit = max(1, (int) $request->get_param('limit'));
|
||||
$offset = max(0, (int) $request->get_param('offset'));
|
||||
$posts = ($sort === 'top') ? self::get_top_beeps($limit + 1, $offset) : self::get_latest_beeps($limit + 1, $offset);
|
||||
|
||||
$has_more = count($posts) > $limit;
|
||||
if ($has_more) {
|
||||
$posts = array_slice($posts, 0, $limit);
|
||||
}
|
||||
|
||||
$html = '';
|
||||
foreach ($posts as $p) {
|
||||
$html .= beep_render_post($p);
|
||||
}
|
||||
if ($html === '' && $offset === 0) {
|
||||
$empty_bird = beep_bird_svg( ['size' => 64, 'fill' => '#4a90c4'] );
|
||||
$html = '<div class="beep-empty"><div class="beep-empty-icon">' . $empty_bird . '</div><p>No beeps yet.</p><span>Be the first to share something!</span></div>';
|
||||
}
|
||||
return rest_ensure_response(['html' => $html, 'sort' => $sort, 'has_more' => $has_more, 'offset' => $offset + count($posts)]);
|
||||
}
|
||||
|
||||
public static function get_latest_beeps($limit, $offset = 0) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->posts}
|
||||
WHERE post_type = %s AND post_status = 'publish'
|
||||
ORDER BY post_date DESC
|
||||
LIMIT %d OFFSET %d",
|
||||
Beep_CPT::POST_TYPE, $limit, $offset
|
||||
));
|
||||
}
|
||||
|
||||
public static function get_top_beeps($limit, $offset = 0) {
|
||||
global $wpdb;
|
||||
$likes = Beep_Likes::table_name();
|
||||
return $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT p.*, COALESCE(l.cnt, 0) AS like_count
|
||||
FROM {$wpdb->posts} p
|
||||
LEFT JOIN (
|
||||
SELECT post_id, COUNT(*) AS cnt
|
||||
FROM $likes
|
||||
WHERE object_type = 'post'
|
||||
GROUP BY post_id
|
||||
) l ON l.post_id = p.ID
|
||||
WHERE p.post_type = %s AND p.post_status = 'publish'
|
||||
ORDER BY COALESCE(l.cnt, 0) DESC, p.post_date DESC
|
||||
LIMIT %d OFFSET %d",
|
||||
Beep_CPT::POST_TYPE, $limit, $offset
|
||||
));
|
||||
}
|
||||
|
||||
public static function render_feed($atts) {
|
||||
$atts = shortcode_atts([
|
||||
'limit' => 50,
|
||||
'header' => 'Beep!',
|
||||
'show_wordmark' => true,
|
||||
'sort' => 'latest',
|
||||
], $atts, 'beep_feed');
|
||||
|
||||
$limit = max(1, (int) $atts['limit']);
|
||||
$sort = ($atts['sort'] === 'top') ? 'top' : 'latest';
|
||||
$posts = ($sort === 'top') ? self::get_top_beeps($limit) : self::get_latest_beeps($limit);
|
||||
$has_posts = !empty($posts);
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<div class="beep-feed" data-beep-feed>
|
||||
<div class="beep-embed">
|
||||
<div class="beep-feed-header">
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:8px;"><strong style="color:#FFFFFF;">Messages from </strong><img src="https://sami-ahmed.net/wp-content/plugins/beep/assets/beep-wordmark.png" alt="Beep!" style="height:22px;width:auto;vertical-align:middle;"></div>
|
||||
</div>
|
||||
|
||||
<?php if ($has_posts) : ?>
|
||||
<div class="beep-tabs" data-beep-tabs role="tablist">
|
||||
<button type="button" class="beep-tab <?php echo $sort === 'latest' ? 'is-active' : ''; ?>"
|
||||
data-beep-sort="latest" role="tab">Latest</button>
|
||||
<button type="button" class="beep-tab <?php echo $sort === 'top' ? 'is-active' : ''; ?>"
|
||||
data-beep-sort="top" role="tab">Top</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (is_user_logged_in() && !beep_user_is_banned(get_current_user_id())) : ?>
|
||||
<?php echo beep_render_composer(); ?>
|
||||
<?php elseif (is_user_logged_in()) : ?>
|
||||
<div class="beep-notice beep-notice-banned">
|
||||
Your account has been suspended from beeping.
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<div class="beep-notice beep-notice-login">
|
||||
<p><strong>Join in.</strong> Sign in to like and reply.</p>
|
||||
<a class="beep-button" href="<?php echo esc_url(wp_login_url(get_permalink())); ?>">Log in</a>
|
||||
<?php if (get_option('users_can_register')) : ?>
|
||||
<a class="beep-button beep-button-secondary" href="<?php echo esc_url(wp_registration_url()); ?>">Sign up</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="beep-list" data-beep-list>
|
||||
<?php
|
||||
if ($has_posts) {
|
||||
foreach ($posts as $p) {
|
||||
echo beep_render_post($p);
|
||||
}
|
||||
} else {
|
||||
$empty_bird = beep_bird_svg( ['size' => 64, 'fill' => '#4a90c4'] );
|
||||
echo '<div class="beep-empty"><div class="beep-empty-icon">' . $empty_bird . '</div><p>No beeps yet.</p><span>Be the first to share something!</span></div>';
|
||||
}
|
||||
?>
|
||||
</div><!-- .beep-list -->
|
||||
</div><!-- .beep-embed -->
|
||||
</div><!-- .beep-feed -->
|
||||
<?php
|
||||
return ob_get_clean() . beep_render_thread_modal() . beep_render_gif_modal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register additional REST routes for media upload.
|
||||
*/
|
||||
public static function register_media_routes() {
|
||||
register_rest_route('beep/v1', '/media/(?P<post_id>\d+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'upload_media'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
|
||||
},
|
||||
]);
|
||||
register_rest_route('beep/v1', '/media/(?P<post_id>\d+)/(?P<attachment_id>\d+)', [
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [__CLASS__, 'delete_media'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
|
||||
},
|
||||
]);
|
||||
register_rest_route('beep/v1', '/voice/(?P<post_id>\d+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'upload_voice'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
public static function upload_media($request) {
|
||||
$post_id = (int) $request->get_param('post_id');
|
||||
$post = get_post($post_id);
|
||||
|
||||
if (!$post || $post->post_type !== Beep_CPT::POST_TYPE) {
|
||||
return new WP_Error('not_found', 'Beep not found.', 404);
|
||||
}
|
||||
if (!current_user_can('edit_post', $post_id)) {
|
||||
return new WP_Error('forbidden', 'Cannot edit this beep.', 403);
|
||||
}
|
||||
if (empty($_FILES['file'])) {
|
||||
return new WP_Error('no_file', 'No file uploaded.');
|
||||
}
|
||||
|
||||
$result = Beep_Media::handle_upload($post_id, $_FILES['file']);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return rest_ensure_response($result);
|
||||
}
|
||||
|
||||
public static function delete_media($request) {
|
||||
$post_id = (int) $request->get_param('post_id');
|
||||
$attachment_id = (int) $request->get_param('attachment_id');
|
||||
$post = get_post($post_id);
|
||||
|
||||
if (!$post || $post->post_type !== Beep_CPT::POST_TYPE) {
|
||||
return new WP_Error('not_found', 'Beep not found.', 404);
|
||||
}
|
||||
if (!current_user_can('edit_post', $post_id)) {
|
||||
return new WP_Error('forbidden', 'Cannot edit this beep.', 403);
|
||||
}
|
||||
|
||||
Beep_Media::remove_image($post_id, $attachment_id);
|
||||
return rest_ensure_response(['ok' => true]);
|
||||
}
|
||||
public static function upload_voice($request) {
|
||||
$post_id = (int) $request->get_param('post_id');
|
||||
$post = get_post($post_id);
|
||||
|
||||
if (!$post || $post->post_type !== Beep_CPT::POST_TYPE) {
|
||||
return new WP_Error('not_found', 'Beep not found.', 404);
|
||||
}
|
||||
if (!current_user_can('edit_post', $post_id)) {
|
||||
return new WP_Error('forbidden', 'Cannot edit this beep.', 403);
|
||||
}
|
||||
if (empty($_FILES['file'])) {
|
||||
return new WP_Error('no_file', 'No file uploaded.');
|
||||
}
|
||||
|
||||
$result = Beep_Media::handle_voice_upload($post_id, $_FILES['file']);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return rest_ensure_response($result);
|
||||
}
|
||||
|
||||
|
||||
public static function register_thread_routes() {
|
||||
register_rest_route('beep/v1', '/thread/(?P<post_id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_thread'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
}
|
||||
|
||||
public static function get_thread($request) {
|
||||
$post_id = (int) $request->get_param('post_id');
|
||||
if (get_post_type($post_id) !== Beep_CPT::POST_TYPE) {
|
||||
return new WP_Error('not_found', 'Beep not found.', 404);
|
||||
}
|
||||
$depth = (int) $request->get_param('depth') ?: 10;
|
||||
$thread = Beep_Replies::get_thread($post_id, 0, $depth);
|
||||
return rest_ensure_response($thread);
|
||||
}
|
||||
|
||||
public static function register_quote_routes() {
|
||||
register_rest_route('beep/v1', '/quote/(?P<post_id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_quoted_post'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
register_rest_route('beep/v1', '/lookup', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'lookup_beep'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
}
|
||||
|
||||
public static function get_quoted_post($request) {
|
||||
$post_id = (int) $request->get_param('post_id');
|
||||
$quoted = Beep_Quotes::get_quoted_post($post_id);
|
||||
if (!$quoted) {
|
||||
return new WP_Error('not_found', 'No quoted post found.', 404);
|
||||
}
|
||||
return rest_ensure_response($quoted);
|
||||
}
|
||||
|
||||
public static function lookup_beep($request) {
|
||||
$query = sanitize_text_field($request->get_param('q'));
|
||||
if (!$query) {
|
||||
return new WP_Error('missing_query', 'Query parameter q is required.', 400);
|
||||
}
|
||||
if (is_numeric($query)) {
|
||||
$post = get_post((int) $query);
|
||||
if ($post && $post->post_type === Beep_CPT::POST_TYPE) {
|
||||
return rest_ensure_response(Beep_Quotes::get_quoted_post($post->ID));
|
||||
}
|
||||
}
|
||||
if (preg_match('/\[beep:(\d+)\]/', $query, $m)) {
|
||||
$post = get_post((int) $m[1]);
|
||||
if ($post && $post->post_type === Beep_CPT::POST_TYPE) {
|
||||
return rest_ensure_response(Beep_Quotes::get_quoted_post($post->ID));
|
||||
}
|
||||
}
|
||||
return new WP_Error('not_found', 'Beep not found.', 404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
<?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;
|
||||
$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
|
||||
. ' 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.
|
||||
* 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' );
|
||||
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 ) . ';">';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
$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)';
|
||||
}
|
||||
$avatar = '<img class="beep-avatar" src="' . esc_url($avatar_url) . '" alt="" style="width:48px;height:48px;object-fit:contain;' . ($filter ? 'filter:' . esc_attr($filter) : '') . '">';
|
||||
|
||||
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>
|
||||
<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">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="beep-compose-poll" data-beep-compose-poll hidden>
|
||||
<div class="beep-poll-input-row">
|
||||
<input type="text" class="beep-poll-question" data-beep-poll-question placeholder="Ask a question...">
|
||||
</div>
|
||||
<div class="beep-poll-options" data-beep-poll-options>
|
||||
<div class="beep-poll-option-row">
|
||||
<input type="text" class="beep-poll-option" data-beep-poll-option placeholder="Option 1" maxlength="100">
|
||||
</div>
|
||||
<div class="beep-poll-option-row">
|
||||
<input type="text" class="beep-poll-option" data-beep-poll-option placeholder="Option 2" maxlength="100">
|
||||
</div>
|
||||
</div>
|
||||
<div class="beep-poll-actions">
|
||||
<button type="button" class="beep-poll-add-option" data-beep-poll-add-option>+ Add option</button>
|
||||
<button type="button" class="beep-poll-remove-option" data-beep-poll-remove-option hidden>- Remove</button>
|
||||
<button type="button" class="beep-poll-remove" data-beep-poll-remove>Remove poll</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="beep-char-counter" data-beep-counter><?php echo (int) BEEP_CHAR_LIMIT; ?></span>
|
||||
<button class="beep-button beep-post-button" data-beep-submit disabled>Beep</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="beep-compose-media-preview" data-beep-media-preview hidden></div>
|
||||
<div class="beep-compose-voice-preview" data-beep-voice-preview hidden>
|
||||
<div class="beep-voice-preview-card">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08C16.39 17.43 19 14.53 19 11h-2z"/></svg>
|
||||
<span>Voice note</span>
|
||||
<button class="beep-voice-remove" data-beep-voice-remove type="button" title="Remove">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single beep post + its replies tree.
|
||||
*/
|
||||
function beep_render_post($post) {
|
||||
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);
|
||||
$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" data-beep-post="<?php echo (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>
|
||||
<?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>
|
||||
</div>
|
||||
<div class="beep-text"><?php echo $content; ?></div>
|
||||
<?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-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>
|
||||
<?php endif; ?>
|
||||
<div class="beep-actions">
|
||||
<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>
|
||||
<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>
|
||||
<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"
|
||||
title="Copy link">
|
||||
<?php echo beep_icon('share'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<a href="#" class="beep-action beep-action-quote"
|
||||
data-beep-quote="<?php echo (int) $post->ID; ?>"
|
||||
title="Quote">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M8 5H6v2H4v12h2v-2h2v2h8V9h-2V7h-2v2H8V5zm10 0v2h2v12h-2v-2h-2v2H8V9h2v2h2V7h2V5h2z"/></svg>
|
||||
</a>
|
||||
<div class="beep-bottom-bar">
|
||||
<?php if ($can_delete) : ?>
|
||||
<button class="beep-delete-link"
|
||||
data-beep-delete="<?php echo (int) $post->ID; ?>"
|
||||
type="button"
|
||||
aria-label="Delete this beep"
|
||||
title="Delete">×</button>
|
||||
<?php endif; ?>
|
||||
<span class="beep-wordmark">Beep!</span>
|
||||
</div>
|
||||
|
||||
<?php if ($can_interact) : ?>
|
||||
<?php echo beep_render_reply_form($post->ID, 0); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="beep-replies" data-beep-replies>
|
||||
<?php
|
||||
$all = Beep_Replies::get_replies($post->ID);
|
||||
echo beep_render_replies_tree($all, 0, 0, $post->ID, $can_interact);
|
||||
?>
|
||||
</div>
|
||||
<span class="beep-wordmark">Beep!</span>
|
||||
</div>
|
||||
</article>
|
||||
<?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) {
|
||||
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>
|
||||
</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_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 = $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');
|
||||
$like_count = Beep_Likes::get_count($comment->comment_ID, 'reply');
|
||||
$user_liked = Beep_Likes::user_has_liked($comment->comment_ID, 'reply');
|
||||
|
||||
$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">
|
||||
<?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>
|
||||
<?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 echo beep_bird_svg( ['size' => 16, 'fill' => '#0064cd', 'class' => 'beep-post-badge beep-post-badge-sm'] ); ?>
|
||||
</div>
|
||||
<div class="beep-text"><?php echo $content; ?></div>
|
||||
|
||||
<?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 if ($can_delete) : ?>
|
||||
<div class="beep-delete-row">
|
||||
<button class="beep-delete-link"
|
||||
data-beep-delete-reply="<?php echo (int) $comment->comment_ID; ?>"
|
||||
type="button"
|
||||
aria-label="Delete this reply"
|
||||
title="Delete">×</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?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;
|
||||
}
|
||||
|
||||
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.01z"/></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] : '';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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-header">' .
|
||||
'<button class="beep-thread-close" data-beep-thread-close type="button">' .
|
||||
'<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-content" data-beep-thread-content></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-header">' .
|
||||
'<button class="beep-gif-close" data-beep-gif-close type="button">' .
|
||||
'<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>';
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
=== Beep ===
|
||||
Contributors: sami
|
||||
Tags: microblog, twitter, tweets, social, comments, polls, elementor
|
||||
Requires at least: 6.0
|
||||
Tested up to: 6.9
|
||||
Requires PHP: 7.4
|
||||
Stable tag: 1.6.0
|
||||
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.
|
||||
|
||||
== Description ==
|
||||
|
||||
Beep adds a Twitter-style microblog to your WordPress site. Drop the
|
||||
[beep_feed] shortcode on any page or use the Elementor widget. Likes,
|
||||
threaded replies, polls, quotes, GIFs, and Latest/Top sort tabs are all built in.
|
||||
|
||||
Features:
|
||||
* Twitter-style feed with [beep_feed] shortcode
|
||||
* Elementor drag-and-drop widget
|
||||
* Polls (create and vote)
|
||||
* Quote posts
|
||||
* GIF picker (Giphy integration)
|
||||
* Likes on beeps and replies
|
||||
* Nested threaded replies
|
||||
* Gravatar avatars
|
||||
* Admin settings page (Settings > Beep)
|
||||
* Customizable colors and branding via CSS variables
|
||||
* Bird-themed branding with custom icons
|
||||
|
||||
== Installation ==
|
||||
|
||||
1. Upload beep.zip via Plugins -> Add New -> Upload Plugin. Activate.
|
||||
2. Add [beep_feed] to a page, or use the Elementor widget.
|
||||
3. For multi-user signup, enable "Anyone can register" in Settings -> General.
|
||||
|
||||
== Shortcode ==
|
||||
|
||||
[beep_feed limit="50" header="Messages from" sort="latest"]
|
||||
|
||||
== Changelog ==
|
||||
|
||||
= 1.6.0 =
|
||||
* Elementor drag-and-drop widget for feed placement.
|
||||
* Admin settings page (Settings > Beep) for Giphy API key and branding.
|
||||
* Polls: create polls in beeps, vote on polls.
|
||||
* Quote posts: quote any beep.
|
||||
* GIF picker powered by Giphy.
|
||||
* Bird-themed redesign with custom bird icons and badges.
|
||||
* CSS variables for customizable colors.
|
||||
* Elementor widget registration.
|
||||
* Settings stored in wp_options.
|
||||
|
||||
= 1.5.1 =
|
||||
* Twitter-style UI redesign with bird assets.
|
||||
* Beep bird badge on posts and replies.
|
||||
* CSS scope isolation improvements.
|
||||
|
||||
= 1.5.0 =
|
||||
* Polls feature (Beep_Polls class, voting UI).
|
||||
* Bird wordmark logo in header.
|
||||
|
||||
= 1.4.0 =
|
||||
* Like replies, not just beeps.
|
||||
* Nested replies (reply to replies, indented up to two levels).
|
||||
* Latest / Top sort tabs at the top of the feed.
|
||||
* Bird badge in the top-right of every beep and reply.
|
||||
* Polished tabs, focus states, action button styling.
|
||||
|
||||
= 1.3.0 =
|
||||
* Feed header reads "Messages from" + Beep wordmark.
|
||||
* Avatar/textarea fixes baked in.
|
||||
|
||||
= 1.2.0 =
|
||||
* Feed header shows Beep wordmark instead of small icon + Home text.
|
||||
|
||||
= 1.1.0 =
|
||||
* New hand-drawn bird logo.
|
||||
|
||||
= 1.0.0 =
|
||||
* Initial release.
|
||||