From 9be0b8f826bc7442d7a1324e3258f866f3a19d76 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 18 Jul 2026 12:46:43 +0200 Subject: [PATCH] Add tweet embed and oEmbed API support --- public/js/embedResize.js | 26 +++++++ public/js/embedTweet.js | 23 ++++++ public/js/widgets.js | 135 ++++++++++++++++++++++++++++++++++++ src/routes/embed.nim | 71 +++++++++++++++++-- src/routes/router_utils.nim | 2 +- src/sass/tweet/_base.scss | 104 ++++++++++++++++++++++++--- src/views/embed.nim | 42 ++++++++++- src/views/general.nim | 2 +- src/views/oembed.nimf | 6 ++ src/views/tweet.nim | 11 --- 10 files changed, 390 insertions(+), 32 deletions(-) create mode 100644 public/js/embedResize.js create mode 100644 public/js/embedTweet.js create mode 100644 public/js/widgets.js create mode 100644 src/views/oembed.nimf diff --git a/public/js/embedResize.js b/public/js/embedResize.js new file mode 100644 index 0000000..227d6af --- /dev/null +++ b/public/js/embedResize.js @@ -0,0 +1,26 @@ +(function() { + var embedElement = document.querySelector('.tweet-embed, .embed-video'); + if (!embedElement) return; + + var lastHeight = 0; + + function sendHeight() { + var currentHeight = embedElement.offsetHeight; + if (currentHeight !== lastHeight) { + lastHeight = currentHeight; + window.parent.postMessage(['resizeIframe', { h: currentHeight, url: location.href }], '*'); + } + } + + // Respond to height requests from parent via MessageChannel + window.addEventListener('message', function(event) { + if (event.source === window.parent && event.ports && event.ports[0]) { + event.ports[0].postMessage(embedElement.offsetHeight); + } + }); + + window.addEventListener('load', sendHeight); + new ResizeObserver(sendHeight).observe(embedElement); + + return sendHeight; +})() diff --git a/public/js/embedTweet.js b/public/js/embedTweet.js new file mode 100644 index 0000000..5b6419d --- /dev/null +++ b/public/js/embedTweet.js @@ -0,0 +1,23 @@ +// This runs after embedResize.js sets up the sendHeight function +(function(sendHeight) { + // Make images load eagerly so height updates correctly + var lazyImages = document.querySelectorAll('img[loading="lazy"]'); + for (var i = 0; i < lazyImages.length; i++) { + lazyImages[i].loading = 'eager'; + } + + // Update height when images finish loading + var allImages = document.querySelectorAll('img'); + for (var i = 0; i < allImages.length; i++) { + var img = allImages[i]; + if (!img.complete) { + img.addEventListener('load', sendHeight); + } + } + + // Open all links in new tab (we're in an iframe) + var allLinks = document.querySelectorAll('a'); + for (var i = 0; i < allLinks.length; i++) { + allLinks[i].target = '_blank'; + } +})(arguments[0]); diff --git a/public/js/widgets.js b/public/js/widgets.js new file mode 100644 index 0000000..0b897ef --- /dev/null +++ b/public/js/widgets.js @@ -0,0 +1,135 @@ +/** + * Drop-in replacement for Twitter's widgets.js + * Include this script to automatically convert twitter-tweet blockquotes to Nitter embeds + */ +(function () { + "use strict"; + + // Determine the Nitter instance URL from the script src, or fall back to current origin + var widgetScripts = document.querySelectorAll('script[src*="widgets.js"]'); + var NITTER_URL = widgetScripts.length + ? new URL(widgetScripts[widgetScripts.length - 1].src).origin + : location.origin; + + var TWEET_URL_PATTERN = + /^https?:\/\/(?:twitter\.com|x\.com)\/([^\/]+)\/status\/(\d+)/i; + + // Track iframes by URL for resize messages + var iframesByUrl = {}; + + /** + * Extract tweet info (username and ID) from a blockquote's links + */ + function findTweetInfo(blockquote) { + var links = blockquote.querySelectorAll("a"); + for (var i = 0; i < links.length; i++) { + var match = TWEET_URL_PATTERN.exec(links[i].href); + if (match) { + return { username: match[1], tweetId: match[2] }; + } + } + return null; + } + + /** + * Transform all twitter-tweet blockquotes into Nitter embed iframes + */ + function transformBlockquotes() { + var blockquotes = document.querySelectorAll("blockquote.twitter-tweet"); + + for (var i = 0; i < blockquotes.length; i++) { + var blockquote = blockquotes[i]; + var tweetInfo = findTweetInfo(blockquote); + if (!tweetInfo) continue; + + var embedUrl = + NITTER_URL + + "/" + + tweetInfo.username + + "/status/" + + tweetInfo.tweetId + + "/embed"; + + var iframe = document.createElement("iframe"); + iframe.src = embedUrl; + iframe.style.cssText = + "width: 100%; max-width: 550px; height: 250px; border: none; display: block;"; + iframe.loading = "lazy"; + + // Track iframe for resize messages + if (!iframesByUrl[embedUrl]) { + iframesByUrl[embedUrl] = []; + } + iframesByUrl[embedUrl].push(iframe); + + blockquote.parentNode.replaceChild(iframe, blockquote); + } + } + + /** + * Handle resize messages from Nitter embeds + */ + function handleResizeMessage(event) { + if (!Array.isArray(event.data) || event.data[0] !== "resizeIframe") return; + + var data = event.data[1]; + if (!data.h || data.h <= 0) return; + + var iframes = iframesByUrl[data.url]; + if (iframes) { + for (var i = 0; i < iframes.length; i++) { + iframes[i].style.height = data.h + "px"; + } + } + } + + // Remove any Twitter widget scripts that might have been loaded + var twitterScripts = document.querySelectorAll( + 'script[src*="platform.twitter.com/widgets.js"], script[src*="platform.x.com/widgets.js"]', + ); + for (var i = 0; i < twitterScripts.length; i++) { + twitterScripts[i].remove(); + } + + // Listen for resize messages from embeds + window.addEventListener("message", handleResizeMessage); + + // Transform existing blockquotes + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", transformBlockquotes); + } else { + transformBlockquotes(); + } + + // Watch for dynamically added blockquotes + var observer = new MutationObserver(function (mutations) { + for (var i = 0; i < mutations.length; i++) { + var addedNodes = mutations[i].addedNodes; + for (var j = 0; j < addedNodes.length; j++) { + var node = addedNodes[j]; + if (node.nodeType !== 1) continue; + + var isTwitterBlockquote = + node.matches && node.matches("blockquote.twitter-tweet"); + var containsTwitterBlockquote = + node.querySelector && node.querySelector("blockquote.twitter-tweet"); + + if (isTwitterBlockquote || containsTwitterBlockquote) { + transformBlockquotes(); + return; + } + } + } + }); + + if (document.body) { + observer.observe(document.body, { childList: true, subtree: true }); + } + + // Provide a fake twttr object for compatibility with sites that check for it + window.twttr = window.twttr || {}; + window.twttr.widgets = { + load: transformBlockquotes, + loaded: true, + }; +})(); diff --git a/src/routes/embed.nim b/src/routes/embed.nim index bdca60f..179ec23 100644 --- a/src/routes/embed.nim +++ b/src/routes/embed.nim @@ -1,18 +1,45 @@ # SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, strutils, strformat, options +import asyncdispatch, strutils, strformat, json import jester, karax/vdom -import ".."/[types, api] +import ".."/[types, api, formatters] import ../views/[embed, tweet, general] +include "../views/oembed.nimf" import router_utils export api, embed, vdom, tweet, general, router_utils +proc parseTweetUrl*(url: string): tuple[username, id: string] = + var path = url + if path.startsWith("https://"): + path = path[8..^1] + elif path.startsWith("http://"): + path = path[7..^1] + + const prefixes = ["twitter.com/", "x.com/", "mobile.twitter.com/", + "www.twitter.com/", "www.x.com/"] + for prefix in prefixes: + if path.startsWith(prefix): + path = path[prefix.len..^1] + let parts = path.split('/') + if parts.len >= 3 and parts[1] == "status": + let tweetId = parts[2].split('?')[0].split('#')[0] + if tweetId.len > 0 and tweetId.allCharsInSet(Digits): + return (parts[0], tweetId) + break + return ("", "") + proc createEmbedRouter*(cfg: Config) = router embed: get "/i/videos/tweet/@id": - let tweet = await getGraphTweetResult(@"id") - if tweet == nil or not tweet.hasVideos: - resp Http404 + let + tweet = await getGraphTweetResult(@"id") + prefs = requestPrefs() + + if tweet == nil: + resp renderErrorEmbed("Tweet not found", prefs, cfg, request) + + if not tweet.hasVideos: + resp renderErrorEmbed("No video in tweet", prefs, cfg, request) resp renderVideoEmbed(tweet, cfg, request) @@ -23,7 +50,7 @@ proc createEmbedRouter*(cfg: Config) = path = getPath() if tweet == nil: - resp Http404 + resp renderErrorEmbed("Tweet not found", prefs, cfg, request) resp renderTweetEmbed(tweet, path, prefs, cfg, request) @@ -34,3 +61,35 @@ proc createEmbedRouter*(cfg: Config) = redirect(&"/i/status/{id}/embed") else: resp Http404 + + get "/api/oembed": + let url = @"url" + if url.len == 0: + resp Http400, "Missing url parameter" + + let (username, tweetId) = parseTweetUrl(url) + if username.len == 0 or tweetId.len == 0: + resp Http400, "Invalid tweet URL" + + let tweet = await getGraphTweetResult(tweetId) + if tweet == nil: + resp Http404 + + let + embedUrl = getUrlPrefix(cfg) & "/" & username & "/status/" & tweetId & "/embed" + authorUrl = getUrlPrefix(cfg) & "/" & tweet.user.username + + responseHeaders().get.add(("Access-Control-Allow-Origin", "*")) + respJson %*{ + "version": "1.0", + "type": "rich", + "provider_name": cfg.title, + "provider_url": getUrlPrefix(cfg), + "author_name": tweet.user.fullname, + "author_url": authorUrl, + "url": embedUrl, + "width": 550, + "height": nil, + "cache_age": "3153600000", + "html": renderOembedIframe(embedUrl) + } diff --git a/src/routes/router_utils.nim b/src/routes/router_utils.nim index 8a26193..612a96b 100644 --- a/src/routes/router_utils.nim +++ b/src/routes/router_utils.nim @@ -4,7 +4,7 @@ from jester import Request, cookies import ../views/general import ".."/[utils, prefs, types] -export utils, prefs, types, uri +export utils, prefs, types, uri, json template savePref*(pref, value: string; req: Request; expire=false) = if not expire or pref in cookies(req): diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 93d2086..10f8b00 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -118,30 +118,114 @@ } } +body:has(> .tweet-embed) { + margin: 0; + padding: 0; + width: 100%; + min-height: 100%; + background: transparent; + overflow: hidden; +} + +html:has(body > .tweet-embed) { + background: transparent; + overflow: hidden; +} + .tweet-embed { - display: flex; - flex-direction: column; - justify-content: center; - height: 100%; + position: relative; + width: 100%; + box-sizing: border-box; background-color: var(--bg_panel); + border: 1px solid var(--border_grey); + border-radius: 12px; + overflow: hidden; + transition: background-color 0.15s ease; + + &:hover { + background-color: var(--bg_hover); + } + + .timeline-item { + pointer-events: none; + } .tweet-content { font-size: 18px; + pointer-events: none; + + a { + pointer-events: all; + } + } + + .attachments { + pointer-events: none; + + a, video, .video-overlay { + pointer-events: all; + } } .tweet-body { display: flex; flex-direction: column; - max-height: calc(100vh - 0.75em * 2); } - .card-image img { - height: auto; - } - - .avatar { + .avatar:not(.mini) { position: absolute; } + + .quote-media-container { + max-height: 600px; + } + + .quote-media-container .gallery-row .attachment, + .quote-media-container .gallery-row .attachment > video, + .quote-media-container .gallery-row .attachment > img, + .quote-media-container .still-image, + .quote-media-container .still-image img, + .still-image, + .still-image img { + max-height: 600px; + } + + &.error-embed { + display: flex; + align-items: center; + justify-content: center; + min-height: 80px; + padding: 20px; + cursor: default; + + &:hover { + background-color: var(--bg_panel); + } + + .error-panel { + margin: 0; + } + } + + .embed-footer { + position: relative; + z-index: 2; + display: block; + padding: 12px 16px; + border-top: 1px solid var(--border_grey); + background: var(--bg_elements); + color: var(--accent); + font-size: 14px; + font-weight: 500; + text-align: center; + text-decoration: none; + pointer-events: all; + transition: background-color 0.15s; + + &:hover { + background: var(--bg_hover); + } + } } .attribution { diff --git a/src/views/embed.nim b/src/views/embed.nim index 62cc76f..80b2dba 100644 --- a/src/views/embed.nim +++ b/src/views/embed.nim @@ -1,15 +1,18 @@ # SPDX-License-Identifier: AGPL-3.0-only -import options import karax/[karaxdsl, vdom] from jester import Request import ".."/[types, formatters] import general, tweet -const doctype = "\n" +const + doctype = "\n" + embedResizeJs = staticRead("../../public/js/embedResize.js") + embedTweetJs = embedResizeJs & staticRead("../../public/js/embedTweet.js") + embedErrorJs = embedResizeJs & ";requestAnimationFrame(arguments[0]);" proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = - let + let video = tweet.getVideos()[0] thumb = video.thumb vidUrl = getVideoEmbed(cfg, tweet.id) @@ -22,4 +25,37 @@ proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = tdiv(class="embed-video"): renderVideo(video, prefs, "") + script: + verbatim embedResizeJs + + result = doctype & $node + +proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string = + let node = buildHtml(html(lang="en")): + renderHead(prefs, cfg, req) + + body: + tdiv(class="tweet-embed"): + a(class="tweet-link", href=getLink(tweet), target="_blank") + renderTweet(tweet, prefs, path, mainTweet=true) + a(class="embed-footer", href=getLink(tweet), target="_blank"): + text "Read more on " & cfg.hostname + + script: + verbatim embedTweetJs + + result = doctype & $node + +proc renderErrorEmbed*(error: string; prefs: Prefs; cfg: Config; req: Request): string = + let node = buildHtml(html(lang="en")): + renderHead(prefs, cfg, req) + + body: + tdiv(class="tweet-embed error-embed"): + tdiv(class="error-panel"): + span: text error + + script: + verbatim embedErrorJs + result = doctype & $node diff --git a/src/views/general.nim b/src/views/general.nim index f223251..5ba6f7c 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=51") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=52") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7") if theme.len > 0: diff --git a/src/views/oembed.nimf b/src/views/oembed.nimf new file mode 100644 index 0000000..34f3ccc --- /dev/null +++ b/src/views/oembed.nimf @@ -0,0 +1,6 @@ +#? stdtmpl(subsChar = '$', metaChar = '#') +## SPDX-License-Identifier: AGPL-3.0-only +#proc renderOembedIframe*(embedUrl: string): string = +# result = "" + +#end proc diff --git a/src/views/tweet.nim b/src/views/tweet.nim index b66269a..8971ab8 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -5,7 +5,6 @@ from jester import Request import renderutils import ".."/[types, utils, formatters] -import general const doctype = "\n" @@ -464,13 +463,3 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; if not prefs.hideTweetStats: renderStats(tweet.stats) - -proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string = - let node = buildHtml(html(lang="en")): - renderHead(prefs, cfg, req) - - body: - tdiv(class="tweet-embed"): - renderTweet(tweet, prefs, path, mainTweet=true) - - result = doctype & $node