Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions assets/css/activitypub-admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,10 @@ input.blog-user-identifier {
margin-right: 8px;
}

.edit-comments-php .column-author img.emoji {
float: none;
}

.contextual-help-tabs-wrap dt {
font-weight: 600;
}
Expand Down
47 changes: 47 additions & 0 deletions includes/class-comment.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ public static function init() {
\add_action( 'update_option_activitypub_allow_likes', array( self::class, 'maybe_update_comment_counts' ), 10, 2 );
\add_action( 'update_option_activitypub_allow_reposts', array( self::class, 'maybe_update_comment_counts' ), 10, 2 );
\add_filter( 'pre_wp_update_comment_count_now', array( static::class, 'pre_wp_update_comment_count_now' ), 10, 3 );
\add_filter( 'get_comment_author', array( static::class, 'render_emoji' ), 10, 3 );
\add_filter( 'comment_author', array( static::class, 'unescape_emoji' ), 20 ); // After esc_html().
}

/**
Expand Down Expand Up @@ -815,4 +817,49 @@ public static function pre_wp_update_comment_count_now( $new_count, $old_count,
public static function is_comment_type_enabled( $comment_type ) {
return '1' === get_option( "activitypub_allow_{$comment_type}s", '1' );
}

/**
* Render emoji in comment author name.
*
* Replaces emoji shortcodes with img tags on the get_comment_author filter.
*
* @param string $author The comment author name.
* @param string $comment_id The comment ID as a numeric string.
* @param \WP_Comment|null $comment The comment object.
* @return string The comment author name with rendered emoji.
*/
public static function render_emoji( $author, $comment_id, $comment = null ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$emoji_data = \get_comment_meta( $comment_id, 'activitypub_author_emoji', true );

if ( empty( $emoji_data ) ) {
return $author;
}

$emoji_tags = \json_decode( $emoji_data, true );
if ( ! is_array( $emoji_tags ) ) {
return $author;
}

// Build activity array for emoji replacement.
$activity = array( 'tag' => $emoji_tags );

return Emoji::replace_custom_emoji( $author, $activity );
}

/**
* Selectively unescape emoji images in comment author.
*
* This runs at priority 20 after WordPress's esc_html() filter on comment_author.
*
* @param string $author The comment author name (already escaped by WordPress).
* @return string The comment author name with emoji images unescaped.
*/
public static function unescape_emoji( $author ) {
// Only unescape if there are emoji images present.
if ( false !== strpos( $author, 'class="emoji"' ) ) {
$author = \html_entity_decode( $author, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
}

return $author;
}
}
225 changes: 225 additions & 0 deletions includes/class-emoji.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
<?php
/**
* ActivityPub Emoji file.
*
* @package Activitypub
*/

namespace Activitypub;

/**
* Handles custom emoji processing for ActivityPub content.
*/
class Emoji {

/**
* Replace custom emoji shortcodes with their corresponding emoji.
*
* @param string $text The text to process.
* @param array $activity The activity array containing emoji definitions.
*
* @return string The processed text with emoji replacements.
*/
public static function replace_custom_emoji( $text, $activity ) {
$emoji_data = self::extract_emoji_data( $activity );
if ( empty( $emoji_data ) ) {
return $text;
}

$emoji_attachments = self::get_emoji_attachments( $emoji_data );

foreach ( $emoji_data as $emoji ) {
$attachment_id = self::get_or_create_emoji_attachment( $emoji, $emoji_attachments );
$text = self::replace_emoji_in_text( $text, $emoji, $attachment_id );
}

return $text;
}

/**
* Extract emoji data from activity tags.
*
* @param array $activity The activity array containing emoji definitions.
*
* @return array {
* Array of emoji data with url and name keys.
*
* @type string $url The URL of the emoji image.
* @type string $name The shortcode name of the emoji (e.g., ":emoji:").
* }
*/
private static function extract_emoji_data( $activity ) {
if ( empty( $activity['tag'] ) || ! is_array( $activity['tag'] ) ) {
return array();
}

$emoji_data = array();

foreach ( $activity['tag'] as $tag ) {
if ( isset( $tag['type'] ) && 'Emoji' === $tag['type'] && ! empty( $tag['name'] ) && ! empty( $tag['icon']['url'] ) ) {
$emoji_data[] = array(
'url' => $tag['icon']['url'],
'name' => $tag['name'],
);
}
}

return $emoji_data;
}

/**
* Get existing emoji attachments for the given emoji data.
*
* @param array $emoji_data {
* Array of emoji data with url and name keys.
*
* @type string $url The URL of the emoji image.
* @type string $name The shortcode name of the emoji (e.g., ":emoji:").
* }
*
* @return array {
* Array containing two lookup arrays:
*
* @type array $url_to_attachment Lookup array mapping emoji URLs to attachment IDs.
* @type array $name_to_attachment Lookup array mapping emoji names to attachment IDs.
* }
*/
private static function get_emoji_attachments( $emoji_data ) {
// Single query for all emoji at once.
$meta_query = array( 'relation' => 'OR' );

foreach ( $emoji_data as $emoji ) {
$meta_query[] = array(
'key' => 'activitypub_emoji_source_url',
'value' => $emoji['url'],
);
$meta_query[] = array(
'key' => 'activitypub_emoji_placeholder',
'value' => $emoji['name'],
);
}

$existing_attachments = \get_posts(
array(
'post_type' => 'attachment',
'posts_per_page' => -1,
'fields' => 'ids',

// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => $meta_query,
)
);

// Build lookup arrays for fast access.
$url_to_attachment = array();
$name_to_attachment = array();

foreach ( $existing_attachments as $post_id ) {
$source_url = \get_post_meta( $post_id, 'activitypub_emoji_source_url', true );
$placeholder = \get_post_meta( $post_id, 'activitypub_emoji_placeholder', true );

if ( $source_url ) {
$url_to_attachment[ $source_url ] = $post_id;
}
if ( $placeholder ) {
$name_to_attachment[ $placeholder ] = $post_id;
}
}

return array(
'url_to_attachment' => $url_to_attachment,
'name_to_attachment' => $name_to_attachment,
);
}

/**
* Get existing emoji attachment or create a new one.
*
* @param array $emoji Single emoji data with url and name.
* @param array $emoji_attachments Lookup arrays from get_emoji_attachments.
*
* @return int Attachment ID or 0 if failed.
*/
private static function get_or_create_emoji_attachment( $emoji, $emoji_attachments ) {
$existing_attachment = $emoji_attachments['url_to_attachment'][ $emoji['url'] ] ?? 0;
$existing_placeholder = $emoji_attachments['name_to_attachment'][ $emoji['name'] ] ?? 0;

// If we have a different image for this placeholder, or no existing image, download the new one.
if ( empty( $existing_attachment ) || ! empty( $existing_placeholder ) ) {
return self::download_emoji( $emoji['name'], $emoji['url'] );
}

return $existing_attachment;
}

/**
* Download and store emoji as attachment.
*
* @param string $emoji_name The emoji placeholder name.
* @param string $emoji_url The emoji source URL.
*
* @return int Attachment ID or 0 if failed.
*/
private static function download_emoji( $emoji_name, $emoji_url ) {
if ( ! function_exists( '\download_url' ) ) {
require_once \ABSPATH . 'wp-admin/includes/file.php';
}

$temp_file = \download_url( $emoji_url, 10 ); // 10 second timeout for emoji downloads.
if ( \is_wp_error( $temp_file ) ) {
return 0;
}

$file_array = array(
'name' => \wp_basename( $emoji_url ),
'tmp_name' => $temp_file,
);

if ( ! function_exists( '\media_handle_sideload' ) ) {
require_once \ABSPATH . 'wp-admin/includes/media.php';
require_once \ABSPATH . 'wp-admin/includes/image.php';
}

$attachment_id = \media_handle_sideload( $file_array, 0, $emoji_name );

// Clean up temp file after processing.
if ( \is_file( $temp_file ) ) {
\wp_delete_file( $temp_file );
}

if ( \is_wp_error( $attachment_id ) ) {
return 0;
}

\update_post_meta( $attachment_id, 'activitypub_emoji_source_url', $emoji_url );
\update_post_meta( $attachment_id, 'activitypub_emoji_placeholder', $emoji_name );

return $attachment_id;
}

/**
* Replace emoji shortcode in text with image or fallback.
*
* @param string $text The text to process.
* @param array $emoji Single emoji data with url and name.
* @param int $attachment_id The attachment ID or 0 if none.
*
* @return string The processed text.
*/
private static function replace_emoji_in_text( $text, $emoji, $attachment_id ) {
$emoji_url = $emoji['url'];
if ( $attachment_id ) {
$emoji_url = \wp_get_attachment_url( $attachment_id );
}

return str_replace(
$emoji['name'],
sprintf(
'<img src="%s" alt="%s" class="emoji" />',
\esc_url( $emoji_url ),
\esc_attr( trim( $emoji['name'], ':' ) )
),
$text
);
}
}
22 changes: 22 additions & 0 deletions includes/collection/class-interactions.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
namespace Activitypub\Collection;

use Activitypub\Comment;
use Activitypub\Emoji;
use Activitypub\Webfinger;
use WP_Comment_Query;

Expand Down Expand Up @@ -81,6 +82,14 @@ public static function update_comment( $activity ) {
$comment_data['comment_author'] = \esc_attr( $meta['name'] ?? $meta['preferredUsername'] );
$comment_data['comment_content'] = \addslashes( $activity['object']['content'] );

add_filter(
'pre_comment_content',
function ( $comment_content ) use ( $activity ) {
return Emoji::replace_custom_emoji( $comment_content, $activity['object'] );
},
20 // After wp_filter_post_kses().
);

return self::persist( $comment_data, self::UPDATE );
}

Expand Down Expand Up @@ -283,6 +292,19 @@ public static function activity_to_comment( $activity ) {
$comment_data['comment_meta']['source_url'] = \esc_url_raw( object_to_uri( $activity['object']['url'] ) );
}

// Store emoji data for display-time replacement.
if ( ! empty( $actor['tag'] ) ) {
$comment_data['comment_meta']['activitypub_author_emoji'] = \wp_json_encode( $actor['tag'] );
}

add_filter(
'pre_comment_content',
function ( $comment_content ) use ( $activity ) {
return Emoji::replace_custom_emoji( $comment_content, $activity['object'] );
},
20
);

return $comment_data;
}

Expand Down
Loading