MouseHunt - Marketplace UI Tweaks

Adds useful features and tweaks to the Marketplace rework

// ==UserScript==
// @name         MouseHunt - Marketplace UI Tweaks
// @author       Tran Situ (tsitu)
// @namespace    https://greasyfork.org/en/users/232363-tsitu
// @version      1.6.2
// @description  Adds useful features and tweaks to the Marketplace rework
// @match        http://www.mousehuntgame.com/*
// @match        https://www.mousehuntgame.com/*
// ==/UserScript==

(function () {
  /**
   * [ Notes ]
   * innerText has poor retrieval perf, use textContent
   *   http://perfectionkills.com/the-poor-misunderstood-innerText/
   * Is there a better way to center scrollRow vertically within table?
   *   https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView
   */

  MutationObserver =
    window.MutationObserver ||
    window.WebKitMutationObserver ||
    window.MozMutationObserver;

  // Initialize 'Browse' tab item caching
  if (localStorage.getItem("marketplace-browse-cache-tsitu") === null) {
    const cacheObj = {
      "Cheese": 0,
      "Baskets & Kits": 0,
      "Charms": 0,
      "Crafting": 0,
      "Special": 0,
      "Collectibles": 0,
      "Weapons": 0,
      "Skins": 0
    };
    localStorage.setItem(
      "marketplace-browse-cache-tsitu",
      JSON.stringify(cacheObj)
    );
  }

  // Only observe changes to the #overlayPopup element
  const observerTarget = document.querySelector("#overlayPopup");

  const observer = new MutationObserver(function () {
    // Check if the Marketplace interface is open
    if (observerTarget.querySelector(".marketplaceView")) {
      // Disconnect and reconnect later to prevent mutation loop
      observer.disconnect();

      // Feature: Move close button to top right and clean up visuals
      const oldClose = observerTarget.querySelector(
        ".button[type=submit][value=Close]"
      );
      if (oldClose) {
        const newClose = oldClose.cloneNode();
        oldClose.remove();
        const suffix = observerTarget.querySelector(".suffix");
        if (suffix) suffix.remove();

        newClose.style.position = "absolute";
        newClose.style.right = "0px";
        newClose.style.top = "5px";
        observerTarget.querySelector(".marketplaceView").prepend(newClose);

        const searchContainer = observerTarget.querySelector(
          ".marketplaceView-header-searchContainer"
        );
        if (searchContainer) {
          searchContainer.style.right = "65px";
          searchContainer.style.width = "220px";
        }

        const searchBar = observerTarget.querySelector(
          ".marketplaceView-header-search"
        );
        if (searchBar) {
          searchBar.style.width = "184px";
        }

        // Remove 'X' in top right
        const topX = observerTarget.querySelector("#jsDialogClose");
        if (topX) topX.remove();
      }

      const browseTab = observerTarget.querySelector(
        "[data-tab=browse].active"
      );
      const backButton = observerTarget.querySelector(
        "a.marketplaceView-breadcrumb"
      );

      if (browseTab && !backButton) {
        /* Browse tab logic (active Browse tab + inactive 'Back' button) */

        // Align trend icon divs to the right
        const trendIcons = observerTarget.querySelectorAll(
          ".marketplaceView-trendIcon"
        );
        trendIcons.forEach(el => {
          const td = el.parentElement;
          if (td) {
            td.style.textAlign = "right";
          }
        });

        const sidebar = observerTarget.querySelector(
          ".marketplaceView-browse-sidebar"
        );
        const itemType = sidebar.querySelector(
          ".marketplaceView-browse-sidebar-link.active"
        );

        // Feature: Make item images 40x40 px
        observerTarget
          .querySelectorAll(".marketplaceView-itemImage")
          .forEach(el => {
            el.style.width = "40px";
            el.style.height = "40px";
            el.style.backgroundSize = "100%";
            el.style.minHeight = "40px";
          });

        let totalValueSum = 0;
        let totalValueSell = 0;
        /**
         * Abbreviates large number values up to 1 decimal point
         * k = 1,000 and m = 1,000,000
         * @param {number} num Integer to abbreviate
         * @return {string}
         */
        function abbrev(num) {
          if (num <= 999) {
            return "" + num;
          } else if (num >= 1000 && num <= 999999) {
            let pre = Math.floor(num / 1000);
            let post = Math.round((num % 1000) / 100);
            if (post === 10) {
              post = 0;
              pre += 1;
            }
            return `${pre}.${post}k`;
          } else if (num >= 1000000) {
            let pre = Math.floor(num / 1000000);
            let post = Math.round((num % 1000000) / 100000);
            if (post === 10) {
              post = 0;
              pre += 1;
            }
            return `${pre}.${post}m`;
          }
        }

        const rows = observerTarget.querySelectorAll("tr[data-item-id]");
        if (rows.length > 0) {
          const avgPriceHeader = observerTarget.querySelector(
            "th.marketplaceView-table-averagePrice"
          );

          const valueHeader = document.createElement("th");
          valueHeader.innerText = "Value";
          valueHeader.className =
            "marketplaceView-table-estvalue marketplaceView-table-numeric sortable";

          // Custom "Value" column sort
          valueHeader.onclick = function () {
            if (!valueHeader.classList.contains("active")) {
              valueHeader.classList.add("active");
              observerTarget
                .querySelectorAll(".marketplaceView-table .sortable")
                .forEach(el => {
                  if (
                    !el.classList.contains("marketplaceView-table-estvalue") &&
                    el.classList.contains("active")
                  ) {
                    el.classList.toggle("active");
                  }
                });
            } else {
              valueHeader.classList.toggle("reverse");
            }

            const unsortedArr = [];
            observerTarget
              .querySelectorAll(".marketplaceView-table tr[data-item-id]")
              .forEach(el => {
                unsortedArr.push(el);
                el.remove();
              });

            const sortedArr = unsortedArr.sort((a, b) => {
              let aT = a.title || 0;
              let bT = b.title || 0;

              if (typeof aT === "string") {
                aT = parseInt(aT.split(" Gold")[0].replace(/,/g, ""));
              }

              if (typeof bT === "string") {
                bT = parseInt(bT.split(" Gold")[0].replace(/,/g, ""));
              }

              if (valueHeader.classList.contains("reverse")) {
                return aT - bT; // Low > High
              } else {
                return bT - aT; // High > Low
              }
            });

            const targetTable = observerTarget.querySelector(
              ".marketplaceView-table tbody"
            );
            sortedArr.forEach(el => {
              targetTable.appendChild(el);
            });

            const emptyEl = observerTarget.querySelector(
              ".marketplaceView-table tr.empty"
            );
            if (emptyEl) {
              emptyEl.remove();
              targetTable.appendChild(emptyEl);
            }

            return false;
          };

          if (
            avgPriceHeader &&
            !observerTarget.querySelector(".marketplaceView-table-estvalue")
          ) {
            // Add 'Value' column header
            avgPriceHeader.insertAdjacentElement("afterend", valueHeader);

            rows.forEach(row => {
              // Add click handlers to the <a>'s that open up an item page
              row.querySelectorAll("a").forEach(el => {
                const aText = el.onclick;
                if (aText) {
                  if (aText.toString().indexOf("showItem") >= 0) {
                    el.addEventListener("click", function () {
                      // Parse current item name and type for caching
                      const name = row.querySelector(
                        ".marketplaceView-table-name"
                      );

                      if (name && itemType) {
                        // Retrieve and overwrite localStorage
                        const lsText = localStorage.getItem(
                          "marketplace-browse-cache-tsitu"
                        );
                        if (lsText) {
                          const lsObj = JSON.parse(lsText);
                          lsObj[itemType.textContent] = name.textContent;
                          localStorage.setItem(
                            "marketplace-browse-cache-tsitu",
                            JSON.stringify(lsObj)
                          );
                        }
                      }
                    });
                  }
                }
              });

              // Parse owned quantity
              let ownedNum = 0;
              const ownedText = row.querySelector(
                ".marketplaceView-table-quantity"
              ).textContent;
              if (ownedText !== "-") {
                ownedNum = parseInt(ownedText.split(",").join(""));
              }

              // Parse average prices
              let priceNum = 0;
              const priceText = row.querySelector(".marketplaceView-goldValue");
              if (priceText.children.length > 0) {
                priceNum = parseInt(
                  priceText.children[0].title.split(" ")[0].split(",").join("")
                );
              }

              const multValue = ownedNum * priceNum;
              if (multValue > 0) {
                totalValueSum += multValue;
                const sellValue = Math.floor((priceNum * 100) / 110) * ownedNum;
                totalValueSell += sellValue;
                row.title = `${multValue.toLocaleString()} Gold (Buy)\n${sellValue.toLocaleString()} Gold (Sell)`;
              }

              let outputText = abbrev(multValue);
              if (priceNum === 0) {
                // Avg. Price currently unavailable, but value isn't necessarily 0
                outputText = "N/A";
              }

              const valueColumn = document.createElement("td");
              valueColumn.innerText = outputText;
              valueColumn.className =
                "marketplaceView-table-numeric value-column-tsitu";

              // Feature: Insert 'Own' x 'Avg. Price' = 'Value' column data
              row
                .querySelector(".marketplaceView-table-averagePrice")
                .insertAdjacentElement("afterend", valueColumn);
            });
          }
        }

        // Add info to the sidebar
        if (
          sidebar &&
          !observerTarget.querySelector(".marketplace-sidebar-tsitu")
        ) {
          // Container div
          const div = document.createElement("div");
          div.className = "marketplace-sidebar-tsitu";
          div.style.margin = "20px";

          // Highlighted text
          const span1 = document.createElement("span");
          span1.style.backgroundColor = "#D6EBA1";
          span1.innerText = "highlighted green";

          // Other text
          const span2 = document.createElement("span");
          span2.innerText = "Last viewed item is ";

          div.appendChild(span2);
          div.appendChild(span1);

          // Feature: Add <span> with sum of values on current tab
          const filterDiv = observerTarget.querySelector(
            ".marketplaceView-browse-filterContainer"
          );
          if (
            filterDiv &&
            !observerTarget.querySelector(".marketplace-total-value-tsitu")
          ) {
            const span = document.createElement("span");
            span.className = "marketplace-total-value-tsitu";
            span.innerText = `Total estimated value on this tab: ${totalValueSum.toLocaleString()} (Buy)\n${totalValueSell.toLocaleString()} (Sell)`;

            div.appendChild(document.createElement("br"));
            div.appendChild(document.createElement("br"));
            div.appendChild(span);
          }

          // Inject into DOM
          sidebar.appendChild(div);
        }

        // Feature: Check cache for most recently clicked item and scroll to it
        const lsText = localStorage.getItem("marketplace-browse-cache-tsitu");
        if (lsText) {
          const lsObj = JSON.parse(lsText);
          const itemType = sidebar.querySelector(
            ".marketplaceView-browse-sidebar-link.active"
          );
          if (itemType) {
            const name = lsObj[itemType.textContent];
            if (name && name !== 0) {
              /**
               * Return row element that matches existing cached item name
               * @param {string} name Cached item name
               * @return {HTMLElement|false} <tr> that should be highlighted and scrolled to
               */
              function findElement(name) {
                for (let el of observerTarget.querySelectorAll(
                  "tr[data-item-id]"
                )) {
                  const aTags = el.querySelectorAll("a");
                  if (aTags.length === 5) {
                    if (name === aTags[2].textContent) {
                      return el;
                    }
                  }
                }

                return false;
              }

              // Calculate index for nth-child
              const targetEl = findElement(name);
              let nthChildValue = 0;
              for (let i = 0; i < rows.length; i++) {
                const el = rows[i];
                if (el === targetEl) {
                  nthChildValue = i + 2;
                  break;
                }
              }

              // tr:nth-child value (min = 2)
              const recentRow = observerTarget.querySelector(
                `.marketplaceView-table tr:nth-child(${nthChildValue})`
              );
              if (recentRow) {
                recentRow.style.backgroundColor = "#D6EBA1";

                // Try scrolling up to 4 rows down
                let scrollRow = recentRow;
                for (let i = 4; i > 0; i--) {
                  const row = observerTarget.querySelector(
                    `.marketplaceView-table tr:nth-child(${nthChildValue + i})`
                  );
                  if (row) {
                    scrollRow = row;
                    break;
                  }
                }

                scrollRow.scrollIntoView({
                  // Seems to wait for XHR & render - slow initially but gets moderately faster
                  behavior: "auto",
                  block: "nearest",
                  inline: "nearest"
                });
              }
            }
          }
        }
      } else if (backButton) {
        /* Listing logic (active 'Back' button) */

        // Feature: Inject tariff info into Sell & Buy Orders rows
        const sellOrders = observerTarget.querySelector(
          ".marketplaceView-item-quickListings.sell"
        );

        const buyOrders = observerTarget.querySelector(
          ".marketplaceView-item-quickListings.buy"
        );

        if (sellOrders && buyOrders) {
          const goldValues = observerTarget.querySelectorAll(
            "td .marketplaceView-goldValue:not(.marketplaceView-suggestedPrice):not(.tsitu-link-bo-minus):not(.tsitu-link-bo-plus)"
          );

          if (goldValues.length > 0) {
            goldValues.forEach(el => {
              const rawVal = el.textContent;
              if (typeof rawVal === "string") {
                const value = parseInt(rawVal.split(",").join(""));
                const tax = Math.ceil(value / 11);
                const preTax = value - tax;
                const titleString = `${preTax.toLocaleString()} (Raw)\n${tax.toLocaleString()} (Tax)`;

                const ppEl = el.parentElement.parentElement;
                if (ppEl.nodeName === "TR") ppEl.title = titleString;
                const qtyEl = ppEl.querySelector(
                  ".marketplaceView-table-listing-quantity"
                );

                // Add a reversible onclick with same info as title
                if (qtyEl && !qtyEl.onclick) {
                  const qtyVal = qtyEl.textContent;

                  function initHandler() {
                    qtyEl.innerText = qtyVal + "\n" + titleString;
                    qtyEl.onclick = revertHandler;
                  }

                  function revertHandler() {
                    qtyEl.innerText = qtyVal;
                    qtyEl.onclick = initHandler;
                  }

                  qtyEl.onclick = initHandler;
                }
              }
            });
          }
        }

        // Feature: More Quick Links
        const orderButton = observerTarget.querySelector(
          ".mousehuntActionButton.marketplaceView-item-submitButton"
        );
        if (orderButton) {
          // Price suggestion parent divs
          const qtySuggest = observerTarget.querySelector(
            ".marketplaceView-item-input.quantity .marketplaceView-item-input-suggested"
          );

          const txType = observerTarget.querySelector(
            ".marketplaceView-item-actionType .marketplaceView-listingType"
          ).classList[1];

          // Check existence of qtySuggest b/c directly clicking 'Sell' results in separate mutations
          if (txType === "sell" && qtySuggest) {
            // Existing 'Sell All' link to clone
            const sellAll = qtySuggest.children[0];

            // Check custom class name to prevent multiple appends
            if (sellAll && !observerTarget.querySelector(".tsitu-link-sabo")) {
              const saQty = parseInt(
                sellAll.textContent.split(": ")[1].split(",").join("")
              );
              if (saQty > 1) {
                const sellAllButOne = sellAll.cloneNode();
                sellAllButOne.className = "tsitu-link-sabo";
                sellAllButOne.setAttribute(
                  "onclick",
                  `hg.views.MarketplaceView.setOrderQuantity(${
                    saQty - 1
                  }); return false;`
                );
                sellAllButOne.innerText = `[ Sell All But One: ${(
                  saQty - 1
                ).toLocaleString()} ]`;
                qtySuggest.appendChild(document.createElement("br"));
                qtySuggest.appendChild(document.createElement("br"));
                qtySuggest.appendChild(sellAllButOne);
              }
            }

            const firstRow = sellOrders.querySelector(
              "td .marketplaceView-goldValue"
            );
            if (firstRow) {
              const rawVal = firstRow.textContent;
              if (typeof rawVal === "string") {
                const value = parseInt(rawVal.split(",").join(""));
                let offerValue = Math.round(value * 0.9999);
                if (offerValue >= value) offerValue = value - 1; // Minimum increment
                if (offerValue <= 10) offerValue = undefined; // Must be at least 10 Gold

                const bestSell = observerTarget.querySelector(
                  ".sell > .marketplaceView-bestPrice"
                );
                if (bestSell.textContent.length > 6) {
                  bestSell.innerText = bestSell.textContent.replace(
                    "Best",
                    "Quick Sell"
                  );
                }

                // Generate <a> manually, not guaranteed an existing link
                if (offerValue) {
                  const boMinusLink = document.createElement("a");
                  boMinusLink.href = "#";
                  boMinusLink.setAttribute(
                    "onclick",
                    `hg.views.MarketplaceView.setOrderPrice(${offerValue}); return false;`
                  );
                  boMinusLink.className =
                    "marketplaceView-goldValue tsitu-link-bo-minus";
                  boMinusLink.innerText = `[ Undercut - 0.01%: ${offerValue.toLocaleString()} ]`;
                  if (!observerTarget.querySelector(".tsitu-link-bo-minus")) {
                    if (bestSell) {
                      bestSell.insertAdjacentElement("afterend", boMinusLink);
                    }
                  }
                }
              }
            }
          } else if (txType === "buy") {
            const firstRow = buyOrders.querySelector(
              "td .marketplaceView-goldValue"
            );
            if (firstRow) {
              const rawVal = firstRow.textContent;
              if (typeof rawVal === "string") {
                const value = parseInt(rawVal.split(",").join(""));
                let offerValue = Math.round(value * 1.0001);
                if (offerValue <= value) offerValue = value + 1; // Minimum increment
                if (offerValue >= 4294967293) offerValue = undefined; // Maximum tx amount

                const bestBuy = observerTarget.querySelector(
                  ".buy > .marketplaceView-bestPrice"
                );
                if (bestBuy.textContent.length > 6) {
                  bestBuy.innerText = bestBuy.textContent.replace(
                    "Best",
                    "Quick Buy"
                  );
                }

                // Generate <a> manually, not guaranteed an existing link
                if (offerValue) {
                  const boPlusLink = document.createElement("a");
                  boPlusLink.href = "#";
                  boPlusLink.setAttribute(
                    "onclick",
                    `hg.views.MarketplaceView.setOrderPrice(${offerValue}); return false;`
                  );
                  boPlusLink.className =
                    "marketplaceView-goldValue tsitu-link-bo-plus";
                  boPlusLink.innerText = `[ Overbid + 0.01%: ${offerValue.toLocaleString()} ]`;
                  if (!observerTarget.querySelector(".tsitu-link-bo-plus")) {
                    if (bestBuy) {
                      bestBuy.insertAdjacentElement("afterend", boPlusLink);
                    }
                  }
                }
              }
            }
          }
        }

        // 'Back' scrolls too far but clicking 'Browse' tab works fine
        // Additional scrolling logic in showItemInBrowser() is to blame
        // Solution: Override 'Back' button behavior only on the 'Browse' tab
        if (browseTab) {
          backButton.setAttribute(
            "onclick",
            "hg.views.MarketplaceView.showBrowser(); return false;"
          );
        }
      }

      // Re-observe after mutation-inducing logic
      observer.observe(observerTarget, {
        childList: true,
        subtree: true
      });
    }
  });

  // Initial observe
  observer.observe(observerTarget, {
    childList: true,
    subtree: true
  });

  /**
   * 2020.09.28
   * loadMoreMyHistory and loadMoreMyHistoryByItem need a "self." in front of their showMyHistory calls
   * Simplest remedy for now seems to be force adding to global scope :/
   */
  const loadHistory = hg.views.MarketplaceView.loadMoreMyHistory.toString();
  if (loadHistory && loadHistory.indexOf("{showMyHistory") >= 0) {
    unsafeWindow.showMyHistory = function () {
      hg.views.MarketplaceView.showMyHistory();
    };
  }

  const loadItemHistory = hg.views.MarketplaceView.loadMoreMyHistoryByItem.toString();
  if (loadItemHistory && loadItemHistory.indexOf("{showMyHistoryByItem") >= 0) {
    unsafeWindow.showMyHistoryByItem = function (itemId) {
      hg.views.MarketplaceView.showMyHistoryByItem(itemId);
    };
  }
})();