From 09852806168454cab20babb7a650e5bfb8ee585f Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 19 Sep 2025 15:34:40 -0500 Subject: [PATCH 1/5] Add Perfecty Push integration for web push notifications - Add new integration class for Perfecty Push plugin compatibility - Hook into ActivityPub events (likes, announces, creates, follows) via activitypub_handled_* actions - Add per-user and blog actor notification settings with UI in profile pages - Include actor profile images with mp.jpg fallback for users without avatars - Support individual notification type toggles (likes, reposts, comments, followers) - Integrate settings into existing ActivityPub profile sections alongside email notifications --- integration/class-perfecty-push.php | 418 ++++++++++++++++++++++++++++ integration/load.php | 9 + 2 files changed, 427 insertions(+) create mode 100644 integration/class-perfecty-push.php diff --git a/integration/class-perfecty-push.php b/integration/class-perfecty-push.php new file mode 100644 index 000000000..9db296b95 --- /dev/null +++ b/integration/class-perfecty-push.php @@ -0,0 +1,418 @@ +get_name() ?? $actor->get_preferred_username() ?? \__( 'Someone', 'activitypub' ); + $actor_image = object_to_uri( $actor->get_icon() ?? ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg' ); + + $title = \__( 'New like on your post', 'activitypub' ); + $message = sprintf( + /* translators: 1: Actor name; 2: Post title */ + \__( '%1$s liked %2$s', 'activitypub' ), + $actor_name, + get_the_title( $comment->comment_post_ID ) + ); + + self::send_notification( $user_id, $message, $title, $actor_image ); + } + + /** + * Handle announce (repost/boost) notifications. + * + * @param object $activity The activity object. + * @param int $user_id The user ID. + * @param string $state The state. + * @param \WP_Comment $comment The comment object. + */ + public static function handle_announce( $activity, $user_id, $state, $comment ) { + if ( ! self::is_notification_enabled( 'announce', $user_id ) ) { + return; + } + + $actor = Remote_Actors::fetch_by_uri( $activity['actor'] ); + if ( is_wp_error( $actor ) ) { + return; + } + + $actor = Remote_Actors::get_actor( $actor ); + + $actor_name = $actor->get_name() ?? $actor->get_preferred_username() ?? \__( 'Someone', 'activitypub' ); + $actor_image = object_to_uri( $actor->get_icon() ?? ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg' ); + + $title = \__( 'Your post was shared', 'activitypub' ); + $message = sprintf( + /* translators: 1: Actor name; 2: Post title */ + \__( '%1$s shared %2$s', 'activitypub' ), + $actor_name, + get_the_title( $comment->comment_post_ID ) + ); + + self::send_notification( $user_id, $message, $title, $actor_image ); + } + + /** + * Handle create (comment) notifications. + * + * @param object $activity The activity object. + * @param int $user_id The user ID. + * @param string $state The state. + * @param \WP_Comment $comment The comment object. + */ + public static function handle_create( $activity, $user_id, $state, $comment ) { + if ( ! self::is_notification_enabled( 'create', $user_id ) ) { + return; + } + + $actor = Remote_Actors::get_actor( $activity['actor'] ); + if ( is_wp_error( $actor ) ) { + return; + } + + $actor_name = $actor->get_name() ?? $actor->get_preferred_username() ?? \__( 'Someone', 'activitypub' ); + $actor_image = object_to_uri( $actor->get_icon() ?? ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg' ); + + /* translators: %s: Actor name */ + $title = sprintf( \__( '%s commented on your post', 'activitypub' ), $actor_name ); + $message = get_comment_excerpt( $comment ); + + self::send_notification( $user_id, $message, $title, $actor_image ); + } + + /** + * Handle follow notifications. + * + * @param string $actor_url The actor URL. + * @param array $activity The activity object. + * @param int $user_id The user ID. + * @param \WP_Post $remote_actor The remote actor object. + */ + public static function handle_follow( $actor_url, $activity, $user_id, $remote_actor ) { + if ( ! self::is_notification_enabled( 'follow', $user_id ) ) { + return; + } + + $remote_actor = Remote_Actors::get_actor( $remote_actor ); + $actor_name = $remote_actor->get_name() ?? $remote_actor->get_preferred_username(); + $actor_image = object_to_uri( $remote_actor->get_icon() ?? ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg' ); + + $title = \__( 'New follower', 'activitypub' ); + /* translators: %s: Actor name */ + $message = sprintf( \__( '%s is now following you', 'activitypub' ), $actor_name ); + + self::send_notification( $user_id, $message, $title, $actor_image ); + } + + /** + * Send a push notification to a user. + * + * @param int $user_id The WordPress user ID. + * @param string $message The notification message. + * @param string $title The notification title. + * @param string $image_url Optional image URL. + * @param string $url_to_open Optional URL to open when clicked. + */ + private static function send_notification( $user_id, $message, $title = '', $image_url = '', $url_to_open = '' ) { + try { + // Check if Perfecty Push Integration class exists and is properly loaded. + if ( ! class_exists( 'Perfecty_Push_Integration' ) ) { + // Attempt to load the integration file manually. + $integration_file = WP_PLUGIN_DIR . '/perfecty-push-notifications/integration/class-perfecty-push-integration.php'; + if ( file_exists( $integration_file ) ) { + include_once $integration_file; + } + } + + if ( class_exists( 'Perfecty_Push_Integration' ) ) { + $integration = new \Perfecty_Push_Integration(); + $integration->notify( $user_id, $message, $title, $image_url, $url_to_open ); + } + } catch ( \Exception $e ) { + error_log( 'ActivityPub Perfecty Push notification failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + } + } + + /** + * Check if a specific notification type is enabled for a user. + * + * @param string $type The notification type (like, announce, create, follow). + * @param int $user_id The user ID to check settings for. + * @return bool True if enabled, false otherwise. + */ + private static function is_notification_enabled( $type, $user_id ) { + $default_enabled = array( 'like', 'announce', 'create', 'follow' ); + + // Check user-specific settings first. + $user_settings = \get_user_meta( $user_id, 'activitypub_perfecty_push_notifications', true ); + if ( ! empty( $user_settings ) && is_array( $user_settings ) ) { + return in_array( $type, $user_settings, true ); + } + + // Fall back to blog settings for blog actor (user_id 0). + if ( 0 === $user_id ) { + $blog_settings = \get_option( 'activitypub_perfecty_push_notifications', $default_enabled ); + return in_array( $type, $blog_settings, true ); + } + + // Default to enabled for all types. + return in_array( $type, $default_enabled, true ); + } + + /** + * Get actor display name from actor URL or object. + * + * @param string|array $actor The actor URL or object. + * @return string The actor display name. + */ + private static function get_actor_name( $actor ) { + if ( is_string( $actor ) ) { + // Try to get actor info from cache or fetch it. + $actor_data = \Activitypub\get_remote_metadata_by_actor( $actor ); + if ( $actor_data && isset( $actor_data['name'] ) ) { + return $actor_data['name']; + } + if ( $actor_data && isset( $actor_data['preferredUsername'] ) ) { + return $actor_data['preferredUsername']; + } + // Fallback to domain extraction. + $parsed_url = \wp_parse_url( $actor ); + return $parsed_url['host'] ?? $actor; + } + + if ( is_array( $actor ) ) { + if ( isset( $actor['name'] ) ) { + return $actor['name']; + } + if ( isset( $actor['preferredUsername'] ) ) { + return $actor['preferredUsername']; + } + } + + return \__( 'Someone', 'activitypub' ); + } + + /** + * Get actor image URL from actor URL or object. + * + * @param string|array $actor The actor URL or object. + * @return string The actor image URL with mp.jpg fallback. + */ + private static function get_actor_image( $actor ) { + $image_url = ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg'; + + if ( is_string( $actor ) ) { + // Try to get actor info from cache or fetch it. + $actor_data = \Activitypub\get_remote_metadata_by_actor( $actor ); + if ( $actor_data ) { + // Check for icon (avatar) in actor data. + if ( isset( $actor_data['icon'] ) ) { + if ( is_array( $actor_data['icon'] ) && isset( $actor_data['icon']['url'] ) ) { + $image_url = $actor_data['icon']['url']; + } elseif ( is_string( $actor_data['icon'] ) ) { + $image_url = $actor_data['icon']; + } + } + // Check for image field as fallback. + if ( empty( $image_url ) && isset( $actor_data['image'] ) ) { + if ( is_array( $actor_data['image'] ) && isset( $actor_data['image']['url'] ) ) { + $image_url = $actor_data['image']['url']; + } elseif ( is_string( $actor_data['image'] ) ) { + $image_url = $actor_data['image']; + } + } + } + } + + if ( is_array( $actor ) ) { + // Check for icon (avatar) in actor object. + if ( isset( $actor['icon'] ) ) { + if ( is_array( $actor['icon'] ) && isset( $actor['icon']['url'] ) ) { + $image_url = $actor['icon']['url']; + } elseif ( is_string( $actor['icon'] ) ) { + $image_url = $actor['icon']; + } + } + // Check for image field as fallback. + if ( empty( $image_url ) && isset( $actor['image'] ) ) { + if ( is_array( $actor['image'] ) && isset( $actor['image']['url'] ) ) { + $image_url = $actor['image']['url']; + } elseif ( is_string( $actor['image'] ) ) { + $image_url = $actor['image']; + } + } + } + + return $image_url; + } + + /** + * Register user settings. + */ + public static function register_user_settings() { + \add_settings_field( + 'activitypub_perfecty_push_notifications', + \esc_html__( 'Push Notifications', 'activitypub' ), + array( self::class, 'render_user_notification_field' ), + 'activitypub_user_settings', + 'activitypub_user_profile' + ); + } + + /** + * Register blog settings. + */ + public static function register_blog_settings() { + // Check if we're on the blog profile tab. + if ( isset( $_GET['tab'] ) && 'blog-profile' === \sanitize_key( $_GET['tab'] ) ) { + \add_settings_field( + 'activitypub_perfecty_push_notifications', + \esc_html__( 'Push Notifications', 'activitypub' ), + array( self::class, 'render_blog_notification_field' ), + 'activitypub_blog_settings', + 'activitypub_blog_profile' + ); + } + } + + /** + * Register the setting field. + */ + public static function register_setting_field() { + \register_setting( + 'activitypub_settings', + 'activitypub_perfecty_push_notifications', + array( + 'type' => 'array', + 'description' => \__( 'ActivityPub Perfecty Push notification types', 'activitypub' ), + 'sanitize_callback' => array( self::class, 'sanitize_notification_types' ), + 'default' => array( 'like', 'announce', 'create', 'follow' ), + ) + ); + } + + /** + * Sanitize notification types setting. + * + * @param array $input The input value. + * @return array The sanitized value. + */ + public static function sanitize_notification_types( $input ) { + $valid_types = array( 'like', 'announce', 'create', 'follow' ); + + if ( ! is_array( $input ) ) { + return array(); + } + + return array_intersect( $input, $valid_types ); + } + + /** + * Render the user notification settings field. + */ + public static function render_user_notification_field() { + $user_id = \get_current_user_id(); + $enabled_types = \get_user_meta( $user_id, 'activitypub_perfecty_push_notifications', true ); + + if ( ! is_array( $enabled_types ) ) { + $enabled_types = array( 'like', 'announce', 'create', 'follow' ); + } + + $notification_types = array( + 'like' => \__( 'Likes', 'activitypub' ), + 'announce' => \__( 'Reposts/Boosts', 'activitypub' ), + 'create' => \__( 'Comments', 'activitypub' ), + 'follow' => \__( 'New Followers', 'activitypub' ), + ); + + echo '
'; + foreach ( $notification_types as $type => $label ) { + printf( + '
', + \esc_attr( $type ), + \checked( in_array( $type, $enabled_types, true ), true, false ), + \esc_html( $label ) + ); + } + echo '
'; + echo '

' . \esc_html__( 'Select which ActivityPub events should trigger push notifications for your account.', 'activitypub' ) . '

'; + } + + /** + * Render the blog notification settings field. + */ + public static function render_blog_notification_field() { + $enabled_types = \get_option( 'activitypub_perfecty_push_notifications', array( 'like', 'announce', 'create', 'follow' ) ); + $notification_types = array( + 'like' => \__( 'Likes', 'activitypub' ), + 'announce' => \__( 'Reposts/Boosts', 'activitypub' ), + 'create' => \__( 'Comments', 'activitypub' ), + 'follow' => \__( 'New Followers', 'activitypub' ), + ); + + echo '
'; + foreach ( $notification_types as $type => $label ) { + printf( + '
', + \esc_attr( $type ), + \checked( in_array( $type, $enabled_types, true ), true, false ), + \esc_html( $label ) + ); + } + echo '
'; + echo '

' . \esc_html__( 'Select which ActivityPub events should trigger push notifications for the blog actor.', 'activitypub' ) . '

'; + } +} diff --git a/integration/load.php b/integration/load.php index 627b33cc3..5ff7c0501 100644 --- a/integration/load.php +++ b/integration/load.php @@ -55,6 +55,15 @@ function plugin_init() { Opengraph::init(); } + /** + * Adds Perfecty Push support. + * + * @see https://wordpress.org/plugins/perfecty-push-notifications/ + */ + if ( \defined( 'PERFECTY_PUSH_VERSION' ) ) { + Perfecty_Push::init(); + } + /** * Adds Jetpack support. * From dac4b3c68161ce3d3f121328e6230fdfd06f7aff Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 19 Sep 2025 15:58:48 -0500 Subject: [PATCH 2/5] Refactor actor handling and improve notification payload Replaces get_actor with fetch_by_uri for actor retrieval and decodes comment excerpts for notifications. Removes unused helper methods for actor name and image, and ensures the notification includes a direct comment link. Also updates integration file path resolution and streamlines notification sending. --- integration/class-perfecty-push.php | 100 ++-------------------------- 1 file changed, 7 insertions(+), 93 deletions(-) diff --git a/integration/class-perfecty-push.php b/integration/class-perfecty-push.php index 9db296b95..195ec3ac1 100644 --- a/integration/class-perfecty-push.php +++ b/integration/class-perfecty-push.php @@ -118,19 +118,21 @@ public static function handle_create( $activity, $user_id, $state, $comment ) { return; } - $actor = Remote_Actors::get_actor( $activity['actor'] ); + $actor = Remote_Actors::fetch_by_uri( $activity['actor'] ); if ( is_wp_error( $actor ) ) { return; } + $actor = Remote_Actors::get_actor( $actor ); + $actor_name = $actor->get_name() ?? $actor->get_preferred_username() ?? \__( 'Someone', 'activitypub' ); $actor_image = object_to_uri( $actor->get_icon() ?? ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg' ); /* translators: %s: Actor name */ $title = sprintf( \__( '%s commented on your post', 'activitypub' ), $actor_name ); - $message = get_comment_excerpt( $comment ); + $message = \html_entity_decode( \get_comment_excerpt( $comment ), ENT_QUOTES, 'UTF-8' ); - self::send_notification( $user_id, $message, $title, $actor_image ); + self::send_notification( $user_id, $message, $title, $actor_image, get_comment_link( $comment ) ); } /** @@ -171,15 +173,14 @@ private static function send_notification( $user_id, $message, $title = '', $ima // Check if Perfecty Push Integration class exists and is properly loaded. if ( ! class_exists( 'Perfecty_Push_Integration' ) ) { // Attempt to load the integration file manually. - $integration_file = WP_PLUGIN_DIR . '/perfecty-push-notifications/integration/class-perfecty-push-integration.php'; + $integration_file = WP_PLUGIN_DIR . '/' . dirname( PERFECTY_PUSH_BASENAME ) . '/integration/class-perfecty-push-integration.php'; if ( file_exists( $integration_file ) ) { include_once $integration_file; } } if ( class_exists( 'Perfecty_Push_Integration' ) ) { - $integration = new \Perfecty_Push_Integration(); - $integration->notify( $user_id, $message, $title, $image_url, $url_to_open ); + ( new \Perfecty_Push_Integration() )->notify( $user_id, $message, $title, $image_url, $url_to_open ); } } catch ( \Exception $e ) { error_log( 'ActivityPub Perfecty Push notification failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log @@ -212,93 +213,6 @@ private static function is_notification_enabled( $type, $user_id ) { return in_array( $type, $default_enabled, true ); } - /** - * Get actor display name from actor URL or object. - * - * @param string|array $actor The actor URL or object. - * @return string The actor display name. - */ - private static function get_actor_name( $actor ) { - if ( is_string( $actor ) ) { - // Try to get actor info from cache or fetch it. - $actor_data = \Activitypub\get_remote_metadata_by_actor( $actor ); - if ( $actor_data && isset( $actor_data['name'] ) ) { - return $actor_data['name']; - } - if ( $actor_data && isset( $actor_data['preferredUsername'] ) ) { - return $actor_data['preferredUsername']; - } - // Fallback to domain extraction. - $parsed_url = \wp_parse_url( $actor ); - return $parsed_url['host'] ?? $actor; - } - - if ( is_array( $actor ) ) { - if ( isset( $actor['name'] ) ) { - return $actor['name']; - } - if ( isset( $actor['preferredUsername'] ) ) { - return $actor['preferredUsername']; - } - } - - return \__( 'Someone', 'activitypub' ); - } - - /** - * Get actor image URL from actor URL or object. - * - * @param string|array $actor The actor URL or object. - * @return string The actor image URL with mp.jpg fallback. - */ - private static function get_actor_image( $actor ) { - $image_url = ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg'; - - if ( is_string( $actor ) ) { - // Try to get actor info from cache or fetch it. - $actor_data = \Activitypub\get_remote_metadata_by_actor( $actor ); - if ( $actor_data ) { - // Check for icon (avatar) in actor data. - if ( isset( $actor_data['icon'] ) ) { - if ( is_array( $actor_data['icon'] ) && isset( $actor_data['icon']['url'] ) ) { - $image_url = $actor_data['icon']['url']; - } elseif ( is_string( $actor_data['icon'] ) ) { - $image_url = $actor_data['icon']; - } - } - // Check for image field as fallback. - if ( empty( $image_url ) && isset( $actor_data['image'] ) ) { - if ( is_array( $actor_data['image'] ) && isset( $actor_data['image']['url'] ) ) { - $image_url = $actor_data['image']['url']; - } elseif ( is_string( $actor_data['image'] ) ) { - $image_url = $actor_data['image']; - } - } - } - } - - if ( is_array( $actor ) ) { - // Check for icon (avatar) in actor object. - if ( isset( $actor['icon'] ) ) { - if ( is_array( $actor['icon'] ) && isset( $actor['icon']['url'] ) ) { - $image_url = $actor['icon']['url']; - } elseif ( is_string( $actor['icon'] ) ) { - $image_url = $actor['icon']; - } - } - // Check for image field as fallback. - if ( empty( $image_url ) && isset( $actor['image'] ) ) { - if ( is_array( $actor['image'] ) && isset( $actor['image']['url'] ) ) { - $image_url = $actor['image']['url']; - } elseif ( is_string( $actor['image'] ) ) { - $image_url = $actor['image']; - } - } - } - - return $image_url; - } - /** * Register user settings. */ From 2777bcca6b85765dd1f9ad5e938f0e3b0666a59a Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 19 Sep 2025 16:04:55 -0500 Subject: [PATCH 3/5] Adjust hook priority for settings registration Set the priority to 11 for user and blog settings registration hooks to ensure they run after ActivityPub settings. --- integration/class-perfecty-push.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration/class-perfecty-push.php b/integration/class-perfecty-push.php index 195ec3ac1..bfe440fc9 100644 --- a/integration/class-perfecty-push.php +++ b/integration/class-perfecty-push.php @@ -32,8 +32,8 @@ public static function init() { \add_action( 'activitypub_followers_post_follow', array( self::class, 'handle_follow' ), 10, 4 ); // Register settings. - \add_action( 'load-profile.php', array( self::class, 'register_user_settings' ) ); - \add_action( 'load-settings_page_activitypub', array( self::class, 'register_blog_settings' ) ); + \add_action( 'load-profile.php', array( self::class, 'register_user_settings' ), 11 ); // After ActivityPub settings. + \add_action( 'load-settings_page_activitypub', array( self::class, 'register_blog_settings' ), 11 ); // After ActivityPub settings. \add_action( 'admin_init', array( self::class, 'register_setting_field' ) ); } From ebd6ce5fd02ffec2329933110eb04a71b9eed219 Mon Sep 17 00:00:00 2001 From: Automattic Bot Date: Fri, 19 Sep 2025 23:07:06 +0200 Subject: [PATCH 4/5] Add changelog --- .github/changelog/2216-from-description | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/changelog/2216-from-description diff --git a/.github/changelog/2216-from-description b/.github/changelog/2216-from-description new file mode 100644 index 000000000..664886334 --- /dev/null +++ b/.github/changelog/2216-from-description @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add Perfecty Push integration for web push notifications on ActivityPub events From 61e4279444f6044caa511d3e4cb3b51d549366e4 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 19 Sep 2025 16:30:57 -0500 Subject: [PATCH 5/5] Register push notification field unconditionally Removes the conditional check for the 'blog-profile' tab when registering the push notifications settings field, ensuring it is always added to the blog settings. --- integration/class-perfecty-push.php | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/integration/class-perfecty-push.php b/integration/class-perfecty-push.php index bfe440fc9..00699e898 100644 --- a/integration/class-perfecty-push.php +++ b/integration/class-perfecty-push.php @@ -230,16 +230,13 @@ public static function register_user_settings() { * Register blog settings. */ public static function register_blog_settings() { - // Check if we're on the blog profile tab. - if ( isset( $_GET['tab'] ) && 'blog-profile' === \sanitize_key( $_GET['tab'] ) ) { - \add_settings_field( - 'activitypub_perfecty_push_notifications', - \esc_html__( 'Push Notifications', 'activitypub' ), - array( self::class, 'render_blog_notification_field' ), - 'activitypub_blog_settings', - 'activitypub_blog_profile' - ); - } + \add_settings_field( + 'activitypub_perfecty_push_notifications', + \esc_html__( 'Push Notifications', 'activitypub' ), + array( self::class, 'render_blog_notification_field' ), + 'activitypub_blog_settings', + 'activitypub_blog_profile' + ); } /**