Ver código fonte

Add tweet embed and oEmbed API support

Zed 1 mês atrás
pai
commit
9be0b8f826

+ 26 - 0
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;
+})()

+ 23 - 0
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]);

+ 135 - 0
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,
+  };
+})();

+ 65 - 6
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)
+      }

+ 1 - 1
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):

+ 93 - 9
src/sass/tweet/_base.scss

@@ -118,29 +118,113 @@
   }
 }
 
+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:not(.mini) {
+    position: absolute;
   }
 
-  .avatar {
-    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);
+    }
   }
 }
 

+ 39 - 3
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 = "<!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

+ 1 - 1
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:

+ 6 - 0
src/views/oembed.nimf

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

+ 0 - 11
src/views/tweet.nim

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