Add tweet embed and oEmbed API support

This commit is contained in:
Zed 2026-07-18 12:46:43 +02:00
commit 9be0b8f826
10 changed files with 389 additions and 31 deletions

26
public/js/embedResize.js Normal file
View file

@ -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;
})()

23
public/js/embedTweet.js Normal file
View file

@ -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]);

135
public/js/widgets.js Normal file
View file

@ -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,
};
})();

View file

@ -1,18 +1,45 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, strutils, strformat, options import asyncdispatch, strutils, strformat, json
import jester, karax/vdom import jester, karax/vdom
import ".."/[types, api] import ".."/[types, api, formatters]
import ../views/[embed, tweet, general] import ../views/[embed, tweet, general]
include "../views/oembed.nimf"
import router_utils import router_utils
export api, embed, vdom, tweet, general, 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) = proc createEmbedRouter*(cfg: Config) =
router embed: router embed:
get "/i/videos/tweet/@id": get "/i/videos/tweet/@id":
let tweet = await getGraphTweetResult(@"id") let
if tweet == nil or not tweet.hasVideos: tweet = await getGraphTweetResult(@"id")
resp Http404 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) resp renderVideoEmbed(tweet, cfg, request)
@ -23,7 +50,7 @@ proc createEmbedRouter*(cfg: Config) =
path = getPath() path = getPath()
if tweet == nil: if tweet == nil:
resp Http404 resp renderErrorEmbed("Tweet not found", prefs, cfg, request)
resp renderTweetEmbed(tweet, path, prefs, cfg, request) resp renderTweetEmbed(tweet, path, prefs, cfg, request)
@ -34,3 +61,35 @@ proc createEmbedRouter*(cfg: Config) =
redirect(&"/i/status/{id}/embed") redirect(&"/i/status/{id}/embed")
else: else:
resp Http404 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)
}

View file

@ -4,7 +4,7 @@ from jester import Request, cookies
import ../views/general import ../views/general
import ".."/[utils, prefs, types] import ".."/[utils, prefs, types]
export utils, prefs, types, uri export utils, prefs, types, uri, json
template savePref*(pref, value: string; req: Request; expire=false) = template savePref*(pref, value: string; req: Request; expire=false) =
if not expire or pref in cookies(req): if not expire or pref in cookies(req):

View file

@ -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 { .tweet-embed {
display: flex; position: relative;
flex-direction: column; width: 100%;
justify-content: center; box-sizing: border-box;
height: 100%;
background-color: var(--bg_panel); 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 { .tweet-content {
font-size: 18px; font-size: 18px;
pointer-events: none;
a {
pointer-events: all;
}
}
.attachments {
pointer-events: none;
a, video, .video-overlay {
pointer-events: all;
}
} }
.tweet-body { .tweet-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: calc(100vh - 0.75em * 2);
} }
.card-image img { .avatar:not(.mini) {
height: auto;
}
.avatar {
position: absolute; 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 { .attribution {

View file

@ -1,12 +1,15 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import options
import karax/[karaxdsl, vdom] import karax/[karaxdsl, vdom]
from jester import Request from jester import Request
import ".."/[types, formatters] import ".."/[types, formatters]
import general, tweet import general, tweet
const doctype = "<!DOCTYPE html>\n" const
doctype = "<!DOCTYPE html>\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 = proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
let let
@ -22,4 +25,37 @@ proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
tdiv(class="embed-video"): tdiv(class="embed-video"):
renderVideo(video, prefs, "") 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 result = doctype & $node

View file

@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
buildHtml(head): 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") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7")
if theme.len > 0: if theme.len > 0:

6
src/views/oembed.nimf Normal file
View file

@ -0,0 +1,6 @@
#? stdtmpl(subsChar = '$', metaChar = '#')
## SPDX-License-Identifier: AGPL-3.0-only
#proc renderOembedIframe*(embedUrl: string): string =
# result = ""
<iframe src="$embedUrl" style="width:100%;max-width:550px;height:250px;border:none" scrolling="no" loading="lazy" onload="let c=new MessageChannel;c.port1.onmessage=e=>this.style.height=e.data+'px';this.contentWindow.postMessage('','*',[c.port2])"></iframe>
#end proc

View file

@ -5,7 +5,6 @@ from jester import Request
import renderutils import renderutils
import ".."/[types, utils, formatters] import ".."/[types, utils, formatters]
import general
const doctype = "<!DOCTYPE html>\n" const doctype = "<!DOCTYPE html>\n"
@ -464,13 +463,3 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
if not prefs.hideTweetStats: if not prefs.hideTweetStats:
renderStats(tweet.stats) 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