Files
ToBePlayed-Frontend/src/pages/InboxPage.jsx
T
lobo 5be0d42bca
Build, Test & Push Frontend / quality-check (push) Successful in 37s
Build, Test & Push Frontend / docker (push) Successful in 1m23s
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.
2026-10-14 16:23:11 +02:00

165 lines
7.8 KiB
React

import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Link} from 'react-router-dom';
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 {profileGameHref, profileHref} from '../utils/people.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.
*/
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 () => {
const {ok, status: code, data} = await fetchInbox();
if (code === 401) return;
if (!ok) {
setStatus('error');
return;
}
setNotes(data.results);
setStatus('ready');
// A failed mark is silent: the notes show as unread again on the next visit.
if (data.unread > 0) markInboxRead();
})();
}, []);
const text = (note) => {
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});
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 ? (
<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)}>
{label}
<ChevronDown className={cn('size-4 shrink-0 transition-transform', open === note.id && 'rotate-180')}/>
</button>
) : label;
if (!linked) return line;
return <><Link to={profileHref(note.profileName)} className="underline">{note.displayName}</Link>: {line}</>;
};
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 gameLabel = (note, {gameName, appid}) => (
<>
{appid == null ? gameName : <Link to={`/library/${appid}`} className="hover:underline">{gameName}</Link>}
{appid != null && note.profileName && (
<Link to={profileGameHref(note.profileName, appid)}
className="ml-2 text-xs text-muted-foreground underline">{t('INBOX.ON_PROFILE')}</Link>
)}
</>
);
const gameLines = (group, gameGroup, note) => {
const {gameName, items} = gameGroup;
if (group.kind === 'STATUS') {
return items.map((item, i) => (
<li key={`${gameName}-${i}`}>
{gameLabel(note, gameGroup)}: {t(`BACKLOG.STATUS.${item.fromStatus}`)} → {t(`BACKLOG.STATUS.${item.toStatus}`)}
</li>
));
}
if (group.kind !== 'ACHIEVEMENT') return <li key={gameName}>{gameLabel(note, gameGroup)}</li>;
const key = `${note.id}:${gameName}`;
const shown = showAll[key] ? items : items.slice(0, ACHIEVEMENTS_SHOWN);
return (
<li key={gameName}>
{gameLabel(note, gameGroup)}
<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 = (note) => {
const entry = details[note.id];
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, note))}</ul>
</div>
));
};
return (
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
<Card>
<CardHeader>
<CardTitle>{t('INBOX.TITLE')}</CardTitle>
</CardHeader>
<CardContent>
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
{status === 'error' && <p className="text-sm text-destructive">{t('INBOX.LOAD_ERROR')}</p>}
{status === 'ready' && notes.length === 0 && <p className="text-sm text-muted-foreground">{t('INBOX.EMPTY')}</p>}
{status === 'ready' && notes.length > 0 && (
<ul className="divide-y">
{notes.map((note) => (
<li key={note.id} className="space-y-1 py-3">
<p className="text-xs text-muted-foreground">
{formatDate(note.createdAt, i18n.language, {dateStyle: 'medium'})}
</p>
<p className={cn('break-words text-sm', !note.read && 'font-semibold')}>
{noteLine(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)}</div>}
</li>
))}
</ul>
)}
</CardContent>
</Card>
</PageContainer>
);
}