CB User Quick Bio

lets you view a users bio content by clicking on the name in any room

À partir de 2025-10-16. Voir la dernière version.

// ==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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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();
})();