Skip to content

Instantly share code, notes, and snippets.

@gokaybiz
Last active October 14, 2026 11:08
Show Gist options
  • Select an option

  • Save gokaybiz/b1bb606541fecaa694e01f39e97f4696 to your computer and use it in GitHub Desktop.

Select an option

Save gokaybiz/b1bb606541fecaa694e01f39e97f4696 to your computer and use it in GitHub Desktop.
Early access to Youtube's new GIF feature via a TamperMonkey script.
// ==UserScript==
// @name YouTube GIF Comments (Tenor)
// @namespace https://gist.github.com/gokaybiz/b1bb606541fecaa694e01f39e97f4696
// @version 1.0.0
// @description Search, attach and post Tenor GIFs in YouTube comments and replies.
// @match https://www.youtube.com/*
// @run-at document-start
// @noframes
// @sandbox raw
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addValueChangeListener
// @grant GM_registerMenuCommand
// @grant GM_addElement
// @connect tenor.com
// @connect www.tenor.com
// @connect tenor.googleapis.com
// ==/UserScript==
(() => {
'use strict';
if (window[Symbol.for('gcfy.userscript.v2')]) return;
window[Symbol.for('gcfy.userscript.v2')] = true;
// ------------------------------------------------------------------ Tenor
const TENOR_ORIGIN = 'https://tenor.com';
const API_ORIGIN = 'https://tenor.googleapis.com/v2';
const CACHE_TTL = 10 * 60 * 1000;
const MAX_RESULTS = 50;
const cache = new Map();
const inFlight = new Map();
GM_registerMenuCommand('Configure Tenor API key (optional)', () => {
const previous = GM_getValue('tenorApiKey', '');
const next = window.prompt('Tenor API v2 key (leave empty to use the public Tenor site key):', previous);
if (next === null) return;
GM_setValue('tenorApiKey', next.trim());
cache.clear();
});
const cached = async (key, work) => {
const hit = cache.get(key);
if (hit && Date.now() - hit.time < CACHE_TTL) return hit.value;
if (inFlight.has(key)) return inFlight.get(key);
const promise = Promise.resolve().then(work).then(value => {
cache.delete(key);
cache.set(key, { time: Date.now(), value });
while (cache.size > 48) cache.delete(cache.keys().next().value);
return value;
}).finally(() => inFlight.delete(key));
inFlight.set(key, promise);
return promise;
};
function gmGet(url) {
const target = new URL(url);
if (target.protocol !== 'https:' ||
!(['tenor.com', 'www.tenor.com', 'tenor.googleapis.com'].includes(target.hostname))) {
return Promise.reject(new Error('Unexpected Tenor URL.'));
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET', url: target.href, anonymous: true, timeout: 15000,
onload: r => {
let finalUrl;
try { finalUrl = new URL(r.finalUrl || target.href); }
catch { reject(new Error('Tenor returned an invalid redirect.')); return; }
if (finalUrl.protocol !== 'https:' ||
!(['tenor.com', 'www.tenor.com', 'tenor.googleapis.com'].includes(finalUrl.hostname))) {
reject(new Error('Tenor redirected to an unexpected host.'));
return;
}
if (r.status < 200 || r.status >= 300) {
reject(new Error(r.status === 404 ? 'Tenor could not find that GIF.' : `Tenor returned HTTP ${r.status}.`));
return;
}
resolve(r.responseText);
},
onerror: () => reject(new Error('Tenor request failed.')),
ontimeout: () => reject(new Error('Tenor request timed out.')),
});
});
}
function scriptData(html, id) {
const m = html.match(new RegExp(`<script\\b(?=[^>]*\\bid=["']${id}["'])[^>]*>([\\s\\S]*?)<\\/script>`, 'i'));
return m ? m[1].trim() : null;
}
function readKeys(html) {
const data = scriptData(html, 'data');
if (!data) return null;
try {
const decoded = JSON.parse(atob(data));
if (typeof decoded.API_V2_KEY === 'string' && typeof decoded.API_V2_CLIENT_KEY === 'string') {
return { key: decoded.API_V2_KEY, client_key: decoded.API_V2_CLIENT_KEY };
}
} catch { /* Site settings have changed; use HTML results. */ }
return null;
}
function fetchPage(url) {
return cached(url, async () => {
const html = await gmGet(url);
const storeText = scriptData(html, 'store-cache');
let store = null;
if (storeText) {
try { store = JSON.parse(storeText); }
catch { /* still allow API key extraction */ }
}
return { store, keys: readKeys(html) };
});
}
async function fetchApi(path, params = {}) {
const configured = GM_getValue('tenorApiKey', '').trim();
const keys = configured
? { key: configured, client_key: 'gcfy-userscript' }
: (await fetchPage(TENOR_ORIGIN + '/')).keys;
if (!keys) throw new Error('Tenor site API settings are unavailable. Configure a Tenor API key in the Tampermonkey menu.');
const query = new URLSearchParams({
...keys, contentfilter: 'high', media_filter: 'tinygif,tinywebp,gif', ...params,
});
const url = `${API_ORIGIN}${path}?${query}`;
return cached(url, async () => {
const json = JSON.parse(await gmGet(url));
if (!Array.isArray(json.results)) throw new Error('Unexpected Tenor API response.');
return json.results;
});
}
const ratedG = r => typeof r.content_rating !== 'string' || r.content_rating.toUpperCase() === 'G';
function toGif(r) {
if (!r || !/^\d{1,20}$/.test(String(r.id)) || !r.media_formats) return null;
const flags = r.flags || '';
if (flags.includes('sticker') || flags.includes('static')) return null;
const f = r.media_formats.tinygif || r.media_formats.tinywebp || r.media_formats.gif;
if (!f || typeof f.url !== 'string' || !/^https:\/\/media\d*\.tenor\.com\//.test(f.url)) return null;
const dims = Array.isArray(f.dims) ? f.dims : [];
return {
id: String(r.id),
title: String(r.content_description || r.h1_title || r.title || 'GIF').slice(0, 200),
preview: f.url,
width: Number(dims[0]) > 0 ? Number(dims[0]) : 220,
height: Number(dims[1]) > 0 ? Number(dims[1]) : 124,
};
}
function toGifs(items) {
const seen = new Set();
return (items || []).map(toGif).filter(g => {
if (!g || seen.has(g.id)) return false;
seen.add(g.id);
return true;
}).slice(0, MAX_RESULTS);
}
async function featured() {
try { return toGifs((await fetchApi('/featured', { limit: String(MAX_RESULTS) })).filter(ratedG)); }
catch { /* Fallback to Tenor's embedded featured data. */ }
const { store } = await fetchPage(TENOR_ORIGIN + '/');
if (!store?.gifs?.featured?.results) throw new Error('Tenor page data is unavailable. Configure a Tenor API key in the Tampermonkey menu.');
return toGifs(store.gifs.featured.results);
}
async function search(query) {
const q = query.trim().slice(0, 120);
if (!q) return featured();
try { return toGifs((await fetchApi('/search', { q, limit: String(MAX_RESULTS) })).filter(ratedG)); }
catch { /* Fallback to Tenor's embedded search data. */ }
const slug = q.toLowerCase().split(/\s+/).filter(Boolean).map(encodeURIComponent).join('-');
const { store } = await fetchPage(`${TENOR_ORIGIN}/search/${slug}-gifs`);
const records = Object.values(store?.universal?.search || {});
if (!records[0]?.results) throw new Error('Tenor search data is unavailable. Configure a Tenor API key in the Tampermonkey menu.');
return toGifs(records[0].results);
}
function decodeMediaId(mediaId) {
const bin = atob(mediaId.slice(0, 11).replace(/-/g, '+').replace(/_/g, '/') + '=');
let value = 0n;
for (let i = 0; i < 8; i++) value = (value << 8n) | BigInt(bin.charCodeAt(i));
return value.toString();
}
async function resolve(input) {
const text = input.trim();
let pageUrl;
let id = /^\d{1,20}$/.test(text) ? text : null;
if (id) pageUrl = `${TENOR_ORIGIN}/view/x-gif-${id}`;
else {
let url;
try { url = new URL(/^https?:\/\//i.test(text) ? text : `https://${text}`); }
catch { throw new Error('That is not a Tenor link.'); }
if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('Only HTTP(S) Tenor links are supported.');
if (/^media\d*\.tenor\.com$/.test(url.hostname)) {
const mediaId = url.pathname.split('/').find(p => /^[A-Za-z0-9_-]{16}$/.test(p));
if (!mediaId) throw new Error('That is not a Tenor GIF link.');
id = decodeMediaId(mediaId);
pageUrl = `${TENOR_ORIGIN}/view/x-gif-${id}`;
} else if (url.hostname === 'tenor.com' || url.hostname === 'www.tenor.com') {
pageUrl = `${TENOR_ORIGIN}${url.pathname}`;
const match = url.pathname.match(/-gif-(\d{1,20})\/?$/);
if (match) id = match[1];
} else throw new Error('That is not a Tenor link.');
}
// Use the API for a known ID first, avoiding an unnecessary HTML fetch.
if (id) {
try {
const posts = await fetchApi('/posts', { ids: id, contentfilter: 'off' });
const result = posts.find(r => String(r.id) === id);
if (result && !ratedG(result)) throw new Error(`YouTube requires G-rated GIFs; this one is ${result.content_rating}.`);
if (result) {
const gifs = toGifs([result]);
if (gifs.length) return gifs;
}
} catch (e) {
if (/YouTube requires G-rated/.test(e.message)) throw e;
}
}
const { store } = await fetchPage(pageUrl);
const entries = Object.values(store?.gifs?.byId || {});
const gifs = toGifs(entries.flatMap(e => e?.results || []).slice(0, 1));
if (!gifs.length) throw new Error('That link does not point at a GIF.');
try {
const posts = await fetchApi('/posts', { ids: gifs[0].id, contentfilter: 'off' });
const result = posts.find(r => String(r.id) === gifs[0].id);
if (result && !ratedG(result)) throw new Error(`YouTube requires G-rated GIFs; this one is ${result.content_rating}.`);
} catch (e) {
if (/YouTube requires G-rated/.test(e.message)) throw e;
}
return gifs;
}
async function tenorRequest(msg) {
if (!msg || typeof msg !== 'object') throw new Error('Invalid GIF request.');
if (msg.type === 'featured') return { ok: true, gifs: await featured() };
if (msg.type === 'search' && typeof msg.query === 'string') return { ok: true, gifs: await search(msg.query) };
if (msg.type === 'resolve' && typeof msg.input === 'string') return { ok: true, gifs: await resolve(msg.input) };
throw new Error('Invalid GIF request.');
}
// ----------------------------------------------------- page-world bridge
// Explicit injection is necessary. Userscripts with GM grants can be in an
// isolated world even when @sandbox raw is requested. Patching the isolated
// window.fetch does not patch the fetch used by YouTube itself.
function installPageInterceptor(channel) {
const PKEY = '__gcfy_tm_page_interceptor_v2';
const toPage = channel + ':stage';
const toTM = channel + ':result';
const emit = (data) => document.dispatchEvent(new CustomEvent(toTM, { detail: JSON.stringify(data) }));
if (window[PKEY]) { emit({ type: 'ready', reused: true }); return; }
const CREATE = /\/youtubei\/v1\/comment\/create_comment(?:_reply)?(?:\?|$)/;
const PLACEHOLDER = /^\[\[gcfy:([a-f0-9]{16})\]\]$/;
const STAGE_LIFE = 12000;
let pending = null;
const originalFetch = window.fetch;
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
const requests = new WeakMap();
const clean = value => String(value).normalize('NFC').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
const validId = id => typeof id === 'string' && /^\d{1,20}$/.test(id);
const validNonce = nonce => typeof nonce === 'string' && /^[a-f0-9]{16}$/.test(nonce);
function clearStage(stage) { if (pending === stage) pending = null; }
function report(stage, type, extras = {}) {
emit({ type, nonce: stage.nonce, ...extras });
}
function apply(body, stage) {
if (!body || typeof body !== 'object' || typeof body.commentText !== 'string') {
throw new Error('YouTube did not supply commentText.');
}
const provided = clean(body.commentText);
if (stage.placeholder) {
const match = body.commentText.trim().match(PLACEHOLDER);
if (!match || match[1] !== stage.nonce) throw new Error('GIF-only placeholder was changed.');
delete body.commentText;
} else if (!stage.texts.some(t => clean(t) === provided)) {
throw new Error('YouTube changed the comment text before submitting.');
}
body.tenorId = stage.id;
clearStage(stage);
report(stage, 'seen');
return JSON.stringify(body);
}
function getStage() {
const stage = pending;
if (stage && stage.expires <= Date.now()) {
clearStage(stage);
report(stage, 'result', { ok: false, error: 'GIF submission expired before YouTube sent it.' });
return null;
}
return stage;
}
function blocked(stage, message) {
clearStage(stage);
report(stage, 'result', { ok: false, error: message });
throw new TypeError('GIF submission blocked: ' + message);
}
function reply(stage, status, text) {
let data;
try { data = JSON.parse(text); } catch { data = null; }
const success = status >= 200 && status < 300 && data?.actionResult?.status === 'STATUS_SUCCEEDED';
const refused = data?.error?.status === 'INVALID_ARGUMENT' ||
(Array.isArray(data?.actions) && data.actions.some(a => !!a.showErrorAction));
const error = success ? null : refused ? 'YouTube rejected this GIF.' :
(data?.error?.message || data?.actionResult?.feedbackText?.runs?.map(r => r.text).join('') ||
`YouTube returned HTTP ${status}; posting could not be confirmed.`);
report(stage, 'result', { ok: success, error });
}
async function requestText(input, init) {
const isReq = input instanceof Request;
const headers = new Headers(init?.headers ?? (isReq ? input.headers : {}));
let bytes;
if (init?.body != null) bytes = await new Response(init.body).arrayBuffer();
else if (isReq) bytes = await input.clone().arrayBuffer();
else throw new Error('Comment body was absent.');
const encoding = headers.get('content-encoding')?.toLowerCase().trim();
let text;
if (encoding === 'gzip' || encoding === 'deflate' || encoding === 'deflate-raw') {
text = await new Response(new Blob([bytes]).stream()
.pipeThrough(new DecompressionStream(encoding))).text();
} else if (!encoding || encoding === 'identity') text = new TextDecoder().decode(bytes);
else throw new Error('Unsupported request encoding: ' + encoding);
return { text, headers };
}
async function interceptFetch(input, init, args) {
const stage = getStage();
if (!stage) return originalFetch.apply(this, args);
let source;
try { source = await requestText(input, init); }
catch (e) { return blocked(stage, e.message || 'Unreadable comment request.'); }
let body;
try { body = JSON.parse(source.text); }
catch { return blocked(stage, 'YouTube sent a non-JSON comment request.'); }
let payload;
try { payload = apply(body, stage); }
catch (e) { return blocked(stage, e.message); }
const headers = source.headers;
headers.delete('content-encoding');
headers.delete('content-length');
let response;
try {
response = input instanceof Request
? await originalFetch.call(this, new Request(input, { ...init, body: payload, headers }))
: await originalFetch.call(this, input, { ...init, body: payload, headers });
} catch (e) {
report(stage, 'result', { ok: false, error: 'Network failure. Check YouTube before retrying.' });
throw e;
}
try {
const clone = response.clone();
const bodyText = await clone.text();
reply(stage, response.status, bodyText);
} catch (e) {
report(stage, 'result', { ok: false, error: 'Could not verify the YouTube response.' });
}
return response;
}
function wrappedFetch(input, init) {
let url = '';
try { url = input instanceof Request ? input.url : String(input); } catch { /* unchanged */ }
if (!CREATE.test(url) || !getStage()) return originalFetch.apply(this, arguments);
return interceptFetch.call(this, input, init, arguments);
}
// YouTube normally uses fetch, but intercept plain JSON XHR as well.
function wrappedOpen(method, url) {
requests.set(this, { method: String(method).toUpperCase(), url: String(url) });
return originalOpen.apply(this, arguments);
}
function wrappedSend(body) {
const req = requests.get(this);
const stage = getStage();
if (!stage || !req || req.method !== 'POST' || !CREATE.test(req.url)) {
return originalSend.apply(this, arguments);
}
if (typeof body !== 'string') return blocked(stage, 'Unsupported XHR request body.');
let data;
try { data = JSON.parse(body); }
catch { return blocked(stage, 'YouTube sent non-JSON XHR.'); }
let rewritten;
try { rewritten = apply(data, stage); }
catch (e) { return blocked(stage, e.message); }
this.addEventListener('loadend', () => {
try { reply(stage, this.status, this.responseText); }
catch { report(stage, 'result', { ok: false, error: 'Could not verify YouTube XHR response.' }); }
}, { once: true });
return originalSend.call(this, rewritten);
}
window.fetch = wrappedFetch;
XMLHttpRequest.prototype.open = wrappedOpen;
XMLHttpRequest.prototype.send = wrappedSend;
window[PKEY] = true;
document.addEventListener(toPage, event => {
let msg;
try { msg = JSON.parse(event.detail); } catch { return; }
if (msg.type === 'ping') {
emit({ type: 'ready', fetch: window.fetch === wrappedFetch,
xhr: XMLHttpRequest.prototype.send === wrappedSend });
} else if (msg.type === 'stage') {
if (!validId(msg.id) || !validNonce(msg.nonce) ||
!Array.isArray(msg.texts) || !msg.texts.every(t => typeof t === 'string')) return;
if (window.fetch !== wrappedFetch && XMLHttpRequest.prototype.send !== wrappedSend) {
emit({ type: 'refused', nonce: msg.nonce, error: 'YouTube replaced both interception functions.' });
return;
}
if (pending) report(pending, 'result', { ok: false, error: 'A newer GIF submission replaced this one.' });
pending = { id: msg.id, nonce: msg.nonce, texts: msg.texts.slice(0, 3),
placeholder: !!msg.placeholder, expires: Date.now() + STAGE_LIFE };
emit({ type: 'staged', nonce: msg.nonce });
} else if (msg.type === 'cancel' && pending?.nonce === msg.nonce) {
pending = null;
}
});
emit({ type: 'ready', fetch: true, xhr: true });
}
const bridgeChannel = 'gcfy:' + [...crypto.getRandomValues(new Uint8Array(12))]
.map(x => x.toString(16).padStart(2, '0')).join('');
const callbacks = new Map();
let bridgeReady = false;
let lastHealth = 'Page interceptor not initialized';
let bridgeAttempts = 0;
const outgoing = bridgeChannel + ':stage';
const incoming = bridgeChannel + ':result';
function sendBridge(obj) {
document.dispatchEvent(new CustomEvent(outgoing, { detail: JSON.stringify(obj) }));
}
document.addEventListener(incoming, event => {
let message;
try { message = JSON.parse(event.detail); } catch { return; }
if (message.type === 'ready') {
bridgeReady = !!(message.fetch || message.xhr || message.reused);
lastHealth = bridgeReady ? 'Page interceptor installed' : 'Page interceptor was replaced';
}
if (typeof message.nonce === 'string') callbacks.get(message.nonce)?.(message);
});
function injectPageInterceptor() {
bridgeAttempts++;
try {
// GM_addElement asks Tampermonkey to insert executable code into the
// page world. It works even when the userscript itself runs isolated.
const code = `;(${installPageInterceptor.toString()})(${JSON.stringify(bridgeChannel)});`;
lastHealth = 'Waiting for page interceptor confirmation';
const node = GM_addElement('script', { textContent: code });
if (!node) throw new Error('Tampermonkey did not attach the page script.');
node.remove();
} catch (e) {
lastHealth = `Page injection failed: ${e?.message || String(e)}`;
console.error('[GCFY]', lastHealth);
}
sendBridge({ type: 'ping' });
}
function prepareBridge() {
if (!bridgeReady && bridgeAttempts < 3) injectPageInterceptor();
}
prepareBridge();
// At document-start, documentElement may not exist yet. Retry immediately
// when the HTML root appears, rather than waiting for DOMContentLoaded.
if (!document.documentElement) {
const rootObserver = new MutationObserver(() => {
if (!document.documentElement) return;
rootObserver.disconnect();
prepareBridge();
});
rootObserver.observe(document, { childList: true });
}
document.addEventListener('DOMContentLoaded', prepareBridge, { once: true });
window.addEventListener('load', prepareBridge, { once: true });
GM_registerMenuCommand('GIF posting diagnostics', () => {
sendBridge({ type: 'ping' });
setTimeout(() => window.alert(
`Page interceptor: ${lastHealth}\nAttempts: ${bridgeAttempts}\n` +
`If unavailable, check Tampermonkey Content Script API settings and disable older GIF scripts.`
), 100);
});
// ------------------------------------------------------------- Page styling
const STYLE = `
.gcfy-btn{flex:none;align-self:center;display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;margin:0;padding:0;border:0;border-radius:50%;background:transparent;color:#0f0f0f;cursor:pointer}
html[dark] .gcfy-btn{color:#f1f1f1}
.gcfy-btn::before{--gcfy-icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='5.5' width='20' height='13' rx='3.5' stroke-width='1.5'/%3E%3Cpath stroke-width='1.25' d='M9.85 10.35C9.45 9.75 8.9 9.4 8.25 9.4c-1.1 0-1.8 1.05-1.8 2.6s.7 2.6 1.8 2.6 1.8-.75 1.8-1.9v-.4H8.5M12.35 9.4v5.2M14.65 14.6V9.4h2.9M14.65 12.15H17'/%3E%3C/svg%3E");content:"";width:24px;height:24px;background-color:currentColor;-webkit-mask:var(--gcfy-icon) center / contain no-repeat;mask:var(--gcfy-icon) center / contain no-repeat}
.gcfy-btn:hover,.gcfy-btn[aria-expanded="true"]{background:rgba(0,0,0,.05)}
html[dark] .gcfy-btn:hover,html[dark] .gcfy-btn[aria-expanded="true"]{background:rgba(255,255,255,.1)}
.gcfy-btn:focus-visible{outline:2px solid #065fd4;outline-offset:2px}
html[dark] .gcfy-btn:focus-visible{outline-color:#3ea6ff}
.gcfy-attachment{margin:8px 0 0;font:12px/1.4 Roboto,Arial,sans-serif;color:#606060}
html[dark] .gcfy-attachment{color:#aaa}
.gcfy-attachment-gif{position:relative;display:inline-block;vertical-align:top;border-radius:8px;overflow:hidden}
.gcfy-attachment-gif img{display:block;width:auto;height:auto;max-width:200px;max-height:120px}
.gcfy-attachment[data-busy] .gcfy-attachment-gif{opacity:.6}
.gcfy-attachment-remove{position:absolute;top:4px;right:4px;width:24px;height:24px;padding:0;border:0;border-radius:50%;background:rgba(0,0,0,.6);color:#fff;font:16px/1 Arial,sans-serif;cursor:pointer}
.gcfy-attachment[data-busy] .gcfy-attachment-remove{display:none}
.gcfy-attachment-remove:focus-visible{outline:2px solid #3ea6ff;outline-offset:1px}
.gcfy-attachment-status:empty{display:none}
.gcfy-attachment-status{margin-top:4px}
.gcfy-attachment-status[data-kind="error"]{color:#c00}
html[dark] .gcfy-attachment-status[data-kind="error"]{color:#ff7b7b}
`;
function insertStyle() {
const el = document.createElement('style');
el.id = 'gcfy-tampermonkey-style';
el.textContent = STYLE;
(document.head || document.documentElement).append(el);
}
if (document.documentElement) insertStyle();
else document.addEventListener('readystatechange', function onReady() {
if (!document.documentElement) return;
document.removeEventListener('readystatechange', onReady);
insertStyle();
});
// ----------------------------------------------------------------- UI layer
// DOM integration for YouTube's dynamically created comment boxes.
const BOX_SCOPE = 'ytd-comment-simplebox-renderer, ytd-comment-thread-renderer';
const POST_DIALOG = 'ytd-backstage-post-dialog-renderer';
const PICKER_W = 340;
const PICKER_H = 420;
const SEARCH_DEBOUNCE_MS = 350;
const ARM_TIMEOUT_MS = 1500; // Kept for compatibility with the picker layout
const SUBMIT_GRACE_MS = 4000;
const RESULT_TIMEOUT_MS = 20000;
const NO_FAVORITES = 'No favorites yet. Point at a GIF and click its star.';
const PICKER_CSS = `
:host { all: initial; position: fixed; z-index: 2147483647; }
.panel {
--bg: #fff; --fg: #0f0f0f; --muted: #606060; --line: rgba(0,0,0,.12); --field: rgba(0,0,0,.05); --accent: #065fd4; --err: #c00;
--thumb: rgba(0,0,0,.25); --thumb-hover: rgba(0,0,0,.45);
box-sizing: border-box; display: flex; flex-direction: column;
width: ${PICKER_W}px; background: var(--bg); color: var(--fg);
border: 1px solid var(--line); border-radius: 12px; overflow: hidden;
box-shadow: 0 8px 28px rgba(0,0,0,.35);
font: 13px/1.4 Roboto, Arial, sans-serif;
}
.panel[data-dark] {
--bg: #212121; --fg: #f1f1f1; --muted: #aaa; --line: rgba(255,255,255,.14); --field: rgba(255,255,255,.08); --accent: #3ea6ff; --err: #ff7b7b;
--thumb: rgba(255,255,255,.25); --thumb-hover: rgba(255,255,255,.45);
}
.search { flex: none; display: flex; gap: 6px; padding: 8px; border-bottom: 1px solid var(--line); }
input {
box-sizing: border-box; flex: 1; min-width: 0; height: 34px; padding: 0 12px;
border: 1px solid transparent; border-radius: 17px; outline: none;
background: var(--field); color: var(--fg); font: inherit;
}
input:focus { border-color: var(--accent); }
input::placeholder { color: var(--muted); }
.fav-tab {
flex: none; width: 34px; height: 34px; padding: 0; border: 0; border-radius: 50%;
background: var(--field); color: var(--muted); font: 16px/1 Arial, sans-serif; cursor: pointer;
}
.fav-tab[aria-pressed="true"] { background: var(--accent); color: var(--bg); }
.fav-tab:focus-visible, .star:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.status { flex: none; padding: 6px 12px 0; color: var(--muted); }
.status:empty { display: none; }
.status[data-kind="error"] { color: var(--err); }
.grid {
flex: 1; min-height: 0; overflow-x: hidden; overflow-y: auto; padding: 8px 2px 8px 8px;
display: flex; align-items: flex-start; gap: 6px;
scrollbar-gutter: stable;
}
/* Slim rounded scrollbar: a 6px thumb inside a 12px transparent track. */
.grid::-webkit-scrollbar { width: 12px; }
.grid::-webkit-scrollbar-track { background: transparent; }
.grid::-webkit-scrollbar-thumb {
border: 3px solid transparent; border-radius: 6px;
background: var(--thumb); background-clip: content-box;
}
.grid::-webkit-scrollbar-thumb:hover { background: var(--thumb-hover); background-clip: content-box; }
@supports not selector(::-webkit-scrollbar) {
.grid { scrollbar-width: thin; scrollbar-color: var(--thumb) transparent; padding-right: 8px; }
}
.col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.tile {
display: block; width: 100%; margin: 0; padding: 0; border: 0; border-radius: 8px;
overflow: hidden; background: var(--field); cursor: pointer;
}
.tile:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.tile:hover img { opacity: .82; }
.tile img { display: block; width: 100%; height: auto; }
/* The star is the tile's sibling, not its child: a button cannot hold another button. */
.cell { position: relative; }
.star {
position: absolute; top: 4px; right: 4px; width: 26px; height: 26px; padding: 0; border: 0; border-radius: 50%;
background: rgba(0,0,0,.6); color: #fff; font: 16px/1 Arial, sans-serif; cursor: pointer; opacity: 0;
}
.cell:hover .star, .star:focus-visible, .star[aria-pressed="true"] { opacity: 1; }
.star[aria-pressed="true"] { color: #ffd45c; }
`;
// The picker is entirely a userscript DOM component; only staging a
// YouTube-native submission crosses the page-world boundary.
function newNonce() {
return [...crypto.getRandomValues(new Uint8Array(8))]
.map(b => b.toString(16).padStart(2, '0')).join('');
}
// ------------------------------------------------------------- GIF buttons
function addButton(box) {
if (box.querySelector('.gcfy-btn')) return;
if (!box.closest(BOX_SCOPE) || box.closest(POST_DIALOG)) return;
const footer = box.querySelector('#footer');
if (!footer) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'gcfy-btn';
btn.title = 'Add a GIF';
btn.setAttribute('aria-label', 'Add a GIF');
btn.setAttribute('aria-haspopup', 'dialog');
btn.setAttribute('aria-expanded', 'false');
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (picker.open && picker.anchor === btn) closePicker();
else openPicker(btn, box);
});
const emoji = footer.querySelector('#emoji-button');
if (emoji && emoji.parentElement === footer) emoji.after(btn);
else footer.prepend(btn);
}
let scanQueued = false;
function scan() {
scanQueued = false;
for (const box of document.querySelectorAll('ytd-commentbox')) addButton(box);
}
const scanner = new MutationObserver(records => {
// Avoid full scans for unrelated YouTube player/metadata changes.
const affectsComments = records.some(({ target, addedNodes }) => {
if (target.nodeType === 1 && target.closest?.('ytd-commentbox')) return true;
for (const node of addedNodes) {
if (node.nodeType !== 1) continue;
if (node.matches?.('ytd-commentbox') || node.querySelector?.('ytd-commentbox')) return true;
}
return false;
});
if (!affectsComments || scanQueued) return;
scanQueued = true;
requestAnimationFrame(scan);
});
function startScanner() {
if (!document.documentElement) return;
scanner.observe(document.documentElement, { childList: true, subtree: true });
scan();
}
if (document.documentElement) startScanner();
else document.addEventListener('readystatechange', function onRoot() {
if (!document.documentElement) return;
document.removeEventListener('readystatechange', onRoot);
startScanner();
});
// ------------------------------------------------------------------ picker
const picker = { open: false, host: null, anchor: null, box: null, els: null, seq: 0, timer: 0, gifs: [], nonce: null, favorites: [], showingFavorites: false };
function buildPicker() {
const host = document.createElement('div');
const root = host.attachShadow({ mode: 'open' });
// A <style> element rather than adoptedStyleSheets: Firefox does not let a
// content script assign constructed stylesheets to a page's shadow root.
const style = document.createElement('style');
style.textContent = PICKER_CSS;
const panel = document.createElement('div');
panel.className = 'panel';
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-label', 'Choose a GIF');
const search = document.createElement('div');
search.className = 'search';
const input = document.createElement('input');
input.type = 'text';
input.placeholder = 'Search Tenor or paste a Tenor link';
input.setAttribute('aria-label', 'Search Tenor or paste a Tenor link');
input.autocomplete = 'off';
input.spellcheck = false;
const favTab = document.createElement('button');
favTab.type = 'button';
favTab.className = 'fav-tab';
favTab.textContent = '★';
favTab.title = 'Favorites';
favTab.setAttribute('aria-label', 'Favorites');
favTab.setAttribute('aria-pressed', 'false');
search.append(input, favTab);
const status = document.createElement('div');
status.className = 'status';
status.setAttribute('role', 'status');
const grid = document.createElement('div');
grid.className = 'grid';
panel.append(search, status, grid);
root.append(style, panel);
input.addEventListener('input', () => {
clearTimeout(picker.timer);
picker.timer = setTimeout(() => load(input.value), SEARCH_DEBOUNCE_MS);
});
favTab.addEventListener('click', () => {
if (picker.showingFavorites) { load(input.value); return; }
// A search that is waiting or under way must not replace the favorites.
clearTimeout(picker.timer);
picker.seq++;
setFavoritesView(true);
renderFavorites(false);
});
grid.addEventListener('click', (e) => {
const star = e.target.closest('.star');
const tile = e.target.closest('.tile');
if (star) toggleFavorite(picker.gifs[Number(star.dataset.index)]);
else if (tile) attach(picker.gifs[Number(tile.dataset.index)]);
});
picker.host = host;
picker.els = { panel, input, status, grid, favTab };
}
function setStatus(text, kind) {
picker.els.status.textContent = text || '';
if (kind) picker.els.status.dataset.kind = kind;
else delete picker.els.status.dataset.kind;
}
function place() {
if (!picker.open) return;
if (!picker.anchor.isConnected) { closePicker(); return; }
const r = picker.anchor.getBoundingClientRect();
const h = Math.min(PICKER_H, innerHeight - 16);
picker.els.panel.style.height = `${h}px`;
let top = r.top - h - 8;
if (top < 8) top = Math.min(r.bottom + 8, innerHeight - h - 8);
const width = Math.min(PICKER_W, innerWidth - 16);
picker.els.panel.style.width = `${width}px`;
const left = Math.min(Math.max(8, r.left), innerWidth - width - 8);
picker.host.style.top = `${Math.max(8, top)}px`;
picker.host.style.left = `${Math.max(8, left)}px`;
}
function openPicker(anchor, box) {
if (!picker.host) buildPicker();
if (picker.open) closePicker();
picker.open = true;
picker.anchor = anchor;
picker.box = box;
anchor.setAttribute('aria-expanded', 'true');
picker.els.panel.toggleAttribute('data-dark', document.documentElement.hasAttribute('dark'));
document.documentElement.append(picker.host);
place();
picker.els.input.focus();
load(picker.els.input.value);
}
function closePicker() {
if (!picker.open) return;
picker.open = false;
clearTimeout(picker.timer);
picker.seq++;
picker.anchor.setAttribute('aria-expanded', 'false');
picker.host.remove();
if (picker.anchor.isConnected) picker.anchor.focus();
}
const looksLikeLink = (text) => /^\d{6,20}$/.test(text) || /^(https?:\/\/)?([\w-]+\.)*tenor\.com\//i.test(text);
async function load(raw) {
const text = raw.trim();
const seq = ++picker.seq;
setFavoritesView(false);
setStatus(text ? 'Searching…' : 'Loading…');
let reply;
try {
reply = await tenorRequest(
!text ? { type: 'featured' } : looksLikeLink(text) ? { type: 'resolve', input: text } : { type: 'search', query: text },
);
} catch (e) {
reply = { ok: false, error: e?.message || 'Tenor could not be reached.' };
}
if (seq !== picker.seq || !picker.open) return;
if (!reply || !reply.ok) {
render([]);
setStatus((reply && reply.error) || 'Tenor could not be reached.', 'error');
return;
}
render(reply.gifs);
setStatus(reply.gifs.length ? '' : 'No GIFs found.');
}
function render(gifs) {
picker.gifs = gifs;
// Two columns that grow downwards; each GIF goes into the shorter one.
const cols = [0, 1].map(() => {
const col = document.createElement('div');
col.className = 'col';
return { el: col, height: 0 };
});
gifs.forEach((gif, i) => {
const cell = document.createElement('div');
cell.className = 'cell';
const tile = document.createElement('button');
tile.type = 'button';
tile.className = 'tile';
tile.dataset.index = String(i);
tile.title = gif.title;
const img = document.createElement('img');
img.src = gif.preview;
img.alt = gif.title;
img.width = gif.width;
img.height = gif.height;
img.loading = 'lazy';
img.referrerPolicy = 'no-referrer';
tile.append(img);
const star = document.createElement('button');
star.type = 'button';
star.className = 'star';
star.dataset.index = String(i);
setStar(star, gif);
cell.append(tile, star);
const col = cols[0].height <= cols[1].height ? cols[0] : cols[1];
col.el.append(cell);
col.height += gif.height / gif.width;
});
picker.els.grid.replaceChildren(...cols.map((c) => c.el));
picker.els.grid.scrollTop = 0;
}
// --------------------------------------------------------------- favorites
function setFavoritesView(on) {
picker.showingFavorites = on;
picker.els.favTab.setAttribute('aria-pressed', String(on));
}
function setStar(star, gif) {
if (!gif) return;
const on = picker.favorites.some((f) => f.id === gif.id);
star.textContent = on ? '★' : '☆';
star.title = on ? 'Remove from favorites' : 'Add to favorites';
star.setAttribute('aria-label', star.title);
star.setAttribute('aria-pressed', String(on));
}
function renderFavorites(keepScroll) {
const top = keepScroll ? picker.els.grid.scrollTop : 0;
render(picker.favorites);
picker.els.grid.scrollTop = top;
setStatus(picker.favorites.length ? '' : NO_FAVORITES);
}
// Load favorites and update them when another Tampermonkey tab saves a change.
function useFavorites(stored) {
picker.favorites = (Array.isArray(stored) ? stored : []).filter((g) => g
&& typeof g.id === 'string' && typeof g.title === 'string' && typeof g.preview === 'string'
&& g.width > 0 && g.height > 0);
if (!picker.open) return;
if (picker.showingFavorites) renderFavorites(true);
else for (const star of picker.els.grid.querySelectorAll('.star')) setStar(star, picker.gifs[Number(star.dataset.index)]);
}
useFavorites(GM_getValue('favorites', []));
GM_addValueChangeListener('favorites', (_name, _old, current) => useFavorites(current));
// Persist favorites in Tampermonkey's shared userscript storage.
async function toggleFavorite(gif) {
const rest = picker.favorites.filter((f) => f.id !== gif.id);
try {
GM_setValue('favorites', rest.length < picker.favorites.length ? rest : [gif, ...rest]);
useFavorites(GM_getValue('favorites', []));
} catch (e) {
setStatus('The favorite could not be saved.', 'error');
}
}
// ------------------------------------------------------------ submissions
const attachments = new Map();
let passThrough = false;
const BUTTON_STYLES = [
['ytSpecButtonShapeNextDisabled', 'ytSpecButtonShapeNextMono'],
['yt-spec-button-shape-next--disabled', 'yt-spec-button-shape-next--mono'],
];
const submitOf = box => box.querySelector('#submit-button button');
const editorOf = box => box.querySelector('#contenteditable-root');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
function insertText(editor, text) {
editor.focus();
const range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
const selection = getSelection();
selection.removeAllRanges();
selection.addRange(range);
let worked = false;
try { worked = document.execCommand('insertText', false, text); } catch { /* fallback */ }
if (!worked || !editor.textContent.includes(text)) {
editor.append(document.createTextNode(text));
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
}
}
function removePlaceholder(editor, text) {
if (!editor || !editor.isConnected || !editor.textContent.includes(text)) return;
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const at = node.data.indexOf(text);
if (at >= 0) {
node.deleteData(at, text.length);
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }));
break;
}
}
}
function attach(gif) {
const box = picker.box;
const footer = box?.isConnected && box.querySelector('#footer');
const editor = footer && editorOf(box);
if (!editor || !gif) {
setStatus('This comment box is no longer available.', 'error');
return;
}
if (attachments.get(box)?.posting) return;
detach(box, false);
const chip = document.createElement('div');
chip.className = 'gcfy-attachment';
const frame = document.createElement('div');
frame.className = 'gcfy-attachment-gif';
const img = document.createElement('img');
img.src = gif.preview;
img.alt = gif.title;
img.width = gif.width;
img.height = gif.height;
img.referrerPolicy = 'no-referrer';
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'gcfy-attachment-remove';
remove.textContent = '×';
remove.title = 'Remove the GIF';
remove.setAttribute('aria-label', 'Remove the GIF');
const status = document.createElement('div');
status.className = 'gcfy-attachment-status';
status.setAttribute('role', 'status');
const observer = new MutationObserver(() => enableSubmit(box));
const att = { gif, chip, status, observer, posting: false, greyed: [] };
remove.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
if (!att.posting) detach(box, true);
});
frame.append(img, remove);
chip.append(frame, status);
footer.before(chip);
attachments.set(box, att);
const submit = submitOf(box);
if (submit) observer.observe(submit, { attributes: true, attributeFilter: ['disabled', 'aria-disabled', 'class'] });
enableSubmit(box);
closePicker();
editor.focus();
}
function enableSubmit(box) {
const submit = submitOf(box);
const att = attachments.get(box);
if (!submit || !att) return;
if (submit.disabled) submit.disabled = false;
if (submit.hasAttribute('aria-disabled')) submit.removeAttribute('aria-disabled');
for (const [disabled, enabled] of BUTTON_STYLES) {
if (!submit.classList.contains(disabled)) continue;
submit.classList.replace(disabled, enabled);
if (!att.greyed.some(pair => pair[0] === disabled)) att.greyed.push([disabled, enabled]);
}
}
function detach(box, restore) {
const att = attachments.get(box);
if (!att) return;
attachments.delete(box);
att.observer.disconnect();
att.chip.remove();
const submit = submitOf(box);
const editor = editorOf(box);
if (restore && submit && editor && !editor.textContent.trim()) {
submit.disabled = true;
for (const [disabled, enabled] of att.greyed) submit.classList.replace(enabled, disabled);
}
}
function setChipStatus(att, message, error = false) {
att.status.textContent = message;
if (error) att.status.dataset.kind = 'error';
else delete att.status.dataset.kind;
}
async function post(box) {
const att = attachments.get(box);
const editor = editorOf(box);
const submit = submitOf(box);
if (!att || !editor || !submit || att.posting) return;
att.posting = true;
att.chip.toggleAttribute('data-busy', true);
const nonce = newNonce();
let placeholder = null;
let ackResolve, resultResolve;
const ack = new Promise(resolve => { ackResolve = resolve; });
const result = new Promise(resolve => { resultResolve = resolve; });
let seen = false;
callbacks.set(nonce, msg => {
if (msg.type === 'staged' || msg.type === 'refused') ackResolve(msg);
else if (msg.type === 'seen') seen = true;
else if (msg.type === 'result') resultResolve(msg);
});
setChipStatus(att, 'Posting GIF…');
try {
// Normal comments remain untouched; only a GIF-only comment requires a
// short-lived input placeholder to trigger YouTube's native handler.
const texts = [editor.innerText || '', editor.textContent || ''];
const gifOnly = !texts.some(t => t.trim());
if (!bridgeReady) {
sendBridge({ type: 'ping' });
await sleep(250);
}
if (!bridgeReady) {
setChipStatus(att, 'The YouTube page interceptor did not initialize. ' + lastHealth, true);
return;
}
sendBridge({ type: 'stage', nonce, id: att.gif.id, texts, placeholder: gifOnly });
const staged = await Promise.race([ack, sleep(1500).then(() => null)]);
if (!staged || staged.type !== 'staged') {
setChipStatus(att, staged?.error || 'YouTube request interception did not respond.', true);
return;
}
if (gifOnly) {
placeholder = `[[gcfy:${nonce}]]`;
insertText(editor, placeholder);
// Give YouTube's Polymer/editor input model a task to observe the text.
await sleep(80);
}
for (let i = 0; i < 12 && submit.disabled; i++) await sleep(50);
passThrough = true;
try { submit.click(); } finally { passThrough = false; }
// Some YouTube versions schedule an asynchronous submit handler.
const report = await Promise.race([result, sleep(5000).then(() => null)]);
let finalReport = report;
if (!finalReport && seen) finalReport = await Promise.race([result, sleep(15000).then(() => null)]);
if (finalReport?.ok) { detach(box, false); return; }
setChipStatus(att, finalReport?.error || (seen
? 'YouTube did not confirm this GIF; inspect the comments before retrying.'
: 'YouTube did not submit through an intercepted endpoint. Nothing was confirmed.'), true);
} finally {
sendBridge({ type: 'cancel', nonce });
if (placeholder) removePlaceholder(editor, placeholder);
callbacks.delete(nonce);
att.posting = false;
att.chip.toggleAttribute('data-busy', false);
}
}
const boxIn = (path) => path.find((n) => n.localName === 'ytd-commentbox');
// Capture phase on window, ahead of YouTube's own handlers: pressing Comment
// in a box with a GIF attached posts the GIF instead.
window.addEventListener('click', (e) => {
if (passThrough) return;
const path = e.composedPath();
const box = boxIn(path);
if (!box || !attachments.has(box)) return;
const submit = box.querySelector('#submit-button');
const cancel = box.querySelector('#cancel-button');
if (submit && path.includes(submit)) {
e.preventDefault();
e.stopImmediatePropagation();
post(box);
} else if (cancel && path.includes(cancel) && !attachments.get(box).posting) {
detach(box, false);
}
}, true);
window.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' || !(e.ctrlKey || e.metaKey)) return;
const box = boxIn(e.composedPath());
if (!box || !attachments.has(box)) return;
e.preventDefault();
e.stopImmediatePropagation();
post(box);
}, true);
// ------------------------------------------------------- page interactions
const inPicker = (e) => picker.open && e.composedPath().includes(picker.host);
// Registered at document_start in the capture phase, ahead of YouTube's own
// handlers, so typing in the search field does not trigger player shortcuts
// and scrolling the grid does not flip to the next Short.
for (const type of ['keydown', 'keyup', 'keypress']) {
window.addEventListener(type, (e) => {
if (!inPicker(e)) return;
e.stopImmediatePropagation();
if (type !== 'keydown') return;
if (e.key === 'Escape') {
e.preventDefault();
closePicker();
} else if (e.key === 'Enter' && e.composedPath()[0] === picker.els.input && picker.gifs.length === 1) {
e.preventDefault();
attach(picker.gifs[0]);
}
}, true);
}
window.addEventListener('wheel', (e) => { if (inPicker(e)) e.stopImmediatePropagation(); }, { capture: true, passive: true });
window.addEventListener('pointerdown', (e) => {
if (!picker.open) return;
const path = e.composedPath();
if (!path.includes(picker.host) && !path.includes(picker.anchor)) closePicker();
}, true);
let placeQueued = false;
const queuePlace = () => {
if (!picker.open || placeQueued) return;
placeQueued = true;
requestAnimationFrame(() => { placeQueued = false; place(); });
};
window.addEventListener('scroll', queuePlace, { capture: true, passive: true });
window.addEventListener('resize', queuePlace, { passive: true });
window.addEventListener('yt-navigate-start', () => {
closePicker();
for (const [box, att] of attachments) if (!att.posting) detach(box, false);
});
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment