import React from "react"; import { ClassNames } from "@emotion/react"; import { Badge, Box, Center, Flex, IconButton, Input, InputGroup, InputLeftElement, InputRightElement, Menu, MenuButton, MenuItem, MenuList, Portal, Wrap, WrapItem, VStack, useToast, Button, Textarea, HStack, } from "@chakra-ui/react"; import { ArrowForwardIcon, CheckIcon, EditIcon, EmailIcon, SearchIcon, StarIcon, } from "@chakra-ui/icons"; import gql from "graphql-tag"; import { Link, useHistory, useParams } from "react-router-dom"; import { useQuery, useLazyQuery, useMutation } from "@apollo/client"; import SimpleMarkdown from "simple-markdown"; import DOMPurify from "dompurify"; import HangerSpinner from "./components/HangerSpinner"; import { Heading1, Heading2, Heading3 } from "./util"; import ItemCard from "./components/ItemCard"; import SupportOnly from "./WardrobePage/support/SupportOnly"; import useSupport from "./WardrobePage/support/useSupport"; import useCurrentUser from "./components/useCurrentUser"; import WIPCallout from "./components/WIPCallout"; const BadgeButton = React.forwardRef((props, ref) => ( )); function UserItemsPage() { const { userId } = useParams(); const currentUser = useCurrentUser(); const isCurrentUser = currentUser.id === userId; const { loading, error, data } = useQuery( gql` query UserItemsPage($userId: ID!) { user(id: $userId) { id username contactNeopetsUsername closetLists { id name description ownsOrWantsItems isDefaultList items { id isNc isPb name thumbnailUrl currentUserOwnsThis currentUserWantsThis } creator { id } } } } `, { variables: { userId }, context: { sendAuth: true } } ); if (loading) { return (
); } if (error) { return {error.message}; } // TODO: I'm not sure why, but apparently `data` is `undefined` in the user // not found case, even though the server is clearly returning // `{data: {user: null}}`? That's... weird, right? :/ if (data?.user == null) { return User not found; } const listsOfOwnedItems = data.user.closetLists.filter( (l) => l.ownsOrWantsItems === "OWNS" ); const listsOfWantedItems = data.user.closetLists.filter( (l) => l.ownsOrWantsItems === "WANTS" ); // Sort default list to the end, then sort alphabetically. We use a similar // sort hack that we use for sorting items in ClosetList! listsOfOwnedItems.sort((a, b) => { const aName = `${a.isDefaultList ? "ZZZ" : "AAA"} ${a.name}`; const bName = `${b.isDefaultList ? "ZZZ" : "AAA"} ${b.name}`; return aName.localeCompare(bName); }); listsOfWantedItems.sort((a, b) => { const aName = `${a.isDefaultList ? "ZZZ" : "AAA"} ${a.name}`; const bName = `${b.isDefaultList ? "ZZZ" : "AAA"} ${b.name}`; return aName.localeCompare(bName); }); const allItemsTheyOwn = listsOfOwnedItems.map((l) => l.items).flat(); const allItemsTheyWant = listsOfWantedItems.map((l) => l.items).flat(); const itemsTheyOwnThatYouWant = allItemsTheyOwn.filter( (i) => i.currentUserWantsThis ); const itemsTheyWantThatYouOwn = allItemsTheyWant.filter( (i) => i.currentUserOwnsThis ); // It's important to de-duplicate these! Otherwise, if the same item appears // in multiple lists, we'll double-count it. const numItemsTheyOwnThatYouWant = new Set( itemsTheyOwnThatYouWant.map((i) => i.id) ).size; const numItemsTheyWantThatYouOwn = new Set( itemsTheyWantThatYouOwn.map((i) => i.id) ).size; return ( {({ css }) => ( {isCurrentUser ? "Your items" : `${data.user.username}'s items`} {data.user.contactNeopetsUsername && ( {data.user.contactNeopetsUsername} )} {data.user.contactNeopetsUsername && ( Neomail )} Support {/* Usually I put "Own" before "Want", but this matches the natural * order on the page: the _matches_ for things you want are things * _this user_ owns, so they come first. I think it's also probably a * more natural train of thought: you come to someone's list _wanting_ * something, and _then_ thinking about what you can offer. */} {!isCurrentUser && numItemsTheyOwnThatYouWant > 0 && ( {numItemsTheyOwnThatYouWant > 1 ? `${numItemsTheyOwnThatYouWant} items you want` : "1 item you want"} )} {!isCurrentUser && numItemsTheyWantThatYouOwn > 0 && ( {numItemsTheyWantThatYouOwn > 1 ? `${numItemsTheyWantThatYouOwn} items you own` : "1 item you own"} )} {isCurrentUser ? "Items you own" : `Items ${data.user.username} owns`} {listsOfOwnedItems.map((closetList) => ( 1} /> ))} {isCurrentUser ? "Items you want" : `Items ${data.user.username} wants`} {listsOfWantedItems.map((closetList) => ( 1} /> ))} )} ); } function UserSearchForm() { const [query, setQuery] = React.useState(""); const { isSupportUser, supportSecret } = useSupport(); const history = useHistory(); const toast = useToast(); const [loadUserSearch, { loading: loading1 }] = useLazyQuery( gql` query UserSearchForm($name: String!) { userByName(name: $name) { id # Consider preloading UserItemsPage fields here, too? } } `, { onCompleted: (data) => { const user = data.userByName; if (!user) { toast({ status: "warning", title: "We couldn't find that user!", description: "Check the spelling and try again?", }); return; } history.push(`/user/${user.id}/lists`); }, onError: (error) => { console.error(error); toast({ status: "error", title: "Error loading user!", description: "Check your connection and try again?", }); }, } ); const [loadUserByEmail, { loading: loading2 }] = useLazyQuery( gql` query UserSearchFormByEmail($email: String!, $supportSecret: String!) { userByEmail(email: $email, supportSecret: $supportSecret) { id # Consider preloading UserItemsPage fields here, too? } } `, { onCompleted: (data) => { const user = data.userByEmail; if (!user) { toast({ status: "warning", title: "We couldn't find that email address!", description: "Check the spelling and try again?", }); return; } history.push(`/user/${user.id}/items`); }, onError: (error) => { console.error(error); toast({ status: "error", title: "Error loading user by email!", description: "Check your connection and try again?", }); }, } ); return ( { const isSupportOnlyEmailSearch = isSupportUser && query.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/); if (isSupportOnlyEmailSearch) { toast({ status: "info", title: "Searching by email! (๐Ÿ’– Support-only)", description: "The email field is protected from most users.", }); loadUserByEmail({ variables: { email: query, supportSecret } }); } else { loadUserSearch({ variables: { name: query } }); } e.preventDefault(); }} > setQuery(e.target.value)} placeholder="Find another userโ€ฆ" borderRadius="full" /> } aria-label="Search" isLoading={loading1 || loading2} minWidth="1.5rem" minHeight="1.5rem" width="1.5rem" height="1.5rem" borderRadius="full" opacity={query ? 1 : 0} transition="opacity 0.2s" aria-hidden={query ? "false" : "true"} /> ); } function ClosetList({ closetList, isCurrentUser, showHeading }) { const { isSupportUser, supportSecret } = useSupport(); const toast = useToast(); const hasYouWantThisBadge = (item) => !isCurrentUser && closetList.ownsOrWantsItems === "OWNS" && item.currentUserWantsThis; const hasYouOwnThisBadge = (item) => !isCurrentUser && closetList.ownsOrWantsItems === "WANTS" && item.currentUserOwnsThis; const hasAnyTradeBadge = (item) => hasYouOwnThisBadge(item) || hasYouWantThisBadge(item); const sortedItems = [...closetList.items].sort((a, b) => { // This is a cute sort hack. We sort first by, bringing "You own/want // this!" to the top, and then sorting by name _within_ those two groups. const aName = `${hasAnyTradeBadge(a) ? "000" : "999"} ${a.name}`; const bName = `${hasAnyTradeBadge(b) ? "000" : "999"} ${b.name}`; return aName.localeCompare(bName); }); // When this mounts, scroll it into view if it matches the location hash. // This works around the fact that, while the browser tries to do this // natively on page load, the list might not be mounted yet! const anchorId = `list-${closetList.id}`; React.useEffect(() => { if (document.location.hash === "#" + anchorId) { document.getElementById(anchorId).scrollIntoView(); } }, [anchorId]); const [ sendSaveChangesMutation, { loading: loadingSaveChanges }, ] = useMutation( gql` mutation ClosetList_Edit( $closetListId: ID! $name: String! $description: String! # Support users can edit any list, if they provide the secret. If you're # editing your own list, this will be empty, and that's okay. $supportSecret: String ) { editClosetList( closetListId: $closetListId name: $name description: $description supportSecret: $supportSecret ) { id name description } } `, { context: { sendAuth: true } } ); const [isEditing, setIsEditing] = React.useState(false); const [editableName, setEditableName] = React.useState(closetList.name); const [editableDescription, setEditableDescription] = React.useState( closetList.description ); const hasChanges = editableName !== closetList.name || editableDescription !== closetList.description; const onSaveChanges = () => { if (!hasChanges) { setIsEditing(false); return; } sendSaveChangesMutation({ variables: { closetListId: closetList.id, name: editableName, description: editableDescription, supportSecret, }, }) .then(() => { setIsEditing(false); toast({ status: "success", title: "Changes saved!", }); }) .catch((err) => { console.error(err); toast({ status: "error", title: "Sorry, we couldn't save this list ๐Ÿ˜–", description: "Check your connection and try again.", }); }); }; let tradeMatchingMode; if (isCurrentUser) { // On your own item list, it's not helpful to show your own trade matches! tradeMatchingMode = "hide-all"; } else if (closetList.ownsOrWantsItems === "OWNS") { tradeMatchingMode = "offering"; } else if (closetList.ownsOrWantsItems === "WANTS") { tradeMatchingMode = "seeking"; } else { throw new Error( `unexpected ownsOrWantsItems value: ${closetList.ownsOrWantsItems}` ); } return ( {showHeading && (isEditing ? ( setEditableName(e.target.value)} maxWidth="20ch" // Shift left by our own padding/border, for alignment with the // original title paddingX="0.75rem" marginLeft="calc(-0.75rem - 1px)" boxShadow="sm" /> ) : ( {closetList.isDefaultList ? ( closetList.name ) : ( {closetList.name} )} ))} {(isCurrentUser || isSupportUser) && !closetList.isDefaultList && (isEditing ? ( <> WIP: Can only edit text for now! ) : ( ))} {closetList.description && ( {isEditing ? (