From 3ec75edf85756a5696d87c75d1714604f46f8974 Mon Sep 17 00:00:00 2001 From: turtlebasket <32886427+turtlebasket@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:39:03 -0800 Subject: [PATCH] twitter status preview --- twitter-link-preview/README.md | 4 + twitter-link-preview/twitter-preview.js | 640 ++++++++++++++++++++++++ 2 files changed, 644 insertions(+) create mode 100644 twitter-link-preview/README.md create mode 100644 twitter-link-preview/twitter-preview.js diff --git a/twitter-link-preview/README.md b/twitter-link-preview/README.md new file mode 100644 index 0000000..e6ec538 --- /dev/null +++ b/twitter-link-preview/README.md @@ -0,0 +1,4 @@ +# Twitter Status Preview + +Hover to preview tweet content (detachable modal) so you don't have to fully open x dot com the everything app + diff --git a/twitter-link-preview/twitter-preview.js b/twitter-link-preview/twitter-preview.js new file mode 100644 index 0000000..6f88b37 --- /dev/null +++ b/twitter-link-preview/twitter-preview.js @@ -0,0 +1,640 @@ +// ==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++ Open + ++++ `; + + 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++ Open + ++${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(); + } + } + }); +})();