Add description to list page

Pulled MarkdownAndSafeHTML into a shared component, and use it on the single list page now too!

I also simplified some of the logic for the item list, because I figure we'll have to give the trade matching stuff its own pass, y'know?
This commit is contained in:
Emi Matchu 2021-06-18 17:26:02 -07:00
parent 3cd0ffd764
commit f20c68ea81
3 changed files with 101 additions and 115 deletions

View file

@ -9,13 +9,14 @@ import {
Wrap, Wrap,
WrapItem, WrapItem,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { ChevronRightIcon } from "@chakra-ui/icons";
import { Heading1, MajorErrorMessage } from "./util"; import { Heading1, MajorErrorMessage } from "./util";
import { gql, useQuery } from "@apollo/client"; import { gql, useQuery } from "@apollo/client";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { HashLink } from "react-router-hash-link"; import { HashLink } from "react-router-hash-link";
import HangerSpinner from "./components/HangerSpinner"; import HangerSpinner from "./components/HangerSpinner";
import { ChevronRightIcon } from "@chakra-ui/icons"; import MarkdownAndSafeHTML from "./components/MarkdownAndSafeHTML";
import ItemCard from "./components/ItemCard"; import ItemCard from "./components/ItemCard";
import WIPCallout from "./components/WIPCallout"; import WIPCallout from "./components/WIPCallout";
@ -28,6 +29,7 @@ function UserItemListPage() {
closetList(id: $listId) { closetList(id: $listId) {
id id
name name
description
ownsOrWantsItems ownsOrWantsItems
creator { creator {
id id
@ -43,7 +45,7 @@ function UserItemListPage() {
} }
} }
`, `,
{ variables: { listId } } { variables: { listId }, context: { sendAuth: true } }
); );
if (loading) { if (loading) {
@ -102,49 +104,20 @@ function UserItemListPage() {
/> />
</Flex> </Flex>
<Box height="6" /> <Box height="6" />
{/* TODO: Description */} {closetList.description && (
<MarkdownAndSafeHTML>{closetList.description}</MarkdownAndSafeHTML>
)}
<ClosetListContents closetList={closetList} /> <ClosetListContents closetList={closetList} />
</Box> </Box>
); );
} }
function ClosetListContents({ closetList }) { function ClosetListContents({ closetList }) {
const isCurrentUser = false; // TODO const sortedItems = [...closetList.items].sort((a, b) =>
a.name.localeCompare(b.name)
);
// TODO: A lot of this is duplicated from UserItemsPage, find shared const tradeMatchingMode = "hide-all"; // TODO
// abstractions!
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);
});
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 ( return (
<Box> <Box>

View file

@ -34,12 +34,11 @@ import {
import gql from "graphql-tag"; import gql from "graphql-tag";
import { Link, useHistory, useParams } from "react-router-dom"; import { Link, useHistory, useParams } from "react-router-dom";
import { useQuery, useLazyQuery, useMutation } from "@apollo/client"; import { useQuery, useLazyQuery, useMutation } from "@apollo/client";
import SimpleMarkdown from "simple-markdown";
import DOMPurify from "dompurify";
import HangerSpinner from "./components/HangerSpinner"; import HangerSpinner from "./components/HangerSpinner";
import { Heading1, Heading2, Heading3 } from "./util"; import { Heading1, Heading2, Heading3 } from "./util";
import ItemCard from "./components/ItemCard"; import ItemCard from "./components/ItemCard";
import MarkdownAndSafeHTML from "./components/MarkdownAndSafeHTML";
import SupportOnly from "./WardrobePage/support/SupportOnly"; import SupportOnly from "./WardrobePage/support/SupportOnly";
import useSupport from "./WardrobePage/support/useSupport"; import useSupport from "./WardrobePage/support/useSupport";
import useCurrentUser from "./components/useCurrentUser"; import useCurrentUser from "./components/useCurrentUser";
@ -657,81 +656,6 @@ function buildClosetListPath(closetList) {
return `/user/${closetList.creator.id}/lists/${ownsOrWants}/${idString}`; 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>
);
}
function UserSupportMenu({ children, user }) { function UserSupportMenu({ children, user }) {
const { supportSecret } = useSupport(); const { supportSecret } = useSupport();
const toast = useToast(); const toast = useToast();

View file

@ -0,0 +1,89 @@
import React from "react";
import { ClassNames } from "@emotion/react";
import { Box } from "@chakra-ui/react";
import SimpleMarkdown from "simple-markdown";
import DOMPurify from "dompurify";
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")
);
/**
* MarkdownAndSafeHTML renders its children as a Markdown string, with some
* safe inline HTML allowed.
*
* Rendering this component *should* be XSS-safe, it's designed to strip out
* bad things! Still, be careful when using it, and consider what you're doing!
*/
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>
);
}
export default MarkdownAndSafeHTML;