CB User Context Menu

Modify the user context menu

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==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);
})();