feat: Initial release v0.1.0

WP FediStream - Stream music over ActivityPub

Features:
- Custom post types: Artist, Album, Track, Playlist
- Custom taxonomies: Genre, Mood, License
- User roles: Artist, Label
- Admin dashboard with statistics
- Frontend templates and shortcodes
- Audio player with queue management
- ActivityPub integration with actor support
- WooCommerce product types for albums/tracks
- User library with favorites and history
- Notification system (in-app and email)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-28 23:23:05 +01:00
commit 4a5d7b9f4d
91 changed files with 22750 additions and 0 deletions

794
includes/User/Library.php Normal file
View File

@@ -0,0 +1,794 @@
<?php
/**
* User Library class.
*
* @package WP_FediStream
*/
namespace WP_FediStream\User;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles user library features (favorites, follows, history).
*/
class Library {
/**
* Constructor.
*/
public function __construct() {
add_action( 'wp_ajax_fedistream_toggle_favorite', array( $this, 'ajax_toggle_favorite' ) );
add_action( 'wp_ajax_fedistream_toggle_follow', array( $this, 'ajax_toggle_follow' ) );
add_action( 'wp_ajax_fedistream_get_library', array( $this, 'ajax_get_library' ) );
add_action( 'wp_ajax_fedistream_get_followed_artists', array( $this, 'ajax_get_followed_artists' ) );
add_action( 'wp_ajax_fedistream_get_history', array( $this, 'ajax_get_history' ) );
add_action( 'wp_ajax_fedistream_clear_history', array( $this, 'ajax_clear_history' ) );
// Add library buttons to content.
add_filter( 'fedistream_track_actions', array( $this, 'add_favorite_button' ), 10, 2 );
add_filter( 'fedistream_album_actions', array( $this, 'add_favorite_button' ), 10, 2 );
add_filter( 'fedistream_artist_actions', array( $this, 'add_follow_button' ), 10, 2 );
// Record play history.
add_action( 'fedistream_track_played', array( $this, 'record_play_history' ), 10, 2 );
}
/**
* Toggle favorite via AJAX.
*
* @return void
*/
public function ajax_toggle_favorite(): void {
check_ajax_referer( 'fedistream_library', 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'wp-fedistream' ) ) );
}
$content_type = isset( $_POST['content_type'] ) ? sanitize_text_field( wp_unslash( $_POST['content_type'] ) ) : '';
$content_id = isset( $_POST['content_id'] ) ? absint( $_POST['content_id'] ) : 0;
if ( ! in_array( $content_type, array( 'track', 'album', 'playlist' ), true ) || ! $content_id ) {
wp_send_json_error( array( 'message' => __( 'Invalid request.', 'wp-fedistream' ) ) );
}
$user_id = get_current_user_id();
$is_favorited = self::is_favorited( $user_id, $content_type, $content_id );
if ( $is_favorited ) {
$result = self::remove_favorite( $user_id, $content_type, $content_id );
$action = 'removed';
} else {
$result = self::add_favorite( $user_id, $content_type, $content_id );
$action = 'added';
}
if ( $result ) {
wp_send_json_success(
array(
'action' => $action,
'is_favorited' => ! $is_favorited,
'message' => 'added' === $action
? __( 'Added to your library.', 'wp-fedistream' )
: __( 'Removed from your library.', 'wp-fedistream' ),
)
);
} else {
wp_send_json_error( array( 'message' => __( 'Failed to update library.', 'wp-fedistream' ) ) );
}
}
/**
* Toggle follow via AJAX.
*
* @return void
*/
public function ajax_toggle_follow(): void {
check_ajax_referer( 'fedistream_library', 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'wp-fedistream' ) ) );
}
$artist_id = isset( $_POST['artist_id'] ) ? absint( $_POST['artist_id'] ) : 0;
if ( ! $artist_id ) {
wp_send_json_error( array( 'message' => __( 'Invalid artist.', 'wp-fedistream' ) ) );
}
$user_id = get_current_user_id();
$is_following = self::is_following( $user_id, $artist_id );
if ( $is_following ) {
$result = self::unfollow_artist( $user_id, $artist_id );
$action = 'unfollowed';
} else {
$result = self::follow_artist( $user_id, $artist_id );
$action = 'followed';
}
if ( $result ) {
wp_send_json_success(
array(
'action' => $action,
'is_following' => ! $is_following,
'message' => 'followed' === $action
? __( 'You are now following this artist.', 'wp-fedistream' )
: __( 'You unfollowed this artist.', 'wp-fedistream' ),
)
);
} else {
wp_send_json_error( array( 'message' => __( 'Failed to update follow status.', 'wp-fedistream' ) ) );
}
}
/**
* Get user library via AJAX.
*
* @return void
*/
public function ajax_get_library(): void {
check_ajax_referer( 'fedistream_library', 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'wp-fedistream' ) ) );
}
$user_id = get_current_user_id();
$type = isset( $_POST['type'] ) ? sanitize_text_field( wp_unslash( $_POST['type'] ) ) : 'all';
$page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1;
$per_page = 20;
$library = self::get_user_library( $user_id, $type, $page, $per_page );
wp_send_json_success( $library );
}
/**
* Get followed artists via AJAX.
*
* @return void
*/
public function ajax_get_followed_artists(): void {
check_ajax_referer( 'fedistream_library', 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'wp-fedistream' ) ) );
}
$user_id = get_current_user_id();
$page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1;
$per_page = 20;
$artists = self::get_followed_artists( $user_id, $page, $per_page );
wp_send_json_success( $artists );
}
/**
* Get listening history via AJAX.
*
* @return void
*/
public function ajax_get_history(): void {
check_ajax_referer( 'fedistream_library', 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'wp-fedistream' ) ) );
}
$user_id = get_current_user_id();
$page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1;
$per_page = 50;
$history = self::get_listening_history( $user_id, $page, $per_page );
wp_send_json_success( $history );
}
/**
* Clear listening history via AJAX.
*
* @return void
*/
public function ajax_clear_history(): void {
check_ajax_referer( 'fedistream_library', 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'wp-fedistream' ) ) );
}
$user_id = get_current_user_id();
$result = self::clear_listening_history( $user_id );
if ( $result ) {
wp_send_json_success( array( 'message' => __( 'History cleared.', 'wp-fedistream' ) ) );
} else {
wp_send_json_error( array( 'message' => __( 'Failed to clear history.', 'wp-fedistream' ) ) );
}
}
/**
* Add a favorite.
*
* @param int $user_id User ID.
* @param string $content_type Content type (track, album, playlist).
* @param int $content_id Content ID.
* @return bool
*/
public static function add_favorite( int $user_id, string $content_type, int $content_id ): bool {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_favorites';
$result = $wpdb->insert(
$table,
array(
'user_id' => $user_id,
'content_type' => $content_type,
'content_id' => $content_id,
'created_at' => current_time( 'mysql' ),
),
array( '%d', '%s', '%d', '%s' )
);
if ( $result ) {
do_action( 'fedistream_favorite_added', $user_id, $content_type, $content_id );
}
return (bool) $result;
}
/**
* Remove a favorite.
*
* @param int $user_id User ID.
* @param string $content_type Content type (track, album, playlist).
* @param int $content_id Content ID.
* @return bool
*/
public static function remove_favorite( int $user_id, string $content_type, int $content_id ): bool {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_favorites';
$result = $wpdb->delete(
$table,
array(
'user_id' => $user_id,
'content_type' => $content_type,
'content_id' => $content_id,
),
array( '%d', '%s', '%d' )
);
if ( $result ) {
do_action( 'fedistream_favorite_removed', $user_id, $content_type, $content_id );
}
return (bool) $result;
}
/**
* Check if content is favorited.
*
* @param int $user_id User ID.
* @param string $content_type Content type.
* @param int $content_id Content ID.
* @return bool
*/
public static function is_favorited( int $user_id, string $content_type, int $content_id ): bool {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_favorites';
$exists = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM {$table} WHERE user_id = %d AND content_type = %s AND content_id = %d",
$user_id,
$content_type,
$content_id
)
);
return (bool) $exists;
}
/**
* Follow an artist.
*
* @param int $user_id User ID.
* @param int $artist_id Artist post ID.
* @return bool
*/
public static function follow_artist( int $user_id, int $artist_id ): bool {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_user_follows';
$result = $wpdb->insert(
$table,
array(
'user_id' => $user_id,
'artist_id' => $artist_id,
'created_at' => current_time( 'mysql' ),
),
array( '%d', '%d', '%s' )
);
if ( $result ) {
do_action( 'fedistream_artist_followed', $user_id, $artist_id );
}
return (bool) $result;
}
/**
* Unfollow an artist.
*
* @param int $user_id User ID.
* @param int $artist_id Artist post ID.
* @return bool
*/
public static function unfollow_artist( int $user_id, int $artist_id ): bool {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_user_follows';
$result = $wpdb->delete(
$table,
array(
'user_id' => $user_id,
'artist_id' => $artist_id,
),
array( '%d', '%d' )
);
if ( $result ) {
do_action( 'fedistream_artist_unfollowed', $user_id, $artist_id );
}
return (bool) $result;
}
/**
* Check if user is following an artist.
*
* @param int $user_id User ID.
* @param int $artist_id Artist post ID.
* @return bool
*/
public static function is_following( int $user_id, int $artist_id ): bool {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_user_follows';
$exists = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM {$table} WHERE user_id = %d AND artist_id = %d",
$user_id,
$artist_id
)
);
return (bool) $exists;
}
/**
* Record play history.
*
* @param int $track_id Track post ID.
* @param int $user_id User ID (0 for anonymous).
* @return void
*/
public function record_play_history( int $track_id, int $user_id ): void {
if ( ! $user_id ) {
return;
}
global $wpdb;
$table = $wpdb->prefix . 'fedistream_listening_history';
// Check if recently played (within last 30 seconds) to avoid duplicates.
$recent = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM {$table} WHERE user_id = %d AND track_id = %d AND played_at > DATE_SUB(NOW(), INTERVAL 30 SECOND)",
$user_id,
$track_id
)
);
if ( $recent ) {
return;
}
$wpdb->insert(
$table,
array(
'user_id' => $user_id,
'track_id' => $track_id,
'played_at' => current_time( 'mysql' ),
),
array( '%d', '%d', '%s' )
);
}
/**
* Get user library.
*
* @param int $user_id User ID.
* @param string $type Content type filter (all, tracks, albums, playlists).
* @param int $page Page number.
* @param int $per_page Items per page.
* @return array
*/
public static function get_user_library( int $user_id, string $type = 'all', int $page = 1, int $per_page = 20 ): array {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_favorites';
$offset = ( $page - 1 ) * $per_page;
$where = 'WHERE user_id = %d';
$params = array( $user_id );
if ( 'all' !== $type && in_array( $type, array( 'track', 'album', 'playlist' ), true ) ) {
$where .= ' AND content_type = %s';
$params[] = $type;
}
$total = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} {$where}",
...$params
)
);
$params[] = $per_page;
$params[] = $offset;
$favorites = $wpdb->get_results(
$wpdb->prepare(
"SELECT content_type, content_id, created_at FROM {$table} {$where} ORDER BY created_at DESC LIMIT %d OFFSET %d",
...$params
)
);
$items = array();
foreach ( $favorites as $favorite ) {
$post = get_post( $favorite->content_id );
if ( ! $post || 'publish' !== $post->post_status ) {
continue;
}
$item = array(
'id' => $post->ID,
'type' => $favorite->content_type,
'title' => $post->post_title,
'permalink' => get_permalink( $post ),
'added_at' => $favorite->created_at,
'thumbnail' => get_the_post_thumbnail_url( $post, 'medium' ),
);
if ( 'track' === $favorite->content_type ) {
$item['duration'] = get_post_meta( $post->ID, '_fedistream_duration', true );
$item['artist'] = self::get_track_artist_name( $post->ID );
} elseif ( 'album' === $favorite->content_type ) {
$item['artist'] = self::get_album_artist_name( $post->ID );
$item['track_count'] = get_post_meta( $post->ID, '_fedistream_total_tracks', true );
}
$items[] = $item;
}
return array(
'items' => $items,
'total' => (int) $total,
'page' => $page,
'per_page' => $per_page,
'total_pages' => (int) ceil( $total / $per_page ),
);
}
/**
* Get followed artists.
*
* @param int $user_id User ID.
* @param int $page Page number.
* @param int $per_page Items per page.
* @return array
*/
public static function get_followed_artists( int $user_id, int $page = 1, int $per_page = 20 ): array {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_user_follows';
$offset = ( $page - 1 ) * $per_page;
$total = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE user_id = %d",
$user_id
)
);
$follows = $wpdb->get_results(
$wpdb->prepare(
"SELECT artist_id, created_at FROM {$table} WHERE user_id = %d ORDER BY created_at DESC LIMIT %d OFFSET %d",
$user_id,
$per_page,
$offset
)
);
$artists = array();
foreach ( $follows as $follow ) {
$post = get_post( $follow->artist_id );
if ( ! $post || 'publish' !== $post->post_status ) {
continue;
}
$artists[] = array(
'id' => $post->ID,
'name' => $post->post_title,
'permalink' => get_permalink( $post ),
'followed_at' => $follow->created_at,
'thumbnail' => get_the_post_thumbnail_url( $post, 'thumbnail' ),
'type' => get_post_meta( $post->ID, '_fedistream_artist_type', true ) ?: 'solo',
);
}
return array(
'artists' => $artists,
'total' => (int) $total,
'page' => $page,
'per_page' => $per_page,
'total_pages' => (int) ceil( $total / $per_page ),
);
}
/**
* Get listening history.
*
* @param int $user_id User ID.
* @param int $page Page number.
* @param int $per_page Items per page.
* @return array
*/
public static function get_listening_history( int $user_id, int $page = 1, int $per_page = 50 ): array {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_listening_history';
$offset = ( $page - 1 ) * $per_page;
$total = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE user_id = %d",
$user_id
)
);
$history = $wpdb->get_results(
$wpdb->prepare(
"SELECT track_id, played_at FROM {$table} WHERE user_id = %d ORDER BY played_at DESC LIMIT %d OFFSET %d",
$user_id,
$per_page,
$offset
)
);
$tracks = array();
foreach ( $history as $item ) {
$post = get_post( $item->track_id );
if ( ! $post || 'publish' !== $post->post_status ) {
continue;
}
$tracks[] = array(
'id' => $post->ID,
'title' => $post->post_title,
'permalink' => get_permalink( $post ),
'played_at' => $item->played_at,
'thumbnail' => get_the_post_thumbnail_url( $post, 'thumbnail' ),
'duration' => get_post_meta( $post->ID, '_fedistream_duration', true ),
'artist' => self::get_track_artist_name( $post->ID ),
);
}
return array(
'tracks' => $tracks,
'total' => (int) $total,
'page' => $page,
'per_page' => $per_page,
'total_pages' => (int) ceil( $total / $per_page ),
);
}
/**
* Clear listening history.
*
* @param int $user_id User ID.
* @return bool
*/
public static function clear_listening_history( int $user_id ): bool {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_listening_history';
$result = $wpdb->delete( $table, array( 'user_id' => $user_id ), array( '%d' ) );
return false !== $result;
}
/**
* Get track artist name.
*
* @param int $track_id Track post ID.
* @return string
*/
private static function get_track_artist_name( int $track_id ): string {
$artist_ids = get_post_meta( $track_id, '_fedistream_artist_ids', true );
if ( is_array( $artist_ids ) && ! empty( $artist_ids ) ) {
$names = array();
foreach ( $artist_ids as $artist_id ) {
$artist = get_post( $artist_id );
if ( $artist ) {
$names[] = $artist->post_title;
}
}
return implode( ', ', $names );
}
$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;
if ( $artist_id ) {
$artist = get_post( $artist_id );
return $artist ? $artist->post_title : '';
}
return '';
}
/**
* Get album artist name.
*
* @param int $album_id Album post ID.
* @return string
*/
private static function get_album_artist_name( int $album_id ): string {
$artist_id = get_post_meta( $album_id, '_fedistream_album_artist', true );
if ( $artist_id ) {
$artist = get_post( $artist_id );
return $artist ? $artist->post_title : '';
}
return '';
}
/**
* Add favorite button to track/album actions.
*
* @param string $actions HTML actions.
* @param int $post_id Post ID.
* @return string
*/
public function add_favorite_button( string $actions, int $post_id ): string {
if ( ! is_user_logged_in() ) {
return $actions;
}
$post = get_post( $post_id );
if ( ! $post ) {
return $actions;
}
$content_type = str_replace( 'fedistream_', '', $post->post_type );
$user_id = get_current_user_id();
$is_favorited = self::is_favorited( $user_id, $content_type, $post_id );
$button = sprintf(
'<button class="fedistream-favorite-btn%s" data-content-type="%s" data-content-id="%d" title="%s">
<span class="dashicons dashicons-heart"></span>
</button>',
$is_favorited ? ' is-favorited' : '',
esc_attr( $content_type ),
$post_id,
$is_favorited ? esc_attr__( 'Remove from library', 'wp-fedistream' ) : esc_attr__( 'Add to library', 'wp-fedistream' )
);
return $actions . $button;
}
/**
* Add follow button to artist actions.
*
* @param string $actions HTML actions.
* @param int $artist_id Artist post ID.
* @return string
*/
public function add_follow_button( string $actions, int $artist_id ): string {
if ( ! is_user_logged_in() ) {
return $actions;
}
$user_id = get_current_user_id();
$is_following = self::is_following( $user_id, $artist_id );
$button = sprintf(
'<button class="fedistream-follow-btn%s" data-artist-id="%d">
<span class="dashicons dashicons-%s"></span>
<span class="button-text">%s</span>
</button>',
$is_following ? ' is-following' : '',
$artist_id,
$is_following ? 'yes' : 'plus',
$is_following ? esc_html__( 'Following', 'wp-fedistream' ) : esc_html__( 'Follow', 'wp-fedistream' )
);
return $actions . $button;
}
/**
* Get user's favorite count.
*
* @param int $user_id User ID.
* @param string $content_type Optional content type filter.
* @return int
*/
public static function get_favorite_count( int $user_id, string $content_type = '' ): int {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_favorites';
if ( $content_type ) {
$count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE user_id = %d AND content_type = %s",
$user_id,
$content_type
)
);
} else {
$count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE user_id = %d",
$user_id
)
);
}
return (int) $count;
}
/**
* Get user's followed artist count.
*
* @param int $user_id User ID.
* @return int
*/
public static function get_following_count( int $user_id ): int {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_user_follows';
$count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE user_id = %d",
$user_id
)
);
return (int) $count;
}
}

View File

@@ -0,0 +1,324 @@
<?php
/**
* User Library Page class.
*
* @package WP_FediStream
*/
namespace WP_FediStream\User;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles the My Library page display.
*/
class LibraryPage {
/**
* Constructor.
*/
public function __construct() {
add_shortcode( 'fedistream_library', array( $this, 'render_library_shortcode' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
}
/**
* Enqueue library scripts and styles.
*
* @return void
*/
public function enqueue_scripts(): void {
if ( ! is_user_logged_in() ) {
return;
}
wp_enqueue_script(
'fedistream-library',
WP_FEDISTREAM_URL . 'assets/js/library.js',
array( 'jquery' ),
WP_FEDISTREAM_VERSION,
true
);
wp_localize_script(
'fedistream-library',
'fedistreamLibrary',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'fedistream_library' ),
'i18n' => array(
'loading' => __( 'Loading...', 'wp-fedistream' ),
'noFavorites' => __( 'No favorites yet.', 'wp-fedistream' ),
'noArtists' => __( 'Not following any artists yet.', 'wp-fedistream' ),
'noHistory' => __( 'No listening history.', 'wp-fedistream' ),
'confirmClear' => __( 'Are you sure you want to clear your listening history?', 'wp-fedistream' ),
'historyCleared' => __( 'History cleared.', 'wp-fedistream' ),
'error' => __( 'An error occurred. Please try again.', 'wp-fedistream' ),
),
)
);
}
/**
* Render the library shortcode.
*
* @param array $atts Shortcode attributes.
* @return string
*/
public function render_library_shortcode( array $atts = array() ): string {
$atts = shortcode_atts(
array(
'tab' => 'favorites',
),
$atts,
'fedistream_library'
);
if ( ! is_user_logged_in() ) {
return $this->render_login_required();
}
$user_id = get_current_user_id();
// Get counts for tabs.
$favorite_count = Library::get_favorite_count( $user_id );
$following_count = Library::get_following_count( $user_id );
ob_start();
?>
<div class="fedistream-library" data-initial-tab="<?php echo esc_attr( $atts['tab'] ); ?>">
<nav class="fedistream-library-nav">
<button class="tab-btn active" data-tab="favorites">
<?php esc_html_e( 'Favorites', 'wp-fedistream' ); ?>
<span class="count"><?php echo esc_html( $favorite_count ); ?></span>
</button>
<button class="tab-btn" data-tab="artists">
<?php esc_html_e( 'Artists', 'wp-fedistream' ); ?>
<span class="count"><?php echo esc_html( $following_count ); ?></span>
</button>
<button class="tab-btn" data-tab="history">
<?php esc_html_e( 'History', 'wp-fedistream' ); ?>
</button>
</nav>
<div class="fedistream-library-filters" data-tab="favorites">
<select class="filter-type">
<option value="all"><?php esc_html_e( 'All', 'wp-fedistream' ); ?></option>
<option value="track"><?php esc_html_e( 'Tracks', 'wp-fedistream' ); ?></option>
<option value="album"><?php esc_html_e( 'Albums', 'wp-fedistream' ); ?></option>
<option value="playlist"><?php esc_html_e( 'Playlists', 'wp-fedistream' ); ?></option>
</select>
</div>
<div class="fedistream-library-content">
<div class="tab-content active" id="tab-favorites">
<div class="library-grid favorites-grid">
<!-- Loaded via AJAX -->
</div>
<div class="library-pagination" data-tab="favorites"></div>
</div>
<div class="tab-content" id="tab-artists">
<div class="library-grid artists-grid">
<!-- Loaded via AJAX -->
</div>
<div class="library-pagination" data-tab="artists"></div>
</div>
<div class="tab-content" id="tab-history">
<div class="history-actions">
<button class="btn-clear-history">
<?php esc_html_e( 'Clear History', 'wp-fedistream' ); ?>
</button>
</div>
<div class="library-list history-list">
<!-- Loaded via AJAX -->
</div>
<div class="library-pagination" data-tab="history"></div>
</div>
</div>
<div class="fedistream-library-loading">
<span class="spinner"></span>
</div>
</div>
<?php
return ob_get_clean();
}
/**
* Render login required message.
*
* @return string
*/
private function render_login_required(): string {
ob_start();
?>
<div class="fedistream-login-required">
<p><?php esc_html_e( 'Please log in to view your library.', 'wp-fedistream' ); ?></p>
<a href="<?php echo esc_url( wp_login_url( get_permalink() ) ); ?>" class="btn btn-primary">
<?php esc_html_e( 'Log In', 'wp-fedistream' ); ?>
</a>
</div>
<?php
return ob_get_clean();
}
/**
* Render a favorite item.
*
* @param array $item Item data.
* @return string
*/
public static function render_favorite_item( array $item ): string {
ob_start();
?>
<div class="library-item favorite-item" data-type="<?php echo esc_attr( $item['type'] ); ?>" data-id="<?php echo esc_attr( $item['id'] ); ?>">
<div class="item-thumbnail">
<?php if ( ! empty( $item['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $item['thumbnail'] ); ?>" alt="<?php echo esc_attr( $item['title'] ); ?>">
<?php else : ?>
<div class="placeholder-thumbnail">
<span class="dashicons dashicons-<?php echo 'track' === $item['type'] ? 'format-audio' : ( 'album' === $item['type'] ? 'album' : 'playlist-audio' ); ?>"></span>
</div>
<?php endif; ?>
<?php if ( 'track' === $item['type'] ) : ?>
<button class="play-btn" data-track-id="<?php echo esc_attr( $item['id'] ); ?>">
<span class="dashicons dashicons-controls-play"></span>
</button>
<?php endif; ?>
</div>
<div class="item-info">
<h4 class="item-title">
<a href="<?php echo esc_url( $item['permalink'] ); ?>"><?php echo esc_html( $item['title'] ); ?></a>
</h4>
<?php if ( ! empty( $item['artist'] ) ) : ?>
<p class="item-artist"><?php echo esc_html( $item['artist'] ); ?></p>
<?php endif; ?>
<?php if ( 'track' === $item['type'] && ! empty( $item['duration'] ) ) : ?>
<p class="item-duration"><?php echo esc_html( self::format_duration( $item['duration'] ) ); ?></p>
<?php elseif ( 'album' === $item['type'] && ! empty( $item['track_count'] ) ) : ?>
<p class="item-tracks">
<?php
printf(
/* translators: %d: number of tracks */
esc_html( _n( '%d track', '%d tracks', (int) $item['track_count'], 'wp-fedistream' ) ),
(int) $item['track_count']
);
?>
</p>
<?php endif; ?>
</div>
<div class="item-actions">
<button class="unfavorite-btn" data-content-type="<?php echo esc_attr( $item['type'] ); ?>" data-content-id="<?php echo esc_attr( $item['id'] ); ?>" title="<?php esc_attr_e( 'Remove from library', 'wp-fedistream' ); ?>">
<span class="dashicons dashicons-heart"></span>
</button>
</div>
</div>
<?php
return ob_get_clean();
}
/**
* Render an artist item.
*
* @param array $artist Artist data.
* @return string
*/
public static function render_artist_item( array $artist ): string {
ob_start();
?>
<div class="library-item artist-item" data-id="<?php echo esc_attr( $artist['id'] ); ?>">
<div class="item-thumbnail artist-avatar">
<?php if ( ! empty( $artist['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $artist['thumbnail'] ); ?>" alt="<?php echo esc_attr( $artist['name'] ); ?>">
<?php else : ?>
<div class="placeholder-thumbnail">
<span class="dashicons dashicons-<?php echo 'band' === $artist['type'] ? 'groups' : 'admin-users'; ?>"></span>
</div>
<?php endif; ?>
</div>
<div class="item-info">
<h4 class="item-title">
<a href="<?php echo esc_url( $artist['permalink'] ); ?>"><?php echo esc_html( $artist['name'] ); ?></a>
</h4>
<p class="item-type">
<?php echo 'band' === $artist['type'] ? esc_html__( 'Band', 'wp-fedistream' ) : esc_html__( 'Artist', 'wp-fedistream' ); ?>
</p>
</div>
<div class="item-actions">
<button class="unfollow-btn" data-artist-id="<?php echo esc_attr( $artist['id'] ); ?>" title="<?php esc_attr_e( 'Unfollow', 'wp-fedistream' ); ?>">
<span class="dashicons dashicons-minus"></span>
<span class="button-text"><?php esc_html_e( 'Unfollow', 'wp-fedistream' ); ?></span>
</button>
</div>
</div>
<?php
return ob_get_clean();
}
/**
* Render a history item.
*
* @param array $track Track data.
* @return string
*/
public static function render_history_item( array $track ): string {
ob_start();
?>
<div class="library-item history-item" data-id="<?php echo esc_attr( $track['id'] ); ?>">
<div class="item-thumbnail">
<?php if ( ! empty( $track['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $track['thumbnail'] ); ?>" alt="<?php echo esc_attr( $track['title'] ); ?>">
<?php else : ?>
<div class="placeholder-thumbnail">
<span class="dashicons dashicons-format-audio"></span>
</div>
<?php endif; ?>
<button class="play-btn" data-track-id="<?php echo esc_attr( $track['id'] ); ?>">
<span class="dashicons dashicons-controls-play"></span>
</button>
</div>
<div class="item-info">
<h4 class="item-title">
<a href="<?php echo esc_url( $track['permalink'] ); ?>"><?php echo esc_html( $track['title'] ); ?></a>
</h4>
<?php if ( ! empty( $track['artist'] ) ) : ?>
<p class="item-artist"><?php echo esc_html( $track['artist'] ); ?></p>
<?php endif; ?>
<p class="item-played">
<?php
printf(
/* translators: %s: relative time */
esc_html__( 'Played %s', 'wp-fedistream' ),
esc_html( human_time_diff( strtotime( $track['played_at'] ), current_time( 'timestamp' ) ) . ' ' . __( 'ago', 'wp-fedistream' ) )
);
?>
</p>
</div>
<div class="item-meta">
<?php if ( ! empty( $track['duration'] ) ) : ?>
<span class="item-duration"><?php echo esc_html( self::format_duration( $track['duration'] ) ); ?></span>
<?php endif; ?>
</div>
</div>
<?php
return ob_get_clean();
}
/**
* Format duration in seconds to MM:SS.
*
* @param int $seconds Duration in seconds.
* @return string
*/
private static function format_duration( int $seconds ): string {
$minutes = floor( $seconds / 60 );
$secs = $seconds % 60;
return sprintf( '%d:%02d', $minutes, $secs );
}
}

View File

@@ -0,0 +1,828 @@
<?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;
}
}