import clsx from "clsx"; import { BadgeCheck, Flag, Megaphone, NotebookPen, NotebookText, Pencil, Trash2, UserPlus, UserRoundCheck, } from "lucide-react"; import * as React from "react"; import { Button, Dialog, DialogTrigger, Popover } from "react-aria-components"; import { useTranslation } from "react-i18next"; import { useFetcher, useLocation, useMatches } from "react-router"; import * as R from "remeda"; import { Avatar } from "~/components/Avatar"; import { LinkButton, SendouButton } from "~/components/elements/Button"; import { toastQueue } from "~/components/elements/Toast"; import { FormWithConfirm } from "~/components/FormWithConfirm"; import { Image, TierImage } from "~/components/Image"; import { LocaleTime } from "~/components/LocaleTime"; import { NoteAvatar } from "~/components/NoteAvatar"; import { Placement } from "~/components/Placement"; import type { XRankPlacementRegion } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; import { MutualFriends } from "~/features/user-page/components/MutualFriends"; import { ReportUserDialog } from "~/features/user-report/components/ReportUserDialog"; import { useLayoutSize } from "~/hooks/useMainContentWidth"; import type { BrandId } from "~/modules/in-game-lists/types"; import { assertUnreachable } from "~/utils/types"; import { brandImageUrl, FRIENDS_PAGE, LFG_PAGE, navIconUrl, stageBannerImageUrl, userCardEditPage, userCardFriendshipPage, userCardNotePage, userPage, } from "~/utils/urls"; import type { UserCardFriendshipLoaderData } from "../routes/user-card.$id.friendship"; import type { UserCardData, UserCardFriendship, UserCardStat, } from "../user-card-types"; import { AddPrivateNoteDialog } from "./AddPrivateNoteDialog"; import styles from "./UserCard.module.css"; const TENTATEK_BRAND_ID: BrandId = "B10"; const STAT_ORDER: Record = { XP: 0, SEASON: 1, PLUS: 2, DIV: 3, }; /** * Click-to-open trigger that shows a popover with the user's card. Card data is resolved from the * route tree by `userId` (a parent loader spreads `{ userCards }` from `UserCardRepository.userCards`); * pass `data` directly to bypass the lookup (e.g. the components showcase). When no card data exists * for the user, the `children` are rendered plain without a trigger. * * Viewer-relative friendship data (`isFriend`) is lazy-loaded from the `/user-card/:id/friendship` * route the first time the card opens. Mutual friends are only fetched and shown when * `withMutualFriends` is set (e.g. the SendouQ looking page); other views (e.g. match pages) skip * both the extra query and the row. */ export function UserCard({ userId, data: dataProp, withMutualFriends = false, children, }: { userId?: number; data?: UserCardData; /** Fetch and show the mutual friends row. Off by default. */ withMutualFriends?: boolean; children: React.ReactNode; }) { const { t } = useTranslation(["common", "q"]); const lookedUpData = useUserCardData(userId); const data = dataProp ?? lookedUpData; // beside the trigger there is no room for the card on a narrow viewport, so it is placed // vertically instead where React Aria can shift it horizontally to keep it on-screen const placement = useLayoutSize() === "mobile" ? "bottom" : "right"; const user = useUser(); const isOwnCard = user?.id === data?.id; const [isOpen, setIsOpen] = React.useState(false); // kept at this level (outside the popover) so the modals survive the popover closing when they // take focus; the note view inside the card opens them const [isNoteDialogOpen, setIsNoteDialogOpen] = React.useState(false); const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = React.useState(false); const [isReportDialogOpen, setIsReportDialogOpen] = React.useState(false); const fetcher = useFetcher(); const friendshipLoadedRef = React.useRef(false); React.useEffect(() => { if (!isOpen) return; if (friendshipLoadedRef.current) return; if (isOwnCard) return; if (typeof data?.id !== "number") return; friendshipLoadedRef.current = true; fetcher.load(userCardFriendshipPage(data.id, { withMutualFriends })); }, [isOpen, isOwnCard, data?.id, withMutualFriends, fetcher.load]); const friendship = fetcher.data; // close the popover so only the modal is shown const openNoteDialog = () => { setIsOpen(false); setIsNoteDialogOpen(true); }; const openDeleteConfirm = () => { setIsOpen(false); setIsDeleteConfirmOpen(true); }; const openReportDialog = () => { setIsOpen(false); setIsReportDialogOpen(true); }; if (!data) return <>{children}; return ( <> {isNoteDialogOpen ? ( setIsNoteDialogOpen(false)} /> ) : null} {isReportDialogOpen ? ( setIsReportDialogOpen(false)} /> ) : null} ); } /** * Resolves a user's `UserCardData` from any matched route loader that spread `{ userCards }` * (see `UserCardRepository.userCards`). Returns `undefined` when no loader on the current route * tree carries data for the given user. */ export function useUserCardData( userId: number | undefined, ): UserCardData | undefined { const matches = useMatches(); if (typeof userId !== "number") return undefined; for (const match of matches) { const data = match.loaderData as | { userCards?: Map } | undefined; const card = data?.userCards?.get(userId); if (card) return card; } return undefined; } function CardContent({ data, friendship, isOwnCard, withMutualFriends, onEditNote, onDeleteNote, onReport, }: { data: UserCardData; /** Lazy-loaded; `undefined` while the friendship fetch is in flight. */ friendship: UserCardFriendship | undefined; isOwnCard: boolean; withMutualFriends: boolean; onEditNote: () => void; onDeleteNote: () => void; /** Not passed for logged-out viewers, hiding the report button. */ onReport: (() => void) | undefined; }) { const { t } = useTranslation(["common", "user"]); const location = useLocation(); const [isNoteOpen, setIsNoteOpen] = React.useState(false); const stats = data.stats.toSorted( (a, b) => STAT_ORDER[a.type] - STAT_ORDER[b.type], ); const editPageUrl = userCardEditPage({ returnTo: `${location.pathname}${location.search}`, }); // an existing note shows in-place (toggled); with no note the button opens the add modal directly const showNoteView = isNoteOpen && data.privateNote !== null; const onNoteButtonPress = () => { if (data.privateNote === null) { onEditNote(); return; } setIsNoteOpen((prev) => !prev); }; return (
{data.freeAgentPostId !== null ? ( } className={styles.freeAgentBadge} > {t("user:card.freeAgent")} ) : null}
{isOwnCard ? ( }> {t("common:actions.edit")} ) : ( <> {friendship && !friendship.isFriend ? ( ) : null} : } onPress={onNoteButtonPress} aria-label={t("user:card.editPrivateNote")} /> {onReport ? ( } onPress={onReport} aria-label="Report user" data-testid="report-user-button" /> ) : null} )}

{data.username}

{data.customUrl ? (
{data.customUrl}
) : null} {data.friendCode ? ( SW-{data.friendCode} ) : ( /** reserve space */ {"\u200b"} )}
{showNoteView ? ( ) : ( <> {stats.length > 0 ? (
{stats.map((stat, i) => ( {i > 0 ? : null} ))}
) : null} {isOwnCard || !withMutualFriends ? null : ( )} {data.shortBio ?

{data.shortBio}

: null} {t("user:card.viewUserPage")} )}
); } function NoteView({ note, onEdit, onDelete, }: { note: UserCardData["privateNote"]; onEdit: () => void; onDelete: () => void; }) { const { t } = useTranslation(["common", "user"]); return (
{t("user:card.privateNote")} {note ? ( ) : null}
{note?.text ?

{note.text}

: null}
} onPress={onEdit} > {t("common:actions.edit")} } onPress={onDelete} > {t("common:actions.delete")}
); } /** * Friend request action on the card, submitting to the `/friends` route action. Normally sends a * request and shows a checkmark once one is pending (server-known or just sent); when the shown * user has already sent the viewer a request, the same add-friend press accepts it instead. * Cancelling a pending request is done on the `/friends` page. */ function FriendRequestButton({ targetUserId, sentFriendRequest, incomingFriendRequestId, }: { targetUserId: number; sentFriendRequest: boolean; incomingFriendRequestId: number | null; }) { const { t } = useTranslation(["user"]); const fetcher = useFetcher(); const previousStateRef = React.useRef(fetcher.state); const acceptsIncomingRequest = incomingFriendRequestId !== null; // Sending a request keeps this button mounted (it becomes the pending checkmark), so the // success toast can wait for the server round-trip here. The accept path instead unmounts the // button as soon as the revalidated friendship data arrives, which can race the toast render, // so that toast is fired directly from the press handler below. React.useEffect(() => { if ( !acceptsIncomingRequest && previousStateRef.current === "submitting" && fetcher.state !== "submitting" && fetcher.data === null ) { toastQueue.add( { message: t("user:card.friendRequestSent"), variant: "success" }, { timeout: 5000 }, ); } previousStateRef.current = fetcher.state; }, [fetcher.state, fetcher.data, acceptsIncomingRequest, t]); if (acceptsIncomingRequest) { return ( } isDisabled={fetcher.state !== "idle" || fetcher.data === null} aria-label="Accept friend request" onPress={() => { toastQueue.add( { message: t("user:card.friendRequestAccepted"), variant: "success", }, { timeout: 5000 }, ); fetcher.submit( { _action: "ACCEPT_REQUEST", friendRequestId: incomingFriendRequestId, }, { method: "post", action: FRIENDS_PAGE }, ); }} /> ); } const requestPending = sentFriendRequest || fetcher.state !== "idle" || fetcher.data === null; if (requestPending) { return ( } isDisabled aria-label={t("user:card.friendRequestPending")} /> ); } return ( } aria-label={t("user:card.sendFriendRequest")} onPress={() => fetcher.submit( { _action: "SEND_REQUEST", userId: targetUserId }, { method: "post", action: FRIENDS_PAGE }, ) } /> ); } /** * Mutual friends row with reserved height so the card does not shift when the lazy friendship fetch * resolves: empty while loading, "No mutual friends" when there are none, the avatar stack otherwise. */ function CardMutualFriends({ friendship, }: { friendship: UserCardFriendship | undefined; }) { const { t } = useTranslation(["user"]); return (
{friendship === undefined ? null : friendship.mutualFriends.length === 0 ? ( {t("user:card.noMutualFriends")} ) : ( )}
); } function Banner({ banner }: { banner: UserCardData["banner"] }) { const style = (() => { switch (banner.type) { case "STAGE": return { backgroundImage: `url(${stageBannerImageUrl(banner.stageId)})`, }; case "URL": return { backgroundImage: `url(${banner.url})` }; case "COLOR": return { backgroundColor: banner.hexCode }; default: assertUnreachable(banner); } })(); return (
); } function Stat({ stat }: { stat: UserCardData["stats"][number] }) { const { t } = useTranslation(["user"]); switch (stat.type) { case "XP": { const unverified = stat.values.find((value) => !value.isVerified); const verified = stat.values.find((value) => value.isVerified); const primary = unverified ?? verified; const secondary = unverified ? verified : undefined; return ( {primary ? ( {primary.isVerified ? ( ) : null} {primary.points} {t("user:card.xp")} ) : null} {secondary ? ( {secondary.points} {t("user:card.xp")} ) : null} ); } case "DIV": return Div {stat.value}; case "PLUS": return ( + {stat.value} ); case "SEASON": return ( {typeof stat.top === "number" ? ( ) : null} ); default: assertUnreachable(stat); } } function DivImage({ region }: { region: XRankPlacementRegion }) { const { t } = useTranslation(["common"]); if (region !== "WEST") return null; return ( {t("common:divisions.WEST")} ); } function customThemeStyle( customTheme: UserCardData["customTheme"], ): React.CSSProperties { if (!customTheme) return {}; return R.pickBy( customTheme, (value, key) => value !== null && !key.includes("--_size") && !key.includes("--_border"), ) as React.CSSProperties; }