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};