373 lines
10 KiB
TypeScript
373 lines
10 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { t } from "@lingui/core/macro";
|
|
import { Trans } from "@lingui/react/macro";
|
|
import { DumpCard } from "../../components/DumpCard.tsx";
|
|
import { ErrorCard } from "../../components/ErrorCard.tsx";
|
|
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
|
|
import { useAuth } from "../../hooks/useAuth.ts";
|
|
import { useDumpListSync } from "../../hooks/useDumpListSync.ts";
|
|
import { useFeedCache } from "../../hooks/useFeedCache.ts";
|
|
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll.ts";
|
|
import { useScrollSave } from "../../hooks/useScrollSave.ts";
|
|
import {
|
|
deserializeDump,
|
|
type Dump,
|
|
hydrateDump,
|
|
type PaginatedData,
|
|
type RawDump,
|
|
type User,
|
|
} from "../../model.ts";
|
|
import { friendlyFetchError } from "../../utils/apiError.ts";
|
|
|
|
type FeedState =
|
|
| { status: "loading" }
|
|
| { status: "error"; error: string }
|
|
| {
|
|
status: "loaded";
|
|
dumps: Dump[];
|
|
hasMore: boolean;
|
|
page: number;
|
|
loadingMore: boolean;
|
|
};
|
|
|
|
type FollowedSection = "users" | "playlists";
|
|
|
|
interface FollowedFeedProps {
|
|
voteCounts: Record<string, number>;
|
|
myVotes: Set<string>;
|
|
user: User;
|
|
castVote: (id: string) => void;
|
|
removeVote: (id: string) => void;
|
|
deletedDumpIds: Set<string>;
|
|
}
|
|
|
|
// ── FollowedSubFeed ───────────────────────────────────────────────────────────
|
|
|
|
interface FollowedSubFeedProps {
|
|
state: FeedState;
|
|
voteCounts: Record<string, number>;
|
|
myVotes: Set<string>;
|
|
user: User;
|
|
castVote: (id: string) => void;
|
|
removeVote: (id: string) => void;
|
|
deletedDumpIds: Set<string>;
|
|
emptyMessage: string;
|
|
onLoadMore: () => void;
|
|
}
|
|
|
|
function FollowedSubFeed({
|
|
state,
|
|
voteCounts,
|
|
myVotes,
|
|
user,
|
|
castVote,
|
|
removeVote,
|
|
deletedDumpIds,
|
|
emptyMessage,
|
|
onLoadMore,
|
|
}: FollowedSubFeedProps) {
|
|
const enabled = state.status === "loaded" && state.hasMore &&
|
|
!state.loadingMore;
|
|
const sentinelRef = useInfiniteScroll(onLoadMore, enabled);
|
|
|
|
if (state.status === "loading") {
|
|
return (
|
|
<p className="index-status">
|
|
<Trans>Loading…</Trans>
|
|
</p>
|
|
);
|
|
}
|
|
if (state.status === "error") {
|
|
return <ErrorCard title={t`Failed to load`} message={state.error} />;
|
|
}
|
|
|
|
const visible = state.dumps.filter((d) => !deletedDumpIds.has(d.id));
|
|
|
|
if (visible.length === 0) {
|
|
return <p className="index-status">{emptyMessage}</p>;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<ul className="dump-feed">
|
|
{visible.map((dump) => (
|
|
<DumpCard
|
|
key={dump.id}
|
|
dump={dump}
|
|
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
|
voted={myVotes.has(dump.id)}
|
|
canVote
|
|
castVote={castVote}
|
|
removeVote={removeVote}
|
|
isOwner={user.id === dump.userId}
|
|
/>
|
|
))}
|
|
</ul>
|
|
<div ref={sentinelRef} />
|
|
{state.loadingMore && (
|
|
<p className="feed-loading-more">
|
|
<Trans>Loading more…</Trans>
|
|
</p>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ── FollowedFeed ──────────────────────────────────────────────────────────────
|
|
|
|
export function FollowedFeed({
|
|
voteCounts,
|
|
myVotes,
|
|
user,
|
|
castVote,
|
|
removeVote,
|
|
deletedDumpIds,
|
|
}: FollowedFeedProps) {
|
|
const { token } = useAuth();
|
|
|
|
const { cached: cachedUsers, saveState: saveUsers } = useFeedCache<Dump>(
|
|
`feed:followed-users:${user.id}`,
|
|
hydrateDump,
|
|
);
|
|
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<
|
|
Dump
|
|
>(
|
|
`feed:followed-playlists:${user.id}`,
|
|
hydrateDump,
|
|
);
|
|
|
|
const [usersState, setUsersState] = useState<FeedState>(() =>
|
|
cachedUsers
|
|
? {
|
|
status: "loaded",
|
|
dumps: cachedUsers.items,
|
|
hasMore: cachedUsers.hasMore,
|
|
page: cachedUsers.page,
|
|
loadingMore: false,
|
|
}
|
|
: { status: "loading" }
|
|
);
|
|
|
|
const [playlistsState, setPlaylistsState] = useState<FeedState>(() =>
|
|
cachedPlaylists
|
|
? {
|
|
status: "loaded",
|
|
dumps: cachedPlaylists.items,
|
|
hasMore: cachedPlaylists.hasMore,
|
|
page: cachedPlaylists.page,
|
|
loadingMore: false,
|
|
}
|
|
: { status: "loading" }
|
|
);
|
|
|
|
const [section, setSection] = useState<FollowedSection>("users");
|
|
|
|
// WS sync
|
|
const setUsersItems = useCallback(
|
|
(fn: (prev: Dump[]) => Dump[]) =>
|
|
setUsersState((s) =>
|
|
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
|
|
),
|
|
[],
|
|
);
|
|
useDumpListSync(setUsersItems);
|
|
|
|
const setPlaylistsItems = useCallback(
|
|
(fn: (prev: Dump[]) => Dump[]) =>
|
|
setPlaylistsState((s) =>
|
|
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
|
|
),
|
|
[],
|
|
);
|
|
useDumpListSync(setPlaylistsItems);
|
|
|
|
// Fetch on mount if not cached
|
|
useEffect(() => {
|
|
if (!token) return;
|
|
if (usersState.status === "loading") {
|
|
fetch(
|
|
`${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
|
{ headers: { Authorization: `Bearer ${token}` } },
|
|
)
|
|
.then((r) => r.json())
|
|
.then((body) => {
|
|
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
|
setUsersState({
|
|
status: "loaded",
|
|
dumps: items.map(deserializeDump),
|
|
hasMore,
|
|
page: 1,
|
|
loadingMore: false,
|
|
});
|
|
})
|
|
.catch((err) =>
|
|
setUsersState({ status: "error", error: friendlyFetchError(err) })
|
|
);
|
|
}
|
|
if (playlistsState.status === "loading") {
|
|
fetch(
|
|
`${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
|
{ headers: { Authorization: `Bearer ${token}` } },
|
|
)
|
|
.then((r) => r.json())
|
|
.then((body) => {
|
|
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
|
setPlaylistsState({
|
|
status: "loaded",
|
|
dumps: items.map(deserializeDump),
|
|
hasMore,
|
|
page: 1,
|
|
loadingMore: false,
|
|
});
|
|
})
|
|
.catch((err) =>
|
|
setPlaylistsState({
|
|
status: "error",
|
|
error: friendlyFetchError(err),
|
|
})
|
|
);
|
|
}
|
|
}, [token, usersState.status, playlistsState.status]);
|
|
|
|
// Scroll save
|
|
useScrollSave(
|
|
usersState.status === "loaded",
|
|
useCallback((y) => {
|
|
if (usersState.status !== "loaded") return;
|
|
saveUsers(usersState.dumps, usersState.page, usersState.hasMore, y);
|
|
}, [usersState, saveUsers]),
|
|
);
|
|
useScrollSave(
|
|
playlistsState.status === "loaded",
|
|
useCallback((y) => {
|
|
if (playlistsState.status !== "loaded") return;
|
|
savePlaylists(
|
|
playlistsState.dumps,
|
|
playlistsState.page,
|
|
playlistsState.hasMore,
|
|
y,
|
|
);
|
|
}, [playlistsState, savePlaylists]),
|
|
);
|
|
|
|
// Load-more callbacks
|
|
const loadMoreUsers = useCallback(() => {
|
|
if (
|
|
usersState.status !== "loaded" || !usersState.hasMore ||
|
|
usersState.loadingMore || !token
|
|
) return;
|
|
const nextPage = usersState.page + 1;
|
|
setUsersState((s) =>
|
|
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
|
);
|
|
fetch(
|
|
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
|
{ headers: { Authorization: `Bearer ${token}` } },
|
|
)
|
|
.then((r) => r.json())
|
|
.then((body) => {
|
|
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
|
setUsersState((s) =>
|
|
s.status === "loaded"
|
|
? {
|
|
...s,
|
|
dumps: [...s.dumps, ...items.map(deserializeDump)],
|
|
hasMore,
|
|
page: nextPage,
|
|
loadingMore: false,
|
|
}
|
|
: s
|
|
);
|
|
})
|
|
.catch(() =>
|
|
setUsersState((s) =>
|
|
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
|
)
|
|
);
|
|
}, [usersState, token]);
|
|
|
|
const loadMorePlaylists = useCallback(() => {
|
|
if (
|
|
playlistsState.status !== "loaded" || !playlistsState.hasMore ||
|
|
playlistsState.loadingMore || !token
|
|
) return;
|
|
const nextPage = playlistsState.page + 1;
|
|
setPlaylistsState((s) =>
|
|
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
|
);
|
|
fetch(
|
|
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
|
{ headers: { Authorization: `Bearer ${token}` } },
|
|
)
|
|
.then((r) => r.json())
|
|
.then((body) => {
|
|
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
|
setPlaylistsState((s) =>
|
|
s.status === "loaded"
|
|
? {
|
|
...s,
|
|
dumps: [...s.dumps, ...items.map(deserializeDump)],
|
|
hasMore,
|
|
page: nextPage,
|
|
loadingMore: false,
|
|
}
|
|
: s
|
|
);
|
|
})
|
|
.catch(() =>
|
|
setPlaylistsState((s) =>
|
|
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
|
)
|
|
);
|
|
}, [playlistsState, token]);
|
|
|
|
return (
|
|
<div className="followed-feed">
|
|
<div className="feed-sort followed-sub-nav">
|
|
<button
|
|
type="button"
|
|
className={`feed-sort-btn${section === "users" ? " active" : ""}`}
|
|
onClick={() => setSection("users")}
|
|
>
|
|
<Trans>From people</Trans>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`feed-sort-btn${section === "playlists" ? " active" : ""}`}
|
|
onClick={() => setSection("playlists")}
|
|
>
|
|
<Trans>From playlists</Trans>
|
|
</button>
|
|
</div>
|
|
|
|
{section === "users" && (
|
|
<FollowedSubFeed
|
|
state={usersState}
|
|
voteCounts={voteCounts}
|
|
myVotes={myVotes}
|
|
user={user}
|
|
castVote={castVote}
|
|
removeVote={removeVote}
|
|
deletedDumpIds={deletedDumpIds}
|
|
emptyMessage={t`Follow some users to see their dumps here.`}
|
|
onLoadMore={loadMoreUsers}
|
|
/>
|
|
)}
|
|
|
|
{section === "playlists" && (
|
|
<FollowedSubFeed
|
|
state={playlistsState}
|
|
voteCounts={voteCounts}
|
|
myVotes={myVotes}
|
|
user={user}
|
|
castVote={castVote}
|
|
removeVote={removeVote}
|
|
deletedDumpIds={deletedDumpIds}
|
|
emptyMessage={t`Follow some public playlists to see their dumps here.`}
|
|
onLoadMore={loadMorePlaylists}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|