impress-2020/src/app/UserItemsPage.js

859 lines
25 KiB
JavaScript
Raw Normal View History

import React from "react";
import { ClassNames } from "@emotion/react";
import {
Badge,
Box,
Center,
Flex,
IconButton,
Input,
InputGroup,
InputLeftElement,
InputRightElement,
2020-11-18 07:42:40 -08:00
Menu,
MenuButton,
MenuItem,
MenuList,
Portal,
Wrap,
2020-12-25 09:17:24 -08:00
WrapItem,
VStack,
useToast,
Button,
Textarea,
HStack,
2020-12-25 09:08:33 -08:00
} from "@chakra-ui/react";
import {
ArrowForwardIcon,
CheckIcon,
2020-11-18 07:42:40 -08:00
EditIcon,
EmailIcon,
SearchIcon,
StarIcon,
} from "@chakra-ui/icons";
import gql from "graphql-tag";
import { Link, useHistory, useParams } from "react-router-dom";
2020-11-18 07:42:40 -08:00
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";
2020-11-18 07:42:40 -08:00
import SupportOnly from "./WardrobePage/support/SupportOnly";
import useSupport from "./WardrobePage/support/useSupport";
import useCurrentUser from "./components/useCurrentUser";
2020-10-10 02:07:49 -07:00
import WIPCallout from "./components/WIPCallout";
2020-11-18 07:42:40 -08:00
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`
2020-11-24 20:54:10 -08:00
query UserItemsPage($userId: ID!) {
user(id: $userId) {
id
username
2020-10-23 23:00:53 -07:00
contactNeopetsUsername
2020-09-11 21:34:28 -07:00
closetLists {
id
name
description
ownsOrWantsItems
isDefaultList
items {
id
isNc
isPb
name
thumbnailUrl
currentUserOwnsThis
currentUserWantsThis
}
creator {
id
}
2020-09-11 21:34:28 -07:00
}
}
}
`,
{ variables: { userId }, context: { sendAuth: true } }
);
if (loading) {
return (
2020-09-11 23:27:23 -07:00
<Center>
<HangerSpinner />
2020-09-11 23:27:23 -07:00
</Center>
);
}
if (error) {
2020-09-06 18:12:34 -07:00
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.isDefaultList ? closetList.name : <Box
as={Link}
to={buildClosetListPath(closetList)}
_hover={{ textDecoration: "underline" }}
>
{closetList.name}
</Box>}
</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>
);
}
function buildClosetListPath(closetList) {
let ownsOrWants;
if (closetList.ownsOrWantsItems === "OWNS") {
ownsOrWants = "owns";
} else if (closetList.ownsOrWantsItems === "WANTS") {
ownsOrWants = "wants";
} else {
throw new Error(
`unexpected ownsOrWantsItems value: ${closetList.ownsOrWantsItems}`
);
}
const idString = closetList.isDefaultList ? "not-in-a-list" : closetList.id;
return `/user/${closetList.creator.id}/lists/${ownsOrWants}/${idString}`;
}
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>
);
}
2020-11-18 07:42:40 -08:00
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),
},
}
);
2020-11-18 07:42:40 -08:00
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>
2020-11-18 07:42:40 -08:00
</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;