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