feat(ui): add follow button, follows card, and inbox page
Build, Test & Push Frontend / quality-check (push) Successful in 34s
Build, Test & Push Frontend / docker (push) Successful in 23s

This commit is contained in:
lobo committed 2026-10-06 16:22:22 +02:00
1 parent 3aed243e35
commit 43c4862606
23 files changed
+603 -2

No files matched your search

+2
View File
@@ -13,6 +13,7 @@ import SettingsPage from './pages/SettingsPage.jsx';
import AdminHintsPage from './pages/AdminHintsPage.jsx';
import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx';
import AdminNewsPage from './pages/AdminNewsPage.jsx';
import InboxPage from './pages/InboxPage.jsx';
import WhatsNewPage from './pages/WhatsNewPage.jsx';
import WelcomePage from './pages/WelcomePage.jsx';
import WelcomeGate from './components/WelcomeGate.jsx';
@@ -91,6 +92,7 @@ function App() {
<Route path="/points" element={<PointsPage/>}/>
<Route path="/settings" element={<SettingsPage/>}/>
<Route path="/whats-new" element={<WhatsNewPage/>}/>
<Route path="/inbox" element={<InboxPage/>}/>
<Route path="/welcome" element={<WelcomePage/>}/>
<Route path="/audit/games" element={<GameCatalogPage/>}/>
<Route path="/audit/links" element={<LinkQueuePage/>}/>
+1
View File
@@ -34,6 +34,7 @@ describe('App', () => {
'GET /api/library/games-count': json(200, {hasSteamId: true}),
'GET /api/library/steam-profile': json(200, null),
'POST /api/auth/refresh': json(401, {}),
'GET /api/inbox': json(200, {results: [], unread: 0}),
});
const {router} = renderWithProviders(<App/>, {entries: ['/library']});
await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy());
+32 -1
View File
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun} from 'lucide-react';
import {Bell, ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
@@ -25,6 +25,7 @@ import useCurrentUser from '../hooks/useCurrentUser.js';
import useSteamProfile from '../hooks/useSteamProfile.js';
import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js';
import {canCorrect} from '../utils/corrections.js';
import {fetchInbox} from '../utils/inbox.js';
import {expireSession, markSession} from '../utils/auth.js';
const NAV_ITEMS = [
@@ -70,6 +71,20 @@ export default function AppHeader({isAuthenticated, onLogout}) {
}, [seesAdminPage, pathname, menuOpenings]);
const shownCount = seesAdminPage ? pendingCount : 0;
// Loaded again on every page change. The inbox page marks the notes read, so it shows no count.
const [unread, setUnread] = useState(0);
useEffect(() => {
if (!isAuthenticated) return undefined;
let current = true;
fetchInbox().then(({ok, data}) => {
if (current && ok) setUnread(data.unread);
});
return () => {
current = false;
};
}, [isAuthenticated, pathname]);
const shownUnread = pathname === '/inbox' ? 0 : unread;
const switchRole = async (role) => {
try {
const response = await viewAs(role === currentUser.role ? null : role);
@@ -133,6 +148,19 @@ export default function AppHeader({isAuthenticated, onLogout}) {
<Link to="/points"><Coins className="text-primary"/><span className="min-w-0 truncate tabular-nums">{wallet.balance}</span></Link>
</Button>
)}
{isAuthenticated && (
<Button asChild variant="ghost" size="icon" title={t('NAV.INBOX')} className="relative shrink-0">
<Link to="/inbox" aria-label={shownUnread > 0 ? t('NAV.INBOX_UNREAD', {count: shownUnread}) : t('NAV.INBOX')}>
<Bell/>
{shownUnread > 0 && (
<span aria-hidden="true"
className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
{shownUnread > 9 ? '9+' : shownUnread}
</span>
)}
</Link>
</Button>
)}
{isAuthenticated ? (
<DropdownMenu onOpenChange={(open) => open && setMenuOpenings((count) => count + 1)}>
<DropdownMenuTrigger asChild>
@@ -154,6 +182,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
<DropdownMenuItem asChild>
<Link to="/points"><Coins/>{t('NAV.POINTS')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/inbox"><Bell/>{t('NAV.INBOX')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/whats-new"><Newspaper/>{t('NAV.WHATS_NEW')}</Link>
</DropdownMenuItem>
+1
View File
@@ -24,6 +24,7 @@ describe('AppHeader points chip', () => {
fetchRoutes({
'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}),
'GET /api/library/steam-profile': json(200, {}),
'GET /api/inbox': json(200, {results: [], unread: 0}),
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
});
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
+19 -1
View File
@@ -18,7 +18,8 @@ afterEach(() => {
const loggedInAs = (me, routes = {}) => {
localStorage.setItem('tbp_session', '1');
fetchRoutes({'GET /api/users/me': json(200, me), 'GET /api/library/steam-profile': json(200, {}), ...routes});
fetchRoutes({'GET /api/users/me': json(200, me), 'GET /api/library/steam-profile': json(200, {}),
'GET /api/inbox': json(200, {results: [], unread: 0}), ...routes});
renderWithProviders(<AppHeader isAuthenticated onLogout={() => {}}/>);
};
@@ -45,6 +46,23 @@ describe('AppHeader', () => {
expect(screen.getByRole('link', {name: 'ToBePlayed'})).toBeTruthy();
});
it('shows how many inbox notes are unread', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/inbox': json(200, {results: [], unread: 3})});
const link = await screen.findByRole('link', {name: 'NAV.INBOX_UNREAD:3'});
expect(link.getAttribute('href')).toBe('/inbox');
expect(link.textContent).toBe('3');
});
it('shows no count without unread notes', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'});
const link = await screen.findByRole('link', {name: 'NAV.INBOX'});
expect(link.textContent).toBe('');
});
it('offers the admin page and "view as" to an admin', async () => {
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
{'GET /api/admin/users/pending-count': json(200, {count: 0})});
+77
View File
@@ -0,0 +1,77 @@
import {useCallback, useEffect, useState} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Users} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {fetchFollows, removeFollow} from '../utils/follows.js';
import {profileHref} from '../utils/people.js';
export default function FollowsCard() {
const {t} = useTranslation();
const [follows, setFollows] = useState(null);
const [failed, setFailed] = useState(false);
const load = useCallback(async () => {
const {ok, data} = await fetchFollows();
setFailed(!ok);
if (ok) setFollows(data);
}, []);
useEffect(() => {
load();
}, [load]);
const end = async (id) => {
const {ok, status} = await removeFollow(id);
if (!ok && status !== 401) toast.error(t('FOLLOW.ERROR'));
await load();
};
const link = (displayName) => (
<Link to={profileHref(displayName)} className="min-w-0 truncate hover:text-primary hover:underline">{displayName}</Link>
);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Users className="size-4 text-primary"/>{t('FOLLOW.TITLE')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{failed && <p className="text-sm text-destructive">{t('FOLLOW.LOAD_ERROR')}</p>}
{follows && (
<>
<section className="space-y-2">
<h3 className="text-sm font-medium">{t('FOLLOW.FOLLOWING')}</h3>
{follows.following.length === 0 && <p className="text-sm text-muted-foreground">{t('FOLLOW.NO_FOLLOWING')}</p>}
<ul className="space-y-1">
{follows.following.map((entry) => (
<li key={entry.id} className="flex items-center justify-between gap-2 text-sm">
{link(entry.displayName)}
<Button variant="outline" size="sm" onClick={() => end(entry.id)}>{t('FOLLOW.UNFOLLOW')}</Button>
</li>
))}
</ul>
</section>
<section className="space-y-2">
<h3 className="text-sm font-medium">{t('FOLLOW.FOLLOWERS')}</h3>
{follows.followers.length === 0 && <p className="text-sm text-muted-foreground">{t('FOLLOW.NO_FOLLOWERS')}</p>}
<ul className="space-y-1">
{follows.followers.map((entry) => (
<li key={entry.id} className="flex items-center justify-between gap-2 text-sm">
{entry.displayName
? link(entry.displayName)
: <span className="min-w-0 truncate text-muted-foreground">{t('FOLLOW.PRIVATE_USER')}</span>}
<Button variant="outline" size="sm" onClick={() => end(entry.id)}>{t('FOLLOW.REMOVE')}</Button>
</li>
))}
</ul>
</section>
</>
)}
</CardContent>
</Card>
);
}
+58
View File
@@ -0,0 +1,58 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import FollowsCard from './FollowsCard.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const follows = {following: [{id: 5, displayName: 'Lobo'}], followers: [{id: 6, displayName: 'Wolf'}, {id: 7, displayName: null}]};
describe('FollowsCard', () => {
it('listsBothSidesWithLinksAndPrivateFollowersWithoutAName', async () => {
fetchRoutes({'GET /api/follows': json(200, follows)});
renderWithProviders(<FollowsCard/>);
expect((await screen.findByRole('link', {name: 'Lobo'})).getAttribute('href')).toBe('/u/Lobo');
expect(screen.getByRole('link', {name: 'Wolf'}).getAttribute('href')).toBe('/u/Wolf');
expect(screen.getByText('FOLLOW.PRIVATE_USER')).toBeTruthy();
});
it('unfollowsByRowIdAndLoadsTheListAgain', async () => {
let answered = follows;
fetchRoutes({'GET /api/follows': () => json(200, answered),
'DELETE /api/follows/5': () => {
answered = {...follows, following: []};
return json(204);
}});
renderWithProviders(<FollowsCard/>);
fireEvent.click(await screen.findByRole('button', {name: 'FOLLOW.UNFOLLOW'}));
await waitFor(() => expect(screen.queryByRole('link', {name: 'Lobo'})).toBeNull());
expect(screen.getByText('FOLLOW.NO_FOLLOWING')).toBeTruthy();
});
it('removesAFollowerByRowId', async () => {
fetchRoutes({'GET /api/follows': json(200, follows), 'DELETE /api/follows/7': json(204)});
renderWithProviders(<FollowsCard/>);
fireEvent.click((await screen.findAllByRole('button', {name: 'FOLLOW.REMOVE'}))[1]);
await waitFor(() => expect(fetch.mock.calls.filter(([, init]) => init?.method === 'DELETE')).toHaveLength(1));
});
it('saysSoWhenTheListDoesNotLoad', async () => {
fetchRoutes({'GET /api/follows': json(500, {})});
renderWithProviders(<FollowsCard/>);
expect(await screen.findByText('FOLLOW.LOAD_ERROR')).toBeTruthy();
});
});
+53
View File
@@ -0,0 +1,53 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {UserMinus, UserPlus} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {fetchFollows, followUser, isFollowing, unfollowUser} from '../../utils/follows.js';
/**
* Follow or unfollow the owner of a public profile. The parent shows it only to a logged-in viewer who is not the owner.
*/
export default function FollowButton({displayName}) {
const {t} = useTranslation();
// null until the list is loaded; a failed load shows no button.
const [following, setFollowing] = useState(null);
const [busy, setBusy] = useState(false);
const [notFollowable, setNotFollowable] = useState(false);
useEffect(() => {
let current = true;
setFollowing(null);
setNotFollowable(false);
fetchFollows().then(({ok, data}) => {
if (current && ok) setFollowing(isFollowing(data, displayName));
});
return () => {
current = false;
};
}, [displayName]);
const toggle = async () => {
setBusy(true);
const response = await (following ? unfollowUser : followUser)(displayName);
setBusy(false);
if (response.ok) {
setFollowing(!following);
} else if (response.status === 404) {
setNotFollowable(true);
} else if (response.status !== 401) {
toast.error(t(response.data?.code === 'FOLLOW_LIMIT' ? 'FOLLOW.LIMIT' : 'FOLLOW.ERROR'));
}
};
if (notFollowable) return <p className="relative mt-4 text-sm text-muted-foreground">{t('FOLLOW.NOT_FOLLOWABLE')}</p>;
if (following === null) return null;
const Icon = following ? UserMinus : UserPlus;
return (
<Button variant={following ? 'outline' : 'default'} size="sm" className="relative mt-4" disabled={busy}
onClick={toggle}>
<Icon/>{t(following ? 'FOLLOW.UNFOLLOW' : 'FOLLOW.FOLLOW')}
</Button>
);
}
@@ -0,0 +1,70 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import FollowButton from './FollowButton.jsx';
import {fetchRoutes, json} from '../../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const noFollows = {following: [], followers: []};
const callsOf = (method) => fetch.mock.calls.filter(([, init]) => init?.method === method);
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('FollowButton', () => {
it('offersFollowAndFollowsByNameThenOffersUnfollow', async () => {
fetchRoutes({'GET /api/follows': json(200, noFollows), 'PUT /api/follows/by-name/Lobo%20Wolf': json(204)});
render(<FollowButton displayName="Lobo Wolf"/>);
fireEvent.click(await screen.findByRole('button', {name: 'FOLLOW.FOLLOW'}));
expect(await screen.findByRole('button', {name: 'FOLLOW.UNFOLLOW'})).toBeTruthy();
expect(callsOf('PUT')).toHaveLength(1);
});
it('offersUnfollowForANameAlreadyFollowedIgnoringCase', async () => {
fetchRoutes({'GET /api/follows': json(200, {following: [{id: 1, displayName: 'lobo'}], followers: []}),
'DELETE /api/follows/by-name/Lobo': json(204)});
render(<FollowButton displayName="Lobo"/>);
fireEvent.click(await screen.findByRole('button', {name: 'FOLLOW.UNFOLLOW'}));
expect(await screen.findByRole('button', {name: 'FOLLOW.FOLLOW'})).toBeTruthy();
expect(callsOf('DELETE')).toHaveLength(1);
});
it('saysSoWhenTheProfileCannotBeFollowed', async () => {
fetchRoutes({'GET /api/follows': json(200, noFollows),
'PUT /api/follows/by-name/Lobo': json(404, {code: 'PROFILE_NOT_FOUND'})});
render(<FollowButton displayName="Lobo"/>);
fireEvent.click(await screen.findByRole('button', {name: 'FOLLOW.FOLLOW'}));
expect(await screen.findByText('FOLLOW.NOT_FOLLOWABLE')).toBeTruthy();
expect(screen.queryByRole('button')).toBeNull();
});
it('toastsOnAnOtherErrorAndKeepsTheButton', async () => {
fetchRoutes({'GET /api/follows': json(200, noFollows),
'PUT /api/follows/by-name/Lobo': json(400, {code: 'FOLLOW_LIMIT'})});
render(<FollowButton displayName="Lobo"/>);
fireEvent.click(await screen.findByRole('button', {name: 'FOLLOW.FOLLOW'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('FOLLOW.LIMIT'));
expect(screen.getByRole('button', {name: 'FOLLOW.FOLLOW'})).toBeTruthy();
});
it('showsNoButtonWhenTheListDoesNotLoad', async () => {
fetchRoutes({'GET /api/follows': json(500, {})});
render(<FollowButton displayName="Lobo"/>);
await waitFor(() => expect(fetch).toHaveBeenCalled());
expect(screen.queryByRole('button')).toBeNull();
});
});
+67
View File
@@ -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>
);
}
+52
View File
@@ -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([]);
});
});
+2
View File
@@ -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')}
+29
View File
@@ -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 {
+3
View File
@@ -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/>
+1
View File
@@ -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: []}),
});
});
+2
View File
@@ -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>}});
+25
View File
@@ -0,0 +1,25 @@
import {request} from './api.js';
const FOLLOWS = () => `${import.meta.env.VITE_CORE_API_URL}/api/follows`;
/**
* GET /api/follows: `{following: [{id, displayName}], followers: [{id, displayName}]}`. `id` is the follow row, not a
* user id. A follower with a private profile has `displayName: null`.
*/
export const fetchFollows = () => request(FOLLOWS());
const byName = (displayName) => `${FOLLOWS()}/by-name/${encodeURIComponent(displayName)}`;
/**
* 204; 404 `PROFILE_NOT_FOUND` for an unknown, private, blocked or deleted name; 400 `FOLLOW_SELF` or `FOLLOW_LIMIT`.
*/
export const followUser = (displayName) => request(byName(displayName), {method: 'PUT'});
/** 204, also for a name that is not followed. */
export const unfollowUser = (displayName) => request(byName(displayName), {method: 'DELETE'});
/** Ends a follow of the user, or removes a follower. 204. */
export const removeFollow = (id) => request(`${FOLLOWS()}/${id}`, {method: 'DELETE'});
export const isFollowing = (follows, displayName) =>
follows.following.some((entry) => entry.displayName?.toLowerCase() === displayName.toLowerCase());
+22
View File
@@ -0,0 +1,22 @@
import {request} from './api.js';
const INBOX = () => `${import.meta.env.VITE_CORE_API_URL}/api/inbox`;
/**
* GET /api/inbox: `{results: [{id, kind, displayName, newGames, achievements, statusChanges, wishlistAdds, createdAt,
* read}], unread}`, the newest 50 notes. `kind` is `DIGEST` or `VISIBILITY`; `unread` counts all unread notes.
*/
export const fetchInbox = () => request(INBOX());
/** All notes count as read. 204. */
export const markInboxRead = () => request(`${INBOX()}/read`, {method: 'PUT'});
const PARTS = [['NEW_GAMES', 'newGames'], ['ACHIEVEMENTS', 'achievements'], ['STATUS_CHANGES', 'statusChanges'],
['WISHLIST_ADDS', 'wishlistAdds']];
/**
* The counts of a digest note that are not 0, in a fixed order: `[{key: 'NEW_GAMES', count}]`. The key names the
* text `INBOX.PART_<key>`.
*/
export const digestParts = (note) =>
PARTS.filter(([, field]) => note[field] > 0).map(([key, field]) => ({key, count: note[field]}));
+15
View File
@@ -0,0 +1,15 @@
import {describe, expect, it} from 'vitest';
import {digestParts} from './inbox.js';
describe('digestParts', () => {
it('keepsOnlyTheNonZeroCountsInAFixedOrder', () => {
const note = {newGames: 12, achievements: 0, statusChanges: 3, wishlistAdds: 2};
expect(digestParts(note)).toEqual([
{key: 'NEW_GAMES', count: 12}, {key: 'STATUS_CHANGES', count: 3}, {key: 'WISHLIST_ADDS', count: 2}]);
});
it('isEmptyWhenEveryCountIsZero', () => {
expect(digestParts({newGames: 0, achievements: 0, statusChanges: 0, wishlistAdds: 0})).toEqual([]);
});
});