PluginProbe
ActivityPub / 8.0.2
ActivityPub v8.0.2
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
← All changes | includes/class-blocks.php +10 -342 8.3.08.0.2 View file →
@@ -6,9 +6,8 @@
6 6 */
7 7
8 8 namespace Activitypub;
9 9
10 -use Activitypub\Cache\Stats_Image;
11 10 use Activitypub\Collection\Actors;
12 11
13 12 /**
14 13 * Block class.
@@ -13,56 +12,9 @@
13 12 /**
14 13 * Block class.
15 14 */
16 15 class Blocks {
17 -
18 16 /**
19 - * HTML tags to skip during block conversion.
20 - *
21 - * @var array<string>
22 - */
23 - const SKIP_TAGS = array( 'BR', 'CITE', 'SOURCE' );
24 -
25 - /**
26 - * HTML void elements that have no closing tag.
27 - *
28 - * @var array<string>
29 - */
30 - const VOID_TAGS = array( 'AREA', 'BASE', 'BR', 'COL', 'EMBED', 'HR', 'IMG', 'INPUT', 'LINK', 'META', 'SOURCE', 'TRACK', 'WBR' );
31 -
32 - /**
33 - * Map of HTML tag names to WordPress block types.
34 - *
35 - * @var array<string, string>
36 - */
37 - const BLOCK_MAP = array(
38 - 'UL' => 'list',
39 - 'OL' => 'list',
40 - 'IMG' => 'image',
41 - 'BLOCKQUOTE' => 'quote',
42 - 'H1' => 'heading',
43 - 'H2' => 'heading',
44 - 'H3' => 'heading',
45 - 'H4' => 'heading',
46 - 'H5' => 'heading',
47 - 'H6' => 'heading',
48 - 'P' => 'paragraph',
49 - 'A' => 'paragraph',
50 - 'ABBR' => 'paragraph',
51 - 'B' => 'paragraph',
52 - 'CODE' => 'paragraph',
53 - 'EM' => 'paragraph',
54 - 'I' => 'paragraph',
55 - 'STRONG' => 'paragraph',
56 - 'SUB' => 'paragraph',
57 - 'SUP' => 'paragraph',
58 - 'SPAN' => 'paragraph',
59 - 'U' => 'paragraph',
60 - 'FIGURE' => 'image',
61 - 'HR' => 'separator',
62 - );
63 -
64 - /**
65 17 * Initialize the class, registering WordPress hooks.
66 18 */
67 19 public static function init() {
68 20 // This is already being called on the init hook, so just add it.
@@ -69,10 +21,8 @@
69 21 self::register_blocks();
70 22 self::register_patterns();
71 23 self::register_templates();
72 24
73 - \add_action( 'pre_get_posts', array( self::class, 'filter_query_loop_vars' ) );
74 -
75 25 \add_action( 'load-post-new.php', array( self::class, 'handle_in_reply_to_get_param' ) );
76 26 // Add editor plugin.
77 27 \add_action( 'enqueue_block_editor_assets', array( self::class, 'enqueue_editor_assets' ) );
78 28 \add_action( 'rest_api_init', array( self::class, 'register_rest_fields' ) );
@@ -77,9 +27,8 @@
77 27 \add_action( 'enqueue_block_editor_assets', array( self::class, 'enqueue_editor_assets' ) );
78 28 \add_action( 'rest_api_init', array( self::class, 'register_rest_fields' ) );
79 29
80 30 \add_filter( 'activitypub_import_mastodon_post_data', array( self::class, 'filter_import_mastodon_post_data' ), 10, 2 );
81 - \add_filter( 'activitypub_attachments', array( self::class, 'add_stats_image_attachment' ), 10, 2 );
82 31
83 32 \add_action( 'activitypub_before_get_content', array( self::class, 'add_post_transformation_callbacks' ) );
84 33 \add_filter( 'activitypub_the_content', array( self::class, 'remove_post_transformation_callbacks' ) );
85 34 }
@@ -88,23 +37,22 @@
88 37 * Enqueue the block editor assets.
89 38 */
90 39 public static function enqueue_editor_assets() {
91 40 $data = array(
92 - 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
93 - 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
94 - 'enabled' => array(
41 + 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
42 + 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
43 + 'enabled' => array(
95 44 'blog' => ! is_user_type_disabled( 'blog' ),
96 45 'users' => ! is_user_type_disabled( 'user' ),
97 46 ),
98 - 'profileUrls' => array(
47 + 'profileUrls' => array(
99 48 'user' => \admin_url( 'profile.php#activitypub' ),
100 49 'blog' => \admin_url( 'options-general.php?page=activitypub&tab=blog-profile' ),
101 50 ),
102 - 'showAvatars' => (bool) \get_option( 'show_avatars' ),
103 - 'defaultQuotePolicy' => \get_option( 'activitypub_default_quote_policy', ACTIVITYPUB_INTERACTION_POLICY_ANYONE ),
104 - 'objectType' => \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE ),
105 - 'noteLength' => ACTIVITYPUB_NOTE_LENGTH,
106 - 'statsImageUrlEndpoint' => Stats_Image::is_available() ? \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE . '/stats/image-url/{user_id}/{year}' ) : '',
51 + 'showAvatars' => (bool) \get_option( 'show_avatars' ),
52 + 'defaultQuotePolicy' => \get_option( 'activitypub_default_quote_policy', ACTIVITYPUB_INTERACTION_POLICY_ANYONE ),
53 + 'objectType' => \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE ),
54 + 'noteLength' => ACTIVITYPUB_NOTE_LENGTH,
107 55 );
108 56 wp_localize_script( 'wp-editor', '_activityPubOptions', $data );
109 57
110 58 // Check for our supported post types.
@@ -144,10 +92,8 @@
144 92 public static function register_blocks() {
145 93 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/extra-fields' );
146 94 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/follow-me' );
147 95 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/followers' );
148 - \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/posts-and-replies' );
149 - \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/stats' );
150 96
151 97 // Only register the Following block if the Following feature is enabled.
152 98 if ( '1' === \get_option( 'activitypub_following_ui', '0' ) ) {
153 99 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/following' );
@@ -224,21 +170,9 @@
224 170 // Register each pattern.
225 171 require ACTIVITYPUB_PLUGIN_DIR . '/patterns/author-header.php';
226 172 require ACTIVITYPUB_PLUGIN_DIR . '/patterns/author-profile.php';
227 173 require ACTIVITYPUB_PLUGIN_DIR . '/patterns/follow-page.php';
228 - require ACTIVITYPUB_PLUGIN_DIR . '/patterns/profile-page.php';
229 174 require ACTIVITYPUB_PLUGIN_DIR . '/patterns/social-sidebar.php';
230 -
231 - // Only register the Following page pattern if the Following feature is enabled.
232 - if ( '1' === \get_option( 'activitypub_following_ui', '0' ) ) {
233 - require ACTIVITYPUB_PLUGIN_DIR . '/patterns/following-page.php';
234 - }
235 -
236 - // Only register the Stats post starter pattern in December and January.
237 - $month = (int) \gmdate( 'n' );
238 - if ( 12 === $month || 1 === $month ) {
239 - require ACTIVITYPUB_PLUGIN_DIR . '/patterns/stats-post.php';
240 - }
241 175 }
242 176
243 177 /**
244 178 * Register FSE templates for block themes.
@@ -261,9 +195,8 @@
261 195 <!-- wp:pattern {"slug":"activitypub/author-profile"} /-->
262 196 <!-- wp:spacer {"height":"32px"} -->
263 197 <div style="height:32px" aria-hidden="true" class="wp-block-spacer"></div>
264 198 <!-- /wp:spacer -->
265 - <!-- wp:activitypub/posts-and-replies /-->
266 199 <!-- wp:query {"queryId":0,"query":{"perPage":10,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","author":"","search":"","exclude":[],"sticky":"","inherit":true}} -->
267 200 <div class="wp-block-query">
268 201 <!-- wp:post-template -->
269 202 <!-- wp:post-title {"isLink":true} /-->
@@ -869,9 +802,9 @@
869 802 <a
870 803 href="#"
871 804 role="button"
872 805 class="pagination-previous"
873 - data-wp-on--click="actions.previousPage"
806 + data-wp-on-async--click="actions.previousPage"
874 807 data-wp-bind--aria-disabled="state.disablePreviousLink"
875 808 aria-label="<?php \esc_attr_e( 'Previous page', 'activitypub' ); ?>"
876 809 >
877 810 <?php \esc_html_e( 'Previous', 'activitypub' ); ?>
@@ -882,9 +815,9 @@
882 815 <a
883 816 href="#"
884 817 role="button"
885 818 class="pagination-next"
886 - data-wp-on--click="actions.nextPage"
819 + data-wp-on-async--click="actions.nextPage"
887 820 data-wp-bind--aria-disabled="state.disableNextLink"
888 821 aria-label="<?php \esc_attr_e( 'Next page', 'activitypub' ); ?>"
889 822 >
890 823 <?php \esc_html_e( 'Next', 'activitypub' ); ?>
@@ -960,9 +893,8 @@
960 893 * @param object $post The post object.
961 894 */
962 895 public static function add_post_transformation_callbacks( $post ) {
963 896 \add_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ), 10, 2 );
964 - \add_filter( 'render_block_activitypub/stats', '__return_empty_string' );
965 897
966 898 // Only transform reply link if it's the first block in the post.
967 899 $blocks = \parse_blocks( $post->post_content );
968 900 if ( ! empty( $blocks ) && 'activitypub/reply' === $blocks[0]['blockName'] ) {
@@ -979,9 +911,8 @@
979 911 */
980 912 public static function remove_post_transformation_callbacks( $content ) {
981 913 \remove_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ) );
982 914 \remove_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ) );
983 - \remove_filter( 'render_block_activitypub/stats', '__return_empty_string' );
984 915
985 916 return $content;
986 917 }
987 918
@@ -1044,93 +975,8 @@
1044 975 );
1045 976 }
1046 977
1047 978 /**
1048 - * Add the stats image as an attachment when a post contains the stats block.
1049 - *
1050 - * Parses the post content for activitypub/stats blocks and appends each
1051 - * as an Image attachment to the ActivityPub object.
1052 - *
1053 - * @since 8.1.0
1054 - *
1055 - * @param array $attachments The existing attachments.
1056 - * @param \WP_Post $post The post object.
1057 - *
1058 - * @return array The attachments with stats images appended.
1059 - */
1060 - public static function add_stats_image_attachment( $attachments, $post ) {
1061 - if ( ! Stats_Image::is_available() ) {
1062 - return $attachments;
1063 - }
1064 -
1065 - /*
1066 - * The stats image intentionally bypasses the `activitypub_max_image_attachments`
1067 - * limit because it replaces the block content rather than being an inline image
1068 - * extracted from the post. It is always appended so that the share-pic is
1069 - * included in the federated activity regardless of the attachment cap.
1070 - */
1071 - $blocks = \parse_blocks( $post->post_content );
1072 - $stats_blocks = self::find_blocks_recursive( $blocks, 'activitypub/stats' );
1073 -
1074 - foreach ( $stats_blocks as $block ) {
1075 - $user_id = self::get_user_id( $block['attrs']['selectedUser'] ?? 'blog' );
1076 -
1077 - if ( null === $user_id ) {
1078 - continue;
1079 - }
1080 -
1081 - $year = (int) ( $block['attrs']['year'] ?? (int) \gmdate( 'Y' ) - 1 );
1082 - $url = Stats_Image::get_url( $user_id, $year );
1083 -
1084 - if ( \is_wp_error( $url ) ) {
1085 - continue;
1086 - }
1087 -
1088 - // Determine mime type from URL extension.
1089 - $mime_type = \str_ends_with( $url, '.webp' ) ? 'image/webp' : 'image/png';
1090 -
1091 - $attachments[] = array(
1092 - 'type' => 'Image',
1093 - 'mediaType' => $mime_type,
1094 - 'url' => $url,
1095 - 'name' => \sprintf(
1096 - /* translators: %d: The year */
1097 - \__( 'Fediverse Stats %d', 'activitypub' ),
1098 - $year
1099 - ),
1100 - );
1101 - }
1102 -
1103 - return $attachments;
1104 - }
1105 -
1106 - /**
1107 - * Recursively find blocks of a given type in a block tree.
1108 - *
1109 - * @since 8.1.0
1110 - *
1111 - * @param array $blocks The parsed blocks.
1112 - * @param string $block_name The block name to search for.
1113 - *
1114 - * @return array The matching blocks.
1115 - */
1116 - private static function find_blocks_recursive( $blocks, $block_name ) {
1117 - $found = array();
1118 -
1119 - foreach ( $blocks as $block ) {
1120 - if ( $block_name === $block['blockName'] ) {
1121 - $found[] = $block;
1122 - }
1123 -
1124 - if ( ! empty( $block['innerBlocks'] ) ) {
1125 - $found = \array_merge( $found, self::find_blocks_recursive( $block['innerBlocks'], $block_name ) );
1126 - }
1127 - }
1128 -
1129 - return $found;
1130 - }
1131 -
1132 - /**
1133 979 * Transform Embed blocks to block level link.
1134 980 *
1135 981 * Remote servers will simply drop iframe elements, rendering incomplete content.
1136 982 *
@@ -1146,184 +992,6 @@
1146 992 if ( ! isset( $block['attrs']['url'] ) ) {
1147 993 return $block_content;
1148 994 }
1149 995 return '<p><a href="' . esc_url( $block['attrs']['url'] ) . '">' . $block['attrs']['url'] . '</a></p>';
1150 - }
1151 -
1152 - /**
1153 - * Convert HTML content to blocks.
1154 - *
1155 - * Tokenizes the content with wp_html_split(), tracks nesting depth,
1156 - * and wraps each top-level element in block comment delimiters.
1157 - *
1158 - * @since 8.1.0
1159 - *
1160 - * @param string $content The HTML content.
1161 - *
1162 - * @return string The content converted to blocks.
1163 - */
1164 - public static function convert_from_html( $content ) {
1165 - if ( empty( $content ) ) {
1166 - return '';
1167 - }
1168 -
1169 - $tokens = \wp_html_split( $content );
1170 - $_content = '';
1171 - $depth = 0;
1172 - $current_tag = '';
1173 - $current_html = '';
1174 -
1175 - foreach ( $tokens as $token ) {
1176 - if ( '' === $token ) {
1177 - continue;
1178 - }
1179 -
1180 - // Text content — accumulate only inside a top-level element.
1181 - if ( '<' !== $token[0] ) {
1182 - if ( $depth > 0 ) {
1183 - $current_html .= $token;
1184 - }
1185 - continue;
1186 - }
1187 -
1188 - // Closing tag.
1189 - if ( '/' === $token[1] ) {
1190 - $current_html .= $token;
1191 - --$depth;
1192 -
1193 - if ( 0 === $depth && '' !== $current_tag ) {
1194 - $_content .= self::to_block( $current_tag, $current_html );
1195 - $current_tag = '';
1196 - $current_html = '';
1197 - }
1198 - continue;
1199 - }
1200 -
1201 - // Extract the tag name from the opening tag.
1202 - if ( ! \preg_match( '/^<([a-zA-Z][a-zA-Z0-9]*)/', $token, $m ) ) {
1203 - if ( $depth > 0 ) {
1204 - $current_html .= $token;
1205 - }
1206 - continue;
1207 - }
1208 -
1209 - $tag = \strtoupper( $m[1] );
1210 -
1211 - // Start of a new top-level element.
1212 - if ( 0 === $depth ) {
1213 - $current_tag = $tag;
1214 - $current_html = $token;
1215 - } else {
1216 - $current_html .= $token;
1217 - }
1218 -
1219 - // Void elements don't increase depth — flush immediately at top level.
1220 - if ( \in_array( $tag, self::VOID_TAGS, true ) ) {
1221 - if ( 0 === $depth && '' !== $current_tag ) {
1222 - $_content .= self::to_block( $current_tag, $current_html );
1223 - $current_tag = '';
1224 - $current_html = '';
1225 - }
1226 - } else {
1227 - ++$depth;
1228 - }
1229 - }
1230 -
1231 - return $_content;
1232 - }
1233 -
1234 - /**
1235 - * Wrap an HTML element in block comment delimiters.
1236 - *
1237 - * @since 8.1.0
1238 - *
1239 - * @param string $tag The uppercase tag name.
1240 - * @param string $html The element HTML.
1241 - *
1242 - * @return string The block-wrapped HTML, or empty string for skipped tags.
1243 - */
1244 - private static function to_block( $tag, $html ) {
1245 - if ( \in_array( $tag, self::SKIP_TAGS, true ) ) {
1246 - return '';
1247 - }
1248 -
1249 - $block_type = self::BLOCK_MAP[ $tag ] ?? 'html';
1250 - $block_attrs = array();
1251 -
1252 - if ( 'OL' === $tag ) {
1253 - $block_attrs['ordered'] = true;
1254 - }
1255 -
1256 - return \get_comment_delimited_block_content( $block_type, $block_attrs, \trim( $html ) );
1257 - }
1258 -
1259 - /**
1260 - * Filter the main query to exclude replies.
1261 - *
1262 - * Adds a WHERE clause to exclude posts containing the `activitypub/reply`
1263 - * block when the visitor has explicitly requested the "Posts" tab via
1264 - * `?filter=posts`. This filters the main query so that Query Loop blocks
1265 - * with `inherit: true` also pick up the filter.
1266 - *
1267 - * The filter only attaches on that explicit opt-in. Admin, feed, and any
1268 - * regular frontend request (front page, archives, search…) are never
1269 - * touched, which is why no block-presence probing is needed: the only
1270 - * way `?filter=posts` appears in a URL is from a click on the
1271 - * `activitypub/posts-and-replies` tab block.
1272 - *
1273 - * @since 8.1.0
1274 - *
1275 - * @param WP_Query $query The WP_Query instance.
1276 - */
1277 - public static function filter_query_loop_vars( $query ) {
1278 - // Never touch admin or feed queries.
1279 - if ( \is_admin() || $query->is_feed() ) {
1280 - return;
1281 - }
1282 -
1283 - if ( ! $query->is_main_query() || $query->is_singular() ) {
1284 - return;
1285 - }
1286 -
1287 - // Skip the reply-exclusion filter for queries that only target
1288 - // non-ActivityPub post types to avoid a full table scan.
1289 - $query_post_type = $query->get( 'post_type' );
1290 - if ( ! empty( $query_post_type ) && 'any' !== $query_post_type ) {
1291 - $query_post_types = (array) $query_post_type;
1292 - if ( ! array_intersect( $query_post_types, \get_post_types_by_support( 'activitypub' ) ) ) {
1293 - return;
1294 - }
1295 - }
1296 -
1297 - // Only filter when the "Posts" tab has been explicitly selected.
1298 - // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1299 - if ( ! isset( $_GET['filter'] ) || 'posts' !== \sanitize_key( \wp_unslash( $_GET['filter'] ) ) ) {
1300 - return;
1301 - }
1302 -
1303 - \add_filter( 'posts_where', array( self::class, 'exclude_replies_where' ) );
1304 - }
1305 -
1306 - /**
1307 - * Exclude posts containing the activitypub/reply block.
1308 - *
1309 - * Removes itself after the first execution to avoid
1310 - * affecting secondary queries on the same page.
1311 - *
1312 - * @since 8.1.0
1313 - *
1314 - * @param string $where The WHERE clause.
1315 - * @return string Modified WHERE clause.
1316 - */
1317 - public static function exclude_replies_where( $where ) {
1318 - \remove_filter( 'posts_where', array( self::class, 'exclude_replies_where' ) );
1319 -
1320 - global $wpdb;
1321 -
1322 - $where .= $wpdb->prepare(
1323 - " AND {$wpdb->posts}.post_content NOT LIKE %s",
1324 - '%<!-- wp:activitypub/reply%'
1325 - );
1326 -
1327 - return $where;
1328 996 }
1329 997 }