From 7ca9a581c43027b230ae5bc883e38f39617e1b1b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 16 Oct 2024 20:44:42 +0200 Subject: [PATCH] Add "visibility" feature (#931) --- activitypub.php | 6 ++ build/editor-plugin/plugin.asset.php | 2 +- build/editor-plugin/plugin.js | 2 +- includes/class-activity-dispatcher.php | 16 ++- includes/class-activitypub.php | 2 +- includes/class-blocks.php | 29 +++++- includes/class-scheduler.php | 2 +- includes/functions.php | 119 +++++++++++++++++----- includes/transformer/class-post.php | 40 +++++--- src/editor-plugin/block.json | 2 +- src/editor-plugin/plugin.js | 37 ++++++- tests/test-class-activitypub-activity.php | 2 +- 12 files changed, 208 insertions(+), 51 deletions(-) diff --git a/activitypub.php b/activitypub.php index 53eb1475..1645a484 100644 --- a/activitypub.php +++ b/activitypub.php @@ -46,6 +46,12 @@ require_once __DIR__ . '/includes/functions.php'; \defined( 'ACTIVITYPUB_SEND_VARY_HEADER' ) || \define( 'ACTIVITYPUB_SEND_VARY_HEADER', false ); \defined( 'ACTIVITYPUB_DEFAULT_OBJECT_TYPE' ) || \define( 'ACTIVITYPUB_DEFAULT_OBJECT_TYPE', 'note' ); +// Post visibility constants. +\define( 'ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC', '' ); +\define( 'ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC', 'quiet_public' ); +\define( 'ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL', 'local' ); + +// Plugin related constants. \define( 'ACTIVITYPUB_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); \define( 'ACTIVITYPUB_PLUGIN_BASENAME', plugin_basename( __FILE__ ) ); \define( 'ACTIVITYPUB_PLUGIN_FILE', plugin_dir_path( __FILE__ ) . '/' . basename( __FILE__ ) ); diff --git a/build/editor-plugin/plugin.asset.php b/build/editor-plugin/plugin.asset.php index 67a3f181..c6e6699a 100644 --- a/build/editor-plugin/plugin.asset.php +++ b/build/editor-plugin/plugin.asset.php @@ -1 +1 @@ - array('react', 'wp-components', 'wp-core-data', 'wp-data', 'wp-editor', 'wp-i18n', 'wp-plugins'), 'version' => '88603987940fec29730d'); + array('react', 'wp-components', 'wp-core-data', 'wp-data', 'wp-editor', 'wp-element', 'wp-i18n', 'wp-plugins', 'wp-primitives'), 'version' => '92b35e5bbecef58deaf6'); diff --git a/build/editor-plugin/plugin.js b/build/editor-plugin/plugin.js index c16cd66e..826013db 100644 --- a/build/editor-plugin/plugin.js +++ b/build/editor-plugin/plugin.js @@ -1 +1 @@ -(()=>{"use strict";const t=window.React,e=window.wp.editor,n=window.wp.plugins,i=window.wp.components,o=window.wp.data,a=window.wp.coreData,r=window.wp.i18n;(0,n.registerPlugin)("activitypub-editor-plugin",{render:()=>{const n=(0,o.useSelect)((t=>t("core/editor").getCurrentPostType()),[]),[w,c]=(0,a.useEntityProp)("postType",n,"meta");return(0,t.createElement)(e.PluginDocumentSettingPanel,{name:"activitypub",title:(0,r.__)("Fediverse","activitypub")},(0,t.createElement)(i.TextControl,{label:(0,r.__)("Content Warning","activitypub"),value:w?.activitypub_content_warning,onChange:t=>{c({...w,activitypub_content_warning:t})},placeholder:(0,r.__)("Optional content warning","activitypub")}))}})})(); \ No newline at end of file +(()=>{"use strict";var e={20:(e,t,i)=>{var n=i(609),o=Symbol.for("react.element"),r=(Symbol.for("react.fragment"),Object.prototype.hasOwnProperty),a=n.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,l={key:!0,ref:!0,__self:!0,__source:!0};t.jsx=function(e,t,i){var n,c={},p=null,s=null;for(n in void 0!==i&&(p=""+i),void 0!==t.key&&(p=""+t.key),void 0!==t.ref&&(s=t.ref),t)r.call(t,n)&&!l.hasOwnProperty(n)&&(c[n]=t[n]);if(e&&e.defaultProps)for(n in t=e.defaultProps)void 0===c[n]&&(c[n]=t[n]);return{$$typeof:o,type:e,key:p,ref:s,props:c,_owner:a.current}}},848:(e,t,i)=>{e.exports=i(20)},609:e=>{e.exports=window.React}},t={};function i(n){var o=t[n];if(void 0!==o)return o.exports;var r=t[n]={exports:{}};return e[n](r,r.exports,i),r.exports}var n=i(609);const o=window.wp.editor,r=window.wp.plugins,a=window.wp.components,l=window.wp.element,c=(0,l.forwardRef)((function({icon:e,size:t=24,...i},n){return(0,l.cloneElement)(e,{width:t,height:t,...i,ref:n})})),p=window.wp.primitives;var s=i(848);const u=(0,s.jsx)(p.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,s.jsx)(p.Path,{d:"M12 3.3c-4.8 0-8.8 3.9-8.8 8.8 0 4.8 3.9 8.8 8.8 8.8 4.8 0 8.8-3.9 8.8-8.8s-4-8.8-8.8-8.8zm6.5 5.5h-2.6C15.4 7.3 14.8 6 14 5c2 .6 3.6 2 4.5 3.8zm.7 3.2c0 .6-.1 1.2-.2 1.8h-2.9c.1-.6.1-1.2.1-1.8s-.1-1.2-.1-1.8H19c.2.6.2 1.2.2 1.8zM12 18.7c-1-.7-1.8-1.9-2.3-3.5h4.6c-.5 1.6-1.3 2.9-2.3 3.5zm-2.6-4.9c-.1-.6-.1-1.1-.1-1.8 0-.6.1-1.2.1-1.8h5.2c.1.6.1 1.1.1 1.8s-.1 1.2-.1 1.8H9.4zM4.8 12c0-.6.1-1.2.2-1.8h2.9c-.1.6-.1 1.2-.1 1.8 0 .6.1 1.2.1 1.8H5c-.2-.6-.2-1.2-.2-1.8zM12 5.3c1 .7 1.8 1.9 2.3 3.5H9.7c.5-1.6 1.3-2.9 2.3-3.5zM10 5c-.8 1-1.4 2.3-1.8 3.8H5.5C6.4 7 8 5.6 10 5zM5.5 15.3h2.6c.4 1.5 1 2.8 1.8 3.7-1.8-.6-3.5-2-4.4-3.7zM14 19c.8-1 1.4-2.2 1.8-3.7h2.6C17.6 17 16 18.4 14 19z"})}),v=(0,s.jsx)(p.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,s.jsx)(p.Path,{d:"M15.5 9.5a1 1 0 100-2 1 1 0 000 2zm0 1.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5zm-2.25 6v-2a2.75 2.75 0 00-2.75-2.75h-4A2.75 2.75 0 003.75 15v2h1.5v-2c0-.69.56-1.25 1.25-1.25h4c.69 0 1.25.56 1.25 1.25v2h1.5zm7-2v2h-1.5v-2c0-.69-.56-1.25-1.25-1.25H15v-1.5h2.5A2.75 2.75 0 0120.25 15zM9.5 8.5a1 1 0 11-2 0 1 1 0 012 0zm1.5 0a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z",fillRule:"evenodd"})}),w=(0,s.jsx)(p.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,s.jsx)(p.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M12 18.5A6.5 6.5 0 0 1 6.93 7.931l9.139 9.138A6.473 6.473 0 0 1 12 18.5Zm5.123-2.498a6.5 6.5 0 0 0-9.124-9.124l9.124 9.124ZM4 12a8 8 0 1 1 16 0 8 8 0 0 1-16 0Z"})}),d=window.wp.data,_=window.wp.coreData,y=window.wp.i18n;(0,r.registerPlugin)("activitypub-editor-plugin",{render:()=>{const e=(0,d.useSelect)((e=>e("core/editor").getCurrentPostType()),[]),[t,i]=(0,_.useEntityProp)("postType",e,"meta"),r={verticalAlign:"middle",gap:"4px",justifyContent:"start",display:"inline-flex",alignItems:"center"},l=(e,t)=>(0,n.createElement)(a.__experimentalText,{style:r},(0,n.createElement)(c,{icon:t}),e);return(0,n.createElement)(o.PluginDocumentSettingPanel,{name:"activitypub",title:(0,y.__)("⁂ Fediverse","activitypub")},(0,n.createElement)(a.TextControl,{label:(0,y.__)("Content Warning","activitypub"),value:t?.activitypub_content_warning,onChange:e=>{i({...t,activitypub_content_warning:e})},placeholder:(0,y.__)("Optional content warning","activitypub")}),(0,n.createElement)(a.RadioControl,{label:(0,y.__)("Visibility","activitypub"),help:(0,y.__)("This adjusts the visibility of a post in the fediverse, but note that it won't affect how the post appears on the blog.","activitypub"),selected:t.activitypub_content_visibility?t.activitypub_content_visibility:"public",options:[{label:l((0,y.__)("Public","activitypub"),u),value:"public"},{label:l((0,y.__)("Quiet public","activitypub"),v),value:"quiet_public"},{label:l((0,y.__)("Do not federate","activitypub"),w),value:"local"}],onChange:e=>{i({...t,activitypub_content_visibility:e})},className:"activitypub-visibility"}))}})})(); \ No newline at end of file diff --git a/includes/class-activity-dispatcher.php b/includes/class-activity-dispatcher.php index 17281a33..c320e552 100644 --- a/includes/class-activity-dispatcher.php +++ b/includes/class-activity-dispatcher.php @@ -269,8 +269,20 @@ class Activity_Dispatcher { */ public static function add_inboxes_by_mentioned_actors( $inboxes, $user_id, $activity ) { $cc = $activity->get_cc(); - if ( $cc ) { - $mentioned_inboxes = Mention::get_inboxes( $cc ); + $to = $activity->get_to(); + + $audience = array_merge( $cc, $to ); + + // Remove "public placeholder" and "same domain" from the audience. + $audience = array_filter( + $audience, + function ( $actor ) { + return 'https://www.w3.org/ns/activitystreams#Public' !== $actor && ! is_same_domain( $actor ); + } + ); + + if ( $audience ) { + $mentioned_inboxes = Mention::get_inboxes( $audience ); return array_merge( $inboxes, $mentioned_inboxes ); } diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index b5bb164d..3a3d6445 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -98,7 +98,7 @@ class Activitypub { $json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/user-json.php'; } elseif ( is_comment() ) { $json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/comment-json.php'; - } elseif ( \is_singular() ) { + } elseif ( \is_singular() && ! is_post_disabled( \get_the_ID() ) ) { $json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/post-json.php'; } elseif ( \is_home() && ! is_user_type_disabled( 'blog' ) ) { $json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/blog-json.php'; diff --git a/includes/class-blocks.php b/includes/class-blocks.php index c42d2fc6..4464616f 100644 --- a/includes/class-blocks.php +++ b/includes/class-blocks.php @@ -42,7 +42,34 @@ class Blocks { 'show_in_rest' => true, 'single' => true, 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', + 'sanitize_callback' => function ( $warning ) { + if ( $warning ) { + return \sanitize_text_field( $warning ); + } + + return null; + }, + ) + ); + \register_post_meta( + $post_type, + 'activitypub_content_visibility', + array( + 'show_in_rest' => true, + 'single' => true, + 'type' => 'string', + 'sanitize_callback' => function ( $visibility ) { + $options = array( + ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, + ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, + ); + + if ( in_array( $visibility, $options, true ) ) { + return $visibility; + } + + return null; + }, ) ); } diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index 0774f296..1cc93cbc 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -113,7 +113,7 @@ class Scheduler { public static function schedule_post_activity( $new_status, $old_status, $post ) { $post = get_post( $post ); - if ( ! $post ) { + if ( ! $post || is_post_disabled( $post ) ) { return; } diff --git a/includes/functions.php b/includes/functions.php index 799af97f..80b503af 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -400,6 +400,31 @@ function is_activitypub_request() { return false; } +/** + * Check if a post is disabled for ActivityPub. + * + * @param mixed $post The post object or ID. + * + * @return boolean True if the post is disabled, false otherwise. + */ +function is_post_disabled( $post ) { + $post = \get_post( $post ); + $disabled = false; + $visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true ); + + if ( ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL === $visibility ) { + $disabled = true; + } + + /* + * Allow plugins to disable posts for ActivityPub. + * + * @param boolean $disabled True if the post is disabled, false otherwise. + * @param \WP_Post $post The post object. + */ + return \apply_filters( 'activitypub_is_post_disabled', $disabled, $post ); +} + /** * This function checks if a user is disabled for ActivityPub. * @@ -408,50 +433,50 @@ function is_activitypub_request() { * @return boolean True if the user is disabled, false otherwise. */ function is_user_disabled( $user_id ) { - $return = false; + $disabled = false; switch ( $user_id ) { // if the user is the application user, it's always enabled. case \Activitypub\Collection\Users::APPLICATION_USER_ID: - $return = false; + $disabled = false; break; // if the user is the blog user, it's only enabled in single-user mode. case \Activitypub\Collection\Users::BLOG_USER_ID: if ( is_user_type_disabled( 'blog' ) ) { - $return = true; + $disabled = true; break; } - $return = false; + $disabled = false; break; // if the user is any other user, it's enabled if it can publish posts. default: if ( ! \get_user_by( 'id', $user_id ) ) { - $return = true; + $disabled = true; break; } if ( is_user_type_disabled( 'user' ) ) { - $return = true; + $disabled = true; break; } if ( ! \user_can( $user_id, 'activitypub' ) ) { - $return = true; + $disabled = true; break; } - $return = false; + $disabled = false; break; } /** * Allow plugins to disable users for ActivityPub. * - * @param boolean $return True if the user is disabled, false otherwise. - * @param int $user_id The User-ID. + * @param boolean $disabled True if the user is disabled, false otherwise. + * @param int $user_id The User-ID. */ - return apply_filters( 'activitypub_is_user_disabled', $return, $user_id ); + return apply_filters( 'activitypub_is_user_disabled', $disabled, $user_id ); } /** @@ -469,45 +494,45 @@ function is_user_type_disabled( $type ) { case 'blog': if ( \defined( 'ACTIVITYPUB_SINGLE_USER_MODE' ) ) { if ( ACTIVITYPUB_SINGLE_USER_MODE ) { - $return = false; + $disabled = false; break; } } if ( \defined( 'ACTIVITYPUB_DISABLE_BLOG_USER' ) ) { - $return = ACTIVITYPUB_DISABLE_BLOG_USER; + $disabled = ACTIVITYPUB_DISABLE_BLOG_USER; break; } if ( '1' !== \get_option( 'activitypub_enable_blog_user', '0' ) ) { - $return = true; + $disabled = true; break; } - $return = false; + $disabled = false; break; case 'user': if ( \defined( 'ACTIVITYPUB_SINGLE_USER_MODE' ) ) { if ( ACTIVITYPUB_SINGLE_USER_MODE ) { - $return = true; + $disabled = true; break; } } if ( \defined( 'ACTIVITYPUB_DISABLE_USER' ) ) { - $return = ACTIVITYPUB_DISABLE_USER; + $disabled = ACTIVITYPUB_DISABLE_USER; break; } if ( '1' !== \get_option( 'activitypub_enable_users', '1' ) ) { - $return = true; + $disabled = true; break; } - $return = false; + $disabled = false; break; default: - $return = new WP_Error( + $disabled = new WP_Error( 'activitypub_wrong_user_type', __( 'Wrong user type', 'activitypub' ), array( 'status' => 400 ) @@ -518,10 +543,10 @@ function is_user_type_disabled( $type ) { /** * Allow plugins to disable user types for ActivityPub. * - * @param boolean $return True if the user type is disabled, false otherwise. - * @param string $type The User-Type. + * @param boolean $disabled True if the user type is disabled, false otherwise. + * @param string $type The User-Type. */ - return apply_filters( 'activitypub_is_user_type_disabled', $return, $type ); + return apply_filters( 'activitypub_is_user_type_disabled', $disabled, $type ); } /** @@ -1334,3 +1359,51 @@ function get_content_warning( $post_id ) { return $warning; } + +/** + * Check if a URL is from the same domain as the site. + * + * @param string $url The URL to check. + * + * @return boolean True if the URL is from the same domain, false otherwise. + */ +function is_same_domain( $url ) { + $remote = \wp_parse_url( $url, PHP_URL_HOST ); + + if ( ! $remote ) { + return false; + } + + $remote = normalize_host( $remote ); + $self = \wp_parse_url( \home_url(), PHP_URL_HOST ); + $self = normalize_host( $self ); + + return $remote === $self; +} + +/** + * Get the visibility of a post. + * + * @param int $post_id The post ID. + * + * @return string|false The visibility of the post or false if not found. + */ +function get_content_visibility( $post_id ) { + $post = get_post( $post_id ); + if ( ! $post ) { + return false; + } + + $visibility = get_post_meta( $post->ID, 'activitypub_content_visibility', true ); + $_visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC; + $options = array( + ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, + ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, + ); + + if ( in_array( $visibility, $options, true ) ) { + $_visibility = $visibility; + } + + return \apply_filters( 'activitypub_content_visibility', $_visibility, $post ); +} diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 2067677d..5de8964d 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -15,9 +15,10 @@ use Activitypub\Collection\Users; use function Activitypub\esc_hashtag; use function Activitypub\is_single_user; use function Activitypub\get_enclosures; +use function Activitypub\get_content_warning; use function Activitypub\site_supports_blocks; use function Activitypub\generate_post_summary; -use function Activitypub\get_content_warning; +use function Activitypub\get_content_visibility; /** * WordPress Post Transformer. @@ -77,6 +78,12 @@ class Post extends Base { $object->set_summary_map( null ); } + // Change order if visibility is "Quiet public". + if ( ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC === get_content_visibility( $post ) ) { + $object->set_to( $this->get_cc() ); + $object->set_cc( $this->get_to() ); + } + return $object; } @@ -686,6 +693,19 @@ class Post extends Base { return $object_type; } + /** + * Returns the recipient of the post. + * + * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-to + * + * @return array The recipient URLs of the post. + */ + public function get_to() { + return array( + 'https://www.w3.org/ns/activitystreams#Public', + ); + } + /** * Returns a list of Mentions, used in the Post. * @@ -694,7 +714,9 @@ class Post extends Base { * @return array The list of Mentions. */ protected function get_cc() { - $cc = array(); + $cc = array( + $this->get_actor_object()->get_followers(), + ); $mentions = $this->get_mentions(); if ( $mentions ) { @@ -952,20 +974,6 @@ class Post extends Base { return null; } - /** - * Returns the recipient of the post. - * - * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-to - * - * @return array The recipient URLs of the post. - */ - public function get_to() { - return array( - 'https://www.w3.org/ns/activitystreams#Public', - $this->get_actor_object()->get_followers(), - ); - } - /** * Returns the published date of the post. * diff --git a/src/editor-plugin/block.json b/src/editor-plugin/block.json index a8d510c7..22cf5cc1 100644 --- a/src/editor-plugin/block.json +++ b/src/editor-plugin/block.json @@ -6,4 +6,4 @@ "keywords": [ ], "editorScript": "file:./plugin.js" -} \ No newline at end of file +} diff --git a/src/editor-plugin/plugin.js b/src/editor-plugin/plugin.js index 7e95dcda..21896b60 100644 --- a/src/editor-plugin/plugin.js +++ b/src/editor-plugin/plugin.js @@ -1,6 +1,7 @@ import { PluginDocumentSettingPanel } from '@wordpress/editor'; import { registerPlugin } from '@wordpress/plugins'; -import { TextControl } from '@wordpress/components'; +import { TextControl, RadioControl, __experimentalText as Text } from '@wordpress/components'; +import { Icon, notAllowed, globe, people } from '@wordpress/icons'; import { useSelect } from '@wordpress/data'; import { useEntityProp } from '@wordpress/core-data'; import { __ } from '@wordpress/i18n'; @@ -13,10 +14,26 @@ const EditorPlugin = () => { ); const [ meta, setMeta ] = useEntityProp( 'postType', postType, 'meta' ); + const labelStyling = { + verticalAlign: "middle", + gap: "4px", + justifyContent: + "start", display: + "inline-flex", + alignItems: "center" + } + + const labelWithIcon = ( text, icon ) => ( + + + {text} + + ); + return ( { } } placeholder={ __( 'Optional content warning', 'activitypub' ) } /> + { + setMeta( { ...meta, activitypub_content_visibility: value } ); + } } + className="activitypub-visibility" + /> ); } -registerPlugin( 'activitypub-editor-plugin', { render: EditorPlugin } ); \ No newline at end of file +registerPlugin( 'activitypub-editor-plugin', { render: EditorPlugin } ); diff --git a/tests/test-class-activitypub-activity.php b/tests/test-class-activitypub-activity.php index 32a1f830..5f550165 100644 --- a/tests/test-class-activitypub-activity.php +++ b/tests/test-class-activitypub-activity.php @@ -25,7 +25,7 @@ class Test_Activitypub_Activity extends WP_UnitTestCase { $activitypub_activity->set_type( 'Create' ); $activitypub_activity->set_object( $activitypub_post ); - $this->assertContains( \Activitypub\get_rest_url_by_path( 'actors/1/followers' ), $activitypub_activity->get_to() ); + $this->assertContains( \Activitypub\get_rest_url_by_path( 'actors/1/followers' ), $activitypub_activity->get_cc() ); $this->assertContains( 'https://example.com/alex', $activitypub_activity->get_cc() ); remove_all_filters( 'activitypub_extract_mentions' );