157 lines
4.7 KiB
TypeScript
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>
|
|
);
|
|
}
|