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 (
+
+
+
+
+ );
+}
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