From 2f07fa6151bf46f8fe704afcd0ee97d71c83dd7b Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 8 Jul 2026 00:11:52 +0200 Subject: [PATCH 01/29] Sort replies by relevance, recency, or likes Fixes #1336 --- src/api.nim | 18 +++++++++--------- src/consts.nim | 1 + src/routes/status.nim | 12 +++++++----- src/sass/tweet/thread.scss | 33 +++++++++++++++++++++++++++++++++ src/types.nim | 3 +++ src/views/general.nim | 2 +- src/views/status.nim | 26 ++++++++++++++++++++++---- src/views/timeline.nim | 4 ++-- tests/base.py | 2 ++ tests/test_reply_sort.py | 37 +++++++++++++++++++++++++++++++++++++ 10 files changed, 117 insertions(+), 21 deletions(-) create mode 100644 tests/test_reply_sort.py diff --git a/src/api.nim b/src/api.nim index f73ef10..23fbcec 100644 --- a/src/api.nim +++ b/src/api.nim @@ -35,8 +35,8 @@ proc userTweetsUrl(id: string; cursor: string): ApiReq = proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq = return apiReq(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles, skipTid=true) -proc tweetDetailUrl(id: string; cursor: string): ApiReq = - return apiReq(graphTweet, tweetVars % [id, cursor]) +proc tweetDetailUrl(id, cursor: string; mode = Relevance): ApiReq = + return apiReq(graphTweet, tweetVars % [id, cursor, $mode]) # let cookieVars = tweetDetailVars % [id, cursor] # result = ApiReq( # cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles), @@ -230,21 +230,21 @@ proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = js = await fetch(url) result = parseGraphTweetResult(js) -proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} = +proc getGraphTweet(id: string; after=""; mode = Relevance): Future[Conversation] {.async.} = if id.len == 0: return let cursor = cursorParam(after) - js = await fetch(tweetDetailUrl(id, cursor)) + js = await fetch(tweetDetailUrl(id, cursor, mode)) result = parseGraphConversation(js, id) -proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} = - result = (await getGraphTweet(id, after)).replies +proc getReplies*(id, after: string; mode = Relevance): Future[Result[Chain]] {.async.} = + result = (await getGraphTweet(id, after, mode)).replies result.beginning = after.len == 0 -proc getTweet*(id: string; after=""): Future[Conversation] {.async.} = - result = await getGraphTweet(id) +proc getTweet*(id: string; after=""; mode = Relevance): Future[Conversation] {.async.} = + result = await getGraphTweet(id, mode=mode) if after.len > 0: - result.replies = await getReplies(id, after) + result.replies = await getReplies(id, after, mode) proc getGraphEditHistory*(id: string): Future[EditHistory] {.async.} = if id.len == 0: return diff --git a/src/consts.nim b/src/consts.nim index 899a06c..afef54d 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -92,6 +92,7 @@ const tweetVars* = """{ "postId": "$1", $2 + "ranking_mode": "$3", "includeHasBirdwatchNotes": false, "includePromotedContent": false, "withBirdwatchNotes": true, diff --git a/src/routes/status.nim b/src/routes/status.nim index f7fb1bb..870b6d7 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -21,16 +21,18 @@ proc createStatusRouter*(cfg: Config) = if id.len > 19 or id.any(c => not c.isDigit): resp Http404, showError("Invalid tweet ID", cfg) - let prefs = requestPrefs() + let + prefs = requestPrefs() + sort = parseEnum[RankingMode](@"sort".toLowerAscii.capitalizeAscii, Relevance) # used for the infinite scroll feature if @"scroll".len > 0: - let replies = await getReplies(id, getCursor()) + let replies = await getReplies(id, getCursor(), sort) if replies.content.len == 0: resp Http204 - resp $renderReplies(replies, prefs, getPath()) + resp $renderReplies(replies, prefs, getPath(), sort=sort) - let conv = await getTweet(id, getCursor()) + let conv = await getTweet(id, getCursor(), sort) if conv == nil or conv.tweet == nil or conv.tweet.id == 0: var error = "Tweet not found" @@ -64,7 +66,7 @@ proc createStatusRouter*(cfg: Config) = elif card.video.isSome(): images = @[card.video.get().thumb] - let html = renderConversation(conv, prefs, getPath() & "#m") + let html = renderConversation(conv, prefs, getPath() & "#m", sort) resp renderMain(html, request, cfg, prefs, title, desc, ogTitle, images=images, video=video) diff --git a/src/sass/tweet/thread.scss b/src/sass/tweet/thread.scss index c5165d7..c7b50ba 100644 --- a/src/sass/tweet/thread.scss +++ b/src/sass/tweet/thread.scss @@ -19,6 +19,39 @@ margin-bottom: 10px; } +.reply-sort { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 2px 14px; + margin-bottom: 10px; + padding: 8px 12px; + background-color: var(--bg_panel); + font-size: 14px; +} + +.reply-sort-label { + color: var(--fg_faded); + margin-right: 2px; +} + +.reply-sort-option { + color: var(--tab); + font-weight: bold; + text-decoration: none; + border-bottom: 0.1rem solid transparent; + + &:hover { + color: var(--fg_color); + text-decoration: none; + } + + &.active { + color: var(--tab_selected); + border-bottom-color: var(--tab_selected); + } +} + .main-tweet, .replies, .edit-history > div { diff --git a/src/types.nim b/src/types.nim index a490316..5fac273 100644 --- a/src/types.nim +++ b/src/types.nim @@ -176,6 +176,9 @@ type QueryKind* = enum posts, replies, media, users, tweets, userList, followers, following + RankingMode* = enum + Relevance, Recency, Likes + Query* = object kind*: QueryKind view*: string diff --git a/src/views/general.nim b/src/views/general.nim index 37213c2..e6203c7 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=45") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=46") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7") if theme.len > 0: diff --git a/src/views/status.nim b/src/views/status.nim index fdb4ae9..39af8fa 100644 --- a/src/views/status.nim +++ b/src/views/status.nim @@ -28,7 +28,22 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode = if thread.hasMore: renderMoreReplies(thread) -proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: Tweet = nil): VNode = +proc renderReplySort(sort: RankingMode): VNode = + buildHtml(tdiv(class="reply-sort")): + span(class="reply-sort-label"): text "Sort replies:" + for mode in RankingMode: + let + cls = if mode == sort: "reply-sort-option active" + else: "reply-sort-option" + label = case mode + of Relevance: "Relevant" + of Recency: "Recent" + of Likes: "Liked" + a(class=cls, href=("?sort=" & $mode & "#r")): + text label + +proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; + tweet: Tweet = nil; sort = Relevance): VNode = buildHtml(tdiv(class="replies", id="r")): var hasReplies = false var replyCount = 0 @@ -40,9 +55,11 @@ proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: T if hasReplies and replies.bottom.len > 0: if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies: - renderMore(Query(), replies.bottom, focus="#r") + let extra = if sort == Relevance: "" else: "sort=" & $sort & "&" + renderMore(Query(), replies.bottom, focus="#r", extra=extra) -proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode = +proc renderConversation*(conv: Conversation; prefs: Prefs; path: string; + sort = Relevance): VNode = let hasAfter = conv.after.content.len > 0 let threadId = conv.tweet.threadId buildHtml(tdiv(class="conversation")): @@ -75,7 +92,8 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode if not conv.replies.beginning: renderNewer(Query(), getLink(conv.tweet), focus="#r") if conv.replies.content.len > 0 or conv.replies.bottom.len > 0: - renderReplies(conv.replies, prefs, path, conv.tweet) + renderReplySort(sort) + renderReplies(conv.replies, prefs, path, conv.tweet, sort) renderToTop(focus="#m") diff --git a/src/views/timeline.nim b/src/views/timeline.nim index 9456bf9..0e45dc2 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -50,9 +50,9 @@ proc renderNewer*(query: Query; path: string; focus=""): VNode = a(href=(p & url)): text "Load newest" -proc renderMore*(query: Query; cursor: string; focus=""): VNode = +proc renderMore*(query: Query; cursor: string; focus=""; extra=""): VNode = buildHtml(tdiv(class="show-more")): - a(href=(&"?{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")): + a(href=(&"?{extra}{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")): text "Load more" proc renderNoMore(): VNode = diff --git a/tests/base.py b/tests/base.py index 6d795e1..f5e0056 100644 --- a/tests/base.py +++ b/tests/base.py @@ -71,6 +71,8 @@ class Conversation(object): thread = '.reply' tweet = '.timeline-item' tweet_text = '.tweet-content' + reply_sort = '.reply-sort' + reply_sort_active = '.reply-sort-option.active' class Poll(object): diff --git a/tests/test_reply_sort.py b/tests/test_reply_sort.py new file mode 100644 index 0000000..1d89e5d --- /dev/null +++ b/tests/test_reply_sort.py @@ -0,0 +1,37 @@ +from parameterized import parameterized + +from base import BaseTestCase, Conversation + +sort_modes = [ + ['jack/status/20', 'Relevant'], + ['jack/status/20?sort=relevance', 'Relevant'], + ['jack/status/20?sort=recency', 'Recent'], + ['jack/status/20?sort=likes', 'Liked'], + ['jack/status/20?sort=garbage', 'Relevant'], + ['jack/status/20?sort=%3Cscript%3E', 'Relevant'], +] + + +class ReplySortTest(BaseTestCase): + @parameterized.expand(sort_modes) + def test_active_mode(self, page, expected_active): + self.open_nitter(page) + self.assert_element_visible(Conversation.reply_sort) + active = self.get_text(Conversation.reply_sort_active) + self.assert_equal(active.strip(), expected_active) + + def test_all_three_options_present(self): + self.open_nitter('jack/status/20') + options = self.find_elements('.reply-sort-option') + labels = [o.text.strip() for o in options] + self.assert_equal(labels, ['Relevant', 'Recent', 'Liked']) + + def test_option_links_carry_sort_param(self): + self.open_nitter('jack/status/20') + for slug in ['Relevance', 'Recency', 'Likes']: + self.assert_element(f'.reply-sort-option[href="?sort={slug}#r"]') + + def test_load_more_preserves_sort(self): + self.open_nitter('jack/status/20?sort=Likes') + href = self.get_attribute('.replies .show-more a', 'href') + self.assert_true('sort=Likes' in href, f'sort missing from: {href}') From 6b86aaa27ffcedc0b780772495b56b9157a6fde4 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 8 Jul 2026 00:11:52 +0200 Subject: [PATCH 02/29] Triple pytest timeout multiplier for cold loads --- tests/pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/pyproject.toml b/tests/pyproject.toml index ade5000..323f114 100644 --- a/tests/pyproject.toml +++ b/tests/pyproject.toml @@ -6,3 +6,6 @@ package-mode = false [tool.poetry.dependencies] python = "^3.14" seleniumbase = "4.46.5" + +[tool.pytest.ini_options] +addopts = "--timeout_multiplier=3" From 0375145ea28d38b32e411834b14018913462abd4 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 8 Jul 2026 01:55:25 +0200 Subject: [PATCH 03/29] Fix crash when TID pair fetch fails --- src/tid.nim | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/tid.nim b/src/tid.nim index 7b453fb..ba1f8ec 100644 --- a/src/tid.nim +++ b/src/tid.nim @@ -16,14 +16,16 @@ var proc getPair(): Future[TidPair] {.async.} = if cachedPairs.len == 0 or int(epochTime()) - lastCached > ttlSec: - lastCached = int(epochTime()) - let client = newAsyncHttpClient() defer: client.close() let resp = await client.get(pairsUrl) if resp.status == $Http200: cachedPairs = parseTidPairs(await resp.body) + lastCached = int(epochTime()) + + if cachedPairs.len == 0: + raise newException(ValueError, "Failed to fetch x-client-transaction-id pairs") return sample(cachedPairs) From 61246df9de96f9338dfbc8424f89f7f7bb26800f Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 8 Jul 2026 18:56:12 +0200 Subject: [PATCH 04/29] Support Top, Latest, Media, and Lists search Fixes #1335 --- src/api.nim | 32 +++++++--- src/parser.nim | 102 +++++++++++++++++++------------ src/parserutils.nim | 6 +- src/query.nim | 4 +- src/routes/rss.nim | 2 +- src/routes/search.nim | 20 ++++++- src/sass/search.scss | 74 +++++++++++++++++++++++ src/sass/timeline.scss | 20 +++++++ src/types.nim | 8 ++- src/views/general.nim | 2 +- src/views/search.nim | 62 +++++++++++++++---- src/views/timeline.nim | 78 ++++++++++++++++++++++++ tests/base.py | 11 +++- tests/test_search.py | 132 +++++++++++++++++++++++++++++++++++++++-- 14 files changed, 475 insertions(+), 78 deletions(-) diff --git a/src/api.nim b/src/api.nim index 23fbcec..041a492 100644 --- a/src/api.nim +++ b/src/api.nim @@ -263,12 +263,19 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = if q.len == 0 or q == emptyQuery: return Timeline(query: query, beginning: true) + let product = + case query.kind + of top: "Top" + # profile media feeds (RSS, multi-user timelines) must stay chronological + of media: (if query.fromUser.len == 0: "Media" else: "Latest") + else: "Latest" + var variables = %*{ "rawQuery": q, "count": 20, "querySource": "typed_query", - "product": "Latest", + "product": product, "withGrokTranslatedBio":true, "withQuickPromoteEligibilityTweetFields":false } @@ -283,33 +290,40 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = # when no more items are available the API just returns the last page in # full. this detects that and clears the page instead. - if after.len > 0 and result.bottom.len > 0 and maxId.len == 0 and - after[0..<64] == result.bottom[0..<64]: + let prefix = min(64, min(after.len, result.bottom.len)) + if prefix > 0 and maxId.len == 0 and + after[0.. 0: variables["cursor"] = % after - result.beginning = false - let + let url = apiReq(graphSearchTimeline, $variables) js = await fetch(url) - result = parseGraphSearch[User](js, after) + result = parseGraphSearch[T](js, after) result.query = query +proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] = + getGraphProductSearch[User](query, "People", after) + +proc getGraphListSearch*(query: Query; after=""): Future[Result[ListSearchResult]] = + getGraphProductSearch[ListSearchResult](query, "Lists", after) + proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} = if id.len == 0: return let js = await fetch(mediaUrl(id, "", 30)) diff --git a/src/parser.nim b/src/parser.nim index 04a7d33..30f70d2 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -206,6 +206,20 @@ proc parseGraphCommunity*(js: JsonNode): Community = if tag.len > 0: result.hashtags.add tag +proc parseListObject(js: JsonNode; owner: User): List = + List( + id: js{"id_str"}.getStr, + name: js{"name"}.getStr, + username: owner.username, + userId: owner.id, + description: js{"description"}.getStr, + members: js{"member_count"}.getInt, + banner: select( + js{"custom_banner_media", "media_info", "original_img_url"}, + js{"default_banner_media", "media_info", "original_img_url"} + ).getImageStr + ) + proc parseGraphList*(js: JsonNode): List = if js.isNull: return @@ -215,15 +229,17 @@ proc parseGraphList*(js: JsonNode): List = if list.isNull: return - result = List( - id: list{"id_str"}.getStr, - name: list{"name"}.getStr, - username: list{"user_results", "result", "legacy", "screen_name"}.getStr, - userId: list{"user_results", "result", "rest_id"}.getStr, - description: list{"description"}.getStr, - members: list{"member_count"}.getInt, - banner: list{"custom_banner_media", "media_info", "original_img_url"}.getImageStr + result = parseListObject(list, parseGraphUser(list)) + +proc parseGraphSearchList(js: JsonNode): ListSearchResult = + let owner = parseGraphUser(js) + result = ListSearchResult( + list: parseListObject(js, owner), + owner: owner, + followersContext: js{"followers_context"}.getStr ) + for url in js{"facepile_urls"}: + result.facepiles.add url.getStr proc parsePoll(js: JsonNode): Poll = let vals = js{"binding_values"} @@ -808,20 +824,31 @@ proc parseGraphEditHistory*(js: JsonNode; tweetId: string): EditHistory = if tweetResult.notNull: result.history.add parseGraphTweet(tweetResult) +iterator extractTweetsFromModuleItems(items: JsonNode): Tweet = + for item in items: + with tweetResult, item.getTweetResult("item"): + let tweet = parseGraphTweet(tweetResult) + if not tweet.available: + tweet.id = item.getEntryId.getId + yield tweet + +iterator extractListsFromItems(items: JsonNode): ListSearchResult = + for item in items: + with listJs, item{"item", "itemContent", "list"}: + let r = parseGraphSearchList(listJs) + if r.list.id.len > 0: + yield r + proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] = with tweetResult, getTweetResult(e): - var tweet = parseGraphTweet(tweetResult) + let tweet = parseGraphTweet(tweetResult) if not tweet.available: tweet.id = e.getEntryId.getId result.add tweet return - for item in e{"content", "items"}: - with tweetResult, item.getTweetResult("item"): - var tweet = parseGraphTweet(tweetResult) - if not tweet.available: - tweet.id = item.getEntryId.getId - result.add tweet + for tweet in extractTweetsFromModuleItems(e{"content", "items"}): + result.add tweet proc parseGraphTimeline*(js: JsonNode; after=""): Profile = result = Profile(tweets: Timeline(beginning: after.len == 0)) @@ -836,12 +863,8 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile = for i in instructions: if i{"moduleItems"}.notNull: - for item in i{"moduleItems"}: - with tweetResult, item.getTweetResult("item"): - let tweet = parseGraphTweet(tweetResult) - if not tweet.available: - tweet.id = item.getEntryId.getId - result.tweets.content.add tweet + for tweet in extractTweetsFromModuleItems(i{"moduleItems"}): + result.tweets.content.add tweet continue if i{"entries"}.notNull: @@ -876,18 +899,13 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = for i in instructions: if i{"moduleItems"}.notNull: - for item in i{"moduleItems"}: - with tweetResult, item.getTweetResult("item"): - let t = parseGraphTweet(tweetResult) - if not t.available: - t.id = item.getEntryId.getId + for t in extractTweetsFromModuleItems(i{"moduleItems"}): + let photo = extractGalleryPhoto(t) + if photo.url.len > 0: + result.add photo - let photo = extractGalleryPhoto(t) - if photo.url.len > 0: - result.add photo - - if result.len == 16: - return + if result.len == 16: + return continue if i.getTypeName != "TimelineAddEntries": @@ -904,7 +922,7 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = if result.len == 16: return -proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] = +proc parseGraphSearch*[T: User | Tweets | ListSearchResult](js: JsonNode; after=""): Result[T] = result = Result[T](beginning: after.len == 0) let instructions = select( @@ -920,19 +938,27 @@ proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] = for e in instruction{"entries"}: let entryId = e.getEntryId when T is Tweets: - if entryId.startsWith("tweet"): - with tweetRes, getTweetResult(e): - let tweet = parseGraphTweet(tweetRes) - if not tweet.available: - tweet.id = entryId.getId + if entryId.startsWith("tweet") or entryId.startsWith("search-grid"): + for tweet in extractTweetsFromEntry(e): result.content.add tweet elif T is User: if entryId.startsWith("user"): with userRes, e{"content", "itemContent"}: result.content.add parseGraphUser(userRes) + elif T is ListSearchResult: + if entryId.startsWith("list-search"): + for list in extractListsFromItems(e{"content", "items"}): + result.content.add list if entryId.startsWith("cursor-bottom"): result.bottom = e{"content", "value"}.getStr + elif typ == "TimelineAddToModule": + when T is Tweets: + for tweet in extractTweetsFromModuleItems(instruction{"moduleItems"}): + result.content.add tweet + elif T is ListSearchResult: + for list in extractListsFromItems(instruction{"moduleItems"}): + result.content.add list elif typ == "TimelineReplaceEntry": if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"): result.bottom = instruction{"entry", "content", "value"}.getStr diff --git a/src/parserutils.nim b/src/parserutils.nim index 07aa088..8db96a5 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -98,9 +98,9 @@ proc getTimeFromMsStr*(js: JsonNode): DateTime = proc getId*(id: string): int64 {.inline.} = let start = id.rfind("-") - if start < 0: - return parseBiggestInt(id) - return parseBiggestInt(id[start + 1 ..< id.len]) + try: + parseBiggestInt(if start < 0: id else: id[start + 1 ..< id.len]) + except ValueError: 0'i64 proc getId*(js: JsonNode): int64 {.inline.} = case js.kind diff --git a/src/query.nim b/src/query.nim index 38fe6f4..f541d27 100644 --- a/src/query.nim +++ b/src/query.nim @@ -104,7 +104,9 @@ proc genQueryUrl*(query: Query): string = if query.view.len > 0: params.add "view=" & encodeUrl(query.view) - if query.kind in {tweets, users}: + # media doubles as the profile media tab, where f isn't part of the URL scheme + if query.kind in {tweets, users, lists, top} or + (query.kind == media and query.fromUser.len == 0): params.add &"f={query.kind}" if query.text.len > 0: params.add "q=" & encodeUrl(query.text) diff --git a/src/routes/rss.nim b/src/routes/rss.nim index 444dc70..f2582c1 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -68,7 +68,7 @@ proc createRssRouter*(cfg: Config) = let prefs = requestPrefs() query = initQuery(params(request)) - if query.kind != tweets: + if query.kind notin {tweets, top, media}: resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg) let diff --git a/src/routes/search.nim b/src/routes/search.nim index ba023fd..7c7fd14 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -20,9 +20,21 @@ proc createSearchRouter*(cfg: Config) = let prefs = requestPrefs() - query = initQuery(params(request)) title = "Search" & (if q.len > 0: " (" & q & ")" else: "") + var query = initQuery(params(request)) + # x.com URL compat: f=user and f=list map to our kind names + # (f=live already falls back to tweets/Latest; f=media matches natively) + if @"f" == "user": + query.kind = users + elif @"f" == "list": + query.kind = lists + + # media searches support view modes, defaulting like /user/media + if query.kind == QueryKind.media and + query.view notin ["timeline", "grid", "gallery"]: + query.view = prefs.mediaView.toLowerAscii + case query.kind of users: if "," in q: @@ -33,12 +45,16 @@ proc createSearchRouter*(cfg: Config) = except InternalError: users = Result[User](beginning: true, query: query) resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) - of tweets: + of tweets, top, QueryKind.media: let tweets = await getGraphTweetSearch(query, getCursor()) rss = if cfg.enableRSSSearch: "/search/rss?" & genQueryUrl(query) else: "" resp renderMain(renderTweetSearch(tweets, prefs, getPath()), request, cfg, prefs, title, rss=rss) + of lists: + let listResults = await getGraphListSearch(query, getCursor()) + resp renderMain(renderListSearch(listResults, prefs, getPath()), + request, cfg, prefs, title) else: resp Http404, showError("Invalid search", cfg) diff --git a/src/sass/search.scss b/src/sass/search.scss index c2adaf7..fa2a2d8 100644 --- a/src/sass/search.scss +++ b/src/sass/search.scss @@ -107,6 +107,80 @@ grid-column-gap: 10px; } +.list-result { + display: flex; + align-items: flex-start; + + .list-result-banner { + flex-shrink: 0; + width: 56px; + height: 56px; + margin-right: 10px; + border-radius: 8px; + overflow: hidden; + background-color: var(--darker_grey); + // stay above the tweet-link overlay's hover background + z-index: 1; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + .list-result-body { + min-width: 0; + pointer-events: none; + z-index: 1; + + a { + pointer-events: all; + } + } + + .list-result-title { + align-items: baseline; + } + + .list-members { + flex-shrink: 0; + margin-left: 0.3em; + color: var(--fg_faded); + } + + .list-result-context { + display: flex; + align-items: center; + flex-wrap: wrap; + margin-top: 2px; + color: var(--fg_faded); + + a { + color: var(--fg_dark); + } + + a.fullname { + color: var(--fg_color); + } + + .list-facepile { + width: 20px; + height: 20px; + border-radius: 50%; + margin-right: 4px; + } + } + + .list-result-description { + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: all; + } +} + .profile-tabs { @include search-resize(820px, 5); @include search-resize(715px, 4); diff --git a/src/sass/timeline.scss b/src/sass/timeline.scss index e167448..b7d4a9f 100644 --- a/src/sass/timeline.scss +++ b/src/sass/timeline.scss @@ -4,6 +4,26 @@ @include panel(100%, 600px); } +.timeline-container.media-only { + max-width: none; + width: 100%; + padding: 0 10px; + box-sizing: border-box; + + > .tab, + > .timeline-header { + max-width: 900px; + margin-left: auto; + margin-right: auto; + } +} + +@media (max-width: 700px) { + .timeline-container.media-only { + padding: 0; + } +} + .timeline > div:not(:first-child) { border-top: 1px solid var(--border_grey); } diff --git a/src/types.nim b/src/types.nim index 5fac273..d19a10d 100644 --- a/src/types.nim +++ b/src/types.nim @@ -174,7 +174,7 @@ type variants*: seq[VideoVariant] QueryKind* = enum - posts, replies, media, users, tweets, userList, followers, following + posts, replies, media, users, tweets, userList, followers, following, lists, top RankingMode* = enum Relevance, Recency, Likes @@ -388,6 +388,12 @@ type members*: int banner*: string + ListSearchResult* = object + list*: List + owner*: User + followersContext*: string + facepiles*: seq[string] + CommunityRule* = object name*: string description*: string diff --git a/src/views/general.nim b/src/views/general.nim index e6203c7..afdda9f 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=46") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=50") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7") if theme.len > 0: diff --git a/src/views/search.nim b/src/views/search.nim index c79f3fe..fe50280 100644 --- a/src/views/search.nim +++ b/src/views/search.nim @@ -39,28 +39,45 @@ proc renderProfileTabs*(query: Query; username: string): VNode = li(class=query.getTabClass(tweets)): a(href=(link & "/search")): text "Search" -proc renderMediaViewTabs*(query: Query; username: string): VNode = +proc mediaViewUrl(query: Query; view: string): string = + var q = query + q.view = view + "?" & genQueryUrl(q) + +proc renderMediaViewTabs*(query: Query): VNode = let currentView = if query.view.len > 0: query.view else: "timeline" - let base = "/" & username & "/media?view=" func cls(view: string): string = if currentView == view: "tab-item active" else: "tab-item" buildHtml(ul(class="tab media-view-tabs")): li(class=cls("timeline")): - a(href=(base & "timeline")): text "Timeline" + a(href=query.mediaViewUrl("timeline")): text "Timeline" li(class=cls("grid")): - a(href=(base & "grid")): text "Grid" + a(href=query.mediaViewUrl("grid")): text "Grid" li(class=cls("gallery")): - a(href=(base & "gallery")): text "Gallery" + a(href=query.mediaViewUrl("gallery")): text "Gallery" proc renderSearchTabs*(query: Query): VNode = var q = query + # the media view mode only applies to the Media tab + q.view = "" buildHtml(ul(class="tab")): + li(class=query.getTabClass(top)): + q.kind = top + a(href=("?" & genQueryUrl(q))): text "Top" li(class=query.getTabClass(tweets)): q.kind = tweets - a(href=("?" & genQueryUrl(q))): text "Tweets" + a(href=("?" & genQueryUrl(q))): text "Latest" + li(class=query.getTabClass(media)): + q.kind = media + q.view = query.view + a(href=("?" & genQueryUrl(q))): text "Media" li(class=query.getTabClass(users)): q.kind = users + q.view = "" a(href=("?" & genQueryUrl(q))): text "Users" + li(class=query.getTabClass(lists)): + q.kind = lists + a(href=("?" & genQueryUrl(q))): text "Lists" proc isPanelOpen(q: Query): bool = q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or @@ -71,7 +88,7 @@ proc renderSearchPanel*(query: Query): VNode = let action = if user.len > 0: &"/{user}/search" else: "/search" buildHtml(form(`method`="get", action=action, class="search-field", autocomplete="off")): - hiddenField("f", "tweets") + hiddenField("f", $query.kind) genInput("q", "", query.text, "Enter search...", class="pref-inline") button(`type`="submit"): icon "search" @@ -102,7 +119,11 @@ proc renderSearchPanel*(query: Query): VNode = proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; pinned=none(Tweet)): VNode = let query = results.query - buildHtml(tdiv(class="timeline-container")): + let containerClass = + if query.fromUser.len == 0 and query.kind == media and + query.view == "gallery": "timeline-container media-only" + else: "timeline-container" + buildHtml(tdiv(class=containerClass)): if query.fromUser.len > 1: tdiv(class="timeline-header"): text query.fromUser.join(" | ") @@ -111,7 +132,7 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; if query.kind != media or query.view != "gallery": renderProfileTabs(query, query.fromUser.join(",")) if query.kind == media and query.fromUser.len == 1: - renderMediaViewTabs(query, query.fromUser[0]) + renderMediaViewTabs(query) if query.fromUser.len == 0 or query.kind == tweets: tdiv(class="timeline-header"): @@ -119,16 +140,31 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; if query.fromUser.len == 0: renderSearchTabs(query) + if query.kind == media: + renderMediaViewTabs(query) renderTimelineTweets(results, prefs, path, pinned) +proc renderSearchForm(kind, placeholder, value: string): VNode = + buildHtml(form(`method`="get", action="/search", + class="search-field", autocomplete="off")): + hiddenField("f", kind) + genInput("q", "", value, placeholder, class="pref-inline") + button(`type`="submit"): icon "search" + proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode = buildHtml(tdiv(class="timeline-container")): tdiv(class="timeline-header"): - form(`method`="get", action="/search", class="search-field", autocomplete="off"): - hiddenField("f", "users") - genInput("q", "", results.query.text, "Enter username...", class="pref-inline") - button(`type`="submit"): icon "search" + renderSearchForm("users", "Enter username...", results.query.text) renderSearchTabs(results.query) renderTimelineUsers(results, prefs) + +proc renderListSearch*(results: Result[ListSearchResult]; prefs: Prefs; + path: string): VNode = + buildHtml(tdiv(class="timeline-container")): + tdiv(class="timeline-header"): + renderSearchForm("lists", "Enter search...", results.query.text) + + renderSearchTabs(results.query) + renderTimelineLists(results, prefs, path) diff --git a/src/views/timeline.nim b/src/views/timeline.nim index 0e45dc2..9c85c9c 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -114,6 +114,84 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode = else: renderNoMore() +proc mentionUsername(word: string): string = + # "@user" -> "user" for well-formed mentions, "" otherwise + if word.len > 1 and word[0] == '@' and + word[1 .. ^1].allCharsInSet({'A'..'Z', 'a'..'z', '0'..'9', '_'}): + word[1 .. ^1] + else: "" + +proc mentionedUser(s: string): string = + # last @mention in strings like "65 followers including @user" + let words = s.split(' ') + for i in countdown(words.high, 0): + result = mentionUsername(words[i]) + if result.len > 0: return + +proc renderMentionedText(s: string): VNode = + # linkify @mentions in plain API strings like "65 followers including @user" + let words = s.split(' ') + buildHtml(span): + for i in 0 ..< words.len: + if i > 0: text " " + let username = mentionUsername(words[i]) + if username.len > 0: + a(href=("/" & username)): text words[i] + else: + text words[i] + +proc renderListCard(r: ListSearchResult): VNode = + let listUrl = "/i/lists/" & r.list.id + buildHtml(tdiv(class="timeline-item list-result")): + a(class="tweet-link", href=listUrl) + a(class="list-result-banner", href=listUrl): + if r.list.banner.len > 0: + genImg(r.list.banner) + tdiv(class="list-result-body"): + tdiv(class="list-result-title fullname-and-username"): + a(class="list-name fullname", href=listUrl): text r.list.name + span(class="list-members"): + text &"ยท {insertSep($r.list.members, ',')} members" + tdiv(class="list-result-context"): + if r.followersContext.len > 0: + # the first facepile belongs to the "including @user" account + let mentioned = mentionedUser(r.followersContext) + for i in 0 ..< r.facepiles.len: + if i == 0 and mentioned.len > 0: + a(class="facepile-link", href=("/" & mentioned)): + genImg(r.facepiles[i], class="list-facepile") + else: + genImg(r.facepiles[i], class="list-facepile") + renderMentionedText(r.followersContext) + else: + if r.owner.username.len > 0: + a(class="facepile-link", href=("/" & r.owner.username)): + genImg(r.owner.getUserPic("_mini"), class="list-facepile") + else: + genImg(r.owner.getUserPic("_mini"), class="list-facepile") + linkUser(r.owner, class="fullname") + linkUser(r.owner, class="username") + if r.list.description.len > 0: + tdiv(class="list-result-description"): + text r.list.description + +proc renderTimelineLists*(results: Result[ListSearchResult]; prefs: Prefs; + path=""): VNode = + buildHtml(tdiv(class="timeline")): + if not results.beginning: + renderNewer(results.query, path) + + if results.content.len > 0: + for list in results.content: + renderListCard(list) + if results.bottom.len > 0: + renderMore(results.query, results.bottom) + renderToTop() + elif results.beginning: + renderNoneFound() + else: + renderNoMore() + proc filterThreads(threads: seq[Tweets]; prefs: Prefs): seq[Tweets] = var retweets: seq[int64] for thread in threads: diff --git a/tests/base.py b/tests/base.py index f5e0056..841094d 100644 --- a/tests/base.py +++ b/tests/base.py @@ -55,14 +55,19 @@ class Timeline(object): protected = '.timeline-protected' photo_rail = '.photo-rail-grid' media_view_tabs = '.media-view-tabs' - media_view_timeline = '.media-view-tabs a[href$="media?view=timeline"]' - media_view_grid = '.media-view-tabs a[href$="media?view=grid"]' - media_view_gallery = '.media-view-tabs a[href$="media?view=gallery"]' + media_view_timeline = '.media-view-tabs a[href*="view=timeline"]' + media_view_grid = '.media-view-tabs a[href*="view=grid"]' + media_view_gallery = '.media-view-tabs a[href*="view=gallery"]' media_view_active = '.media-view-tabs .tab-item.active a' grid_view = '.timeline.media-grid-view' gallery_view = '.timeline.media-gallery-view' +class Search(object): + tab_item = '.tab .tab-item' + tab_active = '.tab .tab-item.active a' + + class Conversation(object): main = '.main-tweet' before = '.before-tweet' diff --git a/tests/test_search.py b/tests/test_search.py index 62c4640..0f5456f 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,9 +1,129 @@ -from base import BaseTestCase from parameterized import parameterized +from base import BaseTestCase, Search -#class SearchTest(BaseTestCase): - #@parameterized.expand([['@mobile_test'], ['@mobile_test_2']]) - #def test_username_search(self, username): - #self.search_username(username) - #self.assert_text(f'{username}') +# [url, expected active tab label] +active_tabs = [ + ['search?f=tweets&q=nasa', 'Latest'], + ['search?f=top&q=nasa', 'Top'], + ['search?f=media&q=nasa', 'Media'], + ['search?f=users&q=nasa', 'Users'], + ['search?f=lists&q=test', 'Lists'], + # unknown/hostile values fall back to Latest + ['search?f=garbage&q=nasa', 'Latest'], + ['search?f=%3Cscript%3E&q=nasa', 'Latest'], + # x.com URL compat: f=live/user/list (f=media/top match natively) + ['search?f=live&q=nasa', 'Latest'], + ['search?f=user&q=nasa', 'Users'], + ['search?f=list&q=test', 'Lists'], +] + +results_pages = [ + ['search?f=tweets&q=nasa'], + ['search?f=top&q=nasa'], + ['search?f=media&q=nasa'], +] + + +class SearchProductTest(BaseTestCase): + @parameterized.expand(active_tabs) + def test_active_tab(self, page, expected_active): + self.open_nitter(page) + active = self.get_text(Search.tab_active) + self.assert_equal(active.strip(), expected_active) + + def test_all_tabs_present(self): + self.open_nitter('search?f=tweets&q=nasa') + tabs = self.find_elements(Search.tab_item) + labels = [t.text.strip() for t in tabs] + self.assert_equal(labels, ['Top', 'Latest', 'Media', 'Users', 'Lists']) + + @parameterized.expand(results_pages) + def test_results_render(self, page): + self.open_nitter(page) + self.assert_element('.timeline .timeline-item') + + def test_tab_links_carry_kind(self): + self.open_nitter('search?f=tweets&q=nasa') + self.assert_element('.tab-item a[href="?f=top&q=nasa"]') + self.assert_element('.tab-item a[href="?f=media&q=nasa"]') + self.assert_element('.tab-item a[href="?f=tweets&q=nasa"]') + self.assert_element('.tab-item a[href="?f=users&q=nasa"]') + self.assert_element('.tab-item a[href="?f=lists&q=nasa"]') + + def test_show_more_preserves_kind(self): + self.open_nitter('search?f=media&q=nasa') + href = self.get_attribute('.show-more a', 'href') + self.assert_true('f=media' in href, f'f=media missing from: {href}') + + def test_search_form_preserves_kind(self): + self.open_nitter('search?f=top&q=nasa') + self.assert_element_present('.search-field input[name="f"][value="top"]') + + def test_media_operators_compose(self): + self.open_nitter('search?f=media&q=nasa&e-nativeretweets=on') + self.assert_element('.timeline .timeline-item') + + @parameterized.expand([['DAAC'], ['AB'], ['maxid:'], ['maxid:abc']]) + def test_garbage_cursor_no_crash(self, cursor): + # short/invalid cursors must render the page, not a 500 error + self.open_nitter(f'search?f=media&q=nasa&cursor={cursor}') + self.assert_element(Search.tab_active) + + def test_no_results(self): + self.open_nitter('search?f=media&q=xkqzjwv_no_results_2026') + self.assert_text('No items found', '.timeline-none') + + def test_list_results_render(self): + self.open_nitter('search?f=lists&q=test') + self.assert_element('.timeline-item.list-result') + self.assert_element('.list-result .list-name') + self.assert_element('.list-result .list-members') + + def test_list_card_links_to_list(self): + self.open_nitter('search?f=lists&q=test') + href = self.get_attribute('.list-result .list-name', 'href') + self.assert_true('/i/lists/' in href, f'unexpected list link: {href}') + + def test_list_row_clickable(self): + self.open_nitter('search?f=lists&q=test') + href = self.get_attribute('.list-result a.tweet-link', 'href') + self.assert_true('/i/lists/' in href, f'unexpected row link: {href}') + + def test_list_avatar_links_to_user(self): + self.open_nitter('search?f=lists&q=test') + # the avatar link in a row must point at the user named in that row + row = '.list-result:has(a.facepile-link)' + self.assert_element(f'{row} a.facepile-link > img') + href = self.get_attribute(f'{row} a.facepile-link', 'href') + ctx = self.get_text(f'{row} .list-result-context') + mentioned = ctx.split('@')[-1].strip() + self.assert_true(href.endswith('/' + mentioned), + f'avatar link {href} does not match @{mentioned}') + + def test_list_pagination_preserves_kind(self): + self.open_nitter('search?f=lists&q=test') + href = self.get_attribute('.show-more a', 'href') + self.assert_true('f=lists' in href, f'f=lists missing from: {href}') + + def test_list_garbage_cursor_no_crash(self): + self.open_nitter('search?f=lists&q=test&cursor=DAAC') + self.assert_element(Search.tab_active) + + def test_media_view_tabs_present(self): + self.open_nitter('search?f=media&q=nasa') + tabs = self.find_elements('.media-view-tabs .tab-item') + labels = [t.text.strip() for t in tabs] + self.assert_equal(labels, ['Timeline', 'Grid', 'Gallery']) + + def test_media_view_grid(self): + self.open_nitter('search?f=media&q=nasa&view=grid') + self.assert_element('.timeline.media-grid-view') + + def test_media_view_gallery(self): + self.open_nitter('search?f=media&q=nasa&view=gallery') + self.assert_element('.timeline.media-gallery-view .gallery-masonry') + + def test_media_view_tabs_only_on_media(self): + self.open_nitter('search?f=tweets&q=nasa') + self.assert_element_not_present('.media-view-tabs') From 8394b044ef8aabc974e055de40a590c04f32dded Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 9 Jul 2026 11:32:31 +0200 Subject: [PATCH 05/29] Qualify QueryKind.media to fix Nim 2.0.x build Fixes #1422 --- src/query.nim | 6 +++--- src/routes/rss.nim | 2 +- src/routes/timeline.nim | 2 +- src/views/profile.nim | 2 +- src/views/search.nim | 10 +++++----- src/views/timeline.nim | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/query.nim b/src/query.nim index f541d27..aeda0a1 100644 --- a/src/query.nim +++ b/src/query.nim @@ -34,7 +34,7 @@ proc initQuery*(pms: Table[string, string]; name=""): Query = proc getMediaQuery*(name: string): Query = Query( - kind: media, + kind: QueryKind.media, filters: @["twimg", "native_video"], fromUser: @[name], sep: "OR" @@ -64,7 +64,7 @@ proc genQueryParam*(query: Query; maxId=""): string = else: param &= ")" - if query.fromUser.len > 0 and query.kind in {posts, media}: + if query.fromUser.len > 0 and query.kind in {posts, QueryKind.media}: param &= " (filter:self_threads OR -filter:replies)" if "nativeretweets" notin query.excludes: @@ -106,7 +106,7 @@ proc genQueryUrl*(query: Query): string = # media doubles as the profile media tab, where f isn't part of the URL scheme if query.kind in {tweets, users, lists, top} or - (query.kind == media and query.fromUser.len == 0): + (query.kind == QueryKind.media and query.fromUser.len == 0): params.add &"f={query.kind}" if query.text.len > 0: params.add "q=" & encodeUrl(query.text) diff --git a/src/routes/rss.nim b/src/routes/rss.nim index f2582c1..f56a2cb 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -68,7 +68,7 @@ proc createRssRouter*(cfg: Config) = let prefs = requestPrefs() query = initQuery(params(request)) - if query.kind notin {tweets, top, media}: + if query.kind notin {QueryKind.tweets, QueryKind.top, QueryKind.media}: resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg) let diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 6e8969e..35ef1aa 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -53,7 +53,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile] let rail = - skipIf(skipRail or query.kind == media, @[]): + skipIf(skipRail or query.kind == QueryKind.media, @[]): getCachedPhotoRail(userId) user = getCachedUser(name) diff --git a/src/views/profile.nim b/src/views/profile.nim index de09368..c9012ed 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -115,7 +115,7 @@ proc renderProtected*(username: string): VNode = proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode = profile.tweets.query.fromUser = @[profile.user.username] let - isGalleryView = profile.tweets.query.kind == media and + isGalleryView = profile.tweets.query.kind == QueryKind.media and profile.tweets.query.view == "gallery" viewClass = if isGalleryView: " media-only" else: "" diff --git a/src/views/search.nim b/src/views/search.nim index fe50280..1c29203 100644 --- a/src/views/search.nim +++ b/src/views/search.nim @@ -120,7 +120,7 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; pinned=none(Tweet)): VNode = let query = results.query let containerClass = - if query.fromUser.len == 0 and query.kind == media and + if query.fromUser.len == 0 and query.kind == QueryKind.media and query.view == "gallery": "timeline-container media-only" else: "timeline-container" buildHtml(tdiv(class=containerClass)): @@ -129,18 +129,18 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; text query.fromUser.join(" | ") if query.fromUser.len > 0: - if query.kind != media or query.view != "gallery": + if query.kind != QueryKind.media or query.view != "gallery": renderProfileTabs(query, query.fromUser.join(",")) - if query.kind == media and query.fromUser.len == 1: + if query.kind == QueryKind.media and query.fromUser.len == 1: renderMediaViewTabs(query) - if query.fromUser.len == 0 or query.kind == tweets: + if query.fromUser.len == 0 or query.kind == QueryKind.tweets: tdiv(class="timeline-header"): renderSearchPanel(query) if query.fromUser.len == 0: renderSearchTabs(query) - if query.kind == media: + if query.kind == QueryKind.media: renderMediaViewTabs(query) renderTimelineTweets(results, prefs, path, pinned) diff --git a/src/views/timeline.nim b/src/views/timeline.nim index 9c85c9c..2890dd1 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -6,7 +6,7 @@ import ".."/[types, query, formatters] import tweet, renderutils proc timelineViewClass(query: Query): string = - if query.kind != media: + if query.kind != QueryKind.media: return "timeline" case query.view From 092397cd6fbb6eaba8952e01c3415c73635a6527 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 11 Jul 2026 00:11:46 +0200 Subject: [PATCH 06/29] Show related tweets under replies Fixes #1334 --- src/parser.nim | 19 ++++++++++++++++++- src/prefs_impl.nim | 3 +++ src/sass/tweet/thread.scss | 9 +++++++++ src/types.nim | 1 + src/views/general.nim | 2 +- src/views/status.nim | 15 ++++++++++++++- 6 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index 30f70d2..0b3352c 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -710,10 +710,26 @@ proc parseGraphTweet*(js: JsonNode): Tweet = with birdwatch, js{"birdwatch_pivot"}: result.note = parseCommunityNote(birdwatch) +proc getConvSection(js: JsonNode): string = + let details = select( + js{"item", "client_event_info", "details"}, + js{"item", "clientEventInfo", "details"} + ) + select( + details{"conversation_details", "conversation_section"}, + details{"conversationDetails", "conversationSection"} + ).getStr + proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = + var checkedSection = false for t in ? js{"content", "items"}: let entryId = t.getEntryId if "tweet-" in entryId and "promoted" notin entryId: + if not checkedSection: + checkedSection = true + if getConvSection(t) == "RelatedTweet": + result.thread.related = true + let tweet = t.getTweetResult("item") if tweet.notNull: result.thread.content.add parseGraphTweet(tweet) @@ -774,7 +790,8 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result.before.content.add tweet elif not entryId.endsWith(tweetId): result.before.content.add Tweet(id: entryId.getId) - elif entryId.startsWith("conversationthread"): + elif entryId.startsWith("conversationthread") or + entryId.startsWith("tweetdetailrelatedtweets"): let (thread, self) = parseGraphThread(e) if self: result.after = thread diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index 699ec4c..8519bd5 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -78,6 +78,9 @@ genPrefs: hideReplies(checkbox, false): "Hide tweet replies" + hideRelated(checkbox, true): + "Hide related tweets under replies" + hideCommunityNotes(checkbox, false): "Hide community notes" diff --git a/src/sass/tweet/thread.scss b/src/sass/tweet/thread.scss index c7b50ba..134e375 100644 --- a/src/sass/tweet/thread.scss +++ b/src/sass/tweet/thread.scss @@ -185,3 +185,12 @@ } } } + +.related-header { + padding: 8px 12px; + margin-top: 10px; + background-color: var(--bg_panel); + color: var(--fg_faded); + font-size: 14px; + border-bottom: 1px solid var(--border_grey); +} diff --git a/src/types.nim b/src/types.nim index d19a10d..bb521c7 100644 --- a/src/types.nim +++ b/src/types.nim @@ -359,6 +359,7 @@ type content*: Tweets hasMore*: bool cursor*: string + related*: bool Conversation* = ref object tweet*: Tweet diff --git a/src/views/general.nim b/src/views/general.nim index afdda9f..f223251 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=50") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=51") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7") if theme.len > 0: diff --git a/src/views/status.nim b/src/views/status.nim index 39af8fa..b16c211 100644 --- a/src/views/status.nim +++ b/src/views/status.nim @@ -1,4 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only +import sequtils import karax/[karaxdsl, vdom] import ".."/[types, formatters] @@ -48,7 +49,7 @@ proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; var hasReplies = false var replyCount = 0 for thread in replies.content: - if thread.content.len == 0: continue + if thread.content.len == 0 or thread.related: continue hasReplies = true replyCount += thread.content.len renderReplyThread(thread, prefs, path) @@ -58,6 +59,14 @@ proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; let extra = if sort == Relevance: "" else: "sort=" & $sort & "&" renderMore(Query(), replies.bottom, focus="#r", extra=extra) +proc renderRelated(replies: Result[Chain]; prefs: Prefs; path: string): VNode = + buildHtml(tdiv(class="related-tweets")): + tdiv(class="related-header"): + text "Related tweets" + for thread in replies.content: + if thread.content.len == 0 or not thread.related: continue + renderReplyThread(thread, prefs, path) + proc renderConversation*(conv: Conversation; prefs: Prefs; path: string; sort = Relevance): VNode = let hasAfter = conv.after.content.len > 0 @@ -95,6 +104,10 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string; renderReplySort(sort) renderReplies(conv.replies, prefs, path, conv.tweet, sort) + if not prefs.hideRelated: + if conv.replies.content.anyIt(it.related and it.content.len > 0): + renderRelated(conv.replies, prefs, path) + renderToTop(focus="#m") proc renderEditHistory*(edits: EditHistory; prefs: Prefs; path: string): VNode = From 5100290f76320dd1eceb48925f048b9f40b0f863 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 11 Jul 2026 00:12:02 +0200 Subject: [PATCH 07/29] Speed up pytest with eager page loads --- tests/conftest.py | 4 ++++ tests/pyproject.toml | 2 +- tests/test_security.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3d87c74 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,4 @@ +from seleniumbase.config import settings + +settings.SKIP_JS_WAITS = True +settings.WAIT_FOR_RSC_ON_PAGE_LOADS = False diff --git a/tests/pyproject.toml b/tests/pyproject.toml index 323f114..63c8de7 100644 --- a/tests/pyproject.toml +++ b/tests/pyproject.toml @@ -8,4 +8,4 @@ python = "^3.14" seleniumbase = "4.46.5" [tool.pytest.ini_options] -addopts = "--timeout_multiplier=3" +addopts = "--pls=eager --reruns=3 --timeout_multiplier=3" diff --git a/tests/test_security.py b/tests/test_security.py index 65d5e9a..04ba680 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -8,7 +8,7 @@ def curl_status(url): """Get HTTP status code using curl to avoid URL normalization by Python libs.""" result = subprocess.run( ['curl', '-s', '-o', '/dev/null', '-w', '%{http_code}', url], - capture_output=True, text=True, timeout=10 + capture_output=True, text=True, timeout=30 ) return int(result.stdout) From 3bc78801b39f0a98f1a424da3f60c4aa60c7025a Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 11 Jul 2026 07:54:00 +0200 Subject: [PATCH 08/29] Increase test reruns and timeout for proxy media --- tests/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/pyproject.toml b/tests/pyproject.toml index 63c8de7..a0def28 100644 --- a/tests/pyproject.toml +++ b/tests/pyproject.toml @@ -8,4 +8,4 @@ python = "^3.14" seleniumbase = "4.46.5" [tool.pytest.ini_options] -addopts = "--pls=eager --reruns=3 --timeout_multiplier=3" +addopts = "--pls=eager --reruns=5 --timeout_multiplier=5" From 06c52473ab46741d1d78cee0067b3751f1ada706 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 18 Jul 2026 12:46:30 +0200 Subject: [PATCH 09/29] Escape HTML in mention and URL display text --- src/parserutils.nim | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/parserutils.nim b/src/parserutils.nim index 8db96a5..6fbed4f 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -233,9 +233,9 @@ proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice]; symbol = $runes[rep.slice.a] result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name) of rkMention: - result.add a($runes[rep.slice], href = rep.url, title = rep.display) + result.add a($runes[rep.slice], href = rep.url, title = escape(rep.display)) of rkUrl: - result.add a(rep.display, href = rep.url) + result.add a(escape(rep.display), href = rep.url) of rkRemove: discard From 9be0b8f826bc7442d7a1324e3258f866f3a19d76 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 18 Jul 2026 12:46:43 +0200 Subject: [PATCH 10/29] 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 From 6d41157603f4d58be2ae680d443fe641f2cf3b41 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 19 Jul 2026 22:30:52 +0200 Subject: [PATCH 11/29] Fix JS concatenation in tweet embeds Fixes #586 --- public/js/embedResize.js | 3 ++- public/js/embedTweet.js | 7 +++++-- src/views/embed.nim | 4 ++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/public/js/embedResize.js b/public/js/embedResize.js index 227d6af..82c97cc 100644 --- a/public/js/embedResize.js +++ b/public/js/embedResize.js @@ -22,5 +22,6 @@ window.addEventListener('load', sendHeight); new ResizeObserver(sendHeight).observe(embedElement); - return sendHeight; + // Expose for embedTweet.js + window._nitterSendHeight = sendHeight; })() diff --git a/public/js/embedTweet.js b/public/js/embedTweet.js index 5b6419d..74d2577 100644 --- a/public/js/embedTweet.js +++ b/public/js/embedTweet.js @@ -1,5 +1,8 @@ // This runs after embedResize.js sets up the sendHeight function -(function(sendHeight) { +(function() { + var sendHeight = window._nitterSendHeight; + if (!sendHeight) return; + // Make images load eagerly so height updates correctly var lazyImages = document.querySelectorAll('img[loading="lazy"]'); for (var i = 0; i < lazyImages.length; i++) { @@ -20,4 +23,4 @@ for (var i = 0; i < allLinks.length; i++) { allLinks[i].target = '_blank'; } -})(arguments[0]); +})(); diff --git a/src/views/embed.nim b/src/views/embed.nim index 80b2dba..27cbb74 100644 --- a/src/views/embed.nim +++ b/src/views/embed.nim @@ -8,8 +8,8 @@ import general, tweet const doctype = "\n" embedResizeJs = staticRead("../../public/js/embedResize.js") - embedTweetJs = embedResizeJs & staticRead("../../public/js/embedTweet.js") - embedErrorJs = embedResizeJs & ";requestAnimationFrame(arguments[0]);" + embedTweetJs = embedResizeJs & ";" & staticRead("../../public/js/embedTweet.js") + embedErrorJs = embedResizeJs & ";if(window._nitterSendHeight)requestAnimationFrame(window._nitterSendHeight);" proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = let From ccde22aa0bc9b3c086f2e8769510e7c49a94938b Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 8 Aug 2026 23:00:19 +0700 Subject: [PATCH 12/29] Use TweetResultByRestId for embeds --- src/api.nim | 7 +++++++ src/consts.nim | 10 +++++++++- src/parser.nim | 4 ++++ src/routes/embed.nim | 6 +++--- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/api.nim b/src/api.nim index 041a492..ca3e659 100644 --- a/src/api.nim +++ b/src/api.nim @@ -230,6 +230,13 @@ proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = js = await fetch(url) result = parseGraphTweetResult(js) +proc getTweetByRestId*(id: string): Future[Tweet] {.async.} = + if id.len == 0: return + let + url = apiReq(graphTweetResultByRestId, tweetByRestIdVars % id, articleFieldToggles) + js = await fetch(url) + result = parseTweetByRestId(js) + proc getGraphTweet(id: string; after=""; mode = Relevance): Future[Conversation] {.async.} = if id.len == 0: return let diff --git a/src/consts.nim b/src/consts.nim index afef54d..ff4aa42 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -35,7 +35,7 @@ const graphCommunityModerators* = "GBMT3GOWy5dYsYC4XJfvow/moderatorsSliceTimeline_Query" graphCommunityHashtags* = "40DyrMxfCknGuZwE-keW_Q/CommunityHashtagsTimeline" - graphTweetResultByRestId* = "qtXMy1p5Y62uCskc_NUPJw/TweetResultByRestId" + graphTweetResultByRestId* = "4hhGRbehkcUVTKf8n0f0xw/TweetResultByRestId" graphTweetResultsByRestIds* = "Sc9EUQTZNEH-wzegn-nHvQ/TweetResultsByRestIds" graphBroadcast* = "FJLCzpXCLPM1jUZqmM7oEA/BroadcastQuery" @@ -166,6 +166,14 @@ const articleFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withArticleSummaryText":true,"withArticleVoiceOver":true}""" + tweetByRestIdVars* = """{ + "tweetId": "$1", + "includePromotedContent": false, + "withBirdwatchNotes": false, + "withVoice": false, + "withCommunity": false +}""".replace(" ", "").replace("\n", "") + communityTweetsVars* = """{ "communityId": "$1", $2 "count": 20, diff --git a/src/parser.nim b/src/parser.nim index 0b3352c..fb5cca4 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -751,6 +751,10 @@ proc parseGraphTweetResult*(js: JsonNode): Tweet = with tweet, js{"data", "tweet_result", "result"}: result = parseGraphTweet(tweet) +proc parseTweetByRestId*(js: JsonNode): Tweet = + with tweet, js{"data", "tweetResult", "result"}: + result = parseGraphTweet(tweet) + proc parseGraphTweetResults*(js: JsonNode): seq[Tweet] = let results = js{"data", "tweetResult"} if results.kind != JArray: return diff --git a/src/routes/embed.nim b/src/routes/embed.nim index 179ec23..5c45f29 100644 --- a/src/routes/embed.nim +++ b/src/routes/embed.nim @@ -32,7 +32,7 @@ proc createEmbedRouter*(cfg: Config) = router embed: get "/i/videos/tweet/@id": let - tweet = await getGraphTweetResult(@"id") + tweet = await getTweetByRestId(@"id") prefs = requestPrefs() if tweet == nil: @@ -45,7 +45,7 @@ proc createEmbedRouter*(cfg: Config) = get "/@user/status/@id/embed": let - tweet = await getGraphTweetResult(@"id") + tweet = await getTweetByRestId(@"id") prefs = requestPrefs() path = getPath() @@ -71,7 +71,7 @@ proc createEmbedRouter*(cfg: Config) = if username.len == 0 or tweetId.len == 0: resp Http400, "Invalid tweet URL" - let tweet = await getGraphTweetResult(tweetId) + let tweet = await getTweetByRestId(tweetId) if tweet == nil: resp Http404 From dc66bd637571df9182c147e2956294e226718ff5 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 8 Aug 2026 23:00:36 +0700 Subject: [PATCH 13/29] Fix embed click-through with CSS-only approach --- src/sass/tweet/_base.scss | 30 ++++++++---------------------- src/views/general.nim | 2 +- 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 10f8b00..3623a19 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -87,7 +87,6 @@ margin-top: 6px; margin-bottom: 0px; color: var(--grey); - pointer-events: all; } .tweet-avatar { @@ -122,7 +121,6 @@ body:has(> .tweet-embed) { margin: 0; padding: 0; width: 100%; - min-height: 100%; background: transparent; overflow: hidden; } @@ -142,34 +140,22 @@ html:has(body > .tweet-embed) { overflow: hidden; transition: background-color 0.15s ease; - &:hover { + &:hover:not(:has(.embed-footer:hover)) { background-color: var(--bg_hover); } .timeline-item { + display: block; pointer-events: none; + background-color: transparent; + } + + .tweet-link:hover { + background-color: transparent; } .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; } .avatar:not(.mini) { @@ -213,7 +199,7 @@ html:has(body > .tweet-embed) { display: block; padding: 12px 16px; border-top: 1px solid var(--border_grey); - background: var(--bg_elements); + background: var(--bg_panel); color: var(--accent); font-size: 14px; font-weight: 500; diff --git a/src/views/general.nim b/src/views/general.nim index 5ba6f7c..d085ad5 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=52") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=84") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7") if theme.len > 0: From 3bfbb7b3fd6e9e958941142058210be26633a4e9 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 8 Aug 2026 23:00:47 +0700 Subject: [PATCH 14/29] Clean up embed JS and add Tweet.html compatibility --- public/js/embedResize.js | 2 +- public/js/embedTweet.js | 16 +++++++++------- src/nitter.nim | 4 ++-- src/views/oembed.nimf | 1 + 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/public/js/embedResize.js b/public/js/embedResize.js index 82c97cc..9a43379 100644 --- a/public/js/embedResize.js +++ b/public/js/embedResize.js @@ -24,4 +24,4 @@ // Expose for embedTweet.js window._nitterSendHeight = sendHeight; -})() +})(); diff --git a/public/js/embedTweet.js b/public/js/embedTweet.js index 74d2577..166f253 100644 --- a/public/js/embedTweet.js +++ b/public/js/embedTweet.js @@ -1,7 +1,6 @@ // This runs after embedResize.js sets up the sendHeight function (function() { var sendHeight = window._nitterSendHeight; - if (!sendHeight) return; // Make images load eagerly so height updates correctly var lazyImages = document.querySelectorAll('img[loading="lazy"]'); @@ -9,12 +8,14 @@ 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); + // Update height when images finish loading (only if sendHeight available) + if (sendHeight) { + var allImages = document.querySelectorAll('img'); + for (var i = 0; i < allImages.length; i++) { + var img = allImages[i]; + if (!img.complete) { + img.addEventListener('load', sendHeight); + } } } @@ -23,4 +24,5 @@ for (var i = 0; i < allLinks.length; i++) { allLinks[i].target = '_blank'; } + })(); diff --git a/src/nitter.nim b/src/nitter.nim index dc38161..a76dda6 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -81,8 +81,8 @@ routes: if request.path.len == 0 or request.path[0] != '/': halt Http400 - # skip all file URLs - cond "." notin request.path + # skip all file URLs (except Twitter widget compatibility) + cond "." notin request.path or request.path == "/embed/Tweet.html" applyUrlPrefs() get "/": diff --git a/src/views/oembed.nimf b/src/views/oembed.nimf index 34f3ccc..3dc793b 100644 --- a/src/views/oembed.nimf +++ b/src/views/oembed.nimf @@ -3,4 +3,5 @@ #proc renderOembedIframe*(embedUrl: string): string = # result = "" +# result = result.strip() #end proc From a39e17abbebf515f7dabde6c9fa13ea485310798 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 8 Aug 2026 23:09:39 +0700 Subject: [PATCH 15/29] Add embed test suite --- tests/test_embed.py | 204 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 tests/test_embed.py diff --git a/tests/test_embed.py b/tests/test_embed.py new file mode 100644 index 0000000..c15d247 --- /dev/null +++ b/tests/test_embed.py @@ -0,0 +1,204 @@ +import requests +from base import BaseTestCase, Media +from parameterized import parameterized + + +class Embed: + container = '.tweet-embed' + footer = '.embed-footer' + tweet_content = '.tweet-content' + tweet_header = '.tweet-header' + fullname = '.fullname' + username = '.username' + avatar = '.avatar' + stats = '.tweet-stats' + quote = '.quote' + error_panel = '.error-panel' + + +class TweetEmbedTest(BaseTestCase): + """Test tweet embed rendering.""" + tweet = 'elonmusk/status/1141367104702038016' + + def test_embed_container_visible(self): + self.open_nitter(self.tweet + '/embed') + self.assert_element_visible(Embed.container) + + def test_embed_has_footer(self): + self.open_nitter(self.tweet + '/embed') + self.assert_element_visible(Embed.footer) + self.assert_text_visible('Read more on', Embed.footer) + + def test_embed_has_tweet_content(self): + self.open_nitter(self.tweet + '/embed') + self.assert_element_visible(Embed.tweet_content) + + def test_embed_has_avatar(self): + self.open_nitter(self.tweet + '/embed') + self.assert_element_visible(Embed.avatar) + + def test_embed_has_username(self): + self.open_nitter(self.tweet + '/embed') + self.assert_element_visible(Embed.username) + + def test_embed_has_stats(self): + self.open_nitter(self.tweet + '/embed') + self.assert_element_visible(Embed.stats) + + def test_embed_footer_links_to_tweet(self): + self.open_nitter(self.tweet + '/embed') + href = self.get_attribute(Embed.footer, 'href') + self.assertIn('/elonmusk/status/1141367104702038016', href) + + +class TweetEmbedMediaTest(BaseTestCase): + """Test embed rendering with various media types.""" + + def test_embed_with_image(self): + self.open_nitter('mobile_test/status/519364660823207936/embed') + self.assert_element_visible(Embed.container) + self.scroll_to(Media.container) + self.assert_element_visible(Media.image) + + def test_embed_with_gif(self): + self.open_nitter('elonmusk/status/1141367104702038016/embed') + self.assert_element_visible(Embed.container) + self.scroll_to(Media.container) + self.assert_element_visible(Media.gif) + + def test_embed_with_video(self): + self.open_nitter('d0m96/status/1078373829917974528/embed') + self.assert_element_visible(Embed.container) + self.scroll_to(Media.container) + self.assert_element_visible(Media.video) + + def test_embed_with_gallery(self): + self.open_nitter('mobile_test/status/451108446603980803/embed') + self.assert_element_visible(Embed.container) + self.scroll_to(Media.container) + self.assert_element_visible(Media.row) + + +class TweetEmbedQuoteTest(BaseTestCase): + """Test embed rendering with quoted tweets.""" + + def test_embed_with_quote_shows_quote(self): + self.open_nitter('elonmusk/status/1138827760107790336/embed') + self.assert_element_visible(Embed.container) + self.assert_element_visible(Embed.quote) + + def test_embed_quote_has_content(self): + self.open_nitter('elonmusk/status/1138827760107790336/embed') + quote = self.find_element(Embed.quote) + self.assertIsNotNone(quote.text) + + +class EmbedErrorTest(BaseTestCase): + """Test embed error handling.""" + + def test_nonexistent_tweet_shows_error(self): + self.open_nitter('nobody/status/1/embed') + self.assert_element_visible('.tweet-embed.error-embed') + self.assert_text_visible('not found', Embed.error_panel) + + def test_protected_account_embed_shows_error(self): + self.open_nitter('mobile_test_7/status/1/embed') + self.assert_element_visible('.tweet-embed.error-embed') + + def test_invalid_tweet_id_shows_error(self): + self.open_nitter('jack/status/notanumber/embed') + self.assert_element_visible('.tweet-embed.error-embed') + + +class OEmbedApiTest(BaseTestCase): + """Test oEmbed API endpoint.""" + base_url = 'http://localhost:8080' + tweet_url = 'https://twitter.com/elonmusk/status/1141367104702038016' + + def test_oembed_returns_json(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.headers['Content-Type'], 'application/json') + + def test_oembed_has_required_fields(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') + data = resp.json() + self.assertEqual(data['type'], 'rich') + self.assertEqual(data['version'], '1.0') + self.assertIn('html', data) + self.assertIn('author_name', data) + self.assertIn('provider_name', data) + + def test_oembed_html_contains_iframe(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') + data = resp.json() + self.assertIn(' Date: Sun, 9 Aug 2026 00:15:58 +0700 Subject: [PATCH 16/29] Improve flaky tests --- tests/test_article.py | 2 ++ tests/test_tweet_media.py | 1 + 2 files changed, 3 insertions(+) diff --git a/tests/test_article.py b/tests/test_article.py index 652ec76..287b948 100644 --- a/tests/test_article.py +++ b/tests/test_article.py @@ -291,6 +291,8 @@ class ArticleQuotedCardTest(BaseTestCase): def test_quoted_card_has_cover_image(self): self.open_nitter(self.quoted_tweet) + # Scroll to element to trigger lazy loading + self.scroll_to('.quote .article-card .card-image img') self.assert_element_visible('.quote .article-card .card-image img') src = self.get_attribute('.quote .article-card .card-image img', 'src') self.assertIn('/pic/', src) diff --git a/tests/test_tweet_media.py b/tests/test_tweet_media.py index 65e6edb..7f81391 100644 --- a/tests/test_tweet_media.py +++ b/tests/test_tweet_media.py @@ -101,6 +101,7 @@ class MediaTest(BaseTestCase): @parameterized.expand(gallery) def test_gallery(self, tweet, rows): self.open_nitter(tweet) + self.scroll_to(Media.container) self.assert_element_visible(Media.container) self.assert_element_visible(Media.row) self.assert_element_visible(Media.image) From a00c6cc11d9c224684154129ad115df9f9ea7541 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 9 Aug 2026 00:58:35 +0700 Subject: [PATCH 17/29] Simplify embed hover CSS with wrapper element --- public/js/embedResize.js | 2 +- src/sass/tweet/_base.scss | 56 ++++++++++++++++++++++----------------- src/views/embed.nim | 7 ++--- 3 files changed, 36 insertions(+), 29 deletions(-) diff --git a/public/js/embedResize.js b/public/js/embedResize.js index 9a43379..486cbcb 100644 --- a/public/js/embedResize.js +++ b/public/js/embedResize.js @@ -1,5 +1,5 @@ (function() { - var embedElement = document.querySelector('.tweet-embed, .embed-video'); + var embedElement = document.querySelector('.embed-wrapper, .tweet-embed, .embed-video'); if (!embedElement) return; var lastHeight = 0; diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 3623a19..050eaad 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -117,6 +117,7 @@ } } +body:has(> .embed-wrapper), body:has(> .tweet-embed) { margin: 0; padding: 0; @@ -125,22 +126,45 @@ body:has(> .tweet-embed) { overflow: hidden; } +html:has(body > .embed-wrapper), html:has(body > .tweet-embed) { background: transparent; overflow: hidden; } +.embed-wrapper { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--border_grey); + border-radius: 12px; + overflow: hidden; + + .embed-footer { + display: block; + padding: 12px 16px; + border-top: 1px solid var(--border_grey); + background: var(--bg_panel); + color: var(--accent); + font-size: 14px; + font-weight: 500; + text-align: center; + text-decoration: none; + transition: background-color 0.15s; + + &:hover { + background: var(--bg_hover); + } + } +} + .tweet-embed { 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:not(:has(.embed-footer:hover)) { + &:hover { background-color: var(--bg_hover); } @@ -180,8 +204,10 @@ html:has(body > .tweet-embed) { display: flex; align-items: center; justify-content: center; - min-height: 80px; + min-height: 160px; padding: 20px; + border: 1px solid var(--border_grey); + border-radius: 12px; cursor: default; &:hover { @@ -192,26 +218,6 @@ html:has(body > .tweet-embed) { margin: 0; } } - - .embed-footer { - position: relative; - z-index: 2; - display: block; - padding: 12px 16px; - border-top: 1px solid var(--border_grey); - background: var(--bg_panel); - 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 27cbb74..f52dd15 100644 --- a/src/views/embed.nim +++ b/src/views/embed.nim @@ -35,9 +35,10 @@ proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; re renderHead(prefs, cfg, req) body: - tdiv(class="tweet-embed"): - a(class="tweet-link", href=getLink(tweet), target="_blank") - renderTweet(tweet, prefs, path, mainTweet=true) + tdiv(class="embed-wrapper"): + 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 From 5733297f9295ae7aded565918b5de0b0060951b0 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 9 Aug 2026 02:11:50 +0700 Subject: [PATCH 18/29] Enhance widgets.js Twitter replacement --- public/js/widgets.js | 262 ++++++++++++++++++++++++++----------------- 1 file changed, 162 insertions(+), 100 deletions(-) diff --git a/public/js/widgets.js b/public/js/widgets.js index 0b897ef..5c8bc9c 100644 --- a/public/js/widgets.js +++ b/public/js/widgets.js @@ -1,135 +1,197 @@ /** * Drop-in replacement for Twitter's widgets.js - * Include this script to automatically convert twitter-tweet blockquotes to Nitter embeds + * Converts twitter-tweet/twitter-video blockquotes to Nitter embeds + * + * Usage: LibRedirect can redirect platform.twitter.com/widgets.js to nitter.net/widgets.js */ (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 + // Idempotent - only run once + if (window.__nitterWidgets) return; + window.__nitterWidgets = true; + + // Determine Nitter instance URL from script src, or fall back to current origin + var scripts = document.querySelectorAll('script[src*="widgets.js"]'); + var NITTER = scripts.length + ? new URL(scripts[scripts.length - 1].src).origin : location.origin; - var TWEET_URL_PATTERN = - /^https?:\/\/(?:twitter\.com|x\.com)\/([^\/]+)\/status\/(\d+)/i; + var TWEET_PATTERN = /(?:twitter\.com|x\.com)\/([^\/]+)\/status\/(\d+)/i; + var SELECTOR = "blockquote.twitter-tweet, blockquote.twitter-video"; - // Track iframes by URL for resize messages - var iframesByUrl = {}; + // Ready callback queue + var readyCallbacks = []; + var isReady = false; - /** - * 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; + function parseTweetUrl(url) { + if (!url) return null; + var match = TWEET_PATTERN.exec(url); + if (match) return { username: match[1], id: match[2] }; + // Fallback: extract any large number (tweet ID) + var idMatch = url.match(/(\d{15,})/); + return idMatch ? { username: null, id: idMatch[1] } : null; } - /** - * Transform all twitter-tweet blockquotes into Nitter embed iframes - */ - function transformBlockquotes() { - var blockquotes = document.querySelectorAll("blockquote.twitter-tweet"); + function createIframe(tweet, options) { + var embedUrl = tweet.username + ? NITTER + "/" + tweet.username + "/status/" + tweet.id + "/embed" + : NITTER + "/i/status/" + tweet.id + "/embed"; + + var iframe = document.createElement("iframe"); + iframe.src = embedUrl; + iframe.className = "nitter-embed-frame"; + iframe.setAttribute("allowtransparency", "true"); + iframe.setAttribute("frameborder", "0"); + iframe.setAttribute("scrolling", "no"); + iframe.loading = "lazy"; + + // Styling with data attribute support + var width = options.width || "550"; + var align = options.align || "center"; + var margin = align === "center" ? "10px auto" : + align === "right" ? "10px 0 10px auto" : "10px auto 10px 0"; + + iframe.style.cssText = "width:100%;max-width:" + width + "px;height:250px;border:none;display:block;margin:" + margin; + + return iframe; + } + + function processBlockquote(bq) { + if (bq.dataset.nitterProcessed) return false; + bq.dataset.nitterProcessed = "true"; + + // Find tweet URL in links + var tweet = null; + var links = bq.querySelectorAll("a[href]"); + for (var i = 0; i < links.length; i++) { + tweet = parseTweetUrl(links[i].href); + if (tweet) break; + } + + if (!tweet) { + console.warn("[Nitter widgets.js] No tweet URL found in blockquote"); + return false; + } + + // Read Twitter's data attributes + var options = { + width: bq.dataset.width, + align: bq.dataset.align, + theme: bq.dataset.theme + }; + + var iframe = createIframe(tweet, options); + + // Hide original (keep as fallback), insert iframe after + bq.style.display = "none"; + bq.parentNode.insertBefore(iframe, bq.nextSibling); + return true; + } + + function processEmbeds(container) { + var root = container || document; + var blockquotes = root.querySelectorAll(SELECTOR + ":not([data-nitter-processed])"); + var count = 0; for (var i = 0; i < blockquotes.length; i++) { - var blockquote = blockquotes[i]; - var tweetInfo = findTweetInfo(blockquote); - if (!tweetInfo) continue; + if (processBlockquote(blockquotes[i])) count++; + } - 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); + if (count > 0) { + console.log("[Nitter widgets.js] Processed " + count + " embed(s)"); } } - /** - * Handle resize messages from Nitter embeds - */ - function handleResizeMessage(event) { + function handleResize(event) { if (!Array.isArray(event.data) || event.data[0] !== "resizeIframe") return; + var h = event.data[1] && event.data[1].h; + if (!h || h <= 0) 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"; + // Find iframe by matching contentWindow + var iframes = document.querySelectorAll("iframe.nitter-embed-frame"); + for (var i = 0; i < iframes.length; i++) { + if (iframes[i].contentWindow === event.source) { + iframes[i].style.height = h + "px"; + break; } } } - // Remove any Twitter widget scripts that might have been loaded + function observeDOM() { + if (!window.MutationObserver || !document.body) return; + + new MutationObserver(function (mutations) { + for (var i = 0; i < mutations.length; i++) { + var nodes = mutations[i].addedNodes; + for (var j = 0; j < nodes.length; j++) { + var node = nodes[j]; + if (node.nodeType !== 1) continue; + if ((node.matches && node.matches(SELECTOR)) || + (node.querySelector && node.querySelector(SELECTOR))) { + processEmbeds(); + return; + } + } + } + }).observe(document.body, { childList: true, subtree: true }); + } + + function fireReady() { + isReady = true; + for (var i = 0; i < readyCallbacks.length; i++) { + try { readyCallbacks[i](window.twttr); } catch (e) {} + } + readyCallbacks = []; + } + + // Expose twttr API for compatibility + var prevTwttr = window.twttr; + window.twttr = { + widgets: { + load: function (el) { processEmbeds(el); }, + createTweet: function (id, container, opts) { + if (!container) return Promise.reject("No container"); + var iframe = createIframe({ id: id, username: null }, opts || {}); + container.appendChild(iframe); + return Promise.resolve(iframe); + }, + loaded: true + }, + ready: function (cb) { + if (typeof cb !== "function") return; + if (isReady) cb(window.twttr); + else readyCallbacks.push(cb); + }, + _e: [] + }; + + // Process any callbacks queued before we loaded (twttr._e pattern) + if (prevTwttr && prevTwttr._e) { + for (var i = 0; i < prevTwttr._e.length; i++) { + try { prevTwttr._e[i](); } catch (e) {} + } + } + + // Remove Twitter scripts that might have snuck in var twitterScripts = document.querySelectorAll( - 'script[src*="platform.twitter.com/widgets.js"], script[src*="platform.x.com/widgets.js"]', + 'script[src*="platform.twitter.com"], script[src*="platform.x.com"]' ); for (var i = 0; i < twitterScripts.length; i++) { twitterScripts[i].remove(); } - // Listen for resize messages from embeds - window.addEventListener("message", handleResizeMessage); + // Initialize + function init() { + window.addEventListener("message", handleResize); + processEmbeds(); + observeDOM(); + fireReady(); + } - // Transform existing blockquotes if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", transformBlockquotes); + document.addEventListener("DOMContentLoaded", init); } else { - transformBlockquotes(); + init(); } - - // 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, - }; })(); From fe8f482d5c6edf410ddffeff2697395918af420d Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 10 Aug 2026 20:17:14 +0700 Subject: [PATCH 19/29] Add video-only embed support and clean up widgets.js --- public/js/embedResize.js | 2 +- public/js/widgets.js | 203 +++++++++++++++++++------------------- src/sass/tweet/embed.scss | 14 ++- src/views/general.nim | 2 +- 4 files changed, 112 insertions(+), 109 deletions(-) diff --git a/public/js/embedResize.js b/public/js/embedResize.js index 486cbcb..69829fe 100644 --- a/public/js/embedResize.js +++ b/public/js/embedResize.js @@ -6,7 +6,7 @@ function sendHeight() { var currentHeight = embedElement.offsetHeight; - if (currentHeight !== lastHeight) { + if (currentHeight !== lastHeight && currentHeight > 0) { lastHeight = currentHeight; window.parent.postMessage(['resizeIframe', { h: currentHeight, url: location.href }], '*'); } diff --git a/public/js/widgets.js b/public/js/widgets.js index 5c8bc9c..24345ae 100644 --- a/public/js/widgets.js +++ b/public/js/widgets.js @@ -1,58 +1,74 @@ /** * Drop-in replacement for Twitter's widgets.js - * Converts twitter-tweet/twitter-video blockquotes to Nitter embeds - * - * Usage: LibRedirect can redirect platform.twitter.com/widgets.js to nitter.net/widgets.js + * Redirects twitter-tweet blockquotes to Nitter embeds */ (function () { "use strict"; - // Idempotent - only run once if (window.__nitterWidgets) return; window.__nitterWidgets = true; - // Determine Nitter instance URL from script src, or fall back to current origin var scripts = document.querySelectorAll('script[src*="widgets.js"]'); var NITTER = scripts.length ? new URL(scripts[scripts.length - 1].src).origin : location.origin; - var TWEET_PATTERN = /(?:twitter\.com|x\.com)\/([^\/]+)\/status\/(\d+)/i; + var TWEET_RE = /(?:twitter\.com|x\.com)\/([^\/]+)\/status\/(\d+)/i; var SELECTOR = "blockquote.twitter-tweet, blockquote.twitter-video"; - // Ready callback queue var readyCallbacks = []; + var eventCallbacks = {}; var isReady = false; + function safeCall(fn, arg) { + try { fn(arg); } catch (e) {} + } + + function fireEvent(name, data) { + (eventCallbacks[name] || []).forEach(function (cb) { safeCall(cb, data); }); + } + function parseTweetUrl(url) { if (!url) return null; - var match = TWEET_PATTERN.exec(url); - if (match) return { username: match[1], id: match[2] }; - // Fallback: extract any large number (tweet ID) - var idMatch = url.match(/(\d{15,})/); - return idMatch ? { username: null, id: idMatch[1] } : null; + var m = TWEET_RE.exec(url); + if (m) return { user: m[1], id: m[2] }; + m = url.match(/(\d{15,})/); + return m ? { user: null, id: m[1] } : null; } - function createIframe(tweet, options) { - var embedUrl = tweet.username - ? NITTER + "/" + tweet.username + "/status/" + tweet.id + "/embed" - : NITTER + "/i/status/" + tweet.id + "/embed"; + function createIframe(tweet, opts) { + var url; + if (opts.videoOnly) { + url = NITTER + "/i/videos/tweet/" + tweet.id; + } else { + var path = tweet.user ? "/" + tweet.user : "/i"; + url = NITTER + path + "/status/" + tweet.id + "/embed"; + if (opts.theme) { + var theme = opts.theme === "dark" ? "nitter" : + opts.theme === "light" ? "twitter" : opts.theme; + url += "?theme=" + encodeURIComponent(theme); + } + } var iframe = document.createElement("iframe"); - iframe.src = embedUrl; + iframe.src = url; iframe.className = "nitter-embed-frame"; + iframe.loading = "lazy"; iframe.setAttribute("allowtransparency", "true"); iframe.setAttribute("frameborder", "0"); iframe.setAttribute("scrolling", "no"); - iframe.loading = "lazy"; + if (opts.videoOnly) iframe.setAttribute("allowfullscreen", "true"); - // Styling with data attribute support - var width = options.width || "550"; - var align = options.align || "center"; - var margin = align === "center" ? "10px auto" : - align === "right" ? "10px 0 10px auto" : "10px auto 10px 0"; + var width = opts.width || 550; + var margin = opts.align === "center" ? "10px auto" : + opts.align === "right" ? "10px 0 10px auto" : "10px 0"; + iframe.style.cssText = + "width:100%;max-width:" + width + "px;height:250px;" + + "border:none;display:block;margin:" + margin; - iframe.style.cssText = "width:100%;max-width:" + width + "px;height:250px;border:none;display:block;margin:" + margin; + iframe.addEventListener("load", function () { + fireEvent("rendered", { target: iframe }); + }); return iframe; } @@ -61,59 +77,41 @@ if (bq.dataset.nitterProcessed) return false; bq.dataset.nitterProcessed = "true"; - // Find tweet URL in links var tweet = null; var links = bq.querySelectorAll("a[href]"); - for (var i = 0; i < links.length; i++) { + for (var i = 0; i < links.length && !tweet; i++) { tweet = parseTweetUrl(links[i].href); - if (tweet) break; } + if (!tweet) return false; - if (!tweet) { - console.warn("[Nitter widgets.js] No tweet URL found in blockquote"); - return false; - } + var d = bq.dataset; + var iframe = createIframe(tweet, { + width: d.mediaMaxWidth || d.width, + align: d.align, + theme: d.theme, + videoOnly: d.mediaMaxWidth !== undefined + }); - // Read Twitter's data attributes - var options = { - width: bq.dataset.width, - align: bq.dataset.align, - theme: bq.dataset.theme - }; - - var iframe = createIframe(tweet, options); - - // Hide original (keep as fallback), insert iframe after bq.style.display = "none"; bq.parentNode.insertBefore(iframe, bq.nextSibling); return true; } - function processEmbeds(container) { - var root = container || document; - var blockquotes = root.querySelectorAll(SELECTOR + ":not([data-nitter-processed])"); - var count = 0; - - for (var i = 0; i < blockquotes.length; i++) { - if (processBlockquote(blockquotes[i])) count++; - } - - if (count > 0) { - console.log("[Nitter widgets.js] Processed " + count + " embed(s)"); - } + function processEmbeds(root) { + var bqs = (root || document).querySelectorAll(SELECTOR + ":not([data-nitter-processed])"); + for (var i = 0; i < bqs.length; i++) processBlockquote(bqs[i]); } - function handleResize(event) { - if (!Array.isArray(event.data) || event.data[0] !== "resizeIframe") return; - var h = event.data[1] && event.data[1].h; + function handleResize(e) { + if (!Array.isArray(e.data) || e.data[0] !== "resizeIframe") return; + var h = e.data[1] && e.data[1].h; if (!h || h <= 0) return; - // Find iframe by matching contentWindow - var iframes = document.querySelectorAll("iframe.nitter-embed-frame"); - for (var i = 0; i < iframes.length; i++) { - if (iframes[i].contentWindow === event.source) { - iframes[i].style.height = h + "px"; - break; + var frames = document.querySelectorAll("iframe.nitter-embed-frame"); + for (var i = 0; i < frames.length; i++) { + if (frames[i].contentWindow === e.source) { + frames[i].style.height = h + "px"; + return; } } } @@ -121,43 +119,49 @@ function observeDOM() { if (!window.MutationObserver || !document.body) return; - new MutationObserver(function (mutations) { - for (var i = 0; i < mutations.length; i++) { - var nodes = mutations[i].addedNodes; - for (var j = 0; j < nodes.length; j++) { - var node = nodes[j]; - if (node.nodeType !== 1) continue; - if ((node.matches && node.matches(SELECTOR)) || - (node.querySelector && node.querySelector(SELECTOR))) { - processEmbeds(); - return; - } - } - } + function matches(el) { + return el.matches(SELECTOR) || el.querySelector(SELECTOR); + } + + new MutationObserver(function (muts) { + var found = muts.some(function (mut) { + return Array.prototype.some.call(mut.addedNodes, function (n) { + return n.nodeType === 1 && matches(n); + }); + }); + if (found) processEmbeds(); }).observe(document.body, { childList: true, subtree: true }); } - function fireReady() { - isReady = true; - for (var i = 0; i < readyCallbacks.length; i++) { - try { readyCallbacks[i](window.twttr); } catch (e) {} - } - readyCallbacks = []; + function embedTweet(id, container, opts) { + if (!container) return Promise.reject("No container"); + var iframe = createIframe({ id: id, user: null }, opts || {}); + container.appendChild(iframe); + return Promise.resolve(iframe); } - // Expose twttr API for compatibility var prevTwttr = window.twttr; window.twttr = { widgets: { - load: function (el) { processEmbeds(el); }, - createTweet: function (id, container, opts) { - if (!container) return Promise.reject("No container"); - var iframe = createIframe({ id: id, username: null }, opts || {}); - container.appendChild(iframe); - return Promise.resolve(iframe); - }, + load: processEmbeds, + createTweet: embedTweet, + createTweetEmbed: embedTweet, + createVideo: embedTweet, loaded: true }, + events: { + bind: function (name, cb) { + if (typeof cb !== "function") return; + if (!eventCallbacks[name]) eventCallbacks[name] = []; + eventCallbacks[name].push(cb); + }, + unbind: function (name, cb) { + if (!eventCallbacks[name]) return; + eventCallbacks[name] = cb + ? eventCallbacks[name].filter(function (f) { return f !== cb; }) + : []; + } + }, ready: function (cb) { if (typeof cb !== "function") return; if (isReady) cb(window.twttr); @@ -166,27 +170,22 @@ _e: [] }; - // Process any callbacks queued before we loaded (twttr._e pattern) + // Process callbacks queued before load (twttr._e pattern) if (prevTwttr && prevTwttr._e) { - for (var i = 0; i < prevTwttr._e.length; i++) { - try { prevTwttr._e[i](); } catch (e) {} - } + prevTwttr._e.forEach(function (cb) { safeCall(cb); }); } - // Remove Twitter scripts that might have snuck in - var twitterScripts = document.querySelectorAll( - 'script[src*="platform.twitter.com"], script[src*="platform.x.com"]' - ); - for (var i = 0; i < twitterScripts.length; i++) { - twitterScripts[i].remove(); - } + // Remove any Twitter scripts that snuck through + document.querySelectorAll('script[src*="platform.twitter.com"], script[src*="platform.x.com"]') + .forEach(function (s) { s.remove(); }); - // Initialize function init() { window.addEventListener("message", handleResize); processEmbeds(); observeDOM(); - fireReady(); + isReady = true; + readyCallbacks.forEach(function (cb) { safeCall(cb, window.twttr); }); + readyCallbacks = []; } if (document.readyState === "loading") { diff --git a/src/sass/tweet/embed.scss b/src/sass/tweet/embed.scss index bee23d3..31370e9 100644 --- a/src/sass/tweet/embed.scss +++ b/src/sass/tweet/embed.scss @@ -2,16 +2,20 @@ @import "_mixins"; .embed-video { + width: 100%; + background-color: black; + .gallery-video { width: 100%; - height: 100%; - position: absolute; - background-color: black; - top: 0%; - left: 0%; } .gallery-video > .attachment { max-height: unset; + width: 100%; + } + + video { + width: 100%; + height: auto; } } diff --git a/src/views/general.nim b/src/views/general.nim index d085ad5..77e8402 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=84") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=85") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7") if theme.len > 0: From ca19250a62a2cabb4c6700da18b5164e6ef2fd3f Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 10 Aug 2026 20:27:38 +0700 Subject: [PATCH 20/29] Remove embedTweet.js, use instead --- public/js/embedTweet.js | 28 ---------------------------- src/views/embed.nim | 11 +++++------ 2 files changed, 5 insertions(+), 34 deletions(-) delete mode 100644 public/js/embedTweet.js diff --git a/public/js/embedTweet.js b/public/js/embedTweet.js deleted file mode 100644 index 166f253..0000000 --- a/public/js/embedTweet.js +++ /dev/null @@ -1,28 +0,0 @@ -// This runs after embedResize.js sets up the sendHeight function -(function() { - var sendHeight = window._nitterSendHeight; - - // 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 (only if sendHeight available) - if (sendHeight) { - 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'; - } - -})(); diff --git a/src/views/embed.nim b/src/views/embed.nim index f52dd15..fc5abdf 100644 --- a/src/views/embed.nim +++ b/src/views/embed.nim @@ -8,8 +8,6 @@ import general, tweet const doctype = "\n" embedResizeJs = staticRead("../../public/js/embedResize.js") - embedTweetJs = embedResizeJs & ";" & staticRead("../../public/js/embedTweet.js") - embedErrorJs = embedResizeJs & ";if(window._nitterSendHeight)requestAnimationFrame(window._nitterSendHeight);" proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = let @@ -33,17 +31,18 @@ proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string = let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req) + base(target="_blank") body: tdiv(class="embed-wrapper"): tdiv(class="tweet-embed"): - a(class="tweet-link", href=getLink(tweet), target="_blank") + a(class="tweet-link", href=getLink(tweet)) renderTweet(tweet, prefs, path, mainTweet=true) - a(class="embed-footer", href=getLink(tweet), target="_blank"): + a(class="embed-footer", href=getLink(tweet)): text "Read more on " & cfg.hostname script: - verbatim embedTweetJs + verbatim embedResizeJs result = doctype & $node @@ -57,6 +56,6 @@ proc renderErrorEmbed*(error: string; prefs: Prefs; cfg: Config; req: Request): span: text error script: - verbatim embedErrorJs + verbatim embedResizeJs result = doctype & $node From 67f620c83ccdfdfe2ffd2bab9aa172c9c7211c67 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 10 Aug 2026 22:51:12 +0700 Subject: [PATCH 21/29] Improve video and error embeds --- src/routes/embed.nim | 15 +++-- src/sass/tweet/_base.scss | 103 ----------------------------- src/sass/tweet/embed.scss | 133 +++++++++++++++++++++++++++++++++++++- src/views/embed.nim | 23 +++++-- src/views/general.nim | 2 +- 5 files changed, 161 insertions(+), 115 deletions(-) diff --git a/src/routes/embed.nim b/src/routes/embed.nim index 5c45f29..5f3e6f3 100644 --- a/src/routes/embed.nim +++ b/src/routes/embed.nim @@ -32,25 +32,30 @@ proc createEmbedRouter*(cfg: Config) = router embed: get "/i/videos/tweet/@id": let - tweet = await getTweetByRestId(@"id") + id = @"id" + tweet = await getTweetByRestId(id) prefs = requestPrefs() if tweet == nil: - resp renderErrorEmbed("Tweet not found", prefs, cfg, request) + resp renderErrorEmbed("Tweet not found", prefs, cfg, request, tweetId=id) if not tweet.hasVideos: - resp renderErrorEmbed("No video in tweet", prefs, cfg, request) + resp renderErrorEmbed("No video in tweet", prefs, cfg, request, + tweetId=id, username=tweet.user.username) resp renderVideoEmbed(tweet, cfg, request) get "/@user/status/@id/embed": let - tweet = await getTweetByRestId(@"id") + id = @"id" + user = @"user" + tweet = await getTweetByRestId(id) prefs = requestPrefs() path = getPath() if tweet == nil: - resp renderErrorEmbed("Tweet not found", prefs, cfg, request) + resp renderErrorEmbed("Tweet not found", prefs, cfg, request, + tweetId=id, username=user) resp renderTweetEmbed(tweet, path, prefs, cfg, request) diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 050eaad..2f6693e 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -117,109 +117,6 @@ } } -body:has(> .embed-wrapper), -body:has(> .tweet-embed) { - margin: 0; - padding: 0; - width: 100%; - background: transparent; - overflow: hidden; -} - -html:has(body > .embed-wrapper), -html:has(body > .tweet-embed) { - background: transparent; - overflow: hidden; -} - -.embed-wrapper { - width: 100%; - box-sizing: border-box; - border: 1px solid var(--border_grey); - border-radius: 12px; - overflow: hidden; - - .embed-footer { - display: block; - padding: 12px 16px; - border-top: 1px solid var(--border_grey); - background: var(--bg_panel); - color: var(--accent); - font-size: 14px; - font-weight: 500; - text-align: center; - text-decoration: none; - transition: background-color 0.15s; - - &:hover { - background: var(--bg_hover); - } - } -} - -.tweet-embed { - position: relative; - width: 100%; - box-sizing: border-box; - background-color: var(--bg_panel); - transition: background-color 0.15s ease; - - &:hover { - background-color: var(--bg_hover); - } - - .timeline-item { - display: block; - pointer-events: none; - background-color: transparent; - } - - .tweet-link:hover { - background-color: transparent; - } - - .tweet-content { - font-size: 18px; - } - - .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: 160px; - padding: 20px; - border: 1px solid var(--border_grey); - border-radius: 12px; - cursor: default; - - &:hover { - background-color: var(--bg_panel); - } - - .error-panel { - margin: 0; - } - } -} - .attribution { display: flex; pointer-events: all; diff --git a/src/sass/tweet/embed.scss b/src/sass/tweet/embed.scss index 31370e9..9ca1d48 100644 --- a/src/sass/tweet/embed.scss +++ b/src/sass/tweet/embed.scss @@ -1,21 +1,150 @@ @import "_variables"; @import "_mixins"; +// Embed page: transparent background, no scrollbars +html:has(body > .embed-wrapper), +html:has(body > .embed-video) { + background: transparent; + overflow: hidden; + + body { + background: transparent; + overflow: hidden; + } +} + +// Tweet embed wrapper +.embed-wrapper { + box-sizing: border-box; + border: 1px solid var(--border_grey); + border-radius: 12px; + overflow: hidden; + + .embed-footer { + display: block; + padding: 12px 16px; + border-top: 1px solid var(--border_grey); + background: var(--bg_panel); + color: var(--accent); + font-size: 14px; + font-weight: 500; + text-align: center; + text-decoration: none; + transition: background-color 0.15s; + + &:hover { + background: var(--bg_hover); + } + } +} + +// Tweet embed content +.tweet-embed { + position: relative; + background-color: var(--bg_panel); + transition: background-color 0.15s; + + &:hover { + background-color: var(--bg_hover); + } + + .timeline-item { + pointer-events: none; + background-color: transparent; + } + + .tweet-link:hover { + background-color: transparent; + } + + .tweet-content { + font-size: 18px; + } + + .avatar:not(.mini) { + position: absolute; + } + + // Cap media height in embeds + .still-image img, + .quote-media-container img, + .quote-media-container video { + max-height: 600px; + } + + &.error-embed { + display: flex; + align-items: center; + justify-content: center; + min-height: 120px; + padding: 20px; + + .error-panel { + margin: 0; + } + } +} + +// Video-only embed .embed-video { - width: 100%; + position: relative; background-color: black; + border: 1px solid var(--border_grey); + + .attachments { + margin: 0; + border-radius: 0; + max-height: 560px; + background-color: unset; + } + + .card { + margin: 0; + } .gallery-video { width: 100%; } .gallery-video > .attachment { - max-height: unset; + max-height: 560px; width: 100%; } video { width: 100%; height: auto; + max-height: 560px; + object-fit: contain; + } + + .video-download { + display: none; + } + + .video-overlay-link { + position: absolute; + top: 12px; + right: 12px; + padding: 6px 12px; + background: rgba(30, 30, 30, 0.75); + backdrop-filter: blur(4px); + color: #fff; + font-size: 13px; + font-weight: 700; + text-decoration: none; + border-radius: 9999px; + border: 1px solid transparent; + transition: background 0.15s, opacity 0.15s; + z-index: 10; + + &:hover { + background: rgba(60, 60, 60, 0.9); + } + } + + &:has(video:playing) .video-overlay-link { + opacity: 0; + pointer-events: none; } } diff --git a/src/views/embed.nim b/src/views/embed.nim index fc5abdf..27fda24 100644 --- a/src/views/embed.nim +++ b/src/views/embed.nim @@ -15,13 +15,17 @@ proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = thumb = video.thumb vidUrl = getVideoEmbed(cfg, tweet.id) prefs = Prefs(hlsPlayback: true, mp4Playback: true) + tweetUrl = getLink(tweet) let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb])) + base(target="_blank") body: tdiv(class="embed-video"): renderVideo(video, prefs, "") + a(class="video-overlay-link", href=tweetUrl): + text "Watch on " & cfg.hostname script: verbatim embedResizeJs @@ -46,14 +50,25 @@ proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; re result = doctype & $node -proc renderErrorEmbed*(error: string; prefs: Prefs; cfg: Config; req: Request): string = +proc renderErrorEmbed*(error: string; prefs: Prefs; cfg: Config; req: Request; + tweetId = ""; username = ""): string = + let link = if tweetId.len > 0: + if username.len > 0: "/" & username & "/status/" & tweetId + else: "/i/status/" & tweetId + else: "/" + let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req) + base(target="_blank") body: - tdiv(class="tweet-embed error-embed"): - tdiv(class="error-panel"): - span: text error + tdiv(class="embed-wrapper"): + tdiv(class="tweet-embed error-embed"): + a(class="tweet-link", href=link) + tdiv(class="error-panel"): + span: text error + a(class="embed-footer", href=link): + text "Read more on " & cfg.hostname script: verbatim embedResizeJs diff --git a/src/views/general.nim b/src/views/general.nim index 77e8402..331c1f2 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=85") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=101") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7") if theme.len > 0: From b76e3c3913a15c0f4ab433d9c4d575ad818ab482 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 10 Aug 2026 22:51:17 +0700 Subject: [PATCH 22/29] Cap embed iframe height at 10000px --- public/js/widgets.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/js/widgets.js b/public/js/widgets.js index 24345ae..4b6bf49 100644 --- a/public/js/widgets.js +++ b/public/js/widgets.js @@ -105,7 +105,7 @@ function handleResize(e) { if (!Array.isArray(e.data) || e.data[0] !== "resizeIframe") return; var h = e.data[1] && e.data[1].h; - if (!h || h <= 0) return; + if (!h || h <= 0 || h > 10000) return; // Cap at 10000px for sanity var frames = document.querySelectorAll("iframe.nitter-embed-frame"); for (var i = 0; i < frames.length; i++) { From 5fa4eefb9d60fdfbc4c54b27197b0575438c0053 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 12 Aug 2026 01:36:40 +0700 Subject: [PATCH 23/29] Improve video embed overlay and proxy behavior --- public/js/embedResize.js | 26 ++++++++++++--- public/js/widgets.js | 69 ++++++++++++++++++++++++++------------- src/sass/tweet/embed.scss | 15 +++++++-- src/views/embed.nim | 2 +- src/views/general.nim | 2 +- 5 files changed, 82 insertions(+), 32 deletions(-) diff --git a/public/js/embedResize.js b/public/js/embedResize.js index 69829fe..664abbf 100644 --- a/public/js/embedResize.js +++ b/public/js/embedResize.js @@ -1,25 +1,41 @@ -(function() { - var embedElement = document.querySelector('.embed-wrapper, .tweet-embed, .embed-video'); +(function () { + var embedElement = document.querySelector( + ".embed-wrapper, .tweet-embed, .embed-video", + ); if (!embedElement) return; + // Video play state for overlay (hidden while playing, visible on hover/pause) + var video = embedElement.querySelector("video"); + if (video) { + video.onplay = function () { + embedElement.classList.add("video-playing"); + }; + video.onpause = video.onended = function () { + embedElement.classList.remove("video-playing"); + }; + } + var lastHeight = 0; function sendHeight() { var currentHeight = embedElement.offsetHeight; if (currentHeight !== lastHeight && currentHeight > 0) { lastHeight = currentHeight; - window.parent.postMessage(['resizeIframe', { h: currentHeight, url: location.href }], '*'); + window.parent.postMessage( + ["resizeIframe", { h: currentHeight, url: location.href }], + "*", + ); } } // Respond to height requests from parent via MessageChannel - window.addEventListener('message', function(event) { + 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); + window.addEventListener("load", sendHeight); new ResizeObserver(sendHeight).observe(embedElement); // Expose for embedTweet.js diff --git a/public/js/widgets.js b/public/js/widgets.js index 4b6bf49..7bb283a 100644 --- a/public/js/widgets.js +++ b/public/js/widgets.js @@ -8,10 +8,7 @@ if (window.__nitterWidgets) return; window.__nitterWidgets = true; - var scripts = document.querySelectorAll('script[src*="widgets.js"]'); - var NITTER = scripts.length - ? new URL(scripts[scripts.length - 1].src).origin - : location.origin; + var NITTER = new URL(document.currentScript.src).origin; var TWEET_RE = /(?:twitter\.com|x\.com)\/([^\/]+)\/status\/(\d+)/i; var SELECTOR = "blockquote.twitter-tweet, blockquote.twitter-video"; @@ -21,11 +18,15 @@ var isReady = false; function safeCall(fn, arg) { - try { fn(arg); } catch (e) {} + try { + fn(arg); + } catch (e) { } } function fireEvent(name, data) { - (eventCallbacks[name] || []).forEach(function (cb) { safeCall(cb, data); }); + (eventCallbacks[name] || []).forEach(function (cb) { + safeCall(cb, data); + }); } function parseTweetUrl(url) { @@ -44,8 +45,12 @@ var path = tweet.user ? "/" + tweet.user : "/i"; url = NITTER + path + "/status/" + tweet.id + "/embed"; if (opts.theme) { - var theme = opts.theme === "dark" ? "nitter" : - opts.theme === "light" ? "twitter" : opts.theme; + var theme = + opts.theme === "dark" + ? "nitter" + : opts.theme === "light" + ? "twitter" + : opts.theme; url += "?theme=" + encodeURIComponent(theme); } } @@ -60,11 +65,18 @@ if (opts.videoOnly) iframe.setAttribute("allowfullscreen", "true"); var width = opts.width || 550; - var margin = opts.align === "center" ? "10px auto" : - opts.align === "right" ? "10px 0 10px auto" : "10px 0"; + var margin = + opts.align === "center" + ? "10px auto" + : opts.align === "right" + ? "10px 0 10px auto" + : "10px 0"; iframe.style.cssText = - "width:100%;max-width:" + width + "px;height:250px;" + - "border:none;display:block;margin:" + margin; + "width:100%;max-width:" + + width + + "px;height:300px;" + + "border:none;display:block;margin:" + + margin; iframe.addEventListener("load", function () { fireEvent("rendered", { target: iframe }); @@ -89,7 +101,7 @@ width: d.mediaMaxWidth || d.width, align: d.align, theme: d.theme, - videoOnly: d.mediaMaxWidth !== undefined + videoOnly: d.mediaMaxWidth !== undefined, }); bq.style.display = "none"; @@ -98,7 +110,9 @@ } function processEmbeds(root) { - var bqs = (root || document).querySelectorAll(SELECTOR + ":not([data-nitter-processed])"); + var bqs = (root || document).querySelectorAll( + SELECTOR + ":not([data-nitter-processed])", + ); for (var i = 0; i < bqs.length; i++) processBlockquote(bqs[i]); } @@ -147,7 +161,7 @@ createTweet: embedTweet, createTweetEmbed: embedTweet, createVideo: embedTweet, - loaded: true + loaded: true, }, events: { bind: function (name, cb) { @@ -158,33 +172,44 @@ unbind: function (name, cb) { if (!eventCallbacks[name]) return; eventCallbacks[name] = cb - ? eventCallbacks[name].filter(function (f) { return f !== cb; }) + ? eventCallbacks[name].filter(function (f) { + return f !== cb; + }) : []; - } + }, }, ready: function (cb) { if (typeof cb !== "function") return; if (isReady) cb(window.twttr); else readyCallbacks.push(cb); }, - _e: [] + _e: [], }; // Process callbacks queued before load (twttr._e pattern) if (prevTwttr && prevTwttr._e) { - prevTwttr._e.forEach(function (cb) { safeCall(cb); }); + prevTwttr._e.forEach(function (cb) { + safeCall(cb); + }); } // Remove any Twitter scripts that snuck through - document.querySelectorAll('script[src*="platform.twitter.com"], script[src*="platform.x.com"]') - .forEach(function (s) { s.remove(); }); + document + .querySelectorAll( + 'script[src*="platform.twitter.com"], script[src*="platform.x.com"]', + ) + .forEach(function (s) { + s.remove(); + }); function init() { window.addEventListener("message", handleResize); processEmbeds(); observeDOM(); isReady = true; - readyCallbacks.forEach(function (cb) { safeCall(cb, window.twttr); }); + readyCallbacks.forEach(function (cb) { + safeCall(cb, window.twttr); + }); readyCallbacks = []; } diff --git a/src/sass/tweet/embed.scss b/src/sass/tweet/embed.scss index 9ca1d48..9ff8403 100644 --- a/src/sass/tweet/embed.scss +++ b/src/sass/tweet/embed.scss @@ -88,6 +88,7 @@ html:has(body > .embed-video) { // Video-only embed .embed-video { position: relative; + min-height: 300px; background-color: black; border: 1px solid var(--border_grey); @@ -106,7 +107,7 @@ html:has(body > .embed-video) { width: 100%; } - .gallery-video > .attachment { + .gallery-video>.attachment { max-height: 560px; width: 100%; } @@ -135,7 +136,9 @@ html:has(body > .embed-video) { text-decoration: none; border-radius: 9999px; border: 1px solid transparent; - transition: background 0.15s, opacity 0.15s; + transition: + background 0.15s, + opacity 0.15s; z-index: 10; &:hover { @@ -143,8 +146,14 @@ html:has(body > .embed-video) { } } - &:has(video:playing) .video-overlay-link { + // Hide button while playing, show on hover or when paused + &.video-playing .video-overlay-link { opacity: 0; pointer-events: none; } + + &.video-playing:hover .video-overlay-link { + opacity: 1; + pointer-events: auto; + } } diff --git a/src/views/embed.nim b/src/views/embed.nim index 27fda24..82ef670 100644 --- a/src/views/embed.nim +++ b/src/views/embed.nim @@ -14,7 +14,7 @@ proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = video = tweet.getVideos()[0] thumb = video.thumb vidUrl = getVideoEmbed(cfg, tweet.id) - prefs = Prefs(hlsPlayback: true, mp4Playback: true) + prefs = Prefs(hlsPlayback: true, mp4Playback: true, proxyVideos: true) tweetUrl = getLink(tweet) let node = buildHtml(html(lang="en")): diff --git a/src/views/general.nim b/src/views/general.nim index 331c1f2..143da54 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=101") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=106") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7") if theme.len > 0: From 1d61f8cf070fd7e2a162ee1ecfa3e08a70a3dd9a Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 13 Aug 2026 02:01:59 +0700 Subject: [PATCH 24/29] Respect instance proxyVideos config in video embeds --- src/views/embed.nim | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/views/embed.nim b/src/views/embed.nim index 82ef670..5136a35 100644 --- a/src/views/embed.nim +++ b/src/views/embed.nim @@ -2,7 +2,7 @@ import karax/[karaxdsl, vdom] from jester import Request -import ".."/[types, formatters] +import ".."/[types, formatters, prefs] import general, tweet const @@ -14,7 +14,7 @@ proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = video = tweet.getVideos()[0] thumb = video.thumb vidUrl = getVideoEmbed(cfg, tweet.id) - prefs = Prefs(hlsPlayback: true, mp4Playback: true, proxyVideos: true) + prefs = Prefs(hlsPlayback: true, mp4Playback: true, proxyVideos: defaultPrefs.proxyVideos) tweetUrl = getLink(tweet) let node = buildHtml(html(lang="en")): From 756beab8a60d4ffd1284288e658484d9047aeb6c Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 13 Aug 2026 02:37:51 +0700 Subject: [PATCH 25/29] Clean up embedResize.js --- public/js/embedResize.js | 37 ++++++++++++++----------------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/public/js/embedResize.js b/public/js/embedResize.js index 664abbf..3fb05a0 100644 --- a/public/js/embedResize.js +++ b/public/js/embedResize.js @@ -1,43 +1,34 @@ (function () { - var embedElement = document.querySelector( - ".embed-wrapper, .tweet-embed, .embed-video", - ); - if (!embedElement) return; + var embed = document.querySelector(".embed-wrapper, .embed-video"); + if (!embed) return; - // Video play state for overlay (hidden while playing, visible on hover/pause) - var video = embedElement.querySelector("video"); + var video = embed.querySelector("video"); if (video) { video.onplay = function () { - embedElement.classList.add("video-playing"); + embed.classList.add("video-playing"); }; video.onpause = video.onended = function () { - embedElement.classList.remove("video-playing"); + embed.classList.remove("video-playing"); }; } var lastHeight = 0; function sendHeight() { - var currentHeight = embedElement.offsetHeight; - if (currentHeight !== lastHeight && currentHeight > 0) { - lastHeight = currentHeight; - window.parent.postMessage( - ["resizeIframe", { h: currentHeight, url: location.href }], - "*", - ); + var h = embed.offsetHeight; + if (h !== lastHeight && h > 0) { + lastHeight = h; + window.parent.postMessage(["resizeIframe", { h: h }], "*"); } } - // 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); + // MessageChannel height request (used by oEmbed) + window.addEventListener("message", function (e) { + if (e.source === window.parent && e.ports && e.ports[0]) { + e.ports[0].postMessage(embed.offsetHeight); } }); window.addEventListener("load", sendHeight); - new ResizeObserver(sendHeight).observe(embedElement); - - // Expose for embedTweet.js - window._nitterSendHeight = sendHeight; + new ResizeObserver(sendHeight).observe(embed); })(); From 989b1f5f6490a0b35a817e1374c9db7b9b82cc83 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 13 Aug 2026 22:43:59 +0700 Subject: [PATCH 26/29] Fix endless test reruns --- .github/workflows/run-tests.yml | 3 ++- tests/pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index dcfff52..daccac9 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -62,6 +62,7 @@ jobs: needs: [build-test] name: Integration test runs-on: ubuntu-24.04 + timeout-minutes: 30 services: redis: @@ -147,4 +148,4 @@ jobs: run: | ./nitter & cd tests - poetry run pytest -n3 --reruns=5 --rs . + poetry run pytest -n3 --rs . diff --git a/tests/pyproject.toml b/tests/pyproject.toml index a0def28..4105166 100644 --- a/tests/pyproject.toml +++ b/tests/pyproject.toml @@ -8,4 +8,4 @@ python = "^3.14" seleniumbase = "4.46.5" [tool.pytest.ini_options] -addopts = "--pls=eager --reruns=5 --timeout_multiplier=5" +addopts = "--pls=eager --reruns=2 --only-rerun=timeout --only-rerun=Timeout --only-rerun=Connection --timeout_multiplier=5" From 7f6c083dce7a5482a5257c9a9e0b719189f19fcb Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 14 Aug 2026 02:27:05 +0700 Subject: [PATCH 27/29] Add oEmbed discovery and standard support Fixes #696 --- src/routes/embed.nim | 68 +++++++++++++++++++++++++++++------------ src/routes/status.nim | 6 +++- src/views/general.nim | 10 ++++-- src/views/oembed.nimf | 4 +-- tests/pyproject.toml | 2 +- tests/test_embed.py | 71 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 135 insertions(+), 26 deletions(-) diff --git a/src/routes/embed.nim b/src/routes/embed.nim index 5f3e6f3..d1531e4 100644 --- a/src/routes/embed.nim +++ b/src/routes/embed.nim @@ -8,24 +8,32 @@ import router_utils export api, embed, vdom, tweet, general, router_utils -proc parseTweetUrl*(url: string): tuple[username, id: string] = +proc parseTweetPath(path: string): tuple[username, id: string] = + let parts = path.split('/') + if parts.len >= 3 and parts[1] in ["status", "statuses"]: + let tweetId = parts[2].split('?')[0].split('#')[0] + if tweetId.len > 0 and tweetId.allCharsInSet(Digits): + return (parts[0], tweetId) + return ("", "") + +proc parseTweetUrl*(url: string; cfg: Config): 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: + const twitterPrefixes = ["twitter.com/", "x.com/", "mobile.twitter.com/", + "www.twitter.com/", "www.x.com/"] + + for prefix in twitterPrefixes: 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 parseTweetPath(path[prefix.len..^1]) + + let nitterPrefix = cfg.hostname & "/" + if path.startsWith(nitterPrefix): + return parseTweetPath(path[nitterPrefix.len..^1]) + return ("", "") proc createEmbedRouter*(cfg: Config) = @@ -68,11 +76,19 @@ proc createEmbedRouter*(cfg: Config) = resp Http404 get "/api/oembed": - let url = @"url" + responseHeaders().get.add(("Access-Control-Allow-Origin", "*")) + + let + url = @"url" + format = @"format" + + if format.len > 0 and format != "json": + resp Http501, "Only JSON format is supported" + if url.len == 0: resp Http400, "Missing url parameter" - let (username, tweetId) = parseTweetUrl(url) + let (username, tweetId) = parseTweetUrl(url, cfg) if username.len == 0 or tweetId.len == 0: resp Http400, "Invalid tweet URL" @@ -81,20 +97,34 @@ proc createEmbedRouter*(cfg: Config) = resp Http404 let - embedUrl = getUrlPrefix(cfg) & "/" & username & "/status/" & tweetId & "/embed" + maxwidthParam = @"maxwidth" + maxwidth = if maxwidthParam.len > 0: + try: clamp(parseInt(maxwidthParam), 220, 550) + except ValueError: 550 + else: 550 + embedUrl = getUrlPrefix(cfg) & "/" & tweet.user.username & "/status/" & tweetId & "/embed" authorUrl = getUrlPrefix(cfg) & "/" & tweet.user.username + title = stripHtml(tweet.text) - responseHeaders().get.add(("Access-Control-Allow-Origin", "*")) - respJson %*{ + var response = %*{ "version": "1.0", "type": "rich", "provider_name": cfg.title, "provider_url": getUrlPrefix(cfg), + "title": title, "author_name": tweet.user.fullname, "author_url": authorUrl, "url": embedUrl, - "width": 550, - "height": nil, + "width": maxwidth, + "height": newJNull(), "cache_age": "3153600000", - "html": renderOembedIframe(embedUrl) + "html": renderOembedIframe(embedUrl, maxwidth) } + + if tweet.media.len > 0: + let thumbUrl = getUrlPrefix(cfg) & getPicUrl(tweet.media[0].getThumb) + response["thumbnail_url"] = %thumbUrl + response["thumbnail_width"] = %maxwidth + response["thumbnail_height"] = %maxwidth + + respJson response diff --git a/src/routes/status.nim b/src/routes/status.nim index 870b6d7..32a4447 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -66,9 +66,13 @@ proc createStatusRouter*(cfg: Config) = elif card.video.isSome(): images = @[card.video.get().thumb] + let + tweetUrl = getUrlPrefix(cfg) & "/" & conv.tweet.user.username & "/status/" & $conv.tweet.id + oembedUrl = getUrlPrefix(cfg) & "/api/oembed?url=" & encodeUrl(tweetUrl) + let html = renderConversation(conv, prefs, getPath() & "#m", sort) resp renderMain(html, request, cfg, prefs, title, desc, ogTitle, - images=images, video=video) + images=images, video=video, oembed=oembedUrl) get "/@name/status/@id/history/?": cond '.' notin @"name" diff --git a/src/views/general.nim b/src/views/general.nim index 143da54..d979898 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -38,7 +38,7 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode = proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; video=""; images: seq[string] = @[]; banner=""; ogTitle=""; - rss=""; alternate=""): VNode = + rss=""; alternate=""; oembed=""): VNode = let theme = prefs.theme.toTheme let ogType = @@ -70,6 +70,10 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; if rss.len > 0: link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed") + if oembed.len > 0: + let oembedTitle = if titleText.len > 0: titleText else: "oEmbed" + link(rel="alternate", type="application/json+oembed", href=oembed, title=oembedTitle) + if prefs.hlsPlayback: script(src="/js/hls.min.js", `defer`="") script(src="/js/hlsPlayback.js?v=1", `defer`="") @@ -124,7 +128,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; titleText=""; desc=""; ogTitle=""; rss=""; video=""; images: seq[string] = @[]; banner=""; - twitterLink=""): string = + twitterLink=""; oembed=""): string = let twitterLink = if twitterLink.len > 0: twitterLink @@ -132,7 +136,7 @@ proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, - rss, twitterLink) + rss, twitterLink, oembed) let bodyClass = if prefs.stickyNav: "fixed-nav" else: "" body(class=bodyClass): diff --git a/src/views/oembed.nimf b/src/views/oembed.nimf index 3dc793b..4f7f947 100644 --- a/src/views/oembed.nimf +++ b/src/views/oembed.nimf @@ -1,7 +1,7 @@ #? stdtmpl(subsChar = '$', metaChar = '#') ## SPDX-License-Identifier: AGPL-3.0-only -#proc renderOembedIframe*(embedUrl: string): string = +#proc renderOembedIframe*(embedUrl: string; maxwidth = 550): string = # result = "" - + # result = result.strip() #end proc diff --git a/tests/pyproject.toml b/tests/pyproject.toml index 4105166..1907e60 100644 --- a/tests/pyproject.toml +++ b/tests/pyproject.toml @@ -8,4 +8,4 @@ python = "^3.14" seleniumbase = "4.46.5" [tool.pytest.ini_options] -addopts = "--pls=eager --reruns=2 --only-rerun=timeout --only-rerun=Timeout --only-rerun=Connection --timeout_multiplier=5" +addopts = "--pls=eager --rcs --reruns=2 --only-rerun=timeout --only-rerun=Timeout --only-rerun=Connection --only-rerun=WebDriverException --timeout_multiplier=5" diff --git a/tests/test_embed.py b/tests/test_embed.py index c15d247..77b69e6 100644 --- a/tests/test_embed.py +++ b/tests/test_embed.py @@ -178,12 +178,83 @@ class OEmbedApiTest(BaseTestCase): resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&maxwidth=400') self.assertEqual(resp.status_code, 200) + def test_oembed_maxwidth_clamps_to_range(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&maxwidth=100') + data = resp.json() + self.assertEqual(data['width'], 220) + self.assertIn('max-width:220px', data['html']) + + def test_oembed_maxwidth_caps_at_550(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&maxwidth=9999') + data = resp.json() + self.assertEqual(data['width'], 550) + + def test_oembed_maxwidth_invalid_uses_default(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&maxwidth=abc') + data = resp.json() + self.assertEqual(data['width'], 550) + def test_oembed_author_url_present(self): resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') data = resp.json() self.assertIn('author_url', data) self.assertIn('elonmusk', data['author_url']) + def test_oembed_accepts_nitter_url(self): + nitter_url = f'{self.base_url}/elonmusk/status/1141367104702038016' + resp = requests.get(f'{self.base_url}/api/oembed?url={nitter_url}') + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertIn('html', data) + + def test_oembed_format_json_accepted(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&format=json') + self.assertEqual(resp.status_code, 200) + + def test_oembed_format_xml_returns_501(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&format=xml') + self.assertEqual(resp.status_code, 501) + + def test_oembed_has_title(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') + data = resp.json() + self.assertIn('title', data) + self.assertIsInstance(data['title'], str) + self.assertGreater(len(data['title']), 0) + + def test_oembed_has_null_height(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') + data = resp.json() + self.assertIsNone(data['height']) + + +class OEmbedDiscoveryTest(BaseTestCase): + """Test oEmbed discovery link tags on tweet pages.""" + + def test_tweet_page_has_oembed_link_tag(self): + self.open_nitter('elonmusk/status/1141367104702038016') + self.assert_element_present('link[type="application/json+oembed"]') + + def test_oembed_link_tag_points_to_api(self): + resp = requests.get('http://localhost:8080/elonmusk/status/1141367104702038016') + self.assertIn('application/json+oembed', resp.text) + self.assertIn('/api/oembed?url=', resp.text) + self.assertIn('1141367104702038016', resp.text) + + def test_oembed_discovery_roundtrip(self): + """Fetch a tweet page, extract oEmbed URL, call it, verify response.""" + import re + resp = requests.get('http://localhost:8080/elonmusk/status/1141367104702038016') + match = re.search( + r'type="application/json\+oembed"\s+href="([^"]*)"', resp.text) + self.assertIsNotNone(match, "No oEmbed discovery link found in page") + oembed_url = match.group(1).replace('&', '&') + oembed_resp = requests.get(oembed_url) + self.assertEqual(oembed_resp.status_code, 200) + data = oembed_resp.json() + self.assertEqual(data['type'], 'rich') + self.assertIn('html', data) + class VideoEmbedTest(BaseTestCase): """Test video embed route (/i/videos/tweet/{id}).""" From 06e59f06159381b6df9ce107553470a78eee7bd2 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 15 Aug 2026 00:23:53 +0700 Subject: [PATCH 28/29] Fix oEmbed CI: accept any host in URL parsing --- src/routes/embed.nim | 10 ++++++++++ tests/test_embed.py | 9 +++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/routes/embed.nim b/src/routes/embed.nim index d1531e4..24bba2d 100644 --- a/src/routes/embed.nim +++ b/src/routes/embed.nim @@ -34,6 +34,16 @@ proc parseTweetUrl*(url: string; cfg: Config): tuple[username, id: string] = if path.startsWith(nitterPrefix): return parseTweetPath(path[nitterPrefix.len..^1]) + # Fall back: strip any hostname and try to parse as a tweet path. + # Handles requests where the URL's host differs from cfg.hostname + # (e.g. localhost in dev/CI, or a reverse proxy with a different domain). + let slashPos = path.find('/') + if slashPos > 0: + let afterHost = path[slashPos + 1..^1] + let parsed = parseTweetPath(afterHost) + if parsed.username.len > 0: + return parsed + return ("", "") proc createEmbedRouter*(cfg: Config) = diff --git a/tests/test_embed.py b/tests/test_embed.py index 77b69e6..e44473a 100644 --- a/tests/test_embed.py +++ b/tests/test_embed.py @@ -230,13 +230,14 @@ class OEmbedApiTest(BaseTestCase): class OEmbedDiscoveryTest(BaseTestCase): """Test oEmbed discovery link tags on tweet pages.""" + base_url = 'http://localhost:8080' def test_tweet_page_has_oembed_link_tag(self): self.open_nitter('elonmusk/status/1141367104702038016') self.assert_element_present('link[type="application/json+oembed"]') def test_oembed_link_tag_points_to_api(self): - resp = requests.get('http://localhost:8080/elonmusk/status/1141367104702038016') + resp = requests.get(f'{self.base_url}/elonmusk/status/1141367104702038016') self.assertIn('application/json+oembed', resp.text) self.assertIn('/api/oembed?url=', resp.text) self.assertIn('1141367104702038016', resp.text) @@ -244,11 +245,15 @@ class OEmbedDiscoveryTest(BaseTestCase): def test_oembed_discovery_roundtrip(self): """Fetch a tweet page, extract oEmbed URL, call it, verify response.""" import re - resp = requests.get('http://localhost:8080/elonmusk/status/1141367104702038016') + from urllib.parse import urlparse + resp = requests.get(f'{self.base_url}/elonmusk/status/1141367104702038016') match = re.search( r'type="application/json\+oembed"\s+href="([^"]*)"', resp.text) self.assertIsNotNone(match, "No oEmbed discovery link found in page") oembed_url = match.group(1).replace('&', '&') + # Rewrite host to base_url in case cfg.hostname differs (e.g. CI) + parsed = urlparse(oembed_url) + oembed_url = f'{self.base_url}{parsed.path}?{parsed.query}' oembed_resp = requests.get(oembed_url) self.assertEqual(oembed_resp.status_code, 200) data = oembed_resp.json() From f74b2282be6c22184a258c5cd402042501435ca6 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 15 Aug 2026 00:35:48 +0700 Subject: [PATCH 29/29] Set CI hostname to localhost for oEmbed tests --- .github/workflows/run-tests.yml | 1 + tests/test_embed.py | 4 ---- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index daccac9..8e6c814 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -137,6 +137,7 @@ jobs: cp nitter.example.conf nitter.conf sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf sed -i 's/maxRetries = 1/maxRetries = 10/g' nitter.conf + sed -i 's/hostname = "nitter.net"/hostname = "localhost:8080"/g' nitter.conf nim r tools/rendermd.nim nim r tools/gencss.nim diff --git a/tests/test_embed.py b/tests/test_embed.py index e44473a..a1ef3e7 100644 --- a/tests/test_embed.py +++ b/tests/test_embed.py @@ -245,15 +245,11 @@ class OEmbedDiscoveryTest(BaseTestCase): def test_oembed_discovery_roundtrip(self): """Fetch a tweet page, extract oEmbed URL, call it, verify response.""" import re - from urllib.parse import urlparse resp = requests.get(f'{self.base_url}/elonmusk/status/1141367104702038016') match = re.search( r'type="application/json\+oembed"\s+href="([^"]*)"', resp.text) self.assertIsNotNone(match, "No oEmbed discovery link found in page") oembed_url = match.group(1).replace('&', '&') - # Rewrite host to base_url in case cfg.hostname differs (e.g. CI) - parsed = urlparse(oembed_url) - oembed_url = f'{self.base_url}{parsed.path}?{parsed.query}' oembed_resp = requests.get(oembed_url) self.assertEqual(oembed_resp.status_code, 200) data = oembed_resp.json()