import React from "react"; import { css } from "emotion"; import { Badge, Box, Center, Wrap, VStack } from "@chakra-ui/core"; import { CheckIcon, EmailIcon, StarIcon } from "@chakra-ui/icons"; import gql from "graphql-tag"; import { useParams } from "react-router-dom"; import { useQuery } 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, { ItemBadgeList, ItemCardList, NcBadge, NpBadge, YouOwnThisBadge, YouWantThisBadge, ZoneBadgeList, } from "./components/ItemCard"; import useCurrentUser from "./components/useCurrentUser"; import WIPCallout from "./components/WIPCallout"; function UserItemsPage() { const { userId } = useParams(); const currentUser = useCurrentUser(); const isCurrentUser = currentUser.id === userId; const { loading, error, data } = useQuery( gql` query ItemsPage($userId: ID!) { user(id: $userId) { id username contactNeopetsUsername closetLists { id name description ownsOrWantsItems isDefaultList items { id isNc 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 && ( )} {isCurrentUser ? "Your items" : `${data.user.username}'s items`} {data.user.contactNeopetsUsername && ( {data.user.contactNeopetsUsername} )} {data.user.contactNeopetsUsername && ( Neomail )} {/* 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 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); }); return ( {showHeading && ( {closetList.name} )} {closetList.description && ( {closetList.description} )} {sortedItems.length > 0 ? ( {sortedItems.map((item) => ( {item.isNc ? : } {hasYouOwnThisBadge(item) && } {hasYouWantThisBadge(item) && } } /> ))} ) : ( This list is empty! )} ); } 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)\/|^[\/#]/, }); return ( ); } function NeopetsStarIcon(props) { // Converted from the Neopets favicon with https://www.vectorizer.io/. return ( ); } export default UserItemsPage;