v3: follows, notifications, invite-only registration, unread markers
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { Dump, PaginatedData, PublicUser } from "../model.ts";
|
||||
@@ -19,15 +19,66 @@ import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||
import type { Playlist, RawPlaylist } from "../model.ts";
|
||||
import { deserializePlaylist } from "../model.ts";
|
||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
|
||||
import { FollowUserButton } from "../components/FollowButton.tsx";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const hydrateDump = (raw: Dump): Dump => deserializeDump(raw as unknown as RawDump);
|
||||
function InviteButton() {
|
||||
const { authFetch } = useAuth();
|
||||
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function generate() {
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/invites`, { method: "POST" });
|
||||
const body = await res.json();
|
||||
if (body.success) {
|
||||
const url =
|
||||
`${globalThis.location.origin}/register?token=${body.data.token}`;
|
||||
setInviteUrl(url);
|
||||
} else {
|
||||
setError("Failed to generate invite");
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to generate invite");
|
||||
}
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
if (!inviteUrl) return;
|
||||
await navigator.clipboard.writeText(inviteUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
if (inviteUrl) {
|
||||
return (
|
||||
<div className="invite-result">
|
||||
<span className="invite-url">{inviteUrl}</span>
|
||||
<button type="button" className="invite-copy-btn" onClick={copy}>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="invite-generate">
|
||||
<button type="button" className="invite-btn" onClick={generate}>
|
||||
+ Invite someone
|
||||
</button>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hydrateDump = (raw: Dump): Dump =>
|
||||
deserializeDump(raw as unknown as RawDump);
|
||||
const hydratePlaylist = (raw: Playlist): Playlist =>
|
||||
deserializePlaylist(raw as unknown as RawPlaylist);
|
||||
|
||||
@@ -75,7 +126,9 @@ export function UserPublicProfile() {
|
||||
`feed:profile-votes:${username ?? ""}`,
|
||||
hydrateDump,
|
||||
);
|
||||
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<Playlist>(
|
||||
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<
|
||||
Playlist
|
||||
>(
|
||||
`feed:profile-playlists:${username ?? ""}`,
|
||||
hydratePlaylist,
|
||||
);
|
||||
@@ -104,31 +157,64 @@ export function UserPublicProfile() {
|
||||
setState({
|
||||
status: "loaded",
|
||||
user: deserializePublicUser(body.data),
|
||||
dumps: { items: cachedDumps.items, hasMore: cachedDumps.hasMore, page: cachedDumps.page, loadingMore: false },
|
||||
votes: { items: cachedVotes.items, hasMore: cachedVotes.hasMore, page: cachedVotes.page, loadingMore: false },
|
||||
playlists: { items: cachedPlaylists.items, hasMore: cachedPlaylists.hasMore, page: cachedPlaylists.page, loadingMore: false },
|
||||
dumps: {
|
||||
items: cachedDumps.items,
|
||||
hasMore: cachedDumps.hasMore,
|
||||
page: cachedDumps.page,
|
||||
loadingMore: false,
|
||||
},
|
||||
votes: {
|
||||
items: cachedVotes.items,
|
||||
hasMore: cachedVotes.hasMore,
|
||||
page: cachedVotes.page,
|
||||
loadingMore: false,
|
||||
},
|
||||
playlists: {
|
||||
items: cachedPlaylists.items,
|
||||
hasMore: cachedPlaylists.hasMore,
|
||||
page: cachedPlaylists.page,
|
||||
loadingMore: false,
|
||||
},
|
||||
});
|
||||
setProfileVotedIds(new Set(cachedVotes.items.map((d) => d.id)));
|
||||
})
|
||||
.catch((err) =>
|
||||
setState({ status: "error", error: err instanceof Error ? err.message : "Failed to load profile" })
|
||||
setState({
|
||||
status: "error",
|
||||
error: err instanceof Error
|
||||
? err.message
|
||||
: "Failed to load profile",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
const authHeaders: HeadersInit = token
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: {};
|
||||
const [userRes, dumpsRes, votesRes, playlistsRes] = await Promise.all([
|
||||
fetch(`${API_URL}/api/users/${username}`),
|
||||
fetch(`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
|
||||
fetch(`${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
|
||||
fetch(`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`,
|
||||
{ headers: authHeaders },
|
||||
),
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`,
|
||||
{ headers: authHeaders },
|
||||
),
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`,
|
||||
{ headers: authHeaders },
|
||||
),
|
||||
]);
|
||||
|
||||
if (!userRes.ok) {
|
||||
throw new Error(
|
||||
userRes.status === 404 ? "User not found" : `HTTP ${userRes.status}`,
|
||||
userRes.status === 404
|
||||
? "User not found"
|
||||
: `HTTP ${userRes.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,7 +240,10 @@ export function UserPublicProfile() {
|
||||
setState({
|
||||
status: "loaded",
|
||||
user: deserializePublicUser(userBody.data),
|
||||
dumps: initialList(dumpsData.items.map(deserializeDump), dumpsData.hasMore),
|
||||
dumps: initialList(
|
||||
dumpsData.items.map(deserializeDump),
|
||||
dumpsData.hasMore,
|
||||
),
|
||||
votes: initialList(voteItems, votesData.hasMore),
|
||||
playlists: initialList(
|
||||
playlistsData.items.map(deserializePlaylist),
|
||||
@@ -189,7 +278,10 @@ export function UserPublicProfile() {
|
||||
myVotes.has(d.id) && !prev.has(d.id) && !voteIds.has(d.id)
|
||||
);
|
||||
if (toAdd.length === 0) return s;
|
||||
return { ...s, votes: { ...s.votes, items: [...toAdd, ...s.votes.items] } };
|
||||
return {
|
||||
...s,
|
||||
votes: { ...s.votes, items: [...toAdd, ...s.votes.items] },
|
||||
};
|
||||
});
|
||||
prevMyVotesRef.current = new Set(myVotes);
|
||||
}, [myVotes, me, profileUserId]);
|
||||
@@ -219,10 +311,16 @@ export function UserPublicProfile() {
|
||||
if (!body.success) return;
|
||||
const dump = deserializeDump(body.data);
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded" || s.votes.items.some((d) => d.id === dumpId)) {
|
||||
if (
|
||||
s.status !== "loaded" ||
|
||||
s.votes.items.some((d) => d.id === dumpId)
|
||||
) {
|
||||
return s;
|
||||
}
|
||||
return { ...s, votes: { ...s.votes, items: [dump, ...s.votes.items] } };
|
||||
return {
|
||||
...s,
|
||||
votes: { ...s.votes, items: [dump, ...s.votes.items] },
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -243,7 +341,10 @@ export function UserPublicProfile() {
|
||||
if (s.playlists.items.some((p) => p.id === ev.playlist!.id)) return s;
|
||||
return {
|
||||
...s,
|
||||
playlists: { ...s.playlists, items: [ev.playlist!, ...s.playlists.items] },
|
||||
playlists: {
|
||||
...s.playlists,
|
||||
items: [ev.playlist!, ...s.playlists.items],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -278,7 +379,9 @@ export function UserPublicProfile() {
|
||||
if (deletedPlaylistIds.size === 0 || state.status !== "loaded") return;
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const filtered = s.playlists.items.filter((p) => !deletedPlaylistIds.has(p.id));
|
||||
const filtered = s.playlists.items.filter((p) =>
|
||||
!deletedPlaylistIds.has(p.id)
|
||||
);
|
||||
if (filtered.length === s.playlists.items.length) return s;
|
||||
return { ...s, playlists: { ...s.playlists, items: filtered } };
|
||||
});
|
||||
@@ -292,14 +395,22 @@ export function UserPublicProfile() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (state.status !== "loaded") return;
|
||||
const y = window.scrollY;
|
||||
const y = globalThis.scrollY;
|
||||
saveDumps(state.dumps.items, state.dumps.page, state.dumps.hasMore, y);
|
||||
saveVotes(state.votes.items, state.votes.page, state.votes.hasMore, y);
|
||||
savePlaylists(state.playlists.items, state.playlists.page, state.playlists.hasMore, y);
|
||||
savePlaylists(
|
||||
state.playlists.items,
|
||||
state.playlists.page,
|
||||
state.playlists.hasMore,
|
||||
y,
|
||||
);
|
||||
}, 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);
|
||||
};
|
||||
}, [state, saveDumps, saveVotes, savePlaylists]);
|
||||
|
||||
// Restore scroll position after cache restoration
|
||||
@@ -307,94 +418,10 @@ export function UserPublicProfile() {
|
||||
useLayoutEffect(() => {
|
||||
if (cachedDumps?.scrollY == null || scrollRestored.current) return;
|
||||
if (state.status === "loaded") {
|
||||
window.scrollTo(0, cachedDumps.scrollY);
|
||||
globalThis.scrollTo(0, cachedDumps.scrollY);
|
||||
scrollRestored.current = true;
|
||||
}
|
||||
// cachedDumps is stable (read once), safe to omit
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.status]);
|
||||
|
||||
const loadMoreDumps = useCallback(() => {
|
||||
if (state.status !== "loaded" || !state.dumps.hasMore || state.dumps.loadingMore || !username) return;
|
||||
const nextPage = state.dumps.page + 1;
|
||||
setState((s) => s.status === "loaded" ? { ...s, dumps: { ...s.dumps, loadingMore: true } } : s);
|
||||
fetch(`${API_URL}/api/users/${username}/dumps?page=${nextPage}&limit=${PAGE_SIZE}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
dumps: {
|
||||
items: [...s.dumps.items, ...items.map(deserializeDump)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
},
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() => setState((s) => s.status === "loaded" ? { ...s, dumps: { ...s.dumps, loadingMore: false } } : s));
|
||||
}, [state, username, token]);
|
||||
|
||||
const loadMoreVotes = useCallback(() => {
|
||||
if (state.status !== "loaded" || !state.votes.hasMore || state.votes.loadingMore || !username) return;
|
||||
const nextPage = state.votes.page + 1;
|
||||
setState((s) => s.status === "loaded" ? { ...s, votes: { ...s.votes, loadingMore: true } } : s);
|
||||
fetch(`${API_URL}/api/users/${username}/votes?page=${nextPage}&limit=${PAGE_SIZE}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
votes: {
|
||||
items: [...s.votes.items, ...items.map(deserializeDump)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
},
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() => setState((s) => s.status === "loaded" ? { ...s, votes: { ...s.votes, loadingMore: false } } : s));
|
||||
}, [state, username, token]);
|
||||
|
||||
const loadMorePlaylists = useCallback(() => {
|
||||
if (state.status !== "loaded" || !state.playlists.hasMore || state.playlists.loadingMore || !username) return;
|
||||
const nextPage = state.playlists.page + 1;
|
||||
setState((s) => s.status === "loaded" ? { ...s, playlists: { ...s.playlists, loadingMore: true } } : s);
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
playlists: {
|
||||
items: [...s.playlists.items, ...items.map(deserializePlaylist)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
},
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() => setState((s) => s.status === "loaded" ? { ...s, playlists: { ...s.playlists, loadingMore: false } } : s));
|
||||
}, [state, username, token]);
|
||||
}, [state.status, cachedDumps]);
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -432,7 +459,10 @@ export function UserPublicProfile() {
|
||||
|
||||
setState((prev) =>
|
||||
prev.status === "loaded"
|
||||
? { ...prev, user: { ...prev.user, avatarMime: body.data?.avatarMime } }
|
||||
? {
|
||||
...prev,
|
||||
user: { ...prev.user, avatarMime: body.data?.avatarMime },
|
||||
}
|
||||
: prev
|
||||
);
|
||||
} catch {
|
||||
@@ -504,11 +534,37 @@ export function UserPublicProfile() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="profile-username">{profileUser.username}</h1>
|
||||
{profileUser.invitedByUsername
|
||||
? (
|
||||
<p className="profile-invited-by">
|
||||
invited by{" "}
|
||||
<Link
|
||||
to={`/users/${profileUser.invitedByUsername}`}
|
||||
className="profile-invited-by-link"
|
||||
>
|
||||
@{profileUser.invitedByUsername}
|
||||
</Link>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<p className="profile-invited-by profile-invited-by--founding">
|
||||
O.G.
|
||||
</p>
|
||||
)}
|
||||
{avatarError && <p className="form-error">{avatarError}</p>}
|
||||
{!isOwnProfile && (
|
||||
<FollowUserButton
|
||||
targetUserId={profileUser.id}
|
||||
targetUsername={profileUser.username}
|
||||
/>
|
||||
)}
|
||||
{isOwnProfile && (
|
||||
<button type="button" className="logout-btn" onClick={logout}>
|
||||
Log out
|
||||
</button>
|
||||
<div className="profile-own-actions">
|
||||
<InviteButton />
|
||||
<button type="button" className="logout-btn" onClick={logout}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -523,9 +579,7 @@ export function UserPublicProfile() {
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwnProfile={isOwnProfile}
|
||||
hasMore={dumps.hasMore}
|
||||
loadingMore={dumps.loadingMore}
|
||||
onLoadMore={loadMoreDumps}
|
||||
viewAllHref={`/users/${profileUser.username}/dumps`}
|
||||
/>
|
||||
|
||||
<UpvotedDumpList
|
||||
@@ -537,16 +591,15 @@ export function UserPublicProfile() {
|
||||
canVote={!!me}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
hasMore={votes.hasMore}
|
||||
loadingMore={votes.loadingMore}
|
||||
onLoadMore={loadMoreVotes}
|
||||
viewAllHref={`/users/${profileUser.username}/upvoted`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="profile-section" id="playlists">
|
||||
<div className="profile-section-header">
|
||||
<h2 className="profile-section-title">
|
||||
Playlists ({playlists.items.length}{playlists.hasMore ? "+" : ""})
|
||||
Playlists ({playlists.items.length}
|
||||
{playlists.hasMore ? "+" : ""})
|
||||
</h2>
|
||||
{isOwnProfile && (
|
||||
<NewPlaylistForm
|
||||
@@ -556,7 +609,10 @@ export function UserPublicProfile() {
|
||||
if (s.playlists.items.some((pl) => pl.id === p.id)) return s;
|
||||
return {
|
||||
...s,
|
||||
playlists: { ...s.playlists, items: [p, ...s.playlists.items] },
|
||||
playlists: {
|
||||
...s.playlists,
|
||||
items: [p, ...s.playlists.items],
|
||||
},
|
||||
};
|
||||
})}
|
||||
/>
|
||||
@@ -567,38 +623,23 @@ export function UserPublicProfile() {
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{playlists.items.map((p) => (
|
||||
<PlaylistCard key={p.id} playlist={p} />
|
||||
<PlaylistCard key={p.id} playlist={p} isOwner={isOwnProfile} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<PlaylistSentinel
|
||||
hasMore={playlists.hasMore}
|
||||
loadingMore={playlists.loadingMore}
|
||||
onLoadMore={loadMorePlaylists}
|
||||
/>
|
||||
{playlists.items.length > 0 && (
|
||||
<Link
|
||||
to={`/users/${profileUser.username}/playlists`}
|
||||
className="profile-view-all"
|
||||
>
|
||||
View all →
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sentinel wrapper (keeps hooks at top level) ──────────────────────────────
|
||||
|
||||
function PlaylistSentinel(
|
||||
{ hasMore, loadingMore, onLoadMore }: {
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
},
|
||||
) {
|
||||
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
|
||||
return (
|
||||
<>
|
||||
<div ref={sentinelRef} />
|
||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Plain dump list ──────────────────────────────────────────────────────────
|
||||
|
||||
function DumpList(
|
||||
@@ -611,9 +652,7 @@ function DumpList(
|
||||
castVote,
|
||||
removeVote,
|
||||
isOwnProfile,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
viewAllHref,
|
||||
}: {
|
||||
title: string;
|
||||
dumps: Dump[];
|
||||
@@ -623,13 +662,10 @@ function DumpList(
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
isOwnProfile?: boolean;
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
viewAllHref: string;
|
||||
},
|
||||
) {
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
|
||||
return (
|
||||
<section className="profile-section">
|
||||
<div className="profile-section-header">
|
||||
@@ -665,8 +701,9 @@ function DumpList(
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div ref={sentinelRef} />
|
||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||
{dumps.length > 0 && (
|
||||
<Link to={viewAllHref} className="profile-view-all">View all →</Link>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -683,9 +720,7 @@ function UpvotedDumpList(
|
||||
canVote,
|
||||
castVote,
|
||||
removeVote,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
viewAllHref,
|
||||
}: {
|
||||
title: string;
|
||||
dumps: Dump[];
|
||||
@@ -695,15 +730,14 @@ function UpvotedDumpList(
|
||||
canVote: boolean;
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
viewAllHref: string;
|
||||
},
|
||||
) {
|
||||
const [fading, setFading] = useState<Record<string, "cooldown" | "dismissing">>({});
|
||||
const [fading, setFading] = useState<
|
||||
Record<string, "cooldown" | "dismissing">
|
||||
>({});
|
||||
const cancels = useRef<Map<string, () => void>>(new Map());
|
||||
const prevVotedIds = useRef<Set<string> | null>(null);
|
||||
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
|
||||
|
||||
useEffect(() => () => {
|
||||
cancels.current.forEach((c) => c());
|
||||
@@ -809,8 +843,9 @@ function UpvotedDumpList(
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<div ref={sentinelRef} />
|
||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||
{visibleDumps.length > 0 && (
|
||||
<Link to={viewAllHref} className="profile-view-all">View all →</Link>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user