Files
gerbeur/src/components/profile/ProfileFollowTab.tsx

157 lines
4.7 KiB
TypeScript

/**
* Renders a profile's "follow" tab, which shows the users the user follows at
* `/@name/follow` and the playlists at `/@name/follow/playlists`.
*
* @module
*/
import { useCallback } from "react";
import { useNavigate } from "react-router";
import { Trans } from "@lingui/react/macro";
import { userApiUrl } from "../../utils/shared/urls.ts";
import type { Wire } from "../../../shared/api.ts";
import type { Playlist } from "../../../shared/playlist.ts";
import type { PublicUser } from "../../../shared/user.ts";
import { type FollowView, userPath } from "../../../shared/paths.ts";
import { canLoadMore, pageUrl, readPage } from "../../utils/feed/pagedList.ts";
import {
deserializePlaylist,
deserializePublicUser,
} from "../../utils/shared/deserialize.ts";
import { usePagedList } from "../../hooks/feed/usePagedList.ts";
import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts";
import { usePlaylistListSync } from "../../hooks/playlist/usePlaylistListSync.ts";
import { ListState } from "../shared/ListState.tsx";
import { TabBar } from "../shared/TabBar.tsx";
import { FeedFooter } from "../feed/FeedFooter.tsx";
import { UserCard } from "../social/UserCard.tsx";
import { PlaylistCardList } from "../playlist/PlaylistCardList.tsx";
import { ProfileSection } from "./ProfileSection.tsx";
/** Passed to {@link ProfileFollowTab} by `UserPublicProfile`. */
export interface ProfileFollowTabProps {
user: PublicUser;
/** The view the path names, after `/follow`. */
view: FollowView;
}
/**
* Returns page after page of what `userId` follows, from
* `GET /api/users/:userId/follow/<view>`, and the sentinel that loads the
* next.
*/
function useFollowList<T>(
userId: string,
view: FollowView,
deserialize: (wire: Wire<T>) => T,
) {
const url = userApiUrl({ id: userId }, "follow", view);
const paged = usePagedList<T>(
url,
useCallback(
(page: number, signal?: AbortSignal) =>
fetch(pageUrl(url, page), { signal })
.then((res) => readPage(res, deserialize)),
[url, deserialize],
),
);
const loaded = paged.state.status === "loaded" ? paged.state.data : null;
const sentinelRef = useInfiniteScroll(paged.loadMore, canLoadMore(loaded));
return { ...paged, sentinelRef };
}
/** Renders the users `userId` follows, the latest followed first. */
function FollowedUsers({ userId }: { userId: string }) {
const { state, sentinelRef } = useFollowList(
userId,
"users",
deserializePublicUser,
);
return (
<ListState
state={state}
isEmpty={({ data }) => data.items.length === 0}
empty={
<p className="empty-state">
<Trans>Not following anyone yet.</Trans>
</p>
}
>
{({ data }) => (
<>
<ul className="user-card-list">
{data.items.map((u) => <UserCard key={u.id} user={u} />)}
</ul>
<FeedFooter
hasMore={data.hasMore}
loadingMore={data.loadingMore}
sentinelRef={sentinelRef}
/>
</>
)}
</ListState>
);
}
/**
* Renders the public playlists `userId` follows, which the socket keeps
* current but never adds to.
*/
function FollowedPlaylists({ userId }: { userId: string }) {
const { state, setItems, sentinelRef } = useFollowList<Playlist>(
userId,
"playlists",
deserializePlaylist,
);
usePlaylistListSync(setItems, { noNewEntries: true });
return (
<ListState
state={state}
isEmpty={({ data }) => data.items.length === 0}
empty={
<p className="empty-state">
<Trans>No followed playlists yet.</Trans>
</p>
}
>
{({ data }) => (
<>
<PlaylistCardList playlists={data.items} />
<FeedFooter
hasMore={data.hasMore}
loadingMore={data.loadingMore}
sentinelRef={sentinelRef}
/>
</>
)}
</ListState>
);
}
/**
* Renders the two views under a row of tabs that switch between their paths,
* each view loading its list when first shown.
*/
export function ProfileFollowTab(
{ user, view }: ProfileFollowTabProps,
): React.JSX.Element {
const navigate = useNavigate();
return (
<ProfileSection title={<Trans>Followed</Trans>}>
<TabBar
tabs={[
{ key: "users" as FollowView, label: <Trans>Users</Trans> },
{ key: "playlists" as FollowView, label: <Trans>Playlists</Trans> },
]}
activeTab={view}
onChange={(v) =>
navigate(userPath(user.username, "follow", v), { replace: true })}
innerClassName="followed-sub-nav"
/>
{view === "users"
? <FollowedUsers userId={user.id} />
: <FollowedPlaylists userId={user.id} />}
</ProfileSection>
);
}