Files
wp-fedistream/includes/User/Notifications.php

829 lines
22 KiB
PHP
Raw Permalink Normal View History

<?php
/**
* User Notifications class.
*
* @package WP_FediStream
*/
namespace WP_FediStream\User;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles user notifications (in-app and email).
*/
class Notifications {
/**
* Notification types.
*/
const TYPE_NEW_RELEASE = 'new_release';
const TYPE_NEW_FOLLOWER = 'new_follower';
const TYPE_FEDIVERSE_LIKE = 'fediverse_like';
const TYPE_FEDIVERSE_BOOST = 'fediverse_boost';
const TYPE_PLAYLIST_ADDED = 'playlist_added';
const TYPE_PURCHASE = 'purchase';
const TYPE_SYSTEM = 'system';
/**
* Constructor.
*/
public function __construct() {
// AJAX handlers.
add_action( 'wp_ajax_fedistream_get_notifications', array( $this, 'ajax_get_notifications' ) );
add_action( 'wp_ajax_fedistream_mark_notification_read', array( $this, 'ajax_mark_read' ) );
add_action( 'wp_ajax_fedistream_mark_all_notifications_read', array( $this, 'ajax_mark_all_read' ) );
add_action( 'wp_ajax_fedistream_delete_notification', array( $this, 'ajax_delete' ) );
// Notification triggers.
add_action( 'fedistream_album_published', array( $this, 'notify_new_release' ), 10, 1 );
add_action( 'fedistream_track_published', array( $this, 'notify_new_track' ), 10, 1 );
add_action( 'fedistream_artist_followed', array( $this, 'notify_artist_followed' ), 10, 2 );
add_action( 'fedistream_activitypub_like_received', array( $this, 'notify_fediverse_like' ), 10, 2 );
add_action( 'fedistream_activitypub_announce_received', array( $this, 'notify_fediverse_boost' ), 10, 2 );
// Email notifications.
add_action( 'fedistream_notification_created', array( $this, 'maybe_send_email' ), 10, 2 );
// Admin bar notification count.
add_action( 'admin_bar_menu', array( $this, 'add_notification_indicator' ), 100 );
// Enqueue scripts.
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
}
/**
* Enqueue notification scripts.
*
* @return void
*/
public function enqueue_scripts(): void {
if ( ! is_user_logged_in() ) {
return;
}
wp_enqueue_script(
'fedistream-notifications',
WP_FEDISTREAM_URL . 'assets/js/notifications.js',
array( 'jquery' ),
WP_FEDISTREAM_VERSION,
true
);
wp_localize_script(
'fedistream-notifications',
'fedistreamNotifications',
array(
'ajaxUrl' => 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 = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>';
$html .= '<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">';
$html .= '<h2 style="color: #333;">' . esc_html( $notification['title'] ) . '</h2>';
$html .= '<p style="color: #666; font-size: 16px;">' . esc_html( $notification['message'] ) . '</p>';
// 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 .= '<p><a href="' . esc_url( $link ) . '" style="display: inline-block; padding: 10px 20px; background: #0073aa; color: #fff; text-decoration: none; border-radius: 4px;">' . esc_html__( 'View Details', 'wp-fedistream' ) . '</a></p>';
}
$html .= '<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">';
$html .= '<p style="color: #999; font-size: 12px;">' . sprintf(
/* translators: %s: site name */
esc_html__( 'This email was sent by %s.', 'wp-fedistream' ),
'<a href="' . esc_url( $site_url ) . '">' . esc_html( $site_name ) . '</a>'
) . '</p>';
$html .= '</div></body></html>';
return $html;
}
/**
* Add notification indicator to admin bar.
*
* @param \WP_Admin_Bar $admin_bar Admin bar instance.
* @return void
*/
public function add_notification_indicator( \WP_Admin_Bar $admin_bar ): void {
if ( ! is_user_logged_in() ) {
return;
}
$user_id = get_current_user_id();
$unread_count = self::get_unread_count( $user_id );
$title = '<span class="ab-icon dashicons dashicons-bell"></span>';
if ( $unread_count > 0 ) {
$title .= '<span class="fedistream-notification-count">' . esc_html( $unread_count ) . '</span>';
}
$admin_bar->add_node(
array(
'id' => 'fedistream-notifications',
'title' => $title,
'href' => '#',
'meta' => array(
'class' => 'fedistream-notifications-menu',
),
)
);
}
/**
* Get local followers for an artist.
*
* @param int $artist_id Artist post ID.
* @return array User IDs.
*/
private function get_artist_local_followers( int $artist_id ): array {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_user_follows';
$user_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT user_id FROM {$table} WHERE artist_id = %d",
$artist_id
)
);
return array_map( 'intval', $user_ids );
}
/**
* Get the WordPress user ID who owns a piece of content.
*
* @param int $content_id Content post ID.
* @return int|null User ID or null.
*/
private function get_content_owner_user_id( int $content_id ): ?int {
$post = get_post( $content_id );
if ( ! $post ) {
return null;
}
// For tracks, get artist.
if ( 'fedistream_track' === $post->post_type ) {
$artist_ids = get_post_meta( $content_id, '_fedistream_artist_ids', true );
if ( ! empty( $artist_ids ) ) {
$artist_id = $artist_ids[0];
return (int) get_post_meta( $artist_id, '_fedistream_user_id', true ) ?: null;
}
$album_id = get_post_meta( $content_id, '_fedistream_album_id', true );
$artist_id = $album_id ? get_post_meta( $album_id, '_fedistream_album_artist', true ) : 0;
if ( $artist_id ) {
return (int) get_post_meta( $artist_id, '_fedistream_user_id', true ) ?: null;
}
}
// For albums, get artist.
if ( 'fedistream_album' === $post->post_type ) {
$artist_id = get_post_meta( $content_id, '_fedistream_album_artist', true );
if ( $artist_id ) {
return (int) get_post_meta( $artist_id, '_fedistream_user_id', true ) ?: null;
}
}
// For artists.
if ( 'fedistream_artist' === $post->post_type ) {
return (int) get_post_meta( $content_id, '_fedistream_user_id', true ) ?: null;
}
return null;
}
}