Kick Emote Picker

Adds "Favorites" Emote Picker to Kick chat UI. For sub emotes / 7TV / or anything that can be a text and pasted into message input field. Very BASIC for now.

// ==UserScript==
// @name        Kick Emote Picker
// @namespace   maartyl scripts
// @match       https://kick.com/*
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_addValueChangeListener
// @grant       GM_xmlhttpRequest
// @version     1.0
// @author      maartyl
// @license     MIT
// @description Adds "Favorites" Emote Picker to Kick chat UI. For sub emotes / 7TV / or anything that can be a text and pasted into message input field. Very BASIC for now.
//
// ==/UserScript==


// TODO: ?  Also add NORMAL EMOJI support - no need for image -> needs different rendering, though... Might be Annoying to MIX ...

(function(_root){

  function init() {
    const dumbEmotes = document.getElementsByClassName("quick-emotes-holder")[0];
    const inp = document.getElementById("message-input");

    const externReady = dumbEmotes && inp;

    if (!externReady){
      //TODO: listen changes? repeat if not ready ?
      //TODO: check all present: if not: delay init // ... Might run forever if it never finds it. ~Zero cost, though.

      console.log("maa.init: chat-dom not ready");

      setTimeout(init, 500);
      return;
    }

    dumbEmotes_display = dumbEmotes.style.display; //save KICK defined value to put back after I hid it;

    // --- end of DOM read

    //TODO: UNREGISTER ALL AND RESTART init-loop on chat UNMOUNT --- FUTURE


    const channelName = window.location.pathname.split("/")[1];
    const confName = "confa." + channelName;

    console.log("maa.init", confName);

    const pane = document.createElement("div");
    pane.id = "maa-emote-picker-pane";
    pane.style.display = "flex";
    pane.style.position = "relative";
    pane.style.flexFlow = "wrap-reverse";
    pane.style.gap = "0.2rem";
    pane.style.maxHeight = "50vh";
    pane.style.overflowY = "scroll";
    pane.style.marginBottom = "-0.7rem";

    const confa = document.createElement("textarea");
    confa.value = GM_getValue(confName, "");
    confa.style.backgroundColor = "transparent";
    confa.style.width = "100%";
    confa.style.minHeight = "0.2rem";
    confa.style.height = "0.2rem";
    confa.style.fontFamily = "monospace";
    //confa.style.textWrap = "nowrap"; // - nicer BUT: enables sideways scrollbar - always - annoying. Not worth exploring how to fix.

    confa.style.paddingTop = "0.5rem";  // prevents text being visible normally, when collapsed
    confa.style.paddingLeft = "0.5rem";  // NOT bottom
    confa.style.paddingRight = "0.5rem";
    //confa.style.marginBottom = "-0.5rem";
    //confa.style.marginTop = "-0.5rem";

    pane.appendChild(confa);


    setEditing(false); //= init layout
    insertAfter(pane, dumbEmotes);
    dumbEmotes.style.display = "none";

     //TODO: use DOM observer of direct children + text changes
    inp.addEventListener("input", (event) => {
      // data == the txt _inserted_ - not text overall -- NULL in delete
      // inputType = insertText vs (probably paste / del)

      setEditing("" != inp.innerText);
    });

    confa.addEventListener("change", (event) => {
      //save new value on "lost focus and changed"
      //paddingTop works well to hide text when collapsed -- better than adding extra newline to start
      var v = event.target.value;
      console.log("maa.conf.set.chng", {v, event});
      GM_setValue(confName, v); //auto ingested in change listener
    });

    function setEditing(isEditing){
      if (isEditing){
        //dumbEmotes.style.display = "none";
        //pane.style.display = "flex";
        pane.style.maxHeight = "50vh";
        pane.style.overflowY = "scroll";
      } else {
        //dumbEmotes.style.display = dumbEmotes_display;
        //pane.style.display = "none";
        pane.style.maxHeight = "3rem";
        pane.style.overflowY = "clip"; // clip = no scroll, unlike "hidden"
      }
    }

    function insertEmoteText(emoteName) {
      //console.log("maa.insert", emoteName);
      inp.focus();
      pasteText(inp, emoteName + " "); // useful space - not needed for emote interpreted, but easy to click multiple
    }


    function pushEmotes(emoteList) {
      // clear old
      for (const old of pane.querySelectorAll("img")){
        pane.removeChild(old);
      }

      // push all from parsed list
      for (const e of emoteList) {
        appendEmote(pane, e);
      }
    }
    function appendEmote(parent, emoteObj) {
      //parent is always the pane for now

      //TODO: hover CSS

      const eimg = document.createElement("img");
      //eimg.style.margin = "0.25rem"; // gap instead
      eimg.style.height = "2rem";
      eimg.style.width = "auto"; //for non rectangular
      eimg.style.borderRadius = "4px";
      eimg.style.cursor = "pointer";
      confa.style.backgroundColor = "transparent";

      eimg.alt = emoteObj.emoteName;
      eimg.title = emoteObj.emoteName;
      eimg.src = emoteObj.emoteImgUrl;

      eimg.addEventListener("click", e => {
        insertEmoteText(emoteObj.emoteName);
      });

      parent.appendChild(eimg);
    }

    function ingestConf(txt) {
      // allows conf: either
      // - directly lines
      // - or single URL - points to the list

      txt = txt.trim();

      if (!isValidUrl(txt)){
        pushEmotes(parseEmoteList(txt));
      } else {
        xhr({url:txt})
          .then(x=> x.status == 200 ? x.responseText : "")
          .then(parseEmoteList)
          .then(pushEmotes)
          .catch(console.error);
      }
    }

    GM_addValueChangeListener(confName,  (name, oldValue, newValue, remote) =>{
      //console.log("maa.conf.sub.chng", {name, oldValue, newValue, remote});
      ingestConf(newValue);
      if (remote){
        confa.value = newValue;
      }
    });
    ingestConf(GM_getValue(confName, "")); // ingest saved on init


    function parseEmoteList(txt){
      // normal line:  name img-url
      // external config: just url  (single instead of file)
      //TODO: idea:
      // - external config LIMITED TO CHANNEL:  /nebride url   -> url leads to again another config file
      // -- I hope emote name cannot contain slash - it CAN contain : and ! ...
      // - SILLY: just use different url per channel - I just have to SAVE separately -- each different emotes anyway / 7tv / ...

      // IDEA: commands      //ppl already used to ! -> command - less confusing than "what just some lone f is ?" -- PROBLEM on command CLASH, tho ...
      //  !f name   img-url
      //  !a alias  name
      //  !include  conf-url
      //  name img-url --> !f
      //  lone-url --> !include


      const lines = txt.split("\n");
      return lines.map(l => {
        const parts = /(\S+)\s+(\S+)/.exec(l);
        if (!parts) {
          //TODO: log weird  / err / ...
          console.warn("maa.conf.no-emote-line:", l);
          return null;
        }

        return {
          emoteName: parts[1],
          emoteImgUrl: parts[2],
        }

      })
      //ignore
        .filter(x=>x);
    }


    //end init
  }

  function pasteText(elemTarget, text) {
    const clipboardData = new DataTransfer();
    clipboardData.setData('text/plain', text);
    elemTarget.dispatchEvent(new ClipboardEvent('paste', { clipboardData }));
  }

  function insertAfter(newNode, existingNode) {
    existingNode.parentNode.insertBefore(newNode, existingNode.nextSibling);
  }

  function xhr(args) {
    return new Promise((onok,onerr) => {
      GM_xmlhttpRequest({
        context: {onok,onerr},
        anonymous:true,
        onload:onok,
        onerror:onerr,
        ...args,
      })
    })
  }

  function isValidUrl(urlString) {
    let url;
		try {
      url = new URL(urlString);
    }
    catch(e) {
      return false;
    }
    return url.protocol === "http:" || url.protocol === "https:";
  }

  init();
})(this)