Emi Matchu
f3e10dea7f
Oh right, `imageUrl` is the name of the field relative to what the app expects, but under the hood `useOutfitAppearance` actually makes that an alias for `imageUrlV2(idealSize: SIZE_600)`. So we need to cache it as the same field with the same params, rather than as just plain `imageUrl`! This fixes the bug where wearing an item from search would require a network round-trip and visually remove all items in the meantime. (Also, none of this issue was visible to most users, because item search is still feature-flagged onto the old GQL one for most people!)
226 lines
5.4 KiB
JavaScript
226 lines
5.4 KiB
JavaScript
import gql from "graphql-tag";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
|
|
import apolloClient from "../apolloClient";
|
|
import { normalizeSwfAssetToLayer, normalizeZone } from "./shared-types";
|
|
|
|
export function useItemAppearances(id, options = {}) {
|
|
return useQuery({
|
|
...options,
|
|
queryKey: ["items", String(id)],
|
|
queryFn: () => loadItemAppearancesData(id),
|
|
});
|
|
}
|
|
|
|
async function loadItemAppearancesData(id) {
|
|
const res = await fetch(
|
|
`/items/${encodeURIComponent(id)}/appearances.json`,
|
|
);
|
|
|
|
if (!res.ok) {
|
|
throw new Error(
|
|
`loading item appearances failed: ${res.status} ${res.statusText}`,
|
|
);
|
|
}
|
|
|
|
return res.json().then(normalizeItemAppearancesData);
|
|
}
|
|
|
|
export function useItemSearch(searchOptions, queryOptions = {}) {
|
|
// Item searches are considered fresh for an hour, unless the search
|
|
// includes user-specific filters, in which case React Query will pretty
|
|
// aggressively reload it!
|
|
const includesUserSpecificFilters = searchOptions.filters.some(
|
|
(f) => f.key === "user_closet_hanger_ownership",
|
|
);
|
|
const staleTime = includesUserSpecificFilters ? 0 : 1000 * 60 * 5;
|
|
|
|
return useQuery({
|
|
...queryOptions,
|
|
queryKey: ["itemSearch", buildItemSearchParams(searchOptions)],
|
|
queryFn: () => loadItemSearch(searchOptions),
|
|
staleTime,
|
|
});
|
|
}
|
|
|
|
function buildItemSearchParams({
|
|
filters = [],
|
|
withAppearancesFor = null,
|
|
page = 1,
|
|
perPage = 30,
|
|
}) {
|
|
const params = new URLSearchParams();
|
|
for (const [i, { key, value, isPositive }] of filters.entries()) {
|
|
params.append(`q[${i}][key]`, key);
|
|
if (key === "fits") {
|
|
params.append(`q[${i}][value][species_id]`, value.speciesId);
|
|
params.append(`q[${i}][value][color_id]`, value.colorId);
|
|
if (value.altStyleId != null) {
|
|
params.append(`q[${i}][value][alt_style_id]`, value.altStyleId);
|
|
}
|
|
} else {
|
|
params.append(`q[${i}][value]`, value);
|
|
}
|
|
if (isPositive == false) {
|
|
params.append(`q[${i}][is_positive]`, "false");
|
|
}
|
|
}
|
|
if (withAppearancesFor != null) {
|
|
const { speciesId, colorId, altStyleId } = withAppearancesFor;
|
|
params.append(`with_appearances_for[species_id]`, speciesId);
|
|
params.append(`with_appearances_for[color_id]`, colorId);
|
|
if (altStyleId != null) {
|
|
params.append(`with_appearances_for[alt_style_id]`, altStyleId);
|
|
}
|
|
}
|
|
params.append("page", page);
|
|
params.append("per_page", perPage);
|
|
return params.toString();
|
|
}
|
|
|
|
async function loadItemSearch(searchOptions) {
|
|
const params = buildItemSearchParams(searchOptions);
|
|
|
|
const res = await fetch(`/items.json?${params}`);
|
|
|
|
if (!res.ok) {
|
|
throw new Error(
|
|
`loading item search failed: ${res.status} ${res.statusText}`,
|
|
);
|
|
}
|
|
|
|
const data = await res.json();
|
|
const result = normalizeItemSearchData(data, searchOptions);
|
|
|
|
for (const item of result.items) {
|
|
writeItemToApolloCache(item, searchOptions.withAppearancesFor);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
window.loadItemSearch = loadItemSearch;
|
|
|
|
/**
|
|
* writeItemToApolloCache is one last important bridge between our loaders and
|
|
* GQL! In `useOutfitState`, we consult the GraphQL cache to look up basic item
|
|
* info like zones, to decide when wearing an item would trigger a conflict
|
|
* with another.
|
|
*/
|
|
function writeItemToApolloCache(item, { speciesId, colorId, altStyleId }) {
|
|
apolloClient.writeQuery({
|
|
query: gql`
|
|
query WriteItemFromLoader(
|
|
$itemId: ID!
|
|
$speciesId: ID!
|
|
$colorId: ID!
|
|
$altStyleId: ID
|
|
) {
|
|
item(id: $itemId) {
|
|
id
|
|
name
|
|
thumbnailUrl
|
|
isNc
|
|
isPb
|
|
currentUserOwnsThis
|
|
currentUserWantsThis
|
|
appearanceOn(
|
|
speciesId: $speciesId
|
|
colorId: $colorId
|
|
altStyleId: $altStyleId
|
|
) {
|
|
id
|
|
layers {
|
|
id
|
|
remoteId
|
|
bodyId
|
|
knownGlitches
|
|
svgUrl
|
|
canvasMovieLibraryUrl
|
|
imageUrl: imageUrlV2(idealSize: SIZE_600)
|
|
swfUrl
|
|
zone {
|
|
id
|
|
}
|
|
}
|
|
|
|
restrictedZones {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
variables: {
|
|
itemId: item.id,
|
|
speciesId,
|
|
colorId,
|
|
altStyleId,
|
|
},
|
|
data: { item },
|
|
});
|
|
}
|
|
|
|
function normalizeItemAppearancesData(data) {
|
|
return {
|
|
name: data.name,
|
|
appearances: data.appearances.map((appearance) => ({
|
|
body: normalizeBody(appearance.body),
|
|
swfAssets: appearance.swf_assets.map(normalizeSwfAssetToLayer),
|
|
})),
|
|
restrictedZones: data.restricted_zones.map((z) => normalizeZone(z)),
|
|
};
|
|
}
|
|
|
|
function normalizeItemSearchData(data, searchOptions) {
|
|
return {
|
|
__typename: "ItemSearchResultV2",
|
|
id: buildItemSearchParams(searchOptions),
|
|
numTotalPages: data.total_pages,
|
|
items: data.items.map((item) => ({
|
|
__typename: "Item",
|
|
id: String(item.id),
|
|
name: item.name,
|
|
thumbnailUrl: item.thumbnail_url,
|
|
isNc: item["nc?"],
|
|
isPb: item["pb?"],
|
|
currentUserOwnsThis: item["owned?"],
|
|
currentUserWantsThis: item["wanted?"],
|
|
appearanceOn: normalizeItemSearchAppearance(
|
|
data.appearances[item.id],
|
|
item,
|
|
),
|
|
})),
|
|
};
|
|
}
|
|
|
|
function normalizeItemSearchAppearance(data, item) {
|
|
if (data == null) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
__typename: "ItemAppearance",
|
|
id: `item-${item.id}-body-${data.body.id}`,
|
|
layers: data.swf_assets.map(normalizeSwfAssetToLayer),
|
|
restrictedZones: data.swf_assets
|
|
.map((a) => a.restricted_zones)
|
|
.flat()
|
|
.map(normalizeZone),
|
|
};
|
|
}
|
|
|
|
function normalizeBody(body) {
|
|
if (String(body.id) === "0") {
|
|
return { id: "0" };
|
|
}
|
|
|
|
return {
|
|
__typename: "Body",
|
|
id: String(body.id),
|
|
species: {
|
|
id: String(body.species.id),
|
|
name: body.species.name,
|
|
humanName: body.species.human_name,
|
|
},
|
|
};
|
|
}
|