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

View file

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

View file

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

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

View file

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

View file

@ -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
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 ".."/[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