From 5be0d42bca26e2d48fb9de10f0f061ca32873002 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 14 Oct 2026 16:23:11 +0200 Subject: [PATCH] feat(follow): add feed links, following page and tile (#52, #51) Feed links go to the game and to the profile view of that game. Add /following page, nav item and an opt-in Following tile. Games not in your library show a catalog view with an add button. --- README.md | 10 +++- public/i18n/de.json | 16 +++++-- public/i18n/en.json | 16 +++++-- src/App.jsx | 2 + src/components/AddGamePanel.jsx | 13 +++-- src/components/AppHeader.jsx | 3 +- src/components/AppHeader.test.jsx | 6 +++ src/components/CatalogGameCard.jsx | 63 +++++++++++++++++++++++++ src/components/FollowingCard.jsx | 36 ++++++++++++++ src/components/FollowingCard.test.jsx | 32 +++++++++++++ src/components/FollowsCard.jsx | 23 +-------- src/components/ProfileGameCard.jsx | 63 +++++++++++++++++++++++++ src/components/people/FollowingList.jsx | 47 ++++++++++++++++++ src/hooks/useFollowing.js | 32 +++++++++++++ src/pages/Dashboard.jsx | 4 +- src/pages/Dashboard.test.jsx | 16 +++++++ src/pages/FollowingPage.jsx | 30 ++++++++++++ src/pages/FollowingPage.test.jsx | 60 +++++++++++++++++++++++ src/pages/GameDetail.jsx | 16 ++++--- src/pages/GameDetail.test.jsx | 58 +++++++++++++++++++++++ src/pages/InboxPage.jsx | 60 +++++++++++++++-------- src/pages/InboxPage.test.jsx | 54 +++++++++++++++++++++ src/pages/PublicProfilePage.jsx | 7 ++- src/pages/PublicProfilePage.test.jsx | 33 +++++++++++++ src/utils/catalog.js | 7 +++ src/utils/follows.js | 5 +- src/utils/inbox.js | 13 +++-- src/utils/inbox.test.js | 6 +++ src/utils/people.js | 19 ++++++++ src/utils/tiles.js | 3 ++ src/utils/tiles.test.js | 6 +++ 31 files changed, 692 insertions(+), 67 deletions(-) create mode 100644 src/components/CatalogGameCard.jsx create mode 100644 src/components/FollowingCard.jsx create mode 100644 src/components/FollowingCard.test.jsx create mode 100644 src/components/ProfileGameCard.jsx create mode 100644 src/components/people/FollowingList.jsx create mode 100644 src/hooks/useFollowing.js create mode 100644 src/pages/FollowingPage.jsx create mode 100644 src/pages/FollowingPage.test.jsx create mode 100644 src/utils/catalog.js diff --git a/README.md b/README.md index c524d14..a363f95 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ To Be Played is split into four repositories: ## Features - **Dashboard** with an edit mode ("Customize"): the user orders, hides and resizes the tiles (small = half width - and less content, large = double width and more), and can add the stat groups of "Your stats" as tiles. Stored + and less content, large = double width and more), and can add the stat groups of "Your stats" and a "Following" tile. Stored in Auth, so it follows the user to the phone; "Reset" goes back to the default. Tiles: most played games and games in progress (one call, `GET /api/library/dashboard-games`), "Pile of Shame" with backlog hours, its trend of the last 90 days ("this month: +N added, -N off the pile") and a random suggestion, Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked @@ -185,6 +185,14 @@ flowchart LR who goes private ends the follow with a note. Two more notes come from the user's own PlayStation link (`PSN_EXPIRING` about 2 days before it ends, `PSN_EXPIRED` when it has ended), with a link to the settings. Notes older than 90 days are deleted. + The feed links on (#52): the person's name goes to their profile while it is public (`profileName`), a game goes to + your own game page (`/library/`), and "on their profile" opens `/u/?game=`, which shows that + game above the tiles (`ProfileGameCard`: status, playtime, achievements, only what the profile shares; one message + when it hides or does not have the game). A game you do not own opens a catalog view (`CatalogGameCard`, `GET + /api/library/games//preview`) with an "Add to my library" button that opens the add search with the name filled + in. `/following` (nav item "Following", #51) lists the people you follow, newest feed message first, with an initial + letter instead of an avatar, and "Unfollow"; the "Following" dashboard tile (opt-in via "Customize") shows the first + 3, 5 or 10 of them and loads its own data. - **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 9bec86f..8a2d51b 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -381,6 +381,7 @@ "LIGHT_MODE": "Helles Design", "PLAY_NEXT": "Nächstes Spiel", "STATS": "Statistik", + "FOLLOWING": "Folge ich", "COMMUNITY": "Community", "POINTS": "Pile Points", "ADMIN": "Admin", @@ -466,7 +467,9 @@ "ERROR_LOAD": "Profil konnte nicht geladen werden.", "TO_START": "Zur Startseite", "PERFECT_GAMES": "Perfekte Spiele", - "AVERAGE_COMPLETION": "Ø Fortschritt" + "AVERAGE_COMPLETION": "Ø Fortschritt", + "GAME_NOT_SHOWN": "Dieses Spiel wird in diesem Profil nicht angezeigt.", + "GAME_ACHIEVEMENTS": "{{unlocked}} / {{total}} ({{percent}} %)" }, "PLAY_NEXT": { "TITLE": "Was spiele ich als Nächstes?", @@ -846,7 +849,9 @@ "LOAD_ERROR": "Der Spielekatalog konnte nicht geladen werden.", "CORRECT": "Korrigieren", "KEY": "ID {{appid}}", - "GONE": "Dieses Spiel gibt es nicht mehr." + "GONE": "Dieses Spiel gibt es nicht mehr.", + "NOT_IN_LIBRARY": "Noch nicht in deiner Bibliothek.", + "ADD": "Zu meiner Bibliothek hinzufügen" }, "AUDIT": { "TITLE": "Protokoll", @@ -951,13 +956,18 @@ "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." + "LOAD_ERROR": "Die Liste konnte nicht geladen werden.", + "LAST_ACTIVITY": "Letzte Aktivität {{date}}", + "NO_ACTIVITY": "Noch keine Aktivität", + "SHOW_ALL": "Alle anzeigen" }, "INBOX": { "TITLE": "Postfach", "EMPTY": "Noch keine Nachrichten.", "LOAD_ERROR": "Das Postfach konnte nicht geladen werden.", "DIGEST": "{{name}}: {{parts}}", + "DIGEST_PARTS": "{{parts}}", + "ON_PROFILE": "im Profil", "VISIBILITY": "{{name}} hat die Sichtbarkeit des Profils geändert, du folgst nicht mehr.", "PSN_EXPIRING": "Deine PlayStation-Verknüpfung endet in etwa 2 Tagen. Füge in den Einstellungen einen neuen NPSSO-Code ein, damit der Sync weiterläuft.", "PSN_EXPIRED": "Deine PlayStation-Verknüpfung ist beendet. Der Sync überspringt PlayStation, bis du einen neuen NPSSO-Code einfügst.", diff --git a/public/i18n/en.json b/public/i18n/en.json index 8f59286..4469d3e 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -381,6 +381,7 @@ "LIGHT_MODE": "Light mode", "PLAY_NEXT": "Play next", "STATS": "Stats", + "FOLLOWING": "Following", "COMMUNITY": "Community", "POINTS": "Pile Points", "ADMIN": "Admin", @@ -466,7 +467,9 @@ "ERROR_LOAD": "Could not load the profile.", "TO_START": "Go to the home page", "PERFECT_GAMES": "Perfect games", - "AVERAGE_COMPLETION": "Avg. completion" + "AVERAGE_COMPLETION": "Avg. completion", + "GAME_NOT_SHOWN": "This game is not shown on this profile.", + "GAME_ACHIEVEMENTS": "{{unlocked}} / {{total}} ({{percent}}%)" }, "PLAY_NEXT": { "TITLE": "What to play next", @@ -846,7 +849,9 @@ "LOAD_ERROR": "The game catalog could not be loaded.", "CORRECT": "Correct", "KEY": "ID {{appid}}", - "GONE": "This game no longer exists." + "GONE": "This game no longer exists.", + "NOT_IN_LIBRARY": "Not in your library yet.", + "ADD": "Add to my library" }, "AUDIT": { "TITLE": "Audit log", @@ -951,13 +956,18 @@ "NOT_FOLLOWABLE": "This profile cannot be followed.", "LIMIT": "You already follow the maximum number of people.", "ERROR": "That did not work. Try again.", - "LOAD_ERROR": "The list could not be loaded." + "LOAD_ERROR": "The list could not be loaded.", + "LAST_ACTIVITY": "Last activity {{date}}", + "NO_ACTIVITY": "No activity yet", + "SHOW_ALL": "Show all" }, "INBOX": { "TITLE": "Inbox", "EMPTY": "No messages yet.", "LOAD_ERROR": "The inbox could not be loaded.", "DIGEST": "{{name}}: {{parts}}", + "DIGEST_PARTS": "{{parts}}", + "ON_PROFILE": "on their profile", "VISIBILITY": "{{name}} changed their profile visibility, so you no longer follow them.", "PSN_EXPIRING": "Your PlayStation link ends in about 2 days. Paste a new NPSSO code in the settings to keep the sync running.", "PSN_EXPIRED": "Your PlayStation link has ended. The sync skips PlayStation until you paste a new NPSSO code.", diff --git a/src/App.jsx b/src/App.jsx index 3e924b0..5e8b30f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -14,6 +14,7 @@ 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 FollowingPage from './pages/FollowingPage.jsx'; import QuestionsPage from './pages/QuestionsPage.jsx'; import WhatsNewPage from './pages/WhatsNewPage.jsx'; import WelcomePage from './pages/WelcomePage.jsx'; @@ -89,6 +90,7 @@ function App() { }/> }/> }/> + }/> }/> }/> }/> diff --git a/src/components/AddGamePanel.jsx b/src/components/AddGamePanel.jsx index 3ab783e..48d07f9 100644 --- a/src/components/AddGamePanel.jsx +++ b/src/components/AddGamePanel.jsx @@ -22,11 +22,14 @@ import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; /** * Search IGDB and add a game the user owns outside Steam, e.g. on PlayStation or Switch; or check several and review * them on the import page. + * + * @param onAdded called with the new appid before the navigation, for a page that shows the same address + * @param initialQuery the search text to start with, e.g. the name of a game seen elsewhere */ -export default function AddGamePanel({onClose}) { +export default function AddGamePanel({onClose, onAdded, initialQuery = ''}) { const {t} = useTranslation(); const navigate = useNavigate(); - const [query, setQuery] = useState(''); + const [query, setQuery] = useState(initialQuery); const [picked, setPicked] = useState(null); // igdbId -> hit, over several searches. const [selected, setSelected] = useState(() => new Map()); @@ -62,7 +65,8 @@ export default function AddGamePanel({onClose}) { {picked ? ( - setPicked(null)} navigate={navigate}/> + setPicked(null)} navigate={navigate} + onAdded={onAdded}/> ) : ( <>

{t('MANUAL_GAME.ADD_HINT')}

@@ -82,7 +86,7 @@ export default function AddGamePanel({onClose}) { ); } -function AddForm({hit, favorite, onBack, navigate}) { +function AddForm({hit, favorite, onBack, navigate, onAdded}) { const {t} = useTranslation(); const [platform, setPlatform] = useState(() => preselectPlatform(null, hit.platforms, favorite)); // The favorite can arrive after the pick; it fills only a platform the user has not picked yet. @@ -113,6 +117,7 @@ function AddForm({hit, favorite, onBack, navigate}) { const {appid} = await response.json(); toast.success(t('MANUAL_GAME.ADDED', {name: hit.name})); check(appid, {status, tell: true}); + onAdded?.(appid); navigate(`/library/${appid}`); } catch (err) { console.error('add game error:', err); diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index a8f917a..0ec47dd 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 {Bell, ChartColumn, CircleQuestionMark, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun, Users} from 'lucide-react'; +import {Bell, ChartColumn, CircleQuestionMark, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun, UserCheck, Users} from 'lucide-react'; import {toast} from 'sonner'; import {Badge} from '@/components/ui/badge'; @@ -37,6 +37,7 @@ const NAV_ITEMS = [ {to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices}, {to: '/questions', labelKey: 'NAV.QUESTIONS', Icon: CircleQuestionMark}, {to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn}, + {to: '/following', labelKey: 'NAV.FOLLOWING', Icon: UserCheck}, {to: '/', labelKey: 'NAV.COMMUNITY', Icon: Users, iconOnly: true}, ]; diff --git a/src/components/AppHeader.test.jsx b/src/components/AppHeader.test.jsx index 9040652..51c2da9 100644 --- a/src/components/AppHeader.test.jsx +++ b/src/components/AppHeader.test.jsx @@ -49,6 +49,12 @@ describe('AppHeader', () => { expect(screen.queryByTitle('NAV.COMMUNITY')).toBeNull(); }); + it('links to the following page when logged in', () => { + loggedInAs({role: 'USER', activeRole: 'USER'}); + + expect(screen.getByTitle('NAV.FOLLOWING').getAttribute('href')).toBe('/following'); + }); + it('links to the community stats when logged in', () => { loggedInAs({role: 'USER', activeRole: 'USER'}); diff --git a/src/components/CatalogGameCard.jsx b/src/components/CatalogGameCard.jsx new file mode 100644 index 0000000..b12d2a0 --- /dev/null +++ b/src/components/CatalogGameCard.jsx @@ -0,0 +1,63 @@ +import {useEffect, useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {Plus} from 'lucide-react'; + +import {Button} from '@/components/ui/button'; +import {Card} from '@/components/ui/card'; +import {Skeleton} from '@/components/ui/skeleton'; +import AddGamePanel from './AddGamePanel.jsx'; +import FitText from './FitText.jsx'; +import GameCover from './GameCover.jsx'; +import TimeToBeat from './TimeToBeat.jsx'; +import {fetchPreview} from '../utils/catalog.js'; + +/** + * A game the user does not have: its catalog data and a way to add it. The add flow is the search of the library, + * started with the game's name; adding ends on the user's own game page. + * + * @param onAdded called after the game was added, so the page can load the user's own game + * @param fallback shown when the catalog does not know the game or the request fails + */ +export default function CatalogGameCard({appid, fallback, onAdded}) { + const {t} = useTranslation(); + const [preview, setPreview] = useState(null); + const [failed, setFailed] = useState(false); + const [adding, setAdding] = useState(false); + + useEffect(() => { + let cancelled = false; + setPreview(null); + setFailed(false); + setAdding(false); + (async () => { + const {ok, data} = await fetchPreview(appid); + if (cancelled) return; + if (ok && data) setPreview(data); + else setFailed(true); + })(); + return () => { + cancelled = true; + }; + }, [appid]); + + if (failed) return fallback; + if (!preview) return ; + + return ( +
+ + +
+

+ {preview.genres?.length > 0 && ( +

{preview.genres.join(', ')}

+ )} + +

{t('CATALOG.NOT_IN_LIBRARY')}

+ {!adding && } +
+
+ {adding && setAdding(false)}/>} +
+ ); +} diff --git a/src/components/FollowingCard.jsx b/src/components/FollowingCard.jsx new file mode 100644 index 0000000..657f8a6 --- /dev/null +++ b/src/components/FollowingCard.jsx @@ -0,0 +1,36 @@ +import {Link} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import {ArrowRight, UserCheck} from 'lucide-react'; + +import {Button} from '@/components/ui/button'; +import {Card} from '@/components/ui/card'; +import FollowingList from './people/FollowingList.jsx'; +import useFollowing from '../hooks/useFollowing.js'; + +/** + * Dashboard tile: the first people of the following list. Loads its own data, so a dashboard without the tile pays nothing. + * + * @param limit how many people the tile size shows + */ +export default function FollowingCard({limit}) { + const {t} = useTranslation(); + const {following, failed, unfollow} = useFollowing(); + + return ( + +
+

+ + {t('FOLLOW.TITLE')} +

+ +
+
+ {failed &&

{t('FOLLOW.LOAD_ERROR')}

} + {following && } +
+
+ ); +} diff --git a/src/components/FollowingCard.test.jsx b/src/components/FollowingCard.test.jsx new file mode 100644 index 0000000..9380e5e --- /dev/null +++ b/src/components/FollowingCard.test.jsx @@ -0,0 +1,32 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, screen} from '@testing-library/react'; +import FollowingCard from './FollowingCard.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); + +describe('FollowingCard', () => { + it('showsAtMostTheLimitOfPeopleAndLinksToTheFollowingPage', async () => { + const following = ['A', 'B', 'C', 'D'].map((displayName, i) => ({id: i, displayName, lastActivityAt: null})); + fetchRoutes({'GET /api/follows': json(200, {followers: [], following})}); + renderWithProviders(); + + await screen.findByRole('link', {name: 'A'}); + + expect(screen.queryByRole('link', {name: 'D'})).toBeNull(); + expect(screen.getAllByRole('button', {name: 'FOLLOW.UNFOLLOW'})).toHaveLength(3); + expect(screen.getByRole('link', {name: /FOLLOW.SHOW_ALL/}).getAttribute('href')).toBe('/following'); + }); + + it('showsTheEmptyStateWhenNobodyIsFollowed', async () => { + fetchRoutes({'GET /api/follows': json(200, {followers: [], following: []})}); + renderWithProviders(); + + expect(await screen.findByText('FOLLOW.NO_FOLLOWING')).toBeTruthy(); + }); +}); diff --git a/src/components/FollowsCard.jsx b/src/components/FollowsCard.jsx index 5c79e70..3a996ee 100644 --- a/src/components/FollowsCard.jsx +++ b/src/components/FollowsCard.jsx @@ -1,34 +1,15 @@ -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 useFollowing from '../hooks/useFollowing.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 {follows, failed, unfollow: end} = useFollowing(); const link = (displayName) => ( {displayName} diff --git a/src/components/ProfileGameCard.jsx b/src/components/ProfileGameCard.jsx new file mode 100644 index 0000000..85de30d --- /dev/null +++ b/src/components/ProfileGameCard.jsx @@ -0,0 +1,63 @@ +import {useEffect, useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {Trophy} from 'lucide-react'; + +import {Card} from '@/components/ui/card'; +import FitText from './FitText.jsx'; +import GameCover from './GameCover.jsx'; +import GameStatusBadge from './GameStatusBadge.jsx'; +import {formatPlaytime} from '../utils/games.js'; +import {fetchProfileGame} from '../utils/people.js'; + +/** + * One game of a public profile, above its tiles. Shows what the profile shares of it; a game the user hides or does + * not have gets one message. + */ +export default function ProfileGameCard({ticket, appid}) { + const {t, i18n} = useTranslation(); + const [game, setGame] = useState(null); + const [missing, setMissing] = useState(false); + + useEffect(() => { + let cancelled = false; + setGame(null); + setMissing(false); + (async () => { + const {ok, data} = await fetchProfileGame(ticket, appid); + if (cancelled) return; + if (ok) setGame(data); + else setMissing(true); + })(); + return () => { + cancelled = true; + }; + }, [ticket, appid]); + + if (missing) { + return {t('PROFILE.GAME_NOT_SHOWN')}; + } + if (!game) return null; + + return ( + + +
+
+
+ {game.status && } + {game.playtimeMinutes != null && {formatPlaytime(game.playtimeMinutes)}} + {game.achievementsTotal != null && ( + + + {t('PROFILE.GAME_ACHIEVEMENTS', { + unlocked: game.achievementsUnlocked, + total: game.achievementsTotal, + percent: game.completionPercent.toLocaleString(i18n.language), + })} + + )} +
+
+
+ ); +} diff --git a/src/components/people/FollowingList.jsx b/src/components/people/FollowingList.jsx new file mode 100644 index 0000000..b600147 --- /dev/null +++ b/src/components/people/FollowingList.jsx @@ -0,0 +1,47 @@ +import {Link} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; + +import {Button} from '@/components/ui/button'; +import {cn} from '@/lib/utils'; +import {formatDate} from '../../utils/format.js'; +import {profileHref} from '../../utils/people.js'; + +// Newest activity first, people without any last; Core's name order stays within a tie. +const byActivity = (a, b) => (b.lastActivityAt ? Date.parse(b.lastActivityAt) : -Infinity) + - (a.lastActivityAt ? Date.parse(a.lastActivityAt) : -Infinity) || 0; + +/** + * The people the user follows, with the time of their newest feed message and an unfollow button. + * + * @param entries `following` of GET /api/follows + * @param limit shows only the first `limit` after the sort + * @param onUnfollow called with the follow row id + * @param compact tighter rows for a tile + */ +export default function FollowingList({entries, limit, onUnfollow, compact}) { + const {t, i18n} = useTranslation(); + if (entries.length === 0) return

{t('FOLLOW.NO_FOLLOWING')}

; + const shown = [...entries].sort(byActivity).slice(0, limit); + return ( +
    + {shown.map((entry) => ( +
  • + +
    + {entry.displayName} +

    + {entry.lastActivityAt + ? t('FOLLOW.LAST_ACTIVITY', {date: formatDate(entry.lastActivityAt, i18n.language, {dateStyle: 'medium'})}) + : t('FOLLOW.NO_ACTIVITY')} +

    +
    + +
  • + ))} +
+ ); +} diff --git a/src/hooks/useFollowing.js b/src/hooks/useFollowing.js new file mode 100644 index 0000000..a235127 --- /dev/null +++ b/src/hooks/useFollowing.js @@ -0,0 +1,32 @@ +import {useCallback, useEffect, useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {toast} from 'sonner'; + +import {fetchFollows, removeFollow} from '../utils/follows.js'; + +/** + * The follows answer (`following` and `followers`), and ending a follow with a reload. `follows` and `following` are null until loaded. + */ +export default function useFollowing() { + 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 unfollow = async (id) => { + const {ok, status} = await removeFollow(id); + if (!ok && status !== 401) toast.error(t('FOLLOW.ERROR')); + await load(); + }; + + return {follows, following: follows?.following ?? null, failed, unfollow}; +} diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 7134f91..4f383eb 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -9,6 +9,7 @@ import {Skeleton} from '@/components/ui/skeleton'; import AchievementsCard from '../components/AchievementsCard.jsx'; import ActivityCard from '../components/ActivityCard.jsx'; import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx'; +import FollowingCard from '../components/FollowingCard.jsx'; import DashboardHint from '../components/DashboardHint.jsx'; import DashboardWelcomeCard from '../components/DashboardWelcomeCard.jsx'; import MostPlayedCard from '../components/MostPlayedCard.jsx'; @@ -30,7 +31,7 @@ import TileGrid from '../components/TileGrid.jsx'; import {StatDrillProvider} from '../components/stats/StatDrill.jsx'; import statGroupTile from '../components/stats/statGroupTile.jsx'; import { - DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES, UP_NEXT_GAMES, + DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, FOLLOWING_SHOWN, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES, UP_NEXT_GAMES, } from '../utils/tiles.js'; import {usePreferences} from '../context/PreferencesContext.jsx'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; @@ -134,6 +135,7 @@ const Dashboard = () => { ); case 'ACTIVITY': return canSync && activity && ; case 'ACHIEVEMENTS': return canSync && achievements && ; + case 'FOLLOWING': return ; default: return statGroupTile(id, stats.stats, true, charts, change); } }; diff --git a/src/pages/Dashboard.test.jsx b/src/pages/Dashboard.test.jsx index ed6caa0..ebbb089 100644 --- a/src/pages/Dashboard.test.jsx +++ b/src/pages/Dashboard.test.jsx @@ -72,6 +72,22 @@ describe('Dashboard', () => { return renderDashboard().container; }; + it('showsTheFollowingTileFromTheSavedLayoutAndHidesItByDefault', async () => { + const routes = dashboardRoutes({dashboardTiles: [{id: 'FOLLOWING', size: 'M'}]}); + fetchRoutes({...routes, 'GET /api/follows': json(200, {followers: [], + following: [{id: 1, displayName: 'Anna', lastActivityAt: null}]})}); + renderDashboard(); + + expect(await screen.findByRole('link', {name: 'Anna'})).toBeTruthy(); + cleanup(); + + fetchRoutes(dashboardRoutes()); + renderDashboard(); + await screen.findByText('DASHBOARD.CURRENTLY_PLAYING'); + + expect(fetch.mock.calls.some(([url]) => String(url).includes('/api/follows'))).toBe(false); + }); + it('shows the Steam avatar and the most played game of the last two weeks as banner', async () => { const container = mockDashboard(); diff --git a/src/pages/FollowingPage.jsx b/src/pages/FollowingPage.jsx new file mode 100644 index 0000000..cc19308 --- /dev/null +++ b/src/pages/FollowingPage.jsx @@ -0,0 +1,30 @@ +import {useTranslation} from 'react-i18next'; +import {Loader2, UserCheck} from 'lucide-react'; + +import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; +import FollowingList from '../components/people/FollowingList.jsx'; +import PageContainer from '../components/PageContainer.jsx'; +import useFollowing from '../hooks/useFollowing.js'; + +/** + * The people the user follows, newest activity first. The followers stay in the settings. + */ +export default function FollowingPage() { + const {t} = useTranslation(); + const {following, failed, unfollow} = useFollowing(); + + return ( + + + + {t('FOLLOW.TITLE')} + + + {failed &&

{t('FOLLOW.LOAD_ERROR')}

} + {!failed && !following && } + {following && } +
+
+
+ ); +} diff --git a/src/pages/FollowingPage.test.jsx b/src/pages/FollowingPage.test.jsx new file mode 100644 index 0000000..bfdf1b8 --- /dev/null +++ b/src/pages/FollowingPage.test.jsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react'; +import FollowingPage from './FollowingPage.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); + +const person = (id, displayName, lastActivityAt) => ({id, displayName, lastActivityAt}); + +describe('FollowingPage', () => { + it('listsThePeopleByLastActivityNewestFirstAndThoseWithoutActivityLast', async () => { + fetchRoutes({'GET /api/follows': json(200, {followers: [], following: [ + person(1, 'Anna', null), person(2, 'Bert', '2026-10-01T10:00:00Z'), person(3, 'Cleo', '2026-10-09T10:00:00Z')]})}); + renderWithProviders(); + + await screen.findByRole('link', {name: 'Cleo'}); + + expect(screen.getAllByRole('link').map((link) => link.textContent)).toEqual(['Cleo', 'Bert', 'Anna']); + expect(screen.getByRole('link', {name: 'Cleo'}).getAttribute('href')).toBe('/u/Cleo'); + expect(screen.getAllByText('FOLLOW.LAST_ACTIVITY')).toHaveLength(2); + expect(screen.getByText('FOLLOW.NO_ACTIVITY')).toBeTruthy(); + }); + + it('unfollowsByTheFollowRowAndLoadsTheListAgain', async () => { + let list = [person(5, 'Anna', null), person(6, 'Bert', null)]; + const mock = fetchRoutes({ + 'GET /api/follows': () => json(200, {followers: [], following: list}), + 'DELETE /api/follows/5': () => { + list = [list[1]]; + return json(204); + }, + }); + renderWithProviders(); + + fireEvent.click((await screen.findAllByRole('button', {name: 'FOLLOW.UNFOLLOW'}))[0]); + + await waitFor(() => expect(screen.queryByRole('link', {name: 'Anna'})).toBeNull()); + expect(screen.getByRole('link', {name: 'Bert'})).toBeTruthy(); + expect(mock.mock.calls.filter(([, init]) => init?.method === 'DELETE')).toHaveLength(1); + }); + + it('saysHowToFollowWhenThereIsNobody', async () => { + fetchRoutes({'GET /api/follows': json(200, {followers: [], following: []})}); + renderWithProviders(); + + expect(await screen.findByText('FOLLOW.NO_FOLLOWING')).toBeTruthy(); + }); + + it('showsAnErrorWhenTheListFails', async () => { + fetchRoutes({'GET /api/follows': json(500, {})}); + renderWithProviders(); + + expect(await screen.findByText('FOLLOW.LOAD_ERROR')).toBeTruthy(); + }); +}); diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index da7f5f0..dbb01a0 100644 --- a/src/pages/GameDetail.jsx +++ b/src/pages/GameDetail.jsx @@ -7,6 +7,7 @@ import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; import {Skeleton} from '@/components/ui/skeleton'; import BacklogCard from '../components/BacklogCard.jsx'; +import CatalogGameCard from '../components/CatalogGameCard.jsx'; import Confetti from '../components/Confetti.jsx'; import GameAchievements from '../components/GameAchievements.jsx'; import GameHeroCard from '../components/GameHeroCard.jsx'; @@ -57,6 +58,12 @@ export default function GameDetail() { }, [data, appid, navigate]); const manual = !!data && data.source !== 'STEAM'; + const errorBlock = error && ( +
+

{t(error)}

+ +
+ ); return ( @@ -71,12 +78,9 @@ export default function GameDetail() { )} - {error && !isLoading && ( -
-

{t(error)}

- -
- )} + {/* Not in the library: the catalog view, which falls back to the same message. */} + {error && !isLoading && (error === 'GAME_DETAIL.NOT_FOUND' + ? : errorBlock)} {!error && data && ( <> diff --git a/src/pages/GameDetail.test.jsx b/src/pages/GameDetail.test.jsx index fd436fb..2b4338c 100644 --- a/src/pages/GameDetail.test.jsx +++ b/src/pages/GameDetail.test.jsx @@ -514,6 +514,64 @@ describe('GameDetail session', () => { expect(screen.queryByText('GAME_DETAIL.ERROR_LOAD')).toBeNull(); }); + describe('a game that is not in the library', () => { + const preview = {appid: 501, name: 'Hades', imageUrl: null, genres: ['Roguelike'], + timeToBeat: {hastilyMinutes: 600, normallyMinutes: 1200, completelyMinutes: null}}; + const notOwned = (previewAnswer) => fetchRoutes({ + 'GET /api/users/me': json(200, {settings: {}}), + 'GET /api/library/games/501': json(404, {}), + 'GET /api/library/games/501/preview': previewAnswer, + 'GET /api/library/game-search': json(200, {results: []}), + }); + + it('showsTheCatalogDataAndOffersToAddTheGame', async () => { + notOwned(json(200, preview)); + renderDetail(501); + + expect(await screen.findByText('Hades')).toBeTruthy(); + expect(screen.getByText('Roguelike')).toBeTruthy(); + expect(screen.getByText('CATALOG.NOT_IN_LIBRARY')).toBeTruthy(); + expect(screen.queryByText('GAME_DETAIL.NOT_FOUND')).toBeNull(); + + fireEvent.click(screen.getByRole('button', {name: 'CATALOG.ADD'})); + + expect(screen.getByRole('searchbox').value).toBe('Hades'); + }); + + it('showsMyGamePageAfterAddingTheGameUnderTheSameKey', async () => { + let owned = false; + fetchRoutes({ + 'GET /api/users/me': json(200, {settings: {favoritePlatform: 'PS5'}}), + 'GET /api/library/games/501': () => (owned ? json(200, {...game, appid: 501, name: 'Hades'}) : json(404, {})), + 'GET /api/library/games/501/preview': json(200, preview), + 'GET /api/library/game-search': json(200, {available: true, results: [ + {appid: 501, igdbId: 7, name: 'Hades', year: 2020, imageUrl: null, platforms: ['PS5'], inLibrary: false}]}), + 'POST /api/library/games': () => { + owned = true; + return json(201, {appid: 501}); + }, + 'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: true, page: 0, totalPages: 0}), + // The points check gives up on a failed answer; the sonner mock of this file has no plain toast(). + 'GET /api/rewards/games/501': json(500, {}), + }); + renderDetail(501); + fireEvent.click(await screen.findByRole('button', {name: 'CATALOG.ADD'})); + fireEvent.click(await screen.findByRole('button', {name: /Hades/})); + fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.ADD'})); + + await waitFor(() => expect(fetch.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(true)); + await waitFor(() => expect(screen.queryByText('CATALOG.NOT_IN_LIBRARY')).toBeNull()); + }); + + it('showsTheNotFoundMessageWhenTheCatalogKnowsNoSuchGame', async () => { + notOwned(json(404, {})); + renderDetail(501); + + expect(await screen.findByText('GAME_DETAIL.NOT_FOUND')).toBeTruthy(); + expect(screen.queryByRole('button', {name: 'CATALOG.ADD'})).toBeNull(); + }); + }); + it('replacesTheUrlOfAMergedKeyWithTheKeptGame', async () => { fetchRoutes({ 'GET /api/users/me': json(200, {settings: {}}), diff --git a/src/pages/InboxPage.jsx b/src/pages/InboxPage.jsx index 1fa99b7..b9f5558 100644 --- a/src/pages/InboxPage.jsx +++ b/src/pages/InboxPage.jsx @@ -7,6 +7,7 @@ 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 {profileGameHref, profileHref} from '../utils/people.js'; import {digestParts, fetchInbox, fetchInboxDetails, groupDetails, markInboxRead} from '../utils/inbox.js'; const PSN_KINDS = ['PSN_EXPIRING', 'PSN_EXPIRED']; @@ -43,8 +44,25 @@ export default function InboxPage() { if (PSN_KINDS.includes(note.kind)) return t(`INBOX.${note.kind}`); 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.DIGEST', {name, parts: partsText(note)}); + }; + + const partsText = (note) => digestParts(note).map(({key, count}) => t(`INBOX.PART_${key}`, {count})).join(', '); + + // A link in a button is invalid, so the profile link sits next to the toggle button. + const noteLine = (note) => { + const linked = note.kind === 'DIGEST' && note.profileName; + const label = linked ? t('INBOX.DIGEST_PARTS', {parts: partsText(note)}) : text(note); + const line = note.kind === 'DIGEST' && note.details ? ( + + ) : label; + if (!linked) return line; + return <>{note.displayName}: {line}; }; const toggle = async (id) => { @@ -59,20 +77,31 @@ export default function InboxPage() { setDetails((current) => ({...current, [id]: ok || status === 404 ? {status: 'ready', results: data?.results ?? []} : {status: 'error', results: []}})); }; - const gameLines = (group, {gameName, items}, noteId) => { + const gameLabel = (note, {gameName, appid}) => ( + <> + {appid == null ? gameName : {gameName}} + {appid != null && note.profileName && ( + {t('INBOX.ON_PROFILE')} + )} + + ); + + const gameLines = (group, gameGroup, note) => { + const {gameName, items} = gameGroup; if (group.kind === 'STATUS') { return items.map((item, i) => (
  • - {gameName}: {t(`BACKLOG.STATUS.${item.fromStatus}`)} → {t(`BACKLOG.STATUS.${item.toStatus}`)} + {gameLabel(note, gameGroup)}: {t(`BACKLOG.STATUS.${item.fromStatus}`)} → {t(`BACKLOG.STATUS.${item.toStatus}`)}
  • )); } - if (group.kind !== 'ACHIEVEMENT') return
  • {gameName}
  • ; - const key = `${noteId}:${gameName}`; + if (group.kind !== 'ACHIEVEMENT') return
  • {gameLabel(note, gameGroup)}
  • ; + const key = `${note.id}:${gameName}`; const shown = showAll[key] ? items : items.slice(0, ACHIEVEMENTS_SHOWN); return (
  • - {gameName} + {gameLabel(note, gameGroup)}
      {shown.map((item, i) =>
    • {item.itemName}
    • )}
    @@ -86,8 +115,8 @@ export default function InboxPage() { ); }; - const detailBlock = (noteId) => { - const entry = details[noteId]; + const detailBlock = (note) => { + const entry = details[note.id]; if (!entry || entry.status === 'loading') return ; if (entry.status === 'error') return

    {t('INBOX.DETAILS_ERROR')}

    ; const groups = groupDetails(entry.results); @@ -95,7 +124,7 @@ export default function InboxPage() { return groups.map((group) => (

    {t(`INBOX.DETAIL_${group.kind}`)}

    -
      {group.games.map((gameGroup) => gameLines(group, gameGroup, noteId))}
    +
      {group.games.map((gameGroup) => gameLines(group, gameGroup, note))}
    )); }; @@ -118,19 +147,12 @@ export default function InboxPage() { {formatDate(note.createdAt, i18n.language, {dateStyle: 'medium'})}

    - {note.kind === 'DIGEST' && note.details ? ( - - ) : text(note)} + {noteLine(note)} {PSN_KINDS.includes(note.kind) && ( <> {t('INBOX.OPEN_SETTINGS')} )}

    - {open === note.id &&
    {detailBlock(note.id)}
    } + {open === note.id &&
    {detailBlock(note)}
    }
  • ))} diff --git a/src/pages/InboxPage.test.jsx b/src/pages/InboxPage.test.jsx index 265d004..7b6ac04 100644 --- a/src/pages/InboxPage.test.jsx +++ b/src/pages/InboxPage.test.jsx @@ -68,6 +68,60 @@ describe('InboxPage', () => { expect(putCalls()).toEqual([]); }); + describe('links', () => { + it('linksTheNameToTheProfileOnlyWhileTheProfileIsPublic', async () => { + fetchRoutes({'GET /api/inbox': json(200, {results: [ + note(1, {newGames: 1, profileName: 'Subject', displayName: 'Subject'}), + note(2, {newGames: 2, displayName: 'Gone', profileName: null})], unread: 0})}); + renderWithProviders(); + + expect((await screen.findByRole('link', {name: 'Subject'})).getAttribute('href')).toBe('/u/Subject'); + expect(screen.queryByRole('link', {name: 'Gone'})).toBeNull(); + expect(screen.getByText('INBOX.DIGEST')).toBeTruthy(); + }); + + it('keepsTheNameLinkOutsideTheButtonThatOpensTheDetails', async () => { + fetchRoutes({'GET /api/inbox': json(200, {results: [ + note(1, {newGames: 1, details: true, profileName: 'Subject', displayName: 'Subject'})], unread: 0})}); + renderWithProviders(); + + const link = await screen.findByRole('link', {name: 'Subject'}); + + expect(link.closest('button')).toBeNull(); + expect(screen.getByRole('button', {name: 'INBOX.DIGEST_PARTS'})).toBeTruthy(); + }); + + const open = async (rows, extra) => { + fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: true, ...extra})], unread: 0}), + 'GET /api/inbox/1/details': json(200, {results: rows})}); + renderWithProviders(); + fireEvent.click(await screen.findByRole('button')); + }; + const game = (extra) => ({kind: 'NEW_GAME', gameName: 'Alpha', itemName: null, fromStatus: null, toStatus: null, ...extra}); + + it('linksTheGameToMyGamePageAndToTheProfileViewOfThatGame', async () => { + await open([game({appid: 501})], {profileName: 'Subject'}); + + expect((await screen.findByRole('link', {name: 'Alpha'})).getAttribute('href')).toBe('/library/501'); + expect(screen.getByRole('link', {name: 'INBOX.ON_PROFILE'}).getAttribute('href')).toBe('/u/Subject?game=501'); + }); + + it('givesNoProfileLinkWithoutAPublicProfile', async () => { + await open([game({appid: 501})], {profileName: null}); + + expect(await screen.findByRole('link', {name: 'Alpha'})).toBeTruthy(); + expect(screen.queryByRole('link', {name: 'INBOX.ON_PROFILE'})).toBeNull(); + }); + + it('leavesARowFromBeforeTheAppidAsPlainText', async () => { + await open([game({})], {profileName: 'Subject'}); + + expect(await screen.findByText('Alpha')).toBeTruthy(); + expect(screen.queryByRole('link', {name: 'Alpha'})).toBeNull(); + expect(screen.queryByRole('link', {name: 'INBOX.ON_PROFILE'})).toBeNull(); + }); + }); + describe('details', () => { const row = (kind, gameName, extra) => ({kind, gameName, itemName: null, fromStatus: null, toStatus: null, ...extra}); const detailCalls = () => fetch.mock.calls.filter(([url]) => String(url).includes('/details')); diff --git a/src/pages/PublicProfilePage.jsx b/src/pages/PublicProfilePage.jsx index 50801e9..92f5f0a 100644 --- a/src/pages/PublicProfilePage.jsx +++ b/src/pages/PublicProfilePage.jsx @@ -1,5 +1,5 @@ import {useEffect, useState} from 'react'; -import {Link, useParams} from 'react-router-dom'; +import {Link, useParams, useSearchParams} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import {CircleCheck, Clock, Gamepad2, LayoutGrid, Library, Percent, Star, Trophy, UserRoundX} from 'lucide-react'; @@ -12,6 +12,7 @@ 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 ProfileGameCard from '../components/ProfileGameCard.jsx'; import {formatDay} from '../utils/format.js'; import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, equippedSkin, lookClass} from '../utils/rewards.js'; import PointsBadge from '../components/PointsBadge.jsx'; @@ -41,6 +42,9 @@ const FINISHED_GAMES = {S: 6, M: 12}; export default function PublicProfilePage() { const {t, i18n} = useTranslation(); const {name} = useParams(); + const [params] = useSearchParams(); + // Only a game key goes into the request address. + const game = /^-?\d+$/.test(params.get('game') ?? '') ? params.get('game') : null; const [profile, setProfile] = useState(null); const [library, setLibrary] = useState(null); const [equipped, setEquipped] = useState({}); @@ -190,6 +194,7 @@ export default function PublicProfilePage() { )} + {game && } { expect(screen.getByText('Lobo').closest('[data-accent]').dataset.accent).toBe('red'); }); + describe('a game of the profile', () => { + const gameAnswer = {appid: 501, name: 'Hades', imageUrl: null, status: 'PLAYING', playtimeMinutes: 90, + achievementsUnlocked: 3, achievementsTotal: 10, completionPercent: 30}; + const gameCalls = () => fetch.mock.calls.filter(([url]) => String(url).includes('/library/games/')); + + it('showsStatusPlaytimeAndAchievementsAboveTheTilesWithTheTicket', async () => { + mockBackends({extra: {'GET /api/public/profiles/library/games/501': json(200, gameAnswer)}}); + renderPage('Lobo?game=501'); + + expect(await screen.findByText('Hades')).toBeTruthy(); + const card = within(screen.getByText('Hades').closest('[data-slot="card"]')); + expect(card.getByText('PROFILE.GAME_ACHIEVEMENTS')).toBeTruthy(); + expect(card.getByText('BACKLOG.STATUS.PLAYING')).toBeTruthy(); + expect(gameCalls()[0][1].headers['X-Profile-Ticket']).toBe('the-ticket'); + }); + + it('saysSoWhenTheProfileDoesNotShowTheGame', async () => { + mockBackends({extra: {'GET /api/public/profiles/library/games/501': json(404, {})}}); + renderPage('Lobo?game=501'); + + expect(await screen.findByText('PROFILE.GAME_NOT_SHOWN')).toBeTruthy(); + expect(screen.getByText('Portal 2')).toBeTruthy(); + }); + + it('asksForNoGameWithoutTheParamOrWithAnythingButAKey', async () => { + mockBackends(); + renderPage('Lobo?game=../x'); + + expect(await screen.findByText('Portal 2')).toBeTruthy(); + expect(gameCalls()).toEqual([]); + }); + }); + it('asks Auth for the ticket, then Core for the library with it', async () => { mockBackends({name: 'Lo bo'}); renderPage('Lo bo'); diff --git a/src/utils/catalog.js b/src/utils/catalog.js new file mode 100644 index 0000000..64d4b58 --- /dev/null +++ b/src/utils/catalog.js @@ -0,0 +1,7 @@ +import {request} from './api.js'; + +/** + * GET /api/library/games/{appid}/preview: `{appid, name, imageUrl, genres, timeToBeat}`, the catalog data of a game + * the user may not own. 404 `GAME_NOT_FOUND` for an unknown key. + */ +export const fetchPreview = (appid) => request(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/preview`); diff --git a/src/utils/follows.js b/src/utils/follows.js index 27024fb..f0e8b74 100644 --- a/src/utils/follows.js +++ b/src/utils/follows.js @@ -3,8 +3,9 @@ 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`. + * GET /api/follows: `{following: [{id, displayName, lastActivityAt}], followers: [{id, displayName, lastActivityAt}]}`. + * `id` is the follow row, not a user id. A follower with a private profile has `displayName: null`. `lastActivityAt` + * is the time of the newest feed message about that person; null for followers and without a message. */ export const fetchFollows = () => request(FOLLOWS()); diff --git a/src/utils/inbox.js b/src/utils/inbox.js index 991943a..2d5e485 100644 --- a/src/utils/inbox.js +++ b/src/utils/inbox.js @@ -3,15 +3,17 @@ 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, + * GET /api/inbox: `{results: [{id, kind, displayName, profileName, newGames, achievements, statusChanges, wishlistAdds, createdAt, * read, details}], unread}`, the newest 50 notes. `kind` is `DIGEST`, `VISIBILITY`, `PSN_EXPIRING` or `PSN_EXPIRED` * (the PSN kinds have no `displayName`); `unread` counts all unread notes. `details` is true when the note can be * opened (`fetchInboxDetails`): it has detail rows and the followed user is still followed and public. + * `profileName` is the followed user's current name while the profile is public, else null: only then the note links + * to the profile. */ export const fetchInbox = () => request(INBOX()); /** - * GET /api/inbox/{id}/details: `{results: [{kind, gameName, itemName, fromStatus, toStatus}]}`; 404 for a note that is + * GET /api/inbox/{id}/details: `{results: [{appid, kind, gameName, itemName, fromStatus, toStatus}]}`; 404 for a note that is * not the caller's. */ export const fetchInboxDetails = (id) => request(`${INBOX()}/${id}/details`); @@ -32,14 +34,15 @@ export const digestParts = (note) => const DETAIL_KINDS = ['NEW_GAME', 'ACHIEVEMENT', 'STATUS', 'WISHLIST']; /** - * Groups details for the page: `[{kind, games: [{gameName, items: [detail]}]}]` in the fixed kind order, only kinds - * with rows. Games keep the order of their first row. + * Groups details for the page: `[{kind, games: [{gameName, appid, items: [detail]}]}]` in the fixed kind order, only + * kinds with rows. Games keep the order of their first row; `appid` is the one of that row (absent in a note from + * before the field). */ export const groupDetails = (details) => DETAIL_KINDS.map((kind) => { const games = new Map(); details.filter((detail) => detail.kind === kind).forEach((detail) => { - if (!games.has(detail.gameName)) games.set(detail.gameName, {gameName: detail.gameName, items: []}); + if (!games.has(detail.gameName)) games.set(detail.gameName, {gameName: detail.gameName, appid: detail.appid, items: []}); games.get(detail.gameName).items.push(detail); }); return {kind, games: [...games.values()]}; diff --git a/src/utils/inbox.test.js b/src/utils/inbox.test.js index 35c5075..ab512e8 100644 --- a/src/utils/inbox.test.js +++ b/src/utils/inbox.test.js @@ -28,6 +28,12 @@ describe('groupDetails', () => { expect(groups[1].games[0].items.map((item) => item.itemName)).toEqual(['one', 'two']); }); + it('keepsTheAppidOfTheFirstRowOfAGame', () => { + const groups = groupDetails([row('NEW_GAME', 'A', {appid: 501}), row('NEW_GAME', 'B')]); + + expect(groups[0].games.map((game) => game.appid)).toEqual([501, undefined]); + }); + it('isEmptyForNoDetails', () => { expect(groupDetails([])).toEqual([]); }); diff --git a/src/utils/people.js b/src/utils/people.js index f74f289..1b4ed04 100644 --- a/src/utils/people.js +++ b/src/utils/people.js @@ -9,3 +9,22 @@ export const fetchUsers = ({key, page = 0}) => request(`${import.meta.env.VITE_API_URL}/api/stats/users?${query([['key', key], ['page', page]])}`); export const profileHref = (displayName) => `/u/${encodeURIComponent(displayName)}`; + +/** The profile of a user, showing one game of theirs above the tiles. */ +export const profileGameHref = (displayName, appid) => `${profileHref(displayName)}?game=${appid}`; + +/** + * One game of a public profile, as far as the profile shows it: `{appid, name, imageUrl, status, playtimeMinutes, + * achievementsUnlocked, achievementsTotal, completionPercent}`, a hidden part is null. No login: the profile ticket + * decides. 404 when the user has no such game or hides it; status 0 when the request fails. + */ +export const fetchProfileGame = async (ticket, appid) => { + try { + const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/public/profiles/library/games/${appid}`, + {headers: {'X-Profile-Ticket': ticket}}); + return {ok: response.ok, status: response.status, data: response.ok ? await response.json() : null}; + } catch (err) { + console.error('Profile game load error:', err); + return {ok: false, status: 0, data: null}; + } +}; diff --git a/src/utils/tiles.js b/src/utils/tiles.js index 85b61d3..1ea83a9 100644 --- a/src/utils/tiles.js +++ b/src/utils/tiles.js @@ -27,6 +27,7 @@ export const DASHBOARD_TILES = { PILE_OF_SHAME: {M: 6, L: 12}, ACTIVITY: {S: 6, M: 12}, ACHIEVEMENTS: {S: 6, M: 12}, + FOLLOWING: {S: 3, M: 6, L: 12}, STATS_OVERVIEW: {M: 12}, ...STAT_TILES, }; @@ -46,6 +47,7 @@ export const DASHBOARD_TITLES = { GAME_COUNT: 'DASHBOARD.STEAM_GAMES', MOST_PLAYED: 'DASHBOARD.MOST_PLAYED', PLAYING: 'DASHBOARD.CURRENTLY_PLAYING', UP_NEXT: 'DASHBOARD.UP_NEXT', PILE_OF_SHAME: 'DASHBOARD.PILE_OF_SHAME', ACTIVITY: 'DASHBOARD.ACTIVITY', ACHIEVEMENTS: 'DASHBOARD.ACHIEVEMENTS.TITLE', + FOLLOWING: 'FOLLOW.TITLE', STATS_OVERVIEW: 'STATS.OVERVIEW.TITLE', ...STAT_TITLES, }; @@ -64,6 +66,7 @@ export const SIZES = ['S', 'M', 'L']; export const TOP_GAMES = {S: 3, M: 5, L: 10}; export const PLAYING_GAMES = {S: 2, M: 4, L: 8}; export const UP_NEXT_GAMES = {S: 3, M: 5, L: 5}; +export const FOLLOWING_SHOWN = {S: 3, M: 5, L: 10}; // Written out, so Tailwind finds every class. const SPANS = { diff --git a/src/utils/tiles.test.js b/src/utils/tiles.test.js index c4e5944..47c0a2f 100644 --- a/src/utils/tiles.test.js +++ b/src/utils/tiles.test.js @@ -14,6 +14,12 @@ describe('normalizeLayout', () => { ], DASHBOARD_TILES, DEFAULT_DASHBOARD)).toEqual([{id: 'STATS_TREND', size: 'L'}, {id: 'PILE_OF_SHAME', size: 'M'}]); }); + it('keepsTheFollowingTileButDoesNotShowItByDefault', () => { + expect(normalizeLayout([{id: 'FOLLOWING', size: 'L'}], DASHBOARD_TILES, DEFAULT_DASHBOARD)) + .toEqual([{id: 'FOLLOWING', size: 'L'}]); + expect(DEFAULT_DASHBOARD.map((tile) => tile.id)).not.toContain('FOLLOWING'); + }); + it('keeps an empty list: every tile hidden', () => { expect(normalizeLayout([], DASHBOARD_TILES, DEFAULT_DASHBOARD)).toEqual([]); });