Last active
October 14, 2026 11:08
-
-
Save gokaybiz/b1bb606541fecaa694e01f39e97f4696 to your computer and use it in GitHub Desktop.
Early access to Youtube's new GIF feature via a TamperMonkey script.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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