add matches to the trades list page when logged in

This commit is contained in:
Emi Matchu 2020-11-25 01:53:42 -08:00
parent 066b914bd5
commit def46b0d9c
3 changed files with 187 additions and 59 deletions

View file

@ -1,18 +1,13 @@
import React from "react"; import React from "react";
import { css } from "emotion"; import { css } from "emotion";
import { import { Box, Skeleton, useColorModeValue, useToken } from "@chakra-ui/core";
Box,
Skeleton,
Tooltip,
useColorModeValue,
useToken,
} from "@chakra-ui/core";
import gql from "graphql-tag"; import gql from "graphql-tag";
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import { Link, useHistory, useParams } from "react-router-dom"; import { Link, useHistory, useParams } from "react-router-dom";
import { Heading2, usePageTitle } from "./util"; import { Heading2, usePageTitle } from "./util";
import ItemPageLayout from "./ItemPageLayout"; import ItemPageLayout from "./ItemPageLayout";
import useCurrentUser from "./components/useCurrentUser";
export function ItemTradesOfferingPage() { export function ItemTradesOfferingPage() {
return ( return (
@ -30,6 +25,10 @@ export function ItemTradesOfferingPage() {
id id
username username
lastTradeActivity lastTradeActivity
matchingItems: itemsTheyWantThatCurrentUserOwns {
id
name
}
} }
closetList { closetList {
id id
@ -59,6 +58,10 @@ export function ItemTradesSeekingPage() {
id id
username username
lastTradeActivity lastTradeActivity
matchingItems: itemsTheyOwnThatCurrentUserWants {
id
name
}
} }
closetList { closetList {
id id
@ -124,20 +127,12 @@ function ItemTradesTable({
compareListHeading, compareListHeading,
tradesQuery, tradesQuery,
}) { }) {
const { isLoggedIn } = useCurrentUser();
const { loading, error, data } = useQuery(tradesQuery, { const { loading, error, data } = useQuery(tradesQuery, {
variables: { itemId }, variables: { itemId },
}); });
// HACK: I'm pretty much hiding this for now, because it's not ready. But const shouldShowCompareColumn = isLoggedIn;
/// it's visible at #show-compare-column!
const shouldShowCompareColumn = window.location.href.includes(
"show-compare-column"
);
const minorColumnWidth = {
base: shouldShowCompareColumn ? "23%" : "30%",
md: "18ex",
};
// We partially randomize trade sorting, but we want it to stay stable across // We partially randomize trade sorting, but we want it to stay stable across
// re-renders. To do this, we can use `getTradeSortKey`, which will either // re-renders. To do this, we can use `getTradeSortKey`, which will either
@ -148,7 +143,10 @@ function ItemTradesTable({
if (!tradeSortKeys.has(trade.id)) { if (!tradeSortKeys.has(trade.id)) {
tradeSortKeys.set( tradeSortKeys.set(
trade.id, trade.id,
getVaguelyRandomizedSortKeyForDate(trade.user.lastTradeActivity) getVaguelyRandomizedTradeSortKey(
trade.user.lastTradeActivity,
trade.user.matchingItems.length
)
); );
} }
return tradeSortKeys.get(trade.id); return tradeSortKeys.get(trade.id);
@ -161,6 +159,11 @@ function ItemTradesTable({
return <Box color="red.400">{error.message}</Box>; return <Box color="red.400">{error.message}</Box>;
} }
const minorColumnWidth = {
base: shouldShowCompareColumn ? "23%" : "30%",
md: "20ex",
};
return ( return (
<Box <Box
as="table" as="table"
@ -180,14 +183,17 @@ function ItemTradesTable({
<Box display={{ base: "none", sm: "block" }}>Last active</Box> <Box display={{ base: "none", sm: "block" }}>Last active</Box>
<Box display={{ base: "block", sm: "none" }}>Last edit</Box> <Box display={{ base: "block", sm: "none" }}>Last edit</Box>
</ItemTradesTableCell> </ItemTradesTableCell>
{shouldShowCompareColumn && (
<ItemTradesTableCell as="th" width={minorColumnWidth}>
<Box display={{ base: "none", sm: "block" }}>
Potential trades
</Box>
<Box display={{ base: "block", sm: "none" }}>Matches</Box>
</ItemTradesTableCell>
)}
<ItemTradesTableCell as="th" width={minorColumnWidth}> <ItemTradesTableCell as="th" width={minorColumnWidth}>
{userHeading} {userHeading}
</ItemTradesTableCell> </ItemTradesTableCell>
{shouldShowCompareColumn && (
<ItemTradesTableCell as="th" width={minorColumnWidth}>
Compare
</ItemTradesTableCell>
)}
<ItemTradesTableCell as="th">List</ItemTradesTableCell> <ItemTradesTableCell as="th">List</ItemTradesTableCell>
</Box> </Box>
</Box> </Box>
@ -221,6 +227,7 @@ function ItemTradesTable({
username={trade.user.username} username={trade.user.username}
listName={trade.closetList.name} listName={trade.closetList.name}
lastTradeActivity={trade.user.lastTradeActivity} lastTradeActivity={trade.user.lastTradeActivity}
matchingItems={trade.user.matchingItems}
shouldShowCompareColumn={shouldShowCompareColumn} shouldShowCompareColumn={shouldShowCompareColumn}
/> />
))} ))}
@ -246,12 +253,17 @@ function ItemTradesTableRow({
username, username,
listName, listName,
lastTradeActivity, lastTradeActivity,
matchingItems,
shouldShowCompareColumn, shouldShowCompareColumn,
}) { }) {
const history = useHistory(); const history = useHistory();
const onClick = React.useCallback(() => history.push(href), [history, href]); const onClick = React.useCallback(() => history.push(href), [history, href]);
const focusBackground = useColorModeValue("gray.100", "gray.600"); const focusBackground = useColorModeValue("gray.100", "gray.600");
const sortedMatchingItems = [...matchingItems].sort((a, b) =>
a.name.localeCompare(b.name)
);
return ( return (
<Box <Box
as="tr" as="tr"
@ -263,47 +275,37 @@ function ItemTradesTableRow({
<ItemTradesTableCell fontSize="xs"> <ItemTradesTableCell fontSize="xs">
{formatVagueDate(lastTradeActivity)} {formatVagueDate(lastTradeActivity)}
</ItemTradesTableCell> </ItemTradesTableCell>
<ItemTradesTableCell overflowWrap="break-word" fontSize="xs">
{username}
</ItemTradesTableCell>
{shouldShowCompareColumn && ( {shouldShowCompareColumn && (
<ItemTradesTableCell fontSize="xs"> <ItemTradesTableCell fontSize="xs">
<Tooltip {matchingItems.length > 0 ? (
placement="bottom" <Box as="ul">
label={ {sortedMatchingItems.slice(0, 4).map((item) => (
<Box> <Box key={item.id} as="li">
{compareListHeading}:
<Box as="ul" listStyle="disc">
<Box as="li" marginLeft="1em">
Adorable Freckles
</Box>
<Box as="li" marginLeft="1em">
Constellation Dress
</Box>
</Box>
<Box>(WIP: This is placeholder data!)</Box>
</Box>
}
>
<Box <Box
tabIndex="0" lineHeight="1.5"
width="100%" maxHeight="1.5em"
className={css` overflow="hidden"
&:hover, textOverflow="ellipsis"
&:focus, whiteSpace="nowrap"
tr:hover &,
tr:focus-within & {
text-decoration: underline dashed;
}
`}
> >
<Box display={{ base: "block", md: "none" }}>2 match</Box> {item.name}
<Box display={{ base: "none", md: "block" }}>2 matches</Box>
</Box> </Box>
</Tooltip> </Box>
))}
{matchingItems.length > 4 && (
<Box as="li">+ {matchingItems.length - 4} more</Box>
)}
</Box>
) : (
<>
<Box display={{ base: "none", sm: "block" }}>No matches</Box>
<Box display={{ base: "block", sm: "none" }}>None</Box>
</>
)}
</ItemTradesTableCell> </ItemTradesTableCell>
)} )}
<ItemTradesTableCell overflowWrap="break-word" fontSize="sm"> <ItemTradesTableCell fontSize="xs">{username}</ItemTradesTableCell>
<ItemTradesTableCell fontSize="sm">
<Box <Box
as={Link} as={Link}
to={href} to={href}
@ -415,16 +417,24 @@ function formatVagueDate(dateString) {
return shortMonthYearFormatter.format(date); return shortMonthYearFormatter.format(date);
} }
function getVaguelyRandomizedSortKeyForDate(dateString) { function getVaguelyRandomizedTradeSortKey(dateString, numMatchingItems) {
const date = new Date(dateString); const date = new Date(dateString);
const hasMatchingItems = numMatchingItems >= 1;
// "This week" sorts after all other dates, but with a random factor! I don't // "This week" sorts after all other dates, but with a random factor! I don't
// want people worrying about gaming themselves up to the very top, just be // want people worrying about gaming themselves up to the very top, just be
// active and trust the system 😅 (I figure that, if you care enough to "game" // active and trust the system 😅 (I figure that, if you care enough to "game"
// the system by faking activity every week, you probably also care enough to // the system by faking activity every week, you probably also care enough to
// be... making real trades every week lmao) // be... making real trades every week lmao)
//
// We also prioritize having matches, but we don't bother to sort _how many_
// matches, to decrease the power of gaming with large honeypot lists, and
// because it's hard to judge how good matches are anyway.
if (isThisWeek(date)) { if (isThisWeek(date)) {
return `ZZZthisweekZZZ-${Math.random()}`; const matchingItemsKey = hasMatchingItems
? "ZZmatchingZZ"
: "AAnotmatchingAA";
return `ZZZthisweekZZZ-${matchingItemsKey}-${Math.random()}`;
} }
return dateString; return dateString;

View file

@ -116,6 +116,85 @@ const buildSpeciesTranslationLoader = (db) =>
); );
}); });
const buildTradeMatchesLoader = (db) =>
new DataLoader(
async (userPairs) => {
const conditions = userPairs
.map(
(_) =>
`(public_user_hangers.user_id = ? AND current_user_hangers.user_id = ? AND public_user_hangers.owned = ? AND current_user_hangers.owned = ?)`
)
.join(" OR ");
const conditionValues = userPairs
.map(({ publicUserId, currentUserId, direction }) => {
if (direction === "public-owns-current-wants") {
return [publicUserId, currentUserId, true, false];
} else if (direction === "public-wants-current-owns") {
return [publicUserId, currentUserId, false, true];
} else {
throw new Error(
`unexpected user pair direction: ${JSON.stringify(direction)}`
);
}
})
.flat();
const [rows, _] = await db.execute(
`
SELECT
public_user_hangers.user_id AS public_user_id,
current_user_hangers.user_id AS current_user_id,
IF(
public_user_hangers.owned,
"public-owns-current-wants",
"public-wants-current-owns"
) AS direction,
GROUP_CONCAT(public_user_hangers.item_id) AS item_ids
FROM closet_hangers AS public_user_hangers
INNER JOIN users AS public_users ON public_users.id = public_user_hangers.user_id
LEFT JOIN closet_lists AS public_user_lists
ON public_user_lists.id = public_user_hangers.list_id
INNER JOIN closet_hangers AS current_user_hangers
ON public_user_hangers.item_id = current_user_hangers.item_id
WHERE (
(${conditions})
AND (
-- For the public user (but not the current), the hanger must be
-- marked Trading.
(public_user_hangers.list_id IS NOT NULL AND public_user_lists.visibility >= 2)
OR (
public_user_hangers.list_id IS NULL AND public_user_hangers.owned = 1
AND public_users.owned_closet_hangers_visibility >= 2
)
OR (
public_user_hangers.list_id IS NULL AND public_user_hangers.owned = 0
AND public_users.wanted_closet_hangers_visibility >= 2
)
)
)
GROUP BY public_user_id, current_user_id;
`,
conditionValues
);
const entities = rows.map(normalizeRow);
return userPairs.map(({ publicUserId, currentUserId, direction }) => {
const entity = entities.find(
(e) =>
e.publicUserId === publicUserId &&
e.currentUserId === currentUserId &&
e.direction === direction
);
return entity ? entity.itemIds.split(",") : [];
});
},
{
cacheKeyFn: ({ publicUserId, currentUserId, direction }) =>
`${publicUserId}-${currentUserId}-${direction}`,
}
);
const loadAllPetTypes = (db) => async () => { const loadAllPetTypes = (db) => async () => {
const [rows, _] = await db.execute( const [rows, _] = await db.execute(
`SELECT species_id, color_id FROM pet_types` `SELECT species_id, color_id FROM pet_types`
@ -1071,6 +1150,7 @@ function buildLoaders(db) {
); );
loaders.speciesLoader = buildSpeciesLoader(db); loaders.speciesLoader = buildSpeciesLoader(db);
loaders.speciesTranslationLoader = buildSpeciesTranslationLoader(db); loaders.speciesTranslationLoader = buildSpeciesTranslationLoader(db);
loaders.tradeMatchesLoader = buildTradeMatchesLoader(db);
loaders.userLoader = buildUserLoader(db); loaders.userLoader = buildUserLoader(db);
loaders.userByNameLoader = buildUserByNameLoader(db); loaders.userByNameLoader = buildUserByNameLoader(db);
loaders.userByEmailLoader = buildUserByEmailLoader(db); loaders.userByEmailLoader = buildUserByEmailLoader(db);

View file

@ -11,6 +11,11 @@ const typeDefs = gql`
itemsTheyOwn: [Item!]! itemsTheyOwn: [Item!]!
itemsTheyWant: [Item!]! itemsTheyWant: [Item!]!
# These items represent potential trade matches. We use this as a preview
# in the trade list table.
itemsTheyOwnThatCurrentUserWants: [Item!]!
itemsTheyWantThatCurrentUserOwns: [Item!]!
# When this user last updated any of their trade lists, as an ISO 8601 # When this user last updated any of their trade lists, as an ISO 8601
# timestamp. # timestamp.
lastTradeActivity: String! lastTradeActivity: String!
@ -119,6 +124,39 @@ const resolvers = {
return items; return items;
}, },
itemsTheyOwnThatCurrentUserWants: async (
{ id: publicUserId },
{ currentUserId },
{ tradeMatchesLoader }
) => {
if (!currentUserId) {
return [];
}
const itemIds = await tradeMatchesLoader.load({
publicUserId,
currentUserId,
direction: "public-owns-current-wants",
});
return itemIds.map((id) => ({ id }));
},
itemsTheyWantThatCurrentUserOwns: async (
{ id: publicUserId },
_,
{ currentUserId, tradeMatchesLoader }
) => {
if (!currentUserId) {
return [];
}
const itemIds = await tradeMatchesLoader.load({
publicUserId,
currentUserId,
direction: "public-wants-current-owns",
});
return itemIds.map((id) => ({ id }));
},
closetLists: async ( closetLists: async (
{ id }, { id },
_, _,