From ae8b5e6280b52dccea2eb2faa7396176f5b107cf Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 7 Jan 2025 11:27:33 -0600 Subject: [PATCH 01/13] Emoji: First pass at support in Interactions --- CHANGELOG.md | 1 + includes/collection/class-interactions.php | 50 +++++++++++++++-- readme.txt | 1 + .../collection/class-test-interactions.php | 56 +++++++++++++++++++ 4 files changed, 102 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2a1fddc9..efbb2bd7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Undo API for Outbox items. * Metadata to New Follower E-Mail. * Allow Activities on URLs instead of requiring Activity-Objects. This is useful especially for sending Announces and Likes. +* Support for custom emoji in interaction contents and actor names. ### Changed diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index 9c1001b4f..00e79ba9d 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -77,8 +77,8 @@ public static function update_comment( $activity ) { } // Found a local comment id. - $commentdata['comment_author'] = \esc_attr( $meta['name'] ? $meta['name'] : $meta['preferredUsername'] ); - $commentdata['comment_content'] = \addslashes( $activity['object']['content'] ); + $commentdata['comment_author'] = self::replace_custom_emoji( $meta['name'] ? $meta['name'] : $meta['preferredUsername'], $meta ); + $commentdata['comment_content'] = \addslashes( self::replace_custom_emoji( $activity['object']['content'], $activity['object'] ) ); return self::persist( $commentdata, self::UPDATE ); } @@ -209,14 +209,22 @@ public static function allowed_comment_html( $allowed_tags, $context = '' ) { } // Add `p` and `br` to the list of allowed tags. - if ( ! array_key_exists( 'br', $allowed_tags ) ) { + if ( ! isset( $allowed_tags['br'] ) ) { $allowed_tags['br'] = array(); } - if ( ! array_key_exists( 'p', $allowed_tags ) ) { + if ( ! isset( $allowed_tags['p'] ) ) { $allowed_tags['p'] = array(); } + if ( ! isset( $allowed_tags['img'] ) ) { + $allowed_tags['img'] = array( + 'src' => array(), + 'alt' => array(), + 'class' => array(), + ); + } + return $allowed_tags; } @@ -257,9 +265,9 @@ public static function activity_to_comment( $activity ) { } $commentdata = array( - 'comment_author' => \esc_attr( $comment_author ), + 'comment_author' => self::replace_custom_emoji( $comment_author, $actor ), 'comment_author_url' => \esc_url_raw( $url ), - 'comment_content' => $comment_content, + 'comment_content' => self::replace_custom_emoji( $comment_content, $activity['object'] ), 'comment_type' => 'comment', 'comment_author_email' => '', 'comment_meta' => array( @@ -339,4 +347,34 @@ public static function count_by_type( $post_id, $type ) { ) ); } + + /** + * 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. + */ + private static function replace_custom_emoji( $text, $activity ) { + if ( empty( $activity['tag'] ) || ! is_array( $activity['tag'] ) ) { + return $text; + } + + foreach ( $activity['tag'] as $tag ) { + if ( isset( $tag['type'] ) && 'Emoji' === $tag['type'] && ! empty( $tag['name'] ) && ! empty( $tag['icon']['url'] ) ) { + $text = str_replace( + $tag['name'], + sprintf( + '%s', + \esc_url( $tag['icon']['url'] ), + \esc_attr( $tag['name'] ) + ), + $text + ); + } + } + + return $text; + } } diff --git a/readme.txt b/readme.txt index b7f59ad5e..ab5eb6175 100644 --- a/readme.txt +++ b/readme.txt @@ -137,6 +137,7 @@ For reasons of data protection, it is not possible to see the followers of other * Added: Undo API for Outbox items. * Added: Setting to adjust the number of days Outbox items are kept before being purged. * Added: Show metadata in the New Follower E-Mail. +* Added: Support for custom emoji in interaction contents and actor names. * Changed: Outbox now precesses the first batch of followers right away to avoid delays in processing new Activities. * Changed: Post bulk edits no longer create Outbox items, unless author or post status change. * Fixed: The Outbox purging routine no longer is limited to deleting 5 items at a time. diff --git a/tests/includes/collection/class-test-interactions.php b/tests/includes/collection/class-test-interactions.php index dee6e8dbf..e8ceb969a 100644 --- a/tests/includes/collection/class-test-interactions.php +++ b/tests/includes/collection/class-test-interactions.php @@ -471,4 +471,60 @@ function () { remove_all_filters( 'pre_get_remote_metadata_by_actor' ); } + + /** + * Test emoji replacement in activity_to_comment. + * + * @covers ::activity_to_comment + * @covers ::replace_custom_emoji + */ + public function test_activity_to_comment_with_emoji() { + $activity = array( + '@context' => array( + 'https://www.w3.org/ns/activitystreams', + array( + 'Emoji' => 'http://joinmastodon.org/ns#Emoji', + ), + ), + 'id' => 'https://example.com/activities/1', + 'type' => 'Note', + 'content' => 'Hello world :kappa: and :smile:', + 'actor' => $this->user_url, + 'object' => array( + 'id' => 'https://example.com/objects/1', + 'content' => 'Hello world :kappa: and :smile:', + 'tag' => array( + array( + 'type' => 'Emoji', + 'name' => ':kappa:', + 'icon' => array( + 'type' => 'Image', + 'mediaType' => 'image/png', + 'url' => 'https://example.com/files/kappa.png', + ), + ), + array( + 'type' => 'Emoji', + 'name' => ':smile:', + 'icon' => array( + 'type' => 'Image', + 'mediaType' => 'image/png', + 'url' => 'https://example.com/files/smile.png', + ), + ), + ), + ), + ); + + $commentdata = Interactions::activity_to_comment( $activity ); + + $this->assertStringContainsString( + ':kappa:', + $commentdata['comment_content'] + ); + $this->assertStringContainsString( + ':smile:', + $commentdata['comment_content'] + ); + } } From 47a64936791656c4e8d059044ea07772c5590c55 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 10 Jan 2025 11:20:44 -0600 Subject: [PATCH 02/13] Process emoji after sanitization --- includes/collection/class-interactions.php | 48 +++++++++++++------ .../collection/class-test-interactions.php | 16 ++++--- 2 files changed, 43 insertions(+), 21 deletions(-) diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index 00e79ba9d..7b7b7266a 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -77,8 +77,22 @@ public static function update_comment( $activity ) { } // Found a local comment id. - $commentdata['comment_author'] = self::replace_custom_emoji( $meta['name'] ? $meta['name'] : $meta['preferredUsername'], $meta ); - $commentdata['comment_content'] = \addslashes( self::replace_custom_emoji( $activity['object']['content'], $activity['object'] ) ); + $commentdata['comment_author'] = \esc_attr( $meta['name'] ? $meta['name'] : $meta['preferredUsername'] ); + $commentdata['comment_content'] = \addslashes( $activity['object']['content'] ); + + add_filter( + 'pre_comment_author_name', + function ( $comment_author ) use ( $meta ) { + return self::replace_custom_emoji( $comment_author, $meta ); + } + ); + add_filter( + 'pre_comment_content', + function ( $comment_content ) use ( $activity ) { + return self::replace_custom_emoji( $comment_content, $activity['object'] ); + }, + 20 + ); return self::persist( $commentdata, self::UPDATE ); } @@ -209,22 +223,14 @@ public static function allowed_comment_html( $allowed_tags, $context = '' ) { } // Add `p` and `br` to the list of allowed tags. - if ( ! isset( $allowed_tags['br'] ) ) { + if ( ! array_key_exists( 'br', $allowed_tags ) ) { $allowed_tags['br'] = array(); } - if ( ! isset( $allowed_tags['p'] ) ) { + if ( ! array_key_exists( 'p', $allowed_tags ) ) { $allowed_tags['p'] = array(); } - if ( ! isset( $allowed_tags['img'] ) ) { - $allowed_tags['img'] = array( - 'src' => array(), - 'alt' => array(), - 'class' => array(), - ); - } - return $allowed_tags; } @@ -265,9 +271,9 @@ public static function activity_to_comment( $activity ) { } $commentdata = array( - 'comment_author' => self::replace_custom_emoji( $comment_author, $actor ), + 'comment_author' => \esc_attr( $comment_author ), 'comment_author_url' => \esc_url_raw( $url ), - 'comment_content' => self::replace_custom_emoji( $comment_content, $activity['object'] ), + 'comment_content' => $comment_content, 'comment_type' => 'comment', 'comment_author_email' => '', 'comment_meta' => array( @@ -284,6 +290,20 @@ public static function activity_to_comment( $activity ) { $commentdata['comment_meta']['source_url'] = \esc_url_raw( object_to_uri( $activity['object']['url'] ) ); } + add_filter( + 'pre_comment_author_name', + function ( $comment_author ) use ( $actor ) { + return self::replace_custom_emoji( $comment_author, $actor ); + } + ); + add_filter( + 'pre_comment_content', + function ( $comment_content ) use ( $activity ) { + return self::replace_custom_emoji( $comment_content, $activity['object'] ); + }, + 20 + ); + return $commentdata; } diff --git a/tests/includes/collection/class-test-interactions.php b/tests/includes/collection/class-test-interactions.php index e8ceb969a..7b1339985 100644 --- a/tests/includes/collection/class-test-interactions.php +++ b/tests/includes/collection/class-test-interactions.php @@ -489,11 +489,12 @@ public function test_activity_to_comment_with_emoji() { 'id' => 'https://example.com/activities/1', 'type' => 'Note', 'content' => 'Hello world :kappa: and :smile:', - 'actor' => $this->user_url, + 'actor' => self::$user_url, 'object' => array( - 'id' => 'https://example.com/objects/1', - 'content' => 'Hello world :kappa: and :smile:', - 'tag' => array( + 'id' => 'https://example.com/objects/1', + 'content' => 'Hello world :kappa: and :smile:', + 'inReplyTo' => self::$post_permalink, + 'tag' => array( array( 'type' => 'Emoji', 'name' => ':kappa:', @@ -516,15 +517,16 @@ public function test_activity_to_comment_with_emoji() { ), ); - $commentdata = Interactions::activity_to_comment( $activity ); + $comment_id = Interactions::add_comment( $activity ); + $comment = get_comment( $comment_id ); $this->assertStringContainsString( ':kappa:', - $commentdata['comment_content'] + $comment->comment_content ); $this->assertStringContainsString( ':smile:', - $commentdata['comment_content'] + $comment->comment_content ); } } From 1c9c24729be93132438413cb6531e08ca9cdbac8 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 19 Feb 2025 17:26:02 -0600 Subject: [PATCH 03/13] Save emoji images to media library --- includes/collection/class-interactions.php | 101 +++++++++++++++++++-- 1 file changed, 93 insertions(+), 8 deletions(-) diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index 7b7b7266a..b5655c113 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -383,15 +383,100 @@ private static function replace_custom_emoji( $text, $activity ) { foreach ( $activity['tag'] as $tag ) { if ( isset( $tag['type'] ) && 'Emoji' === $tag['type'] && ! empty( $tag['name'] ) && ! empty( $tag['icon']['url'] ) ) { - $text = str_replace( - $tag['name'], - sprintf( - '%s', - \esc_url( $tag['icon']['url'] ), - \esc_attr( $tag['name'] ) - ), - $text + $emoji_url = $tag['icon']['url']; + $emoji_name = $tag['name']; + + // Check for existing emoji by URL or placeholder. + $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' => array( + 'relation' => 'OR', + array( + 'key' => 'activitypub_emoji_source_url', + 'value' => $emoji_url, + ), + array( + 'key' => 'activitypub_emoji_placeholder', + 'value' => $emoji_name, + ), + ), + ) ); + + $attachment_id = 0; + $existing_attachment = 0; + $existing_placeholder = 0; + + // Sort through results to identify URL and placeholder matches. + foreach ( $existing_attachments as $post_id ) { + if ( \get_post_meta( $post_id, 'activitypub_emoji_source_url', true ) === $emoji_url ) { + $existing_attachment = $post_id; + } elseif ( \get_post_meta( $post_id, 'activitypub_emoji_placeholder', true ) === $emoji_name ) { + $existing_placeholder = $post_id; + } + } + + // If we have a different image for this placeholder, or no existing image, download the new one. + if ( empty( $existing_attachment ) || ! empty( $existing_placeholder ) ) { + if ( ! function_exists( '\download_url' ) ) { + require_once \ABSPATH . 'wp-admin/includes/file.php'; + } + + $temp_file = \download_url( $emoji_url ); + if ( ! \is_wp_error( $temp_file ) ) { + $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 ); + + if ( ! \is_wp_error( $attachment_id ) ) { + \update_post_meta( $attachment_id, 'activitypub_emoji_source_url', $emoji_url ); + \update_post_meta( $attachment_id, 'activitypub_emoji_placeholder', $emoji_name ); + } + } + + if ( \is_file( $temp_file ) ) { + \wp_delete_file( $temp_file ); + } + } else { + $attachment_id = $existing_attachment; + } + + if ( $attachment_id ) { + $image_url = \wp_get_attachment_url( $attachment_id ); + $text = str_replace( + $emoji_name, + sprintf( + '%s', + \esc_url( $image_url ), + \esc_attr( $emoji_name ) + ), + $text + ); + } else { + // Fallback to the original remote URL if something went wrong. + $text = str_replace( + $emoji_name, + sprintf( + '%s', + \esc_url( $emoji_url ), + \esc_attr( $emoji_name ) + ), + $text + ); + } } } From eca84b5f5fd75459b493c78fc01f6dde168c40d3 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 19 Feb 2025 17:26:16 -0600 Subject: [PATCH 04/13] Fix emoji display in wp-admin --- assets/css/activitypub-admin.css | 4 ++++ includes/class-comment.php | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/assets/css/activitypub-admin.css b/assets/css/activitypub-admin.css index de09ae94e..960724804 100644 --- a/assets/css/activitypub-admin.css +++ b/assets/css/activitypub-admin.css @@ -223,3 +223,7 @@ input.blog-user-identifier { .like .dashboard-comment-wrap .comment-author { margin-block: 0; } + +.edit-comments-php .column-author img.emoji { + float: none; +} diff --git a/includes/class-comment.php b/includes/class-comment.php index 50d15f8ed..17bfef8c7 100644 --- a/includes/class-comment.php +++ b/includes/class-comment.php @@ -31,6 +31,7 @@ public static function init() { \add_filter( 'pre_comment_approved', array( static::class, 'pre_comment_approved' ), 10, 2 ); \add_filter( 'get_avatar_comment_types', array( static::class, 'get_avatar_comment_types' ), 99 ); \add_filter( 'pre_wp_update_comment_count_now', array( static::class, 'pre_wp_update_comment_count_now' ), 10, 3 ); + \add_filter( 'comment_author', array( static::class, 'allow_emoji' ) ); } /** @@ -795,4 +796,18 @@ public static function pre_wp_update_comment_count_now( $new_count, $old_count, return $new_count; } + + /** + * Allow emoji in comment author name. + * + * @param string $author The comment author name. + * @return string The comment author name with rendered emoji. + */ + public static function allow_emoji( $author ) { + if ( false !== strpos( $author, 'emoji' ) ) { + $author = str_replace( '“', '"', \html_entity_decode( $author ) ); + } + + return $author; + } } From 7590c809b61ac92ce4fc7f10e5b42d4ba7d2b260 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Thu, 11 Sep 2025 15:34:03 -0500 Subject: [PATCH 05/13] Optimize emoji processing performance Replace multiple database queries with single batched query and fast lookup arrays. This eliminates N+1 query problem where each emoji required separate database calls. Changes: - Single get_posts() query instead of one per emoji - Fast O(1) lookup arrays instead of O(n) loops - Consolidated meta query building into single loop - Removed redundant array collections --- includes/collection/class-interactions.php | 185 ++++++++++++--------- 1 file changed, 105 insertions(+), 80 deletions(-) diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index 8e49cf90d..c8ae05b1f 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -388,102 +388,127 @@ private static function replace_custom_emoji( $text, $activity ) { return $text; } + // Collect emoji data first. + $emoji_data = array(); + foreach ( $activity['tag'] as $tag ) { if ( isset( $tag['type'] ) && 'Emoji' === $tag['type'] && ! empty( $tag['name'] ) && ! empty( $tag['icon']['url'] ) ) { - $emoji_url = $tag['icon']['url']; - $emoji_name = $tag['name']; - - // Check for existing emoji by URL or placeholder. - $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' => array( - 'relation' => 'OR', - array( - 'key' => 'activitypub_emoji_source_url', - 'value' => $emoji_url, - ), - array( - 'key' => 'activitypub_emoji_placeholder', - 'value' => $emoji_name, - ), - ), - ) + $emoji_data[] = array( + 'url' => $tag['icon']['url'], + 'name' => $tag['name'], ); + } + } - $attachment_id = 0; - $existing_attachment = 0; - $existing_placeholder = 0; + if ( empty( $emoji_data ) ) { + return $text; + } - // Sort through results to identify URL and placeholder matches. - foreach ( $existing_attachments as $post_id ) { - if ( \get_post_meta( $post_id, 'activitypub_emoji_source_url', true ) === $emoji_url ) { - $existing_attachment = $post_id; - } elseif ( \get_post_meta( $post_id, 'activitypub_emoji_placeholder', true ) === $emoji_name ) { - $existing_placeholder = $post_id; - } - } + // 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'], + ); + } - // If we have a different image for this placeholder, or no existing image, download the new one. - if ( empty( $existing_attachment ) || ! empty( $existing_placeholder ) ) { - if ( ! function_exists( '\download_url' ) ) { - require_once \ABSPATH . 'wp-admin/includes/file.php'; - } + $existing_attachments = \get_posts( + array( + 'post_type' => 'attachment', + 'posts_per_page' => -1, + 'fields' => 'ids', - $temp_file = \download_url( $emoji_url ); - if ( ! \is_wp_error( $temp_file ) ) { - $file_array = array( - 'name' => \wp_basename( $emoji_url ), - 'tmp_name' => $temp_file, - ); + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => $meta_query, + ) + ); - if ( ! function_exists( '\media_handle_sideload' ) ) { - require_once \ABSPATH . 'wp-admin/includes/media.php'; - require_once \ABSPATH . 'wp-admin/includes/image.php'; - } + // Build lookup arrays for fast access. + $url_to_attachment = array(); + $name_to_attachment = array(); - $attachment_id = \media_handle_sideload( $file_array, 0, $emoji_name ); + 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 ( ! \is_wp_error( $attachment_id ) ) { - \update_post_meta( $attachment_id, 'activitypub_emoji_source_url', $emoji_url ); - \update_post_meta( $attachment_id, 'activitypub_emoji_placeholder', $emoji_name ); - } + if ( $source_url ) { + $url_to_attachment[ $source_url ] = $post_id; + } + if ( $placeholder ) { + $name_to_attachment[ $placeholder ] = $post_id; + } + } + + // Now process each emoji using the lookup arrays. + foreach ( $emoji_data as $emoji ) { + $emoji_url = $emoji['url']; + $emoji_name = $emoji['name']; + + $attachment_id = 0; + $existing_attachment = $url_to_attachment[ $emoji_url ] ?? 0; + $existing_placeholder = $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 ) ) { + if ( ! function_exists( '\download_url' ) ) { + require_once \ABSPATH . 'wp-admin/includes/file.php'; + } + + $temp_file = \download_url( $emoji_url ); + if ( ! \is_wp_error( $temp_file ) ) { + $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'; } - if ( \is_file( $temp_file ) ) { - \wp_delete_file( $temp_file ); + $attachment_id = \media_handle_sideload( $file_array, 0, $emoji_name ); + + if ( ! \is_wp_error( $attachment_id ) ) { + \update_post_meta( $attachment_id, 'activitypub_emoji_source_url', $emoji_url ); + \update_post_meta( $attachment_id, 'activitypub_emoji_placeholder', $emoji_name ); } - } else { - $attachment_id = $existing_attachment; } - if ( $attachment_id ) { - $image_url = \wp_get_attachment_url( $attachment_id ); - $text = str_replace( - $emoji_name, - sprintf( - '%s', - \esc_url( $image_url ), - \esc_attr( $emoji_name ) - ), - $text - ); - } else { - // Fallback to the original remote URL if something went wrong. - $text = str_replace( - $emoji_name, - sprintf( - '%s', - \esc_url( $emoji_url ), - \esc_attr( $emoji_name ) - ), - $text - ); + if ( \is_file( $temp_file ) ) { + \wp_delete_file( $temp_file ); } + } else { + $attachment_id = $existing_attachment; + } + + if ( $attachment_id ) { + $image_url = \wp_get_attachment_url( $attachment_id ); + $text = str_replace( + $emoji_name, + sprintf( + '%s', + \esc_url( $image_url ), + \esc_attr( $emoji_name ) + ), + $text + ); + } else { + // Fallback to the original remote URL if something went wrong. + $text = str_replace( + $emoji_name, + sprintf( + '%s', + \esc_url( $emoji_url ), + \esc_attr( $emoji_name ) + ), + $text + ); } } From a4fb5c56ccf0f9bd1c4c268c960964c39d475abb Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Thu, 11 Sep 2025 15:39:18 -0500 Subject: [PATCH 06/13] Fix temp file cleanup for emoji downloads Move temp file cleanup inside success block to prevent cleanup attempts when no file was created. This ensures temp files are properly cleaned up only when download_url() succeeds. --- includes/collection/class-interactions.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index c8ae05b1f..47971d181 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -478,10 +478,11 @@ private static function replace_custom_emoji( $text, $activity ) { \update_post_meta( $attachment_id, 'activitypub_emoji_source_url', $emoji_url ); \update_post_meta( $attachment_id, 'activitypub_emoji_placeholder', $emoji_name ); } - } - if ( \is_file( $temp_file ) ) { - \wp_delete_file( $temp_file ); + // Clean up temp file after processing. + if ( \is_file( $temp_file ) ) { + \wp_delete_file( $temp_file ); + } } } else { $attachment_id = $existing_attachment; From fe08d75493c0ff726d121a3afcf3983911ad5281 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Thu, 11 Sep 2025 15:42:18 -0500 Subject: [PATCH 07/13] Set timeout for emoji downloads Adds a 10 second timeout to the download_url call when downloading emoji files to prevent long-running requests. --- includes/collection/class-interactions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index 47971d181..72339d676 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -460,7 +460,7 @@ private static function replace_custom_emoji( $text, $activity ) { require_once \ABSPATH . 'wp-admin/includes/file.php'; } - $temp_file = \download_url( $emoji_url ); + $temp_file = \download_url( $emoji_url, 10 ); // 10 second timeout for emoji downloads. if ( ! \is_wp_error( $temp_file ) ) { $file_array = array( 'name' => \wp_basename( $emoji_url ), From c14786734e5954039bff67c06f1bb49c1d305b3f Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Thu, 11 Sep 2025 16:06:05 -0500 Subject: [PATCH 08/13] Fix fragile emoji detection in comment author names - Use get_comment_author filter instead of comment_author for proper timing - Improve emoji detection to check for class="emoji" instead of just "emoji" - Use proper HTML entity decoding with ENT_QUOTES | ENT_HTML5 flags - Add test coverage for emoji in comment author names --- includes/class-comment.php | 6 ++-- .../collection/class-test-interactions.php | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/includes/class-comment.php b/includes/class-comment.php index cfcd09596..e74285c0f 100644 --- a/includes/class-comment.php +++ b/includes/class-comment.php @@ -33,7 +33,7 @@ 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( 'comment_author', array( static::class, 'allow_emoji' ) ); + \add_filter( 'get_comment_author', array( static::class, 'allow_emoji' ) ); } /** @@ -818,8 +818,8 @@ public static function is_comment_type_enabled( $comment_type ) { * @return string The comment author name with rendered emoji. */ public static function allow_emoji( $author ) { - if ( false !== strpos( $author, 'emoji' ) ) { - $author = str_replace( '"', '"', \html_entity_decode( $author ) ); + if ( false !== strpos( $author, 'class="emoji"' ) ) { + $author = \html_entity_decode( $author, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } return $author; diff --git a/tests/includes/collection/class-test-interactions.php b/tests/includes/collection/class-test-interactions.php index 4e3138bdc..f7db6cfb9 100644 --- a/tests/includes/collection/class-test-interactions.php +++ b/tests/includes/collection/class-test-interactions.php @@ -478,6 +478,34 @@ function () { * @covers ::replace_custom_emoji */ public function test_activity_to_comment_with_emoji() { + // Mock actor with emoji in name. + \add_filter( + 'pre_get_remote_metadata_by_actor', + function ( $value, $actor ) { + return array( + 'name' => 'Test User :kappa:', + 'icon' => array( + 'url' => 'https://example.com/icon', + ), + 'url' => $actor, + 'id' => 'http://example.org/users/example', + 'tag' => array( + array( + 'type' => 'Emoji', + 'name' => ':kappa:', + 'icon' => array( + 'type' => 'Image', + 'mediaType' => 'image/png', + 'url' => 'https://example.com/files/kappa.png', + ), + ), + ), + ); + }, + 10, + 2 + ); + $activity = array( '@context' => array( 'https://www.w3.org/ns/activitystreams', @@ -519,6 +547,7 @@ public function test_activity_to_comment_with_emoji() { $comment_id = Interactions::add_comment( $activity ); $comment = get_comment( $comment_id ); + // Test emoji replacement in comment content. $this->assertStringContainsString( ':kappa:', $comment->comment_content @@ -527,6 +556,10 @@ public function test_activity_to_comment_with_emoji() { ':smile:', $comment->comment_content ); + + // Test emoji replacement in author name. + $author_with_emoji = get_comment_author( $comment_id ); + $this->assertSame( 'Test User :kappa:', $author_with_emoji ); } /** From 8d4505a833883df655873ba1f0a4c1c65e06f0c1 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Thu, 11 Sep 2025 16:29:14 -0500 Subject: [PATCH 09/13] Refactor emoji processing methods for better maintainability Break up large replace_custom_emoji method into focused single-responsibility methods: - extract_emoji_data(): Parse emoji from activity tags - get_emoji_attachments(): Handle database queries and build lookup arrays - get_or_create_emoji_attachment(): Decision logic for reuse vs download - download_emoji(): Handle file download and WordPress attachment creation - replace_emoji_in_text(): Handle text replacement with HTML Improvements: - Better separation of concerns following SRP - Improved documentation with detailed parameter types - Cleaner alt text (removes colons from emoji names) - Simplified fallback logic using consistent emoji URLs --- includes/collection/class-interactions.php | 212 ++++++++++++++------- 1 file changed, 141 insertions(+), 71 deletions(-) diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index 72339d676..a698ad5ac 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -384,11 +384,38 @@ public static function count_by_type( $post_id, $type ) { * @return string The processed text with emoji replacements. */ private static function replace_custom_emoji( $text, $activity ) { - if ( empty( $activity['tag'] ) || ! is_array( $activity['tag'] ) ) { + $emoji_data = self::extract_emoji_data( $activity ); + if ( empty( $emoji_data ) ) { return $text; } - // Collect emoji data first. + $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 ) { @@ -400,10 +427,27 @@ private static function replace_custom_emoji( $text, $activity ) { } } - if ( empty( $emoji_data ) ) { - return $text; - } + 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' ); @@ -445,74 +489,100 @@ private static function replace_custom_emoji( $text, $activity ) { } } - // Now process each emoji using the lookup arrays. - foreach ( $emoji_data as $emoji ) { - $emoji_url = $emoji['url']; - $emoji_name = $emoji['name']; - - $attachment_id = 0; - $existing_attachment = $url_to_attachment[ $emoji_url ] ?? 0; - $existing_placeholder = $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 ) ) { - 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 ) ) { - $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 ); - - if ( ! \is_wp_error( $attachment_id ) ) { - \update_post_meta( $attachment_id, 'activitypub_emoji_source_url', $emoji_url ); - \update_post_meta( $attachment_id, 'activitypub_emoji_placeholder', $emoji_name ); - } - - // Clean up temp file after processing. - if ( \is_file( $temp_file ) ) { - \wp_delete_file( $temp_file ); - } - } - } else { - $attachment_id = $existing_attachment; - } + return array( + 'url_to_attachment' => $url_to_attachment, + 'name_to_attachment' => $name_to_attachment, + ); + } - if ( $attachment_id ) { - $image_url = \wp_get_attachment_url( $attachment_id ); - $text = str_replace( - $emoji_name, - sprintf( - '%s', - \esc_url( $image_url ), - \esc_attr( $emoji_name ) - ), - $text - ); - } else { - // Fallback to the original remote URL if something went wrong. - $text = str_replace( - $emoji_name, - sprintf( - '%s', - \esc_url( $emoji_url ), - \esc_attr( $emoji_name ) - ), - $text - ); - } + /** + * 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 $text; + 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( + '%s', + \esc_url( $emoji_url ), + \esc_attr( $emoji['name'] ) + ), + $text + ); } } From fbf3205988f93abca0c967f7b763e66bde9b52f8 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Thu, 11 Sep 2025 16:36:02 -0500 Subject: [PATCH 10/13] Refactor emoji handling to dedicated Emoji class Moved custom emoji processing logic from Interactions to a new Activitypub\Emoji class for better separation of concerns and maintainability. Updated Interactions and related tests to use the new Emoji class. --- includes/class-emoji.php | 225 ++++++++++++++++++ includes/collection/class-interactions.php | 220 +---------------- .../collection/class-test-interactions.php | 2 +- 3 files changed, 231 insertions(+), 216 deletions(-) create mode 100644 includes/class-emoji.php diff --git a/includes/class-emoji.php b/includes/class-emoji.php new file mode 100644 index 000000000..f15443b35 --- /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( + '%s', + \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 a698ad5ac..a703205ff 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; @@ -84,13 +85,13 @@ public static function update_comment( $activity ) { add_filter( 'pre_comment_author_name', function ( $comment_author ) use ( $meta ) { - return self::replace_custom_emoji( $comment_author, $meta ); + return Emoji::replace_custom_emoji( $comment_author, $meta ); } ); add_filter( 'pre_comment_content', function ( $comment_content ) use ( $activity ) { - return self::replace_custom_emoji( $comment_content, $activity['object'] ); + return Emoji::replace_custom_emoji( $comment_content, $activity['object'] ); }, 20 ); @@ -300,13 +301,13 @@ public static function activity_to_comment( $activity ) { add_filter( 'pre_comment_author_name', function ( $comment_author ) use ( $actor ) { - return self::replace_custom_emoji( $comment_author, $actor ); + return Emoji::replace_custom_emoji( $comment_author, $actor ); } ); add_filter( 'pre_comment_content', function ( $comment_content ) use ( $activity ) { - return self::replace_custom_emoji( $comment_content, $activity['object'] ); + return Emoji::replace_custom_emoji( $comment_content, $activity['object'] ); }, 20 ); @@ -374,215 +375,4 @@ public static function count_by_type( $post_id, $type ) { ) ); } - - /** - * 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. - */ - private 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( - '%s', - \esc_url( $emoji_url ), - \esc_attr( $emoji['name'] ) - ), - $text - ); - } } diff --git a/tests/includes/collection/class-test-interactions.php b/tests/includes/collection/class-test-interactions.php index f7db6cfb9..455c1525b 100644 --- a/tests/includes/collection/class-test-interactions.php +++ b/tests/includes/collection/class-test-interactions.php @@ -475,7 +475,7 @@ function () { * Test emoji replacement in activity_to_comment. * * @covers ::activity_to_comment - * @covers ::replace_custom_emoji + * @covers \Activitypub\Emoji::replace_custom_emoji */ public function test_activity_to_comment_with_emoji() { // Mock actor with emoji in name. From 0716930b893748691074665427e8d8fb748b4f59 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Thu, 2 Oct 2025 12:58:16 -0500 Subject: [PATCH 11/13] Fix tests --- .../collection/class-test-interactions.php | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/includes/collection/class-test-interactions.php b/tests/includes/collection/class-test-interactions.php index 455c1525b..082b78bb7 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

', + 'content' => 'Hello
example

example

', ), ); } @@ -237,7 +239,7 @@ public function test_convert_object_to_comment_already_exists_rejected() { $object = $this->create_test_object( 'https://example.com/test_convert_object_to_comment_already_exists_rejected' ); Interactions::add_comment( $object ); $converted = Interactions::add_comment( $object ); - $this->assertEquals( $converted->get_error_code(), 'comment_duplicate' ); + $this->assertEquals( 'comment_duplicate', $converted->get_error_code() ); } /** @@ -249,7 +251,7 @@ public function test_convert_object_to_comment_reply_to_comment() { $id = 'https://example.com/test_convert_object_to_comment_reply_to_comment'; $object = $this->create_test_object( $id ); Interactions::add_comment( $object ); - $comment = \Activitypub\object_id_to_comment( $id ); + $comment = object_id_to_comment( $id ); $object['object']['inReplyTo'] = $id; $object['object']['id'] = 'https://example.com/234'; @@ -282,7 +284,7 @@ public function test_handle_create_basic2() { $id = 'https://example.com/test_handle_create_basic'; $object = $this->create_test_object( $id ); Interactions::add_comment( $object ); - $comment = \Activitypub\object_id_to_comment( $id ); + $comment = object_id_to_comment( $id ); $this->assertInstanceOf( \WP_Comment::class, $comment ); } @@ -298,12 +300,12 @@ public function test_get_interaction_by_id() { $object['object']['url'] = $url; Interactions::add_comment( $object ); - $comment = \Activitypub\object_id_to_comment( $id ); + $comment = object_id_to_comment( $id ); $interactions = Interactions::get_interaction_by_id( $id ); $this->assertIsArray( $interactions ); $this->assertEquals( $comment->comment_ID, $interactions[0]->comment_ID ); - $comment = \Activitypub\object_id_to_comment( $id ); + $comment = object_id_to_comment( $id ); $interactions = Interactions::get_interaction_by_id( $url ); $this->assertIsArray( $interactions ); $this->assertEquals( $comment->comment_ID, $interactions[0]->comment_ID ); @@ -549,17 +551,17 @@ function ( $value, $actor ) { // Test emoji replacement in comment content. $this->assertStringContainsString( - ':kappa:', + 'kappa', $comment->comment_content ); $this->assertStringContainsString( - ':smile:', + 'smile', $comment->comment_content ); // Test emoji replacement in author name. $author_with_emoji = get_comment_author( $comment_id ); - $this->assertSame( 'Test User :kappa:', $author_with_emoji ); + $this->assertSame( 'Test User kappa', $author_with_emoji ); } /** From 221af33f970a841451d59ecbba0aff566d7e3821 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Thu, 2 Oct 2025 13:48:07 -0500 Subject: [PATCH 12/13] whitespace --- includes/class-emoji.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/class-emoji.php b/includes/class-emoji.php index f15443b35..43fcc74dd 100644 --- a/includes/class-emoji.php +++ b/includes/class-emoji.php @@ -44,7 +44,7 @@ public static function replace_custom_emoji( $text, $activity ) { * @return array { * Array of emoji data with url and name keys. * - * @type string $url The URL of the emoji image. + * @type string $url The URL of the emoji image. * @type string $name The shortcode name of the emoji (e.g., ":emoji:"). * } */ @@ -73,7 +73,7 @@ private static function extract_emoji_data( $activity ) { * @param array $emoji_data { * Array of emoji data with url and name keys. * - * @type string $url The URL of the emoji image. + * @type string $url The URL of the emoji image. * @type string $name The shortcode name of the emoji (e.g., ":emoji:"). * } * From c956b33d913fd49bc4dfe4586cd3f26927a9b5d0 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Thu, 2 Oct 2025 15:11:56 -0500 Subject: [PATCH 13/13] Store emoji shortcodes in database, replace on display Instead of storing HTML markup in the comment_author field, now stores the shortcode (e.g., ":emoji:") and replaces it on display. This makes the implementation more future-proof: - If the plugin is disabled, shortcodes display gracefully - Emoji images can be updated dynamically - Database stays clean without HTML markup Implementation: - Store ActivityPub emoji tag data in comment meta - Use get_comment_author filter to replace shortcodes with img tags - Use comment_author filter to selectively unescape only emoji images - Maintain WordPress security by only unescaping known emoji markup --- includes/class-comment.php | 42 ++++++++++++++++--- includes/collection/class-interactions.php | 19 +++------ .../collection/class-test-interactions.php | 11 ++++- 3 files changed, 52 insertions(+), 20 deletions(-) diff --git a/includes/class-comment.php b/includes/class-comment.php index 4db0d90cd..690ac007f 100644 --- a/includes/class-comment.php +++ b/includes/class-comment.php @@ -33,7 +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, 'allow_emoji' ) ); + \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(). } /** @@ -818,13 +819,44 @@ public static function is_comment_type_enabled( $comment_type ) { } /** - * Allow emoji in comment author name. + * Render emoji in comment author name. * - * @param string $author The 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 allow_emoji( $author ) { - if ( false !== strpos( $author, 'class="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' ); } diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index 8601312d8..f3b77e981 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -82,18 +82,12 @@ 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_author_name', - function ( $comment_author ) use ( $meta ) { - return Emoji::replace_custom_emoji( $comment_author, $meta ); - } - ); add_filter( 'pre_comment_content', function ( $comment_content ) use ( $activity ) { return Emoji::replace_custom_emoji( $comment_content, $activity['object'] ); }, - 20 + 20 // After wp_filter_post_kses(). ); return self::persist( $comment_data, self::UPDATE ); @@ -298,12 +292,11 @@ public static function activity_to_comment( $activity ) { $comment_data['comment_meta']['source_url'] = \esc_url_raw( object_to_uri( $activity['object']['url'] ) ); } - add_filter( - 'pre_comment_author_name', - function ( $comment_author ) use ( $actor ) { - return Emoji::replace_custom_emoji( $comment_author, $actor ); - } - ); + // 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 ) { diff --git a/tests/includes/collection/class-test-interactions.php b/tests/includes/collection/class-test-interactions.php index 082b78bb7..e920534db 100644 --- a/tests/includes/collection/class-test-interactions.php +++ b/tests/includes/collection/class-test-interactions.php @@ -559,9 +559,16 @@ function ( $value, $actor ) { $comment->comment_content ); - // Test emoji replacement in author name. + // Test that shortcode is stored in database, not HTML. + $this->assertSame( 'Test User :kappa:', $comment->comment_author ); + + // Test that emoji metadata is stored. + $emoji_data = get_comment_meta( $comment_id, 'activitypub_author_emoji', true ); + $this->assertNotEmpty( $emoji_data ); + + // Test emoji replacement on display via comment_author filter. $author_with_emoji = get_comment_author( $comment_id ); - $this->assertSame( 'Test User kappa', $author_with_emoji ); + $this->assertStringContainsString( 'kappa', $author_with_emoji ); } /**