impress-2020/src/app/WardrobePage/useOutfitState.js

597 lines
18 KiB
JavaScript
Raw Normal View History

2020-04-21 20:46:53 -07:00
import React from "react";
2020-04-24 18:39:38 -07:00
import gql from "graphql-tag";
import produce, { enableMapSet } from "immer";
import { useQuery, useApolloClient } from "@apollo/client";
import { useParams } from "react-router-dom";
2020-04-21 20:46:53 -07:00
import { itemAppearanceFragment } from "../components/useOutfitAppearance";
2020-04-21 20:46:53 -07:00
2020-04-24 18:39:38 -07:00
enableMapSet();
2020-04-21 20:46:53 -07:00
export const OutfitStateContext = React.createContext(null);
2020-04-24 18:39:38 -07:00
function useOutfitState() {
const apolloClient = useApolloClient();
const initialState = useParseOutfitUrl();
2020-04-24 19:16:24 -07:00
const [state, dispatchToOutfit] = React.useReducer(
outfitStateReducer(apolloClient),
initialState
2020-04-24 19:16:24 -07:00
);
2020-04-21 20:46:53 -07:00
// If there's an outfit ID (i.e. we're on /outfits/:id), load basic data
// about the outfit. We'll use it to initialize the local state.
const {
loading: outfitLoading,
error: outfitError,
data: outfitData,
} = useQuery(
gql`
query OutfitStateSavedOutfit($id: ID!) {
outfit(id: $id) {
id
name
creator {
id
}
petAppearance {
2021-01-04 22:43:33 -08:00
id
species {
id
}
color {
id
}
pose
}
wornItems {
id
}
closetedItems {
id
}
# TODO: Consider pre-loading some fields, instead of doing them in
# follow-up queries?
}
}
`,
{
variables: { id: state.id },
skip: state.id == null,
returnPartialData: true,
onCompleted: (outfitData) => {
// This is only called once the _entire_ query loads, regardless of
// `returnPartialData`. We just use that for some early UI!
//
// Even though we do a HACK to make these values visible early, we
// still want to write them to state, so that reducers can see them and
// edit them!
const outfit = outfitData.outfit;
dispatchToOutfit({
type: "reset",
name: outfit.name,
speciesId: outfit.petAppearance.species.id,
colorId: outfit.petAppearance.color.id,
pose: outfit.petAppearance.pose,
wornItemIds: outfit.wornItems.map((item) => item.id),
closetedItemIds: outfit.closetedItems.map((item) => item.id),
});
},
}
);
// HACK: We fall back to outfit data here, to help the loading states go
// smoother. (Otherwise, there's a flicker where `outfitLoading` is false,
// but the `reset` action hasn't fired yet.) This also enables partial outfit
// data to show early, like the name, if we're navigating from Your Outfits.
//
// We also call `Array.from` on our item IDs. It's more convenient to manage
// them as a Set in state, but most callers will find it more convenient to
// access them as arrays! e.g. for `.map()`.
const outfit = outfitData?.outfit || null;
const id = state.id;
const creator = outfit?.creator || null;
const name = state.name || outfit?.name || null;
const speciesId =
state.speciesId || outfit?.petAppearance?.species?.id || null;
const colorId = state.colorId || outfit?.petAppearance?.color?.id || null;
const pose = state.pose || outfit?.petAppearance?.pose || null;
const appearanceId = state.appearanceId || null;
const wornItemIds = Array.from(
state.wornItemIds || outfit?.wornItems?.map((i) => i.id)
);
const closetedItemIds = Array.from(
state.closetedItemIds || outfit?.closetedItems?.map((i) => i.id)
);
const allItemIds = [...state.wornItemIds, ...state.closetedItemIds];
const {
loading: itemsLoading,
error: itemsError,
data: itemsData,
} = useQuery(
2020-04-24 21:17:03 -07:00
gql`
query OutfitStateItems(
$allItemIds: [ID!]!
$speciesId: ID!
$colorId: ID!
) {
2020-04-24 21:17:03 -07:00
items(ids: $allItemIds) {
# TODO: De-dupe this from SearchPanel?
id
name
thumbnailUrl
2020-08-31 23:27:21 -07:00
isNc
isPb
currentUserOwnsThis
currentUserWantsThis
2020-04-24 21:17:03 -07:00
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
# This enables us to quickly show the item when the user clicks it!
...ItemAppearanceForOutfitPreview
2020-04-24 21:17:03 -07:00
# This is used to group items by zone, and to detect conflicts when
# wearing a new item.
layers {
zone {
id
label @client
2020-04-24 21:17:03 -07:00
}
}
restrictedZones {
id
label @client
isCommonlyUsedByItems @client
}
2020-04-24 21:17:03 -07:00
}
}
# NOTE: We skip this query if items is empty for perf reasons. If
# you're adding more fields, consider changing that condition!
2020-04-24 21:17:03 -07:00
}
${itemAppearanceFragment}
`,
{
variables: { allItemIds, speciesId, colorId },
context: { sendAuth: true },
// Skip if this outfit has no items, as an optimization; or if we don't
// have the species/color ID loaded yet because we're waiting on the
// saved outfit to load.
skip: allItemIds.length === 0 || speciesId == null || colorId == null,
}
2020-04-21 20:46:53 -07:00
);
const resultItems = itemsData?.items || [];
// Okay, time for some big perf hacks! Lower down in the app, we use
// React.memo to avoid re-rendering Item components if the items haven't
// updated. In simpler cases, we just make the component take the individual
// item fields as props... but items are complex and that makes it annoying
// :p Instead, we do these tricks to reuse physical item objects if they're
// still deep-equal to the previous version. This is because React.memo uses
// object identity to compare its props, so now when it checks whether
// `oldItem === newItem`, the answer will be `true`, unless the item really
// _did_ change!
const [cachedItemObjects, setCachedItemObjects] = React.useState([]);
let items = resultItems.map((item) => {
const cachedItemObject = cachedItemObjects.find((i) => i.id === item.id);
if (
cachedItemObject &&
JSON.stringify(cachedItemObject) === JSON.stringify(item)
) {
return cachedItemObject;
}
return item;
});
if (
items.length === cachedItemObjects.length &&
items.every((_, index) => items[index] === cachedItemObjects[index])
) {
// Even reuse the entire array if none of the items changed!
items = cachedItemObjects;
}
React.useEffect(() => {
setCachedItemObjects(items);
}, [items, setCachedItemObjects]);
2020-04-24 21:17:03 -07:00
const itemsById = {};
for (const item of items) {
itemsById[item.id] = item;
}
2020-04-22 14:55:12 -07:00
const zonesAndItems = getZonesAndItems(
itemsById,
wornItemIds,
closetedItemIds
2020-04-21 20:46:53 -07:00
);
const incompatibleItems = items
.filter((i) => i.appearanceOn.layers.length === 0)
.sort((a, b) => a.name.localeCompare(b.name));
2020-04-21 20:46:53 -07:00
2020-04-30 00:45:01 -07:00
const url = buildOutfitUrl(state);
2020-04-24 19:16:24 -07:00
const outfitState = {
id,
creator,
2020-04-24 19:16:24 -07:00
zonesAndItems,
incompatibleItems,
2020-04-24 23:29:26 -07:00
name,
2020-04-24 19:16:24 -07:00
wornItemIds,
2020-04-25 07:22:03 -07:00
closetedItemIds,
2020-04-24 19:16:24 -07:00
allItemIds,
speciesId,
colorId,
2020-05-23 12:47:06 -07:00
pose,
appearanceId,
2020-04-30 00:45:01 -07:00
url,
2020-04-24 19:16:24 -07:00
};
2020-04-24 18:39:38 -07:00
// Keep the URL up-to-date. (We don't listen to it, though 😅)
2020-04-30 00:45:01 -07:00
React.useEffect(() => {
window.history.replaceState(null, "", url);
}, [url]);
return {
loading: outfitLoading || itemsLoading,
error: outfitError || itemsError,
outfitState,
dispatchToOutfit,
};
2020-04-24 18:39:38 -07:00
}
const outfitStateReducer = (apolloClient) => (baseState, action) => {
switch (action.type) {
2020-04-24 23:29:26 -07:00
case "rename":
return { ...baseState, name: action.outfitName };
2020-05-10 00:21:04 -07:00
case "setSpeciesAndColor":
return {
...baseState,
speciesId: action.speciesId,
colorId: action.colorId,
pose: action.pose,
appearanceId: null,
2020-05-10 00:21:04 -07:00
};
2020-04-24 18:39:38 -07:00
case "wearItem":
return produce(baseState, (state) => {
2020-04-24 19:16:24 -07:00
const { wornItemIds, closetedItemIds } = state;
const { itemId, itemIdsToReconsider = [] } = action;
2020-04-24 18:39:38 -07:00
// Move conflicting items to the closet.
//
// We do this by looking them up in the Apollo Cache, which is going to
// include the relevant item data because the `useOutfitState` hook
// queries for it!
//
// (It could be possible to mess up the timing by taking an action
// while worn items are still partially loading, but I think it would
// require a pretty weird action sequence to make that happen... like,
// doing a search and it loads before the worn item data does? Anyway,
// Apollo will throw in that case, which should just essentially reject
// the action.)
let conflictingIds;
try {
conflictingIds = findItemConflicts(itemId, state, apolloClient);
} catch (e) {
console.error(e);
return;
}
2020-04-24 18:39:38 -07:00
for (const conflictingId of conflictingIds) {
wornItemIds.delete(conflictingId);
closetedItemIds.add(conflictingId);
}
2020-04-24 20:19:26 -07:00
// Move this item from the closet to the worn set.
closetedItemIds.delete(itemId);
2020-04-24 18:39:38 -07:00
wornItemIds.add(itemId);
reconsiderItems(itemIdsToReconsider, state, apolloClient);
2020-04-24 18:39:38 -07:00
});
2020-04-24 20:19:26 -07:00
case "unwearItem":
return produce(baseState, (state) => {
const { wornItemIds, closetedItemIds } = state;
const { itemId, itemIdsToReconsider = [] } = action;
2020-04-24 20:19:26 -07:00
// Move this item from the worn set to the closet.
wornItemIds.delete(itemId);
closetedItemIds.add(itemId);
reconsiderItems(itemIdsToReconsider, state, apolloClient);
2020-04-24 20:19:26 -07:00
});
case "removeItem":
return produce(baseState, (state) => {
const { wornItemIds, closetedItemIds } = state;
const { itemId, itemIdsToReconsider = [] } = action;
// Remove this item from both the worn set and the closet.
wornItemIds.delete(itemId);
closetedItemIds.delete(itemId);
reconsiderItems(itemIdsToReconsider, state, apolloClient);
});
case "setPose":
return {
...baseState,
pose: action.pose,
// Usually only the `pose` is specified, but `PosePickerSupport` can
// also specify a corresponding `appearanceId`, to get even more
// particular about which version of the pose to show if more than one.
appearanceId: action.appearanceId || null,
};
2020-04-25 05:29:27 -07:00
case "reset":
return produce(baseState, (state) => {
const {
name,
speciesId,
colorId,
2020-05-23 12:47:06 -07:00
pose,
wornItemIds,
closetedItemIds,
} = action;
state.name = name;
state.speciesId = speciesId ? String(speciesId) : baseState.speciesId;
state.colorId = colorId ? String(colorId) : baseState.colorId;
2020-05-23 12:47:06 -07:00
state.pose = pose || baseState.pose;
state.wornItemIds = wornItemIds
2020-04-25 07:22:03 -07:00
? new Set(wornItemIds.map(String))
: baseState.wornItemIds;
state.closetedItemIds = closetedItemIds
2020-04-25 07:22:03 -07:00
? new Set(closetedItemIds.map(String))
: baseState.closetedItemIds;
});
2020-04-24 18:39:38 -07:00
default:
2020-04-25 04:33:05 -07:00
throw new Error(`unexpected action ${JSON.stringify(action)}`);
2020-04-24 18:39:38 -07:00
}
};
function useParseOutfitUrl() {
const { id } = useParams();
// For the /outfits/:id page, ignore the query string, and just wait for the
// outfit data to load in!
if (id != null) {
return {
id,
name: null,
speciesId: null,
colorId: null,
pose: null,
appearanceId: null,
wornItemIds: [],
closetedItemIds: [],
};
}
// Otherwise, parse the query string, and fill in default values for anything
// not specified.
const urlParams = new URLSearchParams(window.location.search);
return {
id: id,
name: urlParams.get("name"),
speciesId: urlParams.get("species") || "1",
colorId: urlParams.get("color") || "8",
2020-05-23 12:47:06 -07:00
pose: urlParams.get("pose") || "HAPPY_FEM",
appearanceId: urlParams.get("state") || null,
2020-05-11 21:19:34 -07:00
wornItemIds: new Set(urlParams.getAll("objects[]")),
closetedItemIds: new Set(urlParams.getAll("closet[]")),
};
}
2020-04-24 18:39:38 -07:00
function findItemConflicts(itemIdToAdd, state, apolloClient) {
const { wornItemIds, speciesId, colorId } = state;
const { items } = apolloClient.readQuery({
query: gql`
2020-05-19 14:48:54 -07:00
query OutfitStateItemConflicts(
$itemIds: [ID!]!
$speciesId: ID!
$colorId: ID!
) {
2020-04-24 18:39:38 -07:00
items(ids: $itemIds) {
id
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
layers {
zone {
id
}
}
restrictedZones {
id
}
2020-04-24 18:39:38 -07:00
}
}
}
`,
variables: {
itemIds: [itemIdToAdd, ...wornItemIds],
speciesId,
colorId,
},
});
const itemToAdd = items.find((i) => i.id === itemIdToAdd);
if (!itemToAdd.appearanceOn) {
return [];
}
2020-04-24 18:39:38 -07:00
const wornItems = Array.from(wornItemIds).map((id) =>
items.find((i) => i.id === id)
);
const itemToAddZoneSets = getItemZones(itemToAdd);
2020-04-24 18:39:38 -07:00
const conflictingIds = [];
for (const wornItem of wornItems) {
if (!wornItem.appearanceOn) {
continue;
}
const wornItemZoneSets = getItemZones(wornItem);
const itemsConflict =
setsIntersect(
itemToAddZoneSets.occupies,
wornItemZoneSets.occupiesOrRestricts
) ||
setsIntersect(
wornItemZoneSets.occupies,
itemToAddZoneSets.occupiesOrRestricts
);
if (itemsConflict) {
2020-04-24 18:39:38 -07:00
conflictingIds.push(wornItem.id);
}
}
2020-04-22 14:55:12 -07:00
2020-04-24 18:39:38 -07:00
return conflictingIds;
2020-04-22 14:55:12 -07:00
}
function getItemZones(item) {
const occupies = new Set(item.appearanceOn.layers.map((l) => l.zone.id));
const restricts = new Set(item.appearanceOn.restrictedZones.map((z) => z.id));
const occupiesOrRestricts = new Set([...occupies, ...restricts]);
return { occupies, occupiesOrRestricts };
}
function setsIntersect(a, b) {
for (const el of a) {
if (b.has(el)) {
return true;
}
}
return false;
}
/**
* Try to add these items back to the outfit, if there would be no conflicts.
* We use this in Search to try to restore these items after the user makes
* changes, e.g., after they try on another Background we want to restore the
* previous one!
*
* This mutates state.wornItemIds directly, on the assumption that we're in an
* immer block, in which case mutation is the simplest API!
*/
function reconsiderItems(itemIdsToReconsider, state, apolloClient) {
for (const itemIdToReconsider of itemIdsToReconsider) {
const conflictingIds = findItemConflicts(
itemIdToReconsider,
state,
apolloClient
);
if (conflictingIds.length === 0) {
state.wornItemIds.add(itemIdToReconsider);
}
}
}
2020-04-24 19:16:24 -07:00
// TODO: Get this out of here, tbh...
2020-04-22 14:55:12 -07:00
function getZonesAndItems(itemsById, wornItemIds, closetedItemIds) {
const wornItems = wornItemIds.map((id) => itemsById[id]).filter((i) => i);
const closetedItems = closetedItemIds
.map((id) => itemsById[id])
.filter((i) => i);
// We use zone label here, rather than ID, because some zones have the same
// label and we *want* to over-simplify that in this UI. (e.g. there are
// multiple Hat zones, and some items occupy different ones, but mostly let's
// just group them and if they don't conflict then all the better!)
2020-04-21 20:46:53 -07:00
const allItems = [...wornItems, ...closetedItems];
const itemsByZoneLabel = new Map();
2020-04-24 19:16:24 -07:00
for (const item of allItems) {
if (!item.appearanceOn) {
continue;
}
2020-04-24 19:16:24 -07:00
for (const layer of item.appearanceOn.layers) {
const zoneLabel = layer.zone.label;
2020-04-24 19:16:24 -07:00
if (!itemsByZoneLabel.has(zoneLabel)) {
itemsByZoneLabel.set(zoneLabel, []);
2020-04-24 19:16:24 -07:00
}
itemsByZoneLabel.get(zoneLabel).push(item);
2020-04-24 19:16:24 -07:00
}
}
let zonesAndItems = Array.from(itemsByZoneLabel.entries()).map(
([zoneLabel, items]) => ({
zoneLabel: zoneLabel,
2020-04-24 19:16:24 -07:00
items: [...items].sort((a, b) => a.name.localeCompare(b.name)),
})
);
zonesAndItems.sort((a, b) => a.zoneLabel.localeCompare(b.zoneLabel));
2020-04-21 20:46:53 -07:00
// As one last step, try to remove zone groups that aren't helpful.
const groupsWithConflicts = zonesAndItems.filter(
({ items }) => items.length > 1
);
const itemIdsWithConflicts = new Set(
groupsWithConflicts
.map(({ items }) => items)
.flat()
.map((item) => item.id)
);
const itemIdsWeHaveSeen = new Set();
zonesAndItems = zonesAndItems.filter(({ items }) => {
// We need all groups with more than one item. If there's only one, we get
// to think harder :)
if (items.length > 1) {
items.forEach((item) => itemIdsWeHaveSeen.add(item.id));
return true;
}
const item = items[0];
// Has the item been seen a group we kept, or an upcoming group with
// multiple conflicting items? If so, skip this group. If not, keep it.
if (itemIdsWeHaveSeen.has(item.id) || itemIdsWithConflicts.has(item.id)) {
return false;
} else {
itemIdsWeHaveSeen.add(item.id);
return true;
}
});
2020-04-22 14:55:12 -07:00
return zonesAndItems;
2020-04-21 20:46:53 -07:00
}
2020-04-30 00:45:01 -07:00
function buildOutfitUrl(state) {
const {
id,
name,
speciesId,
colorId,
2020-05-23 12:47:06 -07:00
pose,
appearanceId,
wornItemIds,
closetedItemIds,
} = state;
2020-04-30 00:45:01 -07:00
const { origin, pathname } = window.location;
if (id) {
return origin + `/outfits/${id}`;
}
2020-05-10 00:21:04 -07:00
const params = new URLSearchParams({
name: name || "",
species: speciesId,
color: colorId,
2020-05-23 12:47:06 -07:00
pose,
2020-05-10 00:21:04 -07:00
});
2020-04-30 00:45:01 -07:00
for (const itemId of wornItemIds) {
params.append("objects[]", itemId);
}
for (const itemId of closetedItemIds) {
params.append("closet[]", itemId);
}
if (appearanceId != null) {
// `state` is an old name for compatibility with old-style DTI URLs. It
// refers to "PetState", the database table name for pet appearances.
params.append("state", appearanceId);
}
2020-04-30 00:45:01 -07:00
return origin + pathname + "?" + params.toString();
2020-04-30 00:45:01 -07:00
}
2020-04-21 20:46:53 -07:00
export default useOutfitState;