v3: follows, notifications, invite-only registration, unread markers
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
@@ -7,10 +13,15 @@ import { AppHeader } from "../components/AppHeader.tsx";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
import { deserializeDump, type Dump, type PaginatedData, type RawDump } from "../model.ts";
|
||||
import {
|
||||
deserializeDump,
|
||||
type Dump,
|
||||
type PaginatedData,
|
||||
type RawDump,
|
||||
type User,
|
||||
} from "../model.ts";
|
||||
|
||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||
@@ -24,15 +35,88 @@ const hydrateDump = (raw: Dump): Dump =>
|
||||
type DumpsState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| { status: "loaded"; dumps: Dump[]; hasMore: boolean; page: number; loadingMore: boolean };
|
||||
| {
|
||||
status: "loaded";
|
||||
dumps: Dump[];
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
type SortMode = "new" | "hot";
|
||||
type FeedTab = "hot" | "new" | "followed";
|
||||
type FollowedSection = "users" | "playlists";
|
||||
|
||||
function hotScore(dump: Dump): number {
|
||||
const ageHours = (Date.now() - dump.createdAt.getTime()) / 3_600_000;
|
||||
return (dump.voteCount + 1) / Math.pow(ageHours + 2, 1.5);
|
||||
}
|
||||
|
||||
// ── FollowedSubFeed ──────────────────────────────────────────────────────────
|
||||
|
||||
interface FollowedSubFeedProps {
|
||||
state: DumpsState;
|
||||
voteCounts: Record<string, number>;
|
||||
myVotes: Set<string>;
|
||||
user: User | null;
|
||||
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 hasMore = state.status === "loaded" && state.hasMore &&
|
||||
!state.loadingMore;
|
||||
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return <p className="index-status">Loading…</p>;
|
||||
}
|
||||
if (state.status === "error") {
|
||||
return <p className="index-status index-status--error">{state.error}</p>;
|
||||
}
|
||||
|
||||
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={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwner={user?.id === dump.userId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<div ref={sentinelRef} />
|
||||
{state.loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Index ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function Index() {
|
||||
const location = useLocation();
|
||||
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
|
||||
@@ -49,22 +133,70 @@ export function Index() {
|
||||
removeVote,
|
||||
} = useWS();
|
||||
|
||||
const { cached, saveState } = useFeedCache<Dump>(`feed:index:${user?.id ?? "guest"}`, hydrateDump);
|
||||
|
||||
// Main feed
|
||||
const { cached, saveState } = useFeedCache<Dump>(
|
||||
`feed:index:${user?.id ?? "guest"}`,
|
||||
hydrateDump,
|
||||
);
|
||||
const [dumpsState, setDumpsState] = useState<DumpsState>(() =>
|
||||
cached
|
||||
? { status: "loaded", dumps: cached.items, hasMore: cached.hasMore, page: cached.page, loadingMore: false }
|
||||
? {
|
||||
status: "loaded",
|
||||
dumps: cached.items,
|
||||
hasMore: cached.hasMore,
|
||||
page: cached.page,
|
||||
loadingMore: false,
|
||||
}
|
||||
: { status: "loading" }
|
||||
);
|
||||
const [sort, setSort] = useState<SortMode>("hot");
|
||||
const mainFetchDone = useRef(false);
|
||||
|
||||
// Followed feeds
|
||||
const { cached: cachedFollowedUsers, saveState: saveFollowedUsers } =
|
||||
useFeedCache<Dump>(
|
||||
`feed:followed-users:${user?.id ?? "guest"}`,
|
||||
hydrateDump,
|
||||
);
|
||||
const { cached: cachedFollowedPlaylists, saveState: saveFollowedPlaylists } =
|
||||
useFeedCache<Dump>(
|
||||
`feed:followed-playlists:${user?.id ?? "guest"}`,
|
||||
hydrateDump,
|
||||
);
|
||||
|
||||
const [followedUsersDumps, setFollowedUsersDumps] = useState<DumpsState>({
|
||||
status: "loading",
|
||||
});
|
||||
const [followedPlaylistsDumps, setFollowedPlaylistsDumps] = useState<
|
||||
DumpsState
|
||||
>({ status: "loading" });
|
||||
|
||||
const [tab, setTab] = useState<FeedTab>("hot");
|
||||
const [followedSection, setFollowedSection] = useState<FollowedSection>(
|
||||
"users",
|
||||
);
|
||||
|
||||
// When the logo is clicked it navigates to / with state { tab: "hot" }, producing
|
||||
// a new location.key even if already on /. React to that to reset the active tab.
|
||||
useEffect(() => {
|
||||
const st = location.state as { tab?: string } | null;
|
||||
if (st?.tab === "hot" || st?.tab === "new" || st?.tab === "followed") {
|
||||
setTab(st.tab as FeedTab);
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
// ── Main feed fetch ──
|
||||
|
||||
useEffect(() => {
|
||||
if (cached) return; // restored from cache, skip fetch
|
||||
if (mainFetchDone.current || cached) return;
|
||||
mainFetchDone.current = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/dumps/?page=1&limit=${PAGE_SIZE}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
const res = await fetch(
|
||||
`${API_URL}/api/dumps/?page=1&limit=${PAGE_SIZE}`,
|
||||
{
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
@@ -82,13 +214,96 @@ export function Index() {
|
||||
});
|
||||
}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [cached, token]);
|
||||
|
||||
// ── Followed feeds fetch (lazy, on first tab open) ──
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== "followed" || !user || !token) return;
|
||||
|
||||
if (followedUsersDumps.status === "loading") {
|
||||
if (cachedFollowedUsers) {
|
||||
setFollowedUsersDumps({
|
||||
status: "loaded",
|
||||
dumps: cachedFollowedUsers.items,
|
||||
hasMore: cachedFollowedUsers.hasMore,
|
||||
page: cachedFollowedUsers.page,
|
||||
loadingMore: false,
|
||||
});
|
||||
} else {
|
||||
fetch(`${API_URL}/api/follows/feed/users?page=1&limit=${PAGE_SIZE}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
setFollowedUsersDumps({
|
||||
status: "loaded",
|
||||
dumps: items.map(deserializeDump),
|
||||
hasMore,
|
||||
page: 1,
|
||||
loadingMore: false,
|
||||
});
|
||||
})
|
||||
.catch((err) =>
|
||||
setFollowedUsersDumps({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to load",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (followedPlaylistsDumps.status === "loading") {
|
||||
if (cachedFollowedPlaylists) {
|
||||
setFollowedPlaylistsDumps({
|
||||
status: "loaded",
|
||||
dumps: cachedFollowedPlaylists.items,
|
||||
hasMore: cachedFollowedPlaylists.hasMore,
|
||||
page: cachedFollowedPlaylists.page,
|
||||
loadingMore: false,
|
||||
});
|
||||
} else {
|
||||
fetch(
|
||||
`${API_URL}/api/follows/feed/playlists?page=1&limit=${PAGE_SIZE}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
setFollowedPlaylistsDumps({
|
||||
status: "loaded",
|
||||
dumps: items.map(deserializeDump),
|
||||
hasMore,
|
||||
page: 1,
|
||||
loadingMore: false,
|
||||
});
|
||||
})
|
||||
.catch((err) =>
|
||||
setFollowedPlaylistsDumps({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to load",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
tab,
|
||||
user?.id,
|
||||
token,
|
||||
cachedFollowedUsers,
|
||||
cachedFollowedPlaylists,
|
||||
followedUsersDumps.status,
|
||||
followedPlaylistsDumps.status,
|
||||
]);
|
||||
|
||||
// ── Load-more callbacks ──
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (
|
||||
dumpsState.status !== "loaded" ||
|
||||
!dumpsState.hasMore ||
|
||||
dumpsState.status !== "loaded" || !dumpsState.hasMore ||
|
||||
dumpsState.loadingMore
|
||||
) return;
|
||||
const nextPage = dumpsState.page + 1;
|
||||
@@ -120,12 +335,92 @@ export function Index() {
|
||||
);
|
||||
}, [dumpsState, token]);
|
||||
|
||||
const loadMoreFollowedUsers = useCallback(() => {
|
||||
if (
|
||||
followedUsersDumps.status !== "loaded" ||
|
||||
!followedUsersDumps.hasMore ||
|
||||
followedUsersDumps.loadingMore ||
|
||||
!token
|
||||
) return;
|
||||
const nextPage = followedUsersDumps.page + 1;
|
||||
setFollowedUsersDumps((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
||||
);
|
||||
fetch(
|
||||
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
setFollowedUsersDumps((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
dumps: [...s.dumps, ...items.map(deserializeDump)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setFollowedUsersDumps((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||
)
|
||||
);
|
||||
}, [followedUsersDumps, token]);
|
||||
|
||||
const loadMoreFollowedPlaylists = useCallback(() => {
|
||||
if (
|
||||
followedPlaylistsDumps.status !== "loaded" ||
|
||||
!followedPlaylistsDumps.hasMore ||
|
||||
followedPlaylistsDumps.loadingMore ||
|
||||
!token
|
||||
) return;
|
||||
const nextPage = followedPlaylistsDumps.page + 1;
|
||||
setFollowedPlaylistsDumps((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
||||
);
|
||||
fetch(
|
||||
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
setFollowedPlaylistsDumps((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
dumps: [...s.dumps, ...items.map(deserializeDump)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setFollowedPlaylistsDumps((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||
)
|
||||
);
|
||||
}, [followedPlaylistsDumps, token]);
|
||||
|
||||
// ── Scroll save effects ──
|
||||
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
loadMore,
|
||||
dumpsState.status === "loaded" && dumpsState.hasMore && !dumpsState.loadingMore,
|
||||
dumpsState.status === "loaded" && dumpsState.hasMore &&
|
||||
!dumpsState.loadingMore,
|
||||
);
|
||||
|
||||
// Save scroll position + loaded state to sessionStorage on scroll
|
||||
useEffect(() => {
|
||||
if (dumpsState.status !== "loaded") return;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
@@ -133,25 +428,80 @@ export function Index() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (dumpsState.status === "loaded") {
|
||||
saveState(dumpsState.dumps, dumpsState.page, dumpsState.hasMore, window.scrollY);
|
||||
saveState(
|
||||
dumpsState.dumps,
|
||||
dumpsState.page,
|
||||
dumpsState.hasMore,
|
||||
globalThis.scrollY,
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => { window.removeEventListener("scroll", onScroll); clearTimeout(timer); };
|
||||
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
globalThis.removeEventListener("scroll", onScroll);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [dumpsState, saveState]);
|
||||
|
||||
// Restore scroll position after cache restoration
|
||||
useEffect(() => {
|
||||
if (followedUsersDumps.status !== "loaded") return;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const onScroll = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (followedUsersDumps.status === "loaded") {
|
||||
saveFollowedUsers(
|
||||
followedUsersDumps.dumps,
|
||||
followedUsersDumps.page,
|
||||
followedUsersDumps.hasMore,
|
||||
globalThis.scrollY,
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
globalThis.removeEventListener("scroll", onScroll);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [followedUsersDumps, saveFollowedUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (followedPlaylistsDumps.status !== "loaded") return;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const onScroll = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (followedPlaylistsDumps.status === "loaded") {
|
||||
saveFollowedPlaylists(
|
||||
followedPlaylistsDumps.dumps,
|
||||
followedPlaylistsDumps.page,
|
||||
followedPlaylistsDumps.hasMore,
|
||||
globalThis.scrollY,
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
globalThis.removeEventListener("scroll", onScroll);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [followedPlaylistsDumps, saveFollowedPlaylists]);
|
||||
|
||||
// ── Scroll restoration ──
|
||||
|
||||
const scrollRestored = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
if (cached?.scrollY == null || scrollRestored.current) return;
|
||||
if (dumpsState.status === "loaded") {
|
||||
window.scrollTo(0, cached.scrollY);
|
||||
globalThis.scrollTo(0, cached.scrollY);
|
||||
scrollRestored.current = true;
|
||||
}
|
||||
// cached is stable (read once), safe to omit
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dumpsState.status]);
|
||||
}, [dumpsState.status, cached]);
|
||||
|
||||
// ── Derived values ──
|
||||
|
||||
const loading = dumpsState.status === "loading";
|
||||
const error = dumpsState.status === "error" ? dumpsState.error : null;
|
||||
@@ -163,11 +513,13 @@ export function Index() {
|
||||
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId);
|
||||
|
||||
const sortedDumps = [...combined].sort(
|
||||
sort === "hot"
|
||||
? (a, b) => hotScore(b) - hotScore(a)
|
||||
: (a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
|
||||
tab === "new"
|
||||
? (a, b) => b.createdAt.getTime() - a.createdAt.getTime()
|
||||
: (a, b) => hotScore(b) - hotScore(a),
|
||||
);
|
||||
|
||||
// ── Render ──
|
||||
|
||||
const presenceRow = (
|
||||
<div className="index-presence">
|
||||
{onlineUsers.map((u) => (
|
||||
@@ -188,22 +540,31 @@ export function Index() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const sortButtons = !loading && !error && combined.length > 0 && (
|
||||
const tabBar = (
|
||||
<div className="feed-sort">
|
||||
<button
|
||||
type="button"
|
||||
className={`feed-sort-btn${sort === "hot" ? " active" : ""}`}
|
||||
onClick={() => setSort("hot")}
|
||||
className={`feed-sort-btn${tab === "hot" ? " active" : ""}`}
|
||||
onClick={() => setTab("hot")}
|
||||
>
|
||||
Hot
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`feed-sort-btn${sort === "new" ? " active" : ""}`}
|
||||
onClick={() => setSort("new")}
|
||||
className={`feed-sort-btn${tab === "new" ? " active" : ""}`}
|
||||
onClick={() => setTab("new")}
|
||||
>
|
||||
New
|
||||
</button>
|
||||
{user && (
|
||||
<button
|
||||
type="button"
|
||||
className={`feed-sort-btn${tab === "followed" ? " active" : ""}`}
|
||||
onClick={() => setTab("followed")}
|
||||
>
|
||||
Followed
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -213,43 +574,102 @@ export function Index() {
|
||||
centerSlot={
|
||||
<div className="header-center-slot">
|
||||
{presenceRow}
|
||||
{sortButtons}
|
||||
{tabBar}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Shown only on narrow viewports */}
|
||||
<div className="index-below-header">
|
||||
{sortButtons}
|
||||
{tabBar}
|
||||
{presenceRow}
|
||||
</div>
|
||||
|
||||
{loading && <p className="index-status">Loading…</p>}
|
||||
{error && <p className="index-status index-status--error">{error}</p>}
|
||||
{/* Hot / New feed */}
|
||||
{tab !== "followed" && (
|
||||
<>
|
||||
{loading && <p className="index-status">Loading…</p>}
|
||||
{error && <p className="index-status index-status--error">{error}</p>}
|
||||
|
||||
{!loading && !error && combined.length === 0 && (
|
||||
<p className="index-status">No dumps yet. Be the first!</p>
|
||||
{!loading && !error && combined.length === 0 && (
|
||||
<p className="index-status">No dumps yet. Be the first!</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && combined.length > 0 && (
|
||||
<ul className="dump-feed">
|
||||
{sortedDumps.map((dump) => (
|
||||
<DumpCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwner={user?.id === dump.userId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div ref={sentinelRef} />
|
||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !error && combined.length > 0 && (
|
||||
<ul className="dump-feed">
|
||||
{sortedDumps.map((dump) => (
|
||||
<DumpCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
{/* Followed feed */}
|
||||
{tab === "followed" && user && (
|
||||
<div className="followed-feed">
|
||||
<div className="feed-sort followed-sub-nav">
|
||||
<button
|
||||
type="button"
|
||||
className={`feed-sort-btn${
|
||||
followedSection === "users" ? " active" : ""
|
||||
}`}
|
||||
onClick={() => setFollowedSection("users")}
|
||||
>
|
||||
From people
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`feed-sort-btn${
|
||||
followedSection === "playlists" ? " active" : ""
|
||||
}`}
|
||||
onClick={() => setFollowedSection("playlists")}
|
||||
>
|
||||
From playlists
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{followedSection === "users" && (
|
||||
<FollowedSubFeed
|
||||
state={followedUsersDumps}
|
||||
voteCounts={voteCounts}
|
||||
myVotes={myVotes}
|
||||
user={user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwner={user?.id === dump.userId}
|
||||
deletedDumpIds={deletedDumpIds}
|
||||
emptyMessage="Follow some users to see their dumps here."
|
||||
onLoadMore={loadMoreFollowedUsers}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
)}
|
||||
|
||||
<div ref={sentinelRef} />
|
||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||
{followedSection === "playlists" && (
|
||||
<FollowedSubFeed
|
||||
state={followedPlaylistsDumps}
|
||||
voteCounts={voteCounts}
|
||||
myVotes={myVotes}
|
||||
user={user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
deletedDumpIds={deletedDumpIds}
|
||||
emptyMessage="Follow some public playlists to see their dumps here."
|
||||
onLoadMore={loadMoreFollowedPlaylists}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user