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
@@ -140,6 +140,11 @@ To Be Played is split into three repositories:
|
|||||||
automatic hint for 7 days, "Do not show again" for good, the 30 s auto close only for this browser session.
|
automatic hint for 7 days, "Do not show again" for good, the 30 s auto close only for this browser session.
|
||||||
Admins turn each on or off on `/admin/hints`. Hint and news text may use `**bold**` and `[text](url)` (an app
|
Admins turn each on or off on `/admin/hints`. Hint and news text may use `**bold**` and `[text](url)` (an app
|
||||||
path or https; anything else stays plain text); `HintText` renders React elements, never HTML.
|
path or https; anything else stays plain text); `HintText` renders React elements, never HTML.
|
||||||
|
- **Follow users** (#27): a logged-in visitor follows the owner of a public profile with "Follow" on `/u/<name>`; the
|
||||||
|
settings page lists who you follow ("Unfollow") and who follows you ("Remove", a private follower shows as "Private
|
||||||
|
user"). Core writes one inbox note per followed user and batch (new games, achievements, status changes, wishlist
|
||||||
|
adds; what the profile hides is left out). `/inbox` (bell in the header, with the unread count) lists the newest 50 notes
|
||||||
|
and marks them read; a user who goes private ends the follow with a note. Notes older than 90 days are deleted.
|
||||||
- **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it"
|
- **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it"
|
||||||
marks them seen in Auth; closing the dialog any other way shows them again on the next dashboard visit. `/whats-new`
|
marks them seen in Auth; closing the dialog any other way shows them again on the next dashboard visit. `/whats-new`
|
||||||
(menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`.
|
(menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`.
|
||||||
|
|||||||
@@ -354,6 +354,9 @@
|
|||||||
"ERROR": "Die Rolle konnte nicht gewechselt werden."
|
"ERROR": "Die Rolle konnte nicht gewechselt werden."
|
||||||
},
|
},
|
||||||
"ADMIN_HINTS": "Hinweise",
|
"ADMIN_HINTS": "Hinweise",
|
||||||
|
"INBOX": "Postfach",
|
||||||
|
"INBOX_UNREAD_one": "Postfach, {{count}} neue Nachricht",
|
||||||
|
"INBOX_UNREAD_other": "Postfach, {{count}} neue Nachrichten",
|
||||||
"WHATS_NEW": "Neuigkeiten",
|
"WHATS_NEW": "Neuigkeiten",
|
||||||
"ADMIN_NEWS": "Neuigkeiten verwalten",
|
"ADMIN_NEWS": "Neuigkeiten verwalten",
|
||||||
"ADMIN_BROADCASTS": "Rundmail",
|
"ADMIN_BROADCASTS": "Rundmail",
|
||||||
@@ -875,6 +878,36 @@
|
|||||||
"AUTO_LINK_QUEUE": "Offene Paare in der Verknüpfungs-Warteschlange (Auditoren)",
|
"AUTO_LINK_QUEUE": "Offene Paare in der Verknüpfungs-Warteschlange (Auditoren)",
|
||||||
"AUTO_SAVED": "Gespeichert."
|
"AUTO_SAVED": "Gespeichert."
|
||||||
},
|
},
|
||||||
|
"FOLLOW": {
|
||||||
|
"TITLE": "Folgen",
|
||||||
|
"FOLLOW": "Folgen",
|
||||||
|
"UNFOLLOW": "Entfolgen",
|
||||||
|
"REMOVE": "Entfernen",
|
||||||
|
"FOLLOWING": "Personen, denen du folgst",
|
||||||
|
"FOLLOWERS": "Personen, die dir folgen",
|
||||||
|
"NO_FOLLOWING": "Du folgst noch niemandem. Öffne ein öffentliches Profil und drücke auf Folgen.",
|
||||||
|
"NO_FOLLOWERS": "Dir folgt noch niemand.",
|
||||||
|
"PRIVATE_USER": "Privater Nutzer",
|
||||||
|
"NOT_FOLLOWABLE": "Diesem Profil kann man nicht folgen.",
|
||||||
|
"LIMIT": "Du folgst schon so vielen Personen wie möglich.",
|
||||||
|
"ERROR": "Das hat nicht geklappt. Versuche es noch einmal.",
|
||||||
|
"LOAD_ERROR": "Die Liste konnte nicht geladen werden."
|
||||||
|
},
|
||||||
|
"INBOX": {
|
||||||
|
"TITLE": "Postfach",
|
||||||
|
"EMPTY": "Noch keine Nachrichten.",
|
||||||
|
"LOAD_ERROR": "Das Postfach konnte nicht geladen werden.",
|
||||||
|
"DIGEST": "{{name}}: {{parts}}",
|
||||||
|
"VISIBILITY": "{{name}} hat die Sichtbarkeit geändert, du folgst nicht mehr.",
|
||||||
|
"PART_NEW_GAMES_one": "{{count}} neues Spiel",
|
||||||
|
"PART_NEW_GAMES_other": "{{count}} neue Spiele",
|
||||||
|
"PART_ACHIEVEMENTS_one": "{{count}} Erfolg",
|
||||||
|
"PART_ACHIEVEMENTS_other": "{{count}} Erfolge",
|
||||||
|
"PART_STATUS_CHANGES_one": "{{count}} Statuswechsel",
|
||||||
|
"PART_STATUS_CHANGES_other": "{{count}} Statuswechsel",
|
||||||
|
"PART_WISHLIST_ADDS_one": "{{count}} Wunschlisten-Eintrag",
|
||||||
|
"PART_WISHLIST_ADDS_other": "{{count}} Wunschlisten-Einträge"
|
||||||
|
},
|
||||||
"NEWS": {
|
"NEWS": {
|
||||||
"TITLE": "Neuigkeiten",
|
"TITLE": "Neuigkeiten",
|
||||||
"GOT_IT": "Verstanden",
|
"GOT_IT": "Verstanden",
|
||||||
|
|||||||
@@ -354,6 +354,9 @@
|
|||||||
"ERROR": "The role could not be switched."
|
"ERROR": "The role could not be switched."
|
||||||
},
|
},
|
||||||
"ADMIN_HINTS": "Hints",
|
"ADMIN_HINTS": "Hints",
|
||||||
|
"INBOX": "Inbox",
|
||||||
|
"INBOX_UNREAD_one": "Inbox, {{count}} new note",
|
||||||
|
"INBOX_UNREAD_other": "Inbox, {{count}} new notes",
|
||||||
"WHATS_NEW": "What's new",
|
"WHATS_NEW": "What's new",
|
||||||
"ADMIN_NEWS": "News",
|
"ADMIN_NEWS": "News",
|
||||||
"ADMIN_BROADCASTS": "Mail",
|
"ADMIN_BROADCASTS": "Mail",
|
||||||
@@ -875,6 +878,36 @@
|
|||||||
"AUTO_LINK_QUEUE": "Open pairs in the link queue (auditors)",
|
"AUTO_LINK_QUEUE": "Open pairs in the link queue (auditors)",
|
||||||
"AUTO_SAVED": "Saved."
|
"AUTO_SAVED": "Saved."
|
||||||
},
|
},
|
||||||
|
"FOLLOW": {
|
||||||
|
"TITLE": "Following",
|
||||||
|
"FOLLOW": "Follow",
|
||||||
|
"UNFOLLOW": "Unfollow",
|
||||||
|
"REMOVE": "Remove",
|
||||||
|
"FOLLOWING": "People you follow",
|
||||||
|
"FOLLOWERS": "People who follow you",
|
||||||
|
"NO_FOLLOWING": "You follow nobody yet. Open a public profile and press Follow.",
|
||||||
|
"NO_FOLLOWERS": "Nobody follows you yet.",
|
||||||
|
"PRIVATE_USER": "Private user",
|
||||||
|
"NOT_FOLLOWABLE": "This profile cannot be followed.",
|
||||||
|
"LIMIT": "You follow the most people possible.",
|
||||||
|
"ERROR": "That did not work. Try again.",
|
||||||
|
"LOAD_ERROR": "The list could not be loaded."
|
||||||
|
},
|
||||||
|
"INBOX": {
|
||||||
|
"TITLE": "Inbox",
|
||||||
|
"EMPTY": "No notes yet.",
|
||||||
|
"LOAD_ERROR": "The inbox could not be loaded.",
|
||||||
|
"DIGEST": "{{name}}: {{parts}}",
|
||||||
|
"VISIBILITY": "{{name}} changed visibility, you no longer follow them.",
|
||||||
|
"PART_NEW_GAMES_one": "{{count}} new game",
|
||||||
|
"PART_NEW_GAMES_other": "{{count}} new games",
|
||||||
|
"PART_ACHIEVEMENTS_one": "{{count}} achievement",
|
||||||
|
"PART_ACHIEVEMENTS_other": "{{count}} achievements",
|
||||||
|
"PART_STATUS_CHANGES_one": "{{count}} status change",
|
||||||
|
"PART_STATUS_CHANGES_other": "{{count}} status changes",
|
||||||
|
"PART_WISHLIST_ADDS_one": "{{count}} wishlist add",
|
||||||
|
"PART_WISHLIST_ADDS_other": "{{count}} wishlist adds"
|
||||||
|
},
|
||||||
"NEWS": {
|
"NEWS": {
|
||||||
"TITLE": "What's new",
|
"TITLE": "What's new",
|
||||||
"GOT_IT": "Got it",
|
"GOT_IT": "Got it",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import SettingsPage from './pages/SettingsPage.jsx';
|
|||||||
import AdminHintsPage from './pages/AdminHintsPage.jsx';
|
import AdminHintsPage from './pages/AdminHintsPage.jsx';
|
||||||
import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx';
|
import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx';
|
||||||
import AdminNewsPage from './pages/AdminNewsPage.jsx';
|
import AdminNewsPage from './pages/AdminNewsPage.jsx';
|
||||||
|
import InboxPage from './pages/InboxPage.jsx';
|
||||||
import WhatsNewPage from './pages/WhatsNewPage.jsx';
|
import WhatsNewPage from './pages/WhatsNewPage.jsx';
|
||||||
import WelcomePage from './pages/WelcomePage.jsx';
|
import WelcomePage from './pages/WelcomePage.jsx';
|
||||||
import WelcomeGate from './components/WelcomeGate.jsx';
|
import WelcomeGate from './components/WelcomeGate.jsx';
|
||||||
@@ -91,6 +92,7 @@ function App() {
|
|||||||
<Route path="/points" element={<PointsPage/>}/>
|
<Route path="/points" element={<PointsPage/>}/>
|
||||||
<Route path="/settings" element={<SettingsPage/>}/>
|
<Route path="/settings" element={<SettingsPage/>}/>
|
||||||
<Route path="/whats-new" element={<WhatsNewPage/>}/>
|
<Route path="/whats-new" element={<WhatsNewPage/>}/>
|
||||||
|
<Route path="/inbox" element={<InboxPage/>}/>
|
||||||
<Route path="/welcome" element={<WelcomePage/>}/>
|
<Route path="/welcome" element={<WelcomePage/>}/>
|
||||||
<Route path="/audit/games" element={<GameCatalogPage/>}/>
|
<Route path="/audit/games" element={<GameCatalogPage/>}/>
|
||||||
<Route path="/audit/links" element={<LinkQueuePage/>}/>
|
<Route path="/audit/links" element={<LinkQueuePage/>}/>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ describe('App', () => {
|
|||||||
'GET /api/library/games-count': json(200, {hasSteamId: true}),
|
'GET /api/library/games-count': json(200, {hasSteamId: true}),
|
||||||
'GET /api/library/steam-profile': json(200, null),
|
'GET /api/library/steam-profile': json(200, null),
|
||||||
'POST /api/auth/refresh': json(401, {}),
|
'POST /api/auth/refresh': json(401, {}),
|
||||||
|
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||||
});
|
});
|
||||||
const {router} = renderWithProviders(<App/>, {entries: ['/library']});
|
const {router} = renderWithProviders(<App/>, {entries: ['/library']});
|
||||||
await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy());
|
await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy());
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
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 {toast} from 'sonner';
|
||||||
|
|
||||||
import {Badge} from '@/components/ui/badge';
|
import {Badge} from '@/components/ui/badge';
|
||||||
@@ -25,6 +25,7 @@ import useCurrentUser from '../hooks/useCurrentUser.js';
|
|||||||
import useSteamProfile from '../hooks/useSteamProfile.js';
|
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||||
import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js';
|
import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js';
|
||||||
import {canCorrect} from '../utils/corrections.js';
|
import {canCorrect} from '../utils/corrections.js';
|
||||||
|
import {fetchInbox} from '../utils/inbox.js';
|
||||||
import {expireSession, markSession} from '../utils/auth.js';
|
import {expireSession, markSession} from '../utils/auth.js';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
@@ -70,6 +71,20 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
}, [seesAdminPage, pathname, menuOpenings]);
|
}, [seesAdminPage, pathname, menuOpenings]);
|
||||||
const shownCount = seesAdminPage ? pendingCount : 0;
|
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) => {
|
const switchRole = async (role) => {
|
||||||
try {
|
try {
|
||||||
const response = await viewAs(role === currentUser.role ? null : role);
|
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>
|
<Link to="/points"><Coins className="text-primary"/><span className="min-w-0 truncate tabular-nums">{wallet.balance}</span></Link>
|
||||||
</Button>
|
</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 ? (
|
{isAuthenticated ? (
|
||||||
<DropdownMenu onOpenChange={(open) => open && setMenuOpenings((count) => count + 1)}>
|
<DropdownMenu onOpenChange={(open) => open && setMenuOpenings((count) => count + 1)}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -154,6 +182,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to="/points"><Coins/>{t('NAV.POINTS')}</Link>
|
<Link to="/points"><Coins/>{t('NAV.POINTS')}</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/inbox"><Bell/>{t('NAV.INBOX')}</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to="/whats-new"><Newspaper/>{t('NAV.WHATS_NEW')}</Link>
|
<Link to="/whats-new"><Newspaper/>{t('NAV.WHATS_NEW')}</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ describe('AppHeader points chip', () => {
|
|||||||
fetchRoutes({
|
fetchRoutes({
|
||||||
'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}),
|
'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}),
|
||||||
'GET /api/library/steam-profile': json(200, {}),
|
'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: []}),
|
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
|
||||||
});
|
});
|
||||||
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
|
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ afterEach(() => {
|
|||||||
|
|
||||||
const loggedInAs = (me, routes = {}) => {
|
const loggedInAs = (me, routes = {}) => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
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={() => {}}/>);
|
renderWithProviders(<AppHeader isAuthenticated onLogout={() => {}}/>);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,6 +46,23 @@ describe('AppHeader', () => {
|
|||||||
expect(screen.getByRole('link', {name: 'ToBePlayed'})).toBeTruthy();
|
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 () => {
|
it('offers the admin page and "view as" to an admin', async () => {
|
||||||
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
|
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
|
||||||
{'GET /api/admin/users/pending-count': json(200, {count: 0})});
|
{'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 {cn} from '@/lib/utils';
|
||||||
import GameCover from '../components/GameCover.jsx';
|
import GameCover from '../components/GameCover.jsx';
|
||||||
import FitText from '../components/FitText.jsx';
|
import FitText from '../components/FitText.jsx';
|
||||||
|
import FollowButton from '../components/people/FollowButton.jsx';
|
||||||
import ComingBadge from '../components/ComingBadge.jsx';
|
import ComingBadge from '../components/ComingBadge.jsx';
|
||||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||||
import {formatDay} from '../utils/format.js';
|
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>
|
<p className="mt-2 text-sm text-muted-foreground">{t('PROFILE.MEMBER_SINCE', {date: memberSince})}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{viewer && !owner && <FollowButton displayName={profile.displayName}/>}
|
||||||
{owner && !editing && (
|
{owner && !editing && (
|
||||||
<Button variant="outline" size="sm" className="relative mt-4" onClick={() => setEditing(true)}>
|
<Button variant="outline" size="sm" className="relative mt-4" onClick={() => setEditing(true)}>
|
||||||
<LayoutGrid/>{t('TILES.CUSTOMIZE')}
|
<LayoutGrid/>{t('TILES.CUSTOMIZE')}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function mockBackends({name = 'Lobo', profileStatus = 200, libraryStatus = 200,
|
|||||||
...(me && {
|
...(me && {
|
||||||
'GET /api/users/me': json(200, me),
|
'GET /api/users/me': json(200, me),
|
||||||
'PUT /api/users/settings/profile-tiles': json(204, {}),
|
'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 () => {
|
it('shows no "Customize" to a visitor', async () => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import AppearanceCard from '../components/AppearanceCard.jsx';
|
|||||||
import BuildVersionCard from '../components/BuildVersionCard.jsx';
|
import BuildVersionCard from '../components/BuildVersionCard.jsx';
|
||||||
import ChangePasswordCard from '../components/ChangePasswordCard.jsx';
|
import ChangePasswordCard from '../components/ChangePasswordCard.jsx';
|
||||||
import DeleteAccountCard from '../components/DeleteAccountCard.jsx';
|
import DeleteAccountCard from '../components/DeleteAccountCard.jsx';
|
||||||
|
import FollowsCard from '../components/FollowsCard.jsx';
|
||||||
import PreferencesCard from '../components/PreferencesCard.jsx';
|
import PreferencesCard from '../components/PreferencesCard.jsx';
|
||||||
import ProfileCard from '../components/ProfileCard.jsx';
|
import ProfileCard from '../components/ProfileCard.jsx';
|
||||||
import SteamAccountCard from '../components/SteamAccountCard.jsx';
|
import SteamAccountCard from '../components/SteamAccountCard.jsx';
|
||||||
@@ -74,6 +75,8 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<ProfileCard displayName={displayName}/>
|
<ProfileCard displayName={displayName}/>
|
||||||
|
|
||||||
|
<FollowsCard/>
|
||||||
|
|
||||||
<BuildVersionCard/>
|
<BuildVersionCard/>
|
||||||
|
|
||||||
<ChangePasswordCard/>
|
<ChangePasswordCard/>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings'} = {}) {
|
|||||||
'PUT /api/users/settings': noBody,
|
'PUT /api/users/settings': noBody,
|
||||||
'POST /api/auth/refresh': noBody,
|
'POST /api/auth/refresh': noBody,
|
||||||
'GET /api/version': json(404, {}),
|
'GET /api/version': json(404, {}),
|
||||||
|
'GET /api/follows': json(200, {following: [], followers: []}),
|
||||||
});
|
});
|
||||||
renderWithProviders(<SettingsPage/>, {path: '/settings', entries: [entry]});
|
renderWithProviders(<SettingsPage/>, {path: '/settings', entries: [entry]});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ describe('SettingsPage Steam toast', () => {
|
|||||||
'GET /api/users/me': json(200, profile),
|
'GET /api/users/me': json(200, profile),
|
||||||
'POST /api/auth/refresh': {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }},
|
'POST /api/auth/refresh': {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }},
|
||||||
'GET /api/version': json(404, {}),
|
'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/favorite-platform': noBody,
|
||||||
'PUT /api/users/settings/owner-lists': noBody,
|
'PUT /api/users/settings/owner-lists': noBody,
|
||||||
'GET /api/version': json(404, {}),
|
'GET /api/version': json(404, {}),
|
||||||
|
'GET /api/follows': json(200, {following: [], followers: []}),
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderPage(me) {
|
function renderPage(me) {
|
||||||
@@ -292,6 +293,7 @@ describe('SettingsPage session', () => {
|
|||||||
'GET /api/users/me': json(401, {}),
|
'GET /api/users/me': json(401, {}),
|
||||||
'POST /api/auth/refresh': json(401, {}),
|
'POST /api/auth/refresh': json(401, {}),
|
||||||
'GET /api/version': json(404, {}),
|
'GET /api/version': json(404, {}),
|
||||||
|
'GET /api/follows': json(200, {following: [], followers: []}),
|
||||||
});
|
});
|
||||||
const {router} = renderWithProviders(<SettingsPage/>, {path: '/settings', entries: ['/settings'],
|
const {router} = renderWithProviders(<SettingsPage/>, {path: '/settings', entries: ['/settings'],
|
||||||
guarded: true, routes: {'/login': <p>login</p>}});
|
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