// ==UserScript==
// @name Fishtank.live | Unclaimed Item Highlighter + Profile Item Search
// @namespace http://tampermonkey.net/
// @version 0.1.0
// @description Items not claimed by your profile will be highlighted in inventory, marketplace, and chat-linked items (when hovering them). Search functionality in Profile Items section. Privacy friendly! None of your data is logged.
// @author @c
// @match https://fishtank.live/*
// @match https://www.fishtank.live/*
// @connect api.fishtank.live
// @grant GM.xmlHttpRequest
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-start
// @license MIT
// ==/UserScript==
(function() {
'use strict'; // Helps catch common coding errors.
// --- Configuration Constants ---
// These are fixed values the script uses.
const ALL_ITEMS_API_URL = 'https://api.fishtank.live/v1/items/';
const CONSUMED_ITEMS_API_URL_BASE = 'https://api.fishtank.live/v1/items/used/';
const HIGHLIGHT_CLASS = 'unconsumed-highlight-userscript'; // CSS class for the gold border highlight.
// IDs created by this script for its UI elements (these are inherently stable)
const PROFILE_ITEM_SEARCH_WRAPPER_ID = 'highlighter-profile-search-wrapper';
const PROFILE_ITEM_SEARCH_ID = 'highlighter-profile-item-search';
// --- STABLE SELECTORS for finding elements on the Fishtank.live page ---
// These use partial class matching ([class*="..."]) for resilience against hash changes.
// USER PROFILE PAGE: Selectors for the "Items" tab in a user's profile
const PROFILE_ITEMS_CONTAINER_SELECTOR = 'div[class*="user-profile-items_user-profile-items"]'; // Main container for all items listed in a profile.
const PROFILE_ITEMS_GRID_SELECTOR = 'div[class*="user-profile-items_items"]'; // The grid element that holds individual item elements.
const PROFILE_ITEM_SELECTOR = 'div[class*="user-profile-items_item"]'; // An individual item element within the profile grid.
const PROFILE_ITEM_ICON_SELECTOR_IN_GRID = 'img[class*="user-profile-items_icon"]'; // The <img> tag for an item's icon within the profile grid.
// CHAT ITEM POPUP: Selectors for item cards that appear when hovering linked items in chat
const CHAT_ITEM_POPUP_SELECTOR = '[class*="item-card_item-card"]'; // The main container of the item card popup.
const CHAT_ITEM_POPUP_ICON_DIV_SELECTOR = 'div[class*="item-card_icon"]'; // The div directly containing the item's <img> tag in the popup. This element gets the HIGHLIGHT_CLASS.
const CHAT_ITEM_POPUP_GRID_SELECTOR = '[class*="item-card_grid"]'; // The visual grid/area within the icon div that receives the border styling.
// INVENTORY: Selectors for the user's personal inventory panel
const INVENTORY_SLOTS_CONTAINER_SELECTOR = 'div[class*="inventory_slots"]'; // The container holding all inventory item slots.
const INVENTORY_ITEM_SELECTOR = 'button[class*="inventory-item_inventory-item"]'; // An individual inventory item slot (usually a button).
const INVENTORY_ITEM_ICON_CONTAINER_SELECTOR = 'div[class*="inventory-item_icon"]'; // The div containing an item's <img> tag within an inventory slot.
// MARKETPLACE MODAL: Selectors for the item marketplace popup
const MARKETPLACE_MODAL_SELECTOR = 'div[class*="item-market-modal_item-market-modal"]'; // The main wrapper/container for the entire marketplace modal.
const MARKETPLACE_ITEMS_LIST_CONTAINER_SELECTOR = 'div[class*="item-market-modal_items"]'; // The scrollable list container holding all market items.
const MARKETPLACE_LIST_ITEM_SELECTOR = 'div[class*="item-market-modal_market-list-item"]'; // An individual item listing in the marketplace.
const MARKETPLACE_ITEM_ICON_CONTAINER_SELECTOR = 'div[class*="item-market-modal_icon"]'; // The div containing an item's <img> tag within a marketplace listing.
// TOP BAR USER INFO: Selector for finding the user's ID from the top bar
const USER_INFO_TOP_BAR_SELECTOR = '[class*="top-bar-user_"][data-user-id]'; // Element in top bar with user info, must have 'data-user-id'.
// Settings for caching item data to reduce API calls
const CACHE_KEY_ALL_ITEMS = 'fishtank_allItemsData_v1.6.0'; // Versioned key for all items cache.
const CACHE_KEY_ALL_ITEMS_TIMESTAMP = 'fishtank_allItemsTimestamp_v1.6.0'; // Timestamp for all items cache.
const CACHE_DURATION_ALL_ITEMS = 6 * 60 * 60 * 1000; // Cache all items data for 6 hours.
// --- Script's Internal State ---
// These variables store data while the script is running.
let SCRIPT_STATE = {
profileId: null, // Current user's ID for fetching their consumed items.
allItemsMapByIcon: null, // Stores all item details, mapped by their icon image filename.
allItemsMapById: null, // Stores all item details, mapped by their unique ID.
consumedItemIds: null, // A list (Set) of item IDs that the current user has used.
isCoreDataLoading: false, // True if essential item data is currently being fetched.
isCoreDataLoaded: false, // True once essential item data has been fetched.
lastConsumedFetchTime: 0, // When consumed items were last fetched (for caching).
consumedCacheDuration: 1 * 60 * 1000, // How long to cache consumed items (1 minute).
isMarketplaceVisible: false,// True if the marketplace popup is open.
lastFetchedMarketItems: null,// The latest list of items seen in the marketplace.
};
// --- Observers & Debounced Functions ---
// Observers watch for page changes. Debounced functions prevent too many rapid calls.
let inventoryObserver, marketVisibilityObserver, marketItemsListObserver,
profileItemsTabObserver, chatItemPopupObserver, profileItemsGridObserver;
let debouncedHighlightInventory, debouncedHighlightMarketplace, debouncedFilterProfileItems;
/**
* Applies all custom CSS styles needed by the script.
* Called when the page is ready to ensure elements can be targeted.
*/
function applyStyles() {
// Ensure document.body is available or wait for DOMContentLoaded
if (!document.body && document.readyState !== 'complete' && document.readyState !== 'interactive') {
document.addEventListener('DOMContentLoaded', applyStyles);
return;
}
try {
// Styles to make space for the search bar above the profile items grid
GM_addStyle(`
${PROFILE_ITEMS_CONTAINER_SELECTOR} {
position: relative !important; /* Needed for absolute positioning of the search bar within it */
min-height: 60px; /* Ensures space for search bar even if grid is initially empty */
}
${PROFILE_ITEMS_CONTAINER_SELECTOR} > ${PROFILE_ITEMS_GRID_SELECTOR} {
padding-top: 55px !important; /* Adds space at the top of the grid for the overlaid search bar */
}
/* Styles for the search bar wrapper (handles collapse/expand and icon) */
#${PROFILE_ITEM_SEARCH_WRAPPER_ID} {
position: absolute !important; /* Overlays the items panel */
top: 10px; /* Distance from the top of the panel */
left: 50%; /* Horizontally centered */
transform: translateX(-50%); /* Precise centering */
width: 40px; /* Initial collapsed width (icon size) */
height: 40px; /* Initial collapsed height */
border-radius: 20px; /* Rounded for pill/circle shape */
background-color: rgba(40, 40, 45, 0.55); /* Semi-transparent when inactive/collapsed */
display: flex; /* For centering the icon inside */
align-items: center;
justify-content: center;
cursor: pointer; /* Indicates it's interactive */
z-index: 105; /* Ensures it's on top of other panel content */
box-shadow: 0 1px 4px rgba(0,0,0,0.15); /* Subtle shadow for depth */
opacity: 0; /* Starts hidden, fades in when items load */
pointer-events: none; /* Initially non-interactive */
/* Smooth animations for changes in size, color, etc. */
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
border-radius 0.35s cubic-bezier(0.4, 0, 0.2, 1),
background-color 0.3s ease-out,
box-shadow 0.3s ease-out,
opacity 0.3s ease-out;
}
/* The search icon (magnifying glass) displayed within the wrapper when collapsed */
#${PROFILE_ITEM_SEARCH_WRAPPER_ID}::before {
content: ''; /* Required for pseudo-elements */
display: block; /* Ensures it takes up space for layout */
width: 20px; /* Icon dimensions */
height: 20px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='rgba(220,220,220,0.85)'%3E%3Cpath d='M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center;
background-size: contain; /* Scales icon to fit within dimensions */
opacity: 1; /* Icon visible by default (wrapper controls overall visibility) */
transition: opacity 0.2s 0.05s ease-out; /* Smooth fade for icon */
}
/* The actual HTML <input> field for search */
#${PROFILE_ITEM_SEARCH_ID} {
position: absolute; /* Taken out of normal flow when collapsed to allow icon centering */
opacity: 0; /* Hidden when wrapper is collapsed */
pointer-events: none; /* Non-interactive when wrapper is collapsed */
width: 100%; /* Will fill the wrapper when it expands */
height: 100%;
padding: 0 15px; /* Padding for text when expanded */
box-sizing: border-box; /* Border and padding included in width/height */
border: none; /* Visual border handled by wrapper */
border-radius: inherit; /* Takes rounded corners from wrapper */
background-color: transparent; /* Wrapper provides background */
color: #e0e0e0; /* Text color */
font-size: 14px;
text-align: left; /* Standard text alignment */
outline: none; /* Removes default browser focus outline */
/* Transition for opacity and for position (delayed to avoid glitches) */
transition: opacity 0.2s 0.1s ease-out, position 0s 0.2s;
}
/* Styles for the search wrapper when it's hovered or its input is focused */
#${PROFILE_ITEM_SEARCH_WRAPPER_ID}:hover,
#${PROFILE_ITEM_SEARCH_WRAPPER_ID}:focus-within { /* :focus-within applies if input inside has focus */
width: clamp(240px, 70%, 380px); /* Expanded width: min, preferred, max */
background-color: rgba(55, 55, 60, 0.92); /* Becomes more opaque */
box-shadow: 0 3px 8px rgba(0,0,0,0.2); /* Enhanced shadow for active state */
cursor: default; /* Default cursor over expanded area (input gets text cursor) */
}
/* Hide the search icon when the wrapper is expanded */
#${PROFILE_ITEM_SEARCH_WRAPPER_ID}:hover::before,
#${PROFILE_ITEM_SEARCH_WRAPPER_ID}:focus-within::before {
opacity: 0;
transition-delay: 0s; /* Icon disappears quickly */
}
/* Reveal the actual input field when the wrapper is expanded */
#${PROFILE_ITEM_SEARCH_WRAPPER_ID}:hover #${PROFILE_ITEM_SEARCH_ID},
#${PROFILE_ITEM_SEARCH_WRAPPER_ID}:focus-within #${PROFILE_ITEM_SEARCH_ID} {
position: static; /* Input re-enters normal flow within the wrapper */
opacity: 1; /* Becomes visible */
pointer-events: auto; /* Becomes interactive */
cursor: text; /* Text input cursor */
/* Transition for opacity and position (delayed to match fade-in) */
transition: opacity 0.2s 0.1s ease-out, position 0s 0.1s;
}
/* Placeholder text style for the search input */
#${PROFILE_ITEM_SEARCH_ID}::placeholder {
color: rgba(180, 180, 180, 0.7);
}
/* Styling for the native "clear search" (x) button in WebKit browsers (Chrome, Safari, Edge) */
#${PROFILE_ITEM_SEARCH_ID}::-webkit-search-cancel-button {
-webkit-appearance: none; /* Remove default browser styling */
position: absolute; /* Position it within the input field */
right: 12px; /* Distance from the right edge of the input */
top: 50%; /* Vertically center */
transform: translateY(-50%);
height: 16px;
width: 16px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' fill='rgba(200, 200, 200, 0.7)'%3E%3Cpath d='M1 1 L9 9 M9 1 L1 9' stroke='currentColor' stroke-width='2'/%3E%3C/svg%3E");
background-size: 0.7em 0.7em;
background-repeat: no-repeat;
background-position: center;
cursor: pointer;
opacity: 0; /* Initially hidden */
}
/* Show clear button when search bar is active and has text */
#${PROFILE_ITEM_SEARCH_WRAPPER_ID}:hover #${PROFILE_ITEM_SEARCH_ID}:not(:placeholder-shown)::-webkit-search-cancel-button,
#${PROFILE_ITEM_SEARCH_WRAPPER_ID}:focus-within #${PROFILE_ITEM_SEARCH_ID}:not(:placeholder-shown)::-webkit-search-cancel-button {
opacity: 0.7;
transition: opacity 0.2s 0.15s ease-out; /* Smooth fade-in */
}
#${PROFILE_ITEM_SEARCH_ID}::-webkit-search-cancel-button:hover {
opacity: 1; /* More opaque on hover over 'x' button */
}
`);
// General styles for highlighting unconsumed items
GM_addStyle(`
/* Highlight style for item images in Inventory and Marketplace */
${INVENTORY_SLOTS_CONTAINER_SELECTOR} ${INVENTORY_ITEM_SELECTOR}.${HIGHLIGHT_CLASS} ${INVENTORY_ITEM_ICON_CONTAINER_SELECTOR} img,
${MARKETPLACE_LIST_ITEM_SELECTOR}.${HIGHLIGHT_CLASS} ${MARKETPLACE_ITEM_ICON_CONTAINER_SELECTOR} img {
border: 3px solid gold !important;
box-shadow: 0 0 8px 3px gold, inset 0 0 5px 1px rgba(0,0,0,0.4), inset 0 0 10px gold !important;
border-radius: 8px !important;
}
/* Highlight style for the grid area within chat item popups */
/* CHAT_ITEM_POPUP_ICON_DIV_SELECTOR gets the HIGHLIGHT_CLASS, its child CHAT_ITEM_POPUP_GRID_SELECTOR gets styled. */
${CHAT_ITEM_POPUP_ICON_DIV_SELECTOR}.${HIGHLIGHT_CLASS} ${CHAT_ITEM_POPUP_GRID_SELECTOR} {
border: 3px solid gold !important;
box-shadow: 0 0 8px 3px gold !important;
border-radius: 6px !important;
position: relative; z-index: 1; box-sizing: border-box !important;
}
/* Ensure highlight effect isn't clipped by parent elements due to overflow:hidden */
${INVENTORY_ITEM_ICON_CONTAINER_SELECTOR},
${MARKETPLACE_ITEM_ICON_CONTAINER_SELECTOR},
${CHAT_ITEM_POPUP_ICON_DIV_SELECTOR},
${CHAT_ITEM_POPUP_GRID_SELECTOR} { /* This is the element that actually gets the border/shadow */
overflow: visible !important;
}
`);
} catch (e) {
console.error('[HIGHLIGHTER] Error applying styles:', e);
}
}
applyStyles(); // Call early as some styles might affect layout before DOM is fully loaded
/**
* Gets cached data if available and not expired.
* @param {string} key - The main key for the cached data.
* @param {string} timestampKey - The key for the data's timestamp.
* @param {number} duration - The maximum age of the cache in milliseconds.
* @returns {Promise<object|null>} The parsed cached data, or null if not found or expired.
*/
async function getCachedData(key, timestampKey, duration) {
try {
const timestamp = await GM.getValue(timestampKey);
if (timestamp && (Date.now() - timestamp < duration)) {
const dataString = await GM.getValue(key);
if (dataString) { return JSON.parse(dataString); }
}
} catch (error) {
// Errors during cache read are logged by GM automatically if severe, otherwise non-critical for script function.
// console.warn('[HIGHLIGHTER] Error reading from cache:', key, error);
}
return null;
}
/**
* Saves data to cache with a timestamp.
* @param {string} key - The main key for the data to cache.
* @param {string} timestampKey - The key to store the data's timestamp.
* @param {object} data - The data object to be stringified and cached.
* @returns {Promise<void>}
*/
async function setCachedData(key, timestampKey, data) {
try {
await GM.setValue(key, JSON.stringify(data));
await GM.setValue(timestampKey, Date.now());
} catch (error) {
// Errors during cache write are logged by GM automatically if severe.
// console.warn('[HIGHLIGHTER] Error writing to cache:', key, error);
}
}
/**
* Tries to find the current user's profile ID from the page's DOM or cookies.
* Useful for fetching user-specific data like consumed items.
* @returns {string|null} The user's profile ID, or null if not found.
*/
function getProfileIdFromDOM() {
// First, try to get it from a data-user-id attribute on a top-bar user element
const userElement = document.querySelector(USER_INFO_TOP_BAR_SELECTOR);
if (userElement) {
const id = userElement.getAttribute('data-user-id');
if (id) return id;
}
// Fallback: Try to extract from PostHog cookies (common on modern sites for analytics)
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const parts = cookie.split('=');
const name = parts.shift().trim();
// PostHog often stores user ID in cookies like 'ph_phc_PROJECTID_posthog'
if (name.startsWith('ph_phc_') && name.endsWith('_posthog')) {
try {
const value = parts.join('=');
const decodedValue = decodeURIComponent(value);
const parsedData = JSON.parse(decodedValue);
if (parsedData && parsedData.distinct_id) {
return parsedData.distinct_id;
}
} catch (e) {
// Ignore cookie parsing errors, try next cookie
}
}
}
return null; // Return null if ID couldn't be found
}
/**
* Fetches data from a URL using GM.xmlHttpRequest, with support for caching.
* @param {string} url - The URL to fetch data from.
* @param {object} [options={}] - Optional settings.
* @param {boolean} [options.useCache=false] - Whether to try loading from cache first.
* @param {object} [options.cacheKeys={}] - Keys for caching (data, timestamp, duration).
* @param {object} [options.headers={}] - Custom headers for the request.
* @returns {Promise<object>} The JSON response from the API.
* @throws {Error} If the API request fails or JSON parsing fails.
*/
async function makeApiRequest(url, options = {}) {
const { useCache = false, cacheKeys = {}, headers = {} } = options;
if (useCache && cacheKeys.data && cacheKeys.timestamp && cacheKeys.duration) {
const cached = await getCachedData(cacheKeys.data, cacheKeys.timestamp, cacheKeys.duration);
if (cached) return cached;
}
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: 'GET',
url: url,
headers: headers,
onload: async function(response) {
if (response.status >= 200 && response.status < 300) {
try {
const jsonData = JSON.parse(response.responseText);
if (useCache && cacheKeys.data && cacheKeys.timestamp) {
await setCachedData(cacheKeys.data, cacheKeys.timestamp, jsonData);
}
resolve(jsonData);
} catch (e) {
reject(new Error(`Error parsing JSON from ${url}: ${e.message}`));
}
} else {
reject(new Error(`API request to ${url} failed: ${response.status} ${response.statusText}`));
}
},
onerror: function(error) {
reject(new Error(`API network error for ${url}: ${error.error || 'Unknown error'}`));
}
});
});
}
/**
* Extracts just the filename (e.g., "item.png") from a full image URL.
* This is used to map item data to DOM elements via their icon images.
* @param {string} fullUrl - The complete URL of the image.
* @returns {string|null} The extracted filename, or null if an error occurs or URL is invalid.
*/
function extractIconFilename(fullUrl) {
if (!fullUrl || typeof fullUrl !== 'string') return null;
try {
// Get text after the last '/'
let filename = fullUrl.substring(fullUrl.lastIndexOf('/') + 1);
// Remove any URL parameters (e.g., ?v=123)
const queryIndex = filename.indexOf('?');
if (queryIndex !== -1) {
filename = filename.substring(0, queryIndex);
}
return filename;
} catch (e) {
// console.warn('[HIGHLIGHTER] Error extracting icon filename:', fullUrl, e);
return null;
}
}
/**
* Loads essential data for the script: all item definitions and the user's consumed items.
* Manages loading state and caching to avoid redundant API calls.
* @param {boolean} [forceConsumedRefresh=false] - If true, bypasses cache for consumed items.
* @returns {Promise<boolean>} True if core data is successfully loaded (or already loaded and fresh), false otherwise.
*/
async function loadCoreData(forceConsumedRefresh = false) {
// If data is currently being loaded by another call, wait and retry.
if (SCRIPT_STATE.isCoreDataLoading && !forceConsumedRefresh) {
return new Promise(resolve => setTimeout(() => resolve(loadCoreData(forceConsumedRefresh)), 200));
}
const now = Date.now();
// Check if data is already loaded and still fresh
if (SCRIPT_STATE.isCoreDataLoaded && SCRIPT_STATE.allItemsMapByIcon &&
(!SCRIPT_STATE.profileId || SCRIPT_STATE.consumedItemIds) && // Consumed items only needed if profileId is known
!forceConsumedRefresh &&
(!SCRIPT_STATE.profileId || (now - SCRIPT_STATE.lastConsumedFetchTime <= SCRIPT_STATE.consumedCacheDuration))) {
return true; // Data is loaded and fresh
}
SCRIPT_STATE.isCoreDataLoading = true;
// Ensure profile ID is available if not already fetched
if (!SCRIPT_STATE.profileId) {
SCRIPT_STATE.profileId = getProfileIdFromDOM();
}
try {
// Load all item definitions if not already loaded or if cache is stale
if (!SCRIPT_STATE.allItemsMapByIcon) {
const allItemsApiResponse = await makeApiRequest(ALL_ITEMS_API_URL, {
useCache: true,
cacheKeys: {
data: CACHE_KEY_ALL_ITEMS,
timestamp: CACHE_KEY_ALL_ITEMS_TIMESTAMP,
duration: CACHE_DURATION_ALL_ITEMS
}
});
if (!allItemsApiResponse || typeof allItemsApiResponse !== 'object') {
throw new Error("Invalid or empty response from allItems API.");
}
SCRIPT_STATE.allItemsMapByIcon = {};
SCRIPT_STATE.allItemsMapById = {};
Object.values(allItemsApiResponse).forEach(item => {
if (item && item.icon) SCRIPT_STATE.allItemsMapByIcon[item.icon] = item;
if (item && typeof item.id !== 'undefined') SCRIPT_STATE.allItemsMapById[item.id.toString()] = item;
});
}
// Load consumed items if profile ID is known and data is stale or forced refresh
if (SCRIPT_STATE.profileId &&
(forceConsumedRefresh || !SCRIPT_STATE.consumedItemIds || (now - SCRIPT_STATE.lastConsumedFetchTime > SCRIPT_STATE.consumedCacheDuration))) {
const consumedItemsUrl = `${CONSUMED_ITEMS_API_URL_BASE}${SCRIPT_STATE.profileId}`;
const consumedResp = await makeApiRequest(consumedItemsUrl); // Consumed items change often, so less aggressive caching
if (!consumedResp || !consumedResp.usedItems || typeof consumedResp.usedItems !== 'object') {
throw new Error("Invalid or empty response from consumedItems API.");
}
SCRIPT_STATE.consumedItemIds = new Set(Object.keys(consumedResp.usedItems).map(id => parseInt(id, 10)));
SCRIPT_STATE.lastConsumedFetchTime = now;
}
SCRIPT_STATE.isCoreDataLoaded = true;
} catch (error) {
console.error('[HIGHLIGHTER] Core data load failed:', error.message || error);
SCRIPT_STATE.isCoreDataLoaded = false; // Ensure flag is reset on failure
} finally {
SCRIPT_STATE.isCoreDataLoading = false;
}
// Return true only if all necessary data (especially allItemsMap) is loaded
return SCRIPT_STATE.isCoreDataLoaded && SCRIPT_STATE.allItemsMapByIcon !== null;
}
/**
* Applies or removes the highlight class from items in the user's inventory
* based on whether they have been consumed.
* @returns {Promise<void>}
*/
async function highlightInventory() {
if (!SCRIPT_STATE.isCoreDataLoaded && !(await loadCoreData())) return; // Ensure data is loaded
// Consumed items and profile ID are essential for this function
if (!SCRIPT_STATE.consumedItemIds || !SCRIPT_STATE.profileId) return;
const inventorySlotsContainer = document.querySelector(INVENTORY_SLOTS_CONTAINER_SELECTOR);
if (!inventorySlotsContainer) {
// console.warn('[HIGHLIGHTER] Inventory slots container not found.');
return;
}
const inventorySlots = inventorySlotsContainer.querySelectorAll(INVENTORY_ITEM_SELECTOR);
inventorySlots.forEach(slot => {
const imgContainer = slot.querySelector(INVENTORY_ITEM_ICON_CONTAINER_SELECTOR);
let shouldHighlight = false;
// An item slot is processed if it's not disabled and contains an icon image
if (!slot.disabled && imgContainer) {
const img = imgContainer.querySelector('img');
if (img && img.src) { // Check if the image and its src exist
const iconFile = extractIconFilename(img.src);
if (iconFile && SCRIPT_STATE.allItemsMapByIcon && SCRIPT_STATE.allItemsMapByIcon[iconFile]) {
const item = SCRIPT_STATE.allItemsMapByIcon[iconFile];
// Highlight if the item ID is found and not in the consumed list
if (typeof item.id !== 'undefined' && !SCRIPT_STATE.consumedItemIds.has(item.id)) {
shouldHighlight = true;
}
}
}
}
slot.classList.toggle(HIGHLIGHT_CLASS, shouldHighlight);
});
}
/**
* Applies or removes the highlight class from items listed in the marketplace.
* This function relies on `SCRIPT_STATE.lastFetchedMarketItems` being populated by network interception.
* @returns {Promise<void>}
*/
async function highlightMarketplace() {
if (!SCRIPT_STATE.lastFetchedMarketItems || SCRIPT_STATE.lastFetchedMarketItems.length === 0) return;
// Force refresh consumed items when highlighting marketplace, as status might change frequently.
if (!SCRIPT_STATE.isCoreDataLoaded && !(await loadCoreData(true))) return;
if (!SCRIPT_STATE.allItemsMapById || !SCRIPT_STATE.consumedItemIds || !SCRIPT_STATE.profileId) return;
const marketItemsListContainer = document.querySelector(MARKETPLACE_ITEMS_LIST_CONTAINER_SELECTOR);
if (!marketItemsListContainer) {
// console.warn('[HIGHLIGHTER] Marketplace items list container not found.');
return;
}
const marketItemDOMElements = marketItemsListContainer.querySelectorAll(MARKETPLACE_LIST_ITEM_SELECTOR);
// Create a map of DOM elements keyed by their icon filename for efficient lookup
const domItemsByIcon = new Map();
marketItemDOMElements.forEach(domEl => {
const imgContainer = domEl.querySelector(MARKETPLACE_ITEM_ICON_CONTAINER_SELECTOR);
if (imgContainer) {
const img = imgContainer.querySelector('img');
if (img && img.src) {
const iconFile = extractIconFilename(img.src);
if (iconFile) {
if (!domItemsByIcon.has(iconFile)) domItemsByIcon.set(iconFile, []);
domItemsByIcon.get(iconFile).push(domEl); // Store all DOM elements that share this icon
}
}
}
});
// Iterate through the API-fetched market items
SCRIPT_STATE.lastFetchedMarketItems.forEach((apiMarketItem) => {
const itemIdStr = apiMarketItem.itemId.toString();
const itemDetailsFromAllItems = SCRIPT_STATE.allItemsMapById[itemIdStr];
if (!itemDetailsFromAllItems || !itemDetailsFromAllItems.icon) return; // Skip if no details or icon
const itemIconFilename = itemDetailsFromAllItems.icon;
const matchingDomItems = domItemsByIcon.get(itemIconFilename);
if (matchingDomItems) {
// Apply highlight to all DOM elements that match this item's icon
matchingDomItems.forEach(domMarketListItem => {
const numericItemId = parseInt(itemIdStr, 10);
let shouldHighlight = !SCRIPT_STATE.consumedItemIds.has(numericItemId);
domMarketListItem.classList.toggle(HIGHLIGHT_CLASS, shouldHighlight);
});
}
});
}
/**
* Shows or hides items in the profile items grid based on the search term.
* @param {string} searchTerm - The text to filter item names by.
* @param {HTMLElement} itemsGridElement - The DOM element containing the grid of profile items.
*/
function filterProfileItems(searchTerm, itemsGridElement) {
if (!SCRIPT_STATE.allItemsMapByIcon || !itemsGridElement) return; // Essential data/elements missing
const lowerSearchTerm = searchTerm.toLowerCase().trim();
const items = itemsGridElement.querySelectorAll(PROFILE_ITEM_SELECTOR);
items.forEach(itemEl => {
const img = itemEl.querySelector(PROFILE_ITEM_ICON_SELECTOR_IN_GRID);
let isMatch = false;
if (img && img.src) {
const iconFile = extractIconFilename(img.src);
const itemData = SCRIPT_STATE.allItemsMapByIcon[iconFile];
// Match if item name (from API data) includes the search term
if (itemData && itemData.name && itemData.name.toLowerCase().includes(lowerSearchTerm)) {
isMatch = true;
}
}
// Show item if search term is empty or if it's a match
itemEl.style.display = (lowerSearchTerm === '' || isMatch) ? '' : 'none';
});
}
/**
* Creates and adds the collapsible search bar to a user's profile item page.
* It then sets up an observer to fade in the search bar once item elements are loaded into the grid.
* @param {HTMLElement} profileItemsContainer - The main container for the profile items section.
* @param {HTMLElement} itemsGrid - The grid element within the container that holds the items.
*/
function addProfileItemSearch(profileItemsContainer, itemsGrid) {
let searchWrapper = profileItemsContainer.querySelector(`#${PROFILE_ITEM_SEARCH_WRAPPER_ID}`);
// If search bar doesn't exist, create and inject it
if (!searchWrapper) {
searchWrapper = document.createElement('div');
searchWrapper.id = PROFILE_ITEM_SEARCH_WRAPPER_ID;
const searchInput = document.createElement('input');
searchInput.type = 'search'; // Allows native clear button in some browsers
searchInput.id = PROFILE_ITEM_SEARCH_ID;
searchInput.placeholder = 'Search items...';
searchInput.setAttribute('aria-label', 'Search profile items');
// Debounced filtering on input
searchInput.addEventListener('input', (event) => debouncedFilterProfileItems(event.target.value, itemsGrid));
// Handle native "search" event (e.g., when 'x' is clicked or Enter pressed)
searchInput.addEventListener('search', (event) => {
if (!event.target.value) debouncedFilterProfileItems('', itemsGrid); // Clear filter if input is empty
});
searchWrapper.appendChild(searchInput);
// Insert search bar before the first child of the container (usually the grid or a header)
profileItemsContainer.insertBefore(searchWrapper, profileItemsContainer.firstChild);
}
// Observer to fade in search bar once items are loaded into the grid
if (profileItemsGridObserver) profileItemsGridObserver.disconnect(); // Disconnect previous observer if any
profileItemsGridObserver = new MutationObserver((mutations, observer) => {
// Check if any item element is now present in the grid
if (itemsGrid.querySelector(PROFILE_ITEM_SELECTOR)) {
// Animate fade-in
requestAnimationFrame(() => {
searchWrapper.style.opacity = '1';
searchWrapper.style.pointerEvents = 'auto';
});
observer.disconnect(); // Stop observing once items are loaded
profileItemsGridObserver = null;
}
});
// Observe the itemsGrid for childList changes (items being added)
profileItemsGridObserver.observe(itemsGrid, { childList: true });
// Immediate check: if items are already present, fade in search bar right away
if (itemsGrid.querySelector(PROFILE_ITEM_SELECTOR)) {
requestAnimationFrame(() => {
searchWrapper.style.opacity = '1';
searchWrapper.style.pointerEvents = 'auto';
});
if (profileItemsGridObserver) { // If observer was set up, disconnect it
profileItemsGridObserver.disconnect();
profileItemsGridObserver = null;
}
}
}
// --- Network Interception for Marketplace Data ---
// Intercept fetch and XHR to capture marketplace item lists for highlighting.
const originalFetch = window.fetch;
window.fetch = async function(resource, init) {
const url = typeof resource === 'string' ? resource : (resource && resource.url);
let response;
try {
response = await originalFetch.apply(this, arguments); // Call original fetch
const marketListRegex = /api\.fishtank\.live\/v1\/market(\?[\w=&-]+)?$/; // Regex for market list API
const anyMarketActionRegex = /api\.fishtank\.live\/v1\/market\/[\w-]+\/(bid|buyout|cancel)/i; // Regex for market actions
if (url && (marketListRegex.test(url) || anyMarketActionRegex.test(url))) {
if (marketListRegex.test(url) && response.ok) {
// If it's a successful market list fetch, clone response to read JSON
const clonedResponse = response.clone();
clonedResponse.json().then(data => {
SCRIPT_STATE.lastFetchedMarketItems = (data && data.marketItems) ? data.marketItems : [];
// If marketplace is visible, ensure observer is active and re-highlight
if (SCRIPT_STATE.isMarketplaceVisible) {
ensureMarketItemsObserverIsActive();
debouncedHighlightMarketplace();
}
}).catch(err => console.error('[HIGHLIGHTER] Error processing fetch market list JSON:', err, 'URL:', url));
} else if (anyMarketActionRegex.test(url) && SCRIPT_STATE.isMarketplaceVisible) {
// After any market action, the list might change. Re-check highlights.
// This relies on a subsequent market list fetch or the existing SCRIPT_STATE.lastFetchedMarketItems.
ensureMarketItemsObserverIsActive();
debouncedHighlightMarketplace();
}
}
} catch (error) {
console.error('[HIGHLIGHTER] Error in fetch intercept:', error);
if (response) return response; // If response was received before error, return it
throw error; // Re-throw to maintain original fetch behavior on error
}
return response;
};
const XHR = XMLHttpRequest.prototype;
const originalXHROpen = XHR.open;
const originalXHRSend = XHR.send;
// Store URL on XHR open to access it in send/load
XHR.open = function(method, url) {
this._highlighter_method = method;
this._highlighter_url = url;
return originalXHROpen.apply(this, arguments);
};
XHR.send = function(postData) {
this.addEventListener('load', function() {
const url = this._highlighter_url;
const marketListRegex = /api\.fishtank\.live\/v1\/market(\?[\w=&-]+)?$/;
const anyMarketActionRegex = /api\.fishtank\.live\/v1\/market\/[\w-]+\/(bid|buyout|cancel)/i;
if (url && (marketListRegex.test(url) || anyMarketActionRegex.test(url))) {
if (marketListRegex.test(url) && this.status >= 200 && this.status < 300 && this.responseText) {
try {
const data = JSON.parse(this.responseText);
SCRIPT_STATE.lastFetchedMarketItems = (data && data.marketItems) ? data.marketItems : [];
if (SCRIPT_STATE.isMarketplaceVisible) {
ensureMarketItemsObserverIsActive();
debouncedHighlightMarketplace();
}
} catch (e) { console.error('[HIGHLIGHTER] Error processing XHR market list JSON:', e); }
} else if (anyMarketActionRegex.test(url) && SCRIPT_STATE.isMarketplaceVisible) {
ensureMarketItemsObserverIsActive();
debouncedHighlightMarketplace();
}
}
});
return originalXHRSend.apply(this, arguments);
};
/**
* A utility to delay function execution until a certain time has passed
* without the function being called again. Useful for rate-limiting.
* @param {Function} func - The function to debounce.
* @param {number} delay - The delay in milliseconds.
* @returns {Function} The debounced function.
*/
function debounce(func, delay) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), delay);
};
}
/**
* Manages the MutationObserver that watches for changes in the marketplace item list.
* Ensures the observer is active only when the marketplace is visible and targets the correct element.
*/
function ensureMarketItemsObserverIsActive() {
if (!SCRIPT_STATE.isMarketplaceVisible) {
// If marketplace is not visible, disconnect observer if it exists
if (marketItemsListObserver) { marketItemsListObserver.disconnect(); marketItemsListObserver = null; }
return;
}
const el = document.querySelector(MARKETPLACE_ITEMS_LIST_CONTAINER_SELECTOR);
if (el) {
// If observer exists and already targets this element, do nothing
if (marketItemsListObserver && marketItemsListObserver.target === el) return;
// If observer exists but targets a different element (or no element), disconnect it
if (marketItemsListObserver) marketItemsListObserver.disconnect();
marketItemsListObserver = new MutationObserver(() => debouncedHighlightMarketplace());
// Observe for items being added/removed (childList) or attributes changing within items (subtree)
marketItemsListObserver.observe(el, { childList: true, subtree: true }); // subtree might be important if item structure is complex
marketItemsListObserver.target = el; // Store target to avoid re-creating observer unnecessarily
} else {
// If target element not found, ensure observer is disconnected
if (marketItemsListObserver) { marketItemsListObserver.disconnect(); marketItemsListObserver = null; }
}
}
/**
* Initializes all MutationObservers that monitor the page for relevant changes.
* This is where event-driven updates to highlighting and UI are set up.
*/
function setupObservers() {
// Create debounced versions of highlight/filter functions to avoid excessive calls
debouncedHighlightInventory = debounce(highlightInventory, 300);
debouncedHighlightMarketplace = debounce(highlightMarketplace, 500); // Slightly longer for market due to API + DOM changes
debouncedFilterProfileItems = debounce(filterProfileItems, 300);
// Observer for user's inventory changes
const inventoryContainer = document.querySelector(INVENTORY_SLOTS_CONTAINER_SELECTOR);
if (inventoryContainer) {
if (inventoryObserver) inventoryObserver.disconnect();
inventoryObserver = new MutationObserver((mutations) => {
// If items are added/removed or their attributes change (e.g. src, class, disabled)
if (mutations.some(m => m.type === 'childList' || (m.type === 'attributes'))) {
debouncedHighlightInventory();
}
});
inventoryObserver.observe(inventoryContainer, {
childList: true, // Watch for items being added/removed directly in container
subtree: true, // Watch for changes within item elements (e.g., img src)
attributes: true, // Watch for attribute changes
attributeFilter: ['class', 'disabled', 'src'] // Specific attributes to watch
});
if(SCRIPT_STATE.profileId) highlightInventory(); // Initial highlight if profile ID is already known
}
// Observer for marketplace modal visibility
if (marketVisibilityObserver) marketVisibilityObserver.disconnect();
marketVisibilityObserver = new MutationObserver(async (mutations) => {
const marketContainer = document.querySelector(MARKETPLACE_MODAL_SELECTOR);
// Check if modal is actually visible (offsetParent or display style)
const isNowVisible = marketContainer && (marketContainer.offsetParent !== null || window.getComputedStyle(marketContainer).display !== 'none');
if (isNowVisible && !SCRIPT_STATE.isMarketplaceVisible) { // If just became visible
SCRIPT_STATE.isMarketplaceVisible = true;
// Load/refresh core data, especially consumed items, when market opens
if (!SCRIPT_STATE.isCoreDataLoaded || (!SCRIPT_STATE.profileId || !SCRIPT_STATE.consumedItemIds)) {
await loadCoreData(true); // Force refresh of consumed items
}
ensureMarketItemsObserverIsActive(); // Start observing market item list
// Highlight based on last fetched data, or wait for interceptor if list is empty/stale
if (SCRIPT_STATE.lastFetchedMarketItems && SCRIPT_STATE.lastFetchedMarketItems.length > 0) {
debouncedHighlightMarketplace();
}
} else if (!isNowVisible && SCRIPT_STATE.isMarketplaceVisible) { // If just became hidden
SCRIPT_STATE.isMarketplaceVisible = false;
if (marketItemsListObserver) { marketItemsListObserver.disconnect(); marketItemsListObserver = null; } // Stop observing list
}
});
// Observe document.body as modals are often appended/removed from there
marketVisibilityObserver.observe(document.body, { childList: true, subtree: true });
// Observer for the profile items tab container appearing/disappearing
if (profileItemsTabObserver) profileItemsTabObserver.disconnect();
profileItemsTabObserver = new MutationObserver(async (mutations) => {
let foundContainerNode = null;
for (const mutation of mutations) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if the added node itself is the container
if (node.matches && node.matches(PROFILE_ITEMS_CONTAINER_SELECTOR)) {
foundContainerNode = node; break;
} else if (node.querySelector) { // Or if it contains the container
const potentialContainer = node.querySelector(PROFILE_ITEMS_CONTAINER_SELECTOR);
if (potentialContainer) { foundContainerNode = potentialContainer; break; }
}
}
}
if (foundContainerNode) break; // Found one, stop checking mutations
// Handle removal of the profile items container (cleanup search bar)
mutation.removedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE && node.matches && node.matches(PROFILE_ITEMS_CONTAINER_SELECTOR)) {
const searchWrapper = node.querySelector(`#${PROFILE_ITEM_SEARCH_WRAPPER_ID}`);
if (searchWrapper) searchWrapper.remove();
if (profileItemsGridObserver) { profileItemsGridObserver.disconnect(); profileItemsGridObserver = null; }
}
});
}
}
// If profile items container was added and doesn't have search bar yet
if (foundContainerNode && !foundContainerNode.querySelector(`#${PROFILE_ITEM_SEARCH_WRAPPER_ID}`)) {
if (!SCRIPT_STATE.allItemsMapByIcon) await loadCoreData(); // Ensure item data is ready
if (SCRIPT_STATE.allItemsMapByIcon) {
const itemsGrid = foundContainerNode.querySelector(PROFILE_ITEMS_GRID_SELECTOR);
if (itemsGrid) {
addProfileItemSearch(foundContainerNode, itemsGrid);
} else {
console.warn('[HIGHLIGHTER] Profile items grid not found within the container, cannot add search bar.');
}
} else { console.error('[HIGHLIGHTER] Item data unavailable, cannot add profile search.'); }
}
});
// Observe document.body for major page sections being added/removed
profileItemsTabObserver.observe(document.body, { childList: true, subtree: true });
// Observer for item popups in chat
if (chatItemPopupObserver) chatItemPopupObserver.disconnect();
chatItemPopupObserver = new MutationObserver(async (mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if added node is the popup or contains it
let cardEl = (node.matches && node.matches(CHAT_ITEM_POPUP_SELECTOR)) ? node : (node.querySelector ? node.querySelector(CHAT_ITEM_POPUP_SELECTOR) : null);
if (cardEl) {
const iconDiv = cardEl.querySelector(CHAT_ITEM_POPUP_ICON_DIV_SELECTOR);
if (iconDiv) { // Ensure the icon div is found
const imgEl = iconDiv.querySelector('img'); // Image is expected directly inside iconDiv
if (imgEl && imgEl.src) { // Ensure img and its src are valid
// Load core data if not already loaded or if crucial parts are missing
if (!SCRIPT_STATE.isCoreDataLoaded || !SCRIPT_STATE.allItemsMapByIcon || (SCRIPT_STATE.profileId && !SCRIPT_STATE.consumedItemIds) ) {
await loadCoreData();
}
if (SCRIPT_STATE.allItemsMapByIcon) {
const iconFile = extractIconFilename(imgEl.src);
const itemData = SCRIPT_STATE.allItemsMapByIcon[iconFile];
let shouldHighlight = false;
// Highlight if item is known, user is identified, and item not in consumed list
if (itemData && typeof itemData.id !== 'undefined' && SCRIPT_STATE.profileId && SCRIPT_STATE.consumedItemIds) {
if (!SCRIPT_STATE.consumedItemIds.has(itemData.id)) {
shouldHighlight = true;
}
}
// Apply/remove highlight class to the iconDiv (CSS will style the grid child)
iconDiv.classList.toggle(HIGHLIGHT_CLASS, shouldHighlight);
}
}
}
}
}
}
}
}
});
// Observe document.body for chat popups appearing anywhere
chatItemPopupObserver.observe(document.body, { childList: true, subtree: true });
// Initial check for profile items tab already being visible when script loads
const existingProfileContainer = document.querySelector(PROFILE_ITEMS_CONTAINER_SELECTOR);
if (existingProfileContainer && !existingProfileContainer.querySelector(`#${PROFILE_ITEM_SEARCH_WRAPPER_ID}`)) {
const itemsGrid = existingProfileContainer.querySelector(PROFILE_ITEMS_GRID_SELECTOR);
if (itemsGrid) {
if (!SCRIPT_STATE.allItemsMapByIcon) { // If item data not yet loaded
loadCoreData().then(() => { // Load it, then add search if successful
if (SCRIPT_STATE.allItemsMapByIcon) addProfileItemSearch(existingProfileContainer, itemsGrid);
});
} else { // Item data already loaded
addProfileItemSearch(existingProfileContainer, itemsGrid);
}
}
}
}
/**
* Entry point for the script. Loads core data then sets up observers.
*/
async function main() {
await loadCoreData(); // Initial load of all items and potentially consumed items
setupObservers(); // Set up all MutationObservers to react to page changes
}
// --- Script Execution Start ---
// Run main() once the page's basic HTML structure is ready (DOMContentLoaded)
// or immediately if it's already past that stage.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', main);
} else {
main(); // DOM is already interactive or complete
}
})();