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