Injects new Reddit profile pictures into Old Reddit and Reddit-Stream.com next to the username. Caches in localstorage. This version requires an API key. Enter your API Key under CLIENT_ID and CLIENT_SECRET or it will not work.
// ==UserScript== // @name Old Reddit with New Reddit Profile Pictures - API Key Version // @namespace typpi.online // @version 7.0.7 // @description Injects new Reddit profile pictures into Old Reddit and Reddit-Stream.com next to the username. Caches in localstorage. This version requires an API key. Enter your API Key under CLIENT_ID and CLIENT_SECRET or it will not work. // @author Nick2bad4u // @match *://*.reddit.com/* // @match *://reddit-stream.com/* // @connect reddit.com // @connect reddit-stream.com // @grant GM_xmlhttpRequest // @homepageURL https://github.com/Nick2bad4u/UserStyles // @license Unlicense // @resource https://www.google.com/s2/favicons?sz=64&domain=reddit.com // @icon https://www.google.com/s2/favicons?sz=64&domain=reddit.com // @icon64 https://www.google.com/s2/favicons?sz=64&domain=reddit.com // @run-at document-start // @tag reddit // ==/UserScript== (function () { 'use strict'; console.log('Reddit Profile Picture Injector Script loaded'); // Reddit API credentials /** * @constant {string} CLIENT_ID * The client ID used for authentication with Reddit's API. * This ID is required to make authenticated requests to Reddit's API endpoints. * Obtain this value by registering your application at https://www.reddit.com/prefs/apps */ const CLIENT_ID = 'EnterClientIDHere'; /** * @constant {string} CLIENT_SECRET * The client secret key required for Reddit API authentication. * This key should be kept private and not shared publicly. * Obtain this value from your Reddit API application settings. */ const CLIENT_SECRET = 'EnterClientSecretHere'; /** * User agent string used for making API requests. * Format: {ApplicationName}/{Version} by {Author} * @constant {string} */ const USER_AGENT = 'ProfilePictureInjector/7.0.6 by Nick2bad4u'; /** * Access token retrieved from localStorage for authentication purposes. * @type {string|null} */ let accessToken = localStorage.getItem('accessToken'); // Retrieve cached profile pictures and timestamps from localStorage /** * Object containing cached profile picture URLs. * Data is persisted in localStorage and parsed from JSON. * @type {Object.<string, string>} Key-value pairs of username to profile picture URL */ let profilePictureCache = JSON.parse( localStorage.getItem('profilePictureCache') || '{}', ); /** * Object storing timestamps for cached items. * Retrieved from localStorage, defaults to empty object if not found. * @type {Object.<string, number>} */ let cacheTimestamps = JSON.parse( localStorage.getItem('cacheTimestamps') || '{}', ); /** * Duration in milliseconds for which profile picture data will be cached. * Set to 7 days to balance between API rate limits and data freshness. * @constant * @type {number} */ const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds /** * Maximum number of entries that can be stored in the cache. * Prevents memory overflow by limiting cache size. * @constant {number} */ const MAX_CACHE_SIZE = 100000; // Maximum number of cache entries /** * Array of keys from the profilePictureCache object representing cached profile picture entries * @type {string[]} * @const */ const cacheEntries = Object.keys(profilePictureCache); // Rate limit variables /** * Remaining number of API requests allowed before hitting rate limit * @type {number} * @default 1000 */ let rateLimitRemaining = 1000; /** * Unix timestamp indicating when the Reddit API rate limit will reset * @type {number} */ let rateLimitResetTime = 0; /** * Date object representing when the rate limit will reset * @type {Date} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars const resetDate = new Date(rateLimitResetTime); /** * Current timestamp in milliseconds since January 1, 1970 00:00:00 UTC. * @type {number} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars const now = Date.now(); // Save the cache to localStorage /** * Saves the profile picture cache and cache timestamps to localStorage. * The cache is stored as stringified JSON under 'profilePictureCache' key, * and timestamps are stored under 'cacheTimestamps' key. */ function saveCache() { localStorage.setItem( 'profilePictureCache', JSON.stringify(profilePictureCache), ); localStorage.setItem('cacheTimestamps', JSON.stringify(cacheTimestamps)); } // Remove old cache entries /** * Removes expired entries from the Reddit profile picture URL cache. * Iterates through cached usernames and removes entries older than CACHE_DURATION. * After cleaning expired entries, saves the updated cache to storage. * * @function flushOldCache * @returns {void} * * @requires CACHE_DURATION - Maximum age of cache entries in milliseconds * @requires cacheTimestamps - Object storing timestamps for each cached username * @requires profilePictureCache - Object storing profile picture URLs by username * @requires saveCache - Function to persist the cache to storage */ function flushOldCache() { console.log('Flushing old Reddit profile picture URL cache'); const now = Date.now(); for (const username in cacheTimestamps) { if (now - cacheTimestamps[username] > CACHE_DURATION) { console.log(`Deleting cache for Reddit user - ${username}`); delete profilePictureCache[username]; delete cacheTimestamps[username]; } } saveCache(); console.log('Old cache entries flushed'); } // Limit the size of the cache to the maximum allowed entries /** * Manages the size of the profile picture cache by removing oldest entries when the maximum size is exceeded. * Sorts entries by timestamp and removes the oldest ones until the cache size is within the specified limit. * After removal, saves the updated cache to persistent storage. * * @function limitCacheSize * @returns {void} * * @uses profilePictureCache - Global object storing profile picture URLs * @uses cacheTimestamps - Global object storing timestamps for each cache entry * @uses MAX_CACHE_SIZE - Global constant defining maximum number of entries allowed in cache * @uses saveCache - Function to persist the cache to storage */ function limitCacheSize() { const cacheEntries = Object.keys(profilePictureCache); if (cacheEntries.length > MAX_CACHE_SIZE) { console.log(`Current cache size: ${cacheEntries.length} URLs`); console.log('Cache size exceeded, removing oldest entries'); const sortedEntries = cacheEntries.sort( (a, b) => cacheTimestamps[a] - cacheTimestamps[b], ); const entriesToRemove = sortedEntries.slice( 0, cacheEntries.length - MAX_CACHE_SIZE, ); entriesToRemove.forEach((username) => { delete profilePictureCache[username]; delete cacheTimestamps[username]; }); saveCache(); console.log( `Cache size limited to ${MAX_CACHE_SIZE.toLocaleString()} URLs`, ); } } /** * Calculates the total size in bytes of the profile picture cache and its timestamps. * The size is estimated by serializing cache entries to JSON and measuring their byte length. * Each cache entry consists of picture data and timestamp data for a username. * @returns {number} The total size of the cache in bytes */ function getCacheSizeInBytes() { const cacheEntries = Object.keys(profilePictureCache); let totalSize = 0; // Calculate size of profilePictureCache cacheEntries.forEach((username) => { const pictureData = profilePictureCache[username]; const timestampData = cacheTimestamps[username]; // Estimate size of data by serializing to JSON and getting the length totalSize += new TextEncoder().encode(JSON.stringify(pictureData)).length; totalSize += new TextEncoder().encode( JSON.stringify(timestampData), ).length; }); return totalSize; // in bytes } /** * Calculates the current cache size in megabytes. * @returns {number} The size of the cache in megabytes (MB) */ function getCacheSizeInMB() { return getCacheSizeInBytes() / (#### * ####); // Convert bytes to MB } /** * Calculates the cache size in kilobytes (KB). * @returns {number} The size of the cache in KB, calculated by dividing the size in bytes by ####. */ function getCacheSizeInKB() { return getCacheSizeInBytes() / ####; // Convert bytes to KB } // Obtain an access token from Reddit API /** * Obtains an access token from Reddit's API using client credentials. * The token is stored in localStorage along with its expiration time. * * @async * @function getAccessToken * @returns {Promise<string|null>} The access token if successful, null if the request fails * @throws {Error} When the network request fails * * @example * const token = await getAccessToken(); * if (token) { * // Use token for authenticated requests * } */ async function getAccessToken() { console.log('Obtaining access token'); const credentials = btoa(`${CLIENT_ID}:${CLIENT_SECRET}`); try { const response = await fetch( 'https://www.reddit.com/api/v1/access_token', { method: 'POST', headers: { Authorization: `Basic ${credentials}`, 'Content-Type': 'application/x-www-form-urlencoded', }, body: 'grant_type=client_credentials', }, ); if (!response.ok) { console.error('Failed to obtain access token:', response.statusText); return null; } const data = await response.json(); accessToken = data.access_token; const expiration = Date.now() + data.expires_in * 1000; localStorage.setItem('accessToken', accessToken); localStorage.setItem('tokenExpiration', expiration.toString()); console.log('Access token obtained and saved'); return accessToken; } catch (error) { console.error('Error obtaining access token:', error); return null; } } // Fetch profile pictures for a list of usernames /** * Fetches profile pictures for a list of Reddit usernames using Reddit's OAuth API * @async * @param {string[]} usernames - Array of Reddit usernames to fetch profile pictures for * @returns {Promise<(string|null)[]>} Array of profile picture URLs corresponding to the input usernames. Returns null for usernames where fetching failed * @description * - Handles rate limiting by waiting when limit is reached * - Manages OAuth token refresh when expired * - Caches profile pictures to avoid redundant API calls * - Filters out [deleted] and [removed] usernames * - Updates rate limit tracking based on API response headers * - Saves fetched profile pictures to cache * @throws {Error} Possible network or API errors during fetch operations */ async function fetchProfilePictures(usernames) { console.log('Fetching profile pictures'); const now = Date.now(); const tokenExpiration = parseInt( localStorage.getItem('tokenExpiration'), 10, ); // Check rate limit if (rateLimitRemaining <= 0 && now < rateLimitResetTime) { console.warn('Rate limit reached. Waiting until reset...'); const timeRemaining = rateLimitResetTime - now; const minutesRemaining = Math.floor(timeRemaining / 60000); const secondsRemaining = Math.floor((timeRemaining % 60000) / 1000); console.log( `Rate limit will reset in ${minutesRemaining} minutes and ${secondsRemaining} seconds.`, ); await new Promise((resolve) => setTimeout(resolve, rateLimitResetTime - now), ); } // Refresh access token if expired if (!accessToken || now > tokenExpiration) { accessToken = await getAccessToken(); if (!accessToken) return null; } // Filter out cached usernames const uncachedUsernames = usernames.filter( (username) => !profilePictureCache[username] && username !== '[deleted]' && username !== '[removed]', ); if (uncachedUsernames.length === 0) { console.log('All usernames are cached'); return usernames.map((username) => profilePictureCache[username]); } // Fetch profile pictures for uncached usernames /** * Array of promises that fetch profile pictures for uncached Reddit usernames using Reddit's OAuth API * @type {Promise<(string|null)>[]} * @description Each promise attempts to: * 1. Fetch user data from Reddit's OAuth API * 2. Extract and cache the profile picture URL * 3. Update rate limit tracking * 4. Handle errors gracefully * @returns {Promise<(string|null)>[]} Array of promises that resolve to either: * - Profile picture URLs (string) for successful fetches * - null for failed fetches or users without profile pictures * @throws {Error} Individual promises may throw network or API errors, but these are caught and handled */ const fetchPromises = uncachedUsernames.map(async (username) => { try { const response = await fetch( `https://oauth.reddit.com/user/${username}/about`, { headers: { Authorization: `Bearer ${accessToken}`, 'User-Agent': USER_AGENT, }, }, ); // Update rate limit rateLimitRemaining = parseInt(response.headers.get('x-ratelimit-remaining')) || rateLimitRemaining; rateLimitResetTime = now + parseInt(response.headers.get('x-ratelimit-reset')) * 1000 || rateLimitResetTime; // Log rate limit information const timeRemaining = rateLimitResetTime - now; const minutesRemaining = Math.floor(timeRemaining / 60000); const secondsRemaining = Math.floor((timeRemaining % 60000) / 1000); console.log( `Rate Limit Requests Remaining: ${rateLimitRemaining}, 1000 more requests will be added in ${minutesRemaining} minutes and ${secondsRemaining} seconds`, ); if (!response.ok) { console.error( `Error fetching profile picture for ${username}: ${response.statusText}`, ); return null; } const data = await response.json(); if (data.data && data.data.icon_img) { const profilePictureUrl = data.data.icon_img.split('?')[0]; profilePictureCache[username] = profilePictureUrl; cacheTimestamps[username] = Date.now(); saveCache(); console.log(`Fetched profile picture: ${username}`); return profilePictureUrl; } else { console.warn(`No profile picture found for: ${username}`); return null; } } catch (error) { console.error(`Error fetching profile picture for ${username}:`, error); return null; } }); // eslint-disable-next-line @typescript-eslint/no-unused-vars const r###lts = await Promise.all(fetchPromises); limitCacheSize(); return usernames.map((username) => profilePictureCache[username]); } /** * Injects profile pictures next to user comments and adds hover functionality for enlarged views * @async * @param {NodeList} comments - NodeList of comment elements to process * @returns {Promise<void>} * * @description * This function: * 1. Extracts usernames from comments, filtering out deleted/removed users * 2. Fetches profile picture URLs for valid usernames * 3. Creates and injects profile picture elements before each comment * 4. Adds click handlers to open full-size images in new tabs * 5. Implements hover functionality to show enlarged previews * 6. Tracks injection count and logs cache statistics * 7. Reports rate limit status for API calls * * @requires fetchProfilePictures - External function to retrieve profile picture URLs * @requires cacheEntries - Global array tracking cached URLs * @requires MAX_CACHE_SIZE - Global constant for maximum cache size * @requires rateLimitResetTime - Global variable tracking API rate limit reset time * @requires rateLimitRemaining - Global variable tracking remaining API calls * @requires getCacheSizeInMB - Function to calculate cache size in megabytes * @requires getCacheSizeInKB - Function to calculate cache size in kilobytes */ async function injectProfilePictures(comments) { console.log(`Comments found: ${comments.length}`); const usernames = Array.from(comments) .map((comment) => comment.textContent.trim()) .filter( (username) => username !== '[deleted]' && username !== '[removed]', ); const profilePictureUrls = await fetchProfilePictures(usernames); let injectedCount = 0; // Counter for injected profile pictures comments.forEach((comment, index) => { const username = usernames[index]; const profilePictureUrl = profilePictureUrls[index]; if ( profilePictureUrl && !comment.previousElementSibling?.classList.contains('profile-picture') ) { console.log(`Injecting profile picture: ${username}`); const img = document.createElement('img'); img.src = profilePictureUrl; img.classList.add('profile-picture'); img.onerror = () => { img.style.display = 'none'; }; img.addEventListener('click', () => { window.open(profilePictureUrl, '_blank'); }); comment.insertAdjacentElement('beforebegin', img); const enlargedImg = document.createElement('img'); enlargedImg.src = profilePictureUrl; enlargedImg.classList.add('enlarged-profile-picture'); document.body.appendChild(enlargedImg); img.addEventListener('mouseover', () => { enlargedImg.style.display = 'block'; const rect = img.getBoundingClientRect(); enlargedImg.style.top = `${rect.top + window.scrollY + 20}px`; enlargedImg.style.left = `${rect.left + window.scrollX + 20}px`; }); img.addEventListener('mouseout', () => { enlargedImg.style.display = 'none'; }); injectedCount++; // Increment count after successful injection } }); console.log(`Profile pictures injected this run: ${injectedCount}`); console.log(`Current cache size: ${cacheEntries.length}`); console.log( `Cache size limited to ${MAX_CACHE_SIZE.toLocaleString()} URLs`, ); const currentCacheSizeMB = getCacheSizeInMB(); const currentCacheSizeKB = getCacheSizeInKB(); console.log( `Current cache size: ${currentCacheSizeMB.toFixed(2)} MB or ${currentCacheSizeKB.toFixed(2)} KB`, ); const timeRemaining = rateLimitResetTime - Date.now(); const minutesRemaining = Math.floor(timeRemaining / 60000); const secondsRemaining = Math.floor((timeRemaining % 60000) / 1000); console.log( `Rate Limit Requests Remaining: ${rateLimitRemaining} requests, refresh in ${minutesRemaining} minutes and ${secondsRemaining} seconds`, ); } /** * Sets up a MutationObserver to watch for new comments on Reddit. * The observer looks for elements with class 'author' or 'c-username'. * When new comments are detected, it disconnects the observer and * injects profile pictures into the found elements. * * The observer monitors the entire document body for DOM changes, * including nested elements. * * @function setupObserver */ function setupObserver() { console.log('Setting up observer'); // eslint-disable-next-line @typescript-eslint/no-unused-vars const observer = new MutationObserver((mutations) => { const comments = document.querySelectorAll('.author, .c-username'); if (comments.length > 0) { console.log('New comments detected'); observer.disconnect(); injectProfilePictures(comments); } }); observer.observe(document.body, { childList: true, subtree: true, }); console.log('Observer initialized'); } // Run the script /** * Initializes and runs the main script functionality. * This function performs the following operations: * 1. Clears outdated cache entries * 2. Logs the current state of the profile picture cache * 3. Initializes the DOM observer * @function runScript */ function runScript() { flushOldCache(); console.log('Cache loaded:', profilePictureCache); setupObserver(); } window.addEventListener('load', () => { console.log('Page loaded'); runScript(); }); // Add CSS styles for profile pictures /** * Creates a new style element to be injected into the document * @type {HTMLStyleElement} */ const style = document.createElement('style'); style.textContent = ` .profile-picture { width: 20px; height: 20px; border-radius: 50%; margin-right: 5px; transition: transform 0.2s ease-in-out; position: relative; z-index: 1; cursor: pointer; } .enlarged-profile-picture { width: 250px; height: 250px; border-radius: 50%; position: absolute; display: none; z-index: 1000; pointer-events: none; outline: 3px solid #000; box-shadow: 0 4px 8px rgba(0, 0, 0, 1); background-color: rgba(0, 0, 0, 1); } `; document.head.appendChild(style); })();