CB User Context Menu

Modify the user context menu

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

// ==UserScript==
// @name         CB User Context Menu
// @namespace    aravvn.tools
// @author       aravvn
// @license      CC-BY-NC-SA-4.0
// @version      4.9.0
// @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 = `
      #user-context-menu[data-testid="user-context-menu"] .ucmHeader { position: relative; }
      #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;
      }
      #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;
      }
      #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);
})();