mirror of
https://github.com/zedeus/nitter
synced 2026-09-05 14:49:32 +00:00
Add tweet embed and oEmbed API support
This commit is contained in:
parent
06c52473ab
commit
9be0b8f826
10 changed files with 389 additions and 31 deletions
26
public/js/embedResize.js
Normal file
26
public/js/embedResize.js
Normal 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
23
public/js/embedTweet.js
Normal 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
135
public/js/widgets.js
Normal 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,
|
||||
};
|
||||
})();
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = "<!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 =
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
6
src/views/oembed.nimf
Normal file
6
src/views/oembed.nimf
Normal 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
|
||||
|
|
@ -5,7 +5,6 @@ from jester import Request
|
|||
|
||||
import renderutils
|
||||
import ".."/[types, utils, formatters]
|
||||
import general
|
||||
|
||||
const doctype = "<!DOCTYPE html>\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
|
||||
|
|
|
|||
Loading…
Reference in a new issue