@@ -5,26 +5,25 @@ declare(strict_types=1);
namespace App\Services ;
use App\Models\Artwork ;
use App\Models\Collection as CollectionModel ;
use App\Models\Leaderboard ;
use App\Models\Tag ;
use App\Services\HomepageAnnouncementService ;
use App\Services\ArtworkSearchService ;
use App\Models\User ;
use App\Services\EarlyGrowth\EarlyGrowth ;
use App\Services\EarlyGrowth\GridFiller ;
use App\Services\Maturity\ArtworkMaturityService ;
use App\Services\Recommendations\RecommendationFeedResolver ;
use App\Services\UserPreferenceService ;
use App\Services\Worlds\WorldService ;
use App\Support\ArtworkFeaturedImagePath ;
use App\Support\AvatarUrl ;
use App\Models\Collection as CollectionModel ;
use cPad\Plugins\News\Models\NewsArticle ;
use Illuminate\Contracts\Cache\Repository as CacheRepository ;
use Illuminate\Database\QueryException ;
use Illuminate\Support\Collection ;
use Illuminate\Support\Str ;
use Illuminate\Support\Facades\Cache ;
use Illuminate\Support\Facades\DB ;
use Illuminate\Support\Facades\Log ;
use Illuminate\Database\QueryException ;
use cPad\Plugins\News\Models\NewsArticle ;
use App\Services\Maturity\ArtworkMaturityService ;
use Illuminate\Support\Str ;
/**
* HomepageService
@@ -36,7 +35,9 @@ use App\Services\Maturity\ArtworkMaturityService;
final class HomepageService
{
private const CACHE_TTL = 300 ; // 5 minutes
private const DEFAULT_ARTWORK_RAIL_LIMIT = 10 ;
private const ARTWORK_SERIALIZATION_RELATIONS = [
'user:id,name,username' ,
'user.profile:user_id,avatar_hash' ,
@@ -49,6 +50,7 @@ final class HomepageService
private readonly ArtworkService $artworks ,
private readonly ArtworkSearchService $search ,
private readonly ArtworkMaturityService $maturity ,
private readonly ArtworkFeaturedImagePath $featuredImages ,
private readonly UserPreferenceService $prefs ,
private readonly RecommendationFeedResolver $feedResolver ,
private readonly GridFiller $gridFiller ,
@@ -118,7 +120,7 @@ final class HomepageService
{
$configuredStore = ( string ) config ( 'homepage.cache_store' , 'homepage' );
if ( is_array ( config ( 'cache.stores.' . $configuredStore ))) {
if ( is_array ( config ( 'cache.stores.' . $configuredStore ))) {
return $configuredStore ;
}
@@ -174,7 +176,7 @@ final class HomepageService
* 6. suggested_creators – creators the user might want to follow
* 7. tags / creators / news – shared with guest homepage
*/
public function allForUser ( \App\Models\ User $user ) : array
public function allForUser ( User $user ) : array
{
$prefs = $this -> prefs -> build ( $user );
@@ -213,7 +215,7 @@ final class HomepageService
/**
* " For You " homepage preview backed by the personalized feed engine .
*/
public function getForYouPreview ( \App\Models\ User $user , int $limit = 12 ) : array
public function getForYouPreview ( User $user , int $limit = 12 ) : array
{
try {
$feed = $this -> feedResolver -> getFeed (( int ) $user -> id , max ( $limit * 3 , $limit ));
@@ -246,7 +248,7 @@ final class HomepageService
'category_slug' => ( string ) ( $item [ 'category_slug' ] ? ? '' ),
'content_type_name' => ( string ) ( $item [ 'content_type_name' ] ? ? '' ),
'content_type_slug' => ( string ) ( $item [ 'content_type_slug' ] ? ? '' ),
'url' => ( string ) ( $item [ 'url' ] ? ? ( '/art/' . (( int ) ( $item [ 'id' ] ? ? 0 )) . '/' . ( $item [ 'slug' ] ? ? '' ))),
'url' => ( string ) ( $item [ 'url' ] ? ? ( '/art/' . (( int ) ( $item [ 'id' ] ? ? 0 )) . '/' . ( $item [ 'slug' ] ? ? '' ))),
'width' => isset ( $item [ 'width' ]) ? ( int ) $item [ 'width' ] : null ,
'height' => isset ( $item [ 'height' ]) ? ( int ) $item [ 'height' ] : null ,
'published_at' => $item [ 'published_at' ] ? ? null ,
@@ -268,6 +270,7 @@ final class HomepageService
}) -> values () -> all ();
} catch ( \Throwable $e ) {
Log :: warning ( 'HomepageService::getForYouPreview failed' , [ 'error' => $e -> getMessage ()]);
return [];
}
}
@@ -336,7 +339,7 @@ final class HomepageService
);
}
public function getHomepageGroups ( ? \App\Models\ User $viewer = null ) : array
public function getHomepageGroups ( ? User $viewer = null ) : array
{
if ( ! $viewer ) {
return Cache :: remember ( 'homepage.groups' , self :: CACHE_TTL , fn () : array => $this -> buildHomepageGroups ());
@@ -345,7 +348,7 @@ final class HomepageService
return $this -> buildHomepageGroups ( $viewer );
}
private function buildHomepageGroups ( ? \App\Models\ User $viewer = null ) : array
private function buildHomepageGroups ( ? User $viewer = null ) : array
{
$featured = $this -> groupDiscovery -> surfaceCards ( $viewer , 'featured' , 4 );
$spotlight = $featured [ 0 ] ? ? null ;
@@ -369,7 +372,7 @@ final class HomepageService
*/
public function getHeroArtwork () : ? array
{
return Cache :: remember ( 'homepage.hero.' . $this -> viewerCacheSegment (), self :: CACHE_TTL , function () : ? array {
return Cache :: remember ( 'homepage.hero.' . $this -> viewerCacheSegment (), self :: CACHE_TTL , function () : ? array {
$artwork = $this -> artworks -> getFeaturedArtworkWinner ();
if ( ! $artwork instanceof Artwork ) {
@@ -387,7 +390,14 @@ final class HomepageService
$artwork -> loadMissing ( self :: ARTWORK_SERIALIZATION_RELATIONS );
}
return $artwork ? $this -> serializeArtwork ( $artwork , 'lg' ) : null ;
if ( ! $artwork instanceof Artwork ) {
return null ;
}
$payload = $this -> serializeArtwork ( $artwork , 'lg' );
$payload [ 'featured_image' ] = $this -> serializeFeaturedHeroImage ( $artwork );
return $payload ;
});
}
@@ -468,7 +478,7 @@ final class HomepageService
return Cache :: remember ( " homepage.rising. { $limit } . { $this -> viewerCacheSegment () } " , 120 , function () use ( $limit , $cutoff ) : array {
try {
$results = $this -> search -> searchWithThumbnailPreference ([
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"' ,
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"' ,
'sort' => [ 'heat_score:desc' , 'engagement_velocity:desc' , 'created_at:desc' ],
], $limit , true , 1 );
@@ -558,7 +568,7 @@ final class HomepageService
return Cache :: remember ( " homepage.trending. { $limit } . { $this -> viewerCacheSegment () } " , self :: CACHE_TTL , function () use ( $limit , $cutoff ) : array {
try {
$results = $this -> search -> searchWithThumbnailPreference ([
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"' ,
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"' ,
'sort' => [ 'ranking_score:desc' , 'engagement_velocity:desc' , 'views:desc' ],
], $limit , true , 1 );
@@ -614,7 +624,7 @@ final class HomepageService
public function getFreshUploads ( int $limit = 10 ) : array
{
// Include EGS mode in cache key so toggling EGS updates the section within TTL
$egsKey = EarlyGrowth :: gridFillerEnabled () ? 'egs-' . EarlyGrowth :: mode () : 'std' ;
$egsKey = EarlyGrowth :: gridFillerEnabled () ? 'egs-' . EarlyGrowth :: mode () : 'std' ;
$cacheKey = " homepage.fresh. { $limit } . { $egsKey } . { $this -> viewerCacheSegment () } " ;
return Cache :: remember ( $cacheKey , self :: CACHE_TTL , function () use ( $limit ) : array {
@@ -733,7 +743,7 @@ final class HomepageService
'weekly_uploads' => ( int ) $u -> weekly_uploads ,
'views' => ( int ) $u -> total_views ,
'awards' => ( int ) $u -> total_awards ,
'url' => $u -> username ? '/@' . $u -> username : '/profile/' . $u -> id ,
'url' => $u -> username ? '/@' . $u -> username : '/profile/' . $u -> id ,
'avatar' => AvatarUrl :: forUser (( int ) $u -> id , $u -> avatar_hash ? : null , 128 ),
'bg_thumb' => $bgThumb ,
];
@@ -789,7 +799,7 @@ final class HomepageService
'id' => $row -> id ,
'title' => $row -> title ,
'date' => $row -> created_at ,
'url' => '/forum/thread/' . $row -> id . '-' . ( $row -> thread_slug ? ? 'post' ),
'url' => '/forum/thread/' . $row -> id . '-' . ( $row -> thread_slug ? ? 'post' ),
]) -> values () -> all ();
} catch ( QueryException $e ) {
Log :: warning ( 'HomepageService::getNews DB error' , [
@@ -809,7 +819,7 @@ final class HomepageService
* Welcome - row counts : unread messages , unread notifications , new followers .
* Returns quickly from DB using simple COUNTs ; never throws .
*/
public function getUserData ( \App\Models\ User $user ) : array
public function getUserData ( User $user ) : array
{
try {
$unreadMessages = DB :: table ( 'conversations as c' )
@@ -848,7 +858,7 @@ final class HomepageService
* Suggested creators : active public uploaders NOT already followed by the user ,
* ranked by follower count . Optionally filtered to the user ' s top categories .
*/
public function getSuggestedCreators ( \App\Models\ User $user , array $prefs , int $limit = 8 ) : array
public function getSuggestedCreators ( User $user , array $prefs , int $limit = 8 ) : array
{
return Cache :: remember (
" homepage.suggested. { $user -> id } " ,
@@ -881,13 +891,14 @@ final class HomepageService
'id' => $u -> id ,
'name' => $u -> name ,
'username' => $u -> username ,
'url' => $u -> username ? '/@' . $u -> username : '/profile/' . $u -> id ,
'url' => $u -> username ? '/@' . $u -> username : '/profile/' . $u -> id ,
'avatar' => AvatarUrl :: forUser (( int ) $u -> id , $u -> avatar_hash ? : null , 64 ),
'followers_count' => ( int ) $u -> followers_count ,
'artworks_count' => ( int ) $u -> artworks_count ,
]) -> values () -> all ();
} catch ( \Throwable $e ) {
Log :: warning ( 'HomepageService::getSuggestedCreators failed' , [ 'error' => $e -> getMessage ()]);
return [];
}
}
@@ -897,7 +908,7 @@ final class HomepageService
/**
* Latest artworks from creators the user follows ( max 12 ) .
*/
public function getFollowingFeed ( \App\Models\ User $user , array $prefs ) : array
public function getFollowingFeed ( User $user , array $prefs ) : array
{
$followingIds = $prefs [ 'followed_creators' ] ? ? [];
@@ -950,6 +961,7 @@ final class HomepageService
-> all ();
} catch ( \Throwable $e ) {
Log :: warning ( 'HomepageService::getByTags failed' , [ 'error' => $e -> getMessage ()]);
return [];
}
}
@@ -980,6 +992,7 @@ final class HomepageService
-> all ();
} catch ( \Throwable $e ) {
Log :: warning ( 'HomepageService::getByCategories failed' , [ 'error' => $e -> getMessage ()]);
return [];
}
}
@@ -1146,10 +1159,10 @@ final class HomepageService
$primaryCategory = $artwork -> categories -> sortBy ( 'sort_order' ) -> first ();
$thumbSrcset = collect ([
$thumbSm ? $thumbSm . ' 320w' : null ,
$thumbMd ? $thumbMd . ' 640w' : null ,
$thumbLg ? $thumbLg . ' 1280w' : null ,
$thumbXl ? $thumbXl . ' 1920w' : null ,
$thumbSm ? $thumbSm . ' 320w' : null ,
$thumbMd ? $thumbMd . ' 640w' : null ,
$thumbLg ? $thumbLg . ' 1280w' : null ,
$thumbXl ? $thumbXl . ' 1920w' : null ,
]) -> filter () -> implode ( ', ' );
$publisher = $this -> mapArtworkPublisherPayload ( $artwork );
@@ -1182,7 +1195,7 @@ final class HomepageService
'category_slug' => $primaryCategory -> slug ? ? '' ,
'content_type_name' => $primaryCategory ? -> contentType ? -> name ? ? '' ,
'content_type_slug' => $primaryCategory ? -> contentType ? -> slug ? ? '' ,
'url' => '/art/' . $artwork -> id . '/' . ( $artwork -> slug ? ? '' ),
'url' => '/art/' . $artwork -> id . '/' . ( $artwork -> slug ? ? '' ),
'width' => $artwork -> width ,
'height' => $artwork -> height ,
'published_at' => $artwork -> published_at ? -> toIso8601String (),
@@ -1197,6 +1210,80 @@ final class HomepageService
], $artwork , request () -> user ());
}
/**
* @ return array < string , mixed >
*/
private function serializeFeaturedHeroImage ( Artwork $artwork ) : array
{
$variants = $this -> featuredImages -> variants ();
$variantUrls = [];
foreach ( array_keys ( $variants ) as $variant ) {
$variantUrls [ $variant ] = $artwork -> hasFeaturedThumbnail ( $variant )
? $this -> featuredImages -> url ( $artwork , $variant )
: null ;
}
$preloadSrcset = collect ( $variants )
-> map ( function ( array $config , string $variant ) use ( $variantUrls ) : ? string {
$url = $variantUrls [ $variant ] ? ? null ;
return $url ? $url . ' ' . ( int ) $config [ 'width' ] . 'w' : null ;
})
-> filter ()
-> implode ( ', ' );
$xsSources = collect ([ 'xs' , 'mobile_sm' ])
-> map ( function ( string $variant ) use ( $variantUrls , $variants ) : ? string {
$url = $variantUrls [ $variant ] ? ? null ;
return $url ? $url . ' ' . ( int ) ( $variants [ $variant ][ 'width' ] ? ? 0 ) . 'w' : null ;
})
-> filter ()
-> implode ( ', ' );
$mobileSources = collect ([ 'mobile_sm' , 'mobile' ])
-> map ( function ( string $variant ) use ( $variantUrls , $variants ) : ? string {
$url = $variantUrls [ $variant ] ? ? null ;
return $url ? $url . ' ' . ( int ) ( $variants [ $variant ][ 'width' ] ? ? 0 ) . 'w' : null ;
})
-> filter ()
-> implode ( ', ' );
$desktopSources = collect ([ 'desktop' , 'desktop_xl' ])
-> map ( function ( string $variant ) use ( $variantUrls , $variants ) : ? string {
$url = $variantUrls [ $variant ] ? ? null ;
return $url ? $url . ' ' . ( int ) ( $variants [ $variant ][ 'width' ] ? ? 0 ) . 'w' : null ;
})
-> filter ()
-> implode ( ', ' );
$pictureSources = array_values ( array_filter ([
$xsSources !== '' ? [ 'media' => '(max-width: 479px)' , 'srcset' => $xsSources , 'sizes' => '100vw' ] : null ,
$mobileSources !== '' ? [ 'media' => '(max-width: 767px)' , 'srcset' => $mobileSources , 'sizes' => '100vw' ] : null ,
! empty ( $variantUrls [ 'tablet' ]) ? [ 'media' => '(max-width: 1279px)' , 'srcset' => $variantUrls [ 'tablet' ] . ' ' . ( int ) ( $variants [ 'tablet' ][ 'width' ] ? ? 0 ) . 'w' , 'sizes' => '100vw' ] : null ,
$desktopSources !== '' ? [ 'media' => '(min-width: 1280px)' , 'srcset' => $desktopSources , 'sizes' => '100vw' ] : null ,
]));
return [
'alt' => $artwork -> featuredImageAltText (),
'variants' => $variantUrls ,
'sources' => $pictureSources ,
'img_src' => $artwork -> featuredThumbnailUrl ( 'desktop' ),
'img_srcset' => $preloadSrcset !== '' ? $preloadSrcset : ( $artwork -> thumb_srcset ? ? null ),
'img_sizes' => '100vw' ,
'preload_url' => $variantUrls [ 'desktop_xl' ]
? ? $variantUrls [ 'desktop' ]
? ? $artwork -> thumbUrl ( 'xl' )
? ? $artwork -> thumbUrl ( 'lg' )
? ? 'https://files.skinbase.org/default/missing_xl.webp' ,
'preload_srcset' => $preloadSrcset !== '' ? $preloadSrcset : ( $artwork -> thumb_srcset ? ? null ),
'preload_sizes' => '100vw' ,
];
}
/**
* @ return array < string , mixed >| null
*/
@@ -1233,8 +1320,8 @@ final class HomepageService
$payload [ 'metric_badge' ] = [
'label' => $surface === 'community_favorites'
? '30d medals: ' . $score
: 'All-time medals: ' . $score ,
? '30d medals: ' . $score
: 'All-time medals: ' . $score ,
'className' => $surface === 'community_favorites'
? 'bg-amber-500/14 text-amber-100 ring-amber-300/30'
: 'bg-cyan-500/14 text-cyan-100 ring-cyan-300/30' ,
@@ -1245,6 +1332,6 @@ final class HomepageService
private function viewerCacheSegment () : string
{
return 'visibility-' . $this -> maturity -> viewerPreferences ( request () -> user ())[ 'visibility' ];
return 'visibility-' . $this -> maturity -> viewerPreferences ( request () -> user ())[ 'visibility' ];
}
}