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 { css } from "emotion";
import {
Box,
Skeleton,
Tooltip,
useColorModeValue,
useToken,
} from "@chakra-ui/core";
import { Box, Skeleton, useColorModeValue, useToken } from "@chakra-ui/core";
import gql from "graphql-tag";
import { useQuery } from "@apollo/client";
import { Link, useHistory, useParams } from "react-router-dom";
import { Heading2, usePageTitle } from "./util";
import ItemPageLayout from "./ItemPageLayout";
import useCurrentUser from "./components/useCurrentUser";
export function ItemTradesOfferingPage() {
return (
@ -30,6 +25,10 @@ export function ItemTradesOfferingPage() {
id
username
lastTradeActivity
matchingItems: itemsTheyWantThatCurrentUserOwns {
id
name
}
}
closetList {
id
@ -59,6 +58,10 @@ export function ItemTradesSeekingPage() {
id
username
lastTradeActivity
matchingItems: itemsTheyOwnThatCurrentUserWants {
id
name
}
}
closetList {
id
@ -124,20 +127,12 @@ function ItemTradesTable({
compareListHeading,
tradesQuery,
}) {
const { isLoggedIn } = useCurrentUser();
const { loading, error, data } = useQuery(tradesQuery, {
variables: { itemId },
});
// HACK: I'm pretty much hiding this for now, because it's not ready. But
/// it's visible at #show-compare-column!
const shouldShowCompareColumn = window.location.href.includes(
"show-compare-column"
);
const minorColumnWidth = {
base: shouldShowCompareColumn ? "23%" : "30%",
md: "18ex",
};
const shouldShowCompareColumn = isLoggedIn;
// 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
@ -148,7 +143,10 @@ function ItemTradesTable({
if (!tradeSortKeys.has(trade.id)) {
tradeSortKeys.set(
trade.id,
getVaguelyRandomizedSortKeyForDate(trade.user.lastTradeActivity)
getVaguelyRandomizedTradeSortKey(
trade.user.lastTradeActivity,
trade.user.matchingItems.length
)
);
}
return tradeSortKeys.get(trade.id);
@ -161,6 +159,11 @@ function ItemTradesTable({
return <Box color="red.400">{error.message}</Box>;
}
const minorColumnWidth = {
base: shouldShowCompareColumn ? "23%" : "30%",
md: "20ex",
};
return (
<Box
as="table"
@ -180,14 +183,17 @@ function ItemTradesTable({
<Box display={{ base: "none", sm: "block" }}>Last active</Box>
<Box display={{ base: "block", sm: "none" }}>Last edit</Box>
</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}>
{userHeading}
</ItemTradesTableCell>
{shouldShowCompareColumn && (
<ItemTradesTableCell as="th" width={minorColumnWidth}>
Compare
</ItemTradesTableCell>
)}
<ItemTradesTableCell as="th">List</ItemTradesTableCell>
</Box>
</Box>
@ -221,6 +227,7 @@ function ItemTradesTable({
username={trade.user.username}
listName={trade.closetList.name}
lastTradeActivity={trade.user.lastTradeActivity}
matchingItems={trade.user.matchingItems}
shouldShowCompareColumn={shouldShowCompareColumn}
/>
))}
@ -246,12 +253,17 @@ function ItemTradesTableRow({
username,
listName,
lastTradeActivity,
matchingItems,
shouldShowCompareColumn,
}) {
const history = useHistory();
const onClick = React.useCallback(() => history.push(href), [history, href]);
const focusBackground = useColorModeValue("gray.100", "gray.600");
const sortedMatchingItems = [...matchingItems].sort((a, b) =>
a.name.localeCompare(b.name)
);
return (
<Box
as="tr"
@ -263,47 +275,37 @@ function ItemTradesTableRow({
<ItemTradesTableCell fontSize="xs">
{formatVagueDate(lastTradeActivity)}
</ItemTradesTableCell>
<ItemTradesTableCell overflowWrap="break-word" fontSize="xs">
{username}
</ItemTradesTableCell>
{shouldShowCompareColumn && (
<ItemTradesTableCell fontSize="xs">
<Tooltip
placement="bottom"
label={
<Box>
{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>
}
>
{matchingItems.length > 0 ? (
<Box as="ul">
{sortedMatchingItems.slice(0, 4).map((item) => (
<Box key={item.id} as="li">
<Box
tabIndex="0"
width="100%"
className={css`
&:hover,
&:focus,
tr:hover &,
tr:focus-within & {
text-decoration: underline dashed;
}
`}
lineHeight="1.5"
maxHeight="1.5em"
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
<Box display={{ base: "block", md: "none" }}>2 match</Box>
<Box display={{ base: "none", md: "block" }}>2 matches</Box>
{item.name}
</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 overflowWrap="break-word" fontSize="sm">
<ItemTradesTableCell fontSize="xs">{username}</ItemTradesTableCell>
<ItemTradesTableCell fontSize="sm">
<Box
as={Link}
to={href}
@ -415,16 +417,24 @@ function formatVagueDate(dateString) {
return shortMonthYearFormatter.format(date);
}
function getVaguelyRandomizedSortKeyForDate(dateString) {
function getVaguelyRandomizedTradeSortKey(dateString, numMatchingItems) {
const date = new Date(dateString);
const hasMatchingItems = numMatchingItems >= 1;
// "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
// 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
// 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)) {
return `ZZZthisweekZZZ-${Math.random()}`;
const matchingItemsKey = hasMatchingItems
? "ZZmatchingZZ"
: "AAnotmatchingAA";
return `ZZZthisweekZZZ-${matchingItemsKey}-${Math.random()}`;
}
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 [rows, _] = await db.execute(
`SELECT species_id, color_id FROM pet_types`
@ -1071,6 +1150,7 @@ function buildLoaders(db) {
);
loaders.speciesLoader = buildSpeciesLoader(db);
loaders.speciesTranslationLoader = buildSpeciesTranslationLoader(db);
loaders.tradeMatchesLoader = buildTradeMatchesLoader(db);
loaders.userLoader = buildUserLoader(db);
loaders.userByNameLoader = buildUserByNameLoader(db);
loaders.userByEmailLoader = buildUserByEmailLoader(db);

View file

@ -11,6 +11,11 @@ const typeDefs = gql`
itemsTheyOwn: [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
# timestamp.
lastTradeActivity: String!
@ -119,6 +124,39 @@ const resolvers = {
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 (
{ id },
_,