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( + '%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 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

', + '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 ); @@ -471,6 +473,104 @@ function () { remove_all_filters( 'pre_get_remote_metadata_by_actor' ); } + /** + * Test emoji replacement in activity_to_comment. + * + * @covers ::activity_to_comment + * @covers \Activitypub\Emoji::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', + array( + 'Emoji' => 'http://joinmastodon.org/ns#Emoji', + ), + ), + 'id' => 'https://example.com/activities/1', + 'type' => 'Note', + 'content' => 'Hello world :kappa: and :smile:', + 'actor' => self::$user_url, + 'object' => array( + 'id' => 'https://example.com/objects/1', + 'content' => 'Hello world :kappa: and :smile:', + 'inReplyTo' => self::$post_permalink, + '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', + ), + ), + ), + ), + ); + + $comment_id = Interactions::add_comment( $activity ); + $comment = get_comment( $comment_id ); + + // Test emoji replacement in comment content. + $this->assertStringContainsString( + 'kappa', + $comment->comment_content + ); + $this->assertStringContainsString( + 'smile', + $comment->comment_content + ); + + // 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->assertStringContainsString( 'kappa', $author_with_emoji ); + } + /** * Test that incoming likes and reposts are not collected when disabled. *