Public Access
feat(ui): add follow button, follows card, and inbox page
This commit is contained in:
1 parent
3aed243e35
commit
43c4862606
23 files changed
+603
-2
No files matched your search
@@ -0,0 +1,67 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Loader2} from 'lucide-react';
|
||||
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {cn} from '@/lib/utils';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {formatDate} from '../utils/format.js';
|
||||
import {digestParts, fetchInbox, markInboxRead} from '../utils/inbox.js';
|
||||
|
||||
/**
|
||||
* The newest notes about the people the user follows. Opening the page marks them read.
|
||||
*/
|
||||
export default function InboxPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const [notes, setNotes] = useState([]);
|
||||
const [status, setStatus] = useState('loading');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const {ok, status: code, data} = await fetchInbox();
|
||||
if (code === 401) return;
|
||||
if (!ok) {
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
setNotes(data.results);
|
||||
setStatus('ready');
|
||||
// A failed mark is silent: the notes show as unread again on the next visit.
|
||||
if (data.unread > 0) markInboxRead();
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const text = (note) => {
|
||||
const name = note.displayName ?? t('FOLLOW.PRIVATE_USER');
|
||||
if (note.kind === 'VISIBILITY') return t('INBOX.VISIBILITY', {name});
|
||||
const parts = digestParts(note).map(({key, count}) => t(`INBOX.PART_${key}`, {count})).join(', ');
|
||||
return t('INBOX.DIGEST', {name, parts});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('INBOX.TITLE')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
|
||||
{status === 'error' && <p className="text-sm text-destructive">{t('INBOX.LOAD_ERROR')}</p>}
|
||||
{status === 'ready' && notes.length === 0 && <p className="text-sm text-muted-foreground">{t('INBOX.EMPTY')}</p>}
|
||||
{status === 'ready' && notes.length > 0 && (
|
||||
<ul className="divide-y">
|
||||
{notes.map((note) => (
|
||||
<li key={note.id} className="space-y-1 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(note.createdAt, i18n.language, {dateStyle: 'medium'})}
|
||||
</p>
|
||||
<p className={cn('break-words text-sm', !note.read && 'font-semibold')}>{text(note)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, screen, waitFor} from '@testing-library/react';
|
||||
import InboxPage from './InboxPage.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
const note = (id, extra) => ({id, kind: 'DIGEST', displayName: 'Lobo', newGames: 0, achievements: 0, statusChanges: 0,
|
||||
wishlistAdds: 0, createdAt: '2026-10-06T10:00:00Z', read: false, ...extra});
|
||||
const putCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT');
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('InboxPage', () => {
|
||||
it('showsADigestWithOnlyTheNonZeroPartsAndMarksTheNotesRead', async () => {
|
||||
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 12, wishlistAdds: 2})], unread: 1}),
|
||||
'PUT /api/inbox/read': json(204)});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
expect(await screen.findByText('INBOX.DIGEST')).toBeTruthy();
|
||||
await waitFor(() => expect(putCalls()).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('showsAVisibilityNote', async () => {
|
||||
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {kind: 'VISIBILITY'})], unread: 1}),
|
||||
'PUT /api/inbox/read': json(204)});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
expect(await screen.findByText('INBOX.VISIBILITY')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('saysSoWhenThereAreNoNotesAndMarksNothing', async () => {
|
||||
fetchRoutes({'GET /api/inbox': json(200, {results: [], unread: 0})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
expect(await screen.findByText('INBOX.EMPTY')).toBeTruthy();
|
||||
expect(putCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
it('showsAnErrorAndMarksNothingWhenTheLoadFails', async () => {
|
||||
fetchRoutes({'GET /api/inbox': json(500, {})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
expect(await screen.findByText('INBOX.LOAD_ERROR')).toBeTruthy();
|
||||
expect(putCalls()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {Skeleton} from '@/components/ui/skeleton';
|
||||
import {cn} from '@/lib/utils';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import FitText from '../components/FitText.jsx';
|
||||
import FollowButton from '../components/people/FollowButton.jsx';
|
||||
import ComingBadge from '../components/ComingBadge.jsx';
|
||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||
import {formatDay} from '../utils/format.js';
|
||||
@@ -182,6 +183,7 @@ export default function PublicProfilePage() {
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t('PROFILE.MEMBER_SINCE', {date: memberSince})}</p>
|
||||
)}
|
||||
</div>
|
||||
{viewer && !owner && <FollowButton displayName={profile.displayName}/>}
|
||||
{owner && !editing && (
|
||||
<Button variant="outline" size="sm" className="relative mt-4" onClick={() => setEditing(true)}>
|
||||
<LayoutGrid/>{t('TILES.CUSTOMIZE')}
|
||||
|
||||
@@ -25,6 +25,7 @@ function mockBackends({name = 'Lobo', profileStatus = 200, libraryStatus = 200,
|
||||
...(me && {
|
||||
'GET /api/users/me': json(200, me),
|
||||
'PUT /api/users/settings/profile-tiles': json(204, {}),
|
||||
'GET /api/follows': json(200, {following: [], followers: []}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -238,6 +239,34 @@ describe('PublicProfilePage', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('offers "Follow" to a logged-in visitor, not to the owner and not to a guest', async () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
try {
|
||||
mockBackends({me: {displayName: 'Someone', role: 'USER'}});
|
||||
const visitor = renderPage();
|
||||
expect(await screen.findByRole('button', {name: 'FOLLOW.FOLLOW'})).toBeTruthy();
|
||||
visitor.unmount();
|
||||
|
||||
mockBackends({me: {displayName: 'Lobo', role: 'USER'}});
|
||||
renderPage();
|
||||
await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'});
|
||||
expect(screen.queryByRole('button', {name: 'FOLLOW.FOLLOW'})).toBeNull();
|
||||
expect(fetch).not.toHaveBeenCalledWith(expect.stringContaining('/api/follows'), expect.anything());
|
||||
} finally {
|
||||
localStorage.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it('offers no "Follow" to a guest', async () => {
|
||||
mockBackends();
|
||||
renderPage();
|
||||
|
||||
await screen.findByText('Portal 2');
|
||||
|
||||
expect(screen.queryByRole('button', {name: 'FOLLOW.FOLLOW'})).toBeNull();
|
||||
expect(fetch).not.toHaveBeenCalledWith(expect.stringContaining('/api/follows'), expect.anything());
|
||||
});
|
||||
|
||||
it('shows no "Customize" to a visitor', async () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,7 @@ import AppearanceCard from '../components/AppearanceCard.jsx';
|
||||
import BuildVersionCard from '../components/BuildVersionCard.jsx';
|
||||
import ChangePasswordCard from '../components/ChangePasswordCard.jsx';
|
||||
import DeleteAccountCard from '../components/DeleteAccountCard.jsx';
|
||||
import FollowsCard from '../components/FollowsCard.jsx';
|
||||
import PreferencesCard from '../components/PreferencesCard.jsx';
|
||||
import ProfileCard from '../components/ProfileCard.jsx';
|
||||
import SteamAccountCard from '../components/SteamAccountCard.jsx';
|
||||
@@ -74,6 +75,8 @@ export default function SettingsPage() {
|
||||
|
||||
<ProfileCard displayName={displayName}/>
|
||||
|
||||
<FollowsCard/>
|
||||
|
||||
<BuildVersionCard/>
|
||||
|
||||
<ChangePasswordCard/>
|
||||
|
||||
@@ -32,6 +32,7 @@ function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings'} = {}) {
|
||||
'PUT /api/users/settings': noBody,
|
||||
'POST /api/auth/refresh': noBody,
|
||||
'GET /api/version': json(404, {}),
|
||||
'GET /api/follows': json(200, {following: [], followers: []}),
|
||||
});
|
||||
renderWithProviders(<SettingsPage/>, {path: '/settings', entries: [entry]});
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ describe('SettingsPage Steam toast', () => {
|
||||
'GET /api/users/me': json(200, profile),
|
||||
'POST /api/auth/refresh': {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }},
|
||||
'GET /api/version': json(404, {}),
|
||||
'GET /api/follows': json(200, {following: [], followers: []}),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ const authRoutes = (me) => ({
|
||||
'PUT /api/users/settings/favorite-platform': noBody,
|
||||
'PUT /api/users/settings/owner-lists': noBody,
|
||||
'GET /api/version': json(404, {}),
|
||||
'GET /api/follows': json(200, {following: [], followers: []}),
|
||||
});
|
||||
|
||||
function renderPage(me) {
|
||||
@@ -292,6 +293,7 @@ describe('SettingsPage session', () => {
|
||||
'GET /api/users/me': json(401, {}),
|
||||
'POST /api/auth/refresh': json(401, {}),
|
||||
'GET /api/version': json(404, {}),
|
||||
'GET /api/follows': json(200, {following: [], followers: []}),
|
||||
});
|
||||
const {router} = renderWithProviders(<SettingsPage/>, {path: '/settings', entries: ['/settings'],
|
||||
guarded: true, routes: {'/login': <p>login</p>}});
|
||||
|
||||
Reference in new issue
Block a user