import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { Link, useNavigate, useParams } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { API_URL, DEFAULT_PAGE_SIZE, VALIDATION } from "../config/api.ts"; import type { Dump, InviteTreeEntry, PaginatedData, PublicUser, Role, } from "../model.ts"; import { deserializeAuthResponse, deserializeDump, deserializeInviteTreeEntry, deserializePublicUser, hydrateDump, hydratePlaylist, parseAPIResponse, type RawDump, type RawInviteTreeEntry, type RawPublicUser, type UpdateUserRequest, } from "../model.ts"; import { Avatar } from "../components/Avatar.tsx"; import { DumpCard } from "../components/DumpCard.tsx"; import { PlaylistCard } from "../components/PlaylistCard.tsx"; import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx"; import { PageShell } from "../components/PageShell.tsx"; import { PageError } from "../components/PageError.tsx"; import { useAuth } from "../hooks/useAuth.ts"; import { useDocumentTitle } from "../hooks/useDocumentTitle.ts"; import { useTheme } from "../hooks/useTheme.ts"; import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts"; import { useWS } from "../hooks/useWS.ts"; import { useDumpListSync } from "../hooks/useDumpListSync.ts"; import { useFading } from "../hooks/useFading.ts"; import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts"; import { usePositionAwareSync } from "../hooks/usePositionAwareSync.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"; import { ErrorCard } from "../components/ErrorCard.tsx"; import { friendlyFetchError } from "../utils/apiError.ts"; import { can } from "../utils/permissions.ts"; import { TextEditor } from "../components/TextEditor.tsx"; import { Markdown } from "../components/Markdown.tsx"; import { ChangePasswordModal } from "../components/ChangePasswordModal.tsx"; import { TabBar } from "../components/TabBar.tsx"; import { useTabParam } from "../hooks/useTabParam.ts"; import { Controller } from "react-hook-form"; import { expectOk, FormError, FormProvider, SubmitButton, useApiForm, } from "../components/form/index.ts"; function InviteButton() { const { authFetch } = useAuth(); const [inviteUrl, setInviteUrl] = useState(null); const [copied, setCopied] = useState(false); const [error, setError] = useState(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(t`Failed to generate invite`); } } catch { setError(t`Failed to generate invite`); } } async function copy() { if (!inviteUrl) return; await navigator.clipboard.writeText(inviteUrl); setCopied(true); setTimeout(() => setCopied(false), 2000); } if (inviteUrl) { return (
{inviteUrl}
); } return (
{error && ( )}
); } interface PaginatedList { items: T[]; hasMore: boolean; page: number; loadingMore: boolean; } function initialList(items: T[], hasMore: boolean): PaginatedList { return { items, hasMore, page: 1, loadingMore: false }; } type ProfileState = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; user: PublicUser; dumps: PaginatedList; votes: PaginatedList; playlists: PaginatedList; }; type FollowedState = | null | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; users: PaginatedList; playlists: PaginatedList; }; type InviteTreeState = | null | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; entries: InviteTreeEntry[] }; type InviteTreeNode = InviteTreeEntry & { children: InviteTreeNode[] }; const PROFILE_TABS = [ "dumps", "playlists", "followed", "invitees", "settings", "manage", ] as const; type ProfileTab = (typeof PROFILE_TABS)[number]; function buildInviteTree( entries: InviteTreeEntry[], parentId: string, ): InviteTreeNode[] { return entries .filter((e) => e.invitedById === parentId) .map((e) => ({ ...e, children: buildInviteTree(entries, e.id) })); } export function UserPublicProfile() { const { username } = useParams(); const navigate = useNavigate(); const { user: me, authFetch, login, logout, token } = useAuth(); const { style, colorScheme, setStyle, setColorScheme } = useTheme(); const [defaultFeedTab, setDefaultFeedTab] = useDefaultFeedTab(); const { voteCounts, myVotes, lastVoteEvent, lastDumpEvent, lastPlaylistEvent, lastUserEvent, castVote, removeVote, } = useWS(); const { cached: cachedDumps, saveState: saveDumps } = useFeedCache( `feed:profile-dumps:${username ?? ""}`, hydrateDump, ); const { cached: cachedVotes, saveState: saveVotes } = useFeedCache( `feed:profile-votes:${username ?? ""}`, hydrateDump, ); const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache< Playlist >( `feed:profile-playlists:${username ?? ""}`, hydratePlaylist, ); const [state, setState] = useState({ status: "loading" }); useDocumentTitle( state.status === "loaded" ? state.user.username : state.status === "error" ? null : undefined, ); const profileUserId = state.status === "loaded" ? state.user.id : null; const isOwnProfile = me?.id === profileUserId; // Active tab is driven by the `/~/` URL path (linkable / refresh-safe). // `settings` is own-profile only; `manage` requires the user:manage permission // on someone else's profile. Invalid tabs fall back to "dumps". const canManageUsers = can(me, "user:manage"); const availableTabs: ProfileTab[] = PROFILE_TABS.filter((t) => { if (t === "settings") return isOwnProfile; if (t === "manage") return !isOwnProfile && canManageUsers; return true; }); const [tab, setTab] = useTabParam(availableTabs, "dumps"); const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => { setState((s) => s.status !== "loaded" ? s : { ...s, dumps: { ...s.dumps, items: fn(s.dumps.items) } } ); }, []); const dumpItems = state.status === "loaded" ? state.dumps.items : []; usePositionAwareSync( dumpItems, setDumps, lastDumpEvent, (d) => d.isPrivate, (d) => !d.isPrivate && d.userId === profileUserId, ); useDumpListSync(setDumps, { ownerId: profileUserId ?? undefined, isOwner: isOwnProfile, skipReinsert: true, }); // Dump IDs removed due to vote withdrawal — must not be re-inserted on // a future dump_updated event (that would only be for private→public transitions). const withdrawnVoteIdsRef = useRef>(new Set()); const setVotes = useCallback((fn: (prev: Dump[]) => Dump[]) => { setState((s) => s.status !== "loaded" ? s : { ...s, votes: { ...s.votes, items: fn(s.votes.items) } } ); }, []); const voteItems = state.status === "loaded" ? state.votes.items : []; usePositionAwareSync( voteItems, setVotes, lastDumpEvent, (d) => d.isPrivate, (d) => !d.isPrivate && !withdrawnVoteIdsRef.current.has(d.id), ); useDumpListSync(setVotes, { skipReinsert: true }); const setPlaylists = useCallback((fn: (prev: Playlist[]) => Playlist[]) => { setState((s) => s.status !== "loaded" ? s : { ...s, playlists: { ...s.playlists, items: fn(s.playlists.items) } } ); }, []); const playlistItems = state.status === "loaded" ? state.playlists.items : []; const lastPlaylistItem = lastPlaylistEvent?.type === "updated" ? (lastPlaylistEvent.playlist ?? null) : null; usePositionAwareSync( playlistItems, setPlaylists, lastPlaylistItem, (p) => !p.isPublic, (p) => p.isPublic && p.userId === profileUserId, ); usePlaylistListSync(setPlaylists, { isOwner: isOwnProfile, ownerId: profileUserId ?? undefined, skipReinsert: true, }); // Update profile user when they edit their own profile useEffect(() => { if (!lastUserEvent) return; const { user } = lastUserEvent; // Subscribing to the WS-pushed user-update stream — setState here // mirrors external state into local state, no synchronous alternative. // eslint-disable-next-line react-hooks/set-state-in-effect setState((s) => { if (s.status !== "loaded" || s.user.id !== user.id) return s; return { ...s, user }; }); }, [lastUserEvent]); const [uploading, setUploading] = useState(false); const [avatarError, setAvatarError] = useState(null); const fileInputRef = useRef(null); const [descEditing, setDescEditing] = useState(false); const [emailEditing, setEmailEditing] = useState(false); const prevMyVotesRef = useRef | null>(null); const [changePasswordOpen, setChangePasswordOpen] = useState(false); const [followedState, setFollowedState] = useState(null); const [inviteTreeState, setInviteTreeState] = useState(null); const inviteTreeNodes = useMemo(() => { if (!profileUserId || inviteTreeState?.status !== "loaded") return []; return buildInviteTree(inviteTreeState.entries, profileUserId); }, [inviteTreeState, profileUserId]); const [prevUsername, setPrevUsername] = useState(username); if (prevUsername !== username) { setPrevUsername(username); setState({ status: "loading" }); setFollowedState(null); setInviteTreeState(null); } // Refs can't be written during render — reset in an effect instead, which // still runs (and clears the ref) before the vote-diffing effect below. useEffect(() => { prevMyVotesRef.current = null; }, [username]); useEffect(() => { if (!username) return; const controller = new AbortController(); const allCached = cachedDumps && cachedVotes && cachedPlaylists; if (allCached) { // Only fetch the user object (lightweight, always fresh) fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }) .then((r) => r.json()) .then((body) => { if (!body.success) throw new Error("User not found"); const profileUser = deserializePublicUser(body.data); setState({ status: "loaded", user: profileUser, 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, }, }); }) .catch((err) => { if (err.name === "AbortError") return; setState({ status: "error", error: friendlyFetchError(err) }); }); return () => controller.abort(); } (async () => { try { const authHeaders: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {}; const [userRes, dumpsRes, votesRes, playlistsRes] = await Promise.all([ fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal, }), fetch( `${API_URL}/api/users/${username}/dumps?page=1&limit=${DEFAULT_PAGE_SIZE}`, { headers: authHeaders, signal: controller.signal }, ), fetch( `${API_URL}/api/users/${username}/votes?page=1&limit=${DEFAULT_PAGE_SIZE}`, { headers: authHeaders, signal: controller.signal }, ), fetch( `${API_URL}/api/users/${username}/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`, { headers: authHeaders, signal: controller.signal }, ), ]); if (!userRes.ok) { throw new Error( userRes.status === 404 ? "User not found" : `HTTP ${userRes.status}`, ); } const [userBody, dumpsBody, votesBody, playlistsBody] = await Promise .all([ userRes.json(), dumpsRes.json(), votesRes.json(), playlistsRes.json(), ]); const votesData: PaginatedData = votesBody.success ? votesBody.data : { items: [], total: 0, hasMore: false }; const playlistsData: PaginatedData = playlistsBody.success ? playlistsBody.data : { items: [], total: 0, hasMore: false }; const dumpsData: PaginatedData = dumpsBody.success ? dumpsBody.data : { items: [], total: 0, hasMore: false }; const profileUser = deserializePublicUser(userBody.data); const voteItems = votesData.items.map(deserializeDump); setState({ status: "loaded", user: profileUser, dumps: initialList( dumpsData.items.map(deserializeDump), dumpsData.hasMore, ), votes: initialList(voteItems, votesData.hasMore), playlists: initialList( playlistsData.items.map(deserializePlaylist), playlistsData.hasMore, ), }); } catch (err) { if ((err as Error).name === "AbortError") return; setState({ status: "error", error: friendlyFetchError(err), }); } })(); return () => controller.abort(); }, [username, cachedDumps, cachedVotes, cachedPlaylists, token]); // Own profile: prepend dumps newly voted by the user to the preview list useEffect(() => { if (!profileUserId || me?.id !== profileUserId) return; if (prevMyVotesRef.current === null) { prevMyVotesRef.current = new Set(myVotes); return; } const prev = prevMyVotesRef.current; setState((s) => { if (s.status !== "loaded") return s; const voteIds = new Set(s.votes.items.map((d) => d.id)); const toAdd = s.dumps.items.filter((d) => 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] }, }; }); prevMyVotesRef.current = new Set(myVotes); }, [myVotes, me, profileUserId]); // Real-time upvoted list sync via WS vote events useEffect(() => { if (!lastVoteEvent || !profileUserId) return; const { dumpId, voterId, action } = lastVoteEvent; if (voterId !== profileUserId) return; if (action === "remove") { // Keep dump in state.votes.items as a ghost — UpvotedDumpList drives // its own votedIds + fading state and will animate the removal. withdrawnVoteIdsRef.current.add(dumpId); } else { withdrawnVoteIdsRef.current.delete(dumpId); fetch(`${API_URL}/api/dumps/${dumpId}`) .then((r) => r.json()) .then((body) => { if (!body.success) return; const dump = deserializeDump(body.data); setState((s) => { if (s.status !== "loaded") return s; const idx = s.votes.items.findIndex((d) => d.id === dumpId); if (idx !== -1) { // Ghost re-voted: update in-place. const items = [...s.votes.items]; items[idx] = dump; return { ...s, votes: { ...s.votes, items } }; } // First-time vote: prepend. return { ...s, votes: { ...s.votes, items: [dump, ...s.votes.items] }, }; }); }) .catch(() => {}); } }, [lastVoteEvent, profileUserId]); // Save scroll position + loaded state to sessionStorage on scroll useEffect(() => { if (state.status !== "loaded") return; let timer: ReturnType; const onScroll = () => { clearTimeout(timer); timer = setTimeout(() => { if (state.status !== "loaded") return; 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, ); }, 100); }; globalThis.addEventListener("scroll", onScroll, { passive: true }); return () => { globalThis.removeEventListener("scroll", onScroll); clearTimeout(timer); }; }, [state, saveDumps, saveVotes, savePlaylists]); // Keep the playlists cache current whenever the list changes (e.g. via WS), // so a page refresh restores the up-to-date list rather than a stale snapshot. const playlistFeed = state.status === "loaded" ? state.playlists : null; useEffect(() => { if (!playlistFeed) return; savePlaylists( playlistFeed.items, playlistFeed.page, playlistFeed.hasMore, globalThis.scrollY, ); }, [playlistFeed, savePlaylists]); // Lazy-load followed users + playlists when the Followed tab is first opened useEffect(() => { if ( tab !== "followed" || followedState !== null || state.status !== "loaded" ) { return; } queueMicrotask(() => setFollowedState({ status: "loading" })); const controller = new AbortController(); Promise.all([ fetch( `${API_URL}/api/users/${username}/followed-users?page=1&limit=${DEFAULT_PAGE_SIZE}`, { signal: controller.signal }, ), fetch( `${API_URL}/api/users/${username}/followed-playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`, { signal: controller.signal }, ), ]) .then(([ur, pr]) => Promise.all([ur.json(), pr.json()])) .then(([ub, pb]) => { const usersData = ub.success ? ub.data as PaginatedData : { items: [], hasMore: false }; const playlistsData = pb.success ? pb.data as PaginatedData : { items: [], hasMore: false }; setFollowedState({ status: "loaded", users: initialList( usersData.items.map(deserializePublicUser), usersData.hasMore, ), playlists: initialList( playlistsData.items.map(deserializePlaylist), playlistsData.hasMore, ), }); }) .catch((err) => { if (err.name === "AbortError") return; setFollowedState({ status: "error", error: friendlyFetchError(err) }); }); return () => controller.abort(); // followedState intentionally omitted: it's a read-once guard, not a trigger. // Including it would abort the in-flight fetch when state changes to "loading". // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab, state.status, username]); // Lazy-load invite tree when the Invitees tab is first opened useEffect(() => { if ( tab !== "invitees" || inviteTreeState !== null || state.status !== "loaded" ) { return; } queueMicrotask(() => setInviteTreeState({ status: "loading" })); const controller = new AbortController(); fetch( `${API_URL}/api/users/${username}/invitees`, { signal: controller.signal }, ) .then((r) => r.json()) .then((body) => { const entries: InviteTreeEntry[] = body.success ? (body.data as RawInviteTreeEntry[]).map(deserializeInviteTreeEntry) : []; setInviteTreeState({ status: "loaded", entries }); }) .catch((err) => { if (err.name === "AbortError") return; setInviteTreeState({ status: "error", error: friendlyFetchError(err) }); }); return () => controller.abort(); // inviteTreeState intentionally omitted — same read-once guard pattern. // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab, state.status, username]); // Restore scroll position after cache restoration const scrollRestored = useRef(false); useLayoutEffect(() => { if (cachedDumps?.scrollY == null || scrollRestored.current) return; if (state.status === "loaded") { globalThis.scrollTo(0, cachedDumps.scrollY); scrollRestored.current = true; } }, [state.status, cachedDumps]); const handleAvatarUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file || state.status !== "loaded") return; setAvatarError(null); setUploading(true); try { const formData = new FormData(); formData.append("file", file); const res = await authFetch(`${API_URL}/api/avatars/me`, { method: "POST", body: formData, }); const body = parseAPIResponse(await res.json()); if (!body.success) { setAvatarError(body.error.message); return; } const storedRaw = localStorage.getItem("authResponse"); if (storedRaw) { login({ ...deserializeAuthResponse(JSON.parse(storedRaw)), user: deserializePublicUser(body.data), }); } setState((prev) => prev.status === "loaded" ? { ...prev, user: deserializePublicUser(body.data) } : prev ); } catch { setAvatarError(t`Upload failed`); } finally { setUploading(false); if (fileInputRef.current) fileInputRef.current.value = ""; } }; const handleEmailSaved = (email: string) => { const storedRaw = localStorage.getItem("authResponse"); if (storedRaw) { const prev = deserializeAuthResponse(JSON.parse(storedRaw)); login({ ...prev, user: { ...prev.user, email } }); } setEmailEditing(false); }; const handleDescSaved = (description: string | undefined) => { setState((s) => s.status === "loaded" ? { ...s, user: { ...s.user, description } } : s ); setDescEditing(false); }; const handleRoleSaved = (role: Role) => { setState((s) => s.status === "loaded" ? { ...s, user: { ...s.user, role } } : s ); }; if (state.status === "loading") { return (

Loading profile…

); } if (state.status === "error") { return ( {me && ( )} } /> ); } const { user: profileUser, dumps, votes, playlists } = state; return (
{isOwnProfile && ( )}

{profileUser.username}

{profileUser.invitedByUsername ? (

invited by{" "} @{profileUser.invitedByUsername}

) : (

O.G.

)} {isOwnProfile && ( emailEditing ? ( setEmailEditing(false)} onSaved={handleEmailSaved} /> ) : (

setEmailEditing(true)} title="Edit email" > {me?.email ?? t`Add email…`}

) )} {avatarError && ( )} {!isOwnProfile && ( )} {isOwnProfile && (
)}
{(profileUser.description || isOwnProfile) && (
{descEditing ? ( setDescEditing(false)} onSaved={handleDescSaved} /> ) : (
setDescEditing(true) : undefined} > {profileUser.description ? ( {profileUser.description} ) : (
Who am I?
)} {isOwnProfile && ( )}
)}
)} Dumps }, { key: "playlists", label: Playlists }, { key: "followed", label: Followed }, { key: "invitees", label: Invitees }, ...(isOwnProfile ? [{ key: "settings" as const, label: Settings }] : []), ...(!isOwnProfile && canManageUsers ? [{ key: "manage" as const, label: Manage }] : []), ]} activeTab={tab} onChange={(key) => setTab(key)} className="profile-tabs-scroller" innerClassName="profile-tabs" /> {tab === "dumps" && (
)} {tab === "playlists" && (

Playlists ({playlists.items.length} {playlists.hasMore ? "+" : ""})

{isOwnProfile && ( setState((s) => { if (s.status !== "loaded") return s; if ( s.playlists.items.some((pl) => pl.id === p.id ) ) { return s; } return { ...s, playlists: { ...s.playlists, items: [p, ...s.playlists.items], }, }; })} /> )}
{playlists.items.length === 0 ? (

No playlists yet.

) : (
    {playlists.items.map((p) => ( ))}
)} {playlists.items.length > 0 && ( View all → )}
)} {tab === "followed" && (

Following

{followedState === null || followedState.status === "loading" ? (

Loading…

) : followedState.status === "error" ? ( ) : followedState.users.items.length === 0 ? (

Not following anyone yet.

) : ( <>
    {followedState.users.items.map((u) => ( ))}
{followedState.users.hasMore && ( View all → )} )}

Followed playlists

{followedState === null || followedState.status === "loading" ? (

Loading…

) : followedState.status === "error" ? ( ) : followedState.playlists.items.length === 0 ? (

No followed playlists yet.

) : ( <>
    {followedState.playlists.items.map((p) => ( ))}
{followedState.playlists.hasMore && ( View all → )} )}
)} {tab === "invitees" && (

Invitees

{inviteTreeState === null || inviteTreeState.status === "loading" ? (

Loading…

) : inviteTreeState.status === "error" ? ( ) : inviteTreeState.entries.length === 0 ? (

No invitees yet.

) : }
)} {changePasswordOpen && ( setChangePasswordOpen(false)} /> )} {tab === "settings" && isOwnProfile && ( <>

Account

Password

Appearance

Style
Color scheme

Feeds

Default tab
)} {tab === "manage" && !isOwnProfile && canManageUsers && (

User management

)}
); } // ── Plain dump list ────────────────────────────────────────────────────────── function DumpList( { title, dumps, voteCounts, myVotes, canVote, castVote, removeVote, isOwnProfile, viewAllHref, }: { title: string; dumps: Dump[]; voteCounts: Record; myVotes: Set; canVote: boolean; castVote: (id: string) => void; removeVote: (id: string) => void; isOwnProfile?: boolean; viewAllHref: string; }, ) { const [createModalOpen, setCreateModalOpen] = useState(false); return (

{title}

{isOwnProfile && ( )}
{createModalOpen && ( setCreateModalOpen(false)} /> )} {dumps.length === 0 ? (

Nothing here yet.

) : (
    {dumps.map((dump) => ( ))}
)} {dumps.length > 0 && ( View all → )}
); } // ── Upvoted list: fades items out when votes are removed ───────────────────── function UpvotedDumpList( { title, dumps, profileUserId, isOwnProfile, voteCounts, myVotes, canVote, castVote, removeVote, viewAllHref, }: { title: string; dumps: Dump[]; profileUserId: string | null; isOwnProfile: boolean; voteCounts: Record; myVotes: Set; canVote: boolean; castVote: (id: string) => void; removeVote: (id: string) => void; viewAllHref: string; }, ) { const { myVotes: wsMyVotes, lastVoteEvent } = useWS(); const { fading, startFading, cancelFading } = useFading(); // votedIds is managed locally so setVotedIds + startFading/cancelFading can // be called in the same effect body — guaranteeing a single render where the // dump is always in visibleDumps (with or without fading class). This prevents // the DOM node from being unmounted/remounted, which would break CSS transitions. const [votedIds, setVotedIds] = useState(() => new Set(dumps.map((d) => d.id)) ); const prevMyVotesRef = useRef | null>(null); // Own profile: sync votedIds with myVotes; start/cancel fading in same batch. // setVotedIds and startFading/cancelFading must be called together synchronously // in the same effect to guarantee a single render where the DOM node isn't // unmounted — converting to render-phase isn't possible because startFading/ // cancelFading are themselves setState calls that can't run during render. useEffect(() => { if (!profileUserId || !isOwnProfile) return; if (prevMyVotesRef.current === null) { // setVotedIds + prevMyVotesRef must be co-located to stay consistent. setVotedIds(new Set(wsMyVotes)); prevMyVotesRef.current = new Set(wsMyVotes); return; } const prev = prevMyVotesRef.current; setVotedIds(new Set(wsMyVotes)); for (const id of prev) if (!wsMyVotes.has(id)) startFading(id); for (const id of wsMyVotes) if (!prev.has(id)) cancelFading(id); prevMyVotesRef.current = new Set(wsMyVotes); }, [wsMyVotes, isOwnProfile, profileUserId, startFading, cancelFading]); // Non-own profile: sync votedIds with WS vote events for the profile user. // Same constraint as above: setVotedIds and startFading/cancelFading must // fire together so the DOM node stays mounted throughout the transition. useEffect(() => { if (!lastVoteEvent || !profileUserId || isOwnProfile) return; const { dumpId, voterId, action } = lastVoteEvent; if (voterId !== profileUserId) return; if (action === "remove") { // setVotedIds and startFading must fire together to avoid a render with // stale votedIds between the two updates. // eslint-disable-next-line react-hooks/set-state-in-effect setVotedIds((prev) => { const n = new Set(prev); n.delete(dumpId); return n; }); startFading(dumpId); } else { setVotedIds((prev) => new Set([...prev, dumpId])); cancelFading(dumpId); } }, [lastVoteEvent, profileUserId, isOwnProfile, startFading, cancelFading]); const visibleDumps = dumps.filter((d) => votedIds.has(d.id) || d.id in fading ); return (

{title}

{visibleDumps.length === 0 ? (

Nothing here yet.

) : (
    {visibleDumps.map((dump) => { const phase = fading[dump.id]; const extraCls = phase === "cooldown" ? "dump-card--fading" : phase === "dismissing" ? "dump-card--dismissing" : undefined; return ( ); })}
)} {visibleDumps.length > 0 && ( View all → )}
); } // ── Invite tree ────────────────────────────────────────────────────────────── function InviteTreeList({ nodes }: { nodes: InviteTreeNode[] }) { return (
    {nodes.map((node) => (
  • @{node.username} {node.children.length > 0 && }
  • ))}
); } // ── Followed user card ─────────────────────────────────────────────────────── function FollowedUserCard({ user }: { user: PublicUser }) { return (
  • @{user.username}
  • ); } const ROLE_OPTIONS: { value: Role; label: React.ReactNode }[] = [ { value: "user", label: User }, { value: "moderator", label: Moderator }, { value: "admin", label: Admin }, ]; interface RoleSwitcherProps { userId: string; currentRole: Role; onSaved: (role: Role) => void; } // Segmented role control (matches the dump-visibility toggle style), gated // behind the user:manage permission by its caller. function RoleSwitcher({ userId, currentRole, onSaved }: RoleSwitcherProps) { const { authFetch } = useAuth(); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); async function changeRole(role: Role) { if (role === currentRole || saving) return; setSaving(true); setError(null); try { const res = await authFetch(`${API_URL}/api/users/${userId}/role`, { method: "PATCH", body: JSON.stringify({ role }), }); const body = parseAPIResponse(await res.json()); if (!body.success) { setError(body.error.message); return; } onSaved(deserializePublicUser(body.data).role); } catch (err) { setError(friendlyFetchError(err)); } finally { setSaving(false); } } return ( <>
    Role
    {ROLE_OPTIONS.map((opt) => ( ))}
    {error && } ); } interface EmailEditorProps { initialEmail: string; onCancel: () => void; onSaved: (email: string) => void; } function EmailEditor({ initialEmail, onCancel, onSaved }: EmailEditorProps) { const { authFetch } = useAuth(); const form = useApiForm<{ email: string }>({ defaultValues: { email: initialEmail }, }); const email = form.watch("email"); const submitting = form.formState.isSubmitting; const onSubmit = form.submit(async ({ email }) => { await expectOk( await authFetch(`${API_URL}/api/users/me`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify( { email: email.trim() } satisfies UpdateUserRequest, ), }), ); onSaved(email.trim()); }); return (
    { if (e.key === "Escape") onCancel(); }} disabled={submitting} autoFocus />
    Saving…} disabled={!email.trim()} > Save
    ); } interface DescriptionEditorProps { initialDescription: string; onCancel: () => void; onSaved: (description: string | undefined) => void; } function DescriptionEditor( { initialDescription, onCancel, onSaved }: DescriptionEditorProps, ) { const { authFetch } = useAuth(); const form = useApiForm<{ description: string }>({ defaultValues: { description: initialDescription }, }); const description = form.watch("description"); const submitting = form.formState.isSubmitting; const onSubmit = form.submit(async ({ description }) => { const trimmed = description.trim() || undefined; await expectOk( await authFetch(`${API_URL}/api/users/me`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify( { description: trimmed } satisfies UpdateUserRequest, ), }), ); onSaved(trimmed); }); return (
    ( void onSubmit()} placeholder={t`Who am I?`} autoResize maxLength={VALIDATION.USER_DESCRIPTION_MAX} /> )} />
    Saving…} disabled={description.length > VALIDATION.USER_DESCRIPTION_MAX} > Save
    ); }