From ad6f5575d25707f02232abfced725aac68fe97d8 Mon Sep 17 00:00:00 2001
From: lobo
Date: Tue, 13 Oct 2026 09:17:41 +0200
Subject: [PATCH] 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.
---
README.md | 4 +-
public/i18n/de.json | 9 +++-
public/i18n/en.json | 9 +++-
src/pages/InboxPage.jsx | 72 ++++++++++++++++++++++++++++++--
src/pages/InboxPage.test.jsx | 80 +++++++++++++++++++++++++++++++++++-
src/utils/inbox.js | 27 +++++++++++-
src/utils/inbox.test.js | 21 +++++++++-
7 files changed, 212 insertions(+), 10 deletions(-)
diff --git a/README.md b/README.md
index b0f55a4..37d6868 100644
--- a/README.md
+++ b/README.md
@@ -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"
diff --git a/public/i18n/de.json b/public/i18n/de.json
index 155187f..9bec86f 100644
--- a/public/i18n/de.json
+++ b/public/i18n/de.json
@@ -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",
diff --git a/public/i18n/en.json b/public/i18n/en.json
index be15036..8f59286 100644
--- a/public/i18n/en.json
+++ b/public/i18n/en.json
@@ -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",
diff --git a/src/pages/InboxPage.jsx b/src/pages/InboxPage.jsx
index 84f3c00..1fa99b7 100644
--- a/src/pages/InboxPage.jsx
+++ b/src/pages/InboxPage.jsx
@@ -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) => (
+