// ==UserScript==
// @name CB User Context Menu
// @namespace aravvn.tools
// @author aravvn
// @license CC-BY-NC-SA-4.0
// @version 4.9.0
// @description Modify the user context menu
// @match https://chaturbate.com/*
// @match https://*.chaturbate.com/*
// @run-at document-idle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @noframes
// ==/UserScript==
(() => {
'use strict';
/** ---------------- Selectors & Config ---------------- */
const MENU_SEL = '#user-context-menu[data-testid="user-context-menu"]';
const LINK_SEL = 'a[data-testid="username"][href]';
const CACHE_TTL = 5 * 60 * 1000; // success cache 5 min
const POLL_MS = 400;
const MAX_API_TRIES = 2; // <= your request
const FAIL_SILENCE_MS = 10 * 60 * 1000; // cool-down after maxed out
const MODE_KEY = 'cbx_info_mode'; // 'list' | 'pills'
const FIELDS_KEY = 'cbx_info_fields_enabled'; // JSON array of keys
/** ---------------- Field defs ---------------- */
const FIELD_DEFS = [
{ key:'i_am', label:'I Am' },
{ key:'birth_date', label:'Birth Date' },
{ key:'age', label:'Age' },
{ key:'interested_in', label:'Interested In' },
{ key:'location', label:'Location' },
{ key:'languages', label:'Language(s)' },
{ key:'body_type', label:'Body Type' },
{ key:'body_decorations',label:'Body Decorations' },
{ key:'smoke_drink', label:'Smoke / Drink' },
{ key:'last_broadcast', label:'Last Broadcast' },
{ key:'fan_club_cost', label:'Fan Club Cost' },
{ key:'follower_count', label:'Follower Count' },
{ key:'has_social', label:'Social Media' },
{ key:'has_media_sets', label:'Media Sets' },
];
const DEFAULT_ENABLED = [
'i_am','age','interested_in','location','languages',
'body_type','body_decorations','smoke_drink','last_broadcast',
'fan_club_cost','follower_count'
];
/** ---------------- State/helpers ---------------- */
const cache = new Map(); // success cache: user -> { t, data }
const inFlight = new Map(); // user -> Promise
const failState = new Map(); // user -> { attempts, nextAllowedAt }
let debTimer = 0;
const debounce = (fn, ms=80) => (...a)=>{ clearTimeout(debTimer); debTimer=setTimeout(()=>fn(...a), ms); };
const isVisible = (el)=>{ if(!el) return false; const cs=getComputedStyle(el); if(cs.display==='none'||cs.visibility==='hidden'||+cs.opacity===0) return false; const r=el.getBoundingClientRect(); return r.width>0&&r.height>0; };
const esc = (s)=> String(s ?? '').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const pick = (...vals)=> vals.find(v => v!=null && String(v).trim()!=='') ?? '';
const getUserFromHref = (href)=>{ try{ const u=new URL(href, location.origin); return u.pathname.split('/').filter(Boolean)[0]||''; }catch{ return (href||'').replace(/^\/+|\/+$/g,'').split('/')[0]||''; } };
const isValidBio = (bio)=> !!bio && typeof bio === 'object' && Object.keys(bio).length > 0;
const asListText = (val) => {
if (Array.isArray(val)) return val.filter(Boolean).join(', ');
if (val && typeof val === 'object') { try { return Object.values(val).filter(Boolean).join(', '); } catch { return String(val); } }
return pick(val);
};
const timeAgo = (isoOrText) => {
if (!isoOrText) return '';
if (typeof isoOrText==='string' && /\bago\b/i.test(isoOrText)) return isoOrText;
const d = new Date(isoOrText); if (isNaN(d)) return String(isoOrText);
let s = Math.max(0,(Date.now()-d.getTime())/1000);
for (const [lab,sec] of [['y',31536000],['mo',2592000],['d',86400],['h',3600],['m',60],['s',1]]) { const v=Math.floor(s/sec); if(v>=1) return `${v}${lab} ago`; }
return 'just now';
};
/** ---------------- Tampermonkey Prefs ---------------- */
const getMode = () => { try { return (GM_getValue(MODE_KEY, 'list') === 'pills') ? 'pills' : 'list'; } catch { return 'list'; } };
const setMode = (mode) => { try { GM_setValue(MODE_KEY, (mode === 'pills') ? 'pills' : 'list'); } catch {} };
const getEnabledSet = () => {
try {
const raw = GM_getValue(FIELDS_KEY, JSON.stringify(DEFAULT_ENABLED));
const arr = Array.isArray(raw) ? raw : JSON.parse(raw || '[]');
return new Set(arr.filter(Boolean));
} catch { return new Set(DEFAULT_ENABLED); }
};
const setEnabledSet = (set) => { try { GM_setValue(FIELDS_KEY, JSON.stringify(Array.from(set))); } catch {} };
const registerMenu = () => {
const cur = getMode();
GM_registerMenuCommand(`Mode: ${cur === 'list' ? 'Details list (current)' : 'Fact pills (current)'}`, ()=>{});
GM_registerMenuCommand(cur === 'list' ? 'Switch to: Fact pills' : 'Switch to: Details list', ()=>{
const next = cur === 'list' ? 'pills' : 'list';
setMode(next);
const menu = document.querySelector(MENU_SEL);
if (menu && isVisible(menu)) augmentMenu(menu, /*force*/true);
alert(`Info display mode changed to: ${next}`);
});
GM_registerMenuCommand('Configure visible fields…', async ()=>{
const enabled = getEnabledSet();
for (const f of FIELD_DEFS) {
const curOn = enabled.has(f.key);
const ans = confirm(`[CB Info] Show field "${f.label}"?\nCurrent: ${curOn ? 'ON' : 'OFF'}\n\nOK = ON, Cancel = OFF`);
if (ans) enabled.add(f.key); else enabled.delete(f.key);
}
setEnabledSet(enabled);
const menu = document.querySelector(MENU_SEL);
if (menu && isVisible(menu)) augmentMenu(menu, /*force*/true);
alert(`[CB Info] Fields saved: ${FIELD_DEFS.filter(f=>enabled.has(f.key)).map(f=>f.label).join(', ') || '(none)'}`);
});
GM_registerMenuCommand('Reset fields to defaults', ()=>{
setEnabledSet(new Set(DEFAULT_ENABLED));
const menu = document.querySelector(MENU_SEL);
if (menu && isVisible(menu)) augmentMenu(menu, /*force*/true);
alert('[CB Info] Fields reset to defaults.');
});
GM_registerMenuCommand('Export fields JSON', ()=>{
const json = JSON.stringify(Array.from(getEnabledSet()), null, 2);
prompt('Copy your fields JSON:', json);
});
GM_registerMenuCommand('Import fields JSON', ()=>{
const raw = prompt('Paste fields JSON (array of keys):', '[]');
if (!raw) return;
try {
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) throw new Error('Not an array');
const validKeys = new Set(FIELD_DEFS.map(f=>f.key));
const cleaned = arr.filter(k=> validKeys.has(k));
setEnabledSet(new Set(cleaned));
const menu = document.querySelector(MENU_SEL);
if (menu && isVisible(menu)) augmentMenu(menu, /*force*/true);
alert('[CB Info] Fields imported.');
} catch { alert('Invalid JSON.'); }
});
};
/** ---------------- Styles (additive only) ---------------- */
const ensureStyle = ()=>{
if (document.getElementById('cbx-info-mode-style')) return;
const css = document.createElement('style');
css.id = 'cbx-info-mode-style';
css.textContent = `
#user-context-menu[data-testid="user-context-menu"] .ucmHeader { position: relative; }
#user-context-menu [data-cbx="realname"]{
display:block; font-size:11px; line-height:1.2; opacity:.9; margin-top:4px;
max-width:100%; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
#user-context-menu [data-cbx="facts"]{
display:flex; flex-wrap:wrap; gap:6px; margin:6px 10px 0 10px;
}
#user-context-menu [data-cbx="facts"] .cbx-pill{
padding:2px 6px; border:1px solid rgba(0,0,0,.15); border-radius:999px; font-size:10px; opacity:.95;
}
#user-context-menu [data-cbx="details"]{
margin:6px 10px 0 10px;
display:grid; grid-template-columns:auto 1fr; gap:6px 10px;
font-size:11.5px; line-height:1.35;
}
#user-context-menu [data-cbx="details"] .lab{ font-weight:700; opacity:.95; white-space:nowrap; }
#user-context-menu [data-cbx="details"] .val{ opacity:.95; }
`;
document.head.appendChild(css);
};
/** ---------------- API (with retry cap) ---------------- */
const fetchBio = async (user) => {
const url = new URL(`/api/biocontext/${encodeURIComponent(user)}/`, location.origin).href;
const resp = await fetch(url, { credentials:'include' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.json();
};
// Guard against repeated attempts: success cache + failState + inFlight
const getBioLimited = async (user) => {
const now = Date.now();
// 1) Success cache hit?
const hit = cache.get(user);
if (hit && (now - hit.t) < CACHE_TTL) return hit.data;
// 2) Fail cool-down?
const fs = failState.get(user);
if (fs) {
const { attempts, nextAllowedAt } = fs;
if (attempts >= MAX_API_TRIES && now < nextAllowedAt) {
return null; // hard stop during cool-down
}
}
// 3) In-flight dedupe
if (inFlight.has(user)) return inFlight.get(user);
// 4) Do request (once)
const p = (async ()=>{
try {
const data = await fetchBio(user);
if (isValidBio(data)) {
cache.set(user, { t: now, data });
failState.delete(user); // clear fail state on success
return data;
}
// invalid data counts as a failed attempt
const prev = failState.get(user) || { attempts:0, nextAllowedAt:0 };
const attempts = prev.attempts + 1;
const nextAllowedAt = attempts >= MAX_API_TRIES ? (now + FAIL_SILENCE_MS) : now;
failState.set(user, { attempts, nextAllowedAt });
return null;
} catch {
const prev = failState.get(user) || { attempts:0, nextAllowedAt:0 };
const attempts = prev.attempts + 1;
const nextAllowedAt = attempts >= MAX_API_TRIES ? (now + FAIL_SILENCE_MS) : now;
failState.set(user, { attempts, nextAllowedAt });
return null;
} finally {
inFlight.delete(user);
}
})();
inFlight.set(user, p);
return p;
};
/** ---------------- Header box visibility (API-only) ---------------- */
const setHeaderAgeGenderVisibility = (menuEl, hide) => {
const header = menuEl.querySelector('.ucmHeader');
if (!header) return;
const rightBox = header.querySelector('[data-testid="gender-icon"]')?.closest('div');
const ageSpan = header.querySelector('[data-testid="age"]');
if (rightBox) rightBox.style.display = hide ? 'none' : '';
if (ageSpan) ageSpan.style.display = hide ? 'none' : '';
};
/** ---------------- Real name (only when API valid) ---------------- */
const upsertRealName = (menuEl, username, bio) => {
const header = menuEl.querySelector('.ucmHeader'); if (!header) return;
const unameWrap = header.querySelector(LINK_SEL)?.parentElement || header;
const real = pick(bio?.display_name, bio?.real_name, bio?.full_name, bio?.name);
const r = (real||'').trim();
const same = r && username && r.toLowerCase()===username.toLowerCase();
let node = header.querySelector('[data-cbx="realname"]');
if (!r || same){ if (node) node.remove(); return; }
if (!node){
node = document.createElement('div');
node.setAttribute('data-cbx','realname');
node.setAttribute('aria-hidden','true');
unameWrap.after(node);
}
node.textContent = r;
};
/** ---------------- Value extraction map ---------------- */
const valuesFromBio = (bio) => {
const v = {};
v.i_am = pick(bio?.sex, bio?.subgender, bio?.gender);
v.birth_date = pick(bio?.birth_date, bio?.dob, bio?.display_birthday, bio?.birthday);
v.age = pick(bio?.age, bio?.display_age);
v.interested_in = Array.isArray(bio?.interested_in) ? bio.interested_in.join(', ') : pick(bio?.interested_in);
v.location = pick(bio?.location, [bio?.city,bio?.region,bio?.country].filter(Boolean).join(', '));
v.languages = pick(bio?.languages, Array.isArray(bio?.languages_spoken) ? bio.languages_spoken.join(', ') : '');
v.body_type = pick(bio?.body_type, bio?.body, bio?.build);
v.body_decorations= asListText(pick(bio?.body_decorations, bio?.body_decoration, bio?.decorations));
v.smoke_drink = pick(bio?.smoke_drink, [bio?.smokes, bio?.drinks].filter(v=>v!=null).join(' / '));
v.last_broadcast = pick(bio?.time_since_last_broadcast, bio?.last_broadcast ? timeAgo(bio.last_broadcast) : '');
v.fan_club_cost = (bio?.performer_has_fanclub && Number.isFinite(bio?.fan_club_cost)) ? String(bio.fan_club_cost) : '';
v.follower_count = (bio?.follower_count!=null) ? String(bio.follower_count) : '';
v.has_social = Array.isArray(bio?.social_medias) && bio.social_medias.length>0 ? 'Yes' : '';
v.has_media_sets = Array.isArray(bio?.photo_sets) && bio.photo_sets.length>0 ? 'Yes' : '';
return v;
};
/** ---------------- Renderers (respecting field selection) ---------------- */
const ensureStyleOnce = ()=> ensureStyle();
const upsertFacts = (menuEl, values, enabledSet) => {
const header = menuEl.querySelector('.ucmHeader'); if (!header) return;
let holder = menuEl.querySelector('[data-cbx="facts"]');
const iconFor = (k) => ({
i_am:'⚧', birth_date:'🎂', age:'🔢', interested_in:'❤️', location:'📍',
languages:'🗣', body_type:'🏷', body_decorations:'✳️', smoke_drink:'🚬',
last_broadcast:'⏱', fan_club_cost:'⭐', follower_count:'👥', has_social:'🔗', has_media_sets:'🖼'
}[k] || '•');
const pills = FIELD_DEFS
.filter(f => enabledSet.has(f.key))
.map(f => [f.key, values[f.key]])
.filter(([_, val]) => !!val && String(val).trim()!=='')
.map(([k, val]) => `<span class="cbx-pill" data-k="${k}">${iconFor(k)} ${esc(String(val))}</span>`);
if (!pills.length){ if (holder) holder.remove(); return; }
if (!holder){
holder = document.createElement('div');
holder.setAttribute('data-cbx','facts');
const userLabel = menuEl.querySelector('.ucmUserLabel');
(userLabel || header).insertAdjacentElement('afterend', holder);
}
holder.innerHTML = pills.join('');
};
const upsertDetails = (menuEl, values, enabledSet) => {
const header = menuEl.querySelector('.ucmHeader'); if (!header) return;
let box = menuEl.querySelector('[data-cbx="details"]');
const rowsHtml = FIELD_DEFS
.filter(f => enabledSet.has(f.key))
.map(f => [f.label, values[f.key]])
.filter(([_, val]) => !!val && String(val).trim()!=='')
.map(([lab, val]) => `<div class="lab">${esc(lab)}:</div><div class="val">${esc(String(val))}</div>`)
.join('');
if (!rowsHtml){ if (box) box.remove(); return; }
if (!box){
box = document.createElement('div');
box.setAttribute('data-cbx','details');
const userLabel = menuEl.querySelector('.ucmUserLabel');
(userLabel || header).insertAdjacentElement('afterend', box);
}
box.innerHTML = rowsHtml;
};
/** ---------------- Core Logic ---------------- */
const augmentMenu = async (menuEl /*, force*/ ) => {
if (!menuEl || !isVisible(menuEl)) return;
ensureStyleOnce();
const a = menuEl.querySelector(LINK_SEL);
if (!a) return;
const username = getUserFromHref(a.getAttribute('href')||'');
if (!username) return;
const bio = await getBioLimited(username);
if (!isValidBio(bio)) return; // No changes if no valid data
// Hide header age/gender (API-only behavior)
setHeaderAgeGenderVisibility(menuEl, /*hide*/ true);
// Real name
upsertRealName(menuEl, username, bio);
// Values + rendering per mode/fields
const values = valuesFromBio(bio);
const enabledSet = getEnabledSet();
const mode = getMode();
if (mode === 'pills') {
upsertFacts(menuEl, values, enabledSet);
const dt = menuEl.querySelector('[data-cbx="details"]'); if (dt) dt.remove();
} else {
upsertDetails(menuEl, values, enabledSet);
const fx = menuEl.querySelector('[data-cbx="facts"]'); if (fx) fx.remove();
}
};
/** ---------------- Observers ---------------- */
const handleDomChange = debounce(()=>{
const menu = document.querySelector(MENU_SEL);
if (menu && isVisible(menu)) augmentMenu(menu);
}, 60);
const mo = new MutationObserver(handleDomChange);
mo.observe(document.documentElement || document.body, {
childList:true, subtree:true, attributes:true, attributeFilter:['style','class','data-testid','id']
});
setInterval(()=>{
const menu = document.querySelector(MENU_SEL);
if (menu && isVisible(menu)) augmentMenu(menu);
}, POLL_MS);
/** ---------------- Init ---------------- */
ensureStyle();
registerMenu();
const existing = document.querySelector(MENU_SEL);
if (existing && isVisible(existing)) augmentMenu(existing);
})();