Bonk.io Chat Linkify

Makes plain-text links in Bonk.io chat clickable with a Confirm Window for user safety.

// ==UserScript==
// @name         Bonk.io Chat Linkify
// @version      1.0.0
// @description  Makes plain-text links in Bonk.io chat clickable with a Confirm Window for user safety.
// @author       miquella
// @namespace    https://greasyfork.org/en/users/1503369
// @license      MIT
// @match        https://bonk.io/gameframe-release.html
// @run-at       document-end
// @grant        none
// ==/UserScript==

/*
Safety — Bonk.io Chat Linkify
- No network calls; DOM-only. No data leaves your device.
- Scope: https://bonk.io/gameframe-release.html; edits chat + its own modal.
- Link opens: new tab + rel="noopener noreferrer" (blocks tab-nabbing, hides referrer). Your IP is still visible to the site you visit.
- Consent: shows target domain + warning; you must confirm to open.
- Trust list: stored locally (localStorage key: "bonk_linkify_allow_domains").
- Clear via: localStorage.removeItem("bonk_linkify_allow_domains")
- Theme/SFX: read-only sampling from 'div#leaveconfirmwindow';
- Keys: Esc = cancel, Enter = open (when modal focused).
- Caution: NEVER enter passwords, tokens, or recovery codes from links.
*/

(function(){
  "use strict";

  // Targets: lobby + in-game chat containers (root + message selector)
  var AREAS=[{root:"#newbonklobby_chat_content",msg:".newbonklobby_chat_msg_txt"},{root:"#ingamechatcontent",msg:".ingamechatmessage"}];
  // URL detection (fast heuristic) + trim trailing punctuation
  var URL=/(?:https?:\/\/)?(?:www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?:\/[^\s<]*)?/gi, TRAIL=/[),.;!?]+$/;
  // Allowlist storage key (per-browser, local only)
  var KEY_ALLOW="bonk_linkify_allow_domains";

  // Convert plain-text URLs inside a message span into <a> that goes through confirm
  function linkifySpan(span){
    if(!span||span.dataset.linkified) return;
    var text=span.textContent; if(!text||!URL.test(text)) return;
    URL.lastIndex=0; var frag=document.createDocumentFragment(), last=0;
    text.replace(URL,function(m,i){
      if(i>last) frag.appendChild(document.createTextNode(text.slice(last,i)));
      var trailing=""; var cleaned=m.replace(TRAIL,function(p){trailing=p;return"";});
      var href=/^https?:\/\//i.test(cleaned)?cleaned:"https://"+cleaned; // normalize to https
      var a=document.createElement("a");
      a.textContent=cleaned; a.href=href; a.target="_blank"; a.rel="noopener noreferrer nofollow ugc"; // security attrs
      a.addEventListener("click",function(e){e.preventDefault();confirmOrOpen(href);});// route via confirm
      frag.appendChild(a); if(trailing) frag.appendChild(document.createTextNode(trailing));
      last=i+m.length; return m;
    });
    if(last<text.length) frag.appendChild(document.createTextNode(text.slice(last)));
    span.textContent=""; span.appendChild(frag); span.dataset.linkified="1";
  }

  // Initial scan + MutationObserver to catch new messages/edits as they appear
  function scan(root,sel){ var list=root.querySelectorAll(sel); for(var i=0;i<list.length;i++) linkifySpan(list[i]); }
  function initArea(a){
    var r=document.querySelector(a.root); if(!r) return false;
    scan(r,a.msg);
    new MutationObserver(function(records){
      for(var i=0;i<records.length;i++){
        var m=records[i];
        if(m.type==="characterData"){
          var p=m.target&&m.target.parentElement;
          if(p&&p.matches&&p.matches(a.msg)) linkifySpan(p);
        }
        var added=m.addedNodes;
        if(added&&added.length){
          for(var j=0;j<added.length;j++){
            var n=added[j]; if(n.nodeType!==1) continue;
            if(n.matches&&n.matches(a.msg)) linkifySpan(n);
            if(n.querySelectorAll){
              var inner=n.querySelectorAll(a.msg);
              for(var k=0;k<inner.length;k++) linkifySpan(inner[k]);
            }
          }
        }
      }
    }).observe(r,{childList:true,subtree:true,characterData:true});
    return true;
  }

  // Read theme from native "Leave Game?" dialog so modal looks OEM (colors, fonts, radius, shadow)
  function sampleTheme(){
    function pick(s){return document.querySelector(s);}
    var leavePanel=pick("#leaveconfirmwindow")||pick("#leaveconfirmwindowcontainer")||pick(".windowShadow"); // panel-like ref
    var titleBg=pick("#leaveconfirmwindow_textbg")||pick("#leaveconfirmwindow_topbg")||pick(".windowTopBar")||leavePanel; // header strip
    var textRef=pick("#leaveconfirmwindow_text1")||leavePanel||document.body; // text style reference

    var ps=leavePanel?getComputedStyle(leavePanel):null;
    var ts=getComputedStyle(textRef);
    var bs=titleBg?getComputedStyle(titleBg):null;

    var panelBg=(ps&&ps.backgroundColor&&ps.backgroundColor!=="rgba(0, 0, 0, 0)")?ps.backgroundColor:"rgb(22,24,33)"; // fallback if transparent
    var winRadius="3px", btnRadius="2px"; // OEM radii
    var width=(ps&&ps.width&&parseFloat(ps.width))?ps.width:"310px"; // mirror native width
    var titleColor=(titleBg?getComputedStyle(titleBg).color:null)||"rgb(255,255,255)"; // header text color (usually white)
    var titleBgColor=(bs&&bs.backgroundColor)?bs.backgroundColor:"rgb(0,160,140)"; // header strip bg
    var font=(ts&&ts.fontFamily)?ts.fontFamily:"system-ui,sans-serif";
    var shadow=(ps&&ps.boxShadow)?ps.boxShadow:"rgba(0,0,0,0.63) 1px 1px 5px -2px";
    var textColor=(ts&&ts.color)?ts.color:"rgb(198,200,209)";
    return {panelBg,winRadius,btnRadius,width,titleColor,titleBgColor,font,shadow,textColor};
  }

  // Allowlist helpers (localStorage only; never leaves device)
  function getAllow(){ try{ var v=localStorage.getItem(KEY_ALLOW)||"[]"; return JSON.parse(v); }catch(e){ return []; } }
  function setAllow(a){ try{ localStorage.setItem(KEY_ALLOW,JSON.stringify(Array.from(new Set(a)))); }catch(e){} }
  function hostOf(u){ try{ return new URL(u).hostname.toLowerCase(); }catch(e){ return u.replace(/^https?:\/\//i,"").split("/")[0].toLowerCase(); } }

  // Apply native button classes; piggyback global SFX by mimicking hover/press (never clicks)
  function addBtnClasses(el){ el.classList.add("brownButton","brownbutton_classic","buttonShadow","bli-btn"); }
  function wireSFX(el){
    var ref=document.querySelector("#leaveconfirmwindow_okbutton")||document.querySelector("#leaveconfirmwindow_cancelbutton");
    if(!ref) return;
    function fire(t){ try{ ref.dispatchEvent(new MouseEvent(t,{bubbles:true,cancelable:true,view:window})); }catch(e){} }
    el.addEventListener("mouseenter",function(){fire("mouseenter");fire("mouseover");});
    el.addEventListener("mousedown",function(){fire("mousedown");});
    el.addEventListener("mouseup",function(){fire("mouseup");});
  }

  // Open helper (new tab; noopener/noreferrer)
  var isOpen=false;
  function openHref(h){ try{ window.open(h,"_blank","noopener,noreferrer"); }catch(e){ location.href=h; } }

  // Decide: if trusted host, open; else show confirm
  function confirmOrOpen(href){
    var host=hostOf(href);
    if(getAllow().includes(host)) return openHref(href);
    showConfirm(href,host);
  }

  // Build/attach confirm modal (title strip → domain → warning → trust checkbox → actions)
  function showConfirm(href,host){
    if(isOpen) return; isOpen=true;
    var th=sampleTheme(); // re-sample each open to stay in sync with theme

    // Overlay blocks background interaction
    var overlay=document.createElement("div");
    overlay.id="bli_overlay";
    overlay.style.cssText="position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:2147483646";

    // Panel (inherits look; explicit bg to match OEM even if class is transparent in theme)
    var panel=document.createElement("div");
    panel.id="bli_panel"; panel.className="windowShadow";
    panel.style.cssText="position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);width:"+th.width+";padding:0 16px 14px;text-align:center;z-index:2147483647;border-radius:"+th.winRadius+";font-family:"+th.font+";box-shadow:"+th.shadow+";background:"+th.panelBg+";color:"+th.textColor;

    // Header strip (same bg as native title bar)
    var header=document.createElement("div");
    header.id="bli_header";
    header.style.cssText="background:"+th.titleBgColor+";border-radius:"+th.winRadius+" "+th.winRadius+" 0 0;padding:5px 10px;margin:0 -16px 8px -16px";

    // Title text (same color as native header text)
    var headerText=document.createElement("div");
    headerText.id="bli_title"; headerText.textContent="Open external link?";
    headerText.style.cssText="font-size:21px;color:"+th.titleColor;

    // Domain (clear visibility of destination)
    var domain=document.createElement("div");
    domain.id="bli_domain"; domain.textContent=host;
    domain.style.cssText="font-size:13px;opacity:.9;word-break:break-all;margin:2px 0 8px";

    // Warning (social engineering reminders; never asks for secrets)
    var warning=document.createElement("div");
    warning.id="bli_warning";
    warning.innerHTML="Links may lead outside the game. Only continue if you trust the sender. Never enter your passwords, Discord token, or recovery codes.";
    warning.style.cssText="font-size:12px;line-height:1.3;opacity:.85;margin-bottom:8px";

    // Trust checkbox (adds host to local allowlist)
    var trustWrap=document.createElement("label");
    trustWrap.id="bli_trust_wrap"; trustWrap.style.cssText="display:inline-flex;align-items:center;gap:6px;font-size:12px;opacity:.9";
    var trustCb=document.createElement("input"); trustCb.id="bli_trust_checkbox"; trustCb.type="checkbox"; trustCb.style.marginRight="6px";
    var trustLabel=document.createElement("span"); trustLabel.id="bli_trust_label"; trustLabel.textContent="Trust this domain next time";

    // Actions row
    var actions=document.createElement("div");
    actions.id="bli_actions"; actions.style.cssText="display:flex;gap:28px;justify-content:center;margin-top:12px";

    // Buttons (native look + SFX; 2px radius)
    function makeBtn(id,text){
      var b=document.createElement("div"); b.id=id; b.textContent=text; addBtnClasses(b);
      b.style.cssText="min-width:110px;padding:7px 10px;cursor:pointer;user-select:none;text-align:center;border-radius:"+th.btnRadius;
      b.addEventListener("mouseenter",function(){b.style.filter="brightness(1.08)";});
      b.addEventListener("mouseleave",function(){b.style.filter="none";});
      wireSFX(b); return b;
    }
    var btnCancel=makeBtn("bli_cancel","Cancel");
    var btnOpen=makeBtn("bli_open","Open link");

    // Close helpers (Esc=cancel, Enter=confirm)
    function close(){ isOpen=false; overlay.remove(); panel.remove(); window.removeEventListener("keydown",onKey); }
    function onKey(e){ if(e.key==="Escape"){e.preventDefault();close();} if(e.key==="Enter"){e.preventDefault();btnOpen.click();} }

    // Wire button behavior
    btnCancel.addEventListener("click",close);
    btnOpen.addEventListener("click",function(){
      if(trustCb.checked){ var list=getAllow(); list.push(host); setAllow(list); }
      close(); openHref(href);
    });

    // Assemble DOM
    trustWrap.appendChild(trustCb); trustWrap.appendChild(trustLabel);
    actions.appendChild(btnCancel); actions.appendChild(btnOpen);
    header.appendChild(headerText);
    panel.appendChild(header); panel.appendChild(domain); panel.appendChild(warning); panel.appendChild(trustWrap); panel.appendChild(actions);
    document.body.appendChild(overlay); document.body.appendChild(panel);
    window.addEventListener("keydown",onKey);
  }

  // Boot: poll for containers (max ~10s), then stop; minimal overhead
  function boot(){
    var pending=new Set(AREAS.map(function(a){return a.root;})), inited=new Set(), tries=0;
    function tick(){
      for(var i=0;i<AREAS.length;i++){
        var a=AREAS[i]; if(inited.has(a.root)) continue;
        if(initArea(a)){ inited.add(a.root); pending.delete(a.root); }
      }
      if(pending.size&&++tries<=40) return;
      clearInterval(timer);
    }
    var timer=setInterval(tick,250); tick();
  }
  if(document.readyState==="loading") document.addEventListener("DOMContentLoaded",boot,{once:true}); else boot();
})();