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
@@ -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/>}/>
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={() => {}}/></>);
|
||||
|
||||
@@ -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})});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>}});
|
||||
|
||||
@@ -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());
|
||||
@@ -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]}));
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user