/** * Renders a user's profile page, which is also where the signed-in user edits * their own profile. * * @module */ import { useLocation, useNavigate, useParams } from "react-router"; import { Trans } from "@lingui/react/macro"; import { API_URL } from "../config/api.ts"; import type { Invitee } from "../../shared/user.ts"; import type { Wire } from "../../shared/api.ts"; import { FOLLOW_VIEWS, type FollowView, PROFILE_TABS, type ProfileTab, userPath, } from "../../shared/paths.ts"; import { deserializeInvitee } from "../utils/shared/deserialize.ts"; import { dataOr } from "../utils/shared/api.ts"; import { can } from "../utils/auth/permissions.ts"; import { useAuth } from "../hooks/auth/useAuth.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { useFetchState } from "../hooks/shared/useFetchState.ts"; import { useTabParam } from "../hooks/shared/useTabParam.ts"; import { useCanonicalPath } from "../hooks/shared/useCanonicalPath.ts"; import { useProfile } from "../hooks/profile/useProfile.ts"; import { PageShell } from "../components/layout/PageShell.tsx"; import { PageError } from "../components/layout/PageError.tsx"; import { TabBar } from "../components/shared/TabBar.tsx"; import { ProfileHeader } from "../components/profile/ProfileHeader.tsx"; import { ProfileDescription } from "../components/profile/ProfileDescription.tsx"; import { ProfileOverviewTab } from "../components/profile/ProfileOverviewTab.tsx"; import { ProfileDumpsTab } from "../components/profile/ProfileDumpsTab.tsx"; import { ProfilePlaylistsTab } from "../components/profile/ProfilePlaylistsTab.tsx"; import { ProfileFollowTab } from "../components/profile/ProfileFollowTab.tsx"; import { ProfileInviteesTab } from "../components/profile/ProfileInviteesTab.tsx"; import { ProfileManageTab } from "../components/profile/ProfileManageTab.tsx"; import { NotFound } from "./NotFound.tsx"; /** The overview, the bare `/@name`, which no path segment names. */ const OVERVIEW = "overview"; type Tab = ProfileTab | typeof OVERVIEW; const TABS: readonly Tab[] = [OVERVIEW, ...PROFILE_TABS]; /** * Renders `/@:username` and its tabs, `/@:username/:tab`, and the follow * tab's view `/@:username/follow/playlists`, for anyone. `useProfile` loads * the user, by a former name or another case too, and the URL then takes the * current one; each tab loads its own lists, by the user's id, when shown. * On their own profile, users edit their avatar, description and email, and * create invites. The "manage" tab, another user's role and name, is for those * who hold `user:manage`. A tab or view the profile hasn't is `NotFound`. */ export function UserPublicProfile(): React.JSX.Element { const { handle = "" } = useParams(); const username = handle.slice(1); const { pathname } = useLocation(); const navigate = useNavigate(); const { user: me, logout } = useAuth(); const { state, isOwnProfile, setUser } = useProfile(username); const user = state.status === "loaded" ? state.data : null; useDocumentTitle( user ? user.username : state.status === "error" ? null : undefined, ); // The base is the path's own first segment, however it spells the name. const base = `/${pathname.split("/")[1]}`; const [tab, setTab] = useTabParam(base, TABS, OVERVIEW); useCanonicalPath( user && `${userPath(user.username)}${pathname.slice(base.length)}`, ); // Only the follow tab has a view, one segment further. const extra = pathname.slice(base.length).split("/").filter(Boolean).slice(1); const view: FollowView | null = extra.length === 0 ? "users" : tab === "follow" && extra.length === 1 && extra[0] !== FOLLOW_VIEWS[0] && (FOLLOW_VIEWS as readonly string[]).includes(extra[0]) ? extra[0] as FollowView : null; const canManageUsers = can(me, "user:manage"); const showManageTab = !isOwnProfile && canManageUsers; const invitees = useFetchState( user?.id, (signal) => fetch(`${API_URL}/api/users/${user!.id}/invitees`, { signal }) .then((res) => dataOr[]>(res, [])) .then((entries) => entries.map(deserializeInvitee)), { enabled: tab === "invitees" }, ); if (!tab || !view || (tab === "manage" && user && !showManageTab)) { return ; } if (state.status === "loading") { return (

Loading profile…

); } if (state.status === "error") { return ( {me && ( )} } /> ); } const profileUser = state.data; return ( setUser(() => u)} /> setUser((u) => ({ ...u, description }))} /> Overview }, { key: "dumps" as Tab, label: Dumps }, { key: "upvoted" as Tab, label: Upvoted }, { key: "playlists" as Tab, label: Playlists }, { key: "follow" as Tab, label: Followed }, { key: "invitees" as Tab, label: Invitees }, ...(showManageTab ? [{ key: "manage" as Tab, label: Manage }] : []), ]} activeTab={tab} onChange={(key) => setTab(key)} className="profile-tabs-scroller" innerClassName="profile-tabs" /> {tab === OVERVIEW && ( )} {(tab === "dumps" || tab === "upvoted") && ( )} {tab === "playlists" && ( )} {tab === "follow" && } {tab === "invitees" && ( )} {tab === "manage" && ( setUser(() => u)} /> )} ); }