From 43c486260687ec89d917e352a9edb71ba47cbc92 Mon Sep 17 00:00:00 2001 From: lobo Date: Tue, 6 Oct 2026 16:22:22 +0200 Subject: [PATCH] feat(ui): add follow button, follows card, and inbox page --- README.md | 5 ++ public/i18n/de.json | 33 +++++++++ public/i18n/en.json | 33 +++++++++ src/App.jsx | 2 + src/App.test.jsx | 1 + src/components/AppHeader.jsx | 33 ++++++++- src/components/AppHeader.points.test.jsx | 1 + src/components/AppHeader.test.jsx | 20 +++++- src/components/FollowsCard.jsx | 77 +++++++++++++++++++++ src/components/FollowsCard.test.jsx | 58 ++++++++++++++++ src/components/people/FollowButton.jsx | 53 ++++++++++++++ src/components/people/FollowButton.test.jsx | 70 +++++++++++++++++++ src/pages/InboxPage.jsx | 67 ++++++++++++++++++ src/pages/InboxPage.test.jsx | 52 ++++++++++++++ src/pages/PublicProfilePage.jsx | 2 + src/pages/PublicProfilePage.test.jsx | 29 ++++++++ src/pages/SettingsPage.jsx | 3 + src/pages/SettingsPage.steam.test.jsx | 1 + src/pages/SettingsPage.steamToast.test.jsx | 1 + src/pages/SettingsPage.test.jsx | 2 + src/utils/follows.js | 25 +++++++ src/utils/inbox.js | 22 ++++++ src/utils/inbox.test.js | 15 ++++ 23 files changed, 603 insertions(+), 2 deletions(-) create mode 100644 src/components/FollowsCard.jsx create mode 100644 src/components/FollowsCard.test.jsx create mode 100644 src/components/people/FollowButton.jsx create mode 100644 src/components/people/FollowButton.test.jsx create mode 100644 src/pages/InboxPage.jsx create mode 100644 src/pages/InboxPage.test.jsx create mode 100644 src/utils/follows.js create mode 100644 src/utils/inbox.js create mode 100644 src/utils/inbox.test.js diff --git a/README.md b/README.md index 1bf4fc6..eb01bb8 100644 --- a/README.md +++ b/README.md @@ -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. 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. +- **Follow users** (#27): a logged-in visitor follows the owner of a public profile with "Follow" on `/u/`; 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" 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`. diff --git a/public/i18n/de.json b/public/i18n/de.json index 2431009..587e2a4 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -354,6 +354,9 @@ "ERROR": "Die Rolle konnte nicht gewechselt werden." }, "ADMIN_HINTS": "Hinweise", + "INBOX": "Postfach", + "INBOX_UNREAD_one": "Postfach, {{count}} neue Nachricht", + "INBOX_UNREAD_other": "Postfach, {{count}} neue Nachrichten", "WHATS_NEW": "Neuigkeiten", "ADMIN_NEWS": "Neuigkeiten verwalten", "ADMIN_BROADCASTS": "Rundmail", @@ -875,6 +878,36 @@ "AUTO_LINK_QUEUE": "Offene Paare in der Verknüpfungs-Warteschlange (Auditoren)", "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": { "TITLE": "Neuigkeiten", "GOT_IT": "Verstanden", diff --git a/public/i18n/en.json b/public/i18n/en.json index 026f4c6..62e19bf 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -354,6 +354,9 @@ "ERROR": "The role could not be switched." }, "ADMIN_HINTS": "Hints", + "INBOX": "Inbox", + "INBOX_UNREAD_one": "Inbox, {{count}} new note", + "INBOX_UNREAD_other": "Inbox, {{count}} new notes", "WHATS_NEW": "What's new", "ADMIN_NEWS": "News", "ADMIN_BROADCASTS": "Mail", @@ -875,6 +878,36 @@ "AUTO_LINK_QUEUE": "Open pairs in the link queue (auditors)", "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": { "TITLE": "What's new", "GOT_IT": "Got it", diff --git a/src/App.jsx b/src/App.jsx index a987cb9..a2a3761 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() { }/> }/> }/> + }/> }/> }/> }/> diff --git a/src/App.test.jsx b/src/App.test.jsx index 5559280..057a015 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -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(, {entries: ['/library']}); await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy()); diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index 21b755b..e8aed81 100644 --- a/src/components/AppHeader.jsx +++ b/src/components/AppHeader.jsx @@ -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}) { {wallet.balance} )} + {isAuthenticated && ( + + )} {isAuthenticated ? ( open && setMenuOpenings((count) => count + 1)}> @@ -154,6 +182,9 @@ export default function AppHeader({isAuthenticated, onLogout}) { {t('NAV.POINTS')} + + {t('NAV.INBOX')} + {t('NAV.WHATS_NEW')} diff --git a/src/components/AppHeader.points.test.jsx b/src/components/AppHeader.points.test.jsx index 7332875..b83e761 100644 --- a/src/components/AppHeader.points.test.jsx +++ b/src/components/AppHeader.points.test.jsx @@ -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(<> {}}/>); diff --git a/src/components/AppHeader.test.jsx b/src/components/AppHeader.test.jsx index 3abfa31..7799129 100644 --- a/src/components/AppHeader.test.jsx +++ b/src/components/AppHeader.test.jsx @@ -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( {}}/>); }; @@ -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})}); diff --git a/src/components/FollowsCard.jsx b/src/components/FollowsCard.jsx new file mode 100644 index 0000000..5c79e70 --- /dev/null +++ b/src/components/FollowsCard.jsx @@ -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) => ( + {displayName} + ); + + return ( + + + {t('FOLLOW.TITLE')} + + + {failed &&

{t('FOLLOW.LOAD_ERROR')}

} + {follows && ( + <> +
+

{t('FOLLOW.FOLLOWING')}

+ {follows.following.length === 0 &&

{t('FOLLOW.NO_FOLLOWING')}

} +
    + {follows.following.map((entry) => ( +
  • + {link(entry.displayName)} + +
  • + ))} +
+
+
+

{t('FOLLOW.FOLLOWERS')}

+ {follows.followers.length === 0 &&

{t('FOLLOW.NO_FOLLOWERS')}

} +
    + {follows.followers.map((entry) => ( +
  • + {entry.displayName + ? link(entry.displayName) + : {t('FOLLOW.PRIVATE_USER')}} + +
  • + ))} +
+
+ + )} +
+
+ ); +} diff --git a/src/components/FollowsCard.test.jsx b/src/components/FollowsCard.test.jsx new file mode 100644 index 0000000..4f11329 --- /dev/null +++ b/src/components/FollowsCard.test.jsx @@ -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(); + + 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(); + + 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(); + + 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(); + + expect(await screen.findByText('FOLLOW.LOAD_ERROR')).toBeTruthy(); + }); +}); diff --git a/src/components/people/FollowButton.jsx b/src/components/people/FollowButton.jsx new file mode 100644 index 0000000..af2c9d6 --- /dev/null +++ b/src/components/people/FollowButton.jsx @@ -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

{t('FOLLOW.NOT_FOLLOWABLE')}

; + if (following === null) return null; + const Icon = following ? UserMinus : UserPlus; + return ( + + ); +} diff --git a/src/components/people/FollowButton.test.jsx b/src/components/people/FollowButton.test.jsx new file mode 100644 index 0000000..53d6eb4 --- /dev/null +++ b/src/components/people/FollowButton.test.jsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await waitFor(() => expect(fetch).toHaveBeenCalled()); + expect(screen.queryByRole('button')).toBeNull(); + }); +}); diff --git a/src/pages/InboxPage.jsx b/src/pages/InboxPage.jsx new file mode 100644 index 0000000..f93de20 --- /dev/null +++ b/src/pages/InboxPage.jsx @@ -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 ( + + + + {t('INBOX.TITLE')} + + + {status === 'loading' && } + {status === 'error' &&

{t('INBOX.LOAD_ERROR')}

} + {status === 'ready' && notes.length === 0 &&

{t('INBOX.EMPTY')}

} + {status === 'ready' && notes.length > 0 && ( +
    + {notes.map((note) => ( +
  • +

    + {formatDate(note.createdAt, i18n.language, {dateStyle: 'medium'})} +

    +

    {text(note)}

    +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/pages/InboxPage.test.jsx b/src/pages/InboxPage.test.jsx new file mode 100644 index 0000000..2c6ea58 --- /dev/null +++ b/src/pages/InboxPage.test.jsx @@ -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(); + + 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(); + + expect(await screen.findByText('INBOX.VISIBILITY')).toBeTruthy(); + }); + + it('saysSoWhenThereAreNoNotesAndMarksNothing', async () => { + fetchRoutes({'GET /api/inbox': json(200, {results: [], unread: 0})}); + renderWithProviders(); + + expect(await screen.findByText('INBOX.EMPTY')).toBeTruthy(); + expect(putCalls()).toEqual([]); + }); + + it('showsAnErrorAndMarksNothingWhenTheLoadFails', async () => { + fetchRoutes({'GET /api/inbox': json(500, {})}); + renderWithProviders(); + + expect(await screen.findByText('INBOX.LOAD_ERROR')).toBeTruthy(); + expect(putCalls()).toEqual([]); + }); +}); diff --git a/src/pages/PublicProfilePage.jsx b/src/pages/PublicProfilePage.jsx index 4178d95..50801e9 100644 --- a/src/pages/PublicProfilePage.jsx +++ b/src/pages/PublicProfilePage.jsx @@ -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() {

{t('PROFILE.MEMBER_SINCE', {date: memberSince})}

)} + {viewer && !owner && } {owner && !editing && (