admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'fedistream_notifications' ), 'pollInterval' => 60000, // 1 minute. 'i18n' => array( 'noNotifications' => __( 'No notifications', 'wp-fedistream' ), 'markAllRead' => __( 'Mark all as read', 'wp-fedistream' ), 'viewAll' => __( 'View all notifications', 'wp-fedistream' ), 'justNow' => __( 'Just now', 'wp-fedistream' ), 'error' => __( 'An error occurred.', 'wp-fedistream' ), ), ) ); } /** * Create a notification. * * @param int $user_id User ID. * @param string $type Notification type. * @param string $title Notification title. * @param string $message Notification message. * @param array $data Additional data. * @return int|false Notification ID or false on failure. */ public static function create( int $user_id, string $type, string $title, string $message, array $data = array() ) { global $wpdb; $table = $wpdb->prefix . 'fedistream_notifications'; $result = $wpdb->insert( $table, array( 'user_id' => $user_id, 'type' => $type, 'title' => $title, 'message' => $message, 'data' => wp_json_encode( $data ), 'is_read' => 0, 'created_at' => current_time( 'mysql' ), ), array( '%d', '%s', '%s', '%s', '%s', '%d', '%s' ) ); if ( $result ) { $notification_id = $wpdb->insert_id; /** * Fires when a notification is created. * * @param int $notification_id Notification ID. * @param array $notification Notification data. */ do_action( 'fedistream_notification_created', $notification_id, array( 'user_id' => $user_id, 'type' => $type, 'title' => $title, 'message' => $message, 'data' => $data, ) ); return $notification_id; } return false; } /** * Get notifications for a user. * * @param int $user_id User ID. * @param bool $unread_only Only get unread notifications. * @param int $limit Number of notifications to retrieve. * @param int $offset Offset for pagination. * @return array */ public static function get_for_user( int $user_id, bool $unread_only = false, int $limit = 20, int $offset = 0 ): array { global $wpdb; $table = $wpdb->prefix . 'fedistream_notifications'; $where = 'WHERE user_id = %d'; $params = array( $user_id ); if ( $unread_only ) { $where .= ' AND is_read = 0'; } $params[] = $limit; $params[] = $offset; $notifications = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} {$where} ORDER BY created_at DESC LIMIT %d OFFSET %d", ...$params ) ); $result = array(); foreach ( $notifications as $notification ) { $result[] = array( 'id' => (int) $notification->id, 'type' => $notification->type, 'title' => $notification->title, 'message' => $notification->message, 'data' => json_decode( $notification->data, true ) ?: array(), 'is_read' => (bool) $notification->is_read, 'created_at' => $notification->created_at, 'read_at' => $notification->read_at, ); } return $result; } /** * Get unread count for a user. * * @param int $user_id User ID. * @return int */ public static function get_unread_count( int $user_id ): int { global $wpdb; $table = $wpdb->prefix . 'fedistream_notifications'; $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE user_id = %d AND is_read = 0", $user_id ) ); return (int) $count; } /** * Mark a notification as read. * * @param int $notification_id Notification ID. * @param int $user_id User ID (for verification). * @return bool */ public static function mark_read( int $notification_id, int $user_id ): bool { global $wpdb; $table = $wpdb->prefix . 'fedistream_notifications'; $result = $wpdb->update( $table, array( 'is_read' => 1, 'read_at' => current_time( 'mysql' ), ), array( 'id' => $notification_id, 'user_id' => $user_id, ), array( '%d', '%s' ), array( '%d', '%d' ) ); return false !== $result; } /** * Mark all notifications as read for a user. * * @param int $user_id User ID. * @return bool */ public static function mark_all_read( int $user_id ): bool { global $wpdb; $table = $wpdb->prefix . 'fedistream_notifications'; $result = $wpdb->update( $table, array( 'is_read' => 1, 'read_at' => current_time( 'mysql' ), ), array( 'user_id' => $user_id, 'is_read' => 0, ), array( '%d', '%s' ), array( '%d', '%d' ) ); return false !== $result; } /** * Delete a notification. * * @param int $notification_id Notification ID. * @param int $user_id User ID (for verification). * @return bool */ public static function delete( int $notification_id, int $user_id ): bool { global $wpdb; $table = $wpdb->prefix . 'fedistream_notifications'; $result = $wpdb->delete( $table, array( 'id' => $notification_id, 'user_id' => $user_id, ), array( '%d', '%d' ) ); return (bool) $result; } /** * AJAX: Get notifications. * * @return void */ public function ajax_get_notifications(): void { check_ajax_referer( 'fedistream_notifications', 'nonce' ); if ( ! is_user_logged_in() ) { wp_send_json_error( array( 'message' => __( 'Not logged in.', 'wp-fedistream' ) ) ); } $user_id = get_current_user_id(); $unread_only = isset( $_POST['unread_only'] ) && $_POST['unread_only']; $limit = isset( $_POST['limit'] ) ? absint( $_POST['limit'] ) : 20; $offset = isset( $_POST['offset'] ) ? absint( $_POST['offset'] ) : 0; $notifications = self::get_for_user( $user_id, $unread_only, $limit, $offset ); $unread_count = self::get_unread_count( $user_id ); wp_send_json_success( array( 'notifications' => $notifications, 'unread_count' => $unread_count, ) ); } /** * AJAX: Mark notification as read. * * @return void */ public function ajax_mark_read(): void { check_ajax_referer( 'fedistream_notifications', 'nonce' ); if ( ! is_user_logged_in() ) { wp_send_json_error( array( 'message' => __( 'Not logged in.', 'wp-fedistream' ) ) ); } $notification_id = isset( $_POST['notification_id'] ) ? absint( $_POST['notification_id'] ) : 0; if ( ! $notification_id ) { wp_send_json_error( array( 'message' => __( 'Invalid notification.', 'wp-fedistream' ) ) ); } $user_id = get_current_user_id(); $result = self::mark_read( $notification_id, $user_id ); if ( $result ) { wp_send_json_success( array( 'unread_count' => self::get_unread_count( $user_id ), ) ); } else { wp_send_json_error( array( 'message' => __( 'Failed to update notification.', 'wp-fedistream' ) ) ); } } /** * AJAX: Mark all notifications as read. * * @return void */ public function ajax_mark_all_read(): void { check_ajax_referer( 'fedistream_notifications', 'nonce' ); if ( ! is_user_logged_in() ) { wp_send_json_error( array( 'message' => __( 'Not logged in.', 'wp-fedistream' ) ) ); } $user_id = get_current_user_id(); $result = self::mark_all_read( $user_id ); if ( $result ) { wp_send_json_success( array( 'unread_count' => 0 ) ); } else { wp_send_json_error( array( 'message' => __( 'Failed to update notifications.', 'wp-fedistream' ) ) ); } } /** * AJAX: Delete notification. * * @return void */ public function ajax_delete(): void { check_ajax_referer( 'fedistream_notifications', 'nonce' ); if ( ! is_user_logged_in() ) { wp_send_json_error( array( 'message' => __( 'Not logged in.', 'wp-fedistream' ) ) ); } $notification_id = isset( $_POST['notification_id'] ) ? absint( $_POST['notification_id'] ) : 0; if ( ! $notification_id ) { wp_send_json_error( array( 'message' => __( 'Invalid notification.', 'wp-fedistream' ) ) ); } $user_id = get_current_user_id(); $result = self::delete( $notification_id, $user_id ); if ( $result ) { wp_send_json_success( array( 'unread_count' => self::get_unread_count( $user_id ), ) ); } else { wp_send_json_error( array( 'message' => __( 'Failed to delete notification.', 'wp-fedistream' ) ) ); } } /** * Notify followers of a new album release. * * @param int $album_id Album post ID. * @return void */ public function notify_new_release( int $album_id ): void { $album = get_post( $album_id ); if ( ! $album ) { return; } $artist_id = get_post_meta( $album_id, '_fedistream_album_artist', true ); if ( ! $artist_id ) { return; } $artist = get_post( $artist_id ); if ( ! $artist ) { return; } // Get all local followers of this artist. $followers = $this->get_artist_local_followers( $artist_id ); foreach ( $followers as $user_id ) { self::create( $user_id, self::TYPE_NEW_RELEASE, sprintf( /* translators: %s: artist name */ __( 'New release from %s', 'wp-fedistream' ), $artist->post_title ), sprintf( /* translators: 1: artist name, 2: album title */ __( '%1$s released a new album: %2$s', 'wp-fedistream' ), $artist->post_title, $album->post_title ), array( 'album_id' => $album_id, 'artist_id' => $artist_id, 'album_url' => get_permalink( $album ), 'artist_url' => get_permalink( $artist ), 'thumbnail' => get_the_post_thumbnail_url( $album, 'thumbnail' ), ) ); } } /** * Notify followers of a new track. * * @param int $track_id Track post ID. * @return void */ public function notify_new_track( int $track_id ): void { $track = get_post( $track_id ); if ( ! $track ) { return; } // Get artist from track or album. $artist_ids = get_post_meta( $track_id, '_fedistream_artist_ids', true ); if ( empty( $artist_ids ) ) { $album_id = get_post_meta( $track_id, '_fedistream_album_id', true ); $artist_id = $album_id ? get_post_meta( $album_id, '_fedistream_album_artist', true ) : 0; $artist_ids = $artist_id ? array( $artist_id ) : array(); } if ( empty( $artist_ids ) ) { return; } $artist_id = $artist_ids[0]; $artist = get_post( $artist_id ); if ( ! $artist ) { return; } // Only notify for single releases (tracks without album). $album_id = get_post_meta( $track_id, '_fedistream_album_id', true ); if ( $album_id ) { return; // Album release handles notification. } $followers = $this->get_artist_local_followers( $artist_id ); foreach ( $followers as $user_id ) { self::create( $user_id, self::TYPE_NEW_RELEASE, sprintf( /* translators: %s: artist name */ __( 'New track from %s', 'wp-fedistream' ), $artist->post_title ), sprintf( /* translators: 1: artist name, 2: track title */ __( '%1$s released a new track: %2$s', 'wp-fedistream' ), $artist->post_title, $track->post_title ), array( 'track_id' => $track_id, 'artist_id' => $artist_id, 'track_url' => get_permalink( $track ), 'artist_url' => get_permalink( $artist ), ) ); } } /** * Notify artist when they get a new local follower. * * @param int $user_id User ID who followed. * @param int $artist_id Artist post ID. * @return void */ public function notify_artist_followed( int $user_id, int $artist_id ): void { $artist = get_post( $artist_id ); if ( ! $artist ) { return; } // Get the artist's WordPress user ID. $artist_user_id = get_post_meta( $artist_id, '_fedistream_user_id', true ); if ( ! $artist_user_id ) { return; } $follower = get_user_by( 'id', $user_id ); if ( ! $follower ) { return; } self::create( $artist_user_id, self::TYPE_NEW_FOLLOWER, __( 'New follower', 'wp-fedistream' ), sprintf( /* translators: %s: follower display name */ __( '%s started following you', 'wp-fedistream' ), $follower->display_name ), array( 'follower_id' => $user_id, 'follower_name' => $follower->display_name, ) ); } /** * Notify of a Fediverse like. * * @param int $content_id Content post ID. * @param array $actor Actor data. * @return void */ public function notify_fediverse_like( int $content_id, array $actor ): void { $post = get_post( $content_id ); if ( ! $post ) { return; } $artist_user_id = $this->get_content_owner_user_id( $content_id ); if ( ! $artist_user_id ) { return; } $actor_name = $actor['name'] ?? $actor['preferredUsername'] ?? __( 'Someone', 'wp-fedistream' ); self::create( $artist_user_id, self::TYPE_FEDIVERSE_LIKE, __( 'New like from Fediverse', 'wp-fedistream' ), sprintf( /* translators: 1: actor name, 2: content title */ __( '%1$s liked your %2$s', 'wp-fedistream' ), $actor_name, $post->post_title ), array( 'content_id' => $content_id, 'content_type' => $post->post_type, 'actor_uri' => $actor['id'] ?? '', 'actor_name' => $actor_name, 'actor_icon' => $actor['icon']['url'] ?? '', ) ); } /** * Notify of a Fediverse boost/announce. * * @param int $content_id Content post ID. * @param array $actor Actor data. * @return void */ public function notify_fediverse_boost( int $content_id, array $actor ): void { $post = get_post( $content_id ); if ( ! $post ) { return; } $artist_user_id = $this->get_content_owner_user_id( $content_id ); if ( ! $artist_user_id ) { return; } $actor_name = $actor['name'] ?? $actor['preferredUsername'] ?? __( 'Someone', 'wp-fedistream' ); self::create( $artist_user_id, self::TYPE_FEDIVERSE_BOOST, __( 'New boost from Fediverse', 'wp-fedistream' ), sprintf( /* translators: 1: actor name, 2: content title */ __( '%1$s boosted your %2$s', 'wp-fedistream' ), $actor_name, $post->post_title ), array( 'content_id' => $content_id, 'content_type' => $post->post_type, 'actor_uri' => $actor['id'] ?? '', 'actor_name' => $actor_name, 'actor_icon' => $actor['icon']['url'] ?? '', ) ); } /** * Maybe send email notification. * * @param int $notification_id Notification ID. * @param array $notification Notification data. * @return void */ public function maybe_send_email( int $notification_id, array $notification ): void { $user_id = $notification['user_id']; $type = $notification['type']; // Check user preference for email notifications. $email_enabled = get_user_meta( $user_id, 'fedistream_email_notifications', true ); if ( '0' === $email_enabled ) { return; } // Check specific notification type preference. $type_enabled = get_user_meta( $user_id, 'fedistream_email_' . $type, true ); if ( '0' === $type_enabled ) { return; } $user = get_user_by( 'id', $user_id ); if ( ! $user || ! $user->user_email ) { return; } $subject = sprintf( /* translators: 1: site name, 2: notification title */ __( '[%1$s] %2$s', 'wp-fedistream' ), get_bloginfo( 'name' ), $notification['title'] ); $message = $this->build_email_message( $notification ); $headers = array( 'Content-Type: text/html; charset=UTF-8', ); wp_mail( $user->user_email, $subject, $message, $headers ); } /** * Build email message HTML. * * @param array $notification Notification data. * @return string */ private function build_email_message( array $notification ): string { $site_name = get_bloginfo( 'name' ); $site_url = home_url(); $html = '
'; $html .= '' . esc_html( $notification['message'] ) . '
'; // Add action link if available. $data = $notification['data']; $link = ''; if ( ! empty( $data['album_url'] ) ) { $link = $data['album_url']; } elseif ( ! empty( $data['track_url'] ) ) { $link = $data['track_url']; } elseif ( ! empty( $data['artist_url'] ) ) { $link = $data['artist_url']; } if ( $link ) { $html .= '' . esc_html__( 'View Details', 'wp-fedistream' ) . '
'; } $html .= '' . sprintf( /* translators: %s: site name */ esc_html__( 'This email was sent by %s.', 'wp-fedistream' ), '' . esc_html( $site_name ) . '' ) . '
'; $html .= '