Skip to content

Commit 1d8070d

Browse files
authored
Merge branch 'trunk' into add/relay-mode
2 parents f689752 + df2e21c commit 1d8070d

File tree

9 files changed

+303
-15
lines changed

9 files changed

+303
-15
lines changed

.claude/skills/activitypub-pr-workflow/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ try/{idea} # Experimental ideas.
3535
git checkout -b add/new-feature
3636

3737
# Create PR with GitHub CLI.
38-
gh pr create --assignee @me --reviewer Fediverse
38+
gh pr create --assignee @me --reviewer Automattic/fediverse
3939

4040
# Check PR status and CI.
4141
gh pr status

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [7.7.1] - 2025-12-04
9+
### Fixed
10+
- Fix admin styling for quote comments to match likes and reposts [#2584]
11+
- Mastodon importer now unpacks nested archives instead of getting confused by the extra folder. [#2581]
12+
- Add individually specified recipients to public activities in shared inbox. [#2585]
13+
814
## [7.7.0] - 2025-12-03
915
### Added
1016
- Add documentation guide for using ActivityPub blocks in classic themes with Block Template Parts [#2577]
@@ -1567,6 +1573,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
15671573
### Added
15681574
- initial
15691575

1576+
[7.7.1]: https://github.com/Automattic/wordpress-activitypub/compare/7.7.0...7.7.1
15701577
[7.7.0]: https://github.com/Automattic/wordpress-activitypub/compare/7.6.1...7.7.0
15711578
[7.6.1]: https://github.com/Automattic/wordpress-activitypub/compare/7.6.0...7.6.1
15721579
[7.6.0]: https://github.com/Automattic/wordpress-activitypub/compare/7.5.0...7.6.0

activitypub.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Plugin Name: ActivityPub
44
* Plugin URI: https://github.com/Automattic/wordpress-activitypub
55
* Description: The ActivityPub protocol is a decentralized social networking protocol based upon the ActivityStreams 2.0 data format.
6-
* Version: 7.7.0
6+
* Version: 7.7.1
77
* Author: Matthias Pfefferle & Automattic
88
* Author URI: https://automattic.com/
99
* License: MIT
@@ -17,7 +17,7 @@
1717

1818
namespace Activitypub;
1919

20-
\define( 'ACTIVITYPUB_PLUGIN_VERSION', '7.7.0' );
20+
\define( 'ACTIVITYPUB_PLUGIN_VERSION', '7.7.1' );
2121

2222
// Plugin related constants.
2323
\define( 'ACTIVITYPUB_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );

assets/css/activitypub-admin.css

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -230,13 +230,11 @@ input.blog-user-identifier {
230230
font-family: dashicons;
231231
}
232232

233-
.repost .dashboard-comment-wrap,
234-
.like .dashboard-comment-wrap {
233+
.activitypub-comment .dashboard-comment-wrap {
235234
padding-inline-start: 63px;
236235
}
237236

238-
.repost .dashboard-comment-wrap .comment-author,
239-
.like .dashboard-comment-wrap .comment-author {
237+
.activitypub-comment .dashboard-comment-wrap .comment-author {
240238
margin-block: 0;
241239
}
242240

includes/rest/class-inbox-controller.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -368,12 +368,13 @@ public function get_item_schema() {
368368
* @return array An array of user IDs who are the recipients of the activity.
369369
*/
370370
private function get_local_recipients( $activity ) {
371-
if ( is_activity_public( $activity ) ) { // Public activity, deliver to followers of the actor.
372-
return Following::get_follower_ids( $activity['actor'] );
371+
$user_ids = array();
372+
373+
if ( is_activity_public( $activity ) ) {
374+
$user_ids = Following::get_follower_ids( $activity['actor'] );
373375
}
374376

375377
$recipients = extract_recipients_from_activity( $activity );
376-
$user_ids = array();
377378

378379
foreach ( $recipients as $recipient ) {
379380

includes/wp-admin/import/class-mastodon.php

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -225,15 +225,15 @@ public static function import() {
225225

226226
// Unzip package to working directory.
227227
\unzip_file( $file, self::$archive );
228-
$files = $wp_filesystem->dirlist( self::$archive );
228+
self::maybe_unwrap_archive();
229229

230-
if ( ! isset( $files['outbox.json'] ) ) {
230+
if ( ! $wp_filesystem->exists( self::$archive . '/outbox.json' ) ) {
231231
echo '<p><strong>' . \esc_html( $error_message ) . '</strong><br />';
232232
echo \esc_html__( 'The archive does not contain an Outbox file, please try again.', 'activitypub' ) . '</p>';
233+
return;
233234
}
234235

235-
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
236-
self::$outbox = \json_decode( \file_get_contents( self::$archive . '/outbox.json' ), true );
236+
self::$outbox = \json_decode( $wp_filesystem->get_contents( self::$archive . '/outbox.json' ), true );
237237

238238
\wp_suspend_cache_invalidation();
239239
\wp_defer_term_counting( true );
@@ -434,4 +434,32 @@ private static function prepend_archive_path( $attachment ) {
434434

435435
return $attachment;
436436
}
437+
438+
/**
439+
* Detect and unwrap single nested directory in archive.
440+
*
441+
* Some Mastodon exports wrap all files in a root folder. This method
442+
* detects this pattern and updates the archive path to point inside it.
443+
*/
444+
private static function maybe_unwrap_archive() {
445+
global $wp_filesystem;
446+
447+
$files = $wp_filesystem->dirlist( self::$archive );
448+
449+
// Check if there's exactly one directory at root level.
450+
if ( count( $files ) !== 1 ) {
451+
return;
452+
}
453+
454+
$first = reset( $files );
455+
if ( 'd' !== $first['type'] ) {
456+
return;
457+
}
458+
459+
// Check if outbox.json exists inside the nested directory.
460+
$nested_path = self::$archive . '/' . $first['name'];
461+
if ( $wp_filesystem->exists( $nested_path . '/outbox.json' ) ) {
462+
self::$archive = $nested_path;
463+
}
464+
}
437465
}

readme.txt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ Contributors: automattic, pfefferle, mattwiebe, obenland, akirk, jeherve, mediaf
33
Tags: fediverse, activitypub, indieweb, activitystream, social web
44
Requires at least: 6.5
55
Tested up to: 6.9
6-
Stable tag: 7.7.0
6+
Stable tag: 7.7.1
77
Requires PHP: 7.2
88
License: MIT
99
License URI: http://opensource.org/licenses/MIT
@@ -110,6 +110,12 @@ For reasons of data protection, it is not possible to see the followers of other
110110

111111
== Changelog ==
112112

113+
### 7.7.1 - 2025-12-04
114+
#### Fixed
115+
- Fix admin styling for quote comments to match likes and reposts
116+
- Mastodon importer now unpacks nested archives instead of getting confused by the extra folder.
117+
- Add individually specified recipients to public activities in shared inbox.
118+
113119
### 7.7.0 - 2025-12-03
114120
#### Added
115121
- Add documentation guide for using ActivityPub blocks in classic themes with Block Template Parts

tests/phpunit/tests/includes/rest/class-test-inbox-controller.php

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,4 +935,215 @@ public function test_regular_inbox_action_with_shared_inbox_context() {
935935
\remove_action( 'activitypub_inbox', $inbox_action );
936936
\delete_option( 'activitypub_actor_mode' );
937937
}
938+
939+
/**
940+
* Test get_local_recipients combines followers and explicit recipients for public activities.
941+
*
942+
* @covers ::get_local_recipients
943+
*/
944+
public function test_get_local_recipients_public_activity_with_explicit_recipients() {
945+
// Enable actor mode to allow user actors.
946+
\update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE );
947+
948+
// Create test users (authors have activitypub capability by default).
949+
$follower_user_id = self::factory()->user->create( array( 'role' => 'author' ) );
950+
$mentioned_user_id = self::factory()->user->create( array( 'role' => 'author' ) );
951+
952+
// Get actor IDs.
953+
$mentioned_actor = Actors::get_by_id( $mentioned_user_id );
954+
$mentioned_actor_id = $mentioned_actor->get_id();
955+
956+
// Create a remote actor and make follower_user follow them.
957+
$remote_actor_url = 'https://example.com/actor/combined-test';
958+
959+
// Mock the remote actor fetch.
960+
$remote_object_filter = function ( $pre, $url ) use ( $remote_actor_url ) {
961+
if ( $url === $remote_actor_url ) {
962+
return array(
963+
'@context' => 'https://www.w3.org/ns/activitystreams',
964+
'id' => $remote_actor_url,
965+
'type' => 'Person',
966+
'preferredUsername' => 'testactor',
967+
'name' => 'Test Actor',
968+
'inbox' => 'https://example.com/actor/combined-test/inbox',
969+
);
970+
}
971+
return $pre;
972+
};
973+
\add_filter( 'activitypub_pre_http_get_remote_object', $remote_object_filter, 10, 2 );
974+
975+
$remote_actor = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $remote_actor_url );
976+
977+
// Make follower_user follow the remote actor.
978+
\add_post_meta( $remote_actor->ID, '_activitypub_followed_by', $follower_user_id );
979+
980+
// Public activity that explicitly addresses mentioned_user (who is NOT a follower).
981+
$activity = array(
982+
'type' => 'Create',
983+
'actor' => $remote_actor_url,
984+
'to' => array(
985+
'https://www.w3.org/ns/activitystreams#Public',
986+
$mentioned_actor_id,
987+
),
988+
);
989+
990+
// Use reflection to test the private method.
991+
$reflection = new \ReflectionClass( $this->inbox_controller );
992+
$method = $reflection->getMethod( 'get_local_recipients' );
993+
if ( \PHP_VERSION_ID < 80100 ) {
994+
$method->setAccessible( true );
995+
}
996+
997+
$result = $method->invoke( $this->inbox_controller, $activity );
998+
999+
// Should return BOTH the follower AND the explicitly mentioned user.
1000+
$this->assertNotEmpty( $result, 'Should return recipients for public activity with explicit addressing' );
1001+
$this->assertContains( $follower_user_id, $result, 'Should contain follower' );
1002+
$this->assertContains( $mentioned_user_id, $result, 'Should contain explicitly mentioned user' );
1003+
1004+
// Verify no duplicates if someone is both a follower and explicitly mentioned.
1005+
$this->assertEquals( count( $result ), count( array_unique( $result ) ), 'Should not contain duplicate user IDs' );
1006+
1007+
// Clean up.
1008+
\delete_option( 'activitypub_actor_mode' );
1009+
\remove_filter( 'activitypub_pre_http_get_remote_object', $remote_object_filter );
1010+
}
1011+
1012+
/**
1013+
* Test get_local_recipients deduplicates when user is both follower and explicit recipient.
1014+
*
1015+
* @covers ::get_local_recipients
1016+
*/
1017+
public function test_get_local_recipients_deduplicates_follower_and_explicit() {
1018+
// Enable actor mode to allow user actors.
1019+
\update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE );
1020+
1021+
// Create test user (author has activitypub capability by default).
1022+
$user_id = self::factory()->user->create( array( 'role' => 'author' ) );
1023+
1024+
// Get actor ID.
1025+
$user_actor = Actors::get_by_id( $user_id );
1026+
$user_actor_id = $user_actor->get_id();
1027+
1028+
// Create a remote actor and make user follow them.
1029+
$remote_actor_url = 'https://example.com/actor/dedup-test';
1030+
1031+
// Mock the remote actor fetch.
1032+
$remote_object_filter = function ( $pre, $url ) use ( $remote_actor_url ) {
1033+
if ( $url === $remote_actor_url ) {
1034+
return array(
1035+
'@context' => 'https://www.w3.org/ns/activitystreams',
1036+
'id' => $remote_actor_url,
1037+
'type' => 'Person',
1038+
'preferredUsername' => 'testactor',
1039+
'name' => 'Test Actor',
1040+
'inbox' => 'https://example.com/actor/dedup-test/inbox',
1041+
);
1042+
}
1043+
return $pre;
1044+
};
1045+
\add_filter( 'activitypub_pre_http_get_remote_object', $remote_object_filter, 10, 2 );
1046+
1047+
$remote_actor = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $remote_actor_url );
1048+
1049+
// Make user follow the remote actor.
1050+
\add_post_meta( $remote_actor->ID, '_activitypub_followed_by', $user_id );
1051+
1052+
// Public activity that ALSO explicitly addresses the same user.
1053+
$activity = array(
1054+
'type' => 'Create',
1055+
'actor' => $remote_actor_url,
1056+
'to' => array(
1057+
'https://www.w3.org/ns/activitystreams#Public',
1058+
$user_actor_id, // User is both follower AND explicitly addressed.
1059+
),
1060+
);
1061+
1062+
// Use reflection to test the private method.
1063+
$reflection = new \ReflectionClass( $this->inbox_controller );
1064+
$method = $reflection->getMethod( 'get_local_recipients' );
1065+
if ( \PHP_VERSION_ID < 80100 ) {
1066+
$method->setAccessible( true );
1067+
}
1068+
1069+
$result = $method->invoke( $this->inbox_controller, $activity );
1070+
1071+
// Should return the user only once (no duplicates).
1072+
$this->assertContains( $user_id, $result, 'Should contain user' );
1073+
1074+
// Count occurrences of user_id in result.
1075+
$occurrences = count( array_keys( $result, $user_id, true ) );
1076+
$this->assertEquals( 1, $occurrences, 'User should appear exactly once in result' );
1077+
1078+
// Clean up.
1079+
\delete_option( 'activitypub_actor_mode' );
1080+
\remove_filter( 'activitypub_pre_http_get_remote_object', $remote_object_filter );
1081+
}
1082+
1083+
/**
1084+
* Test get_local_recipients for non-public activity with followers.
1085+
*
1086+
* @covers ::get_local_recipients
1087+
*/
1088+
public function test_get_local_recipients_non_public_activity_ignores_followers() {
1089+
// Enable actor mode to allow user actors.
1090+
\update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE );
1091+
1092+
// Create test users.
1093+
$follower_user_id = self::factory()->user->create( array( 'role' => 'author' ) );
1094+
$mentioned_user_id = self::factory()->user->create( array( 'role' => 'author' ) );
1095+
1096+
// Get actor ID for mentioned user.
1097+
$mentioned_actor = Actors::get_by_id( $mentioned_user_id );
1098+
$mentioned_actor_id = $mentioned_actor->get_id();
1099+
1100+
// Create a remote actor and make follower_user follow them.
1101+
$remote_actor_url = 'https://example.com/actor/non-public-test';
1102+
1103+
// Mock the remote actor fetch.
1104+
$remote_object_filter = function ( $pre, $url ) use ( $remote_actor_url ) {
1105+
if ( $url === $remote_actor_url ) {
1106+
return array(
1107+
'@context' => 'https://www.w3.org/ns/activitystreams',
1108+
'id' => $remote_actor_url,
1109+
'type' => 'Person',
1110+
'preferredUsername' => 'testactor',
1111+
'name' => 'Test Actor',
1112+
'inbox' => 'https://example.com/actor/non-public-test/inbox',
1113+
);
1114+
}
1115+
return $pre;
1116+
};
1117+
\add_filter( 'activitypub_pre_http_get_remote_object', $remote_object_filter, 10, 2 );
1118+
1119+
$remote_actor = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $remote_actor_url );
1120+
1121+
// Make follower_user follow the remote actor.
1122+
\add_post_meta( $remote_actor->ID, '_activitypub_followed_by', $follower_user_id );
1123+
1124+
// Non-public activity (direct message) that explicitly addresses only mentioned_user.
1125+
$activity = array(
1126+
'type' => 'Create',
1127+
'actor' => $remote_actor_url,
1128+
'to' => array( $mentioned_actor_id ), // Only mentioned user, NOT public.
1129+
);
1130+
1131+
// Use reflection to test the private method.
1132+
$reflection = new \ReflectionClass( $this->inbox_controller );
1133+
$method = $reflection->getMethod( 'get_local_recipients' );
1134+
if ( \PHP_VERSION_ID < 80100 ) {
1135+
$method->setAccessible( true );
1136+
}
1137+
1138+
$result = $method->invoke( $this->inbox_controller, $activity );
1139+
1140+
// Should return ONLY the mentioned user, NOT the follower.
1141+
$this->assertNotEmpty( $result, 'Should return explicitly addressed recipient' );
1142+
$this->assertContains( $mentioned_user_id, $result, 'Should contain mentioned user' );
1143+
$this->assertNotContains( $follower_user_id, $result, 'Should NOT contain follower for non-public activity' );
1144+
1145+
// Clean up.
1146+
\delete_option( 'activitypub_actor_mode' );
1147+
\remove_filter( 'activitypub_pre_http_get_remote_object', $remote_object_filter );
1148+
}
9381149
}

0 commit comments

Comments
 (0)