40, 'width' => 40, 'flex-height' => true, 'flex-width' => true, ) ); add_theme_support( 'custom-header', array( 'default-text-color' => '000000', 'width' => 1500, 'height' => 500, 'flex-height' => true, 'flex-width' => true, ) ); add_theme_support( 'custom-background', array( 'default-color' => 'ffffff', ) ); add_theme_support( 'html5', array( 'comment-list', 'comment-form', 'search-form', 'gallery', 'caption', 'style', 'script', ) ); register_nav_menus( array( 'primary' => esc_html__( 'Primary Menu', 'blurt' ), 'footer' => esc_html__( 'Footer Menu', 'blurt' ), ) ); add_image_size( 'blurt-gallery-thumb', 300, 300, true ); add_image_size( 'blurt-gallery-large', 600, 600, false ); } add_action( 'after_setup_theme', 'blurt_setup' ); /** * Set the content width in pixels. */ function blurt_content_width() { $GLOBALS['content_width'] = 780; } add_action( 'after_setup_theme', 'blurt_content_width', 0 ); /** * Enforce strict reverse-chronological feed order. * * WordPress puts sticky posts at the top of the home query by default, * which breaks the "no algorithmic timeline" rule. This ensures the * main feed, author archives, search, and tag pages all sort by date * descending with no sticky-post reordering. * * @param WP_Query $query The main query. */ function blurt_enforce_reverse_chronological( $query ) { if ( is_admin() || ! $query->is_main_query() ) { return; } $query->set( 'orderby', 'date' ); $query->set( 'order', 'DESC' ); $query->set( 'ignore_sticky_posts', 1 ); // Exclude reposts from search results. if ( $query->is_search() ) { $meta_query = $query->get( 'meta_query' ); if ( ! is_array( $meta_query ) ) { $meta_query = array(); } $meta_query[] = array( 'key' => '_blurt_repost_of', 'compare' => 'NOT EXISTS', ); $query->set( 'meta_query', $meta_query ); } } add_action( 'pre_get_posts', 'blurt_enforce_reverse_chronological' ); /** * Register the 'tab' query var for profile page tab switching. * * @param array $vars Allowed query variables. * @return array Modified query variables. */ function blurt_query_vars( $vars ) { $vars[] = 'tab'; return $vars; } add_filter( 'query_vars', 'blurt_query_vars' ); /** * Auto-create the "Tools" page on theme activation so that * page-tools.php is used via the WordPress template hierarchy. */ function blurt_create_tools_page() { $existing = get_page_by_path( 'tools', OBJECT, 'page' ); if ( ! $existing ) { // Also check trashed/drafted pages. $existing = get_posts( array( 'name' => 'tools', 'post_type' => 'page', 'post_status' => array( 'publish', 'draft', 'trash', 'private', 'pending' ), 'numberposts' => 1, ) ); } if ( $existing ) { return; } wp_insert_post( array( 'post_title' => __( 'Tools', 'blurt' ), 'post_name' => 'tools', 'post_type' => 'page', 'post_status' => 'publish', 'post_content' => '', 'post_author' => get_current_user_id() ? get_current_user_id() : 1, ) ); } add_action( 'after_switch_theme', 'blurt_create_tools_page' ); /** * Check if the current page is the Tools page. * * @return bool True if viewing the tools page. */ function blurt_is_tools_page() { return is_page( 'tools' ); } /** * Include pending comments in comment queries for admins. * * Filters both the main comment query and the top-level count query * used by comments_template() so that unapproved comments are fetched. * * @param array $args Comment query arguments. * @return array Modified arguments. */ function blurt_include_pending_comments( $args ) { if ( current_user_can( 'moderate_comments' ) ) { $args['status'] = 'all'; } return $args; } add_filter( 'comments_template_query_args', 'blurt_include_pending_comments' ); add_filter( 'comments_template_top_level_query_args', 'blurt_include_pending_comments' ); /** * Also filter the pre_get_comments query to include pending for admins. * * This catches comment queries that bypass comments_template filters, * such as direct WP_Comment_Query calls on single post views. * * @param WP_Comment_Query $comment_query The comment query instance. */ function blurt_pre_get_comments_pending( $comment_query ) { if ( is_admin() ) { return; } if ( current_user_can( 'moderate_comments' ) && is_singular() ) { $comment_query->query_vars['status'] = 'all'; } } add_action( 'pre_get_comments', 'blurt_pre_get_comments_pending' ); /** * AJAX: Moderate a comment (approve, spam, or delete). */ function blurt_moderate_comment() { check_ajax_referer( 'blurt_nonce', 'nonce' ); if ( ! is_user_logged_in() || ! current_user_can( 'moderate_comments' ) ) { wp_send_json_error( 'Insufficient permissions.' ); } $comment_id = isset( $_POST['comment_id'] ) ? absint( $_POST['comment_id'] ) : 0; $action = isset( $_POST['mod_action'] ) ? sanitize_key( $_POST['mod_action'] ) : ''; if ( ! $comment_id || ! get_comment( $comment_id ) ) { wp_send_json_error( 'Invalid comment.' ); } if ( ! in_array( $action, array( 'approve', 'spam', 'delete' ), true ) ) { wp_send_json_error( 'Invalid action.' ); } if ( 'approve' === $action ) { wp_set_comment_status( $comment_id, 'approve' ); } elseif ( 'spam' === $action ) { wp_spam_comment( $comment_id ); } elseif ( 'delete' === $action ) { wp_trash_comment( $comment_id ); } wp_send_json_success( array( 'action' => $action, 'comment_id' => $comment_id ) ); } add_action( 'wp_ajax_blurt_moderate_comment', 'blurt_moderate_comment' ); /** * Disable Jetpack Post Flair (sharing buttons, likes, ratings) entirely. * * Blurt has its own interaction stubs in the post card footer, so the * default wp.com post-flair bar is redundant and visually out of place. */ add_filter( 'post_flair_disable', '__return_true' ); /** * Check whether the current site is on a free (unpaid) plan. * * Used to gate the promotional card so it only appears on free sites, * matching the same logic as the WordPress.com marketing bar. * * @return bool True if the site is free (no paid plan). */ function blurt_is_free_site() { return ! (bool) get_blog_option( get_current_blog_id(), 'bundle_upgrade' ); } /** * Disable Jetpack Carousel. * * Blurt has its own image lightbox. The Carousel overlay conflicts * with it and its close button is covered by the marketing bar. */ add_filter( 'jp_carousel_maybe_disable', '__return_true' ); /** * Disable WordPress emoji detection script. * * Modern browsers render emoji natively. The wp-emoji-release.min.js * script and related inline styles are unnecessary overhead. */ function blurt_disable_emojis() { remove_action( 'wp_head', 'print_emoji_detection_script', 7 ); remove_action( 'wp_print_styles', 'print_emoji_styles' ); remove_filter( 'the_content_feed', 'wp_staticize_emoji' ); remove_filter( 'comment_text_rss', 'wp_staticize_emoji' ); remove_filter( 'wp_mail', 'wp_staticize_emoji_for_email' ); } add_action( 'init', 'blurt_disable_emojis' ); /** * Disable the Verbum comment editor so Blurt uses its own simple textarea form. * * Verbum (loaded by jetpack-mu-wpcom) aggressively replaces the comment form * with a rich editor. We undo those hooks and dequeue its assets so the theme's * own compose-style comment form in comments.php is used instead. */ function blurt_disable_verbum() { // Bail early if Verbum class doesn't exist (e.g. non-wpcom installs). if ( ! class_exists( 'Automattic\Jetpack\Jetpack_Mu_Wpcom\Verbum_Comments' ) && ! class_exists( 'Verbum_Comments' ) ) { return; } // Remove Verbum's comment_form_field_comment filter that returns false. remove_filter( 'comment_form_field_comment', '__return_false', 11 ); // Remove Verbum's logged-in-as blanking. remove_filter( 'comment_form_logged_in', '__return_empty_string' ); // Dequeue Verbum scripts and styles so the editor wrapper never loads. add_action( 'wp_enqueue_scripts', function () { wp_dequeue_script( 'verbum-settings' ); wp_dequeue_script( 'verbum' ); wp_dequeue_style( 'verbum' ); wp_dequeue_script( 'verbum-editor' ); wp_dequeue_style( 'verbum-editor' ); }, 999 ); // Remove Verbum's render element that injects the editor wrapper div. global $wp_filter; foreach ( array( 'comment_form_submit_field', 'comment_form_must_log_in_after' ) as $hook ) { if ( isset( $wp_filter[ $hook ] ) ) { foreach ( $wp_filter[ $hook ]->callbacks as $priority => $callbacks ) { foreach ( $callbacks as $key => $callback ) { if ( is_array( $callback['function'] ) && is_object( $callback['function'][0] ) && 'verbum_render_element' === $callback['function'][1] ) { remove_filter( $hook, $callback['function'], $priority ); } } } } } // Remove Verbum's comment_form_defaults override so our args are respected. if ( isset( $wp_filter['comment_form_defaults'] ) ) { foreach ( $wp_filter['comment_form_defaults']->callbacks as $priority => $callbacks ) { foreach ( $callbacks as $key => $callback ) { if ( is_array( $callback['function'] ) && is_object( $callback['function'][0] ) && 'comment_form_defaults' === $callback['function'][1] ) { remove_filter( 'comment_form_defaults', $callback['function'], $priority ); } } } } } add_action( 'wp', 'blurt_disable_verbum' ); /** * Auto-assign hashtags in post content as WordPress tags. * * When a post is published, any #word patterns in the content are * extracted and assigned as post_tag terms. This ensures the tag * archive pages exist and blurt_linkify_hashtags() can generate * valid URLs. * * @param int $post_id Post ID. * @param WP_Post $post Post object. * @param bool $update Whether this is an update. */ function blurt_auto_assign_hashtags( $post_id, $post, $update ) { if ( 'post' !== $post->post_type || 'publish' !== $post->post_status ) { return; } if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; } // Skip reposts — their content belongs to the original post. if ( get_post_meta( $post_id, '_blurt_repost_of', true ) ) { return; } $content = wp_strip_all_tags( $post->post_content ); if ( preg_match_all( '/(?<=\s|^)#([a-zA-Z0-9_\-]+)/u', $content, $matches ) ) { $tags = array_unique( array_map( 'sanitize_title', $matches[1] ) ); $tags = array_filter( $tags ); if ( $tags ) { wp_set_post_tags( $post_id, $tags, true ); } } } add_action( 'save_post', 'blurt_auto_assign_hashtags', 10, 3 ); /** * Make URLs in post and comment content clickable. * * WordPress core's make_clickable() converts bare URLs and email addresses * into anchor tags. Hooked before hashtag linkification so that URL-detection * runs on plain text, and hashtag links are added afterward. */ add_filter( 'the_content', 'make_clickable', 9 ); add_filter( 'comment_text', 'make_clickable', 9 ); /** * Convert #hashtags in post content to clickable tag archive links. * * Matches #word patterns that are not already inside an HTML tag or anchor, * and replaces them with a link to the corresponding WordPress tag page. * * All hashtags in the content are extracted first and batch-fetched in a * single get_terms() query, avoiding one DB hit per hashtag. A static * cache retains results across multiple calls within the same request. * * @param string $content The post content. * @return string Content with hashtags linked. */ function blurt_linkify_hashtags( $content ) { // Static cache of slug => WP_Term (or false) persists across calls. static $term_cache = array(); $pattern = '/(?<=\s|^|>)#([a-zA-Z0-9_\-]+)(?=[\s.,;:!?\'")<\]|$])/u'; // Extract all hashtag slugs from this content. if ( ! preg_match_all( $pattern, $content, $all_matches ) ) { return $content; } $slugs_to_fetch = array(); foreach ( $all_matches[1] as $raw_tag ) { $slug = sanitize_title( $raw_tag ); if ( $slug && ! isset( $term_cache[ $slug ] ) ) { $slugs_to_fetch[] = $slug; } } // Batch-fetch any uncached slugs in a single query. if ( $slugs_to_fetch ) { $terms = get_terms( array( 'taxonomy' => 'post_tag', 'slug' => array_unique( $slugs_to_fetch ), 'hide_empty' => false, ) ); // Index fetched terms by slug. $found = array(); if ( ! is_wp_error( $terms ) ) { foreach ( $terms as $term ) { $found[ $term->slug ] = $term; } } // Populate cache: found terms get the object, misses get false. foreach ( array_unique( $slugs_to_fetch ) as $slug ) { $term_cache[ $slug ] = isset( $found[ $slug ] ) ? $found[ $slug ] : false; } } // Fallback URL pieces (cached across calls via static). static $tag_base_url = null; if ( null === $tag_base_url ) { $tag_base = get_option( 'tag_base' ); $tag_base_url = home_url( '/' . ( $tag_base ? $tag_base : 'tag' ) . '/' ); } return preg_replace_callback( $pattern, function ( $matches ) use ( &$term_cache, $tag_base_url ) { $tag_slug = sanitize_title( $matches[1] ); $tag = isset( $term_cache[ $tag_slug ] ) ? $term_cache[ $tag_slug ] : false; if ( $tag ) { $url = get_tag_link( $tag->term_id ); } else { $url = $tag_base_url . rawurlencode( $tag_slug ) . '/'; } return sprintf( '#%s', esc_url( $url ), esc_html( $matches[1] ) ); }, $content ); } add_filter( 'the_content', 'blurt_linkify_hashtags', 20 ); // Priority 31 so it runs after wpautop (30) — the

tags ensure // the regex lookahead matches hashtags at the end of a comment. add_filter( 'comment_text', 'blurt_linkify_hashtags', 31 ); /** * Strip Gutenberg inline styles and color classes from post content. * * The block editor injects inline style attributes (e.g. * style="color:var(--wp--preset--color--contrast)") and utility classes * (e.g. has-text-color, has-contrast-color) on paragraph and wrapper * elements. These inline styles have higher specificity than any CSS * selector, so they override the theme's own color rules. * * Since Blurt controls all its own typography and color via style.css, * we strip these from the rendered content so the theme styles apply * cleanly. * * @param string $content The post content HTML. * @return string Cleaned content. */ function blurt_strip_block_inline_styles( $content ) { // Remove inline style attributes entirely. $content = preg_replace( '/\s+style="[^"]*"/i', '', $content ); // Remove Gutenberg color/layout utility classes. $content = preg_replace_callback( '/class="([^"]*)"/i', function ( $matches ) { $classes = preg_replace( '/\b(has-[\w-]+-color|has-text-color|has-background|has-link-color|has-[\w-]+-background-color|wp-elements-[\w-]+)\b/', '', $matches[1] ); $classes = preg_replace( '/\s{2,}/', ' ', trim( $classes ) ); return 'class="' . $classes . '"'; }, $content ); return $content; } add_filter( 'the_content', 'blurt_strip_block_inline_styles', 999 ); add_filter( 'comment_text', 'blurt_strip_block_inline_styles', 999 ); /** * Strip [gallery] shortcodes from post content. * * Blurt displays post images via blurt_get_post_images() in the * template gallery grid. Without this filter the gallery shortcode * is also rendered inline by the_content (and enhanced by Jetpack * Carousel), causing duplicate image sets. * * @param string $content The post content. * @return string Content without gallery shortcodes. */ function blurt_strip_gallery_shortcode( $content ) { return preg_replace( '/\[gallery[^\]]*\]/', '', $content ); } add_filter( 'the_content', 'blurt_strip_gallery_shortcode', 5 ); /** * Enqueue scripts and styles. */ function blurt_scripts() { wp_enqueue_style( 'blurt-style', get_stylesheet_uri(), array(), wp_get_theme()->get( 'Version' ) ); wp_enqueue_script( 'blurt-script', get_template_directory_uri() . '/js/blurt.js', array(), wp_get_theme()->get( 'Version' ), array( 'strategy' => 'defer' ) ); // Determine end-of-timeline message based on page context. $end_message = __( 'You\u2019ve reached the beginning of the timeline.', 'blurt' ); if ( is_tag() ) { $end_message = sprintf( /* translators: %s: tag name */ __( 'No more posts tagged #%s', 'blurt' ), single_tag_title( '', false ) ); } elseif ( is_search() ) { $end_message = __( 'No more results found.', 'blurt' ); } elseif ( is_author() ) { $tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'posts'; $author_name = get_queried_object() ? get_queried_object()->display_name : ''; switch ( $tab ) { case 'reposts': $end_message = sprintf( __( 'No more reposts from %s.', 'blurt' ), $author_name ); break; case 'comments': $end_message = sprintf( __( 'No more comments from %s.', 'blurt' ), $author_name ); break; case 'media': $end_message = sprintf( __( 'No more media posts from %s.', 'blurt' ), $author_name ); break; case 'likes': $end_message = sprintf( __( 'No more liked posts from %s.', 'blurt' ), $author_name ); break; default: $end_message = sprintf( __( 'No more posts from %s.', 'blurt' ), $author_name ); break; } } elseif ( is_category() || is_archive() ) { $end_message = __( 'No more posts in this archive.', 'blurt' ); } wp_localize_script( 'blurt-script', 'blurtData', array( 'ajax_url' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'blurt_nonce' ), 'poll_interval' => 13000, 'user_logged_in' => is_user_logged_in(), 'current_user_id' => get_current_user_id(), 'can_upload' => is_user_logged_in() && current_user_can( 'upload_files' ), 'max_post_images' => 4, 'max_comment_images' => 1, 'end_message' => $end_message, 'i18n' => array( 'repost' => __( 'Repost', 'blurt' ), 'undo_repost' => __( 'Undo repost', 'blurt' ), 'quote_repost' => __( 'Quote repost', 'blurt' ), /* translators: shown while an image is being uploaded */ 'uploading' => __( 'Uploading…', 'blurt' ), 'upload_error' => __( 'Upload failed. Please try again.', 'blurt' ), 'remove_image' => __( 'Remove image', 'blurt' ), ), ) ); // Enqueue import scripts on the tools page. if ( blurt_is_tools_page() && current_user_can( 'manage_options' ) ) { wp_enqueue_script( 'jszip', get_template_directory_uri() . '/js/jszip.min.js', array(), '3.10.1', true ); wp_enqueue_script( 'fflate', get_template_directory_uri() . '/js/fflate.min.js', array(), '0.8.2', true ); wp_enqueue_script( 'blurt-import', get_template_directory_uri() . '/js/import.js', array( 'jszip', 'fflate' ), wp_get_theme()->get( 'Version' ), true ); } } add_action( 'wp_enqueue_scripts', 'blurt_scripts' ); /** * Dequeue block editor and global styles that this classic theme does not need. * * WordPress core enqueues wp-block-library, global-styles, and classic-theme-styles * on the frontend even for classic themes. These inject color declarations on p, a, * and other elements that compete with theme styles. Since Blurt never uses block * markup, these stylesheets are unnecessary and cause text to appear faint. */ /** * Remove the default WordPress.com comment likes button. * * Blurt has its own heart/like button on comments, so the built-in * "comment-likes comment-not-liked" widget is redundant. */ function blurt_disable_comment_likes() { remove_filter( 'comment_text', 'comment_like_button', 12 ); } add_action( 'wp', 'blurt_disable_comment_likes' ); /** * Disable the WordPress.com actionbar. * * Blurt has its own compose, like, and follow UI so the floating * actionbar is redundant. Remove both the asset enqueue and the * footer HTML injection. */ function blurt_disable_actionbar() { remove_action( 'wp_enqueue_scripts', 'wpcom_actionbar_enqueue_scripts', 101 ); remove_action( 'wp_footer', 'wpcom_actionbar_footer' ); } add_action( 'init', 'blurt_disable_actionbar' ); function blurt_dequeue_block_styles() { wp_dequeue_style( 'wp-block-library' ); wp_dequeue_style( 'wp-block-library-theme' ); wp_dequeue_style( 'global-styles' ); wp_dequeue_style( 'classic-theme-styles' ); wp_dequeue_style( 'free-site-marketing-bar' ); wp_dequeue_style( 'wpcom-core-compat-playlist-styles' ); wp_dequeue_style( 'wpcom-bbpress2-staff-css' ); wp_dequeue_style( 'reblogging' ); wp_dequeue_style( 'geo-location-flair' ); wp_dequeue_style( 'h4-global' ); } add_action( 'wp_enqueue_scripts', 'blurt_dequeue_block_styles', 100 ); /** * Register widget areas. */ function blurt_widgets_init() { register_sidebar( array( 'name' => esc_html__( 'Right Sidebar', 'blurt' ), 'id' => 'sidebar-right', 'description' => esc_html__( 'Add widgets here for the right sidebar.', 'blurt' ), 'before_widget' => '

', 'after_widget' => '
', 'before_title' => '

', 'after_title' => '

', ) ); } add_action( 'widgets_init', 'blurt_widgets_init' ); /** * AJAX: Poll for new updates. * * Accepts a 'since' timestamp and returns count of newer posts. */ function blurt_poll_updates() { // Use non-fatal nonce check — polling is read-only and cached pages // may have stale nonces for logged-out visitors. check_ajax_referer( 'blurt_nonce', 'nonce', false ); $since_id = isset( $_POST['since_id'] ) ? absint( $_POST['since_id'] ) : 0; if ( 0 === $since_id ) { wp_send_json_success( array( 'count' => 0, 'latest_id' => 0, ) ); } // Find published posts with an ID greater than the latest visible one. global $wpdb; $results = $wpdb->get_results( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'post' AND post_status = 'publish' AND ID > %d ORDER BY ID DESC LIMIT 100", $since_id ) ); $count = count( $results ); $latest_id = $since_id; if ( $results ) { $latest_id = (int) $results[0]->ID; } wp_send_json_success( array( 'count' => $count, 'latest_id' => $latest_id, ) ); } add_action( 'wp_ajax_blurt_poll_updates', 'blurt_poll_updates' ); add_action( 'wp_ajax_nopriv_blurt_poll_updates', 'blurt_poll_updates' ); /** * AJAX: Fetch rendered HTML for posts newer than a given ID. * * Returns post card HTML for all published posts with an ID greater * than the supplied since_id, ordered newest first. */ function blurt_fetch_new_posts() { check_ajax_referer( 'blurt_nonce', 'nonce', false ); $since_id = isset( $_POST['since_id'] ) ? absint( $_POST['since_id'] ) : 0; if ( 0 === $since_id ) { wp_send_json_success( array( 'html' => '' ) ); } global $wpdb; $post_ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'post' AND post_status = 'publish' AND ID > %d ORDER BY ID DESC LIMIT 50", $since_id ) ); if ( ! $post_ids ) { wp_send_json_success( array( 'html' => '' ) ); } $html = ''; foreach ( $post_ids as $pid ) { $html .= blurt_render_post_card( (int) $pid ); } wp_send_json_success( array( 'html' => $html ) ); } add_action( 'wp_ajax_blurt_fetch_new_posts', 'blurt_fetch_new_posts' ); add_action( 'wp_ajax_nopriv_blurt_fetch_new_posts', 'blurt_fetch_new_posts' ); /** * AJAX: Import a batch of items from an external service. * * Accepts a JSON-encoded array of normalized items and creates * posts/comments for each. Returns a mapping of source IDs to * new WordPress post/comment IDs. */ function blurt_import_items() { check_ajax_referer( 'blurt_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Insufficient permissions.' ); } $items_json = isset( $_POST['items'] ) ? wp_unslash( $_POST['items'] ) : '[]'; $items = json_decode( $items_json, true ); if ( ! is_array( $items ) ) { wp_send_json_error( 'Invalid items data.' ); } $mapping = array(); $stats = array( 'posts' => 0, 'replies' => 0, 'media' => 0, 'skipped' => 0, ); foreach ( $items as $item ) { $source_id = isset( $item['source_id'] ) ? sanitize_text_field( $item['source_id'] ) : ''; $type = isset( $item['type'] ) ? sanitize_key( $item['type'] ) : 'post'; $text = isset( $item['text'] ) ? sanitize_textarea_field( $item['text'] ) : ''; $date = isset( $item['created_at'] ) ? sanitize_text_field( $item['created_at'] ) : ''; $media_ids = isset( $item['media_ids'] ) ? array_map( 'absint', (array) $item['media_ids'] ) : array(); $parent_wp = isset( $item['parent_wp_id'] ) ? absint( $item['parent_wp_id'] ) : 0; $reply_source_id = isset( $item['reply_to_source_id'] ) ? sanitize_text_field( $item['reply_to_source_id'] ) : ''; $repost_wp = isset( $item['repost_of_wp_id'] ) ? absint( $item['repost_of_wp_id'] ) : 0; $repost_source_id = isset( $item['repost_of_source_id'] ) ? sanitize_text_field( $item['repost_of_source_id'] ) : ''; $is_quote = ! empty( $item['is_quote'] ); // External author fields (for imported reposts of non-site users). $ext_name = isset( $item['external_author_name'] ) ? sanitize_text_field( $item['external_author_name'] ) : ''; $ext_handle = isset( $item['external_author_handle'] ) ? sanitize_text_field( $item['external_author_handle'] ) : ''; $ext_avatar = isset( $item['external_author_avatar'] ) ? esc_url_raw( $item['external_author_avatar'] ) : ''; $ext_url = isset( $item['external_author_url'] ) ? esc_url_raw( $item['external_author_url'] ) : ''; $ext_post_url = isset( $item['external_post_url'] ) ? esc_url_raw( $item['external_post_url'] ) : ''; // Duplicate detection: check if this source_id was already imported. // Runs before empty-content check so duplicates populate the mapping. if ( $source_id ) { global $wpdb; $existing_id = $wpdb->get_var( $wpdb->prepare( "SELECT pm.post_id FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = '_blurt_import_source_id' AND pm.meta_value = %s AND p.post_status != 'trash' LIMIT 1", $source_id ) ); if ( $existing_id ) { $mapping[ $source_id ] = array( 'type' => 'post', 'id' => (int) $existing_id, ); $stats['posts']++; continue; } // Also check comments for reply duplicates. if ( 'reply' === $type ) { $existing_comment_id = $wpdb->get_var( $wpdb->prepare( "SELECT comment_id FROM {$wpdb->commentmeta} WHERE meta_key = '_blurt_import_source_id' AND meta_value = %s LIMIT 1", $source_id ) ); if ( $existing_comment_id ) { $mapping[ $source_id ] = array( 'type' => 'comment', 'id' => (int) $existing_comment_id, ); $stats['posts']++; continue; } } } // Skip items with no text and no media (nothing to import). if ( '' === $text && empty( $media_ids ) && 'repost' !== $type ) { $stats['skipped']++; continue; } $post_date = $date ? gmdate( 'Y-m-d H:i:s', strtotime( $date ) ) : current_time( 'mysql', true ); $post_date_gmt = $post_date; // Resolve reply parent within the same batch. if ( 'reply' === $type && ! $parent_wp && $reply_source_id && isset( $mapping[ $reply_source_id ] ) ) { $parent_wp = $mapping[ $reply_source_id ]['id']; } // If reply parent still can't be resolved, import as a standalone post. if ( 'reply' === $type && ! $parent_wp ) { $type = 'post'; } if ( 'reply' === $type && $parent_wp ) { // Import as a comment on the parent post. $user = wp_get_current_user(); $comment_id = wp_new_comment( array( 'comment_post_ID' => $parent_wp, 'comment_content' => $text, 'comment_parent' => 0, 'user_id' => $user->ID, 'comment_author' => $user->display_name, 'comment_author_email' => $user->user_email, 'comment_date_gmt' => $post_date_gmt, ), true ); if ( ! is_wp_error( $comment_id ) ) { if ( $source_id ) { update_comment_meta( $comment_id, '_blurt_import_source_id', $source_id ); } $mapping[ $source_id ] = array( 'type' => 'comment', 'id' => $comment_id, ); $stats['replies']++; } else { $stats['skipped']++; } continue; } // Build post title and content. For media-only posts (no text), // use a placeholder so wp_insert_post doesn't reject empty content. $post_title = wp_trim_words( wp_strip_all_tags( $text ), 10, '...' ); $post_content = $text; if ( '' === $post_content && ! empty( $media_ids ) ) { $post_title = __( 'Photo', 'blurt' ); $post_content = ' '; } // Create as a post. $post_id = wp_insert_post( array( 'post_title' => $post_title, 'post_content' => $post_content, 'post_status' => 'publish', 'post_author' => get_current_user_id(), 'post_date_gmt' => $post_date_gmt, 'post_date' => get_date_from_gmt( $post_date_gmt ), ) ); if ( ! $post_id || is_wp_error( $post_id ) ) { $stats['skipped']++; continue; } // Store import source ID for duplicate detection. if ( $source_id ) { update_post_meta( $post_id, '_blurt_import_source_id', $source_id ); } // Attach media. if ( $media_ids ) { blurt_attach_images_to_post( $post_id, $media_ids ); $stats['media'] += count( $media_ids ); } // Store external author meta if present. if ( $ext_name ) { update_post_meta( $post_id, '_blurt_external_author_name', $ext_name ); if ( $ext_handle ) { update_post_meta( $post_id, '_blurt_external_author_handle', $ext_handle ); } if ( $ext_avatar ) { update_post_meta( $post_id, '_blurt_external_author_avatar', $ext_avatar ); } if ( $ext_url ) { update_post_meta( $post_id, '_blurt_external_author_url', $ext_url ); } if ( $ext_post_url ) { update_post_meta( $post_id, '_blurt_external_post_url', $ext_post_url ); } } // Handle reposts — resolve within-batch references first. if ( 'repost' === $type ) { if ( ! $repost_wp && $repost_source_id && isset( $mapping[ $repost_source_id ] ) ) { $repost_wp = $mapping[ $repost_source_id ]['id']; } if ( $repost_wp ) { update_post_meta( $post_id, '_blurt_repost_of', $repost_wp ); if ( $is_quote ) { update_post_meta( $post_id, '_blurt_is_quote_repost', '1' ); } } } $mapping[ $source_id ] = array( 'type' => 'post', 'id' => $post_id, ); $stats['posts']++; } wp_send_json_success( array( 'mapping' => $mapping, 'stats' => $stats, ) ); } add_action( 'wp_ajax_blurt_import_items', 'blurt_import_items' ); /** * AJAX: Proxy-fetch a remote image and upload it as an attachment. * * Used during imports to bypass CORS restrictions when downloading * images from external CDNs (e.g. Bluesky's cdn.bsky.app). */ function blurt_import_fetch_image() { check_ajax_referer( 'blurt_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Insufficient permissions.' ); } $url = isset( $_POST['url'] ) ? esc_url_raw( $_POST['url'] ) : ''; if ( ! $url ) { wp_send_json_error( 'No URL provided.' ); } // Verify the URL uses HTTPS. $scheme = wp_parse_url( $url, PHP_URL_SCHEME ); if ( 'https' !== $scheme ) { wp_send_json_error( 'Only HTTPS URLs are allowed.' ); } require_once ABSPATH . 'wp-admin/includes/image.php'; require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; // Download the file to a temp location. $tmp_file = download_url( $url, 30 ); if ( is_wp_error( $tmp_file ) ) { wp_send_json_error( 'Download failed: ' . $tmp_file->get_error_message() ); } $filename = isset( $_POST['filename'] ) ? sanitize_file_name( $_POST['filename'] ) : basename( wp_parse_url( $url, PHP_URL_PATH ) ); $file_array = array( 'name' => $filename, 'tmp_name' => $tmp_file, ); $attachment_id = media_handle_sideload( $file_array, 0 ); if ( is_wp_error( $attachment_id ) ) { @unlink( $tmp_file ); wp_send_json_error( 'Upload failed: ' . $attachment_id->get_error_message() ); } // Save alt text and use as caption if no caption exists. $alt = isset( $_POST['alt'] ) ? sanitize_text_field( $_POST['alt'] ) : ''; if ( $alt ) { update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt ); $attachment = get_post( $attachment_id ); if ( $attachment && '' === $attachment->post_excerpt ) { wp_update_post( array( 'ID' => $attachment_id, 'post_excerpt' => $alt, ) ); } } $thumb_url = wp_get_attachment_image_url( $attachment_id, 'medium' ); $full_url = wp_get_attachment_image_url( $attachment_id, 'full' ); wp_send_json_success( array( 'id' => $attachment_id, 'url' => $thumb_url, 'full_url' => $full_url ? $full_url : $thumb_url, ) ); } add_action( 'wp_ajax_blurt_import_fetch_image', 'blurt_import_fetch_image' ); /** * AJAX: Search for tags matching a prefix. * * Returns up to 8 tags whose name starts with the given term, * ordered by post count descending. */ function blurt_search_tags() { check_ajax_referer( 'blurt_nonce', 'nonce' ); $term = isset( $_POST['term'] ) ? sanitize_text_field( $_POST['term'] ) : ''; if ( '' === $term ) { wp_send_json_success( array( 'tags' => array() ) ); } $tags = get_tags( array( 'search' => $term, 'orderby' => 'count', 'order' => 'DESC', 'number' => 8, 'hide_empty' => false, ) ); if ( ! $tags || is_wp_error( $tags ) ) { wp_send_json_success( array( 'tags' => array() ) ); } $results = array(); foreach ( $tags as $tag ) { // Only include tags whose name starts with the typed prefix. if ( 0 === stripos( $tag->name, $term ) ) { $results[] = array( 'name' => $tag->name, 'count' => (int) $tag->count, ); } } wp_send_json_success( array( 'tags' => $results ) ); } add_action( 'wp_ajax_blurt_search_tags', 'blurt_search_tags' ); add_action( 'wp_ajax_nopriv_blurt_search_tags', 'blurt_search_tags' ); /** * AJAX: Search for users matching a term. * * Returns up to 8 users whose login or display name matches, * with avatar URLs for the dropdown. */ function blurt_search_users() { check_ajax_referer( 'blurt_nonce', 'nonce' ); $term = isset( $_POST['term'] ) ? sanitize_text_field( $_POST['term'] ) : ''; if ( '' === $term ) { wp_send_json_success( array( 'users' => array() ) ); } $user_query = new WP_User_Query( array( 'search' => '*' . $term . '*', 'search_columns' => array( 'user_login', 'display_name' ), 'number' => 8, 'orderby' => 'display_name', 'order' => 'ASC', ) ); $results = array(); if ( $user_query->get_results() ) { foreach ( $user_query->get_results() as $user ) { $results[] = array( 'login' => $user->user_login, 'display_name' => $user->display_name, 'avatar_url' => blurt_get_avatar_url( $user->ID, 32 ), ); } } wp_send_json_success( array( 'users' => $results ) ); } add_action( 'wp_ajax_blurt_search_users', 'blurt_search_users' ); add_action( 'wp_ajax_nopriv_blurt_search_users', 'blurt_search_users' ); /** * Convert @mentions in content to clickable profile links. * * Matches @username patterns that are not already inside an HTML tag * or anchor, and replaces them with a link to the user's author page. * * @param string $content The content. * @return string Content with mentions linked. */ function blurt_linkify_mentions( $content ) { return preg_replace_callback( '/(?<=\s|^|>)@([a-zA-Z0-9_\-\.]+)(?=[\s.,;:!?\'")<\]|$])/u', function ( $matches ) { $user = get_user_by( 'login', $matches[1] ); if ( ! $user ) { return $matches[0]; } $url = get_author_posts_url( $user->ID ); return sprintf( '@%s', esc_url( $url ), esc_html( $matches[1] ) ); }, $content ); } add_filter( 'the_content', 'blurt_linkify_mentions', 21 ); add_filter( 'comment_text', 'blurt_linkify_mentions', 32 ); /** * Render a single post card for the timeline. * * Returns the same HTML that the index.php loop produces so that * AJAX-created posts can be injected without a page reload. * * @param int $post_id Post ID. * @return string Post card HTML. */ function blurt_render_post_card( $post_id ) { $post = get_post( $post_id ); if ( ! $post ) { return ''; } $original_id = blurt_is_repost( $post ); $is_quote = $original_id && get_post_meta( $post->ID, '_blurt_is_quote_repost', true ); $comment_repost_id = get_post_meta( $post->ID, '_blurt_repost_of_comment', true ); $is_comment_repost = (bool) $comment_repost_id; $repost_comment = $is_comment_repost ? get_comment( $comment_repost_id ) : null; $is_any_repost = $original_id || $is_comment_repost; $display_post = ( $original_id && ! $is_quote ) ? get_post( $original_id ) : $post; $display_author = (int) $display_post->post_author; $action_post_id = ( $original_id && ! $is_quote ) ? $original_id : $post->ID; // For direct comment reposts (not quotes), show the original commenter. $is_direct_comment_repost = $is_comment_repost && ! get_post_meta( $post->ID, '_blurt_is_quote_repost', true ); if ( $is_direct_comment_repost && $repost_comment && $repost_comment->user_id ) { $display_author = (int) $repost_comment->user_id; } // Check if the display post has an external (non-WordPress) author. $ext_author_name = get_post_meta( $display_post->ID, '_blurt_external_author_name', true ); $ext_author_handle = get_post_meta( $display_post->ID, '_blurt_external_author_handle', true ); $ext_author_avatar = get_post_meta( $display_post->ID, '_blurt_external_author_avatar', true ); $ext_author_url = get_post_meta( $display_post->ID, '_blurt_external_author_url', true ); $ext_post_url = get_post_meta( $display_post->ID, '_blurt_external_post_url', true ); $is_external = (bool) $ext_author_name; $repost_data = blurt_get_repost_data( $action_post_id ); $like_data = blurt_get_like_data( $action_post_id ); ob_start(); ?>
post_author ) ) ); ?>
<?php echo esc_attr( $ext_author_name ); ?> user_id ? $repost_comment->user_id : 0; ?> <?php echo esc_attr( get_comment_author( $repost_comment ) ); ?> <?php echo esc_attr( get_the_author_meta( 'display_name', $display_author ) ); ?>
@ > user_id ) : ?> @user_id ) ); ?> @
ID ) ) : ?>
post_content ); ?>
post_author; ?>
@
post_content ), 40, '...' ) ); ?>
ID, '_blurt_is_quote_repost', true ); if ( $is_comment_quote && $repost_comment ) : ?>
user_id ) : ?> @user_id ) ); ?>
comment_content ), 40, '...' ) ); ?>
comment_post_ID ); $post_author_login = $post ? get_the_author_meta( 'user_login', $post->post_author ) : ''; $comment_like = blurt_get_comment_like_data( $comment->comment_ID, $comment->comment_post_ID ); $comment_image_id = blurt_get_comment_image( $comment->comment_ID ); ob_start(); ?>
  • <?php echo esc_attr( get_comment_author( $comment ) ); ?>
    user_id ) : ?> @user_id ) ); ?>
    post_excerpt : ''; if ( ! $cm_img_caption && $cm_img_alt ) { $cm_img_caption = $cm_img_alt; } ?>
    'blurt-post-image' ) ); ?>
    comment_ID ); ?>
    500 ) { wp_send_json_error( 'Invalid content.' ); } $quote_of = isset( $_POST['blurt_quote_of'] ) ? absint( $_POST['blurt_quote_of'] ) : 0; $quote_of_comment = isset( $_POST['blurt_quote_of_comment'] ) ? absint( $_POST['blurt_quote_of_comment'] ) : 0; $post_id = wp_insert_post( array( 'post_title' => wp_trim_words( $content, 10, '...' ), 'post_content' => $content, 'post_status' => 'publish', 'post_author' => get_current_user_id(), ) ); if ( ! $post_id || is_wp_error( $post_id ) ) { wp_send_json_error( 'Failed to create post.' ); } if ( $quote_of && get_post( $quote_of ) ) { update_post_meta( $post_id, '_blurt_repost_of', $quote_of ); update_post_meta( $post_id, '_blurt_is_quote_repost', '1' ); } if ( $quote_of_comment && get_comment( $quote_of_comment ) ) { update_post_meta( $post_id, '_blurt_repost_of_comment', $quote_of_comment ); update_post_meta( $post_id, '_blurt_is_quote_repost', '1' ); } if ( ! empty( $_POST['blurt_images'] ) ) { $image_ids = array_map( 'absint', explode( ',', sanitize_text_field( $_POST['blurt_images'] ) ) ); $image_ids = array_filter( $image_ids ); blurt_attach_images_to_post( $post_id, $image_ids ); } wp_send_json_success( array( 'html' => blurt_render_post_card( $post_id ), 'post_id' => $post_id, ) ); } add_action( 'wp_ajax_blurt_ajax_compose', 'blurt_ajax_compose' ); /** * AJAX: Create a new comment and return its rendered card HTML. */ function blurt_ajax_comment() { check_ajax_referer( 'blurt_nonce', 'nonce' ); $post_id = isset( $_POST['comment_post_ID'] ) ? absint( $_POST['comment_post_ID'] ) : 0; $parent = isset( $_POST['comment_parent'] ) ? absint( $_POST['comment_parent'] ) : 0; $content = isset( $_POST['comment'] ) ? sanitize_textarea_field( $_POST['comment'] ) : ''; if ( ! $post_id || ! get_post( $post_id ) || '' === $content ) { wp_send_json_error( 'Invalid data.' ); } $comment_data = array( 'comment_post_ID' => $post_id, 'comment_content' => $content, 'comment_parent' => $parent, ); if ( is_user_logged_in() ) { $user = wp_get_current_user(); $comment_data['user_id'] = $user->ID; $comment_data['comment_author'] = $user->display_name; $comment_data['comment_author_email'] = $user->user_email; $comment_data['comment_author_url'] = $user->user_url; } else { $author = isset( $_POST['author'] ) ? sanitize_text_field( $_POST['author'] ) : ''; $email = isset( $_POST['email'] ) ? sanitize_email( $_POST['email'] ) : ''; if ( '' === $author || '' === $email ) { wp_send_json_error( 'Name and email are required.' ); } $comment_data['comment_author'] = $author; $comment_data['comment_author_email'] = $email; } $comment_id = wp_new_comment( $comment_data, true ); if ( is_wp_error( $comment_id ) ) { wp_send_json_error( $comment_id->get_error_message() ); } $comment = get_comment( $comment_id ); $status = $comment ? $comment->comment_approved : '1'; wp_send_json_success( array( 'html' => blurt_render_comment_card( $comment_id ), 'comment_id' => $comment_id, 'status' => $status, ) ); } add_action( 'wp_ajax_blurt_ajax_comment', 'blurt_ajax_comment' ); add_action( 'wp_ajax_nopriv_blurt_ajax_comment', 'blurt_ajax_comment' ); /** * AJAX: Repost a comment as a new post. * * Creates a new post containing the comment text, attributed to * the current user. */ /** * Get repost data for a comment. * * Uses two small queries instead of one unbounded query: * 1. A count query (posts_per_page=1 with found_rows) for the total. * 2. A single-row author-scoped query to check if the current user reposted. * * @param int $comment_id The comment ID. * @return array { count: int, reposted: bool } */ function blurt_get_comment_repost_data( $comment_id ) { // Count all reposts of this comment. $count_query = new WP_Query( array( 'meta_key' => '_blurt_repost_of_comment', 'meta_value' => $comment_id, 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => 1, 'fields' => 'ids', 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); $count = (int) $count_query->found_posts; $reposted = false; // Check if the current user has reposted this comment (single-row lookup). if ( is_user_logged_in() ) { $user_repost = get_posts( array( 'author' => get_current_user_id(), 'meta_key' => '_blurt_repost_of_comment', 'meta_value' => $comment_id, 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => 1, 'fields' => 'ids', ) ); if ( $user_repost ) { $reposted = true; } } return array( 'count' => $count, 'reposted' => $reposted, ); } function blurt_repost_comment() { check_ajax_referer( 'blurt_nonce', 'nonce' ); if ( ! is_user_logged_in() || ! current_user_can( 'publish_posts' ) ) { wp_send_json_error( 'Cannot publish posts.' ); } $comment_id = isset( $_POST['comment_id'] ) ? absint( $_POST['comment_id'] ) : 0; $comment = get_comment( $comment_id ); if ( ! $comment ) { wp_send_json_error( 'Invalid comment.' ); } // Check if the current user already reposted this comment. $existing = get_posts( array( 'author' => get_current_user_id(), 'meta_key' => '_blurt_repost_of_comment', 'meta_value' => $comment_id, 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => 1, 'fields' => 'ids', ) ); // Toggle: if already reposted, undo it. if ( $existing ) { wp_delete_post( $existing[0], true ); wp_send_json_success( array( 'reposted' => false, 'undone' => true, ) ); } $comment_text = $comment->comment_content; $post_id = wp_insert_post( array( 'post_title' => wp_trim_words( wp_strip_all_tags( $comment_text ), 10, '...' ), 'post_content' => $comment_text, 'post_status' => 'publish', 'post_author' => get_current_user_id(), ) ); if ( ! $post_id || is_wp_error( $post_id ) ) { wp_send_json_error( 'Failed to create post.' ); } update_post_meta( $post_id, '_blurt_repost_of_comment', $comment_id ); wp_send_json_success( array( 'html' => blurt_render_post_card( $post_id ), 'post_id' => $post_id, 'reposted' => true, ) ); } add_action( 'wp_ajax_blurt_repost_comment', 'blurt_repost_comment' ); /** * Get like data for a post. * * Returns the like count and whether the current user has liked it, * using the WordPress.com Likes system. * * @param int $post_id The post ID. * @return array { count: int, liked: bool } */ function blurt_get_like_data( $post_id ) { $blog_id = get_current_blog_id(); $count = 0; $liked = false; if ( class_exists( 'Likes' ) ) { $count = (int) Likes::total_like_count_for_post( $blog_id, $post_id ); if ( is_user_logged_in() ) { $liked = Likes::post_is_liked( $blog_id, $post_id ); } } return array( 'count' => $count, 'liked' => $liked, ); } /** * AJAX: Toggle like on a post. * * Uses the WordPress.com Likes class to like or unlike a post. */ function blurt_toggle_like() { check_ajax_referer( 'blurt_nonce', 'nonce' ); if ( ! is_user_logged_in() ) { wp_send_json_error( 'Not logged in.' ); } if ( ! class_exists( 'Likes' ) ) { wp_send_json_error( 'Likes not available.' ); } $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; if ( ! $post_id || ! get_post( $post_id ) ) { wp_send_json_error( 'Invalid post ID.' ); } $blog_id = get_current_blog_id(); $liked = Likes::post_is_liked( $blog_id, $post_id ); if ( $liked ) { $result = Likes::remove_post_like( $blog_id, $post_id ); } else { $result = Likes::set_post_like( $blog_id, $post_id, 'theme' ); } if ( is_wp_error( $result ) ) { wp_send_json_error( $result->get_error_message() ); } $new_count = (int) Likes::total_like_count_for_post( $blog_id, $post_id ); wp_send_json_success( array( 'liked' => ! $liked, 'count' => $new_count, ) ); } add_action( 'wp_ajax_blurt_toggle_like', 'blurt_toggle_like' ); /** * Get like data for a comment. * * @param int $comment_id The comment ID. * @param int $post_id The parent post ID. * @return array { count: int, liked: bool } */ function blurt_get_comment_like_data( $comment_id, $post_id ) { $blog_id = get_current_blog_id(); $count = 0; $liked = false; if ( class_exists( 'Likes' ) ) { $count = (int) Likes::total_like_count_for_comment( $blog_id, $post_id, $comment_id ); if ( is_user_logged_in() ) { $liked = Likes::comment_like_current_user_likes( $blog_id, $comment_id ); } } return array( 'count' => $count, 'liked' => $liked, ); } /** * AJAX: Toggle like on a comment. * * Uses Likes::comment_like_record() to like and * Likes::remove_comment_like() to unlike. */ function blurt_toggle_comment_like() { check_ajax_referer( 'blurt_nonce', 'nonce' ); if ( ! is_user_logged_in() ) { wp_send_json_error( 'Not logged in.' ); } if ( ! class_exists( 'Likes' ) ) { wp_send_json_error( 'Likes not available.' ); } $comment_id = isset( $_POST['comment_id'] ) ? absint( $_POST['comment_id'] ) : 0; $comment = get_comment( $comment_id ); if ( ! $comment ) { wp_send_json_error( 'Invalid comment.' ); } $blog_id = get_current_blog_id(); $post_id = (int) $comment->comment_post_ID; $user_id = get_current_user_id(); $liked = Likes::comment_like_current_user_likes( $blog_id, $comment_id ); if ( $liked ) { Likes::remove_comment_like( $blog_id, $post_id, $comment_id ); } else { Likes::comment_like_record( $user_id, $blog_id, $post_id, $comment_id ); } $new_count = (int) Likes::total_like_count_for_comment( $blog_id, $post_id, $comment_id ); wp_send_json_success( array( 'liked' => ! $liked, 'count' => $new_count, ) ); } add_action( 'wp_ajax_blurt_toggle_comment_like', 'blurt_toggle_comment_like' ); /** * Check whether a post is a repost of another post. * * @param WP_Post|int $post The post object or ID. * @return int|false The original post ID, or false if not a repost. */ function blurt_is_repost( $post ) { $post = get_post( $post ); if ( ! $post ) { return false; } $original_id = get_post_meta( $post->ID, '_blurt_repost_of', true ); if ( ! $original_id ) { return false; } $original_id = absint( $original_id ); if ( ! get_post( $original_id ) ) { return false; } return $original_id; } /** * Get repost data for a post. * * Uses two small queries instead of one unbounded query: * 1. A count query (posts_per_page=1 with found_rows) for the total. * 2. A single-row author-scoped query to check if the current user reposted. * * @param int $post_id The post ID. * @return array { count: int, reposted: bool, repost_id: int } */ function blurt_get_repost_data( $post_id ) { // Count all reposts of this post. $count_query = new WP_Query( array( 'meta_key' => '_blurt_repost_of', 'meta_value' => $post_id, 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => 1, 'fields' => 'ids', 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); $count = (int) $count_query->found_posts; $reposted = false; $repost_id = 0; // Check if the current user has reposted (single-row lookup). if ( is_user_logged_in() ) { $user_repost = get_posts( array( 'author' => get_current_user_id(), 'meta_key' => '_blurt_repost_of', 'meta_value' => $post_id, 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => 1, 'fields' => 'ids', ) ); if ( $user_repost ) { $reposted = true; $repost_id = $user_repost[0]; } } return array( 'count' => $count, 'reposted' => $reposted, 'repost_id' => $repost_id, ); } /** * AJAX: Toggle repost on a post. * * Creates a new post referencing the original, or deletes the current * user's existing repost. */ function blurt_toggle_repost() { check_ajax_referer( 'blurt_nonce', 'nonce' ); if ( ! is_user_logged_in() ) { wp_send_json_error( 'Not logged in.' ); } if ( ! current_user_can( 'publish_posts' ) ) { wp_send_json_error( 'Cannot publish posts.' ); } $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; $original = get_post( $post_id ); if ( ! $original ) { wp_send_json_error( 'Invalid post ID.' ); } // Don't allow reposting a repost — repost the original instead. if ( blurt_is_repost( $original ) ) { wp_send_json_error( 'Cannot repost a repost.' ); } $repost_data = blurt_get_repost_data( $post_id ); if ( $repost_data['reposted'] ) { wp_delete_post( $repost_data['repost_id'], true ); } else { $original_content = $original->post_content; $original_author = get_the_author_meta( 'display_name', $original->post_author ); $repost_content = sprintf( '
    %s%s
    ', wp_kses_post( $original_content ), esc_html( $original_author ) ); $new_post_id = wp_insert_post( array( 'post_title' => wp_trim_words( wp_strip_all_tags( $original_content ), 10, '...' ), 'post_content' => $repost_content, 'post_status' => 'publish', 'post_author' => get_current_user_id(), ) ); if ( is_wp_error( $new_post_id ) ) { wp_send_json_error( 'Failed to create repost.' ); } update_post_meta( $new_post_id, '_blurt_repost_of', $post_id ); } $new_data = blurt_get_repost_data( $post_id ); wp_send_json_success( array( 'reposted' => $new_data['reposted'], 'count' => $new_data['count'], ) ); } add_action( 'wp_ajax_blurt_toggle_repost', 'blurt_toggle_repost' ); /** * Enforce 500 character limit on post content. * * @param array $data An array of slashed, sanitized post data. * @param array $postarr An array of sanitized post data. * @return array Modified post data. */ function blurt_enforce_character_limit( $data, $postarr ) { if ( 'post' !== $data['post_type'] ) { return $data; } // Reposts contain the original post's content and should not be truncated. if ( ! empty( $postarr['meta_input']['_blurt_repost_of'] ) || ( ! empty( $postarr['ID'] ) && get_post_meta( $postarr['ID'], '_blurt_repost_of', true ) ) ) { return $data; } $content = wp_strip_all_tags( $data['post_content'] ); $char_count = mb_strlen( trim( blurt_strip_urls_for_count( $content ) ) ); if ( $char_count > 500 ) { // Only truncate the non-URL text portion; this is a safety net. $data['post_content'] = mb_substr( $data['post_content'], 0, 1000 ); } return $data; } add_filter( 'wp_insert_post_data', 'blurt_enforce_character_limit', 10, 2 ); /** * Pre-update check for character limit. * * @param int $post_id Post ID. */ function blurt_pre_update_character_check( $post_id ) { if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; } if ( 'post' !== get_post_type( $post_id ) ) { return; } $post = get_post( $post_id ); $content = wp_strip_all_tags( $post->post_content ); if ( mb_strlen( trim( blurt_strip_urls_for_count( $content ) ) ) > 500 ) { wp_update_post( array( 'ID' => $post_id, 'post_content' => mb_substr( $post->post_content, 0, 1000 ), ) ); } } add_action( 'pre_post_update', 'blurt_pre_update_character_check' ); /** * Customizer additions. * * @param WP_Customize_Manager $wp_customize Theme Customizer object. */ function blurt_customize_register( $wp_customize ) { $wp_customize->add_section( 'blurt_options', array( 'title' => esc_html__( 'Blurt Options', 'blurt' ), 'priority' => 30, ) ); $wp_customize->add_setting( 'blurt_accent_color', array( 'default' => '#6366F1', 'sanitize_callback' => 'sanitize_hex_color', 'transport' => 'postMessage', ) ); $wp_customize->add_control( new WP_Customize_Color_Control( $wp_customize, 'blurt_accent_color', array( 'label' => esc_html__( 'Accent Color', 'blurt' ), 'section' => 'blurt_options', ) ) ); } add_action( 'customize_register', 'blurt_customize_register' ); /** * Output customizer CSS. */ function blurt_customizer_css() { $accent_color = get_theme_mod( 'blurt_accent_color', '#6366F1' ); ?> post_content ); return mb_strlen( trim( blurt_strip_urls_for_count( $text ) ) ); } /** * Get user statistics. * * @param int $user_id User ID. * @return array Array with post_count, follower_count, following_count. */ function blurt_get_user_stats( $user_id ) { $post_count = count_user_posts( $user_id, 'post', true ); $follower_count = 0; if ( function_exists( 'wpcom_subs_total_for_blog' ) ) { $follower_count = (int) wpcom_subs_total_for_blog(); } $following_count = 0; if ( function_exists( 'wpcom_subs_get_blogs_ids' ) ) { $following_count = count( wpcom_subs_get_blogs_ids( array( 'user_id' => $user_id ) ) ); } return array( 'post_count' => $post_count, 'follower_count' => $follower_count, 'following_count' => $following_count, ); } /** * Get avatar URL for a user. * * @param int $user_id User ID. * @param int $size Avatar size in pixels. * @return string Avatar URL. */ /** * Apply WordPress.com Site Accelerator (Photon) optimization to an image URL. * * Adds width, quality, and strip parameters for optimal delivery. * * @param string $url The image URL. * @param int $width Desired width in pixels. 0 for no resize. * @param int $height Desired height in pixels. 0 for no resize. * @param array $args Additional Photon args (quality, fit, resize, etc.). * @return string Optimized image URL. */ function blurt_optimize_image_url( $url, $width = 0, $height = 0, $args = array() ) { if ( ! $url ) { return $url; } // Append Photon parameters directly to the site-origin URL. // WordPress.com supports these parameters on its own domain. $params = array( 'strip' => 'all', 'quality' => 80, ); if ( $width && $height ) { $params['fit'] = $width . ',' . $height; } elseif ( $width ) { $params['w'] = $width; } elseif ( $height ) { $params['h'] = $height; } $params = array_merge( $params, $args ); return add_query_arg( $params, $url ); } function blurt_get_avatar_url( $user_id, $size = 48 ) { // Request 2x the display size for crisp rendering on high-DPI screens. $retina_size = $size * 2; $url = get_avatar_url( $user_id, array( 'size' => $retina_size ) ); if ( ! $url ) { return ''; } return blurt_optimize_image_url( $url, $retina_size, $retina_size, array( 'fit' => $retina_size . ',' . $retina_size ) ); } /** * Format post time as full date and time string. * * @param WP_Post|int $post Post object or ID. * @return string Formatted date and time. */ function blurt_time_format( $post ) { $post = get_post( $post ); if ( ! $post ) { return ''; } return get_the_date( '', $post ) . ' ' . get_the_time( '', $post ); } /** * Return an tag for a gallery image without srcset. * * Using wp_get_attachment_image() generates srcset/sizes attributes * that cause the browser to swap between resolutions on hover, * producing a visible flicker. This outputs a clean tag with a * single large URL and a data-orig-file attribute for the lightbox. * * @param int $attachment_id Attachment ID. * @return string HTML img tag. */ function blurt_gallery_image( $attachment_id ) { $url = wp_get_attachment_image_url( $attachment_id, 'large' ); if ( ! $url ) { return ''; } $alt = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ); $full_url = wp_get_attachment_image_url( $attachment_id, 'full' ); $attachment = get_post( $attachment_id ); $caption = $attachment ? $attachment->post_excerpt : ''; // Fall back to alt text if no caption. if ( ! $caption && $alt ) { $caption = $alt; } // Apply Photon optimization — gallery images at content width, lightbox at full. $optimized_url = blurt_optimize_image_url( $url, 780, 0 ); $optimized_full = blurt_optimize_image_url( $full_url ? $full_url : $url, 1560, 0, array( 'quality' => 90 ) ); $html = sprintf( '%s', esc_url( $optimized_url ), esc_attr( $alt ), esc_url( $optimized_full ) ); if ( $caption ) { $html .= sprintf( '%s', esc_html( $caption ) ); } return $html; } /** * Get trending tags based on recent post activity. * * Queries tags used in recently published posts, starting with a * 24-hour window and expanding to 7 days, 30 days, then all-time * if no tags are found in the narrower window. * * Results are cached in a transient for 10 minutes and in a static * variable for the duration of the request so that multiple calls * on the same page (e.g. mobile trending strip + sidebar) do not * repeat the work. * * @param int $number Maximum number of tags to return. * @return array Array of objects with tag (WP_Term) and count properties. */ function blurt_get_trending_tags( $number = 10 ) { // Static cache: avoid recomputing within the same request. static $static_cache = array(); if ( isset( $static_cache[ $number ] ) ) { return $static_cache[ $number ]; } // Transient cache: avoid recomputing across requests. $transient_key = 'blurt_trending_' . $number; $cached = get_transient( $transient_key ); if ( false !== $cached ) { $static_cache[ $number ] = $cached; return $cached; } $periods = array( '24 hours ago' => __( 'Last 24 hours', 'blurt' ), '7 days ago' => __( 'Last 7 days', 'blurt' ), '30 days ago' => __( 'Last 30 days', 'blurt' ), 'all' => __( 'All time', 'blurt' ), ); $empty_result = array( 'tags' => array(), 'period' => '', ); foreach ( $periods as $after => $label ) { $query_args = array( 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => 200, 'fields' => 'ids', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, 'ignore_sticky_posts' => true, ); if ( 'all' !== $after ) { $query_args['date_query'] = array( array( 'after' => $after ), ); } $posts = new WP_Query( $query_args ); if ( empty( $posts->posts ) ) { continue; } // Prime the term cache in a single query. update_object_term_cache( $posts->posts, 'post' ); $tag_tally = array(); foreach ( $posts->posts as $pid ) { $post_tags = get_the_tags( $pid ); if ( ! $post_tags ) { continue; } foreach ( $post_tags as $tag ) { if ( ! isset( $tag_tally[ $tag->term_id ] ) ) { $tag_tally[ $tag->term_id ] = array( 'tag' => $tag, 'count' => 0, ); } $tag_tally[ $tag->term_id ]['count']++; } } if ( empty( $tag_tally ) ) { continue; } usort( $tag_tally, function ( $a, $b ) { return $b['count'] - $a['count']; } ); $result = array(); foreach ( array_slice( $tag_tally, 0, $number ) as $item ) { $item['tag']->count = $item['count']; $result[] = $item['tag']; } $data = array( 'tags' => $result, 'period' => $label, ); set_transient( $transient_key, $data, 10 * MINUTE_IN_SECONDS ); $static_cache[ $number ] = $data; return $data; } set_transient( $transient_key, $empty_result, 10 * MINUTE_IN_SECONDS ); $static_cache[ $number ] = $empty_result; return $empty_result; } /** * Output the back bar navigation. * * @param string $label Text shown next to the back arrow. Defaults to 'Back'. */ function blurt_back_bar( $label = '' ) { if ( '' === $label ) { $label = __( 'Back', 'blurt' ); } ?>
    '', 'explore' => '', 'profile' => '', 'reply' => '', 'repost' => '', 'like' => '', 'share' => '', 'search' => '', 'pin' => '', 'compose' => '', 'arrow-left' => '', 'check' => '', 'more' => '', 'media' => '', 'calendar' => '', 'link' => '', 'moon' => '', 'sun' => '', 'close' => '', 'tools' => '', 'import' => '', ); if ( ! isset( $icons[ $name ] ) ) { return ''; } return ''; } /** * AJAX: Upload an image and return its attachment ID and thumbnail URL. * * Images are uploaded as unattached media (post_parent = 0). They are * later associated with a post or comment when the compose form is * submitted. */ function blurt_upload_image() { check_ajax_referer( 'blurt_nonce', 'nonce' ); if ( ! is_user_logged_in() ) { wp_send_json_error( 'Not logged in.' ); } if ( ! current_user_can( 'upload_files' ) ) { wp_send_json_error( 'Cannot upload files.' ); } if ( empty( $_FILES['blurt_image'] ) ) { wp_send_json_error( 'No file provided.' ); } $file = $_FILES['blurt_image']; $filetype = wp_check_filetype( $file['name'] ); $is_image = $filetype['type'] && 0 === strpos( $filetype['type'], 'image/' ); $is_video = $filetype['type'] && 0 === strpos( $filetype['type'], 'video/' ); if ( ! $is_image && ! $is_video ) { wp_send_json_error( 'Invalid file type. Only images and videos are allowed.' ); } require_once ABSPATH . 'wp-admin/includes/image.php'; require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; $attachment_id = media_handle_upload( 'blurt_image', 0 ); if ( is_wp_error( $attachment_id ) ) { wp_send_json_error( $attachment_id->get_error_message() ); } // Save alt text and use as caption if no caption exists. $alt_text = isset( $_POST['alt'] ) ? sanitize_text_field( $_POST['alt'] ) : ''; if ( $alt_text ) { update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt_text ); $attachment = get_post( $attachment_id ); if ( $attachment && '' === $attachment->post_excerpt ) { wp_update_post( array( 'ID' => $attachment_id, 'post_excerpt' => $alt_text, ) ); } } $media_type = $is_video ? 'video' : 'image'; if ( $is_video ) { $thumb_url = wp_get_attachment_url( $attachment_id ); $full_url = $thumb_url; } else { $thumb_url = wp_get_attachment_image_url( $attachment_id, 'medium' ); $full_url = wp_get_attachment_image_url( $attachment_id, 'full' ); } wp_send_json_success( array( 'id' => $attachment_id, 'url' => $thumb_url, 'full_url' => $full_url ? $full_url : $thumb_url, 'type' => $media_type, ) ); } add_action( 'wp_ajax_blurt_upload_image', 'blurt_upload_image' ); /** * AJAX: Update alt text and caption on an attachment. */ function blurt_update_attachment_meta() { check_ajax_referer( 'blurt_nonce', 'nonce' ); if ( ! is_user_logged_in() ) { wp_send_json_error( 'Not logged in.' ); } $attachment_id = isset( $_POST['attachment_id'] ) ? absint( $_POST['attachment_id'] ) : 0; $alt = isset( $_POST['alt'] ) ? sanitize_text_field( $_POST['alt'] ) : ''; $caption = isset( $_POST['caption'] ) ? sanitize_text_field( $_POST['caption'] ) : ''; if ( ! $attachment_id || ! get_post( $attachment_id ) ) { wp_send_json_error( 'Invalid attachment.' ); } // Verify the attachment belongs to the current user. $attachment = get_post( $attachment_id ); if ( (int) $attachment->post_author !== get_current_user_id() ) { wp_send_json_error( 'Not your attachment.' ); } update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt ); // Use alt as caption fallback. $save_caption = $caption ? $caption : $alt; if ( $save_caption ) { wp_update_post( array( 'ID' => $attachment_id, 'post_excerpt' => $save_caption, ) ); } wp_send_json_success(); } add_action( 'wp_ajax_blurt_update_attachment_meta', 'blurt_update_attachment_meta' ); /** * Attach uploaded images to a newly created post. * * Sets each attachment's post_parent so that blurt_get_post_images() * can retrieve them for display in the gallery grid. * * @param int $post_id The new post ID. * @param int[] $attachment_ids Array of attachment IDs to attach. */ function blurt_attach_images_to_post( $post_id, $attachment_ids ) { if ( empty( $attachment_ids ) || ! $post_id ) { return; } // Cap at 4 images. $attachment_ids = array_slice( $attachment_ids, 0, 4 ); foreach ( $attachment_ids as $att_id ) { wp_update_post( array( 'ID' => $att_id, 'post_parent' => $post_id, ) ); } } /** * Get image attachment IDs for a post. * * Returns attached images and the featured image (if set), capped at 4. * * @param WP_Post|int $post Post object or ID. * @return int[] Array of attachment IDs. */ function blurt_get_post_images( $post ) { $post = get_post( $post ); if ( ! $post ) { return array(); } $image_ids = array(); // Attached images (from Blurt compose upload flow). $attached = get_attached_media( 'image', $post->ID ); foreach ( $attached as $attachment ) { $image_ids[] = $attachment->ID; } // Include featured image if not already in the list. $thumbnail_id = get_post_thumbnail_id( $post->ID ); if ( $thumbnail_id && ! in_array( (int) $thumbnail_id, $image_ids, true ) ) { array_unshift( $image_ids, (int) $thumbnail_id ); } return array_slice( $image_ids, 0, 4 ); } /** * Get all media (images and videos) attached to a post. * * @param WP_Post|int $post Post object or ID. * @return array[] Array of { id: int, type: string } arrays. */ function blurt_get_post_media( $post ) { $post = get_post( $post ); if ( ! $post ) { return array(); } $media = array(); $seen = array(); $thumbnail_id = get_post_thumbnail_id( $post->ID ); if ( $thumbnail_id ) { $media[] = array( 'id' => (int) $thumbnail_id, 'type' => 'image', ); $seen[] = (int) $thumbnail_id; } $images = get_attached_media( 'image', $post->ID ); foreach ( $images as $attachment ) { if ( ! in_array( $attachment->ID, $seen, true ) ) { $media[] = array( 'id' => $attachment->ID, 'type' => 'image', ); $seen[] = $attachment->ID; } } $videos = get_attached_media( 'video', $post->ID ); foreach ( $videos as $attachment ) { if ( ! in_array( $attachment->ID, $seen, true ) ) { $media[] = array( 'id' => $attachment->ID, 'type' => 'video', ); $seen[] = $attachment->ID; } } return array_slice( $media, 0, 4 ); } /** * Return a