impress-2020/src/app/UserItemsPage.js
Matchu a0be9942fb Add extra flair to trade matches
I refactor the hide-badge thing into 3 "trade matching modes". Then, the logic for whether to hide specific badges moves into the component, and we use that same flag to decide whether to show the big word "match"!
2021-06-08 01:23:31 -07:00

836 lines
24 KiB
JavaScript

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 { 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) => (
<Badge as="button" ref={ref} {...props} />
));
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 }, context: { sendAuth: true } }
);
if (loading) {
return (
<Center>
<HangerSpinner />
</Center>
);
}
if (error) {
return <Box color="red.400">{error.message}</Box>;
}
// 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 <Box color="red.400">User not found</Box>;
}
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 (
<ClassNames>
{({ css }) => (
<Box>
<Flex align="center" wrap="wrap-reverse">
<Box>
<Heading1>
{isCurrentUser ? "Your items" : `${data.user.username}'s items`}
</Heading1>
<Wrap spacing="2" opacity="0.7">
{data.user.contactNeopetsUsername && (
<WrapItem>
<Badge
as="a"
href={`http://www.neopets.com/userlookup.phtml?user=${data.user.contactNeopetsUsername}`}
display="flex"
alignItems="center"
>
<NeopetsStarIcon marginRight="1" />
{data.user.contactNeopetsUsername}
</Badge>
</WrapItem>
)}
{data.user.contactNeopetsUsername && (
<WrapItem>
<Badge
as="a"
href={`http://www.neopets.com/neomessages.phtml?type=send&recipient=${data.user.contactNeopetsUsername}`}
display="flex"
alignItems="center"
>
<EmailIcon marginRight="1" />
Neomail
</Badge>
</WrapItem>
)}
<SupportOnly>
<WrapItem>
<UserSupportMenu user={data.user}>
<MenuButton
as={BadgeButton}
display="flex"
alignItems="center"
>
<EditIcon marginRight="1" />
Support
</MenuButton>
</UserSupportMenu>
</WrapItem>
</SupportOnly>
{/* 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 && (
<WrapItem>
<Badge
as="a"
href="#owned-items"
colorScheme="blue"
display="flex"
alignItems="center"
>
<StarIcon marginRight="1" />
{numItemsTheyOwnThatYouWant > 1
? `${numItemsTheyOwnThatYouWant} items you want`
: "1 item you want"}
</Badge>
</WrapItem>
)}
{!isCurrentUser && numItemsTheyWantThatYouOwn > 0 && (
<WrapItem>
<Badge
as="a"
href="#wanted-items"
colorScheme="green"
display="flex"
alignItems="center"
>
<CheckIcon marginRight="1" />
{numItemsTheyWantThatYouOwn > 1
? `${numItemsTheyWantThatYouOwn} items you own`
: "1 item you own"}
</Badge>
</WrapItem>
)}
</Wrap>
</Box>
<Box flex="1 0 auto" width="2" />
<Box marginBottom="1">
<UserSearchForm />
</Box>
</Flex>
<Box marginTop="4">
<Heading2 id="owned-items" marginBottom="2">
{isCurrentUser
? "Items you own"
: `Items ${data.user.username} owns`}
</Heading2>
<VStack
spacing="8"
alignItems="stretch"
className={css`
clear: both;
`}
>
{listsOfOwnedItems.map((closetList) => (
<ClosetList
key={closetList.id}
closetList={closetList}
isCurrentUser={isCurrentUser}
showHeading={listsOfOwnedItems.length > 1}
/>
))}
</VStack>
</Box>
<Heading2 id="wanted-items" marginTop="10" marginBottom="2">
{isCurrentUser
? "Items you want"
: `Items ${data.user.username} wants`}
</Heading2>
<VStack spacing="4" alignItems="stretch">
{listsOfWantedItems.map((closetList) => (
<ClosetList
key={closetList.id}
closetList={closetList}
isCurrentUser={isCurrentUser}
showHeading={listsOfWantedItems.length > 1}
/>
))}
</VStack>
</Box>
)}
</ClassNames>
);
}
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 (
<Box
as="form"
onSubmit={(e) => {
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();
}}
>
<InputGroup size="sm">
<InputLeftElement pointerEvents="none">
<SearchIcon color="gray.400" />
</InputLeftElement>
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Find another user…"
borderRadius="full"
/>
<InputRightElement>
<IconButton
type="submit"
variant="ghost"
icon={<ArrowForwardIcon />}
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"}
/>
</InputRightElement>
</InputGroup>
</Box>
);
}
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 (
<Box id={anchorId}>
<Flex align="center" wrap="wrap" marginBottom="2">
{showHeading &&
(isEditing ? (
<Heading3
as={Input}
value={editableName}
onChange={(e) => 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"
/>
) : (
<Heading3
fontStyle={closetList.isDefaultList ? "italic" : "normal"}
lineHeight="1.2" // to match Input
paddingY="2px" // to account for Input border/padding
>
{closetList.name}
</Heading3>
))}
<Box flex="1 0 auto" width="4" />
{(isCurrentUser || isSupportUser) &&
!closetList.isDefaultList &&
(isEditing ? (
<>
<WIPCallout
size="sm"
details="To edit the items, head back to Classic DTI!"
marginY="2"
>
WIP: Can only edit text for now!
</WIPCallout>
<Box width="4" />
<HStack spacing="2" marginLeft="auto" marginY="1">
<Button size="sm" onClick={() => setIsEditing(false)}>
Cancel
</Button>
<Button
display="flex"
align="center"
size="sm"
colorScheme="green"
onClick={onSaveChanges}
isLoading={loadingSaveChanges}
>
<CheckIcon marginRight="1" />
Save changes
</Button>
</HStack>
</>
) : (
<Button
display="flex"
align="center"
size="sm"
onClick={() => setIsEditing(true)}
>
<EditIcon marginRight="1" />
Edit
</Button>
))}
</Flex>
{closetList.description && (
<Box marginBottom="2">
{isEditing ? (
<Textarea
value={editableDescription}
onChange={(e) => setEditableDescription(e.target.value)}
// Shift left by our own padding/border, for alignment with the
// original title
paddingX="0.75rem"
marginLeft="calc(-0.75rem - 1px)"
boxShadow="sm"
/>
) : (
<MarkdownAndSafeHTML>{closetList.description}</MarkdownAndSafeHTML>
)}
</Box>
)}
{sortedItems.length > 0 ? (
<Wrap spacing="4" justify="center">
{sortedItems.map((item) => (
<WrapItem key={item.id}>
<ItemCard
item={item}
variant="grid"
tradeMatchingMode={tradeMatchingMode}
/>
</WrapItem>
))}
</Wrap>
) : (
<Box fontStyle="italic">This list is empty!</Box>
)}
</Box>
);
}
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 (
<ClassNames>
{({ css }) => (
<Box
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
className={css`
.paragraph,
ol,
ul {
margin-bottom: 1em;
}
ol,
ul {
margin-left: 2em;
}
`}
/>
)}
</ClassNames>
);
}
function UserSupportMenu({ children, user }) {
const { supportSecret } = useSupport();
const toast = useToast();
const { loading, error, data } = useQuery(
gql`
query UserSupportMenu($userId: ID!, $supportSecret: String!) {
user(id: $userId) {
id
emailForSupportUsers(supportSecret: $supportSecret)
}
}
`,
{
variables: {
userId: user.id,
supportSecret,
onError: (e) => console.error(e),
},
}
);
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 (
<Menu>
{children}
<Portal>
<MenuList>
<MenuItem onClick={editUsername}>Edit username</MenuItem>
<MenuItem
as="a"
href={
data?.user?.emailForSupportUsers
? `mailto:${data.user.emailForSupportUsers}`
: undefined
}
isDisabled={data?.user?.emailForSupportUsers == null}
sx={{
'&[aria-disabled="true"]': {
cursor: loading ? "wait !important" : "not-allowed",
},
}}
>
Send email
{error && <> (Error: {error.message})</>}
</MenuItem>
</MenuList>
</Portal>
</Menu>
);
}
function NeopetsStarIcon(props) {
// Converted from the Neopets favicon with https://www.vectorizer.io/.
return (
<Box {...props}>
<svg
version="1.0"
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 160 160"
preserveAspectRatio="xMidYMid meet"
>
<path
fill="currentColor"
fill-rule="evenodd"
d="M85 129 L60 108 40 119 C11 135,7 132,24 108 L39 86 23 68 L6 50 32 50 L58 50 73 29 L88 8 94 29 L101 50 128 50 L155 50 131 68 L107 86 113 118 C121 155,118 156,85 129 "
/>
</svg>
</Box>
);
}
export default UserItemsPage;