diff --git a/README.md b/README.md index dcc91f2..4c41fcd 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ **Contributors:** [automattic](https://profiles.wordpress.org/automattic/), [pfefferle](https://profiles.wordpress.org/pfefferle/), [mediaformat](https://profiles.wordpress.org/mediaformat/), [mattwiebe](https://profiles.wordpress.org/mattwiebe/), [akirk](https://profiles.wordpress.org/akirk/), [jeherve](https://profiles.wordpress.org/jeherve/), [nuriapena](https://profiles.wordpress.org/nuriapena/), [cavalierlife](https://profiles.wordpress.org/cavalierlife/) **Tags:** OStatus, fediverse, activitypub, activitystream **Requires at least:** 4.7 -**Tested up to:** 6.3 -**Stable tag:** 1.0.10 +**Tested up to:** 6.4 +**Stable tag:** 1.1.0 **Requires PHP:** 5.6 **License:** MIT **License URI:** http://opensource.org/licenses/MIT @@ -105,6 +105,16 @@ Where 'blog' is the path to the subdirectory at which your blog resides. Project maintained on GitHub at [automattic/wordpress-activitypub](https://github.com/automattic/wordpress-activitypub). +### 1.1.0 ### + +* Improved: audio and video attachments are now supported! +* Improved: better error messages if remote profile is not accessible +* Improved: PHP 8.1 compatibility +* Fixed: don't try to parse mentions or hashtags for very large (>1MB) posts to prevent timeouts +* Fixed: better handling of ISO-639-1 locale codes +* Improved: more reliable [ap_author], props @uk3 +* Improved: NodeInfo statistics + ### 1.0.10 ### * Improved: better error messages if remote profile is not accessible diff --git a/activitypub.php b/activitypub.php index 0b41709..0c1e3c3 100644 --- a/activitypub.php +++ b/activitypub.php @@ -3,7 +3,7 @@ * Plugin Name: ActivityPub * Plugin URI: https://github.com/pfefferle/wordpress-activitypub/ * Description: The ActivityPub protocol is a decentralized social networking protocol based upon the ActivityStreams 2.0 data format. - * Version: 1.0.10 + * Version: 1.1.0 * Author: Matthias Pfefferle & Automattic * Author URI: https://automattic.com/ * License: MIT diff --git a/includes/class-hashtag.php b/includes/class-hashtag.php index 2d03ac4..6031d1f 100644 --- a/includes/class-hashtag.php +++ b/includes/class-hashtag.php @@ -43,6 +43,10 @@ class Hashtag { * @return string the filtered post-content */ public static function the_content( $the_content ) { + // small protection against execution timeouts: limit to 1 MB + if ( mb_strlen( $the_content ) > MB_IN_BYTES ) { + return $the_content; + } $tag_stack = array(); $protected_tags = array( 'pre', diff --git a/includes/class-mention.php b/includes/class-mention.php index d55e5f2..beb6246 100644 --- a/includes/class-mention.php +++ b/includes/class-mention.php @@ -26,6 +26,10 @@ class Mention { * @return string the filtered post-content */ public static function the_content( $the_content ) { + // small protection against execution timeouts: limit to 1 MB + if ( mb_strlen( $the_content ) > MB_IN_BYTES ) { + return $the_content; + } $tag_stack = array(); $protected_tags = array( 'pre', diff --git a/includes/class-shortcodes.php b/includes/class-shortcodes.php index 708aa61..491a6ad 100644 --- a/includes/class-shortcodes.php +++ b/includes/class-shortcodes.php @@ -390,7 +390,8 @@ class Shortcodes { return ''; } - $name = \get_the_author_meta( 'display_name', $item->post_author ); + $author_id = \get_post_field( 'post_author', $item->ID ); + $name = \get_the_author_meta( 'display_name', $author_id ); if ( ! $name ) { return ''; @@ -415,7 +416,8 @@ class Shortcodes { return ''; } - $url = \get_the_author_meta( 'user_url', $item->post_author ); + $author_id = \get_post_field( 'post_author', $item->ID ); + $url = \get_the_author_meta( 'user_url', $author_id ); if ( ! $url ) { return ''; diff --git a/includes/compat.php b/includes/compat.php index 4bee640..3dd405c 100644 --- a/includes/compat.php +++ b/includes/compat.php @@ -35,3 +35,15 @@ if ( ! function_exists( 'get_self_link' ) ) { return esc_url( apply_filters( 'self_link', set_url_scheme( 'http://' . $host['host'] . $path ) ) ); } } + +if ( ! function_exists( 'is_countable' ) ) { + /** + * Polyfill for `is_countable()` function added in PHP 7.3. + * + * @param mixed $value The value to check. + * @return bool True if `$value` is countable, otherwise false. + */ + function is_countable( $value ) { + return is_array( $value ) || $value instanceof \Countable; + } +} diff --git a/includes/functions.php b/includes/functions.php index d5e6503..fea1821 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -5,6 +5,7 @@ use WP_Error; use Activitypub\Http; use Activitypub\Activity\Activity; use Activitypub\Collection\Followers; +use Activitypub\Collection\Users; /** * Returns the ActivityPub default JSON-context @@ -549,3 +550,75 @@ function is_activity_public( $data ) { return in_array( 'https://www.w3.org/ns/activitystreams#Public', $recipients, true ); } + +/** + * Get active users based on a given duration + * + * @param int $duration The duration to check in month(s) + * + * @return int The number of active users + */ +function get_active_users( $duration = 1 ) { + + $duration = intval( $duration ); + $transient_key = sprintf( 'monthly_active_users_%d', $duration ); + $count = get_transient( $transient_key ); + + if ( false === $count ) { + global $wpdb; + $query = "SELECT COUNT( DISTINCT post_author ) FROM {$wpdb->posts} WHERE post_type = 'post' AND post_status = 'publish' AND post_date <= DATE_SUB( NOW(), INTERVAL %d MONTH )"; + $query = $wpdb->prepare( $query, $duration ); + $count = $wpdb->get_var( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + + set_transient( $transient_key, $count, DAY_IN_SECONDS ); + } + + // if 0 authors where active + if ( 0 === $count ) { + return 0; + } + + // if single user mode + if ( is_single_user() ) { + return 1; + } + + // if blog user is disabled + if ( is_user_disabled( Users::BLOG_USER_ID ) ) { + return $count; + } + + // also count blog user + return $count + 1; +} + +/** + * Get the total number of users + * + * @return int The total number of users + */ +function get_total_users() { + // if single user mode + if ( is_single_user() ) { + return 1; + } + + $users = \get_users( + array( + 'capability__in' => array( 'publish_posts' ), + ) + ); + + if ( is_array( $users ) ) { + $users = count( $users ); + } else { + $users = 1; + } + + // if blog user is disabled + if ( is_user_disabled( Users::BLOG_USER_ID ) ) { + return $users; + } + + return $users + 1; +} diff --git a/includes/rest/class-collection.php b/includes/rest/class-collection.php index 2e6522e..365641c 100644 --- a/includes/rest/class-collection.php +++ b/includes/rest/class-collection.php @@ -105,7 +105,7 @@ class Collection { '@context' => Activity::CONTEXT, 'id' => get_rest_url_by_path( sprintf( 'users/%d/collections/tags', $user->get__id() ) ), 'type' => 'Collection', - 'totalItems' => count( $tags ), + 'totalItems' => is_countable( $tags ) ? count( $tags ) : 0, 'items' => array(), ); @@ -163,7 +163,7 @@ class Collection { '@context' => Activity::CONTEXT, 'id' => get_rest_url_by_path( sprintf( 'users/%d/collections/featured', $user_id ) ), 'type' => 'OrderedCollection', - 'totalItems' => count( $posts ), + 'totalItems' => is_countable( $posts ) ? count( $posts ) : 0, 'orderedItems' => array(), ); diff --git a/includes/rest/class-following.php b/includes/rest/class-following.php index 22c9d46..58e4375 100644 --- a/includes/rest/class-following.php +++ b/includes/rest/class-following.php @@ -75,7 +75,7 @@ class Following { $items = apply_filters( 'activitypub_rest_following', array(), $user ); // phpcs:ignore - $json->totalItems = count( $items ); // phpcs:ignore + $json->totalItems = is_countable( $items ) ? count( $items ) : 0; // phpcs:ignore $json->orderedItems = $items; // phpcs:ignore $json->first = $json->partOf; // phpcs:ignore diff --git a/includes/rest/class-nodeinfo.php b/includes/rest/class-nodeinfo.php index 1f6277a..62151ff 100644 --- a/includes/rest/class-nodeinfo.php +++ b/includes/rest/class-nodeinfo.php @@ -3,6 +3,8 @@ namespace Activitypub\Rest; use WP_REST_Response; +use function Activitypub\get_total_users; +use function Activitypub\get_active_users; use function Activitypub\get_rest_url_by_path; /** @@ -82,24 +84,14 @@ class Nodeinfo { 'version' => \get_bloginfo( 'version' ), ); - $users = \get_users( - array( - 'capability__in' => array( 'publish_posts' ), - ) - ); - - if ( is_array( $users ) ) { - $users = count( $users ); - } else { - $users = 1; - } - $posts = \wp_count_posts(); $comments = \wp_count_comments(); $nodeinfo['usage'] = array( 'users' => array( - 'total' => $users, + 'total' => get_total_users(), + 'activeMonth' => get_active_users( '1 month ago' ), + 'activeHalfyear' => get_active_users( '6 month ago' ), ), 'localPosts' => (int) $posts->publish, 'localComments' => (int) $comments->approved, @@ -139,24 +131,14 @@ class Nodeinfo { 'version' => \get_bloginfo( 'version' ), ); - $users = \get_users( - array( - 'capability__in' => array( 'publish_posts' ), - ) - ); - - if ( is_array( $users ) ) { - $users = count( $users ); - } else { - $users = 1; - } - $posts = \wp_count_posts(); $comments = \wp_count_comments(); $nodeinfo['usage'] = array( 'users' => array( - 'total' => (int) $users, + 'total' => get_total_users(), + 'activeMonth' => get_active_users( 1 ), + 'activeHalfyear' => get_active_users( 6 ), ), 'localPosts' => (int) $posts->publish, 'localComments' => (int) $comments->approved, diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 9250afe..2117ce7 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -82,7 +82,7 @@ class Post { $object->set_content( $this->get_content() ); $object->set_content_map( array( - \strstr( \get_locale(), '_', true ) => $this->get_content(), + $this->get_locale() => $this->get_content(), ) ); $path = sprintf( 'users/%d/followers', intval( $wp_post->post_author ) ); @@ -143,78 +143,65 @@ class Post { } /** - * Returns the Image Attachments for this Post, parsed from blocks. - * @param int $max_images The maximum number of images to return. - * @param array $image_ids The image IDs to append new IDs to. + * Generates all Media Attachments for a Post. * - * @return array The image IDs. - */ - protected function get_block_image_ids( $max_images, $image_ids = [] ) { - $blocks = \parse_blocks( $this->wp_post->post_content ); - return self::get_image_ids_from_blocks( $blocks, $image_ids, $max_images ); - } - - /** - * Recursively get image IDs from blocks. - * @param array $blocks The blocks to search for image IDs - * @param array $image_ids The image IDs to append new IDs to - * @param int $max_images The maximum number of images to return. - * - * @return array The image IDs. - */ - protected static function get_image_ids_from_blocks( $blocks, $image_ids, $max_images ) { - foreach ( $blocks as $block ) { - // recurse into inner blocks - if ( ! empty( $block['innerBlocks'] ) ) { - $image_ids = self::get_image_ids_from_blocks( $block['innerBlocks'], $image_ids, $max_images ); - } - - switch ( $block['blockName'] ) { - case 'core/image': - case 'core/cover': - if ( ! empty( $block['attrs']['id'] ) ) { - $image_ids[] = $block['attrs']['id']; - } - break; - case 'jetpack/slideshow': - case 'jetpack/tiled-gallery': - if ( ! empty( $block['attrs']['ids'] ) ) { - $image_ids = array_merge( $image_ids, $block['attrs']['ids'] ); - } - break; - case 'jetpack/image-compare': - if ( ! empty( $block['attrs']['beforeImageId'] ) ) { - $image_ids[] = $block['attrs']['beforeImageId']; - } - if ( ! empty( $block['attrs']['afterImageId'] ) ) { - $image_ids[] = $block['attrs']['afterImageId']; - } - break; - } - - // we could be at or over max, stop unneeded work - if ( count( $image_ids ) >= $max_images ) { - break; - } - } - - // still need to slice it because one gallery could knock us over the limit - return \array_slice( $image_ids, 0, $max_images ); - } - - /** - * Generates all Image Attachments for a Post. - * - * @return array The Image Attachments. + * @return array The Attachments. */ protected function get_attachments() { - $max_images = intval( \apply_filters( 'activitypub_max_image_attachments', \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ) ) ); + // Once upon a time we only supported images, but we now support audio/video as well. + // We maintain the image-centric naming for backwards compatibility. + $max_media = intval( \apply_filters( 'activitypub_max_image_attachments', \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ) ) ); - $images = array(); + if ( site_supports_blocks() && \has_blocks( $this->wp_post->post_content ) ) { + return $this->get_block_attachments( $max_media ); + } + return $this->get_classic_editor_images( $max_media ); + } + + /** + * Get media attachments from blocks. They will be formatted as ActivityPub attachments, not as WP attachments. + * + * @param int $max_media The maximum number of attachments to return. + * + * @return array The attachments. + */ + protected function get_block_attachments( $max_media ) { + // max media can't be negative or zero + if ( $max_media <= 0 ) { + return array(); + } + + $id = $this->wp_post->ID; + + $media_ids = array(); + + // list post thumbnail first if this post has one + if ( \function_exists( 'has_post_thumbnail' ) && \has_post_thumbnail( $id ) ) { + $media_ids[] = \get_post_thumbnail_id( $id ); + } + + if ( $max_media > 0 ) { + $blocks = \parse_blocks( $this->wp_post->post_content ); + $media_ids = self::get_media_ids_from_blocks( $blocks, $media_ids, $max_media ); + } + $media_ids = \array_unique( $media_ids ); + + return \array_filter( \array_map( array( self::class, 'wp_attachment_to_activity_attachment' ), $media_ids ) ); + } + + /** + * Get image attachments from the classic editor. + * Note that audio/video attachments are only supported in the block editor. + * + * @param int $max_images The maximum number of images to return. + * + * @return array The attachments. + */ + protected function get_classic_editor_images( $max_images ) { // max images can't be negative or zero if ( $max_images <= 0 ) { - return $images; + return array(); } $id = $this->wp_post->ID; @@ -228,68 +215,144 @@ class Post { } if ( $max_images > 0 ) { - // first try to get images that are actually in the post content - if ( site_supports_blocks() && \has_blocks( $this->wp_post->post_content ) ) { - $block_image_ids = $this->get_block_image_ids( $max_images, $image_ids ); - $image_ids = \array_merge( $image_ids, $block_image_ids ); - } else { - // fallback to images attached to the post - $query = new \WP_Query( - array( - 'post_parent' => $id, - 'post_status' => 'inherit', - 'post_type' => 'attachment', - 'post_mime_type' => 'image', - 'order' => 'ASC', - 'orderby' => 'menu_order ID', - 'posts_per_page' => $max_images, - ) - ); - foreach ( $query->get_posts() as $attachment ) { - if ( ! \in_array( $attachment->ID, $image_ids, true ) ) { - $image_ids[] = $attachment->ID; - } + $query = new \WP_Query( + array( + 'post_parent' => $id, + 'post_status' => 'inherit', + 'post_type' => 'attachment', + 'post_mime_type' => 'image', + 'order' => 'ASC', + 'orderby' => 'menu_order ID', + 'posts_per_page' => $max_images, + ) + ); + foreach ( $query->get_posts() as $attachment ) { + if ( ! \in_array( $attachment->ID, $image_ids, true ) ) { + $image_ids[] = $attachment->ID; } } } - $image_ids = \array_unique( $image_ids ); - // get URLs for each image - foreach ( $image_ids as $id ) { - $image_size = 'full'; + return \array_filter( \array_map( array( self::class, 'wp_attachment_to_activity_attachment' ), $image_ids ) ); + } - /** - * Filter the image URL returned for each post. - * - * @param array|false $thumbnail The image URL, or false if no image is available. - * @param int $id The attachment ID. - * @param string $image_size The image size to retrieve. Set to 'full' by default. - */ - $thumbnail = apply_filters( - 'activitypub_get_image', - $this->get_image( $id, $image_size ), - $id, - $image_size - ); + /** + * Recursively get media IDs from blocks. + * @param array $blocks The blocks to search for media IDs + * @param array $media_ids The media IDs to append new IDs to + * @param int $max_media The maximum number of media to return. + * + * @return array The image IDs. + */ + protected static function get_media_ids_from_blocks( $blocks, $media_ids, $max_media ) { - if ( $thumbnail ) { - $mimetype = \get_post_mime_type( $id ); - $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true ); - $image = array( - 'type' => 'Image', - 'url' => $thumbnail[0], - 'mediaType' => $mimetype, - ); + foreach ( $blocks as $block ) { + // recurse into inner blocks + if ( ! empty( $block['innerBlocks'] ) ) { + $media_ids = self::get_media_ids_from_blocks( $block['innerBlocks'], $media_ids, $max_media ); + } - if ( $alt ) { - $image['name'] = $alt; - } - $images[] = $image; + switch ( $block['blockName'] ) { + case 'core/image': + case 'core/cover': + case 'core/audio': + case 'core/video': + case 'videopress/video': + if ( ! empty( $block['attrs']['id'] ) ) { + $media_ids[] = $block['attrs']['id']; + } + break; + case 'jetpack/slideshow': + case 'jetpack/tiled-gallery': + if ( ! empty( $block['attrs']['ids'] ) ) { + $media_ids = array_merge( $media_ids, $block['attrs']['ids'] ); + } + break; + case 'jetpack/image-compare': + if ( ! empty( $block['attrs']['beforeImageId'] ) ) { + $media_ids[] = $block['attrs']['beforeImageId']; + } + if ( ! empty( $block['attrs']['afterImageId'] ) ) { + $media_ids[] = $block['attrs']['afterImageId']; + } + break; + } + + // stop doing unneeded work + if ( count( $media_ids ) >= $max_media ) { + break; } } - return $images; + // still need to slice it because one gallery could knock us over the limit + return array_slice( $media_ids, 0, $max_media ); + } + + /** + * Converts a WordPress Attachment to an ActivityPub Attachment. + * + * @param int $id The Attachment ID. + * + * @return array The ActivityPub Attachment. + */ + public static function wp_attachment_to_activity_attachment( $id ) { + $attachment = array(); + $mime_type = \get_post_mime_type( $id ); + $mime_type_parts = \explode( '/', $mime_type ); + // switching on image/audio/video + switch ( $mime_type_parts[0] ) { + case 'image': + $image_size = 'full'; + + /** + * Filter the image URL returned for each post. + * + * @param array|false $thumbnail The image URL, or false if no image is available. + * @param int $id The attachment ID. + * @param string $image_size The image size to retrieve. Set to 'full' by default. + */ + $thumbnail = apply_filters( + 'activitypub_get_image', + self::get_image( $id, $image_size ), + $id, + $image_size + ); + + if ( $thumbnail ) { + $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true ); + $image = array( + 'type' => 'Image', + 'url' => $thumbnail[0], + 'mediaType' => $mime_type, + ); + + if ( $alt ) { + $image['name'] = $alt; + } + $attachment = $image; + } + break; + + case 'audio': + case 'video': + $attachment = array( + 'type' => 'Document', + 'mediaType' => $mime_type, + 'url' => \wp_get_attachment_url( $id ), + 'name' => \get_the_title( $id ), + ); + $meta = wp_get_attachment_metadata( $id ); + // height and width for videos + if ( isset( $meta['width'] ) && isset( $meta['height'] ) ) { + $attachment['width'] = $meta['width']; + $attachment['height'] = $meta['height']; + } + // @todo: add `icon` support for audio/video attachments. Maybe use post thumbnail? + break; + } + + return \apply_filters( 'activitypub_attachment', $attachment, $id ); } /** @@ -300,7 +363,7 @@ class Post { * * @return array|false Array of image data, or boolean false if no image is available. */ - protected function get_image( $id, $image_size = 'full' ) { + protected static function get_image( $id, $image_size = 'full' ) { /** * Hook into the image retrieval process. Before image retrieval. * @@ -309,7 +372,7 @@ class Post { */ do_action( 'activitypub_get_image_pre', $id, $image_size ); - $thumbnail = \wp_get_attachment_image_src( $id, $image_size ); + $image = \wp_get_attachment_image_src( $id, $image_size ); /** * Hook into the image retrieval process. After image retrieval. @@ -319,7 +382,7 @@ class Post { */ do_action( 'activitypub_get_image_post', $id, $image_size ); - return $thumbnail; + return $image; } /** @@ -335,6 +398,8 @@ class Post { return \ucfirst( \get_option( 'activitypub_object_type', 'note' ) ); } + // Default to Article. + $object_type = 'Article'; $post_type = \get_post_type( $this->wp_post ); switch ( $post_type ) { case 'post': @@ -515,4 +580,25 @@ class Post { protected function get_mentions() { return apply_filters( 'activitypub_extract_mentions', array(), $this->wp_post->post_content, $this->wp_post ); } + + /** + * Returns the locale of the post. + * + * @return string The locale of the post. + */ + public function get_locale() { + $post_id = $this->wp_post->ID; + $lang = \strtolower( \strtok( \get_locale(), '_-' ) ); + + /** + * Filter the locale of the post. + * + * @param string $lang The locale of the post. + * @param int $post_id The post ID. + * @param WP_Post $post The post object. + * + * @return string The filtered locale of the post. + */ + return apply_filters( 'activitypub_post_locale', $lang, $post_id, $this->wp_post ); + } } diff --git a/integration/class-nodeinfo.php b/integration/class-nodeinfo.php index f1e2506..dea6c67 100644 --- a/integration/class-nodeinfo.php +++ b/integration/class-nodeinfo.php @@ -1,6 +1,9 @@ get_total_users(), + 'activeMonth' => get_active_users( '1 month ago' ), + 'activeHalfyear' => get_active_users( '6 month ago' ), + ); + return $nodeinfo; } @@ -44,6 +53,12 @@ class Nodeinfo { public static function add_nodeinfo2_discovery( $nodeinfo ) { $nodeinfo['protocols'][] = 'activitypub'; + $nodeinfo['usage']['users'] = array( + 'total' => get_total_users(), + 'activeMonth' => get_active_users( '1 month ago' ), + 'activeHalfyear' => get_active_users( '6 month ago' ), + ); + return $nodeinfo; } } diff --git a/readme.txt b/readme.txt index 7e48fc1..eed006a 100644 --- a/readme.txt +++ b/readme.txt @@ -2,8 +2,8 @@ Contributors: automattic, pfefferle, mediaformat, mattwiebe, akirk, jeherve, nuriapena, cavalierlife Tags: OStatus, fediverse, activitypub, activitystream Requires at least: 4.7 -Tested up to: 6.3 -Stable tag: 1.0.10 +Tested up to: 6.4 +Stable tag: 1.1.0 Requires PHP: 5.6 License: MIT License URI: http://opensource.org/licenses/MIT @@ -105,6 +105,16 @@ Where 'blog' is the path to the subdirectory at which your blog resides. Project maintained on GitHub at [automattic/wordpress-activitypub](https://github.com/automattic/wordpress-activitypub). += 1.1.0 = + +* Improved: audio and video attachments are now supported! +* Improved: better error messages if remote profile is not accessible +* Improved: PHP 8.1 compatibility +* Fixed: don't try to parse mentions or hashtags for very large (>1MB) posts to prevent timeouts +* Fixed: better handling of ISO-639-1 locale codes +* Improved: more reliable [ap_author], props @uk3 +* Improved: NodeInfo statistics + = 1.0.10 = * Improved: better error messages if remote profile is not accessible diff --git a/templates/settings.php b/templates/settings.php index fd80145..5ce90e1 100644 --- a/templates/settings.php +++ b/templates/settings.php @@ -138,7 +138,7 @@
%s
', 'activitypub' ),
+ \__( 'The number of media (images, audio, video) to attach to posts. Default: %s
', 'activitypub' ),
\esc_html( ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS )
),
'default'
);
?>
+ + + + +