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
1import "@/public/css/specific.css";
2import config from "@/utils/config.json";
3
4import { bricksToCurrency } from "@/utils/utilities";
5import { userDetails } from "@/utils/types";
6import * as apiTypes from "@/utils/api/types";
7import { getAPI } from "@/utils/api";
8
9const itemID = window.location.pathname.split("/")[2];
10
11/**
12 * Adds the locale real-life dollar value next to the amount of bricks an item costs in the text of the purchase button.
13 */
14export function irlBrickPrice() {
15 try {
16 const purchaseBtn = document.querySelector(
17 'button[onclick^="buy"], button[data-price]',
18 )!;
19 const currency = bricksToCurrency(
20 parseInt(purchaseBtn.getAttribute("data-price")!),
21 "USD",
22 );
23
24 if (currency) {
25 const spanTag = document.createElement("span");
26 spanTag.classList.add("text-muted");
27 spanTag.style.fontSize = "0.7rem";
28 spanTag.style.fontWeight = "lighter";
29 spanTag.innerText = ` (${currency})`;
30 purchaseBtn.appendChild(spanTag);
31 }
32 } catch (e) {
33 // The store purchase button has several different ways of being represented, this should
34 // only happen when the item is already owned
35 console.warn("[Poly+] Failure to find purchase button on page.");
36 }
37
38 const addPrice = function (item: HTMLElement) {
39 const price = item.getElementsByClassName("text-success")[0];
40 const purchaseBtn = item.querySelector("button[data-listing-id]");
41
42 if (price && purchaseBtn) {
43 const currency = bricksToCurrency(
44 parseInt(purchaseBtn.getAttribute("data-price")!),
45 "USD",
46 );
47
48 if (currency) {
49 const spanTag = document.createElement("span");
50 spanTag.classList.add("text-muted");
51 spanTag.style.fontSize = "0.7rem";
52 spanTag.style.fontWeight = "lighter";
53 spanTag.innerText = ` (${currency})`;
54 price.appendChild(spanTag);
55 }
56 }
57 };
58
59 const resellers = document.getElementById("resellers-container");
60 if (resellers) {
61 for (const reseller of resellers.children) {
62 addPrice(reseller as HTMLElement);
63 }
64
65 const mutations = new MutationObserver((mutations) => {
66 for (const record of mutations) {
67 for (const node of record.addedNodes) {
68 addPrice(node as HTMLElement);
69 }
70 }
71 });
72
73 mutations.observe(resellers, { childList: true });
74 }
75}
76
77/**
78 * Adds a button below the item thumbnail preview allowing the user to preview their avatar with the item they are looking at equipped.
79 * @param user The public-facing details of the authenticated user.
80 */
81export function tryOn(user: userDetails) {
82 const publicAPI = getAPI("public");
83
84 const itemId = window.location.pathname.split("/")[2];
85 const favoriteBtn = document.querySelector(
86 'button[onclick^="toggleFavorite"]',
87 )!;
88
89 const button = document.createElement("button");
90 button.classList.add("btn", "btn-outline-primary", "btn-sm", "mt-2");
91 button.style.width = "50px";
92 button.innerHTML = '<img src="' + browser.runtime.getURL("/svgs/vial.svg") +
93 '" width="15" height="15">';
94
95 const modal = document.createElement("dialog");
96 modal.classList.add("polyplus-modal");
97 Object.assign(modal.style, {
98 width: "450px",
99 height: "500px",
100 border: "1px solid #484848",
101 backgroundColor: "#181818",
102 borderRadius: "20px",
103 overflow: "hidden",
104 });
105
106 modal.innerHTML = `
107 <div class="row text-muted mb-2" style="font-size: 0.8rem;">
108 <div class="col">
109 <h5 class="mb-0" style="color: #fff;">Poly+ Item Preview</h5>
110 Try this item on your avatar before purchasing it!
111 </div>
112 <div class="col-md-2">
113 <button class="btn btn-info w-100 mx-auto" onclick="this.parentElement.parentElement.parentElement.close();">X</button>
114 </div>
115 </div>
116 </div>
117 <div class="modal-body"></div>
118 `;
119
120 document.body.prepend(modal);
121 favoriteBtn.parentElement!.appendChild(button);
122
123 const loadFrame = async (source: apiTypes.avatarApiSchema) => {
124 console.info("[Poly+] Loading avatar preview with avatar data: ", source);
125 for (const [key, value] of Object.entries(source.colors)) {
126 source.colors[key as keyof typeof source.colors] = "#" + value;
127 }
128
129 const avatar: { [key: string]: any } = {
130 useCharacter: true,
131 items: [] as Array<number>,
132 headColor: source.colors.head,
133 torsoColor: source.colors.torso,
134 leftArmColor: source.colors.leftArm,
135 rightArmColor: source.colors.rightArm,
136 leftLegColor: source.colors.leftLeg,
137 rightLegColor: source.colors.rightLeg,
138 };
139
140 const itemInfo: apiTypes.itemApiSchema =
141 await (await fetch(publicAPI.url + "store/" + itemId)).json();
142 for (
143 const item of [
144 ...source.assets.filter((
145 item: any,
146 ) => (item.type != itemInfo.type || itemInfo.type == "hat")),
147 {
148 id: itemInfo.id,
149 type: itemInfo.type,
150 },
151 ]
152 ) {
153 if (item.type == "hat" || item.type == "tool") {
154 const mesh: apiTypes.meshApiSchema = await (await fetch(
155 publicAPI.url + "assets/serve-mesh/" + item.id,
156 )).json();
157 if (mesh.success) {
158 if (item.type == "hat") {
159 avatar.items.push(mesh.url);
160 } else {
161 avatar.tool = mesh.url;
162 }
163 }
164 } else {
165 const texture: apiTypes.textureApiSchema = await (await fetch(
166 publicAPI.url + "assets/serve/" + item.id + "/Asset",
167 )).json();
168 if (texture.success) avatar[item.type] = texture.url;
169 }
170 }
171
172 const frame = document.createElement("iframe");
173 Object.assign(frame.style, {
174 width: "100%",
175 height: "auto",
176 aspectRatio: "1",
177 borderRadius: "20px",
178 background: "#1e1e1e",
179 });
180 frame.src = `https://polytoria.com/ptstatic/itemview/#${
181 btoa(encodeURIComponent(JSON.stringify(avatar)))
182 }`;
183
184 modal.getElementsByClassName("modal-body")[0].appendChild(frame);
185 };
186
187 button.addEventListener("click", async () => {
188 if (!modal.getElementsByTagName("iframe")[0]) {
189 button.innerHTML = `
190 <span class="spinner-grow spinner-grow-sm" aria-hidden="true"></span>
191 <span class="visually-hidden" role="status">Loading...</span>
192 `;
193
194 const avatar = await user.getAvatar();
195 if (avatar == "disabled") {
196 console.error(
197 "[Poly+] API is disabled, cancelling try-on items loading..",
198 );
199
200 const modalBody = modal.getElementsByClassName(
201 "modal-body",
202 )[0] as HTMLDivElement;
203 modalBody.style.marginTop = "50px";
204
205 modalBody.innerHTML = `
206 <div class="text-center p-2">
207 <img src="${
208 browser.runtime.getURL("/svgs/error.svg")
209 }" width="100" height="100">
210 <p class="text-muted mb-0">Sorry! This feature is currently unavailable. Please check back later!</p>
211 </div>
212 `;
213
214 modal.showModal();
215 button.innerHTML = '<img src="' +
216 browser.runtime.getURL("/svgs/vial.svg") +
217 '" width="15" height="15">';
218 return;
219 }
220
221 await loadFrame(avatar);
222 button.innerHTML = '<img src="' +
223 browser.runtime.getURL("/svgs/vial.svg") + '" width="15" height="15">';
224 }
225
226 modal.showModal();
227 });
228}
229
230/**
231 * Replaces the item sales counter with the number of owners the item has, useful for items that were granted by staff or earned via an event.
232 */
233export async function accurateOwnerCount() {
234 const publicAPI = getAPI("public");
235
236 const counter = document.querySelectorAll(".col.text-center")[2]!;
237 if (!counter || counter.children[1].textContent!.trim() != "0") return;
238
239 const owners: "disabled" | number = await pullKVCache(
240 "ownerCount",
241 itemID,
242 async () => {
243 if (!publicAPI.enabled) return "disabled";
244 const res: apiTypes.ownersApiSchema = await (await fetch(
245 publicAPI.url + "store/" + itemID + "/owners",
246 )).json();
247 return res.total;
248 },
249 60000, // 1 minute
250 false,
251 );
252
253 if (owners == "disabled") {
254 throw new Error(
255 "[Poly+] API disabled, cancelling accurate item owner count loading..",
256 );
257 }
258
259 (counter.children[0] as HTMLHeadingElement).innerText = "Owners";
260 (counter.children[1] as HTMLHeadingElement).innerText = owners
261 .toLocaleString();
262}