Guest User

Untitled

a guest
Aug 30th, 2026
23
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 52.91 KB | None | 0 0
  1. // ==UserScript==
  2. // @name HF FFZ/BTTV Emotes
  3. // @namespace hf-emotes
  4. // @version 3.6
  5. // @description Resolves :emotename: to FrankerFaceZ / BTTV emotes in Hackforums chat (shoutbox + Convo), with inline autocomplete, recents, a matching placeholder, and per-channel emote sets
  6. // @match https://hackforums.net/*
  7. // @match https://*.hackforums.net/*
  8. // @grant GM_xmlhttpRequest
  9. // @grant GM_setValue
  10. // @grant GM_getValue
  11. // @connect api.frankerfacez.com
  12. // @connect api.betterttv.net
  13. // @connect gql.twitch.tv
  14. // ==/UserScript==
  15.  
  16. (function () {
  17. 'use strict';
  18.  
  19. // ---- CONFIG ----
  20. // Includes #message-convo so emotes actually render inside Convo message
  21. // threads, not just the shoutbox and our own compose preview.
  22. const CHAT_CONTAINER_SELECTOR = '#shoutbox, .shoutbox, #chatbox, .chatbox, #message-convo';
  23. const EMOTE_SIZE = 32; // px, size for emotes rendered in posted chat messages
  24. const INLINE_EMOTE_SIZE = 20; // px, size for emotes rendered inline in the compose box
  25. const RECENTS_KEY = 'hf-emotes-recents';
  26. const CHANNELS_KEY = 'hf-emotes-channels';
  27. const MAX_RECENTS = 20;
  28. const MIN_AUTOCOMPLETE_CHARS = 3; // require ":" + at least this many letters before searching
  29. // Twitch's own web client embeds this Client-ID in every page it serves; it's the
  30. // same one FFZ/BTTV/7TV's own browser extensions use client-side to resolve a
  31. // channel *name* to the numeric Twitch ID BTTV's API requires. Read-only lookup,
  32. // no auth, nothing scraped beyond a public user ID.
  33. const TWITCH_GQL_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
  34.  
  35. const EMOTE_TOKEN_REGEX = /:([a-zA-Z0-9_]{2,30}):/g;
  36. // matches an in-progress token being typed, e.g. ":cat" with no closing colon yet
  37. const PARTIAL_TOKEN_REGEX = /:([a-zA-Z0-9_]{2,30})$/;
  38.  
  39. // Emotes are kept in layers - one Map for globals, one per added channel -
  40. // and merged into `emoteMap` (what the rest of the script reads from) so
  41. // removing a channel is just dropping its layer and re-merging, without
  42. // needing to know which entries came from where.
  43. let globalEmoteMap = new Map();
  44. let channelEmoteMaps = new Map(); // channel name (lowercase) -> Map(name -> url)
  45. let emoteMap = new Map(); // merged view: name (lowercase) -> image url
  46. let emoteNamesSorted = []; // cached sorted keys for fast prefix search
  47.  
  48. function addEmoteTo(map, name, url) {
  49. if (!name || !url) return;
  50. map.set(name.toLowerCase(), url);
  51. }
  52.  
  53. function rebuildEmoteMap() {
  54. emoteMap = new Map(globalEmoteMap);
  55. channelEmoteMaps.forEach(map => {
  56. map.forEach((url, name) => emoteMap.set(name, url));
  57. });
  58. rebuildNameCache();
  59. }
  60.  
  61. function rebuildNameCache() {
  62. emoteNamesSorted = [...emoteMap.keys()].sort();
  63. }
  64.  
  65. function fetchJSON(url) {
  66. return new Promise((resolve, reject) => {
  67. GM_xmlhttpRequest({
  68. method: 'GET',
  69. url,
  70. onload: (res) => {
  71. try {
  72. resolve(JSON.parse(res.responseText));
  73. } catch (e) {
  74. reject(e);
  75. }
  76. },
  77. onerror: reject
  78. });
  79. });
  80. }
  81.  
  82. // NOTE on "real" vs "cached" APIs: api.betterttv.net/3/cached/emotes/global is BTTV's
  83. // actual, current, and only public v3 endpoint for global emotes - "cached" is just
  84. // its name, not a stale mirror (the old v2 endpoint is gone). FFZ's v1/set/global is
  85. // likewise the live endpoint. There isn't a separate "uncached" global endpoint for
  86. // either service, so both loaders below already hit the real source.
  87. async function loadFFZGlobal() {
  88. try {
  89. const data = await fetchJSON('https://api.frankerfacez.com/v1/set/global');
  90. const sets = data.sets || {};
  91. Object.values(sets).forEach(set => {
  92. (set.emoticons || []).forEach(emote => {
  93. const urls = emote.urls;
  94. const best = urls['2'] || urls['1'] || Object.values(urls)[0];
  95. const url = best.startsWith('http') ? best : 'https:' + best;
  96. addEmoteTo(globalEmoteMap, emote.name, url);
  97. });
  98. });
  99. } catch (e) {
  100. console.warn('[HF Emotes] FFZ global load failed', e);
  101. }
  102. }
  103.  
  104. async function loadBTTVGlobal() {
  105. try {
  106. const data = await fetchJSON('https://api.betterttv.net/3/cached/emotes/global');
  107. data.forEach(emote => {
  108. const url = `https://cdn.betterttv.net/emote/${emote.id}/2x`;
  109. addEmoteTo(globalEmoteMap, emote.code, url);
  110. });
  111. } catch (e) {
  112. console.warn('[HF Emotes] BTTV global load failed', e);
  113. }
  114. }
  115.  
  116. // ---------------------------------------------------------------------
  117. // Per-channel emote sets (FFZ room + BTTV channel), on top of globals
  118. // ---------------------------------------------------------------------
  119.  
  120. // FFZ looks channels up by login name directly - no ID resolution needed.
  121. async function fetchFFZChannelEmotes(channel) {
  122. const map = new Map();
  123. const data = await fetchJSON(`https://api.frankerfacez.com/v1/room/${encodeURIComponent(channel)}`);
  124. const setId = data.room && data.room.set;
  125. const set = data.sets && data.sets[setId];
  126. (set && set.emoticons || []).forEach(emote => {
  127. const urls = emote.urls;
  128. const best = urls['2'] || urls['1'] || Object.values(urls)[0];
  129. const url = best.startsWith('http') ? best : 'https:' + best;
  130. addEmoteTo(map, emote.name, url);
  131. });
  132. return map;
  133. }
  134.  
  135. // BTTV's channel-emotes endpoint only accepts a numeric Twitch user ID, not a
  136. // name, so resolve that first via Twitch's public GraphQL endpoint.
  137. function resolveTwitchId(channel) {
  138. return new Promise((resolve, reject) => {
  139. GM_xmlhttpRequest({
  140. method: 'POST',
  141. url: 'https://gql.twitch.tv/gql',
  142. headers: {
  143. 'Content-Type': 'text/plain;charset=UTF-8',
  144. 'Client-Id': TWITCH_GQL_CLIENT_ID
  145. },
  146. data: JSON.stringify({ query: `query { user(login: "${channel.toLowerCase()}") { id } }` }),
  147. onload: (res) => {
  148. try {
  149. const json = JSON.parse(res.responseText);
  150. const id = json && json.data && json.data.user && json.data.user.id;
  151. if (id) resolve(id); else reject(new Error('twitch user not found'));
  152. } catch (e) {
  153. reject(e);
  154. }
  155. },
  156. onerror: reject
  157. });
  158. });
  159. }
  160.  
  161. async function fetchBTTVChannelEmotes(channel) {
  162. const map = new Map();
  163. let twitchId;
  164. try {
  165. twitchId = await resolveTwitchId(channel);
  166. } catch (e) {
  167. return map; // couldn't resolve an ID - FFZ may still have emotes, so don't throw
  168. }
  169.  
  170. const data = await fetchJSON(`https://api.betterttv.net/3/cached/users/twitch/${twitchId}`);
  171. [...(data.channelEmotes || []), ...(data.sharedEmotes || [])].forEach(emote => {
  172. const url = `https://cdn.betterttv.net/emote/${emote.id}/2x`;
  173. addEmoteTo(map, emote.code, url);
  174. });
  175. return map;
  176. }
  177.  
  178. // Adds a channel's FFZ + BTTV emotes as a new layer and re-merges. Throws
  179. // with a user-facing message if the name doesn't resolve on either service,
  180. // or if it's already added - callers show that message directly.
  181. async function addChannel(rawName) {
  182. const name = rawName.trim().toLowerCase();
  183. if (!name) throw new Error('Enter a channel name.');
  184. if (channelEmoteMaps.has(name)) throw new Error(`"${rawName}" is already added.`);
  185.  
  186. const [ffzMap, bttvMap] = await Promise.all([
  187. fetchFFZChannelEmotes(name).catch(() => new Map()),
  188. fetchBTTVChannelEmotes(name).catch(() => new Map())
  189. ]);
  190.  
  191. if (ffzMap.size === 0 && bttvMap.size === 0) {
  192. throw new Error(`No FFZ or BTTV emotes found for "${rawName}".`);
  193. }
  194.  
  195. const merged = new Map([...ffzMap, ...bttvMap]);
  196. channelEmoteMaps.set(name, merged);
  197. saveChannels();
  198. rebuildEmoteMap();
  199.  
  200. return { ffz: ffzMap.size, bttv: bttvMap.size };
  201. }
  202.  
  203. function removeChannel(name) {
  204. channelEmoteMaps.delete(name);
  205. saveChannels();
  206. rebuildEmoteMap();
  207. }
  208.  
  209. function saveChannels() {
  210. try {
  211. GM_setValue(CHANNELS_KEY, JSON.stringify([...channelEmoteMaps.keys()]));
  212. } catch (e) {
  213. console.warn('[HF Emotes] failed to save channels', e);
  214. }
  215. }
  216.  
  217. async function loadSavedChannels() {
  218. let names = [];
  219. try {
  220. names = JSON.parse(GM_getValue(CHANNELS_KEY, '[]'));
  221. if (!Array.isArray(names)) names = [];
  222. } catch (e) {
  223. names = [];
  224. }
  225.  
  226. await Promise.all(names.map(async (name) => {
  227. try {
  228. const [ffzMap, bttvMap] = await Promise.all([
  229. fetchFFZChannelEmotes(name).catch(() => new Map()),
  230. fetchBTTVChannelEmotes(name).catch(() => new Map())
  231. ]);
  232. channelEmoteMaps.set(name, new Map([...ffzMap, ...bttvMap]));
  233. } catch (e) {
  234. console.warn(`[HF Emotes] failed to load saved channel "${name}"`, e);
  235. }
  236. }));
  237. }
  238.  
  239. // Surfaces a failure to the user instead of a silent no-op, since a click
  240. // or a channel lookup that appears to do nothing is exactly what prompted
  241. // this - falls back to console if jGrowl (loaded site-wide already) isn't there.
  242. function notifyEmoteError(message) {
  243. try {
  244. if (typeof $ !== 'undefined' && $.jGrowl) {
  245. $.jGrowl(message, { theme: 'jgrowl_error' });
  246. return;
  247. }
  248. } catch (e) { /* fall through to console */ }
  249. console.warn('[HF Emotes]', message);
  250. }
  251.  
  252. // ---------------------------------------------------------------------
  253. // Rendering emotes in posted chat messages
  254. // ---------------------------------------------------------------------
  255.  
  256. function styleEmoteImg(img) {
  257. img.style.width = EMOTE_SIZE + 'px';
  258. img.style.height = EMOTE_SIZE + 'px';
  259. img.style.objectFit = 'contain';
  260. img.style.verticalAlign = 'middle';
  261. img.style.cursor = 'pointer'; // clickable - see installMessageEmoteClickToInsert
  262. }
  263.  
  264. function replaceEmotesInNode(node) {
  265. const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, null, false);
  266. const textNodes = [];
  267. let n;
  268. while ((n = walker.nextNode())) {
  269. if (EMOTE_TOKEN_REGEX.test(n.nodeValue)) {
  270. textNodes.push(n);
  271. }
  272. EMOTE_TOKEN_REGEX.lastIndex = 0;
  273. }
  274.  
  275. textNodes.forEach(textNode => {
  276. const text = textNode.nodeValue;
  277. EMOTE_TOKEN_REGEX.lastIndex = 0;
  278. let match;
  279. let lastIndex = 0;
  280. const frag = document.createDocumentFragment();
  281. let matched = false;
  282.  
  283. while ((match = EMOTE_TOKEN_REGEX.exec(text)) !== null) {
  284. const name = match[1].toLowerCase();
  285. const url = emoteMap.get(name);
  286. if (!url) continue;
  287.  
  288. matched = true;
  289. frag.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
  290.  
  291. const img = document.createElement('img');
  292. img.src = url;
  293. img.alt = match[0];
  294. img.title = match[1];
  295. styleEmoteImg(img);
  296. img.className = 'hf-emote';
  297.  
  298. frag.appendChild(img);
  299. lastIndex = EMOTE_TOKEN_REGEX.lastIndex;
  300.  
  301. recordRecentUse(name); // seeing it rendered in chat counts as "used"
  302. }
  303.  
  304. if (matched) {
  305. frag.appendChild(document.createTextNode(text.slice(lastIndex)));
  306. textNode.parentNode.replaceChild(frag, textNode);
  307. }
  308. });
  309. }
  310.  
  311. function processExistingMessages() {
  312. document.querySelectorAll(CHAT_CONTAINER_SELECTOR).forEach(container => {
  313. replaceEmotesInNode(container);
  314. });
  315. }
  316.  
  317. // Some pages/extensions might swap the underlying <img src> after insertion,
  318. // or emotes might arrive as raw <img> already (e.g. pasted). Force size on any hf-emote.
  319. function enforceEmoteSizing(root) {
  320. (root || document).querySelectorAll('img.hf-emote').forEach(styleEmoteImg);
  321. }
  322.  
  323. function observeChat() {
  324. const containers = document.querySelectorAll(CHAT_CONTAINER_SELECTOR);
  325. if (containers.length === 0) {
  326. setTimeout(observeChat, 1000);
  327. return;
  328. }
  329.  
  330. containers.forEach(container => {
  331. const observer = new MutationObserver(mutations => {
  332. mutations.forEach(mutation => {
  333. mutation.addedNodes.forEach(node => {
  334. if (node.nodeType === Node.ELEMENT_NODE) {
  335. replaceEmotesInNode(node);
  336. enforceEmoteSizing(node);
  337. } else if (node.nodeType === Node.TEXT_NODE) {
  338. replaceEmotesInNode(node.parentNode);
  339. }
  340. });
  341. });
  342. });
  343.  
  344. observer.observe(container, { childList: true, subtree: true });
  345. });
  346.  
  347. processExistingMessages();
  348. enforceEmoteSizing();
  349. }
  350.  
  351. // Clicking an emote as it appears in a posted message (not the recents bar,
  352. // and not one already sitting in our own compose editor - those are handled
  353. // by their own listeners) inserts that same emote into the compose box.
  354. // A single document-level listener covers every message container and
  355. // survives Convo re-rendering the DOM around individual messages.
  356. function installMessageEmoteClickToInsert() {
  357. document.addEventListener('mousedown', (e) => {
  358. const img = e.target.closest && e.target.closest('img.hf-emote');
  359. if (!img) return;
  360. if (img.classList.contains('hf-emote-recent') || img.classList.contains('hf-emote-inline')) return;
  361. if (!editorEl) return; // compose editor isn't mounted yet, nothing to insert into
  362.  
  363. const name = (img.title || '').toLowerCase();
  364. if (!name || !emoteMap.has(name)) return;
  365.  
  366. e.preventDefault();
  367. e.stopPropagation();
  368. insertEmoteAtCaret(name);
  369. });
  370. }
  371.  
  372. // ---------------------------------------------------------------------
  373. // Recently used emotes
  374. // ---------------------------------------------------------------------
  375.  
  376. function loadRecents() {
  377. try {
  378. const raw = GM_getValue(RECENTS_KEY, '[]');
  379. const arr = JSON.parse(raw);
  380. return Array.isArray(arr) ? arr : [];
  381. } catch (e) {
  382. return [];
  383. }
  384. }
  385.  
  386. function saveRecents(arr) {
  387. try {
  388. GM_setValue(RECENTS_KEY, JSON.stringify(arr));
  389. } catch (e) {
  390. console.warn('[HF Emotes] failed to save recents', e);
  391. }
  392. }
  393.  
  394. let recents = loadRecents();
  395.  
  396. function recordRecentUse(name) {
  397. name = name.toLowerCase();
  398. if (!emoteMap.has(name)) return;
  399. recents = recents.filter(n => n !== name);
  400. recents.unshift(name);
  401. if (recents.length > MAX_RECENTS) recents.length = MAX_RECENTS;
  402. saveRecents(recents);
  403. renderRecentsBar();
  404. }
  405.  
  406. let recentsBarEl = null;
  407. let recentsSectionEl = null; // wrapper: "Recent Emotes" label + the bar itself
  408.  
  409. function renderRecentsBar() {
  410. if (!recentsBarEl || !recentsSectionEl) return;
  411. recentsBarEl.innerHTML = '';
  412. if (recents.length === 0) {
  413. recentsSectionEl.style.display = 'none';
  414. return;
  415. }
  416. recentsSectionEl.style.display = 'block';
  417. recents.forEach(name => {
  418. const url = emoteMap.get(name);
  419. if (!url) return;
  420. const btn = document.createElement('img');
  421. btn.src = url;
  422. btn.title = name;
  423. btn.alt = name;
  424. btn.className = 'hf-emote-recent';
  425. btn.style.width = EMOTE_SIZE + 'px';
  426. btn.style.height = EMOTE_SIZE + 'px';
  427. btn.style.objectFit = 'contain';
  428. btn.style.verticalAlign = 'middle';
  429. btn.style.cursor = 'pointer';
  430. btn.style.margin = '2px';
  431.  
  432. // mousedown + preventDefault (not click) so the compose editor never loses
  433. // focus/selection - that loss was why clicking silently failed before.
  434. btn.addEventListener('mousedown', (e) => {
  435. e.preventDefault();
  436. e.stopPropagation(); // don't let a host-page mousedown handler steal the selection first
  437. insertEmoteAtCaret(name);
  438. });
  439.  
  440. recentsBarEl.appendChild(btn);
  441. });
  442. }
  443.  
  444. // Mounts the recents bar as a SIBLING placed right after `.us-content`,
  445. // not as a child of it. Convo.groupChatUsersUpdate walks `.us-content >
  446. // div` and indexes into Convo.rankDetails by each child's `data-gid` to
  447. // decide where new rank-group headers go; an element living in there
  448. // without a `data-gid` makes that lookup return undefined and throws,
  449. // which can abort the update mid-way - this is what looked like the
  450. // recents bar "hiding" online users. Living outside `.us-content`
  451. // entirely avoids that, and naturally keeps this section below every
  452. // rank group and every user in it, since it comes after the whole block
  453. // rather than being interleaved inside it. Note: this section starts
  454. // hidden and only appears once at least one emote has actually been
  455. // used - an empty recents list on a fresh install/profile is expected,
  456. // not a bug.
  457. function mountRecentsBar() {
  458. // The userlist sidebar only exists for group convos - Convo only
  459. // inserts it when convo_party_id == -1. A direct message never gets
  460. // one, so don't poll for it forever.
  461. if (typeof convo_party_id !== 'undefined' && convo_party_id != -1) return;
  462.  
  463. const container = document.querySelector('.us-content');
  464. if (!container) {
  465. setTimeout(mountRecentsBar, 1000);
  466. return;
  467. }
  468.  
  469. if (recentsSectionEl && recentsSectionEl.isConnected) return; // already mounted
  470.  
  471. // `.us-content` ships with a large inline padding-bottom (250px in
  472. // the site's own markup) that was presumably sized for its own
  473. // content. Since our section is a sibling placed right after it,
  474. // that padding just becomes dead scrollable space above us instead
  475. // of pushing anything meaningful down - shrink it so "Recent
  476. // Emotes" appears right under the last user instead of requiring a
  477. // scroll to reach.
  478. container.style.paddingBottom = '8px';
  479.  
  480. recentsSectionEl = document.createElement('div');
  481. recentsSectionEl.id = 'hf-emotes-recents-section';
  482. recentsSectionEl.style.display = 'none'; // shown once there's at least one recent
  483. recentsSectionEl.style.padding = '6px 10px 10px';
  484. recentsSectionEl.style.borderTop = '1px solid #272727';
  485.  
  486. const label = document.createElement('div');
  487. label.textContent = 'Recent Emotes';
  488. label.style.fontSize = '11px';
  489. label.style.fontWeight = 'bold';
  490. label.style.textTransform = 'uppercase';
  491. label.style.letterSpacing = '0.5px';
  492. label.style.color = '#888';
  493. label.style.marginBottom = '4px';
  494. recentsSectionEl.appendChild(label);
  495.  
  496. recentsBarEl = document.createElement('div');
  497. recentsBarEl.id = 'hf-emotes-recents-bar';
  498. recentsBarEl.style.display = 'flex';
  499. recentsBarEl.style.flexWrap = 'wrap';
  500. recentsBarEl.style.gap = '2px';
  501. recentsSectionEl.appendChild(recentsBarEl);
  502.  
  503. // Always lands right after `.us-content` - i.e. beneath every rank
  504. // group and every member in it - and stays completely outside the
  505. // subtree Convo's own userlist code manages.
  506. container.insertAdjacentElement('afterend', recentsSectionEl);
  507. renderRecentsBar();
  508. }
  509.  
  510. // ---------------------------------------------------------------------
  511. // Compose editor: a contenteditable box that mirrors the real #comment
  512. // textarea. Emotes are shown as actual <img> elements while composing;
  513. // the real textarea (what actually gets submitted) is kept in sync by
  514. // serializing those images back to ":name:" tokens on every change.
  515. // ---------------------------------------------------------------------
  516.  
  517. let sourceTextarea = null;
  518. let editorEl = null;
  519. // True while WE are the ones writing to sourceTextarea.value, so our own
  520. // value-watcher (below) doesn't rebuild the editor in response to its own writes.
  521. let syncingFromEditor = false;
  522. // Caret position captured while the user was last typing/clicking inside
  523. // the editor. Some host pages run their own mousedown/click handlers that
  524. // steal the selection before our insert logic runs; preventDefault() on
  525. // our own listeners can't stop those other handlers, so we keep this as a
  526. // fallback anchor point instead of trusting live selection state alone.
  527. let lastKnownRange = null;
  528.  
  529. function getTextarea() {
  530. return document.getElementById('comment');
  531. }
  532.  
  533. // The site's own code (e.g. clearing the box after a message sends) writes
  534. // straight to sourceTextarea.value, bypassing our editor entirely. This
  535. // intercepts that so the visible editor stays in sync with the real value.
  536. function installTextareaValueWatcher(textarea) {
  537. if (textarea.dataset.hfEmotesValueWatched) return;
  538. textarea.dataset.hfEmotesValueWatched = '1';
  539.  
  540. const proto = Object.getPrototypeOf(textarea);
  541. const desc = Object.getOwnPropertyDescriptor(proto, 'value');
  542. if (!desc || !desc.set || !desc.get) return; // unexpected environment; skip quietly
  543.  
  544. Object.defineProperty(textarea, 'value', {
  545. configurable: true,
  546. enumerable: desc.enumerable,
  547. get() {
  548. return desc.get.call(textarea);
  549. },
  550. set(v) {
  551. desc.set.call(textarea, v);
  552. if (!syncingFromEditor && editorEl) {
  553. deserializeTextToEditor(v);
  554. }
  555. }
  556. });
  557. }
  558.  
  559. function saveCurrentRangeIfValid() {
  560. const sel = window.getSelection();
  561. if (sel && sel.rangeCount > 0) {
  562. const r = sel.getRangeAt(0);
  563. if (r.collapsed && r.startContainer.nodeType === Node.TEXT_NODE &&
  564. (editorEl.contains(r.startContainer))) {
  565. lastKnownRange = r.cloneRange();
  566. }
  567. }
  568. }
  569.  
  570. function createInlineEmoteImg(name) {
  571. const img = document.createElement('img');
  572. img.src = emoteMap.get(name);
  573. img.alt = `:${name}:`;
  574. img.title = name;
  575. img.className = 'hf-emote hf-emote-inline';
  576. img.dataset.emoteName = name;
  577. img.contentEditable = 'false';
  578. img.draggable = false;
  579. img.style.width = INLINE_EMOTE_SIZE + 'px';
  580. img.style.height = INLINE_EMOTE_SIZE + 'px';
  581. img.style.objectFit = 'contain';
  582. img.style.verticalAlign = 'middle';
  583. img.style.display = 'inline-block';
  584. img.style.margin = '0 1px';
  585. return img;
  586. }
  587.  
  588. // Walks the editor's DOM and turns it back into plain text, converting
  589. // inline emote <img> nodes back into ":name:" tokens.
  590. function serializeEditorToText(root) {
  591. let out = '';
  592.  
  593. function walk(node) {
  594. node.childNodes.forEach(child => {
  595. if (child.nodeType === Node.TEXT_NODE) {
  596. out += child.data;
  597. } else if (child.nodeType === Node.ELEMENT_NODE) {
  598. if (child.tagName === 'IMG' && child.classList.contains('hf-emote-inline')) {
  599. out += `:${child.dataset.emoteName}:`;
  600. } else if (child.tagName === 'BR') {
  601. out += '\n';
  602. } else if (child.tagName === 'DIV' || child.tagName === 'P') {
  603. // Browsers wrap each new line in its own DIV/P inside contenteditable.
  604. if (out.length > 0 && !out.endsWith('\n')) out += '\n';
  605. walk(child);
  606. } else {
  607. walk(child);
  608. }
  609. }
  610. });
  611. }
  612.  
  613. walk(root);
  614. return out.replace(/\u00A0/g, ' '); // normalize inserted non-breaking spaces
  615. }
  616.  
  617. function syncEditorToTextarea() {
  618. if (!sourceTextarea || !editorEl) return;
  619. syncingFromEditor = true;
  620. sourceTextarea.value = serializeEditorToText(editorEl);
  621. syncingFromEditor = false;
  622. sourceTextarea.dispatchEvent(new Event('input', { bubbles: true }));
  623. sourceTextarea.dispatchEvent(new Event('change', { bubbles: true }));
  624. }
  625.  
  626. // Builds the editor's initial contents from whatever text is already in
  627. // the textarea (e.g. a quoted reply), rendering any known tokens as images.
  628. function deserializeTextToEditor(text) {
  629. editorEl.innerHTML = '';
  630. if (!text) return;
  631.  
  632. EMOTE_TOKEN_REGEX.lastIndex = 0;
  633. let match;
  634. let lastIndex = 0;
  635.  
  636. while ((match = EMOTE_TOKEN_REGEX.exec(text)) !== null) {
  637. const name = match[1].toLowerCase();
  638. const before = text.slice(lastIndex, match.index);
  639. if (before) editorEl.appendChild(document.createTextNode(before));
  640.  
  641. if (emoteMap.has(name)) {
  642. editorEl.appendChild(createInlineEmoteImg(name));
  643. editorEl.appendChild(document.createTextNode('\u00A0'));
  644. } else {
  645. editorEl.appendChild(document.createTextNode(match[0]));
  646. }
  647. lastIndex = EMOTE_TOKEN_REGEX.lastIndex;
  648. }
  649.  
  650. editorEl.appendChild(document.createTextNode(text.slice(lastIndex)));
  651. }
  652.  
  653. // Returns the current selection's range if it's inside the editor,
  654. // otherwise falls back to the end of the editor's content.
  655. function getCurrentEditorRange() {
  656. const sel = window.getSelection();
  657. if (sel && sel.rangeCount > 0) {
  658. const r = sel.getRangeAt(0);
  659. if (editorEl.contains(r.commonAncestorContainer) || r.commonAncestorContainer === editorEl) {
  660. return r.cloneRange();
  661. }
  662. }
  663. // Live selection isn't usable (lost focus, or landed outside the
  664. // editor) - fall back to wherever the caret last was while typing.
  665. if (lastKnownRange && editorEl.contains(lastKnownRange.startContainer)) {
  666. return lastKnownRange.cloneRange();
  667. }
  668. const r = document.createRange();
  669. r.selectNodeContents(editorEl);
  670. r.collapse(false);
  671. return r;
  672. }
  673.  
  674. // Given a collapsed range whose caret sits right after a typed partial
  675. // token like ":cat", expands the range to cover that token's text so it
  676. // can be deleted and replaced with the emote image.
  677. function expandRangeOverPartialToken(range) {
  678. if (!range.collapsed) return range;
  679. const node = range.startContainer;
  680. if (node.nodeType !== Node.TEXT_NODE) return null;
  681.  
  682. const offset = range.startOffset;
  683. const textBefore = node.data.slice(0, offset);
  684. const m = textBefore.match(PARTIAL_TOKEN_REGEX);
  685. if (!m) return null;
  686.  
  687. const r = document.createRange();
  688. r.setStart(node, offset - m[0].length);
  689. r.setEnd(node, offset);
  690. return r;
  691. }
  692.  
  693. // Inserts the given emote as an inline <img> at the caret (or at the end
  694. // of the editor if nothing is focused there). If replacePartialToken is
  695. // set, first deletes the ":xyz" text the user was typing - used by direct
  696. // ":xyz:" -> emote replacement in chat/editor text. If explicitRange is
  697. // given (used by the autocomplete popup), that exact range is used as-is
  698. // instead of recomputing anything from live selection state - see the
  699. // comment on acPartialRange for why that recomputation was unreliable.
  700. function insertEmoteAtCaret(name, { replacePartialToken = false, explicitRange = null } = {}) {
  701. if (!editorEl) {
  702. notifyEmoteError('Could not insert the emote - the compose box isn\'t ready yet.');
  703. return;
  704. }
  705. if (!emoteMap.has(name)) {
  706. notifyEmoteError(`":${name}:" isn't a known emote anymore.`);
  707. return;
  708. }
  709.  
  710. let range = explicitRange;
  711. if (!range) {
  712. range = getCurrentEditorRange();
  713. if (replacePartialToken) {
  714. const expanded = expandRangeOverPartialToken(range);
  715. if (!expanded) return; // couldn't find the token being typed; don't guess
  716. range = expanded;
  717. }
  718. }
  719.  
  720. const frag = document.createDocumentFragment();
  721. frag.appendChild(createInlineEmoteImg(name));
  722. const space = document.createTextNode('\u00A0');
  723. frag.appendChild(space);
  724.  
  725. range.deleteContents();
  726. range.insertNode(frag);
  727.  
  728. const sel = window.getSelection();
  729. const newRange = document.createRange();
  730. newRange.setStartAfter(space);
  731. newRange.collapse(true);
  732. sel.removeAllRanges();
  733. sel.addRange(newRange);
  734.  
  735. editorEl.focus();
  736. syncEditorToTextarea();
  737. recordRecentUse(name);
  738. }
  739.  
  740. // ---------------------------------------------------------------------
  741. // Autocomplete
  742. // ---------------------------------------------------------------------
  743.  
  744. let acPopup = null;
  745. let acMatches = [];
  746. let acSelectedIndex = -1;
  747. // Captured the instant a partial token like ":cat" is detected in
  748. // updateAutocomplete(), while we know exactly where it sits. Clicking a
  749. // match reuses this directly instead of re-deriving the range from
  750. // live/last-known selection at click time - that recomputation was
  751. // unreliable because every mouseenter over a row rebuilds the whole
  752. // popup's DOM, and by click time the live selection no longer reliably
  753. // matched the ":partial" text, causing a silent no-op insert (the popup
  754. // would close, but nothing would be inserted).
  755. let acPartialRange = null;
  756.  
  757. function createAcPopup() {
  758. const el = document.createElement('div');
  759. el.id = 'hf-emotes-autocomplete';
  760. el.style.position = 'absolute';
  761. el.style.zIndex = '999999';
  762. el.style.background = '#1e1e1e';
  763. el.style.border = '1px solid #444';
  764. el.style.borderRadius = '4px';
  765. el.style.padding = '4px';
  766. el.style.display = 'none';
  767. el.style.maxHeight = '220px';
  768. el.style.overflowY = 'auto';
  769. el.style.boxShadow = '0 4px 10px rgba(0,0,0,0.4)';
  770. document.body.appendChild(el);
  771. return el;
  772. }
  773.  
  774. function positionAcPopup() {
  775. const rect = editorEl.getBoundingClientRect();
  776. acPopup.style.left = (rect.left + window.scrollX) + 'px';
  777. acPopup.style.top = (rect.top + window.scrollY - acPopup.offsetHeight - 4) + 'px';
  778. acPopup.style.minWidth = Math.min(rect.width, 280) + 'px';
  779. }
  780.  
  781. function closeAcPopup() {
  782. acPopup.style.display = 'none';
  783. acMatches = [];
  784. acSelectedIndex = -1;
  785. acPartialRange = null;
  786. }
  787.  
  788. function applyAcSelection(index) {
  789. const name = acMatches[index];
  790. if (!name || !acPartialRange) return;
  791. try {
  792. insertEmoteAtCaret(name, { explicitRange: acPartialRange });
  793. } catch (err) {
  794. notifyEmoteError('Could not insert that emote.');
  795. console.error('[HF Emotes] insert from autocomplete failed', err);
  796. }
  797. closeAcPopup();
  798. }
  799.  
  800. function renderAcPopup() {
  801. acPopup.innerHTML = '';
  802. acMatches.forEach((name, i) => {
  803. const row = document.createElement('div');
  804. row.className = 'hf-ac-row';
  805. row.style.display = 'flex';
  806. row.style.alignItems = 'center';
  807. row.style.gap = '6px';
  808. row.style.padding = '3px 6px';
  809. row.style.borderRadius = '3px';
  810. row.style.cursor = 'pointer';
  811. row.style.background = i === acSelectedIndex ? '#3a3a3a' : 'transparent';
  812.  
  813. const img = document.createElement('img');
  814. img.src = emoteMap.get(name);
  815. img.style.width = '20px';
  816. img.style.height = '20px';
  817. img.style.objectFit = 'contain';
  818.  
  819. const label = document.createElement('span');
  820. label.textContent = name;
  821. label.style.color = '#eee';
  822. label.style.fontSize = '12px';
  823. label.style.fontFamily = 'monospace';
  824.  
  825. row.appendChild(img);
  826. row.appendChild(label);
  827.  
  828. row.addEventListener('mouseenter', () => {
  829. acSelectedIndex = i;
  830. renderAcPopup();
  831. });
  832. row.addEventListener('mousedown', (e) => {
  833. e.preventDefault(); // keep the editor's focus/selection intact
  834. e.stopPropagation(); // don't let a host-page mousedown handler steal the selection first
  835. applyAcSelection(i);
  836. });
  837. // mousedown already did the insert - these just stop the click/mouseup
  838. // that inevitably follows from reaching any host-page "click outside
  839. // closes/resets this" handler and undoing what we just inserted.
  840. row.addEventListener('mouseup', (e) => {
  841. e.preventDefault();
  842. e.stopPropagation();
  843. });
  844. row.addEventListener('click', (e) => {
  845. e.preventDefault();
  846. e.stopPropagation();
  847. });
  848.  
  849. acPopup.appendChild(row);
  850. });
  851.  
  852. acPopup.style.display = acMatches.length ? 'block' : 'none';
  853. if (acMatches.length) positionAcPopup();
  854. }
  855.  
  856. function updateAutocomplete() {
  857. const range = getCurrentEditorRange();
  858. if (!range || !range.collapsed || range.startContainer.nodeType !== Node.TEXT_NODE) {
  859. closeAcPopup();
  860. return;
  861. }
  862.  
  863. const textBefore = range.startContainer.data.slice(0, range.startOffset);
  864. const m = textBefore.match(PARTIAL_TOKEN_REGEX);
  865. if (!m || m[1].length < MIN_AUTOCOMPLETE_CHARS) {
  866. closeAcPopup();
  867. return;
  868. }
  869.  
  870. // Snapshot the exact ":partial" text's range right now, while its
  871. // position relative to the caret is certain. This is what actually
  872. // gets deleted/replaced on click or Enter/Tab - see acPartialRange.
  873. const tokenRange = document.createRange();
  874. tokenRange.setStart(range.startContainer, range.startOffset - m[0].length);
  875. tokenRange.setEnd(range.startContainer, range.startOffset);
  876. acPartialRange = tokenRange;
  877.  
  878. const query = m[1].toLowerCase();
  879. acMatches = emoteNamesSorted.filter(name => name.startsWith(query)).slice(0, 30);
  880. acSelectedIndex = acMatches.length ? 0 : -1;
  881. renderAcPopup();
  882. }
  883.  
  884. // Replays an Enter keystroke on the real textarea so any send-on-Enter
  885. // handler the site has bound directly to it still fires, even though the
  886. // user is actually typing into our contenteditable overlay.
  887. function makeEnterKeyboardEvent(type) {
  888. const evt = new KeyboardEvent(type, {
  889. key: 'Enter',
  890. code: 'Enter',
  891. bubbles: true,
  892. cancelable: true
  893. });
  894. // keyCode/which aren't settable via the constructor in every browser;
  895. // force them since older-style handlers often check these specifically.
  896. try {
  897. Object.defineProperty(evt, 'keyCode', { get: () => 13 });
  898. Object.defineProperty(evt, 'which', { get: () => 13 });
  899. } catch (e) { /* already non-configurable in this browser; ignore */ }
  900. return evt;
  901. }
  902.  
  903. function forwardEnterToTextarea() {
  904. if (!sourceTextarea) return;
  905. ['keydown', 'keypress', 'keyup'].forEach(type => {
  906. sourceTextarea.dispatchEvent(makeEnterKeyboardEvent(type));
  907. });
  908. }
  909.  
  910. function handleEditorKeydown(e) {
  911. const acOpen = acPopup.style.display !== 'none' && acMatches.length > 0;
  912.  
  913. if (acOpen) {
  914. if (e.key === 'ArrowDown') {
  915. e.preventDefault();
  916. acSelectedIndex = (acSelectedIndex + 1) % acMatches.length;
  917. renderAcPopup();
  918. return;
  919. }
  920. if (e.key === 'ArrowUp') {
  921. e.preventDefault();
  922. acSelectedIndex = (acSelectedIndex - 1 + acMatches.length) % acMatches.length;
  923. renderAcPopup();
  924. return;
  925. }
  926. if (e.key === 'Escape') {
  927. closeAcPopup();
  928. return;
  929. }
  930. if ((e.key === 'Enter' || e.key === 'Tab') && acSelectedIndex >= 0) {
  931. e.preventDefault();
  932. applyAcSelection(acSelectedIndex);
  933. return;
  934. }
  935. }
  936.  
  937. // Plain Enter (not picking an autocomplete match) sends the message,
  938. // matching how the original plain textarea behaved. Shift+Enter still
  939. // inserts a real newline for multi-line composition.
  940. if (e.key === 'Enter' && !e.shiftKey) {
  941. e.preventDefault();
  942. syncEditorToTextarea();
  943. forwardEnterToTextarea();
  944. }
  945. }
  946.  
  947. // ---------------------------------------------------------------------
  948. // Mounting the editor over the real textarea
  949. // ---------------------------------------------------------------------
  950.  
  951. // contenteditable elements have no native `placeholder` attribute, so
  952. // hiding the real textarea (below) silently drops its placeholder text.
  953. // This injects one shared CSS rule - based on ::placeholder if the site
  954. // styles it, otherwise a sane default - and applies it via a data
  955. // attribute + :empty, so the placeholder shows/hides exactly like the
  956. // native one did (visible only when there's no text and no emote).
  957. let placeholderStyleInstalled = false;
  958. function installPlaceholderStyle(sourceTextarea) {
  959. if (placeholderStyleInstalled) return;
  960. placeholderStyleInstalled = true;
  961.  
  962. let color = 'rgba(150, 150, 150, 0.8)';
  963. let fontStyle = 'inherit';
  964. let opacity = '1';
  965. try {
  966. const ph = window.getComputedStyle(sourceTextarea, '::placeholder');
  967. if (ph && ph.color) color = ph.color;
  968. if (ph && ph.fontStyle) fontStyle = ph.fontStyle;
  969. if (ph && ph.opacity) opacity = ph.opacity;
  970. } catch (e) { /* some browsers don't support querying pseudo-elements this way */ }
  971.  
  972. const style = document.createElement('style');
  973. style.textContent = `
  974. #hf-emotes-editor:empty::before {
  975. content: attr(data-placeholder);
  976. color: ${color};
  977. font-style: ${fontStyle};
  978. opacity: ${opacity};
  979. pointer-events: none;
  980. }
  981. `;
  982. document.head.appendChild(style);
  983. }
  984.  
  985. // Contenteditable often leaves a stray empty text node or <br> behind
  986. // after the user deletes everything, which would stop :empty (and so
  987. // the placeholder) from matching even though nothing is left to see.
  988. function normalizeEmptyEditor() {
  989. if (editorEl.textContent === '' && !editorEl.querySelector('img')) {
  990. editorEl.innerHTML = '';
  991. }
  992. }
  993.  
  994. function mountEditor() {
  995. sourceTextarea = getTextarea();
  996. if (!sourceTextarea) return;
  997. if (sourceTextarea.dataset.hfEmotesMounted) return;
  998. sourceTextarea.dataset.hfEmotesMounted = '1';
  999.  
  1000. editorEl = document.createElement('div');
  1001. editorEl.id = 'hf-emotes-editor';
  1002. editorEl.contentEditable = 'true';
  1003. editorEl.className = 'hf-emotes-editor';
  1004. editorEl.dataset.placeholder = sourceTextarea.getAttribute('placeholder') || '';
  1005. installPlaceholderStyle(sourceTextarea);
  1006.  
  1007. // Mirror the real textarea's look so it doesn't stand out visually.
  1008. const cs = window.getComputedStyle(sourceTextarea);
  1009. ['font', 'lineHeight', 'padding', 'border', 'borderRadius', 'background', 'color'].forEach(prop => {
  1010. editorEl.style[prop] = cs[prop];
  1011. });
  1012. editorEl.style.width = sourceTextarea.offsetWidth ? sourceTextarea.offsetWidth + 'px' : '100%';
  1013. editorEl.style.minHeight = sourceTextarea.offsetHeight ? sourceTextarea.offsetHeight + 'px' : '4em';
  1014. editorEl.style.overflowY = 'auto';
  1015. editorEl.style.whiteSpace = 'pre-wrap';
  1016. editorEl.style.wordBreak = 'break-word';
  1017. editorEl.style.boxSizing = 'border-box';
  1018.  
  1019. sourceTextarea.style.display = 'none';
  1020. sourceTextarea.insertAdjacentElement('afterend', editorEl);
  1021. installTextareaValueWatcher(sourceTextarea);
  1022.  
  1023. deserializeTextToEditor(sourceTextarea.value);
  1024.  
  1025. editorEl.addEventListener('input', () => {
  1026. normalizeEmptyEditor();
  1027. saveCurrentRangeIfValid();
  1028. syncEditorToTextarea();
  1029. updateAutocomplete();
  1030. });
  1031. editorEl.addEventListener('click', () => {
  1032. saveCurrentRangeIfValid();
  1033. updateAutocomplete();
  1034. });
  1035. editorEl.addEventListener('keyup', saveCurrentRangeIfValid);
  1036. editorEl.addEventListener('keydown', handleEditorKeydown);
  1037. editorEl.addEventListener('blur', () => setTimeout(closeAcPopup, 150));
  1038.  
  1039. const form = sourceTextarea.closest('form');
  1040. if (form) form.addEventListener('submit', syncEditorToTextarea);
  1041. }
  1042.  
  1043. function watchForComposeBox() {
  1044. mountEditor();
  1045. if (!editorEl) {
  1046. setTimeout(watchForComposeBox, 1000);
  1047. return;
  1048. }
  1049. // Re-mount if the site's own JS ever swaps the textarea out (e.g. AJAX page load).
  1050. const bodyObserver = new MutationObserver(() => {
  1051. const t = getTextarea();
  1052. if (t && !t.dataset.hfEmotesMounted) mountEditor();
  1053. });
  1054. bodyObserver.observe(document.body, { childList: true, subtree: true });
  1055. }
  1056.  
  1057. // ---------------------------------------------------------------------
  1058. // Channel manager: a small popover for adding/removing FFZ + BTTV emote
  1059. // sets from specific Twitch channels, since the global sets don't cover
  1060. // everything. Anchored in the top toolbar next to the other page-level
  1061. // icons (home / group chat / compose / export / fullscreen), right
  1062. // before the fullscreen toggle, rather than the compose-area attach
  1063. // button - it reads more like a page setting than a per-message action.
  1064. // ---------------------------------------------------------------------
  1065.  
  1066. let channelButtonEl = null;
  1067. let channelPopupEl = null;
  1068.  
  1069. function buildChannelManagerButton() {
  1070. const anchor = document.getElementById('convo_fullscreen_toggle');
  1071. if (!anchor) {
  1072. setTimeout(buildChannelManagerButton, 1000);
  1073. return;
  1074. }
  1075. if (channelButtonEl && channelButtonEl.isConnected) return;
  1076.  
  1077. channelButtonEl = document.createElement('a');
  1078. channelButtonEl.href = 'javascript:void(0);';
  1079. channelButtonEl.id = 'hf-emotes-channel-button';
  1080. channelButtonEl.title = 'Emote Channels';
  1081. channelButtonEl.setAttribute('data-tooltip', 'Emote Channels');
  1082. // Match the styling/markup pattern of the other toolbar icons exactly.
  1083. Object.assign(channelButtonEl.style, {
  1084. display: 'inline-block',
  1085. lineHeight: '37px',
  1086. padding: '0px 15px'
  1087. });
  1088. // fa-terminal matches HF's own hacker/terminal branding better than a
  1089. // generic smiley, and sits naturally alongside the other flat FA
  1090. // icons already used in this toolbar (fa-bars, fa-home, fa-users...).
  1091. channelButtonEl.innerHTML = '<i class="fa fa-terminal" aria-hidden="true"></i>';
  1092.  
  1093. anchor.insertAdjacentElement('beforebegin', channelButtonEl);
  1094.  
  1095. channelButtonEl.addEventListener('click', (e) => {
  1096. e.preventDefault();
  1097. e.stopPropagation();
  1098. toggleChannelPopup();
  1099. });
  1100. }
  1101.  
  1102. function toggleChannelPopup() {
  1103. if (channelPopupEl && channelPopupEl.isConnected) {
  1104. closeChannelPopup();
  1105. } else {
  1106. openChannelPopup();
  1107. }
  1108. }
  1109.  
  1110. function handleOutsideChannelPopupClick(e) {
  1111. if (!channelPopupEl) return;
  1112. // channelButtonEl.contains(...) rather than a strict === check, since
  1113. // a click can land on the <i> icon inside the button and still needs
  1114. // to count as "on the button", not "outside the popup".
  1115. if (channelPopupEl.contains(e.target) || (channelButtonEl && channelButtonEl.contains(e.target))) return;
  1116. closeChannelPopup();
  1117. }
  1118.  
  1119. function closeChannelPopup() {
  1120. if (channelPopupEl) {
  1121. channelPopupEl.remove();
  1122. channelPopupEl = null;
  1123. }
  1124. document.removeEventListener('mousedown', handleOutsideChannelPopupClick, true);
  1125. }
  1126.  
  1127. function positionChannelPopup() {
  1128. if (!channelPopupEl || !channelButtonEl) return;
  1129. const rect = channelButtonEl.getBoundingClientRect();
  1130. // Anchor from the right edge of the button instead of the left, since
  1131. // this button now sits at the far right of the toolbar and a
  1132. // left-anchored popup would run off the edge of the page.
  1133. channelPopupEl.style.right = (window.innerWidth - rect.right - window.scrollX) + 'px';
  1134. channelPopupEl.style.left = 'auto';
  1135. channelPopupEl.style.top = (rect.bottom + window.scrollY + 6) + 'px';
  1136. }
  1137.  
  1138. function renderChannelChips(list) {
  1139. list.innerHTML = '';
  1140. if (channelEmoteMaps.size === 0) {
  1141. const empty = document.createElement('div');
  1142. empty.textContent = 'No channels added yet.';
  1143. empty.style.color = '#888';
  1144. list.appendChild(empty);
  1145. return;
  1146. }
  1147.  
  1148. channelEmoteMaps.forEach((_, name) => {
  1149. const chip = document.createElement('span');
  1150. Object.assign(chip.style, {
  1151. display: 'inline-flex',
  1152. alignItems: 'center',
  1153. gap: '4px',
  1154. background: '#333',
  1155. borderRadius: '10px',
  1156. padding: '2px 8px'
  1157. });
  1158.  
  1159. const label = document.createElement('span');
  1160. label.textContent = name;
  1161. chip.appendChild(label);
  1162.  
  1163. const remove = document.createElement('span');
  1164. remove.textContent = '\u00D7';
  1165. remove.title = `Remove ${name}`;
  1166. remove.style.cursor = 'pointer';
  1167. remove.style.fontWeight = 'bold';
  1168. remove.addEventListener('click', () => {
  1169. removeChannel(name);
  1170. renderChannelChips(list);
  1171. });
  1172. chip.appendChild(remove);
  1173.  
  1174. list.appendChild(chip);
  1175. });
  1176. }
  1177.  
  1178. function openChannelPopup() {
  1179. channelPopupEl = document.createElement('div');
  1180. channelPopupEl.id = 'hf-emotes-channel-popup';
  1181. Object.assign(channelPopupEl.style, {
  1182. position: 'absolute',
  1183. zIndex: '999999',
  1184. background: '#1e1e1e',
  1185. border: '1px solid #444',
  1186. borderRadius: '6px',
  1187. padding: '10px',
  1188. width: '240px',
  1189. boxShadow: '0 4px 10px rgba(0,0,0,0.4)',
  1190. color: '#eee',
  1191. fontSize: '13px'
  1192. });
  1193.  
  1194. const title = document.createElement('div');
  1195. title.textContent = 'Emote channels (FFZ / BTTV)';
  1196. title.style.fontWeight = 'bold';
  1197. title.style.marginBottom = '6px';
  1198. channelPopupEl.appendChild(title);
  1199.  
  1200. const list = document.createElement('div');
  1201. Object.assign(list.style, { display: 'flex', flexWrap: 'wrap', gap: '4px', marginBottom: '8px' });
  1202. channelPopupEl.appendChild(list);
  1203. renderChannelChips(list);
  1204.  
  1205. const inputRow = document.createElement('div');
  1206. inputRow.style.display = 'flex';
  1207. inputRow.style.gap = '4px';
  1208.  
  1209. const input = document.createElement('input');
  1210. input.type = 'text';
  1211. input.placeholder = 'Twitch channel name';
  1212. Object.assign(input.style, {
  1213. flex: '1', minWidth: '0', background: '#111', border: '1px solid #333',
  1214. borderRadius: '4px', color: '#eee', padding: '4px 6px'
  1215. });
  1216.  
  1217. const addBtn = document.createElement('button');
  1218. addBtn.type = 'button';
  1219. addBtn.textContent = 'Add';
  1220. Object.assign(addBtn.style, {
  1221. background: '#3a3a3a', border: '1px solid #555', borderRadius: '4px',
  1222. color: '#eee', cursor: 'pointer', padding: '4px 8px'
  1223. });
  1224.  
  1225. const status = document.createElement('div');
  1226. Object.assign(status.style, { marginTop: '6px', minHeight: '14px', color: '#aaa' });
  1227.  
  1228. async function submit() {
  1229. const name = input.value.trim();
  1230. if (!name) return;
  1231. addBtn.disabled = true;
  1232. status.style.color = '#aaa';
  1233. status.textContent = `Looking up ${name}...`;
  1234. try {
  1235. const result = await addChannel(name);
  1236. input.value = '';
  1237. status.style.color = '#7bcf7b';
  1238. status.textContent = `Added ${name} - ${result.ffz} FFZ, ${result.bttv} BTTV emotes.`;
  1239. renderChannelChips(list);
  1240. } catch (err) {
  1241. status.style.color = '#d97a7a';
  1242. status.textContent = err.message || 'Something went wrong.';
  1243. } finally {
  1244. addBtn.disabled = false;
  1245. }
  1246. }
  1247.  
  1248. addBtn.addEventListener('click', submit);
  1249. input.addEventListener('keydown', (e) => {
  1250. if (e.key === 'Enter') {
  1251. e.preventDefault();
  1252. submit();
  1253. }
  1254. });
  1255. // Typing/clicking in the popover shouldn't fall through to the
  1256. // compose editor's own keydown/mousedown handlers behind it.
  1257. input.addEventListener('mousedown', (e) => e.stopPropagation());
  1258.  
  1259. inputRow.appendChild(input);
  1260. inputRow.appendChild(addBtn);
  1261. channelPopupEl.appendChild(inputRow);
  1262. channelPopupEl.appendChild(status);
  1263.  
  1264. document.body.appendChild(channelPopupEl);
  1265. positionChannelPopup();
  1266.  
  1267. setTimeout(() => document.addEventListener('mousedown', handleOutsideChannelPopupClick, true), 0);
  1268. input.focus();
  1269. }
  1270.  
  1271. function installChannelManagerUI() {
  1272. buildChannelManagerButton();
  1273. }
  1274.  
  1275. // ---------------------------------------------------------------------
  1276. // Init
  1277. // ---------------------------------------------------------------------
  1278.  
  1279. (async function init() {
  1280. await Promise.all([loadFFZGlobal(), loadBTTVGlobal()]);
  1281. await loadSavedChannels();
  1282. rebuildEmoteMap();
  1283.  
  1284. // Console-only helper, no on-page UI. Useful for checking things like
  1285. // whether a given name is actually in the loaded global set:
  1286. // hfEmotesDebug.has('wojack')
  1287. (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window).hfEmotesDebug = {
  1288. list: () => [...emoteMap.keys()].sort(),
  1289. has: (name) => emoteMap.has(String(name).toLowerCase()),
  1290. get: (name) => emoteMap.get(String(name).toLowerCase()),
  1291. channels: () => [...channelEmoteMaps.keys()]
  1292. };
  1293.  
  1294. acPopup = createAcPopup();
  1295.  
  1296. observeChat();
  1297. mountRecentsBar();
  1298. watchForComposeBox();
  1299. installMessageEmoteClickToInsert();
  1300. installChannelManagerUI();
  1301. })();
  1302. })();
Advertisement
Add Comment
Please, Sign In to add comment