CB User Context Menu

Modify the user context menu

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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