A rewrite of Poly+, my quality-of-life browser extension for Polytoria. Built entirely fresh using the WXT extension framework, Typescript, and with added better overall code quality.
extension

feat: Try-on Store Items

Index 5304bf27 843b1326

Changed files
+240 -28
entrypoints
places.content
store.content
public
svgs
utils
+3 -3
entrypoints/places.content/view.ts
···
-
import { _favoritedPlaces, limits } from "@/utils/storage";
+
import config from "@/utils/config.json";
+
import { _favoritedPlaces } from "@/utils/storage";
import { expireCache } from "@/utils/utilities";
const placeID = window.location.pathname.split('/')[2];
export async function favoritedPlaces() {
let placeIDs = await _favoritedPlaces.getValue();
-
const titleCard = document.getElementById;
const button = document.createElement('button');
button.classList.add('btn', 'btn-primary', 'btn-sm');
···
button.disabled = false;
if (placeIDs.indexOf(placeID) == -1) {
// Not Pinned
-
if (placeIDs.length >= limits.favoritedPlaces) {
+
if (placeIDs.length >= config.limits.favoritedPlaces) {
button.disabled = true;
}
button.children[0].classList.value = 'fa-regular fa-star';
+21 -15
entrypoints/store.content/index.ts
···
import { preferences } from "@/utils/storage";
import * as discovery from "./discovery";
import * as view from "./view";
+
import { userDetails, avatarApiSchema } from "@/utils/types";
export default defineContentScript({
matches: ['*://polytoria.com/store/*'],
main() {
preferences.getPreferences()
.then((values) => {
-
if (!window.location.pathname.split('/')[2]) {
-
// Discovery
-
if (values.irlBrickPrice.enabled) discovery.irlBrickPrice();
-
-
if (values.storeOwnedTags.enabled) {
-
getUserDetails()
-
.then((user) => {
-
if (!user) return;
-
discovery.ownedTags(user.userId);
-
});
-
}
-
} else {
-
// View
-
if (values.irlBrickPrice.enabled) view.irlBrickPrice();
-
};
+
getUserDetails()
+
.then((user) => {
+
if (!user) {
+
// Error page or event page most likely, where users are not authenticated
+
console.warn('[Poly+] Failure to get logged in user details.');
+
return;
+
};
+
+
if (!window.location.pathname.split('/')[2]) {
+
// Discovery
+
if (values.irlBrickPrice.enabled) discovery.irlBrickPrice();
+
if (values.storeOwnedTags.enabled) discovery.ownedTags(user.userId);
+
} else {
+
// View
+
if (values.irlBrickPrice.enabled) view.irlBrickPrice();
+
+
//@ts-ignore
+
view.tryOn(user);
+
};
+
})
});
}
});
+121
entrypoints/store.content/view.ts
···
+
import config from "@/utils/config.json";
+
import { avatarApiSchema, itemApiSchema, meshApiSchema, textureApiSchema, userDetails } from "@/utils/types";
import { bricksToCurrency } from "@/utils/utilities";
+
import "@/public/css/specific.css";
export function irlBrickPrice() {
try {
···
// only happen when the item is already owned
console.warn('[Poly+] Failure to find purchase button on page.');
};
+
};
+
+
export function tryOn(user: userDetails) {
+
const itemId = window.location.pathname.split('/')[2]
+
const favoriteBtn = document.querySelector('button[onclick^="toggleFavorite"]')!;
+
+
const button = document.createElement('button');
+
button.classList.add('btn', 'btn-outline-primary', 'btn-sm', 'mt-2');
+
button.innerHTML = '<img src="' + browser.runtime.getURL("/svgs/vial.svg") + '" width="15" height="15">';
+
+
const modal = document.createElement('dialog');
+
modal.classList.add('polyplus-modal');
+
Object.assign(modal.style, {
+
width: '450px',
+
border: '1px solid #484848',
+
backgroundColor: '#181818',
+
borderRadius: '20px',
+
overflow: 'hidden'
+
});
+
+
modal.innerHTML = `
+
<div class="row text-muted mb-2" style="font-size: 0.8rem;">
+
<div class="col">
+
<h5 class="mb-0" style="color: #fff;">Preview</h5>
+
Try this item on your avatar before purchasing it!
+
</div>
+
<div class="col-md-2">
+
<button class="btn btn-info w-100 mx-auto" onclick="this.parentElement.parentElement.parentElement.close();">X</button>
+
</div>
+
</div>
+
</div>
+
<div class="modal-body"></div>
+
`;
+
+
document.body.prepend(modal);
+
favoriteBtn.parentElement!.appendChild(button);
+
+
const loadFrame = async (source: avatarApiSchema) => {
+
console.info('[Poly+] Loading avatar preview with avatar data: ', source);
+
for (const [key, value] of Object.entries(source.colors)) {
+
source.colors[key as keyof typeof source.colors] = "#" + value;
+
};
+
+
const avatar: { [key: string]: any } = {
+
useCharacter: true,
+
items: [] as Array<number>,
+
headColor: source.colors.head,
+
torsoColor: source.colors.torso,
+
leftArmColor: source.colors.leftArm,
+
rightArmColor: source.colors.rightArm,
+
leftLegColor: source.colors.leftLeg,
+
rightLegColor: source.colors.rightLeg,
+
};
+
+
const itemInfo: itemApiSchema = (await (await fetch(config.api.urls.public + "store/" + itemId)).json());
+
for (const item of [
+
...source.assets.filter((item) => (item.type != itemInfo.type || itemInfo.type == "hat")),
+
{
+
id: itemInfo.id,
+
type: itemInfo.type
+
}
+
]) {
+
if (item.type == "hat" || item.type == "tool") {
+
const mesh: meshApiSchema = (await (await fetch(config.api.urls.public + "assets/serve-mesh/" + item.id)).json());
+
if (mesh.success) {
+
if (item.type == "hat") {
+
avatar.items.push(mesh.url)
+
} else {
+
avatar.tool = mesh.url;
+
};
+
};
+
} else {
+
const texture: textureApiSchema = (await (await fetch(config.api.urls.public + "assets/serve/" + item.id + "/Asset")).json());
+
if (texture.success) avatar[item.type] = texture.url;
+
};
+
};
+
+
const frame = document.createElement('iframe');
+
Object.assign(frame.style, {
+
width: '100%',
+
height: 'auto',
+
aspectRatio: '1',
+
borderRadius: '20px',
+
background: '#1e1e1e'
+
})
+
frame.src = config.api.urls.itemview + btoa(encodeURIComponent(JSON.stringify(avatar)));
+
+
modal.getElementsByClassName('modal-body')[0].appendChild(frame);
+
};
+
+
button.addEventListener('click', async () => {
+
if (!modal.getElementsByTagName('iframe')[0]) {
+
button.innerHTML = `
+
<span class="spinner-grow spinner-grow-sm" aria-hidden="true"></span>
+
<span class="visually-hidden" role="status">Loading...</span>
+
`;
+
+
const avatar = await user.getAvatar();
+
if (avatar == "disabled") {
+
console.error('[Poly+] API is disabled, cancelling try-on items loading..');
+
+
modal.getElementsByClassName('modal-body')[0].innerHTML = `
+
<div class="text-center p-2">
+
<img src="${ browser.runtime.getURL('/svgs/error.svg') }" width="100" height="100">
+
<p class="text-muted mb-0">Sorry! This feature is currently unavailable. Please check back later!</p>
+
</div>
+
`;
+
+
modal.showModal();
+
return;
+
};
+
+
await loadFrame(avatar);
+
button.innerHTML = '<img src="' + browser.runtime.getURL("/svgs/vial.svg") + '" width="15" height="15">';
+
};
+
+
modal.showModal();
+
});
};
+1
public/svgs/vial.svg
···
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path fill="#fdfcfd" d="M342.6 9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L28.1 342.6C10.1 360.6 0 385 0 410.5L0 416c0 53 43 96 96 96l5.5 0c25.5 0 49.9-10.1 67.9-28.1L448 205.3l9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-32-32-96-96-32-32zM205.3 256L352 109.3 402.7 160l-96 96-101.5 0z"/></svg>
+5 -1
utils/config.json
···
"enabled": true,
"urls": {
"public": "https://api.polytoria.com/v1/",
-
"internal": "https://polytoria.com/api/"
+
"internal": "https://polytoria.com/api/",
+
"itemview": "https://polytoria.com/ptstatic/itemview/#"
}
+
},
+
"limits": {
+
"favoritedPlaces": 15
}
}
+1 -6
utils/storage.ts
···
inventory: []
},
version: 1
-
});
-
-
// Limits
-
export const limits = {
-
favoritedPlaces: 15
-
}
+
});
+74
utils/types.ts
···
+
type itemTypes = "hat" | "tool" | "face" | "shirt" | "pants";
+
export type userApiSchema = {
id: number,
username: string,
···
createdAt: string,
updatedAt: string
};
+
+
export type avatarApiSchema = {
+
id: string,
+
colors: {
+
head: string,
+
torso: string,
+
leftArm: string,
+
rightArm: string,
+
leftLeg: string,
+
rightLeg: string
+
},
+
assets: Array<{
+
id: number,
+
type: itemTypes,
+
accessoryType: string,
+
name: string,
+
thumbnail: string,
+
path: string
+
}>,
+
isDefault: boolean
+
}
+
+
export type itemApiSchema = {
+
id: number,
+
type: itemTypes,
+
accessoryType: string,
+
name: string,
+
description: string,
+
tags: Array<string>,
+
creator: {
+
type: string,
+
id: number,
+
name: string,
+
thumbnail: string
+
},
+
thumbnail: string,
+
price: number | null,
+
averagePrice: number | null,
+
version: number,
+
sales: number,
+
favorites: number,
+
totalStock: number | null,
+
onSaleUntil: string | null,
+
isLimited: boolean,
+
createdAt: string,
+
updatedAt: string | null
+
}
+
+
export type meshApiSchema = {
+
success: boolean,
+
url?: string,
+
errors?: Array<{
+
code: string,
+
message: string
+
}>
+
};
+
+
export type textureApiSchema = {
+
success: boolean,
+
url?: string,
+
errors?: Array<{
+
code: string,
+
message: string
+
}>
+
};
+
+
export type userDetails = {
+
username: string,
+
userId: number,
+
bricks: number,
+
getAvatar: () => Promise<avatarApiSchema> | Promise<"disabled">
+
}
export interface cacheInterface {
favoritedPlaces: never[],
+14 -3
utils/utilities.ts
···
+
import config from "@/utils/config.json";
import { cache } from "./storage";
-
import { cacheInterface } from "./types";
+
import { avatarApiSchema, cacheInterface } from "./types";
import * as currencyPackages from "./currencyPackages.json"
export async function pullCache(key: string, replenish: Function, expiry: number, forceReplenish: boolean) {
···
const brickBalance = document.getElementsByClassName('brickBalanceCount')[0];
if (!profileLink || !brickBalance) return null;
+
+
const userId = parseInt(profileLink.href.split('/')[4]);
return {
username: profileLink.innerText.trim(),
-
userId: parseInt(profileLink.href.split('/')[4]),
-
bricks: parseInt(brickBalance.textContent!.replace(/,/g, ""))
+
userId: userId,
+
bricks: parseInt(brickBalance.textContent!.replace(/,/g, "")),
+
getAvatar: async () => {
+
if (config.api.enabled) {
+
const avatar = (await (await fetch(config.api.urls.public + 'users/' + userId + '/avatar')).json());
+
return avatar as avatarApiSchema;
+
} else {
+
return "disabled";
+
};
+
}
}
};