Defer profile favourites until needed
This commit is contained in:
@@ -10,6 +10,7 @@ use App\Models\User;
|
|||||||
use Carbon\CarbonInterface;
|
use Carbon\CarbonInterface;
|
||||||
use App\Services\ThumbnailPresenter;
|
use App\Services\ThumbnailPresenter;
|
||||||
use App\Support\UsernamePolicy;
|
use App\Support\UsernamePolicy;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -106,7 +107,7 @@ final class ProfileApiController extends Controller
|
|||||||
return response()->json(['error' => 'User not found'], 404);
|
return response()->json(['error' => 'User not found'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$perPage = 24;
|
$perPage = 12;
|
||||||
$offset = max(0, (int) base64_decode((string) $request->input('cursor', ''), true));
|
$offset = max(0, (int) base64_decode((string) $request->input('cursor', ''), true));
|
||||||
|
|
||||||
$favIds = DB::table($favouriteTable . ' as af')
|
$favIds = DB::table($favouriteTable . ' as af')
|
||||||
@@ -116,6 +117,10 @@ final class ProfileApiController extends Controller
|
|||||||
->where('a.is_public', true)
|
->where('a.is_public', true)
|
||||||
->where('a.is_approved', true)
|
->where('a.is_approved', true)
|
||||||
->whereNotNull('a.published_at')
|
->whereNotNull('a.published_at')
|
||||||
|
->when(app(ArtworkMaturityService::class)->viewerPreferences($request->user())['visibility'] === ArtworkMaturityService::VIEW_HIDE, function ($query): void {
|
||||||
|
$query->whereRaw('COALESCE(a.is_mature, 0) = 0')
|
||||||
|
->whereRaw("COALESCE(a.maturity_status, 'clear') != ?", [ArtworkMaturityService::STATUS_SUSPECTED]);
|
||||||
|
})
|
||||||
->orderByDesc('af.created_at')
|
->orderByDesc('af.created_at')
|
||||||
->orderByDesc('af.artwork_id')
|
->orderByDesc('af.artwork_id')
|
||||||
->offset($offset)
|
->offset($offset)
|
||||||
@@ -246,6 +251,8 @@ final class ProfileApiController extends Controller
|
|||||||
return [
|
return [
|
||||||
'id' => $art->id,
|
'id' => $art->id,
|
||||||
'name' => $art->title,
|
'name' => $art->title,
|
||||||
|
'picture' => $art->file_name,
|
||||||
|
'datum' => $this->formatIsoDate($art->published_at),
|
||||||
'thumb' => $present['url'],
|
'thumb' => $present['url'],
|
||||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||||
'width' => $art->width,
|
'width' => $art->width,
|
||||||
|
|||||||
@@ -1011,14 +1011,17 @@ class ProfileController extends Controller
|
|||||||
? $this->loadFeaturedArtworks($user, $viewer)
|
? $this->loadFeaturedArtworks($user, $viewer)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// ── Favourites ───────────────────────────────────────────────────────
|
// ── Favourites (only needed for the direct Favourites tab) ───────────
|
||||||
$favouriteLimit = 12;
|
$favourites = null;
|
||||||
$favouriteTable = $this->resolveFavouriteTable();
|
if ($initialTab === 'favourites') {
|
||||||
$favourites = [
|
$favouriteLimit = 12;
|
||||||
'data' => [],
|
$favouriteTable = $this->resolveFavouriteTable();
|
||||||
'next_cursor' => null,
|
$favourites = [
|
||||||
];
|
'data' => [],
|
||||||
if ($favouriteTable !== null) {
|
'next_cursor' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($favourites !== null && $favouriteTable !== null) {
|
||||||
$favIds = DB::table($favouriteTable . ' as af')
|
$favIds = DB::table($favouriteTable . ' as af')
|
||||||
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
|
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
|
||||||
->where('af.user_id', $user->id)
|
->where('af.user_id', $user->id)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import TabWorlds from '../../components/profile/tabs/TabWorlds'
|
|||||||
import TabFollowers from '../../components/profile/tabs/TabFollowers'
|
import TabFollowers from '../../components/profile/tabs/TabFollowers'
|
||||||
import GroupProfileSummary from '../../components/groups/GroupProfileSummary'
|
import GroupProfileSummary from '../../components/groups/GroupProfileSummary'
|
||||||
import useLazyProfileFeaturedArtworks from '../../lib/useLazyProfileFeaturedArtworks'
|
import useLazyProfileFeaturedArtworks from '../../lib/useLazyProfileFeaturedArtworks'
|
||||||
|
import useLazyProfileFavourites from '../../lib/useLazyProfileFavourites'
|
||||||
|
|
||||||
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity', 'followers']
|
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity', 'followers']
|
||||||
|
|
||||||
@@ -92,6 +93,11 @@ export default function ProfileShow() {
|
|||||||
initialFeaturedArtworks: featuredArtworks,
|
initialFeaturedArtworks: featuredArtworks,
|
||||||
enabled: activeTab === 'artworks',
|
enabled: activeTab === 'artworks',
|
||||||
})
|
})
|
||||||
|
const favouritesState = useLazyProfileFavourites({
|
||||||
|
username: user?.username || user?.name || '',
|
||||||
|
initialFavourites: favourites,
|
||||||
|
enabled: activeTab === 'favourites',
|
||||||
|
})
|
||||||
|
|
||||||
const handleTabChange = useCallback((tab) => {
|
const handleTabChange = useCallback((tab) => {
|
||||||
if (!VALID_TABS.includes(tab)) return
|
if (!VALID_TABS.includes(tab)) return
|
||||||
@@ -133,10 +139,8 @@ export default function ProfileShow() {
|
|||||||
? artworks
|
? artworks
|
||||||
: (artworks?.data ?? [])
|
: (artworks?.data ?? [])
|
||||||
const artworkNextCursor = artworks?.next_cursor ?? null
|
const artworkNextCursor = artworks?.next_cursor ?? null
|
||||||
const favouriteList = Array.isArray(favourites)
|
const favouriteList = favouritesState.items
|
||||||
? favourites
|
const favouriteNextCursor = favouritesState.nextCursor
|
||||||
: (favourites?.data ?? [])
|
|
||||||
const favouriteNextCursor = favourites?.next_cursor ?? null
|
|
||||||
|
|
||||||
// Normalise social links (may be object keyed by platform, or array)
|
// Normalise social links (may be object keyed by platform, or array)
|
||||||
const socialLinksObj = Array.isArray(socialLinks)
|
const socialLinksObj = Array.isArray(socialLinks)
|
||||||
@@ -281,6 +285,9 @@ export default function ProfileShow() {
|
|||||||
favourites={{ data: favouriteList, next_cursor: favouriteNextCursor }}
|
favourites={{ data: favouriteList, next_cursor: favouriteNextCursor }}
|
||||||
isOwner={isOwner}
|
isOwner={isOwner}
|
||||||
username={user.username || user.name}
|
username={user.username || user.name}
|
||||||
|
loading={favouritesState.loading}
|
||||||
|
error={favouritesState.error}
|
||||||
|
onRetry={favouritesState.retry}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'activity' && (
|
{activeTab === 'activity' && (
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function FavSkeleton() {
|
|||||||
* TabFavourites
|
* TabFavourites
|
||||||
* Shows artworks the user has favourited.
|
* Shows artworks the user has favourited.
|
||||||
*/
|
*/
|
||||||
export default function TabFavourites({ favourites, isOwner, username }) {
|
export default function TabFavourites({ favourites, isOwner, username, loading = false, error = false, onRetry }) {
|
||||||
const initialItems = Array.isArray(favourites)
|
const initialItems = Array.isArray(favourites)
|
||||||
? favourites
|
? favourites
|
||||||
: (favourites?.data ?? [])
|
: (favourites?.data ?? [])
|
||||||
@@ -80,7 +80,20 @@ export default function TabFavourites({ favourites, isOwner, username }) {
|
|||||||
{isOwner ? 'Your Favourites' : 'Favourites'}
|
{isOwner ? 'Your Favourites' : 'Favourites'}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{items.length === 0 ? (
|
{loading && items.length === 0 && (
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => <FavSkeleton key={`initial-sk-${i}`} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && items.length === 0 && (
|
||||||
|
<div className="py-12 text-center text-slate-400">
|
||||||
|
<p>Favourites could not be loaded.</p>
|
||||||
|
{onRetry ? <button type="button" onClick={onRetry} className="mt-4 rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200">Retry</button> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && items.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||||
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mb-5 text-slate-500">
|
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mb-5 text-slate-500">
|
||||||
<i className="fa-solid fa-heart text-3xl" />
|
<i className="fa-solid fa-heart text-3xl" />
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export default function useLazyProfileFavourites({ username, initialFavourites, enabled }) {
|
||||||
|
const hasInitialValue = initialFavourites !== null && initialFavourites !== undefined
|
||||||
|
const initialItems = Array.isArray(initialFavourites)
|
||||||
|
? initialFavourites
|
||||||
|
: (initialFavourites?.data ?? [])
|
||||||
|
const [items, setItems] = useState(() => initialItems)
|
||||||
|
const [nextCursor, setNextCursor] = useState(() => initialFavourites?.next_cursor ?? null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const requestKeyRef = useRef(username)
|
||||||
|
const requestedRef = useRef(hasInitialValue)
|
||||||
|
const abortRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (requestKeyRef.current === username) return undefined
|
||||||
|
|
||||||
|
abortRef.current?.abort()
|
||||||
|
abortRef.current = null
|
||||||
|
requestKeyRef.current = username
|
||||||
|
requestedRef.current = false
|
||||||
|
setItems([])
|
||||||
|
setNextCursor(null)
|
||||||
|
setLoading(false)
|
||||||
|
setError(false)
|
||||||
|
return undefined
|
||||||
|
}, [username])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (requestKeyRef.current !== username || !hasInitialValue) return undefined
|
||||||
|
|
||||||
|
abortRef.current?.abort()
|
||||||
|
abortRef.current = null
|
||||||
|
requestedRef.current = true
|
||||||
|
setItems(initialItems)
|
||||||
|
setNextCursor(initialFavourites?.next_cursor ?? null)
|
||||||
|
setLoading(false)
|
||||||
|
setError(false)
|
||||||
|
return undefined
|
||||||
|
}, [hasInitialValue, initialFavourites, initialItems, username])
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!enabled || hasInitialValue || requestedRef.current || !username) return
|
||||||
|
|
||||||
|
requestedRef.current = true
|
||||||
|
const controller = new AbortController()
|
||||||
|
const requestKey = requestKeyRef.current
|
||||||
|
abortRef.current = controller
|
||||||
|
setLoading(true)
|
||||||
|
setError(false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/profile/${encodeURIComponent(username)}/favourites`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Favourites could not be loaded.')
|
||||||
|
const payload = await response.json()
|
||||||
|
if (requestKey !== requestKeyRef.current || controller.signal.aborted) return
|
||||||
|
setItems(Array.isArray(payload?.data) ? payload.data : [])
|
||||||
|
setNextCursor(payload?.next_cursor ?? null)
|
||||||
|
} catch (caught) {
|
||||||
|
if (caught?.name === 'AbortError' || requestKey !== requestKeyRef.current) return
|
||||||
|
requestedRef.current = false
|
||||||
|
setError(true)
|
||||||
|
} finally {
|
||||||
|
if (requestKey === requestKeyRef.current) {
|
||||||
|
abortRef.current = null
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [enabled, hasInitialValue, username])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) load()
|
||||||
|
}, [enabled, load])
|
||||||
|
|
||||||
|
useEffect(() => () => abortRef.current?.abort(), [])
|
||||||
|
|
||||||
|
return { items, nextCursor, loading, error, retry: load }
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, waitFor } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import useLazyProfileFavourites from './useLazyProfileFavourites'
|
||||||
|
|
||||||
|
function Harness({ username = 'alice', initial = null, enabled = false }) {
|
||||||
|
const state = useLazyProfileFavourites({
|
||||||
|
username,
|
||||||
|
initialFavourites: initial,
|
||||||
|
enabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
return <output data-testid="state">{JSON.stringify(state)}</output>
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useLazyProfileFavourites', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not request before activation and requests page one once on activation', async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ data: [{ id: 1 }], next_cursor: 'c1' }),
|
||||||
|
})
|
||||||
|
const view = render(<Harness />)
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled()
|
||||||
|
view.rerender(<Harness enabled />)
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/profile/alice/favourites', expect.objectContaining({
|
||||||
|
credentials: 'same-origin',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses SSR data without a page-one request and survives rerenders', async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||||
|
const view = render(<Harness enabled initial={{ data: [{ id: 9 }], next_cursor: 'c2' }} />)
|
||||||
|
view.rerender(<Harness enabled initial={{ data: [{ id: 9 }], next_cursor: 'c2' }} />)
|
||||||
|
|
||||||
|
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).items).toEqual([{ id: 9 }]))
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not refetch after switching away and back', async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ data: [{ id: 3 }], next_cursor: null }),
|
||||||
|
})
|
||||||
|
const view = render(<Harness enabled />)
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
|
||||||
|
|
||||||
|
view.rerender(<Harness enabled={false} />)
|
||||||
|
view.rerender(<Harness enabled />)
|
||||||
|
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).items).toEqual([{ id: 3 }]))
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets profile state and ignores a stale response', async () => {
|
||||||
|
let resolveRequest
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(() => new Promise((resolve) => {
|
||||||
|
resolveRequest = resolve
|
||||||
|
}))
|
||||||
|
const view = render(<Harness username="alice" enabled />)
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
|
||||||
|
|
||||||
|
view.rerender(<Harness username="bob" enabled />)
|
||||||
|
expect(JSON.parse(view.getByTestId('state').textContent).items).toEqual([])
|
||||||
|
resolveRequest({ ok: true, json: async () => ({ data: [{ id: 1 }], next_cursor: null }) })
|
||||||
|
|
||||||
|
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).items).toEqual([]))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Inertia\Testing\AssertableInertia;
|
||||||
|
|
||||||
|
function profileFavouritesFixture(): array
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['username' => 'lazyfavourites']);
|
||||||
|
$artworks = Artwork::factory()->count(13)->for($user)->create();
|
||||||
|
|
||||||
|
foreach ($artworks as $index => $artwork) {
|
||||||
|
DB::table('artwork_favourites')->insert([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'artwork_id' => $artwork->id,
|
||||||
|
'created_at' => now()->subMinutes($index),
|
||||||
|
'updated_at' => now()->subMinutes($index),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$user, $artworks];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('skips the favourites producer on the default Posts SSR request', function (): void {
|
||||||
|
[$user] = profileFavouritesFixture();
|
||||||
|
DB::flushQueryLog();
|
||||||
|
DB::enableQueryLog();
|
||||||
|
|
||||||
|
$this->get('/@' . $user->username)
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->where('initialTab', 'posts')
|
||||||
|
->where('favourites', null));
|
||||||
|
|
||||||
|
expect(collect(DB::getQueryLog())->pluck('query')->filter(fn (string $query): bool => str_contains(strtolower($query), 'artwork_favourites'))->all())->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the first favourites page in direct SSR with the existing cursor contract', function (): void {
|
||||||
|
[$user, $artworks] = profileFavouritesFixture();
|
||||||
|
|
||||||
|
$this->get('/@' . $user->username . '/favourites')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->where('initialTab', 'favourites')
|
||||||
|
->where('favourites.next_cursor', base64_encode('12'))
|
||||||
|
->where('favourites.data.0.id', $artworks->first()->id));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the same ordered twelve-item page and continues with the next cursor', function (): void {
|
||||||
|
[$user, $artworks] = profileFavouritesFixture();
|
||||||
|
$expected = $artworks->values();
|
||||||
|
|
||||||
|
$first = $this->getJson('/api/profile/' . $user->username . '/favourites')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(12, 'data')
|
||||||
|
->assertJsonPath('data.0.id', $expected[0]->id)
|
||||||
|
->assertJsonPath('next_cursor', base64_encode('12'));
|
||||||
|
|
||||||
|
$this->getJson('/api/profile/' . $user->username . '/favourites?cursor=' . urlencode($first->json('next_cursor')))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(1, 'data')
|
||||||
|
->assertJsonPath('data.0.id', $expected[12]->id)
|
||||||
|
->assertJsonPath('next_cursor', null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not increment profile views for the lazy favourites endpoint', function (): void {
|
||||||
|
[$user] = profileFavouritesFixture();
|
||||||
|
$before = (int) $user->profile_views_count;
|
||||||
|
|
||||||
|
$this->getJson('/api/profile/' . $user->username . '/favourites')->assertOk();
|
||||||
|
|
||||||
|
expect((int) $user->fresh()->profile_views_count)->toBe($before);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user