Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- // ==UserScript==
- // @name HF FFZ/BTTV Emotes
- // @namespace hf-emotes
- // @version 3.6
- // @description Resolves :emotename: to FrankerFaceZ / BTTV emotes in Hackforums chat (shoutbox + Convo), with inline autocomplete, recents, a matching placeholder, and per-channel emote sets
- // @match https://hackforums.net/*
- // @match https://*.hackforums.net/*
- // @grant GM_xmlhttpRequest
- // @grant GM_setValue
- // @grant GM_getValue
- // @connect api.frankerfacez.com
- // @connect api.betterttv.net
- // @connect gql.twitch.tv
- // ==/UserScript==
- (function () {
- 'use strict';
- // ---- CONFIG ----
- // Includes #message-convo so emotes actually render inside Convo message
- // threads, not just the shoutbox and our own compose preview.
- const CHAT_CONTAINER_SELECTOR = '#shoutbox, .shoutbox, #chatbox, .chatbox, #message-convo';
- const EMOTE_SIZE = 32; // px, size for emotes rendered in posted chat messages
- const INLINE_EMOTE_SIZE = 20; // px, size for emotes rendered inline in the compose box
- const RECENTS_KEY = 'hf-emotes-recents';
- const CHANNELS_KEY = 'hf-emotes-channels';
- const MAX_RECENTS = 20;
- const MIN_AUTOCOMPLETE_CHARS = 3; // require ":" + at least this many letters before searching
- // Twitch's own web client embeds this Client-ID in every page it serves; it's the
- // same one FFZ/BTTV/7TV's own browser extensions use client-side to resolve a
- // channel *name* to the numeric Twitch ID BTTV's API requires. Read-only lookup,
- // no auth, nothing scraped beyond a public user ID.
- const TWITCH_GQL_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
- const EMOTE_TOKEN_REGEX = /:([a-zA-Z0-9_]{2,30}):/g;
- // matches an in-progress token being typed, e.g. ":cat" with no closing colon yet
- const PARTIAL_TOKEN_REGEX = /:([a-zA-Z0-9_]{2,30})$/;
- // Emotes are kept in layers - one Map for globals, one per added channel -
- // and merged into `emoteMap` (what the rest of the script reads from) so
- // removing a channel is just dropping its layer and re-merging, without
- // needing to know which entries came from where.
- let globalEmoteMap = new Map();
- let channelEmoteMaps = new Map(); // channel name (lowercase) -> Map(name -> url)
- let emoteMap = new Map(); // merged view: name (lowercase) -> image url
- let emoteNamesSorted = []; // cached sorted keys for fast prefix search
- function addEmoteTo(map, name, url) {
- if (!name || !url) return;
- map.set(name.toLowerCase(), url);
- }
- function rebuildEmoteMap() {
- emoteMap = new Map(globalEmoteMap);
- channelEmoteMaps.forEach(map => {
- map.forEach((url, name) => emoteMap.set(name, url));
- });
- rebuildNameCache();
- }
- function rebuildNameCache() {
- emoteNamesSorted = [...emoteMap.keys()].sort();
- }
- function fetchJSON(url) {
- return new Promise((resolve, reject) => {
- GM_xmlhttpRequest({
- method: 'GET',
- url,
- onload: (res) => {
- try {
- resolve(JSON.parse(res.responseText));
- } catch (e) {
- reject(e);
- }
- },
- onerror: reject
- });
- });
- }
- // NOTE on "real" vs "cached" APIs: api.betterttv.net/3/cached/emotes/global is BTTV's
- // actual, current, and only public v3 endpoint for global emotes - "cached" is just
- // its name, not a stale mirror (the old v2 endpoint is gone). FFZ's v1/set/global is
- // likewise the live endpoint. There isn't a separate "uncached" global endpoint for
- // either service, so both loaders below already hit the real source.
- async function loadFFZGlobal() {
- try {
- const data = await fetchJSON('https://api.frankerfacez.com/v1/set/global');
- const sets = data.sets || {};
- Object.values(sets).forEach(set => {
- (set.emoticons || []).forEach(emote => {
- const urls = emote.urls;
- const best = urls['2'] || urls['1'] || Object.values(urls)[0];
- const url = best.startsWith('http') ? best : 'https:' + best;
- addEmoteTo(globalEmoteMap, emote.name, url);
- });
- });
- } catch (e) {
- console.warn('[HF Emotes] FFZ global load failed', e);
- }
- }
- async function loadBTTVGlobal() {
- try {
- const data = await fetchJSON('https://api.betterttv.net/3/cached/emotes/global');
- data.forEach(emote => {
- const url = `https://cdn.betterttv.net/emote/${emote.id}/2x`;
- addEmoteTo(globalEmoteMap, emote.code, url);
- });
- } catch (e) {
- console.warn('[HF Emotes] BTTV global load failed', e);
- }
- }
- // ---------------------------------------------------------------------
- // Per-channel emote sets (FFZ room + BTTV channel), on top of globals
- // ---------------------------------------------------------------------
- // FFZ looks channels up by login name directly - no ID resolution needed.
- async function fetchFFZChannelEmotes(channel) {
- const map = new Map();
- const data = await fetchJSON(`https://api.frankerfacez.com/v1/room/${encodeURIComponent(channel)}`);
- const setId = data.room && data.room.set;
- const set = data.sets && data.sets[setId];
- (set && set.emoticons || []).forEach(emote => {
- const urls = emote.urls;
- const best = urls['2'] || urls['1'] || Object.values(urls)[0];
- const url = best.startsWith('http') ? best : 'https:' + best;
- addEmoteTo(map, emote.name, url);
- });
- return map;
- }
- // BTTV's channel-emotes endpoint only accepts a numeric Twitch user ID, not a
- // name, so resolve that first via Twitch's public GraphQL endpoint.
- function resolveTwitchId(channel) {
- return new Promise((resolve, reject) => {
- GM_xmlhttpRequest({
- method: 'POST',
- url: 'https://gql.twitch.tv/gql',
- headers: {
- 'Content-Type': 'text/plain;charset=UTF-8',
- 'Client-Id': TWITCH_GQL_CLIENT_ID
- },
- data: JSON.stringify({ query: `query { user(login: "${channel.toLowerCase()}") { id } }` }),
- onload: (res) => {
- try {
- const json = JSON.parse(res.responseText);
- const id = json && json.data && json.data.user && json.data.user.id;
- if (id) resolve(id); else reject(new Error('twitch user not found'));
- } catch (e) {
- reject(e);
- }
- },
- onerror: reject
- });
- });
- }
- async function fetchBTTVChannelEmotes(channel) {
- const map = new Map();
- let twitchId;
- try {
- twitchId = await resolveTwitchId(channel);
- } catch (e) {
- return map; // couldn't resolve an ID - FFZ may still have emotes, so don't throw
- }
- const data = await fetchJSON(`https://api.betterttv.net/3/cached/users/twitch/${twitchId}`);
- [...(data.channelEmotes || []), ...(data.sharedEmotes || [])].forEach(emote => {
- const url = `https://cdn.betterttv.net/emote/${emote.id}/2x`;
- addEmoteTo(map, emote.code, url);
- });
- return map;
- }
- // Adds a channel's FFZ + BTTV emotes as a new layer and re-merges. Throws
- // with a user-facing message if the name doesn't resolve on either service,
- // or if it's already added - callers show that message directly.
- async function addChannel(rawName) {
- const name = rawName.trim().toLowerCase();
- if (!name) throw new Error('Enter a channel name.');
- if (channelEmoteMaps.has(name)) throw new Error(`"${rawName}" is already added.`);
- const [ffzMap, bttvMap] = await Promise.all([
- fetchFFZChannelEmotes(name).catch(() => new Map()),
- fetchBTTVChannelEmotes(name).catch(() => new Map())
- ]);
- if (ffzMap.size === 0 && bttvMap.size === 0) {
- throw new Error(`No FFZ or BTTV emotes found for "${rawName}".`);
- }
- const merged = new Map([...ffzMap, ...bttvMap]);
- channelEmoteMaps.set(name, merged);
- saveChannels();
- rebuildEmoteMap();
- return { ffz: ffzMap.size, bttv: bttvMap.size };
- }
- function removeChannel(name) {
- channelEmoteMaps.delete(name);
- saveChannels();
- rebuildEmoteMap();
- }
- function saveChannels() {
- try {
- GM_setValue(CHANNELS_KEY, JSON.stringify([...channelEmoteMaps.keys()]));
- } catch (e) {
- console.warn('[HF Emotes] failed to save channels', e);
- }
- }
- async function loadSavedChannels() {
- let names = [];
- try {
- names = JSON.parse(GM_getValue(CHANNELS_KEY, '[]'));
- if (!Array.isArray(names)) names = [];
- } catch (e) {
- names = [];
- }
- await Promise.all(names.map(async (name) => {
- try {
- const [ffzMap, bttvMap] = await Promise.all([
- fetchFFZChannelEmotes(name).catch(() => new Map()),
- fetchBTTVChannelEmotes(name).catch(() => new Map())
- ]);
- channelEmoteMaps.set(name, new Map([...ffzMap, ...bttvMap]));
- } catch (e) {
- console.warn(`[HF Emotes] failed to load saved channel "${name}"`, e);
- }
- }));
- }
- // Surfaces a failure to the user instead of a silent no-op, since a click
- // or a channel lookup that appears to do nothing is exactly what prompted
- // this - falls back to console if jGrowl (loaded site-wide already) isn't there.
- function notifyEmoteError(message) {
- try {
- if (typeof $ !== 'undefined' && $.jGrowl) {
- $.jGrowl(message, { theme: 'jgrowl_error' });
- return;
- }
- } catch (e) { /* fall through to console */ }
- console.warn('[HF Emotes]', message);
- }
- // ---------------------------------------------------------------------
- // Rendering emotes in posted chat messages
- // ---------------------------------------------------------------------
- function styleEmoteImg(img) {
- img.style.width = EMOTE_SIZE + 'px';
- img.style.height = EMOTE_SIZE + 'px';
- img.style.objectFit = 'contain';
- img.style.verticalAlign = 'middle';
- img.style.cursor = 'pointer'; // clickable - see installMessageEmoteClickToInsert
- }
- function replaceEmotesInNode(node) {
- const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, null, false);
- const textNodes = [];
- let n;
- while ((n = walker.nextNode())) {
- if (EMOTE_TOKEN_REGEX.test(n.nodeValue)) {
- textNodes.push(n);
- }
- EMOTE_TOKEN_REGEX.lastIndex = 0;
- }
- textNodes.forEach(textNode => {
- const text = textNode.nodeValue;
- EMOTE_TOKEN_REGEX.lastIndex = 0;
- let match;
- let lastIndex = 0;
- const frag = document.createDocumentFragment();
- let matched = false;
- while ((match = EMOTE_TOKEN_REGEX.exec(text)) !== null) {
- const name = match[1].toLowerCase();
- const url = emoteMap.get(name);
- if (!url) continue;
- matched = true;
- frag.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
- const img = document.createElement('img');
- img.src = url;
- img.alt = match[0];
- img.title = match[1];
- styleEmoteImg(img);
- img.className = 'hf-emote';
- frag.appendChild(img);
- lastIndex = EMOTE_TOKEN_REGEX.lastIndex;
- recordRecentUse(name); // seeing it rendered in chat counts as "used"
- }
- if (matched) {
- frag.appendChild(document.createTextNode(text.slice(lastIndex)));
- textNode.parentNode.replaceChild(frag, textNode);
- }
- });
- }
- function processExistingMessages() {
- document.querySelectorAll(CHAT_CONTAINER_SELECTOR).forEach(container => {
- replaceEmotesInNode(container);
- });
- }
- // Some pages/extensions might swap the underlying <img src> after insertion,
- // or emotes might arrive as raw <img> already (e.g. pasted). Force size on any hf-emote.
- function enforceEmoteSizing(root) {
- (root || document).querySelectorAll('img.hf-emote').forEach(styleEmoteImg);
- }
- function observeChat() {
- const containers = document.querySelectorAll(CHAT_CONTAINER_SELECTOR);
- if (containers.length === 0) {
- setTimeout(observeChat, 1000);
- return;
- }
- containers.forEach(container => {
- const observer = new MutationObserver(mutations => {
- mutations.forEach(mutation => {
- mutation.addedNodes.forEach(node => {
- if (node.nodeType === Node.ELEMENT_NODE) {
- replaceEmotesInNode(node);
- enforceEmoteSizing(node);
- } else if (node.nodeType === Node.TEXT_NODE) {
- replaceEmotesInNode(node.parentNode);
- }
- });
- });
- });
- observer.observe(container, { childList: true, subtree: true });
- });
- processExistingMessages();
- enforceEmoteSizing();
- }
- // Clicking an emote as it appears in a posted message (not the recents bar,
- // and not one already sitting in our own compose editor - those are handled
- // by their own listeners) inserts that same emote into the compose box.
- // A single document-level listener covers every message container and
- // survives Convo re-rendering the DOM around individual messages.
- function installMessageEmoteClickToInsert() {
- document.addEventListener('mousedown', (e) => {
- const img = e.target.closest && e.target.closest('img.hf-emote');
- if (!img) return;
- if (img.classList.contains('hf-emote-recent') || img.classList.contains('hf-emote-inline')) return;
- if (!editorEl) return; // compose editor isn't mounted yet, nothing to insert into
- const name = (img.title || '').toLowerCase();
- if (!name || !emoteMap.has(name)) return;
- e.preventDefault();
- e.stopPropagation();
- insertEmoteAtCaret(name);
- });
- }
- // ---------------------------------------------------------------------
- // Recently used emotes
- // ---------------------------------------------------------------------
- function loadRecents() {
- try {
- const raw = GM_getValue(RECENTS_KEY, '[]');
- const arr = JSON.parse(raw);
- return Array.isArray(arr) ? arr : [];
- } catch (e) {
- return [];
- }
- }
- function saveRecents(arr) {
- try {
- GM_setValue(RECENTS_KEY, JSON.stringify(arr));
- } catch (e) {
- console.warn('[HF Emotes] failed to save recents', e);
- }
- }
- let recents = loadRecents();
- function recordRecentUse(name) {
- name = name.toLowerCase();
- if (!emoteMap.has(name)) return;
- recents = recents.filter(n => n !== name);
- recents.unshift(name);
- if (recents.length > MAX_RECENTS) recents.length = MAX_RECENTS;
- saveRecents(recents);
- renderRecentsBar();
- }
- let recentsBarEl = null;
- let recentsSectionEl = null; // wrapper: "Recent Emotes" label + the bar itself
- function renderRecentsBar() {
- if (!recentsBarEl || !recentsSectionEl) return;
- recentsBarEl.innerHTML = '';
- if (recents.length === 0) {
- recentsSectionEl.style.display = 'none';
- return;
- }
- recentsSectionEl.style.display = 'block';
- recents.forEach(name => {
- const url = emoteMap.get(name);
- if (!url) return;
- const btn = document.createElement('img');
- btn.src = url;
- btn.title = name;
- btn.alt = name;
- btn.className = 'hf-emote-recent';
- btn.style.width = EMOTE_SIZE + 'px';
- btn.style.height = EMOTE_SIZE + 'px';
- btn.style.objectFit = 'contain';
- btn.style.verticalAlign = 'middle';
- btn.style.cursor = 'pointer';
- btn.style.margin = '2px';
- // mousedown + preventDefault (not click) so the compose editor never loses
- // focus/selection - that loss was why clicking silently failed before.
- btn.addEventListener('mousedown', (e) => {
- e.preventDefault();
- e.stopPropagation(); // don't let a host-page mousedown handler steal the selection first
- insertEmoteAtCaret(name);
- });
- recentsBarEl.appendChild(btn);
- });
- }
- // Mounts the recents bar as a SIBLING placed right after `.us-content`,
- // not as a child of it. Convo.groupChatUsersUpdate walks `.us-content >
- // div` and indexes into Convo.rankDetails by each child's `data-gid` to
- // decide where new rank-group headers go; an element living in there
- // without a `data-gid` makes that lookup return undefined and throws,
- // which can abort the update mid-way - this is what looked like the
- // recents bar "hiding" online users. Living outside `.us-content`
- // entirely avoids that, and naturally keeps this section below every
- // rank group and every user in it, since it comes after the whole block
- // rather than being interleaved inside it. Note: this section starts
- // hidden and only appears once at least one emote has actually been
- // used - an empty recents list on a fresh install/profile is expected,
- // not a bug.
- function mountRecentsBar() {
- // The userlist sidebar only exists for group convos - Convo only
- // inserts it when convo_party_id == -1. A direct message never gets
- // one, so don't poll for it forever.
- if (typeof convo_party_id !== 'undefined' && convo_party_id != -1) return;
- const container = document.querySelector('.us-content');
- if (!container) {
- setTimeout(mountRecentsBar, 1000);
- return;
- }
- if (recentsSectionEl && recentsSectionEl.isConnected) return; // already mounted
- // `.us-content` ships with a large inline padding-bottom (250px in
- // the site's own markup) that was presumably sized for its own
- // content. Since our section is a sibling placed right after it,
- // that padding just becomes dead scrollable space above us instead
- // of pushing anything meaningful down - shrink it so "Recent
- // Emotes" appears right under the last user instead of requiring a
- // scroll to reach.
- container.style.paddingBottom = '8px';
- recentsSectionEl = document.createElement('div');
- recentsSectionEl.id = 'hf-emotes-recents-section';
- recentsSectionEl.style.display = 'none'; // shown once there's at least one recent
- recentsSectionEl.style.padding = '6px 10px 10px';
- recentsSectionEl.style.borderTop = '1px solid #272727';
- const label = document.createElement('div');
- label.textContent = 'Recent Emotes';
- label.style.fontSize = '11px';
- label.style.fontWeight = 'bold';
- label.style.textTransform = 'uppercase';
- label.style.letterSpacing = '0.5px';
- label.style.color = '#888';
- label.style.marginBottom = '4px';
- recentsSectionEl.appendChild(label);
- recentsBarEl = document.createElement('div');
- recentsBarEl.id = 'hf-emotes-recents-bar';
- recentsBarEl.style.display = 'flex';
- recentsBarEl.style.flexWrap = 'wrap';
- recentsBarEl.style.gap = '2px';
- recentsSectionEl.appendChild(recentsBarEl);
- // Always lands right after `.us-content` - i.e. beneath every rank
- // group and every member in it - and stays completely outside the
- // subtree Convo's own userlist code manages.
- container.insertAdjacentElement('afterend', recentsSectionEl);
- renderRecentsBar();
- }
- // ---------------------------------------------------------------------
- // Compose editor: a contenteditable box that mirrors the real #comment
- // textarea. Emotes are shown as actual <img> elements while composing;
- // the real textarea (what actually gets submitted) is kept in sync by
- // serializing those images back to ":name:" tokens on every change.
- // ---------------------------------------------------------------------
- let sourceTextarea = null;
- let editorEl = null;
- // True while WE are the ones writing to sourceTextarea.value, so our own
- // value-watcher (below) doesn't rebuild the editor in response to its own writes.
- let syncingFromEditor = false;
- // Caret position captured while the user was last typing/clicking inside
- // the editor. Some host pages run their own mousedown/click handlers that
- // steal the selection before our insert logic runs; preventDefault() on
- // our own listeners can't stop those other handlers, so we keep this as a
- // fallback anchor point instead of trusting live selection state alone.
- let lastKnownRange = null;
- function getTextarea() {
- return document.getElementById('comment');
- }
- // The site's own code (e.g. clearing the box after a message sends) writes
- // straight to sourceTextarea.value, bypassing our editor entirely. This
- // intercepts that so the visible editor stays in sync with the real value.
- function installTextareaValueWatcher(textarea) {
- if (textarea.dataset.hfEmotesValueWatched) return;
- textarea.dataset.hfEmotesValueWatched = '1';
- const proto = Object.getPrototypeOf(textarea);
- const desc = Object.getOwnPropertyDescriptor(proto, 'value');
- if (!desc || !desc.set || !desc.get) return; // unexpected environment; skip quietly
- Object.defineProperty(textarea, 'value', {
- configurable: true,
- enumerable: desc.enumerable,
- get() {
- return desc.get.call(textarea);
- },
- set(v) {
- desc.set.call(textarea, v);
- if (!syncingFromEditor && editorEl) {
- deserializeTextToEditor(v);
- }
- }
- });
- }
- function saveCurrentRangeIfValid() {
- const sel = window.getSelection();
- if (sel && sel.rangeCount > 0) {
- const r = sel.getRangeAt(0);
- if (r.collapsed && r.startContainer.nodeType === Node.TEXT_NODE &&
- (editorEl.contains(r.startContainer))) {
- lastKnownRange = r.cloneRange();
- }
- }
- }
- function createInlineEmoteImg(name) {
- const img = document.createElement('img');
- img.src = emoteMap.get(name);
- img.alt = `:${name}:`;
- img.title = name;
- img.className = 'hf-emote hf-emote-inline';
- img.dataset.emoteName = name;
- img.contentEditable = 'false';
- img.draggable = false;
- img.style.width = INLINE_EMOTE_SIZE + 'px';
- img.style.height = INLINE_EMOTE_SIZE + 'px';
- img.style.objectFit = 'contain';
- img.style.verticalAlign = 'middle';
- img.style.display = 'inline-block';
- img.style.margin = '0 1px';
- return img;
- }
- // Walks the editor's DOM and turns it back into plain text, converting
- // inline emote <img> nodes back into ":name:" tokens.
- function serializeEditorToText(root) {
- let out = '';
- function walk(node) {
- node.childNodes.forEach(child => {
- if (child.nodeType === Node.TEXT_NODE) {
- out += child.data;
- } else if (child.nodeType === Node.ELEMENT_NODE) {
- if (child.tagName === 'IMG' && child.classList.contains('hf-emote-inline')) {
- out += `:${child.dataset.emoteName}:`;
- } else if (child.tagName === 'BR') {
- out += '\n';
- } else if (child.tagName === 'DIV' || child.tagName === 'P') {
- // Browsers wrap each new line in its own DIV/P inside contenteditable.
- if (out.length > 0 && !out.endsWith('\n')) out += '\n';
- walk(child);
- } else {
- walk(child);
- }
- }
- });
- }
- walk(root);
- return out.replace(/\u00A0/g, ' '); // normalize inserted non-breaking spaces
- }
- function syncEditorToTextarea() {
- if (!sourceTextarea || !editorEl) return;
- syncingFromEditor = true;
- sourceTextarea.value = serializeEditorToText(editorEl);
- syncingFromEditor = false;
- sourceTextarea.dispatchEvent(new Event('input', { bubbles: true }));
- sourceTextarea.dispatchEvent(new Event('change', { bubbles: true }));
- }
- // Builds the editor's initial contents from whatever text is already in
- // the textarea (e.g. a quoted reply), rendering any known tokens as images.
- function deserializeTextToEditor(text) {
- editorEl.innerHTML = '';
- if (!text) return;
- EMOTE_TOKEN_REGEX.lastIndex = 0;
- let match;
- let lastIndex = 0;
- while ((match = EMOTE_TOKEN_REGEX.exec(text)) !== null) {
- const name = match[1].toLowerCase();
- const before = text.slice(lastIndex, match.index);
- if (before) editorEl.appendChild(document.createTextNode(before));
- if (emoteMap.has(name)) {
- editorEl.appendChild(createInlineEmoteImg(name));
- editorEl.appendChild(document.createTextNode('\u00A0'));
- } else {
- editorEl.appendChild(document.createTextNode(match[0]));
- }
- lastIndex = EMOTE_TOKEN_REGEX.lastIndex;
- }
- editorEl.appendChild(document.createTextNode(text.slice(lastIndex)));
- }
- // Returns the current selection's range if it's inside the editor,
- // otherwise falls back to the end of the editor's content.
- function getCurrentEditorRange() {
- const sel = window.getSelection();
- if (sel && sel.rangeCount > 0) {
- const r = sel.getRangeAt(0);
- if (editorEl.contains(r.commonAncestorContainer) || r.commonAncestorContainer === editorEl) {
- return r.cloneRange();
- }
- }
- // Live selection isn't usable (lost focus, or landed outside the
- // editor) - fall back to wherever the caret last was while typing.
- if (lastKnownRange && editorEl.contains(lastKnownRange.startContainer)) {
- return lastKnownRange.cloneRange();
- }
- const r = document.createRange();
- r.selectNodeContents(editorEl);
- r.collapse(false);
- return r;
- }
- // Given a collapsed range whose caret sits right after a typed partial
- // token like ":cat", expands the range to cover that token's text so it
- // can be deleted and replaced with the emote image.
- function expandRangeOverPartialToken(range) {
- if (!range.collapsed) return range;
- const node = range.startContainer;
- if (node.nodeType !== Node.TEXT_NODE) return null;
- const offset = range.startOffset;
- const textBefore = node.data.slice(0, offset);
- const m = textBefore.match(PARTIAL_TOKEN_REGEX);
- if (!m) return null;
- const r = document.createRange();
- r.setStart(node, offset - m[0].length);
- r.setEnd(node, offset);
- return r;
- }
- // Inserts the given emote as an inline <img> at the caret (or at the end
- // of the editor if nothing is focused there). If replacePartialToken is
- // set, first deletes the ":xyz" text the user was typing - used by direct
- // ":xyz:" -> emote replacement in chat/editor text. If explicitRange is
- // given (used by the autocomplete popup), that exact range is used as-is
- // instead of recomputing anything from live selection state - see the
- // comment on acPartialRange for why that recomputation was unreliable.
- function insertEmoteAtCaret(name, { replacePartialToken = false, explicitRange = null } = {}) {
- if (!editorEl) {
- notifyEmoteError('Could not insert the emote - the compose box isn\'t ready yet.');
- return;
- }
- if (!emoteMap.has(name)) {
- notifyEmoteError(`":${name}:" isn't a known emote anymore.`);
- return;
- }
- let range = explicitRange;
- if (!range) {
- range = getCurrentEditorRange();
- if (replacePartialToken) {
- const expanded = expandRangeOverPartialToken(range);
- if (!expanded) return; // couldn't find the token being typed; don't guess
- range = expanded;
- }
- }
- const frag = document.createDocumentFragment();
- frag.appendChild(createInlineEmoteImg(name));
- const space = document.createTextNode('\u00A0');
- frag.appendChild(space);
- range.deleteContents();
- range.insertNode(frag);
- const sel = window.getSelection();
- const newRange = document.createRange();
- newRange.setStartAfter(space);
- newRange.collapse(true);
- sel.removeAllRanges();
- sel.addRange(newRange);
- editorEl.focus();
- syncEditorToTextarea();
- recordRecentUse(name);
- }
- // ---------------------------------------------------------------------
- // Autocomplete
- // ---------------------------------------------------------------------
- let acPopup = null;
- let acMatches = [];
- let acSelectedIndex = -1;
- // Captured the instant a partial token like ":cat" is detected in
- // updateAutocomplete(), while we know exactly where it sits. Clicking a
- // match reuses this directly instead of re-deriving the range from
- // live/last-known selection at click time - that recomputation was
- // unreliable because every mouseenter over a row rebuilds the whole
- // popup's DOM, and by click time the live selection no longer reliably
- // matched the ":partial" text, causing a silent no-op insert (the popup
- // would close, but nothing would be inserted).
- let acPartialRange = null;
- function createAcPopup() {
- const el = document.createElement('div');
- el.id = 'hf-emotes-autocomplete';
- el.style.position = 'absolute';
- el.style.zIndex = '999999';
- el.style.background = '#1e1e1e';
- el.style.border = '1px solid #444';
- el.style.borderRadius = '4px';
- el.style.padding = '4px';
- el.style.display = 'none';
- el.style.maxHeight = '220px';
- el.style.overflowY = 'auto';
- el.style.boxShadow = '0 4px 10px rgba(0,0,0,0.4)';
- document.body.appendChild(el);
- return el;
- }
- function positionAcPopup() {
- const rect = editorEl.getBoundingClientRect();
- acPopup.style.left = (rect.left + window.scrollX) + 'px';
- acPopup.style.top = (rect.top + window.scrollY - acPopup.offsetHeight - 4) + 'px';
- acPopup.style.minWidth = Math.min(rect.width, 280) + 'px';
- }
- function closeAcPopup() {
- acPopup.style.display = 'none';
- acMatches = [];
- acSelectedIndex = -1;
- acPartialRange = null;
- }
- function applyAcSelection(index) {
- const name = acMatches[index];
- if (!name || !acPartialRange) return;
- try {
- insertEmoteAtCaret(name, { explicitRange: acPartialRange });
- } catch (err) {
- notifyEmoteError('Could not insert that emote.');
- console.error('[HF Emotes] insert from autocomplete failed', err);
- }
- closeAcPopup();
- }
- function renderAcPopup() {
- acPopup.innerHTML = '';
- acMatches.forEach((name, i) => {
- const row = document.createElement('div');
- row.className = 'hf-ac-row';
- row.style.display = 'flex';
- row.style.alignItems = 'center';
- row.style.gap = '6px';
- row.style.padding = '3px 6px';
- row.style.borderRadius = '3px';
- row.style.cursor = 'pointer';
- row.style.background = i === acSelectedIndex ? '#3a3a3a' : 'transparent';
- const img = document.createElement('img');
- img.src = emoteMap.get(name);
- img.style.width = '20px';
- img.style.height = '20px';
- img.style.objectFit = 'contain';
- const label = document.createElement('span');
- label.textContent = name;
- label.style.color = '#eee';
- label.style.fontSize = '12px';
- label.style.fontFamily = 'monospace';
- row.appendChild(img);
- row.appendChild(label);
- row.addEventListener('mouseenter', () => {
- acSelectedIndex = i;
- renderAcPopup();
- });
- row.addEventListener('mousedown', (e) => {
- e.preventDefault(); // keep the editor's focus/selection intact
- e.stopPropagation(); // don't let a host-page mousedown handler steal the selection first
- applyAcSelection(i);
- });
- // mousedown already did the insert - these just stop the click/mouseup
- // that inevitably follows from reaching any host-page "click outside
- // closes/resets this" handler and undoing what we just inserted.
- row.addEventListener('mouseup', (e) => {
- e.preventDefault();
- e.stopPropagation();
- });
- row.addEventListener('click', (e) => {
- e.preventDefault();
- e.stopPropagation();
- });
- acPopup.appendChild(row);
- });
- acPopup.style.display = acMatches.length ? 'block' : 'none';
- if (acMatches.length) positionAcPopup();
- }
- function updateAutocomplete() {
- const range = getCurrentEditorRange();
- if (!range || !range.collapsed || range.startContainer.nodeType !== Node.TEXT_NODE) {
- closeAcPopup();
- return;
- }
- const textBefore = range.startContainer.data.slice(0, range.startOffset);
- const m = textBefore.match(PARTIAL_TOKEN_REGEX);
- if (!m || m[1].length < MIN_AUTOCOMPLETE_CHARS) {
- closeAcPopup();
- return;
- }
- // Snapshot the exact ":partial" text's range right now, while its
- // position relative to the caret is certain. This is what actually
- // gets deleted/replaced on click or Enter/Tab - see acPartialRange.
- const tokenRange = document.createRange();
- tokenRange.setStart(range.startContainer, range.startOffset - m[0].length);
- tokenRange.setEnd(range.startContainer, range.startOffset);
- acPartialRange = tokenRange;
- const query = m[1].toLowerCase();
- acMatches = emoteNamesSorted.filter(name => name.startsWith(query)).slice(0, 30);
- acSelectedIndex = acMatches.length ? 0 : -1;
- renderAcPopup();
- }
- // Replays an Enter keystroke on the real textarea so any send-on-Enter
- // handler the site has bound directly to it still fires, even though the
- // user is actually typing into our contenteditable overlay.
- function makeEnterKeyboardEvent(type) {
- const evt = new KeyboardEvent(type, {
- key: 'Enter',
- code: 'Enter',
- bubbles: true,
- cancelable: true
- });
- // keyCode/which aren't settable via the constructor in every browser;
- // force them since older-style handlers often check these specifically.
- try {
- Object.defineProperty(evt, 'keyCode', { get: () => 13 });
- Object.defineProperty(evt, 'which', { get: () => 13 });
- } catch (e) { /* already non-configurable in this browser; ignore */ }
- return evt;
- }
- function forwardEnterToTextarea() {
- if (!sourceTextarea) return;
- ['keydown', 'keypress', 'keyup'].forEach(type => {
- sourceTextarea.dispatchEvent(makeEnterKeyboardEvent(type));
- });
- }
- function handleEditorKeydown(e) {
- const acOpen = acPopup.style.display !== 'none' && acMatches.length > 0;
- if (acOpen) {
- if (e.key === 'ArrowDown') {
- e.preventDefault();
- acSelectedIndex = (acSelectedIndex + 1) % acMatches.length;
- renderAcPopup();
- return;
- }
- if (e.key === 'ArrowUp') {
- e.preventDefault();
- acSelectedIndex = (acSelectedIndex - 1 + acMatches.length) % acMatches.length;
- renderAcPopup();
- return;
- }
- if (e.key === 'Escape') {
- closeAcPopup();
- return;
- }
- if ((e.key === 'Enter' || e.key === 'Tab') && acSelectedIndex >= 0) {
- e.preventDefault();
- applyAcSelection(acSelectedIndex);
- return;
- }
- }
- // Plain Enter (not picking an autocomplete match) sends the message,
- // matching how the original plain textarea behaved. Shift+Enter still
- // inserts a real newline for multi-line composition.
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- syncEditorToTextarea();
- forwardEnterToTextarea();
- }
- }
- // ---------------------------------------------------------------------
- // Mounting the editor over the real textarea
- // ---------------------------------------------------------------------
- // contenteditable elements have no native `placeholder` attribute, so
- // hiding the real textarea (below) silently drops its placeholder text.
- // This injects one shared CSS rule - based on ::placeholder if the site
- // styles it, otherwise a sane default - and applies it via a data
- // attribute + :empty, so the placeholder shows/hides exactly like the
- // native one did (visible only when there's no text and no emote).
- let placeholderStyleInstalled = false;
- function installPlaceholderStyle(sourceTextarea) {
- if (placeholderStyleInstalled) return;
- placeholderStyleInstalled = true;
- let color = 'rgba(150, 150, 150, 0.8)';
- let fontStyle = 'inherit';
- let opacity = '1';
- try {
- const ph = window.getComputedStyle(sourceTextarea, '::placeholder');
- if (ph && ph.color) color = ph.color;
- if (ph && ph.fontStyle) fontStyle = ph.fontStyle;
- if (ph && ph.opacity) opacity = ph.opacity;
- } catch (e) { /* some browsers don't support querying pseudo-elements this way */ }
- const style = document.createElement('style');
- style.textContent = `
- #hf-emotes-editor:empty::before {
- content: attr(data-placeholder);
- color: ${color};
- font-style: ${fontStyle};
- opacity: ${opacity};
- pointer-events: none;
- }
- `;
- document.head.appendChild(style);
- }
- // Contenteditable often leaves a stray empty text node or <br> behind
- // after the user deletes everything, which would stop :empty (and so
- // the placeholder) from matching even though nothing is left to see.
- function normalizeEmptyEditor() {
- if (editorEl.textContent === '' && !editorEl.querySelector('img')) {
- editorEl.innerHTML = '';
- }
- }
- function mountEditor() {
- sourceTextarea = getTextarea();
- if (!sourceTextarea) return;
- if (sourceTextarea.dataset.hfEmotesMounted) return;
- sourceTextarea.dataset.hfEmotesMounted = '1';
- editorEl = document.createElement('div');
- editorEl.id = 'hf-emotes-editor';
- editorEl.contentEditable = 'true';
- editorEl.className = 'hf-emotes-editor';
- editorEl.dataset.placeholder = sourceTextarea.getAttribute('placeholder') || '';
- installPlaceholderStyle(sourceTextarea);
- // Mirror the real textarea's look so it doesn't stand out visually.
- const cs = window.getComputedStyle(sourceTextarea);
- ['font', 'lineHeight', 'padding', 'border', 'borderRadius', 'background', 'color'].forEach(prop => {
- editorEl.style[prop] = cs[prop];
- });
- editorEl.style.width = sourceTextarea.offsetWidth ? sourceTextarea.offsetWidth + 'px' : '100%';
- editorEl.style.minHeight = sourceTextarea.offsetHeight ? sourceTextarea.offsetHeight + 'px' : '4em';
- editorEl.style.overflowY = 'auto';
- editorEl.style.whiteSpace = 'pre-wrap';
- editorEl.style.wordBreak = 'break-word';
- editorEl.style.boxSizing = 'border-box';
- sourceTextarea.style.display = 'none';
- sourceTextarea.insertAdjacentElement('afterend', editorEl);
- installTextareaValueWatcher(sourceTextarea);
- deserializeTextToEditor(sourceTextarea.value);
- editorEl.addEventListener('input', () => {
- normalizeEmptyEditor();
- saveCurrentRangeIfValid();
- syncEditorToTextarea();
- updateAutocomplete();
- });
- editorEl.addEventListener('click', () => {
- saveCurrentRangeIfValid();
- updateAutocomplete();
- });
- editorEl.addEventListener('keyup', saveCurrentRangeIfValid);
- editorEl.addEventListener('keydown', handleEditorKeydown);
- editorEl.addEventListener('blur', () => setTimeout(closeAcPopup, 150));
- const form = sourceTextarea.closest('form');
- if (form) form.addEventListener('submit', syncEditorToTextarea);
- }
- function watchForComposeBox() {
- mountEditor();
- if (!editorEl) {
- setTimeout(watchForComposeBox, 1000);
- return;
- }
- // Re-mount if the site's own JS ever swaps the textarea out (e.g. AJAX page load).
- const bodyObserver = new MutationObserver(() => {
- const t = getTextarea();
- if (t && !t.dataset.hfEmotesMounted) mountEditor();
- });
- bodyObserver.observe(document.body, { childList: true, subtree: true });
- }
- // ---------------------------------------------------------------------
- // Channel manager: a small popover for adding/removing FFZ + BTTV emote
- // sets from specific Twitch channels, since the global sets don't cover
- // everything. Anchored in the top toolbar next to the other page-level
- // icons (home / group chat / compose / export / fullscreen), right
- // before the fullscreen toggle, rather than the compose-area attach
- // button - it reads more like a page setting than a per-message action.
- // ---------------------------------------------------------------------
- let channelButtonEl = null;
- let channelPopupEl = null;
- function buildChannelManagerButton() {
- const anchor = document.getElementById('convo_fullscreen_toggle');
- if (!anchor) {
- setTimeout(buildChannelManagerButton, 1000);
- return;
- }
- if (channelButtonEl && channelButtonEl.isConnected) return;
- channelButtonEl = document.createElement('a');
- channelButtonEl.href = 'javascript:void(0);';
- channelButtonEl.id = 'hf-emotes-channel-button';
- channelButtonEl.title = 'Emote Channels';
- channelButtonEl.setAttribute('data-tooltip', 'Emote Channels');
- // Match the styling/markup pattern of the other toolbar icons exactly.
- Object.assign(channelButtonEl.style, {
- display: 'inline-block',
- lineHeight: '37px',
- padding: '0px 15px'
- });
- // fa-terminal matches HF's own hacker/terminal branding better than a
- // generic smiley, and sits naturally alongside the other flat FA
- // icons already used in this toolbar (fa-bars, fa-home, fa-users...).
- channelButtonEl.innerHTML = '<i class="fa fa-terminal" aria-hidden="true"></i>';
- anchor.insertAdjacentElement('beforebegin', channelButtonEl);
- channelButtonEl.addEventListener('click', (e) => {
- e.preventDefault();
- e.stopPropagation();
- toggleChannelPopup();
- });
- }
- function toggleChannelPopup() {
- if (channelPopupEl && channelPopupEl.isConnected) {
- closeChannelPopup();
- } else {
- openChannelPopup();
- }
- }
- function handleOutsideChannelPopupClick(e) {
- if (!channelPopupEl) return;
- // channelButtonEl.contains(...) rather than a strict === check, since
- // a click can land on the <i> icon inside the button and still needs
- // to count as "on the button", not "outside the popup".
- if (channelPopupEl.contains(e.target) || (channelButtonEl && channelButtonEl.contains(e.target))) return;
- closeChannelPopup();
- }
- function closeChannelPopup() {
- if (channelPopupEl) {
- channelPopupEl.remove();
- channelPopupEl = null;
- }
- document.removeEventListener('mousedown', handleOutsideChannelPopupClick, true);
- }
- function positionChannelPopup() {
- if (!channelPopupEl || !channelButtonEl) return;
- const rect = channelButtonEl.getBoundingClientRect();
- // Anchor from the right edge of the button instead of the left, since
- // this button now sits at the far right of the toolbar and a
- // left-anchored popup would run off the edge of the page.
- channelPopupEl.style.right = (window.innerWidth - rect.right - window.scrollX) + 'px';
- channelPopupEl.style.left = 'auto';
- channelPopupEl.style.top = (rect.bottom + window.scrollY + 6) + 'px';
- }
- function renderChannelChips(list) {
- list.innerHTML = '';
- if (channelEmoteMaps.size === 0) {
- const empty = document.createElement('div');
- empty.textContent = 'No channels added yet.';
- empty.style.color = '#888';
- list.appendChild(empty);
- return;
- }
- channelEmoteMaps.forEach((_, name) => {
- const chip = document.createElement('span');
- Object.assign(chip.style, {
- display: 'inline-flex',
- alignItems: 'center',
- gap: '4px',
- background: '#333',
- borderRadius: '10px',
- padding: '2px 8px'
- });
- const label = document.createElement('span');
- label.textContent = name;
- chip.appendChild(label);
- const remove = document.createElement('span');
- remove.textContent = '\u00D7';
- remove.title = `Remove ${name}`;
- remove.style.cursor = 'pointer';
- remove.style.fontWeight = 'bold';
- remove.addEventListener('click', () => {
- removeChannel(name);
- renderChannelChips(list);
- });
- chip.appendChild(remove);
- list.appendChild(chip);
- });
- }
- function openChannelPopup() {
- channelPopupEl = document.createElement('div');
- channelPopupEl.id = 'hf-emotes-channel-popup';
- Object.assign(channelPopupEl.style, {
- position: 'absolute',
- zIndex: '999999',
- background: '#1e1e1e',
- border: '1px solid #444',
- borderRadius: '6px',
- padding: '10px',
- width: '240px',
- boxShadow: '0 4px 10px rgba(0,0,0,0.4)',
- color: '#eee',
- fontSize: '13px'
- });
- const title = document.createElement('div');
- title.textContent = 'Emote channels (FFZ / BTTV)';
- title.style.fontWeight = 'bold';
- title.style.marginBottom = '6px';
- channelPopupEl.appendChild(title);
- const list = document.createElement('div');
- Object.assign(list.style, { display: 'flex', flexWrap: 'wrap', gap: '4px', marginBottom: '8px' });
- channelPopupEl.appendChild(list);
- renderChannelChips(list);
- const inputRow = document.createElement('div');
- inputRow.style.display = 'flex';
- inputRow.style.gap = '4px';
- const input = document.createElement('input');
- input.type = 'text';
- input.placeholder = 'Twitch channel name';
- Object.assign(input.style, {
- flex: '1', minWidth: '0', background: '#111', border: '1px solid #333',
- borderRadius: '4px', color: '#eee', padding: '4px 6px'
- });
- const addBtn = document.createElement('button');
- addBtn.type = 'button';
- addBtn.textContent = 'Add';
- Object.assign(addBtn.style, {
- background: '#3a3a3a', border: '1px solid #555', borderRadius: '4px',
- color: '#eee', cursor: 'pointer', padding: '4px 8px'
- });
- const status = document.createElement('div');
- Object.assign(status.style, { marginTop: '6px', minHeight: '14px', color: '#aaa' });
- async function submit() {
- const name = input.value.trim();
- if (!name) return;
- addBtn.disabled = true;
- status.style.color = '#aaa';
- status.textContent = `Looking up ${name}...`;
- try {
- const result = await addChannel(name);
- input.value = '';
- status.style.color = '#7bcf7b';
- status.textContent = `Added ${name} - ${result.ffz} FFZ, ${result.bttv} BTTV emotes.`;
- renderChannelChips(list);
- } catch (err) {
- status.style.color = '#d97a7a';
- status.textContent = err.message || 'Something went wrong.';
- } finally {
- addBtn.disabled = false;
- }
- }
- addBtn.addEventListener('click', submit);
- input.addEventListener('keydown', (e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- submit();
- }
- });
- // Typing/clicking in the popover shouldn't fall through to the
- // compose editor's own keydown/mousedown handlers behind it.
- input.addEventListener('mousedown', (e) => e.stopPropagation());
- inputRow.appendChild(input);
- inputRow.appendChild(addBtn);
- channelPopupEl.appendChild(inputRow);
- channelPopupEl.appendChild(status);
- document.body.appendChild(channelPopupEl);
- positionChannelPopup();
- setTimeout(() => document.addEventListener('mousedown', handleOutsideChannelPopupClick, true), 0);
- input.focus();
- }
- function installChannelManagerUI() {
- buildChannelManagerButton();
- }
- // ---------------------------------------------------------------------
- // Init
- // ---------------------------------------------------------------------
- (async function init() {
- await Promise.all([loadFFZGlobal(), loadBTTVGlobal()]);
- await loadSavedChannels();
- rebuildEmoteMap();
- // Console-only helper, no on-page UI. Useful for checking things like
- // whether a given name is actually in the loaded global set:
- // hfEmotesDebug.has('wojack')
- (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window).hfEmotesDebug = {
- list: () => [...emoteMap.keys()].sort(),
- has: (name) => emoteMap.has(String(name).toLowerCase()),
- get: (name) => emoteMap.get(String(name).toLowerCase()),
- channels: () => [...channelEmoteMaps.keys()]
- };
- acPopup = createAcPopup();
- observeChat();
- mountRecentsBar();
- watchForComposeBox();
- installMessageEmoteClickToInsert();
- installChannelManagerUI();
- })();
- })();
Advertisement
Add Comment
Please, Sign In to add comment