CB User Context Menu

Modify the user context menu

Versão de: 19/10/2025. Veja: a última versão.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

You will need to install an extension such as Tampermonkey to install this script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         CB User Context Menu
// @namespace    aravvn.tools
// @author       aravvn
// @license      CC-BY-NC-SA-4.0
// @version      4.9.1
// @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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 = `
    /* Auto-width for the context menu */
    #user-context-menu[data-testid="user-context-menu"] {
      width: auto !important;
      min-width: 180px !important;
      max-width: 360px !important;
      white-space: normal !important;
    }

    /* Keep text wrapping nicely */
    #user-context-menu[data-testid="user-context-menu"] * {
      white-space: normal !important;
      text-overflow: initial !important;
    }

    /* Existing styles from before... */
    #user-context-menu[data-testid="user-context-menu"] .ucmHeader { position: relative; }

    /* Real name under username */
    #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;
    }

    /* Fact pills */
    #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;
    }

    /* Detail list */
    #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);
})();