Public Access
feat(inbox): add expandable details to digest notes
Digest notes only showed counts. Now a digest note opens to list its
games and items, grouped by kind. The list loads once, from
GET /api/inbox/{id}/details. Achievements show 5 per game first.
This commit is contained in:
1 parent
600cf07b5a
commit
ad6f5575d2
7 files changed
+211
-9
No files matched your search
@@ -177,7 +177,9 @@ flowchart LR
|
||||
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. Two more notes come from the user's own
|
||||
and marks them read; a digest note opens inline (click) and lists its games, achievements, status changes and wishlist
|
||||
items, while you still follow the person and the part is public (5 achievements per game, then "show all"); a user
|
||||
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.
|
||||
- **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it"
|
||||
|
||||
+8
-1
@@ -969,7 +969,14 @@
|
||||
"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"
|
||||
"PART_WISHLIST_ADDS_other": "{{count}} Wunschlisten-Einträge",
|
||||
"DETAIL_NEW_GAME": "Neue Spiele",
|
||||
"DETAIL_ACHIEVEMENT": "Erfolge und Trophäen",
|
||||
"DETAIL_STATUS": "Statusänderungen",
|
||||
"DETAIL_WISHLIST": "Wunschliste",
|
||||
"SHOW_ALL": "Alle {{count}} anzeigen",
|
||||
"DETAILS_GONE": "Keine Details mehr: du folgst dieser Person nicht mehr oder der Bereich ist jetzt privat.",
|
||||
"DETAILS_ERROR": "Die Details konnten nicht geladen werden."
|
||||
},
|
||||
"NEWS": {
|
||||
"TITLE": "Neuigkeiten",
|
||||
|
||||
+8
-1
@@ -969,7 +969,14 @@
|
||||
"PART_STATUS_CHANGES_one": "{{count}} status change",
|
||||
"PART_STATUS_CHANGES_other": "{{count}} status changes",
|
||||
"PART_WISHLIST_ADDS_one": "{{count}} new wishlist item",
|
||||
"PART_WISHLIST_ADDS_other": "{{count}} new wishlist items"
|
||||
"PART_WISHLIST_ADDS_other": "{{count}} new wishlist items",
|
||||
"DETAIL_NEW_GAME": "New games",
|
||||
"DETAIL_ACHIEVEMENT": "Achievements and trophies",
|
||||
"DETAIL_STATUS": "Status changes",
|
||||
"DETAIL_WISHLIST": "Wishlist",
|
||||
"SHOW_ALL": "Show all {{count}}",
|
||||
"DETAILS_GONE": "No details any more: you no longer follow this person or the part is private now.",
|
||||
"DETAILS_ERROR": "The details could not be loaded."
|
||||
},
|
||||
"NEWS": {
|
||||
"TITLE": "What's new",
|
||||
|
||||
+68
-2
@@ -1,15 +1,16 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {Loader2} from 'lucide-react';
|
||||
import {ChevronDown, 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';
|
||||
import {digestParts, fetchInbox, fetchInboxDetails, groupDetails, markInboxRead} from '../utils/inbox.js';
|
||||
|
||||
const PSN_KINDS = ['PSN_EXPIRING', 'PSN_EXPIRED'];
|
||||
const ACHIEVEMENTS_SHOWN = 5;
|
||||
|
||||
/**
|
||||
* The newest notes about the people the user follows and about the PlayStation link. Opening the page marks them read.
|
||||
@@ -18,6 +19,10 @@ export default function InboxPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const [notes, setNotes] = useState([]);
|
||||
const [status, setStatus] = useState('loading');
|
||||
const [open, setOpen] = useState(null);
|
||||
// note id -> {status: 'loading' | 'error' | 'ready', results}
|
||||
const [details, setDetails] = useState({});
|
||||
const [showAll, setShowAll] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -42,6 +47,59 @@ export default function InboxPage() {
|
||||
return t('INBOX.DIGEST', {name, parts});
|
||||
};
|
||||
|
||||
const toggle = async (id) => {
|
||||
if (open === id) {
|
||||
setOpen(null);
|
||||
return;
|
||||
}
|
||||
setOpen(id);
|
||||
if (details[id]) return;
|
||||
setDetails((current) => ({...current, [id]: {status: 'loading', results: []}}));
|
||||
const {ok, status, data} = await fetchInboxDetails(id);
|
||||
setDetails((current) => ({...current, [id]: ok || status === 404 ? {status: 'ready', results: data?.results ?? []} : {status: 'error', results: []}}));
|
||||
};
|
||||
|
||||
const gameLines = (group, {gameName, items}, noteId) => {
|
||||
if (group.kind === 'STATUS') {
|
||||
return items.map((item, i) => (
|
||||
<li key={`${gameName}-${i}`}>
|
||||
{gameName}: {t(`BACKLOG.STATUS.${item.fromStatus}`)} → {t(`BACKLOG.STATUS.${item.toStatus}`)}
|
||||
</li>
|
||||
));
|
||||
}
|
||||
if (group.kind !== 'ACHIEVEMENT') return <li key={gameName}>{gameName}</li>;
|
||||
const key = `${noteId}:${gameName}`;
|
||||
const shown = showAll[key] ? items : items.slice(0, ACHIEVEMENTS_SHOWN);
|
||||
return (
|
||||
<li key={gameName}>
|
||||
{gameName}
|
||||
<ul className="ml-4 list-disc">
|
||||
{shown.map((item, i) => <li key={i}>{item.itemName}</li>)}
|
||||
</ul>
|
||||
{!showAll[key] && items.length > ACHIEVEMENTS_SHOWN && (
|
||||
<button type="button" className="ml-4 underline"
|
||||
onClick={() => setShowAll((current) => ({...current, [key]: true}))}>
|
||||
{t('INBOX.SHOW_ALL', {count: items.length})}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const detailBlock = (noteId) => {
|
||||
const entry = details[noteId];
|
||||
if (!entry || entry.status === 'loading') return <Loader2 className="size-4 animate-spin text-muted-foreground"/>;
|
||||
if (entry.status === 'error') return <p className="text-xs text-destructive">{t('INBOX.DETAILS_ERROR')}</p>;
|
||||
const groups = groupDetails(entry.results);
|
||||
if (groups.length === 0) return <p className="text-xs text-muted-foreground">{t('INBOX.DETAILS_GONE')}</p>;
|
||||
return groups.map((group) => (
|
||||
<div key={group.kind} className="space-y-1">
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground">{t(`INBOX.DETAIL_${group.kind}`)}</h3>
|
||||
<ul className="space-y-0.5 text-sm">{group.games.map((gameGroup) => gameLines(group, gameGroup, noteId))}</ul>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
|
||||
<Card>
|
||||
@@ -60,11 +118,19 @@ export default function InboxPage() {
|
||||
{formatDate(note.createdAt, i18n.language, {dateStyle: 'medium'})}
|
||||
</p>
|
||||
<p className={cn('break-words text-sm', !note.read && 'font-semibold')}>
|
||||
{note.kind === 'DIGEST' && note.details ? (
|
||||
<button type="button" aria-expanded={open === note.id}
|
||||
className="inline-flex cursor-pointer items-center gap-1 break-words text-left hover:underline"
|
||||
onClick={() => toggle(note.id)}>
|
||||
{text(note)}
|
||||
<ChevronDown className={cn('size-4 shrink-0 transition-transform', open === note.id && 'rotate-180')}/>
|
||||
</button>
|
||||
) : text(note)}
|
||||
{PSN_KINDS.includes(note.kind) && (
|
||||
<> <Link to="/settings" className="underline">{t('INBOX.OPEN_SETTINGS')}</Link></>
|
||||
)}
|
||||
</p>
|
||||
{open === note.id && <div className="space-y-2 pt-1">{detailBlock(note.id)}</div>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, screen, waitFor} from '@testing-library/react';
|
||||
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
import InboxPage from './InboxPage.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||
@@ -67,4 +67,82 @@ describe('InboxPage', () => {
|
||||
expect(await screen.findByText('INBOX.LOAD_ERROR')).toBeTruthy();
|
||||
expect(putCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
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'));
|
||||
const setup = (results, 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})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
};
|
||||
|
||||
it('opensANoteOnceShowsTheRowsGroupedAndClosesWithoutANewRequest', async () => {
|
||||
setup([row('NEW_GAME', 'Alpha'), row('ACHIEVEMENT', 'Alpha', {itemName: 'Win'}),
|
||||
row('STATUS', 'Beta', {fromStatus: 'BACKLOG', toStatus: 'PLAYING'})]);
|
||||
|
||||
const button = await screen.findByRole('button', {name: 'INBOX.DIGEST'});
|
||||
expect(button.getAttribute('aria-expanded')).toBe('false');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(await screen.findByText('INBOX.DETAIL_ACHIEVEMENT')).toBeTruthy();
|
||||
expect(screen.getByText('Win')).toBeTruthy();
|
||||
expect(screen.getByText(/Beta: BACKLOG\.STATUS\.BACKLOG → BACKLOG\.STATUS\.PLAYING/)).toBeTruthy();
|
||||
expect(detailCalls()).toHaveLength(1);
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(screen.queryByText('INBOX.DETAIL_ACHIEVEMENT')).toBeNull();
|
||||
fireEvent.click(button);
|
||||
expect(await screen.findByText('INBOX.DETAIL_ACHIEVEMENT')).toBeTruthy();
|
||||
expect(detailCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('givesANoteWithoutDetailsNoButtonAndNoRequest', async () => {
|
||||
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: false})], unread: 0})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
expect(await screen.findByText('INBOX.DIGEST')).toBeTruthy();
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
expect(detailCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
it('saysSoWhenTheResultIsEmpty', async () => {
|
||||
setup([]);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'INBOX.DIGEST'}));
|
||||
|
||||
expect(await screen.findByText('INBOX.DETAILS_GONE')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('saysSoWhenTheNoteIsGone', async () => {
|
||||
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: true})], unread: 0}),
|
||||
'GET /api/inbox/1/details': json(404, {})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'INBOX.DIGEST'}));
|
||||
|
||||
expect(await screen.findByText('INBOX.DETAILS_GONE')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('showsAnErrorWhenTheDetailsFail', async () => {
|
||||
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: true})], unread: 0}),
|
||||
'GET /api/inbox/1/details': json(500, {})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'INBOX.DIGEST'}));
|
||||
|
||||
expect(await screen.findByText('INBOX.DETAILS_ERROR')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('showsFiveAchievementsOfAGameAndTheRestOnShowAll', async () => {
|
||||
setup(Array.from({length: 6}, (_, i) => row('ACHIEVEMENT', 'Alpha', {itemName: `ach${i}`})));
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'INBOX.DIGEST'}));
|
||||
|
||||
expect(await screen.findByText('ach4')).toBeTruthy();
|
||||
expect(screen.queryByText('ach5')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', {name: 'INBOX.SHOW_ALL:6'}));
|
||||
expect(screen.getByText('ach5')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
+25
-2
@@ -4,11 +4,18 @@ const INBOX = () => `${import.meta.env.VITE_CORE_API_URL}/api/inbox`;
|
||||
|
||||
/**
|
||||
* GET /api/inbox: `{results: [{id, kind, displayName, newGames, achievements, statusChanges, wishlistAdds, createdAt,
|
||||
* read}], unread}`, the newest 50 notes. `kind` is `DIGEST`, `VISIBILITY`, `PSN_EXPIRING` or `PSN_EXPIRED` (the PSN
|
||||
* kinds have no `displayName`); `unread` counts all unread notes.
|
||||
* 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.
|
||||
*/
|
||||
export const fetchInbox = () => request(INBOX());
|
||||
|
||||
/**
|
||||
* GET /api/inbox/{id}/details: `{results: [{kind, gameName, itemName, fromStatus, toStatus}]}`; 404 for a note that is
|
||||
* not the caller's.
|
||||
*/
|
||||
export const fetchInboxDetails = (id) => request(`${INBOX()}/${id}/details`);
|
||||
|
||||
/** All notes count as read. 204. */
|
||||
export const markInboxRead = () => request(`${INBOX()}/read`, {method: 'PUT'});
|
||||
|
||||
@@ -21,3 +28,19 @@ const PARTS = [['NEW_GAMES', 'newGames'], ['ACHIEVEMENTS', 'achievements'], ['ST
|
||||
*/
|
||||
export const digestParts = (note) =>
|
||||
PARTS.filter(([, field]) => note[field] > 0).map(([key, field]) => ({key, count: note[field]}));
|
||||
|
||||
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.
|
||||
*/
|
||||
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: []});
|
||||
games.get(detail.gameName).items.push(detail);
|
||||
});
|
||||
return {kind, games: [...games.values()]};
|
||||
}).filter((group) => group.games.length > 0);
|
||||
+20
-1
@@ -1,5 +1,5 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {digestParts} from './inbox.js';
|
||||
import {digestParts, groupDetails} from './inbox.js';
|
||||
|
||||
describe('digestParts', () => {
|
||||
it('keepsOnlyTheNonZeroCountsInAFixedOrder', () => {
|
||||
@@ -13,3 +13,22 @@ describe('digestParts', () => {
|
||||
expect(digestParts({newGames: 0, achievements: 0, statusChanges: 0, wishlistAdds: 0})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupDetails', () => {
|
||||
const row = (kind, gameName, extra) => ({kind, gameName, itemName: null, fromStatus: null, toStatus: null, ...extra});
|
||||
|
||||
it('putsTheKindsInAFixedOrderGroupsPerGameAndLeavesEmptyKindsOut', () => {
|
||||
const details = [row('WISHLIST', 'W'), row('ACHIEVEMENT', 'A', {itemName: 'one'}), row('NEW_GAME', 'A'),
|
||||
row('ACHIEVEMENT', 'B', {itemName: 'x'}), row('ACHIEVEMENT', 'A', {itemName: 'two'})];
|
||||
|
||||
const groups = groupDetails(details);
|
||||
|
||||
expect(groups.map((group) => group.kind)).toEqual(['NEW_GAME', 'ACHIEVEMENT', 'WISHLIST']);
|
||||
expect(groups[1].games.map((game) => game.gameName)).toEqual(['A', 'B']);
|
||||
expect(groups[1].games[0].items.map((item) => item.itemName)).toEqual(['one', 'two']);
|
||||
});
|
||||
|
||||
it('isEmptyForNoDetails', () => {
|
||||
expect(groupDetails([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user