diff --git a/assets/css/activitypub-admin.css b/assets/css/activitypub-admin.css
index 7a92bdaa8..482981fb3 100644
--- a/assets/css/activitypub-admin.css
+++ b/assets/css/activitypub-admin.css
@@ -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;
}
diff --git a/includes/class-comment.php b/includes/class-comment.php
index ff90f6b39..690ac007f 100644
--- a/includes/class-comment.php
+++ b/includes/class-comment.php
@@ -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().
}
/**
@@ -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;
+ }
}
diff --git a/includes/class-emoji.php b/includes/class-emoji.php
new file mode 100644
index 000000000..43fcc74dd
--- /dev/null
+++ b/includes/class-emoji.php
@@ -0,0 +1,225 @@
+ $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(
+ '',
+ \esc_url( $emoji_url ),
+ \esc_attr( trim( $emoji['name'], ':' ) )
+ ),
+ $text
+ );
+ }
+}
diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php
index f532ecf00..f3b77e981 100644
--- a/includes/collection/class-interactions.php
+++ b/includes/collection/class-interactions.php
@@ -8,6 +8,7 @@
namespace Activitypub\Collection;
use Activitypub\Comment;
+use Activitypub\Emoji;
use Activitypub\Webfinger;
use WP_Comment_Query;
@@ -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 );
}
@@ -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;
}
diff --git a/tests/includes/collection/class-test-interactions.php b/tests/includes/collection/class-test-interactions.php
index 34bc339ca..e920534db 100644
--- a/tests/includes/collection/class-test-interactions.php
+++ b/tests/includes/collection/class-test-interactions.php
@@ -9,6 +9,8 @@
use Activitypub\Collection\Interactions;
+use function Activitypub\object_id_to_comment;
+
/**
* Test class for Activitypub Interactions.
*
@@ -54,7 +56,7 @@ class Test_Interactions extends \WP_UnitTestCase {
/**
* Create fake data before tests run.
*
- * @param WP_UnitTest_Factory $factory Helper that creates fake data.
+ * @param \WP_UnitTest_Factory $factory Helper that creates fake data.
*/
public static function wpSetUpBeforeClass( $factory ) {
self::$user_id = $factory->user->create(
@@ -163,7 +165,7 @@ public function create_test_rich_object( $id = 'https://example.com/123' ) {
'id' => $id,
'url' => 'https://example.com/example',
'inReplyTo' => self::$post_permalink,
- 'content' => 'Hello
example
example
example