// ==UserScript== // @name X/Twitter status tooltip preview // @namespace local // @version 0.4.0 // @description Hover X/Twitter status links to show an embedded tweet tooltip with header + theme-aware styling. // @match *://*/* // @grant GM_xmlhttpRequest // @connect publish.twitter.com // ==/UserScript== (() => { "use strict"; // ---------- config ---------- const SHOW_DELAY_MS = 250; const HIDE_DELAY_MS = 200; const MAX_WIDTH_PX = 420; // visual width cap for tooltip const OFFSET_PX = 14; const ANIM_MS = 140; // must match CSS transition timing // ---------- cache ---------- // key: `${normalizedUrl}|${theme}` const cache = new Map(); // ---------- helpers ---------- function isStatusUrl(u) { try { const url = new URL(u); const host = url.hostname.replace(/^www\./, ""); if (host !== "twitter.com" && host !== "x.com") return false; return /\/status\/\d+/.test(url.pathname) || /\/i\/web\/status\/\d+/.test(url.pathname); } catch { return false; } } function normalizeToTwitterDotCom(u) { const url = new URL(u); url.hostname = "twitter.com"; return url.toString(); } function detectTheme() { // Use OS color scheme for widget theme. return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } function ensureWidgetsJs() { if (document.getElementById("xwidgetsjs")) return; const s = document.createElement("script"); s.id = "xwidgetsjs"; s.async = true; s.src = "https://platform.twitter.com/widgets.js"; document.head.appendChild(s); } function gmGet(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url, onload: (r) => { if (r.status >= 200 && r.status < 300) resolve(r.responseText); else reject(new Error(`HTTP ${r.status}`)); }, onerror: () => reject(new Error("Network error")), }); }); } async function fetchOEmbed(tweetUrl, theme) { const normalized = normalizeToTwitterDotCom(tweetUrl); const key = `${normalized}|${theme}`; if (cache.has(key)) return { normalized, html: cache.get(key) }; // X oEmbed endpoint. const oembedUrl = "https://publish.twitter.com/oembed" + "?omit_script=1" + "&dnt=1" + "&maxwidth=" + encodeURIComponent(String(MAX_WIDTH_PX)) + "&theme=" + encodeURIComponent(theme) + "&url=" + encodeURIComponent(normalized); const text = await gmGet(oembedUrl); const data = JSON.parse(text); if (!data || typeof data.html !== "string") throw new Error("Unexpected oEmbed response"); // Add a theme hint for widgets.js. const themedHtml = data.html.replace( /
tip.setAttribute("data-open", "1")); } function closeTip() { tip.removeAttribute("data-open"); window.setTimeout(() => { tip.style.display = "none"; tip.innerHTML = ""; }, ANIM_MS); } // ---------- header + body composition ---------- function xIconSvg(theme) { const fill = theme === "dark" ? "rgba(255,255,255,0.92)" : "rgba(0,0,0,0.82)"; return ` `; } function setTipSkeleton(tweetUrl, theme, statusText) { const safeUrl = tweetUrl; // already absolute string tip.innerHTML = `${xIconSvg(theme)}[space] to detach`; const closeBtn = tip.querySelector(".xttip-close"); setupDragHandlers(); updateDetachHint(); closeBtn.addEventListener("click", () => { activeLink = null; activeKey = null; isDetached = false; tip.removeAttribute("data-detached"); closeTip(); }); } function setTipEmbed(tweetUrl, theme, embedHtml) { const safeUrl = tweetUrl; tip.innerHTML = `${statusText}${xIconSvg(theme)}[space] to detach${embedHtml}`; const closeBtn = tip.querySelector(".xttip-close"); setupDragHandlers(); updateDetachHint(); closeBtn.addEventListener("click", () => { activeLink = null; activeKey = null; isDetached = false; tip.removeAttribute("data-detached"); closeTip(); }); ensureWidgetsJs(); if (window.twttr?.widgets?.load) window.twttr.widgets.load(tip); } // ---------- hover state ---------- let showTimer = null; let hideTimer = null; let activeLink = null; // let activeKey = null; // `${normalized}|${theme}` let lastEv = null; // last pointer event for positioning let isDetached = false; // detached from hover tracking; draggable let isDragging = false; let dragOffsetX = 0; let dragOffsetY = 0; let detachFxTimer = null; function updateDetachHint() { const title = tip.querySelector(".xttip-title"); if (!title) return; title.textContent = isDetached ? "[space] to close" : "[space] to detach"; } function playDetachEffect() { tip.setAttribute("data-detach-just", "1"); if (detachFxTimer) clearTimeout(detachFxTimer); detachFxTimer = setTimeout(() => { tip.removeAttribute("data-detach-just"); detachFxTimer = null; }, 260); } function clearTimers() { if (showTimer) clearTimeout(showTimer); if (hideTimer) clearTimeout(hideTimer); showTimer = null; hideTimer = null; } function scheduleHide() { if (isDetached) return; if (hideTimer) clearTimeout(hideTimer); hideTimer = setTimeout(() => { activeLink = null; activeKey = null; closeTip(); }, HIDE_DELAY_MS); } // Keep open when hovering tooltip itself tip.addEventListener("pointerenter", () => { if (isDetached) return; if (hideTimer) clearTimeout(hideTimer); hideTimer = null; }); tip.addEventListener("pointerleave", () => { if (isDetached) return; scheduleHide(); }); function clampTipPosition(x, y) { const vw = window.innerWidth; const vh = window.innerHeight; const rect = tip.getBoundingClientRect(); const w = rect.width || MAX_WIDTH_PX; const h = rect.height || 220; return { x: clamp(x, 8, vw - w - 8), y: clamp(y, 8, vh - h - 8), }; } function onDragMove(ev) { if (!isDragging || !isDetached) return; const next = clampTipPosition(ev.clientX - dragOffsetX, ev.clientY - dragOffsetY); tip.style.left = `${next.x}px`; tip.style.top = `${next.y}px`; } function onDragEnd() { if (!isDragging) return; isDragging = false; document.removeEventListener("pointermove", onDragMove, true); document.removeEventListener("pointerup", onDragEnd, true); document.removeEventListener("pointercancel", onDragEnd, true); } function setupDragHandlers() { const header = tip.querySelector(".xttip-header"); if (!header) return; header.addEventListener("pointerdown", (ev) => { if (!isDetached) return; const interactive = ev.target.closest("a,button"); if (interactive) return; const rect = tip.getBoundingClientRect(); dragOffsetX = ev.clientX - rect.left; dragOffsetY = ev.clientY - rect.top; isDragging = true; document.addEventListener("pointermove", onDragMove, true); document.addEventListener("pointerup", onDragEnd, true); document.addEventListener("pointercancel", onDragEnd, true); ev.preventDefault(); }); } document.addEventListener( "pointerenter", (ev) => { if (isDetached) return; const link = closestLink(ev.target); if (!link) return; const href = link.getAttribute("href"); if (!href) return; const abs = new URL(href, location.href).toString(); if (!isStatusUrl(abs)) return; // Already on this link if (activeLink === link) return; clearTimers(); activeLink = link; lastEv = ev; const theme = detectTheme(); showTimer = setTimeout(async () => { if (activeLink !== link) return; openTip(); positionTipFromEvent(lastEv); // Loading skeleton (with header already) setTipSkeleton(abs, theme, "Loading…"); try { const { normalized, html } = await fetchOEmbed(abs, theme); const key = `${normalized}|${theme}`; // If user moved away while fetching, ignore. if (activeLink !== link) return; activeKey = key; setTipEmbed(abs, theme, html); positionTipFromEvent(lastEv); } catch (err) { if (activeLink !== link) return; setTipSkeleton(abs, theme, `Failed to load preview: ${String(err.message || err)}`); positionTipFromEvent(lastEv); } }, SHOW_DELAY_MS); }, true ); document.addEventListener( "pointerleave", (ev) => { const link = closestLink(ev.target); if (!link) return; if (link !== activeLink) return; if (isDetached) return; // If leaving link INTO tooltip, do not hide. const toEl = ev.relatedTarget; if (toEl && tip.contains(toEl)) return; clearTimers(); scheduleHide(); }, true ); // Follow cursor while hovering the active link. document.addEventListener( "pointermove", (ev) => { if (!activeLink) return; if (isDetached) return; const link = closestLink(ev.target); if (link !== activeLink) return; lastEv = ev; if (tip.style.display === "block") positionTipFromEvent(ev); }, true ); // Escape closes window.addEventListener("keydown", (e) => { if (e.key === "Escape" && tip.style.display === "block") { activeLink = null; activeKey = null; isDetached = false; tip.removeAttribute("data-detached"); closeTip(); } if (e.key === " " && tip.style.display === "block") { const t = e.target; if ( t instanceof HTMLElement && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(t.tagName)) ) { return; } e.preventDefault(); if (!isDetached) { isDetached = true; clearTimers(); tip.setAttribute("data-detached", "1"); playDetachEffect(); activeLink = null; activeKey = null; updateDetachHint(); } else { isDetached = false; tip.removeAttribute("data-detached"); activeLink = null; activeKey = null; closeTip(); } } }); })();