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, } 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 HangerSpinner from "./components/HangerSpinner"; import { Heading1, Heading2, usePageTitle } from "./util"; import SupportOnly from "./WardrobePage/support/SupportOnly"; import useSupport from "./WardrobePage/support/useSupport"; import useCurrentUser from "./components/useCurrentUser"; import { ClosetList, NeopetsStarIcon } from "./UserItemListPage"; const BadgeButton = React.forwardRef((props, ref) => ( <Badge as="button" ref={ref} {...props} /> )); function UserItemListsIndexPage() { const { userId } = useParams(); const currentUser = useCurrentUser(); const isCurrentUser = currentUser.id === userId; const { loading, error, data } = useQuery( gql` query UserItemListsIndexPage($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 } } ); let pageTitleText; if (isCurrentUser) { pageTitleText = "Your lists"; } else if (data?.user) { pageTitleText = `${data?.user?.username}'s lists`; } else { pageTitleText = null; } usePageTitle(pageTitleText); 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>{pageTitleText}</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} headingVariant={ closetList.isDefaultList && listsOfOwnedItems.length === 1 ? "hidden" : "list-item" } /> ))} </VStack> </Box> <Box borderTop="1px solid currentColor" marginTop="16" marginBottom="6" /> <Heading2 id="wanted-items" 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} headingVariant={ closetList.isDefaultList && listsOfWantedItems.length === 1 ? "hidden" : "list-item" } /> ))} </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 UserItemListsIndexPage 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 UserItemListsIndexPage 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 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> ); } export default UserItemListsIndexPage;