// ==UserScript==
// @name CB User Quick Bio
// @namespace aravvn.tools
// @author aravvn
// @license CC-BY-NC-SA-4.0
// @version 3.2.2
// @description lets you view a users bio content by clicking on the name in any room
// @author aravvn
// @match https://chaturbate.com/*
// @match https://*.chaturbate.com/*
// @run-at document-idle
// @grant none
// @noframes
// ==/UserScript==
(() => {
'use strict';
const MENU_SEL = '#user-context-menu[data-testid="user-context-menu"]';
const LINK_SEL = 'a[data-testid="username"][href]';
const PANEL_ID = 'cb-biox-phone';
const BODY_ID = 'cb-biox-body';
const CACHE_TTL = 5 * 60 * 1000;
let lastUser = '';
let debTimer = 0;
const cache = new Map();
const debounce = (fn, ms=70) => (...a)=>{ clearTimeout(debTimer); debTimer=setTimeout(()=>fn(...a), ms); };
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 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 absUrl = (u) => { try { return new URL(u, location.origin).href; } catch { return u; } };
const sanitizeAndRestyleHTML = (dirty) => {
const tmp = document.createElement('div');
tmp.innerHTML = String(dirty || '');
tmp.querySelectorAll('script,style,link,object,embed,meta,noscript').forEach(n=>n.remove());
tmp.querySelectorAll('*').forEach(el=>{
[...el.attributes].forEach(a=>{ if(/^on/i.test(a.name)) el.removeAttribute(a.name); });
['style','align','bgcolor','border','cellpadding','cellspacing','color','face','size','id'].forEach(attr=>el.removeAttribute(attr));
if (el.tagName === 'FONT') { const p=el.parentNode; if(p){ while(el.firstChild) p.insertBefore(el.firstChild, el); el.remove(); } return; }
if (el.tagName === 'A') { el.target = '_blank'; el.rel = 'noopener noreferrer'; }
if (el.tagName === 'IMG' || el.tagName === 'VIDEO') { el.removeAttribute('width'); el.removeAttribute('height'); }
if (el.tagName === 'IFRAME') { el.removeAttribute('width'); if(!el.getAttribute('height')) el.setAttribute('height','360'); }
});
tmp.innerHTML = tmp.innerHTML.replace(/(?:<br\s*\/?>\s*){3,}/gi, '<br><br>');
tmp.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach(h=>h.classList.add('hc-h'));
tmp.querySelectorAll('p,li').forEach(e=>e.classList.add('hc-p'));
tmp.querySelectorAll('blockquote').forEach(e=>e.classList.add('hc-quote'));
tmp.querySelectorAll('pre,code').forEach(e=>e.classList.add('hc-code'));
tmp.querySelectorAll('table').forEach(t=>t.classList.add('hc-table'));
tmp.querySelectorAll('a').forEach(a=>a.classList.add('hc-a'));
tmp.querySelectorAll('img').forEach(img=>img.classList.add('hc-img'));
tmp.querySelectorAll('video').forEach(v=>{ v.classList.add('hc-video'); v.setAttribute('controls',''); });
tmp.querySelectorAll('iframe').forEach(f=>f.classList.add('hc-iframe'));
tmp.querySelectorAll('table, pre').forEach(el=>{
if (!el.parentElement) return;
if (!el.parentElement.classList.contains('hc-wrap-scroll')) {
const w = document.createElement('div');
w.className = 'hc-wrap-scroll';
el.parentElement.insertBefore(w, el);
w.appendChild(el);
}
});
tmp.querySelectorAll('p, li, blockquote, code, pre, div').forEach(el=>{
el.style.wordBreak = 'break-word';
el.style.overflowWrap = 'anywhere';
});
const wrap = document.createElement('div');
wrap.className = 'html-clean';
wrap.append(...[...tmp.childNodes]);
return wrap.outerHTML;
};
const ensurePanel = () => {
let panel = document.getElementById(PANEL_ID);
if (panel) return panel;
panel = document.createElement('div');
panel.id = PANEL_ID;
panel.innerHTML = `
<style>
#${PANEL_ID}{
position:fixed; right:16px; bottom:16px;
width:min(420px,92vw); height:min(86vh,820px);
z-index:2147483646; border-radius:22px;
background:#0f1217; color:#e9eef5;
box-shadow:0 20px 50px rgba(0,0,0,.55);
border:1px solid rgba(255,255,255,.08);
display:none; overflow:hidden;
}
#${PANEL_ID} *{ box-sizing:border-box }
.ph-head{ height:56px; display:flex; align-items:center; gap:10px; padding:0 14px;
border-bottom:1px solid rgba(255,255,255,.08);
background:linear-gradient(0deg, rgba(255,255,255,.02), rgba(255,255,255,.06));
user-select:none; cursor:move;
}
.ph-username{ font:800 16px/1.15 system-ui,-apple-system,Segoe UI,Roboto,Arial }
.ph-body{ height:calc(100% - 56px - 48px); overflow:auto; padding:14px }
.ph-tabs{ height:48px; display:flex; gap:8px; align-items:center; padding:0 10px 8px;
border-top:1px solid rgba(255,255,255,.08); background:linear-gradient(180deg, rgba(255,255,255,.02), rgba(255,255,255,.06));
}
.ph-tab{ flex:1; text-align:center; padding:8px 10px; border-radius:12px;
border:1px solid rgba(255,255,255,.10); cursor:pointer; user-select:none;
font:600 12px/1 system-ui,-apple-system,Segoe UI,Roboto,Arial; opacity:.9;
}
.ph-tab.active{ background:#141924; border-color:rgba(255,255,255,.16) }
.card{ background:#11161f; border:1px solid rgba(255,255,255,.10); border-radius:14px; padding:12px; margin-bottom:12px }
.card h3{ margin:0 0 8px; font:700 13px/1.2 system-ui,-apple-system,Segoe UI,Roboto,Arial; color:#f3f6fb }
.btns{ display:flex; gap:8px; flex-wrap:wrap; margin-top:8px }
.btn{ padding:8px 12px; border-radius:10px; border:1px solid rgba(255,255,255,.14); background:#0f141e; color:#e9eef5; cursor:pointer; text-decoration:none; font-weight:600 }
.btn:hover{ background:#141c29 }
.chips{ display:flex; flex-wrap:wrap; gap:8px; margin-top:10px }
.chip{ padding:4px 10px; border:1px solid rgba(255,255,255,.16); border-radius:999px; font-size:12px; opacity:.95 }
.json{ white-space:pre; font:12px/1.35 ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;
background:#0f141c; border:1px solid rgba(255,255,255,.10); border-radius:12px; padding:10px; overflow:auto; max-height:50vh;
}
/* Clean HTML Theme */
.html-clean{ line-height:1.55; font-size:13px; color:#e9eef5 }
.html-clean .hc-h{ margin:10px 0 6px; font-weight:800; color:#f3f6fb }
.html-clean .hc-p{ margin:8px 0 }
.html-clean .hc-a{ color:#9ac7ff; text-decoration:underline }
.html-clean .hc-img, .html-clean .hc-video{ max-width:100% !important; height:auto !important; display:block; border-radius:8px; }
.html-clean .hc-iframe{ width:100% !important; border:none; border-radius:8px; }
.html-clean .hc-code{ background:#0b111a; border:1px solid rgba(255,255,255,.10); padding:10px; border-radius:10px; overflow-x:auto }
.html-clean .hc-table{ width:100%; border-collapse:collapse; display:block; overflow-x:auto; }
.html-clean .hc-table th, .html-clean .hc-table td{ padding:6px 8px; border:1px solid rgba(255,255,255,.12); vertical-align:top }
.html-clean .hc-quote{ margin:10px 0; padding:8px 10px; border-left:3px solid #3a7bd5; background:#0f141c; border-radius:8px }
.hc-wrap-scroll{ overflow-x:auto; max-width:100% }
/* Media (phone style) */
.ps-grid{ display:grid; grid-template-columns:1fr; gap:10px }
.ps-card{ display:flex; gap:10px; border:1px solid rgba(255,255,255,.12); background:#0f141c; border-radius:12px; padding:8px }
.ps-cover{ width:96px; height:72px; border-radius:8px; overflow:hidden; background:#0b0f15; border:1px solid rgba(255,255,255,.08) }
.ps-cover img{ width:100%; height:100%; object-fit:cover }
.ps-info{ flex:1; min-width:0 }
.ps-name{ font-weight:800; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
.ps-meta{ display:flex; gap:6px; flex-wrap:wrap; margin-top:4px }
.badge{ padding:2px 6px; border-radius:999px; border:1px solid rgba(255,255,255,.20); font-size:11px }
.price{ margin-left:auto; font-weight:800 }
.flags{ margin-top:4px; display:flex; gap:6px; flex-wrap:wrap }
/* prettier "Access" pill */
.flag-access{
display:inline-flex; align-items:center; gap:6px;
padding:3px 10px; border-radius:999px;
background:linear-gradient(135deg,#1e6b3c,#2fae62);
color:#ecfff3; border:1px solid rgba(255,255,255,.12);
box-shadow:0 2px 10px rgba(47,174,98,.35), inset 0 0 0 1px rgba(255,255,255,.08);
font-size:12px; font-weight:800; letter-spacing:.02em;
text-shadow:0 1px 0 rgba(0,0,0,.35);
}
.flag-access .ico{
width:16px; height:16px; border-radius:50%;
background:rgba(255,255,255,.18); display:inline-flex;
align-items:center; justify-content:center; font-size:12px;
}
.flag-access .ico::before{ content:"✓"; line-height:1; }
.flag-bought{
padding:2px 8px; border:1px solid rgba(108,199,144,.45);
color:#98e3b6; border-radius:8px; font-size:11px; background:rgba(108,199,144,.08);
}
.muted{ opacity:.75 }
</style>
<div class="ph-head" id="${PANEL_ID}-drag">
<div class="ph-username">Quick Profile</div>
</div>
<div class="ph-body" id="${BODY_ID}"></div>
<div class="ph-tabs" id="${PANEL_ID}-tabs"></div>
`;
document.body.appendChild(panel);
// drag
const handle = document.getElementById(`${PANEL_ID}-drag`);
let sx,sy,sl,st,drag=false;
handle.addEventListener('pointerdown',(e)=>{ drag=true; sx=e.clientX; sy=e.clientY; const r=panel.getBoundingClientRect(); sl=r.left; st=r.top; panel.setPointerCapture(e.pointerId); e.preventDefault(); });
window.addEventListener('pointermove',(e)=>{ if(!drag) return; const nl=sl+(e.clientX-sx), nt=st+(e.clientY-sy); panel.style.left=Math.max(8,Math.min(window.innerWidth-panel.offsetWidth-8,nl))+'px'; panel.style.top=Math.max(8,Math.min(window.innerHeight-panel.offsetHeight-8,nt))+'px'; panel.style.right='auto'; panel.style.bottom='auto'; });
window.addEventListener('pointerup',()=>drag=false);
window.addEventListener('keydown',(e)=>{ if(e.key==='Escape' && panel.style.display!=='none') closePanel(); });
return panel;
};
const closePanel = () => {
const panel = document.getElementById(PANEL_ID);
const body = document.getElementById(BODY_ID);
if (panel) panel.style.display='none';
if (body) body.innerHTML='';
lastUser='';
};
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();
};
const getBioCached = async (user) => {
const now = Date.now();
const hit = cache.get(user);
if (hit && (now - hit.t) < CACHE_TTL) return hit.data;
const data = await fetchBio(user);
cache.set(user, { t: now, data });
return data;
};
const chip = (t) => `<span class="chip">${esc(t)}</span>`;
const renderTabs = (tabsEl, panes) => {
tabsEl.innerHTML = panes.map(p => `<div class="ph-tab${p.active?' active':''}" data-tab="${p.id}">${p.label}</div>`).join('');
tabsEl.querySelectorAll('.ph-tab').forEach(tab=>{
tab.addEventListener('click', ()=>{
const target = tab.dataset.tab;
tabsEl.querySelectorAll('.ph-tab').forEach(t=>t.classList.toggle('active', t===tab));
document.querySelectorAll('[data-pane]').forEach(p=> p.style.display = (p.dataset.pane===target)?'':'none');
document.getElementById(BODY_ID)?.scrollTo({ top:0, behavior:'instant' });
});
});
};
const renderPhotoSets = (sets=[]) => {
if (!Array.isArray(sets) || sets.length===0) return '<div class="muted">No photo/video sets.</div>';
return `
<div class="ps-grid">
${sets.map(s=>{
const cover=s.cover_url?absUrl(s.cover_url):'';
const name=s.name||`Set #${s.id??''}`;
const tokens=Number.isFinite(s.tokens)?`${s.tokens} tokens`:'';
const badges=[
s.is_video?'VIDEO':'PHOTO',
s.video_ready?'READY':'',
s.video_has_sound?'SOUND':'',
s.fan_club_only?'FC ONLY':(s.fan_club_unlock?'FC UNLOCK':''),
s.label_text||''
].filter(Boolean);
const canAccess = !!s.user_can_access;
const purchased = !!s.user_has_purchased;
return `
<div class="ps-card">
<div class="ps-cover">${cover?`<img src="${esc(cover)}" alt="">`:''}</div>
<div class="ps-info">
<div class="ps-name" title="${esc(name)}">${esc(name)}</div>
<div class="ps-meta">
${badges.map(b=>`<span class="badge">${esc(b)}</span>`).join('')}
${tokens?`<span class="price">${esc(tokens)}</span>`:''}
</div>
<div class="flags">
${canAccess ? `<span class="flag-access" title="You can access"><span class="ico" aria-hidden="true"></span><span>Access</span></span>` : ''}
${purchased ? `<span class="flag-bought">Purchased ✓</span>` : ''}
</div>
</div>
</div>
`;
}).join('')}
</div>
`;
};
const renderSocials = (socials=[]) => {
if (!Array.isArray(socials) || socials.length===0) return '<div class="muted">No social offers.</div>';
return `
<div class="soc-list">
${socials.map(s=>{
const title=s.title_name||s.name||'Social';
const img=s.image_url?absUrl(s.image_url):'';
const price=Number.isFinite(s.tokens)?`${s.tokens} tokens`:'';
const link=s.link?absUrl(s.link):'#';
const label=s.label_text?`<span class="soc-label"${s.label_color?` style="border-color:${esc(s.label_color)};color:${esc(s.label_color)}"`:''}>${esc(s.label_text)}</span>`:'';
const flag=s.purchased?`<span class="soc-label" style="border-color:#6cc790;color:#6cc790">Purchased ✓</span>`:'';
return `
<a class="soc-item" href="${esc(link)}" target="_blank" rel="noopener">
${img?`<img class="soc-ico" src="${esc(img)}" alt="">`:`<div class="soc-ico"></div>`}
<div>
<div class="soc-title">${esc(title)}</div>
<div class="soc-meta">
${price?`<span class="soc-price">${esc(price)}</span>`:''}
${label}${flag}
</div>
</div>
</a>
`;
}).join('')}
</div>
`;
};
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';
};
const renderView = (username, data) => {
const body = document.getElementById(BODY_ID);
const panel= document.getElementById(PANEL_ID);
const tabs = document.getElementById(`${PANEL_ID}-tabs`);
const head = document.getElementById(`${PANEL_ID}-drag`);
if (!body || !panel || !tabs || !head) return;
head.querySelector('.ph-username').innerHTML = `@${esc(username)}`;
const chips = [
data.sex || data.subgender ? `⚧ ${data.sex || data.subgender}` : '',
data.languages ? `🗣 ${data.languages}` : '',
Array.isArray(data.interested_in) && data.interested_in.length ? `❤️ ${data.interested_in.join(', ')}` : '',
data.room_status ? `● ${data.room_status}` : '',
data.follower_count!=null ? `👥 ${data.follower_count.toLocaleString?.() ?? data.follower_count}` : '',
data.time_since_last_broadcast ? `⏱ ${data.time_since_last_broadcast}` : (data.last_broadcast?`⏱ ${timeAgo(data.last_broadcast)}`:'')
].filter(Boolean).map(chip).join('');
const filtered = { ...data };
delete filtered.photo_sets;
delete filtered.social_medias;
delete filtered.about_me;
delete filtered.wish_list;
const profileUrl = `/${encodeURIComponent(username)}/`;
const hasFanclub = data.performer_has_fanclub === true;
const fanLink = data.fan_club_join_url || '';
const fanCost = data.fan_club_cost;
const aboutHTML = sanitizeAndRestyleHTML(data.about_me || '');
const wishlistHTML = sanitizeAndRestyleHTML(data.wish_list || '');
const photoSets = Array.isArray(data.photo_sets) ? data.photo_sets : [];
const socialsArr = Array.isArray(data.social_medias) ? data.social_medias : [];
const showMedia = (photoSets && photoSets.length) || (socialsArr && socialsArr.length);
body.innerHTML = `
<div class="card">
<div class="chips">${chips}</div>
<div class="btns" style="margin-top:10px">
<a class="btn" href="${esc(profileUrl)}" target="_blank" rel="noopener">Open profile ↗</a>
${ (hasFanclub && fanLink) ? `<a class="btn" href="${esc(fanLink)}" target="_blank" rel="noopener">Join Fan Club${Number.isFinite(fanCost)?` (${fanCost})`:''}</a>` : '' }
<button class="btn" id="cb-biox-copy">⧉ Copy username</button>
</div>
</div>
<div class="card" data-pane="overview">
<h3>Overview (Raw)</h3>
<div class="json" id="cb-biox-json">${esc(JSON.stringify(filtered, null, 2))}</div>
</div>
${data.about_me ? `
<div class="card" data-pane="about" style="display:none">
<h3>About Me</h3>
${aboutHTML}
</div>`:''}
${data.wish_list ? `
<div class="card" data-pane="wishlist" style="display:none">
<h3>Wishlist</h3>
${wishlistHTML}
</div>`:''}
${showMedia ? `
<div class="card" data-pane="media" style="display:none">
<h3>Photo/Video Sets</h3>
${renderPhotoSets(photoSets)}
<div style="height:10px"></div>
<h3>Social Offers</h3>
${renderSocials(socialsArr)}
</div>`:''}
`; // <-- WICHTIG: richtiges Backtick schließt das Template
const panes = [{ id:'overview', label:'Overview', active:true }];
if (data.about_me) panes.push({ id:'about', label:'About', active:false });
if (data.wish_list) panes.push({ id:'wishlist', label:'Wishlist', active:false });
if (showMedia) panes.push({ id:'media', label:'Media', active:false });
renderTabs(tabs, panes);
const copyBtn = body.querySelector('#cb-biox-copy');
if (copyBtn) {
copyBtn.addEventListener('click', async ()=>{
try { await navigator.clipboard.writeText(username); copyBtn.textContent='✓ Copied'; }
catch { copyBtn.textContent=username; }
setTimeout(()=> copyBtn.textContent='⧉ Copy username', 900);
});
}
};
const processMenu = async () => {
const menu = document.querySelector(MENU_SEL);
if (!menu || !isVisible(menu)) { closePanel(); return; }
const a = menu.querySelector(LINK_SEL);
if (!a) return;
const user = getUserFromHref(a.getAttribute('href')||'');
if (!user || user === lastUser) return;
lastUser = user;
const panel = ensurePanel();
const body = document.getElementById(BODY_ID);
const r = menu.getBoundingClientRect();
panel.style.display = 'block';
const left = Math.min(window.innerWidth - panel.offsetWidth - 8, Math.max(8, r.right + 12));
const top = Math.min(window.innerHeight - panel.offsetHeight - 8, Math.max(8, r.top));
panel.style.left = left + 'px';
panel.style.top = top + 'px';
panel.style.right='auto'; panel.style.bottom='auto';
if (body) body.innerHTML = `<div class="muted">Loading…</div>`;
try {
const data = await getBioCached(user);
renderView(user, data);
document.getElementById(BODY_ID)?.scrollTo({ top:0, behavior:'instant' });
} catch (err) {
if (body) body.innerHTML = `<div class="muted">Failed to load (${esc(String(err))}).</div>`;
}
};
const debouncedProcess = debounce(processMenu, 80);
const mo = new MutationObserver((muts)=>{
let menuAdded=false, menuRemoved=false, menuChanged=false;
for (const m of muts){
if (m.type==='childList'){
for (const n of m.addedNodes){ if(n instanceof HTMLElement && (n.matches?.(MENU_SEL)||n.querySelector?.(MENU_SEL))) menuAdded=true; }
for (const n of m.removedNodes){ if(n instanceof HTMLElement && (n.matches?.(MENU_SEL)||n.querySelector?.(MENU_SEL))) menuRemoved=true; }
} else if (m.type==='attributes'){
const t=m.target; if(t instanceof HTMLElement && t.matches(MENU_SEL)) menuChanged=true;
}
}
if (menuAdded || menuChanged){ const menu=document.querySelector(MENU_SEL); if(menu && isVisible(menu)) debouncedProcess(); }
if (menuRemoved){ closePanel(); } else { const menu=document.querySelector(MENU_SEL); if(!menu || !isVisible(menu)) closePanel(); }
});
mo.observe(document.documentElement||document.body, {
childList:true, subtree:true, attributes:true, attributeFilter:['style','class','data-testid','id']
});
const existing = document.querySelector(MENU_SEL);
if (existing && isVisible(existing)) debouncedProcess();
})();