import React from "react"; import { css } from "emotion"; import { Badge, Box, Center, Flex, IconButton, Input, InputGroup, InputLeftElement, InputRightElement, Menu, MenuButton, MenuItem, MenuList, Portal, Wrap, VStack, useBreakpointValue, useToast, } from "@chakra-ui/core"; import { ArrowForwardIcon, CheckIcon, EditIcon, EmailIcon, SearchIcon, StarIcon, } from "@chakra-ui/icons"; import gql from "graphql-tag"; import { useHistory, useParams } from "react-router-dom"; import { useQuery, useLazyQuery, useMutation } from "@apollo/client"; import { AutoSizer, Grid, WindowScroller } from "react-virtualized"; import SimpleMarkdown from "simple-markdown"; import DOMPurify from "dompurify"; import HangerSpinner from "./components/HangerSpinner"; import { Heading1, Heading2, Heading3 } from "./util"; import ItemCard, { ItemBadgeList, ItemKindBadge, YouOwnThisBadge, YouWantThisBadge, getZoneBadges, } 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 allOccupiedZones { id label @client } } } } } `, { variables: { userId } } ); if (loading) { return (
); } if (error) { return {error.message}; } 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 ( {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 && ( )} {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}/items`); }, 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 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]); return ( {showHeading && ( {closetList.name} )} {closetList.description && ( {closetList.description} )} {sortedItems.length > 0 ? ( {sortedItems.map((item) => ( {hasYouOwnThisBadge(item) && } {hasYouWantThisBadge(item) && } {getZoneBadges(item.allOccupiedZones, { variant: "occupies", })} } /> ))} ) : ( This list is empty! )} ); } function VirtualizedItemCardList({ children }) { const columnCount = useBreakpointValue({ base: 1, md: 2, lg: 3 }); const rowCount = Math.ceil(children.length / columnCount); return ( {({ width }) => ( {({ height, isScrolling, onChildScroll, scrollTop, registerChild, }) => ( ( 0 ? "6" : "0"} > {children[rowIndex * columnCount + columnIndex]} )} columnCount={columnCount} columnWidth={width / columnCount} rowCount={rowCount} rowHeight={100} width={width} height={height} autoHeight isScrolling={isScrolling} onScroll={onChildScroll} scrollTop={scrollTop} /> )} )} ); } const unsafeMarkdownRules = { autolink: SimpleMarkdown.defaultRules.autolink, br: SimpleMarkdown.defaultRules.br, em: SimpleMarkdown.defaultRules.em, escape: SimpleMarkdown.defaultRules.escape, link: SimpleMarkdown.defaultRules.link, list: SimpleMarkdown.defaultRules.list, newline: SimpleMarkdown.defaultRules.newline, paragraph: SimpleMarkdown.defaultRules.paragraph, strong: SimpleMarkdown.defaultRules.strong, u: SimpleMarkdown.defaultRules.u, // DANGER: We override Markdown's `text` rule to _not_ escape HTML. This is // intentional, to allow users to embed some limited HTML. DOMPurify is // responsible for sanitizing the HTML afterward. Do not use these rules // without sanitizing!! text: { ...SimpleMarkdown.defaultRules.text, html: (node) => node.content, }, }; const markdownParser = SimpleMarkdown.parserFor(unsafeMarkdownRules); const unsafeMarkdownOutput = SimpleMarkdown.htmlFor( SimpleMarkdown.ruleOutput(unsafeMarkdownRules, "html") ); function MarkdownAndSafeHTML({ children }) { const htmlAndMarkdown = children; const unsafeHtml = unsafeMarkdownOutput(markdownParser(htmlAndMarkdown)); const sanitizedHtml = DOMPurify.sanitize(unsafeHtml, { ALLOWED_TAGS: [ "b", "i", "u", "strong", "em", "a", "p", "div", "br", "ol", "ul", "li", ], ALLOWED_ATTR: ["href", "class"], // URL must either start with an approved host (external link), or with a // slash or hash (internal link). ALLOWED_URI_REGEXP: /^https?:\/\/(impress\.openneo\.net|impress-2020\.openneo\.net|www\.neopets\.com|neopets\.com|items\.jellyneo\.net)\/|^[/#]/, }); return ( ); } function UserSupportMenu({ children, user }) { const { supportSecret } = useSupport(); const toast = useToast(); const [sendEditUsernameMutation] = useMutation( gql` mutation UserSupportMenuRename( $userId: ID! $newUsername: String! $supportSecret: String! ) { setUsername( userId: $userId newUsername: $newUsername supportSecret: $supportSecret ) { id username } } `, { onCompleted: (data) => { const updatedUser = data.setUsername; toast({ status: "success", title: `Successfully renamed user ${updatedUser.id} to ${updatedUser.username}!`, }); }, } ); const editUsername = React.useCallback(() => { const newUsername = prompt( "What should this user's username be?", user.username ); if (!newUsername || newUsername === user.username) { toast({ status: "info", title: "Got it, no change!", description: `User ${user.id}'s username will continue to be ${user.username}.`, }); return; } sendEditUsernameMutation({ variables: { userId: user.id, newUsername, supportSecret }, }).catch((e) => { console.error(e); toast({ status: "error", title: "Error renaming user.", description: "See error details in the console!", }); }); }, [sendEditUsernameMutation, user.id, user.username, supportSecret, toast]); return ( {children} Edit username ); } function NeopetsStarIcon(props) { // Converted from the Neopets favicon with https://www.vectorizer.io/. return ( ); } export default UserItemsPage;