Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
d727234
Refactor avatar handling to use remote actor meta
pfefferle Oct 27, 2025
27a3bf1
Remove avatar_url meta from comment creation
pfefferle Oct 27, 2025
2d4a06d
Update includes/collection/class-remote-actors.php
pfefferle Oct 27, 2025
1326fcc
Improve avatar URL handling and add migration tests
pfefferle Oct 27, 2025
2c18173
Refactor avatar URL handling in Remote_Actors
pfefferle Oct 27, 2025
9a72a3b
Optimize comment meta query in migration class
pfefferle Oct 27, 2025
a887f63
Merge branch 'trunk' into improve-avatar-handling
pfefferle Oct 27, 2025
8063d28
Add optimized remote actor queries and avatar tests
pfefferle Oct 27, 2025
67f4b6f
Merge branch 'trunk' into improve-avatar-handling
pfefferle Oct 27, 2025
c918461
Refactor remote actor deletion to use actor ID
pfefferle Oct 27, 2025
7d8fb8e
Merge branch 'trunk' into improve-avatar-handling
pfefferle Oct 27, 2025
712c3a4
Add changelog
matticbot Oct 27, 2025
a562838
Merge branch 'trunk' into improve-avatar-handling
pfefferle Oct 28, 2025
bf10eb0
Update includes/collection/class-remote-actors.php
pfefferle Oct 28, 2025
6491890
Add return type to delete_remote_actor docblock
pfefferle Oct 28, 2025
4aab7a1
Merge branch 'trunk' into improve-avatar-handling
pfefferle Oct 28, 2025
524b91f
Update includes/collection/class-remote-actors.php
pfefferle Oct 29, 2025
bcdd915
Update includes/collection/class-remote-actors.php
pfefferle Oct 29, 2025
ebf3c62
Update includes/collection/class-remote-actors.php
pfefferle Oct 29, 2025
b33c8b4
Update tests/phpunit/tests/includes/class-test-migration.php
pfefferle Oct 29, 2025
baf34f7
Remove WP_Post type check for remote actor
pfefferle Oct 29, 2025
396df8f
Refactor comment creation in migration test
pfefferle Oct 29, 2025
7c7b4b2
Remove cleanup code from migration tests
pfefferle Oct 29, 2025
9c23bb1
Optimize avatar migration query and update meta check
pfefferle Oct 29, 2025
1979e8e
Update includes/class-migration.php
pfefferle Oct 29, 2025
b769c32
Merge branch 'trunk' into improve-avatar-handling
pfefferle Oct 29, 2025
1f75317
Update avatar URL storage logic in Remote_Actors
pfefferle Oct 29, 2025
299c898
Merge branch 'trunk' into improve-avatar-handling
pfefferle Oct 29, 2025
5ff43e1
Rename Avatar class to Avatars and update references
pfefferle Oct 29, 2025
a472a99
Set default avatar URL when icon is missing
pfefferle Oct 29, 2025
34753a6
Merge branch 'trunk' into improve-avatar-handling
pfefferle Oct 29, 2025
ce06914
Update test for get_avatar_url default behavior
pfefferle Oct 29, 2025
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
1 change: 1 addition & 0 deletions activitypub.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ function rest_init() {
*/
function plugin_init() {
\add_action( 'init', array( __NAMESPACE__ . '\Activitypub', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Avatar', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Comment', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Dispatcher', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Embed', 'init' ) );
Expand Down
52 changes: 0 additions & 52 deletions includes/class-activitypub.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ public static function init() {
\add_action( 'init', array( self::class, 'theme_compat' ), 11 );
\add_action( 'init', array( self::class, 'register_user_meta' ), 11 );

\add_filter( 'pre_get_avatar_data', array( self::class, 'pre_get_avatar_data' ), 11, 2 );

\add_action( 'wp_trash_post', array( self::class, 'trash_post' ), 1 );
\add_action( 'untrash_post', array( self::class, 'untrash_post' ), 1 );

Expand Down Expand Up @@ -90,56 +88,6 @@ public static function uninstall() {
Options::delete();
}

/**
* Replaces the default avatar.
*
* @param array $args Arguments passed to get_avatar_data(), after processing.
* @param int|string|object $id_or_email A user ID, email address, or comment object.
*
* @return array $args
*/
public static function pre_get_avatar_data( $args, $id_or_email ) {
if (
! $id_or_email instanceof \WP_Comment ||
! isset( $id_or_email->comment_type ) ||
$id_or_email->user_id
) {
return $args;
}

/**
* Filter allowed comment types for avatars.
*
* @param array $allowed_comment_types Array of allowed comment types.
*/
$allowed_comment_types = \apply_filters( 'get_avatar_comment_types', array( 'comment' ) );
if ( ! \in_array( $id_or_email->comment_type ?: 'comment', $allowed_comment_types, true ) ) { // phpcs:ignore Universal.Operators.DisallowShortTernary
return $args;
}

// Check if comment has an avatar.
$avatar = \get_comment_meta( $id_or_email->comment_ID, 'avatar_url', true );

if ( $avatar ) {
if ( empty( $args['class'] ) ) {
$args['class'] = array();
} elseif ( \is_string( $args['class'] ) ) {
$args['class'] = \explode( ' ', $args['class'] );
}

/** This filter is documented in wp-includes/link-template.php */
$args['url'] = \apply_filters( 'get_avatar_url', $avatar, $id_or_email, $args );
$args['class'][] = 'avatar';
$args['class'][] = 'avatar-activitypub';
$args['class'][] = 'avatar-' . (int) $args['size'];
$args['class'][] = 'photo';
$args['class'][] = 'u-photo';
$args['class'] = \array_unique( $args['class'] );
}

return $args;
}

/**
* Store permalink in meta, to send delete Activity.
*
Expand Down
82 changes: 82 additions & 0 deletions includes/class-avatar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php
/**
* Avatar class file.
*
* @package Activitypub
*/

namespace Activitypub;

use Activitypub\Collection\Remote_Actors;

/**
* ActivityPub Avatar class.
*/
class Avatar {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'pre_get_avatar_data', array( self::class, 'pre_get_avatar_data' ), 11, 2 );
}

/**
* Replaces the default avatar.
*
* @param array $args Arguments passed to get_avatar_data(), after processing.
* @param int|string|object $id_or_email A user ID, email address, or comment object.
*
* @return array $args
*/
public static function pre_get_avatar_data( $args, $id_or_email ) {
if (
! $id_or_email instanceof \WP_Comment ||
! isset( $id_or_email->comment_type ) ||
$id_or_email->user_id
) {
return $args;
}

/**
* Filter allowed comment types for avatars.
*
* @param array $allowed_comment_types Array of allowed comment types.
*/
$allowed_comment_types = \apply_filters( 'get_avatar_comment_types', array( 'comment' ) );
if ( ! \in_array( $id_or_email->comment_type ?: 'comment', $allowed_comment_types, true ) ) { // phpcs:ignore Universal.Operators.DisallowShortTernary
return $args;
}

$avatar = null;

// First, try to get avatar from remote actor.
$remote_actor_id = \get_comment_meta( $id_or_email->comment_ID, '_activitypub_remote_actor_id', true );
if ( $remote_actor_id ) {
$avatar = Remote_Actors::get_avatar_url( $remote_actor_id );
}

// Fall back to avatar_url comment meta for backward compatibility.
if ( ! $avatar ) {
$avatar = \get_comment_meta( $id_or_email->comment_ID, 'avatar_url', true );
}

if ( $avatar ) {
if ( empty( $args['class'] ) ) {
$args['class'] = array();
} elseif ( \is_string( $args['class'] ) ) {
$args['class'] = \explode( ' ', $args['class'] );
}

/** This filter is documented in wp-includes/link-template.php */
$args['url'] = \apply_filters( 'get_avatar_url', $avatar, $id_or_email, $args );
$args['class'][] = 'avatar';
$args['class'][] = 'avatar-activitypub';
$args['class'][] = 'avatar-' . (int) $args['size'];
$args['class'][] = 'photo';
$args['class'][] = 'u-photo';
$args['class'] = \array_unique( $args['class'] );
}

return $args;
}
}
73 changes: 73 additions & 0 deletions includes/class-migration.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public static function init() {
Scheduler::register_async_batch_callback( 'activitypub_update_comment_counts', array( self::class, 'update_comment_counts' ) );
Scheduler::register_async_batch_callback( 'activitypub_create_post_outbox_items', array( self::class, 'create_post_outbox_items' ) );
Scheduler::register_async_batch_callback( 'activitypub_create_comment_outbox_items', array( self::class, 'create_comment_outbox_items' ) );
Scheduler::register_async_batch_callback( 'activitypub_migrate_avatar_to_remote_actors', array( self::class, 'migrate_avatar_to_remote_actors' ) );
}

/**
Expand Down Expand Up @@ -211,6 +212,7 @@ public static function maybe_migrate() {

if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) {
self::clean_up_inbox();
\wp_schedule_single_event( \time(), 'activitypub_migrate_avatar_to_remote_actors' );
}

// Ensure all required cron schedules are registered.
Expand Down Expand Up @@ -1079,4 +1081,75 @@ private static function clean_up_inbox() {
\wp_delete_post( $post_id, true );
}
}

/**
* Migrate avatar URLs from comment meta to remote actors in batches.
*
* This migration:
* 1. Finds all comments with ActivityPub protocol and avatar_url meta
* 2. Looks up the remote actor by comment_author_url
* 3. Adds _activitypub_remote_actor_id to comment meta
* 4. Stores avatar_url in remote actor post meta
*
* Note: We don't use offset because as we add _activitypub_remote_actor_id,
* comments are filtered out of the query. We just keep fetching the next
* batch until no more comments match the criteria.
*
* @param int $batch_size Optional. Number of comments to process per batch. Default 50.
* @return array|null Array with batch size if there are more comments to process, null otherwise.
*/
public static function migrate_avatar_to_remote_actors( $batch_size = 50 ) {
global $wpdb;

// Get comments with avatar_url meta that don't have _activitypub_remote_actor_id yet.
// No offset needed - as we process comments, they're filtered out by the LEFT JOIN.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$comments = $wpdb->get_results(
$wpdb->prepare(
"SELECT c.comment_ID, c.comment_author_url, m.meta_value as avatar_url
FROM {$wpdb->comments} c
INNER JOIN {$wpdb->commentmeta} m1 ON c.comment_ID = m1.comment_id AND m1.meta_key = 'protocol' AND m1.meta_value = 'activitypub'
INNER JOIN {$wpdb->commentmeta} m ON c.comment_ID = m.comment_id AND m.meta_key = 'avatar_url'
LEFT JOIN {$wpdb->commentmeta} m2 ON c.comment_ID = m2.comment_id AND m2.meta_key = '_activitypub_remote_actor_id'
WHERE m2.meta_id IS NULL
LIMIT %d",
$batch_size
)
);

foreach ( $comments as $comment ) {
if ( empty( $comment->comment_author_url ) ) {
continue;
}

// Try to get the remote actor by URI.
$remote_actor = Remote_Actors::get_by_uri( $comment->comment_author_url );

// If not found, try to fetch it remotely.
if ( \is_wp_error( $remote_actor ) ) {
$remote_actor = Remote_Actors::fetch_by_uri( $comment->comment_author_url );
}

// If we have a valid remote actor, store the reference.
if ( ! \is_wp_error( $remote_actor ) && $remote_actor instanceof \WP_Post ) {
// Add _activitypub_remote_actor_id to comment meta.
\add_comment_meta( $comment->comment_ID, '_activitypub_remote_actor_id', $remote_actor->ID, true );

// Ensure avatar is stored on remote actor if not already present.
$existing_avatar = \get_post_meta( $remote_actor->ID, '_activitypub_avatar_url', true );
if ( empty( $existing_avatar ) && ! empty( $comment->avatar_url ) ) {
\update_post_meta( $remote_actor->ID, '_activitypub_avatar_url', \esc_url_raw( $comment->avatar_url ) );
}
}
}

// Return batch info if there are more comments to process.
if ( count( $comments ) === $batch_size ) {
return array(
'batch_size' => $batch_size,
);
}

return null;
}
}
9 changes: 7 additions & 2 deletions includes/collection/class-interactions.php
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,13 @@ public static function activity_to_comment( $activity ) {
),
);

if ( isset( $actor['icon']['url'] ) ) {
$comment_data['comment_meta']['avatar_url'] = \esc_url_raw( $actor['icon']['url'] );
// Store reference to remote actor post.
$actor_uri = object_to_uri( $activity['actor'] ?? null );
if ( $actor_uri ) {
$remote_actor = Remote_Actors::get_by_uri( $actor_uri );
if ( ! \is_wp_error( $remote_actor ) && $remote_actor instanceof \WP_Post ) {
$comment_data['comment_meta']['_activitypub_remote_actor_id'] = $remote_actor->ID;
}
}

if ( isset( $activity['object']['url'] ) ) {
Expand Down
49 changes: 46 additions & 3 deletions includes/collection/class-remote-actors.php
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,19 @@ private static function prepare_custom_post_type( $actor ) {
\add_filter( 'activitypub_activity_object_array', array( 'Activitypub\Hashtag', 'filter_activity_object' ), 99 );
\add_filter( 'activitypub_activity_object_array', array( 'Activitypub\Link', 'filter_activity_object' ), 99 );

$meta_input = array(
'_activitypub_inbox' => $inbox,
);

// Store avatar URL if available, or empty string to clear it.
$icon = $actor->get_icon();
if ( ! empty( $icon['url'] ) ) {
$meta_input['_activitypub_avatar_url'] = \esc_url_raw( $icon['url'] );
} else {
// Set to empty string to clear the meta if avatar was removed.
$meta_input['_activitypub_avatar_url'] = '';
}

return array(
'guid' => \esc_url_raw( $actor->get_id() ),
'post_title' => \wp_strip_all_tags( \wp_slash( $actor->get_name() ?? $actor->get_preferred_username() ) ),
Expand All @@ -523,9 +536,7 @@ private static function prepare_custom_post_type( $actor ) {
'post_content' => \wp_slash( $actor_json ),
'post_excerpt' => \wp_kses( \wp_slash( (string) $actor->get_summary() ), 'user_description' ),
'post_status' => 'publish',
'meta_input' => array(
'_activitypub_inbox' => $inbox,
),
'meta_input' => $meta_input,
);
}

Expand Down Expand Up @@ -625,4 +636,36 @@ public static function get_acct( $id ) {

return $acct;
}

/**
* Get the avatar URL for a remote actor.
*
* @param int $id The ID of the remote actor post.
*
* @return string The avatar URL or empty string if not found.
*/
public static function get_avatar_url( $id ) {
$avatar_url = \get_post_meta( $id, '_activitypub_avatar_url', true );

// If not in meta, try to extract from post_content JSON.
if ( $avatar_url ) {
return $avatar_url;
}

$post = \get_post( $id );
if ( ! $post || empty( $post->post_content ) ) {
return '';
}

$actor_data = \json_decode( $post->post_content, true );
if ( empty( $actor_data['icon']['url'] ) ) {
return '';
}

$avatar_url = $actor_data['icon']['url'];
// Cache it in meta for next time.
\update_post_meta( $id, '_activitypub_avatar_url', \esc_url_raw( $avatar_url ) );

return $avatar_url;
}
}
Loading