twitter status preview

This commit is contained in:
turtlebasket
2026-02-25 19:39:03 -08:00
parent aab8dd084a
commit 3ec75edf85
2 changed files with 644 additions and 0 deletions

View File

@@ -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

View File

@@ -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(
/<blockquote\s+class="twitter-tweet"/i,
`<blockquote class="twitter-tweet" data-theme="${theme}"`
);
cache.set(key, themedHtml);
return { normalized, html: themedHtml };
}
function closestLink(el) {
return el && el.closest ? el.closest("a[href]") : null;
}
function clamp(n, lo, hi) {
return Math.max(lo, Math.min(hi, n));
}
// ---------- UI / styles ----------
const style = document.createElement("style");
style.textContent = `
.xttip {
position: fixed;
z-index: 2147483647;
width: min(${MAX_WIDTH_PX}px, calc(100vw - 24px));
max-height: min(420px, calc(100vh - 24px));
overflow: auto;
padding: 12px 12px 10px;
border-radius: 14px;
background: rgba(18, 18, 18, 0.92);
color: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(255, 255, 255, 0.10);
box-shadow:
0 18px 60px rgba(0,0,0,0.55),
0 2px 10px rgba(0,0,0,0.35);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: none;
transform-origin: top left;
transform: translateY(6px) scale(0.98);
opacity: 0;
transition: opacity 110ms ease, transform 130ms ease;
pointer-events: auto;
}
.xttip {
transition:
opacity 110ms ease,
transform 130ms ease,
border-color 160ms ease,
box-shadow 180ms ease,
background-color 180ms ease;
}
.xttip[data-open="1"] {
opacity: 1;
transform: translateY(0) scale(1);
}
.xttip::before {
content: "";
position: absolute;
width: 10px;
height: 10px;
left: 18px;
top: -6px;
transform: rotate(45deg);
background: rgba(18, 18, 18, 0.92);
border-left: 1px solid rgba(255, 255, 255, 0.10);
border-top: 1px solid rgba(255, 255, 255, 0.10);
}
.xttip, .xttip * {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif !important;
}
.xttip-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255,255,255,0.10);
}
.xttip-header-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.xttip-icon {
width: 18px;
height: 18px;
flex: 0 0 auto;
opacity: 0.95;
}
.xttip-title {
font-size: 12px;
line-height: 1.1;
font-weight: 600;
letter-spacing: 0.2px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.9;
}
.xttip[data-detached="1"]::before {
display: none;
}
.xttip[data-detached="1"] .xttip-header {
cursor: move;
user-select: none;
}
.xttip[data-detached="1"] {
border-color: rgba(96, 165, 250, 0.65);
box-shadow:
0 22px 68px rgba(0,0,0,0.62),
0 0 0 2px rgba(96,165,250,0.28);
}
.xttip[data-detach-just="1"] {
animation: xttip-detach-pop 230ms cubic-bezier(0.2, 0.9, 0.2, 1);
}
@keyframes xttip-detach-pop {
0% {
transform: translateY(0) scale(1);
filter: saturate(1);
}
45% {
transform: translateY(-2px) scale(1.02);
filter: saturate(1.16);
}
100% {
transform: translateY(0) scale(1);
filter: saturate(1);
}
}
.xttip-actions {
display: flex;
align-items: center;
gap: 10px;
flex: 0 0 auto;
}
.xttip-open {
font-size: 12px;
text-decoration: none;
opacity: 0.85;
padding: 4px 6px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.14);
}
.xttip-open:hover { opacity: 1; }
.xttip-close {
cursor: pointer;
border: 0;
background: transparent;
color: inherit;
font-size: 18px;
line-height: 1;
padding: 2px 6px;
border-radius: 8px;
opacity: 0.75;
}
.xttip-close:hover { opacity: 1; }
.xttip-body { padding-top: 2px; }
.xttip-loading {
font-size: 13px;
line-height: 1.35;
opacity: 0.85;
padding: 8px 2px;
}
/* Light mode variant */
@media (prefers-color-scheme: light) {
.xttip {
background: rgba(255, 255, 255, 0.94);
color: rgba(0, 0, 0, 0.88);
border: 1px solid rgba(0, 0, 0, 0.10);
box-shadow:
0 18px 60px rgba(0,0,0,0.20),
0 2px 10px rgba(0,0,0,0.12);
}
.xttip::before {
background: rgba(255, 255, 255, 0.94);
border-left: 1px solid rgba(0, 0, 0, 0.10);
border-top: 1px solid rgba(0, 0, 0, 0.10);
}
.xttip-header { border-bottom: 1px solid rgba(0,0,0,0.10); }
.xttip-open { border: 1px solid rgba(0,0,0,0.14); }
}
/* Reduce default margins inside embeds so it fits better */
.xttip .twitter-tweet { margin: 0 !important; }
`;
document.documentElement.appendChild(style);
const tip = document.createElement("div");
tip.className = "xttip";
tip.setAttribute("role", "dialog");
tip.setAttribute("aria-label", "Tweet preview tooltip");
document.documentElement.appendChild(tip);
// ---------- tooltip positioning ----------
function positionTipFromEvent(ev) {
const vw = window.innerWidth;
const vh = window.innerHeight;
// Ensure it has layout for correct dimensions
const rect = tip.getBoundingClientRect();
const w = rect.width || MAX_WIDTH_PX;
const h = rect.height || 220;
let x = ev.clientX + OFFSET_PX;
let y = ev.clientY + OFFSET_PX;
// clamp within viewport (small margins)
x = clamp(x, 8, vw - w - 8);
y = clamp(y, 8, vh - h - 8);
tip.style.left = `${x}px`;
tip.style.top = `${y}px`;
}
// ---------- open/close with animation ----------
function openTip() {
tip.style.display = "block";
requestAnimationFrame(() => 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 `
<svg class="xttip-icon" viewBox="0 0 24 24" aria-hidden="true">
<path fill="${fill}" d="M18.9 2H22l-6.8 7.8L23 22h-6.7l-5.2-6.7L5.2 22H2l7.4-8.5L1 2h6.8l4.7 6.1L18.9 2zm-1.2 18h1.7L6.2 3.9H4.4L17.7 20z"/>
</svg>
`;
}
function setTipSkeleton(tweetUrl, theme, statusText) {
const safeUrl = tweetUrl; // already absolute string
tip.innerHTML = `
<div class="xttip-header">
<div class="xttip-header-left">
${xIconSvg(theme)}
<div class="xttip-title">[space] to detach</div>
</div>
<div class="xttip-actions">
<a class="xttip-open" href="${safeUrl}" target="_blank" rel="noopener noreferrer">Open</a>
<button class="xttip-close" type="button" title="Close" aria-label="Close">×</button>
</div>
</div>
<div class="xttip-body">
<div class="xttip-loading">${statusText}</div>
</div>
`;
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 = `
<div class="xttip-header">
<div class="xttip-header-left">
${xIconSvg(theme)}
<div class="xttip-title">[space] to detach</div>
</div>
<div class="xttip-actions">
<a class="xttip-open" href="${safeUrl}" target="_blank" rel="noopener noreferrer">Open</a>
<button class="xttip-close" type="button" title="Close" aria-label="Close">×</button>
</div>
</div>
<div class="xttip-body">${embedHtml}</div>
`;
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; // <a>
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();
}
}
});
})();