Public Access
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.
165 lines
7.8 KiB
React
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>
|
|
);
|
|
}
|