v3: follows, notifications, invite-only registration, unread markers

This commit is contained in:
khannurien
2026-03-21 18:42:47 +00:00
parent 7c098e7c4c
commit 608c6bc6a8
55 changed files with 4743 additions and 884 deletions

View File

@@ -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>
);
}