// ==UserScript==
// @name Old Reddit with New Reddit Profile Pictures - API Key Version - Reddit-Stream Version
// @namespace typpi.online
// @version 7.0.3
// @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-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-stream.com
// @icon https://www.google.com/s2/favicons?sz=64&domain=reddit-stream.com
// @icon64 https://www.google.com/s2/favicons?sz=64&domain=reddit-stream.com
// @run-at document-start
// @tag reddit
// ==/UserScript==
(function () {
'use strict';
console.log(
'Reddit Profile Picture Injector Script loaded',
);
// Reddit API credentials
const CLIENT_ID = 'EnterClientIDHere';
const CLIENT_SECRET = 'EnterClientSecretHere';
const USER_AGENT =
'ProfilePictureInjector/7.0.2 by Nick2bad4u';
let accessToken = localStorage.getItem(
'accessToken',
);
// Retrieve cached profile pictures and timestamps from localStorage
let profilePictureCache = JSON.parse(
localStorage.getItem('profilePictureCache') ||
'{}',
);
let cacheTimestamps = JSON.parse(
localStorage.getItem('cacheTimestamps') ||
'{}',
);
const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
const MAX_CACHE_SIZE = 100000; // Maximum number of cache entries
const cacheEntries = Object.keys(
profilePictureCache,
);
// Rate limit variables
let rateLimitRemaining = 1000;
let rateLimitResetTime = 0;
const resetDate = new Date(rateLimitResetTime);
const now = Date.now();
// Save the cache to localStorage
function saveCache() {
localStorage.setItem(
'profilePictureCache',
JSON.stringify(profilePictureCache),
);
localStorage.setItem(
'cacheTimestamps',
JSON.stringify(cacheTimestamps),
);
}
// Remove old cache entries
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
function limitCacheSize() {
const cacheEntries = Object.keys(
profilePictureCache,
);
if (cacheEntries.length > MAX_CACHE_SIZE) {
console.log(
`Current cache size: ${cacheEntries.length}`,
);
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`,
);
}
}
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
}
function getCacheSizeInMB() {
return getCacheSizeInBytes() / (1024 * 1024); // Convert bytes to MB
}
function getCacheSizeInKB() {
return getCacheSizeInBytes() / 1024; // Convert bytes to KB
}
// Obtain an access token from Reddit API
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
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
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} requests, reset 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;
}
},
);
const results = await Promise.all(
fetchPromises,
);
limitCacheSize();
return usernames.map(
(username) => profilePictureCache[username],
);
}
// Inject profile pictures into comments
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}`,
);
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}, refresh in ${minutesRemaining} minutes and ${secondsRemaining} seconds`,
);
}
// Set up a MutationObserver to detect new comments
function setupObserver() {
console.log(
'Setting up observer to detect reddit comments',
);
const processedComments = new Set(); // Track already processed comments
let newCommentsBatch = []; // Store new comments temporarily
let batchTimeout; // Timeout variable for batching
let isFirstRun = true; // Flag to check if it's the first run
const observer = new MutationObserver(
(mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (
node.nodeType === Node.ELEMENT_NODE
) {
const newComments = Array.from(
node.querySelectorAll(
'.author, .c-username',
),
).filter(
(comment) =>
!processedComments.has(comment),
);
if (newComments.length > 0) {
newComments.forEach((comment) => {
processedComments.add(comment);
newCommentsBatch.push(comment); // Add to batch
});
// Clear previous timeout and set a new one for batching
clearTimeout(batchTimeout);
// Set a delay for the first run, then use regular debounce for others
batchTimeout = setTimeout(
() => {
injectProfilePictures(
newCommentsBatch,
);
newCommentsBatch = []; // Reset the batch
isFirstRun = false; // Disable first run flag after initial run
},
isFirstRun ? 150 : 100,
); // First run delay: 1000ms, regular: 300ms
}
}
});
});
},
);
observer.observe(document.body, {
childList: true,
subtree: true,
});
console.log('Observer initialized');
}
// Run the script
function runScript() {
flushOldCache();
console.log(
'Cache loaded:',
profilePictureCache,
);
setupObserver();
}
window.addEventListener('load', () => {
console.log('Page loaded');
runScript();
});
// Add CSS styles for profile pictures
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);
})();