From 5b4a3fe691ce7a1a8e7cd491b0b37d3c95a621ce Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 23 Nov 2025 19:26:48 +0100 Subject: [PATCH 001/161] Redirect /i/status/id/history to /i/status/id Fixes #1231 --- src/routes/status.nim | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/routes/status.nim b/src/routes/status.nim index 7e89220..0168dac 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -31,8 +31,6 @@ proc createStatusRouter*(cfg: Config) = resp $renderReplies(replies, prefs, getPath()) let conv = await getTweet(id, getCursor()) - if conv == nil: - echo "nil conv" if conv == nil or conv.tweet == nil or conv.tweet.id == 0: var error = "Tweet not found" @@ -68,7 +66,7 @@ proc createStatusRouter*(cfg: Config) = get "/@name/@s/@id/@m/?@i?": cond @"s" in ["status", "statuses"] - cond @"m" in ["video", "photo"] + cond @"m" in ["video", "photo", "history"] redirect("/$1/status/$2" % [@"name", @"id"]) get "/@name/statuses/@id/?": @@ -76,6 +74,6 @@ proc createStatusRouter*(cfg: Config) = get "/i/web/status/@id": redirect("/i/status/" & @"id") - + get "/@name/thread/@id/?": redirect("/$1/status/$2" % [@"name", @"id"]) From 53edbbc4e9d5cc8177596472894d49ea753a7049 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 23 Nov 2025 19:58:24 +0100 Subject: [PATCH 002/161] Fix broken tweet pagination ("Load more" button) Fixes #1277 --- src/parser.nim | 1 + 1 file changed, 1 insertion(+) diff --git a/src/parser.nim b/src/parser.nim index 700e896..5bf2b0b 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -485,6 +485,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result.before.content.add tweet elif entryId.startsWith("cursor-bottom"): var cursorValue = select( + e{"content", "value"}, e{"content", "content", "value"}, e{"content", "itemContent", "value"} ) From 25df68209472133bf0b9f9abecc725587d9facba Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 24 Nov 2025 23:04:25 +0100 Subject: [PATCH 003/161] Expose username as HTML attribute Fixes #551 --- src/views/timeline.nim | 2 +- src/views/tweet.nim | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/views/timeline.nim b/src/views/timeline.nim index abeb6d3..a205c04 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -56,7 +56,7 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode = index=i, last=(i == thread.high), showThread=show) proc renderUser(user: User; prefs: Prefs): VNode = - buildHtml(tdiv(class="timeline-item")): + buildHtml(tdiv(class="timeline-item", data-username=user.username)): a(class="tweet-link", href=("/" & user.username)) tdiv(class="tweet-body profile-result"): tdiv(class="tweet-header"): diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 8ff8cb1..552ab89 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -272,7 +272,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; divClass = "thread-last " & class if not tweet.available: - return buildHtml(tdiv(class=divClass & "unavailable timeline-item")): + return buildHtml(tdiv(class=divClass & "unavailable timeline-item", data-username=tweet.user.username)): tdiv(class="unavailable-box"): if tweet.tombstone.len > 0: text tweet.tombstone @@ -294,7 +294,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; tweet = tweet.retweet.get retweet = fullTweet.user.fullname - buildHtml(tdiv(class=("timeline-item " & divClass))): + buildHtml(tdiv(class=("timeline-item " & divClass), data-username=tweet.user.username)): if not mainTweet: a(class="tweet-link", href=getLink(tweet)) From 1657eeb769d79ffe414ec31bd4ce4686e3a3400c Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 24 Nov 2025 23:04:25 +0100 Subject: [PATCH 004/161] Fix canonical link causing redirects to Twitter Fixes #526 --- src/formatters.nim | 2 +- src/views/general.nim | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/formatters.nim b/src/formatters.nim index cafaa4f..7bbbe8b 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -6,7 +6,7 @@ import types, utils, query const cards = "cards.twitter.com/cards" tco = "https://t.co" - twitter = parseUri("https://twitter.com") + twitter = parseUri("https://x.com") let twRegex = re"(?<=(? 0: icon "rss", title="RSS Feed", href=rss - icon "bird", title="Open in Twitter", href=canonical + icon "bird", title="Open in X", href=canonical a(href="https://liberapay.com/zedeus"): verbatim lp icon "info", title="About", href="/about" icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path)) proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; video=""; images: seq[string] = @[]; banner=""; ogTitle=""; - rss=""; canonical=""): VNode = + rss=""; alternate=""): VNode = var theme = prefs.theme.toTheme if "theme" in req.params: theme = req.params["theme"].toTheme @@ -66,8 +66,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; link(rel="search", type="application/opensearchdescription+xml", title=cfg.title, href=opensearchUrl) - if canonical.len > 0: - link(rel="canonical", href=canonical) + if alternate.len > 0: + link(rel="alternate", href=alternate, title="View on X") if cfg.enableRss and rss.len > 0: link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed") @@ -125,14 +125,14 @@ proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; titleText=""; desc=""; ogTitle=""; rss=""; video=""; images: seq[string] = @[]; banner=""): string = - let canonical = getTwitterLink(req.path, req.params) + let twitterLink = getTwitterLink(req.path, req.params) let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, - rss, canonical) + rss, twitterLink) body: - renderNavbar(cfg, req, rss, canonical) + renderNavbar(cfg, req, rss, twitterLink) tdiv(class="container"): body From d47eb8f0eb6ba34055ba80e1537e7b415107a7af Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 24 Nov 2025 23:04:25 +0100 Subject: [PATCH 005/161] Fix double slashes in url replacements Fixes #520 --- src/formatters.nim | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/formatters.nim b/src/formatters.nim index 7bbbe8b..e491928 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -59,25 +59,28 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string = result = body if prefs.replaceYouTube.len > 0 and "youtu" in result: - result = result.replace(ytRegex, prefs.replaceYouTube) + let youtubeHost = strip(prefs.replaceYouTube, chars={'/'}) + result = result.replace(ytRegex, youtubeHost) if prefs.replaceTwitter.len > 0: + let twitterHost = strip(prefs.replaceTwitter, chars={'/'}) if tco in result: - result = result.replace(tco, https & prefs.replaceTwitter & "/t.co") + result = result.replace(tco, https & twitterHost & "/t.co") if "x.com" in result: - result = result.replace(xRegex, prefs.replaceTwitter) + result = result.replace(xRegex, twitterHost) result = result.replacef(xLinkRegex, a( - prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1")) + twitterHost & "$2", href = https & twitterHost & "$1")) if "twitter.com" in result: - result = result.replace(cards, prefs.replaceTwitter & "/cards") - result = result.replace(twRegex, prefs.replaceTwitter) + result = result.replace(cards, twitterHost & "/cards") + result = result.replace(twRegex, twitterHost) result = result.replacef(twLinkRegex, a( - prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1")) + twitterHost & "$2", href = https & twitterHost & "$1")) if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result): - result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/") - result = result.replace(rdRegex, prefs.replaceReddit) - if prefs.replaceReddit in result and "/gallery/" in result: + let redditHost = strip(prefs.replaceReddit, chars={'/'}) + result = result.replace(rdShortRegex, redditHost & "/comments/") + result = result.replace(rdRegex, redditHost) + if redditHost in result and "/gallery/" in result: result = result.replace("/gallery/", "/comments/") if absolute.len > 0 and "href" in result: From 4748311f8dd9de0e3cab7cd00027c333b024dd9b Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 24 Nov 2025 23:04:25 +0100 Subject: [PATCH 006/161] Fix intent/follow URL redirect Fixes #629 --- src/routes/timeline.nim | 6 ++++++ src/routes/unsupported.nim | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 7a10e91..49c7ce2 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -105,6 +105,12 @@ proc createTimelineRouter*(cfg: Config) = get "/intent/user": respUserId() + get "/intent/follow/?": + let username = request.params.getOrDefault("screen_name") + if username.len == 0: + resp Http400, showError("Missing screen_name parameter", cfg) + redirect("/" & username) + get "/@name/?@tab?/?": cond '.' notin @"name" cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] diff --git a/src/routes/unsupported.nim b/src/routes/unsupported.nim index 0c085d4..362b36b 100644 --- a/src/routes/unsupported.nim +++ b/src/routes/unsupported.nim @@ -17,7 +17,7 @@ proc createUnsupportedRouter*(cfg: Config) = get "/@name/lists/?": feature() get "/intent/?@i?": - cond @"i" notin ["user"] + cond @"i" notin ["user", "follow"] feature() get "/i/@i?/?@j?": From f038b53fa2790866742dc1d6ca511950ed8bf276 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 24 Nov 2025 23:04:25 +0100 Subject: [PATCH 007/161] Fix body font size to match x.com Fixes #711 --- src/sass/index.scss | 2 +- src/views/general.nim | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sass/index.scss b/src/sass/index.scss index 6cab48e..36a8a93 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -51,7 +51,7 @@ body { background-color: var(--bg_color); color: var(--fg_color); font-family: $font_0, $font_1, $font_2, $font_3; - font-size: 14px; + font-size: 15px; line-height: 1.3; margin: 0; } diff --git a/src/views/general.nim b/src/views/general.nim index faf4c1d..d431e98 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -52,7 +52,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=19") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=20") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3") if theme.len > 0: From 4979d07f2ed9ee15238fd29d52acca42710a5333 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 24 Nov 2025 23:04:25 +0100 Subject: [PATCH 008/161] Add spaces filter, remove broken filters --- src/query.nim | 5 ++--- src/views/search.nim | 4 +--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/query.nim b/src/query.nim index 06e1da2..b6ff5df 100644 --- a/src/query.nim +++ b/src/query.nim @@ -6,10 +6,9 @@ import types const validFilters* = @[ "media", "images", "twimg", "videos", - "native_video", "consumer_video", "pro_video", + "native_video", "consumer_video", "spaces", "links", "news", "quote", "mentions", - "replies", "retweets", "nativeretweets", - "verified", "safe" + "replies", "retweets", "nativeretweets" ] emptyQuery* = "include:nativeretweets" diff --git a/src/views/search.nim b/src/views/search.nim index 9f7fc95..35af526 100644 --- a/src/views/search.nim +++ b/src/views/search.nim @@ -10,14 +10,12 @@ const toggles = { "media": "Media", "videos": "Videos", "news": "News", - "verified": "Verified", "native_video": "Native videos", "replies": "Replies", "links": "Links", "images": "Images", - "safe": "Safe", "quote": "Quotes", - "pro_video": "Pro videos" + "spaces": "Spaces" }.toOrderedTable proc renderSearch*(): VNode = From 12bbddf204e805885e0760b93120f90560b8354b Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 24 Nov 2025 23:04:25 +0100 Subject: [PATCH 009/161] Update search panel grid layout and animation --- src/sass/include/_mixins.css | 13 +------------ src/sass/search.scss | 15 +++++++-------- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/src/sass/include/_mixins.css b/src/sass/include/_mixins.css index 94e11ee..5fde51a 100644 --- a/src/sass/include/_mixins.css +++ b/src/sass/include/_mixins.css @@ -66,18 +66,7 @@ } #search-panel-toggle:checked ~ .search-panel { - @if $rows == 6 { - max-height: 200px !important; - } - @if $rows == 5 { - max-height: 300px !important; - } - @if $rows == 4 { - max-height: 300px !important; - } - @if $rows == 3 { - max-height: 365px !important; - } + max-height: 380px !important; } } } diff --git a/src/sass/search.scss b/src/sass/search.scss index f70f7ea..db0bc66 100644 --- a/src/sass/search.scss +++ b/src/sass/search.scss @@ -42,7 +42,7 @@ @include input-colors; } - @include create-toggle(search-panel, 200px); + @include create-toggle(search-panel, 380px); } .search-panel { @@ -104,19 +104,18 @@ .search-toggles { flex-grow: 1; display: grid; - grid-template-columns: repeat(6, auto); + grid-template-columns: repeat(5, auto); grid-column-gap: 10px; } .profile-tabs { @include search-resize(820px, 5); - @include search-resize(725px, 4); - @include search-resize(600px, 6); - @include search-resize(560px, 5); - @include search-resize(480px, 4); + @include search-resize(715px, 4); + @include search-resize(700px, 5); + @include search-resize(485px, 4); @include search-resize(410px, 3); } -@include search-resize(560px, 5); -@include search-resize(480px, 4); +@include search-resize(700px, 5); +@include search-resize(485px, 4); @include search-resize(410px, 3); From 78101df2cc22e30158bd77d18cc8267ca42834f2 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 24 Nov 2025 23:04:25 +0100 Subject: [PATCH 010/161] Style number input field --- src/query.nim | 15 ++++++++++----- src/sass/inputs.scss | 22 ++++++++++++++++++++-- src/sass/search.scss | 3 ++- src/types.nim | 2 +- src/views/general.nim | 2 +- src/views/renderutils.nim | 7 +++++++ src/views/search.nim | 6 +++--- 7 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src/query.nim b/src/query.nim index b6ff5df..c77bf5f 100644 --- a/src/query.nim +++ b/src/query.nim @@ -17,6 +17,11 @@ template `@`(param: string): untyped = if param in pms: pms[param] else: "" +proc validateNumber(value: string): string = + if value.anyIt(not it.isDigit): + return "" + return value + proc initQuery*(pms: Table[string, string]; name=""): Query = result = Query( kind: parseEnum[QueryKind](@"f", tweets), @@ -25,7 +30,7 @@ proc initQuery*(pms: Table[string, string]; name=""): Query = excludes: validFilters.filterIt("e-" & it in pms), since: @"since", until: @"until", - near: @"near" + minLikes: validateNumber(@"min_faves") ) if name.len > 0: @@ -77,8 +82,8 @@ proc genQueryParam*(query: Query): string = result &= " since:" & query.since if query.until.len > 0: result &= " until:" & query.until - if query.near.len > 0: - result &= &" near:\"{query.near}\" within:15mi" + if query.minLikes.len > 0: + result &= " min_faves:" & query.minLikes if query.text.len > 0: if result.len > 0: result &= " " & query.text @@ -102,8 +107,8 @@ proc genQueryUrl*(query: Query): string = params.add "since=" & query.since if query.until.len > 0: params.add "until=" & query.until - if query.near.len > 0: - params.add "near=" & query.near + if query.minLikes.len > 0: + params.add "min_faves=" & query.minLikes if params.len > 0: result &= params.join("&") diff --git a/src/sass/inputs.scss b/src/sass/inputs.scss index 17c2a22..d6cbb1d 100644 --- a/src/sass/inputs.scss +++ b/src/sass/inputs.scss @@ -14,6 +14,7 @@ button { input[type="text"], input[type="date"], +input[type="number"], select { @include input-colors; background-color: var(--bg_elements); @@ -24,7 +25,12 @@ select { font-size: 14px; } -input[type="text"] { +input[type="number"] { + -moz-appearance: textfield; +} + +input[type="text"], +input[type="number"] { height: 16px; } @@ -38,6 +44,17 @@ input[type="date"]::-webkit-inner-spin-button { display: none; } +input[type="number"] { + -moz-appearance: textfield; +} + +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + display: none; + -webkit-appearance: none; + margin: 0; +} + input[type="date"]::-webkit-clear-button { margin-left: 17px; filter: grayscale(100%); @@ -164,7 +181,8 @@ input::-webkit-datetime-edit-year-field:focus { appearance: none; } - input[type="text"] { + input[type="text"], + input[type="number"] { position: absolute; right: 0; max-width: 140px; diff --git a/src/sass/search.scss b/src/sass/search.scss index db0bc66..234d677 100644 --- a/src/sass/search.scss +++ b/src/sass/search.scss @@ -24,7 +24,8 @@ height: 23px; } - input[type="text"] { + input[type="text"], + input[type="number"] { height: calc(100% - 4px); width: calc(100% - 8px); } diff --git a/src/types.nim b/src/types.nim index f16fe6f..20a49c9 100644 --- a/src/types.nim +++ b/src/types.nim @@ -140,7 +140,7 @@ type fromUser*: seq[string] since*: string until*: string - near*: string + minLikes*: string sep*: string Gif* = object diff --git a/src/views/general.nim b/src/views/general.nim index d431e98..23681b5 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -52,7 +52,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=20") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=21") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3") if theme.len > 0: diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index 41ef8df..fcdf06f 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -89,6 +89,13 @@ proc genDate*(pref, state: string): VNode = input(name=pref, `type`="date", value=state) icon "calendar" +proc genNumberInput*(pref, label, state, placeholder: string; class=""; autofocus=true; min="0"): VNode = + let p = placeholder + buildHtml(tdiv(class=("pref-group pref-input " & class))): + if label.len > 0: + label(`for`=pref): text label + input(name=pref, `type`="number", placeholder=p, value=state, autofocus=(autofocus and state.len == 0), min=min, step="1") + proc genImg*(url: string; class=""): VNode = buildHtml(): img(src=getPicUrl(url), class=class, alt="", loading="lazy") diff --git a/src/views/search.nim b/src/views/search.nim index 35af526..a43008f 100644 --- a/src/views/search.nim +++ b/src/views/search.nim @@ -51,7 +51,7 @@ proc renderSearchTabs*(query: Query): VNode = proc isPanelOpen(q: Query): bool = q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or - @[q.near, q.until, q.since].anyIt(it.len > 0)) + @[q.minLikes, q.until, q.since].anyIt(it.len > 0)) proc renderSearchPanel*(query: Query): VNode = let user = query.fromUser.join(",") @@ -83,8 +83,8 @@ proc renderSearchPanel*(query: Query): VNode = span(class="search-title"): text "-" genDate("until", query.until) tdiv: - span(class="search-title"): text "Near" - genInput("near", "", query.near, "Location...", autofocus=false) + span(class="search-title"): text "Minimum likes" + genNumberInput("min_faves", "", query.minLikes, "Number...", autofocus=false) proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; pinned=none(Tweet)): VNode = From 2b922c049af946253df17a40f78d7e6027fdea0d Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 25 Nov 2025 01:02:45 +0100 Subject: [PATCH 011/161] Embed quote tweet in RSS (#1316) Fixes #132 Closes #820 --- src/views/rss.nimf | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/views/rss.nimf b/src/views/rss.nimf index 819f99c..a6f069c 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -25,7 +25,7 @@ #end proc # #proc getDescription(desc: string; cfg: Config): string = -Twitter feed for: ${desc}. Generated by ${cfg.hostname} +Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)} #end proc # #proc getTweetsWithPinned(profile: Profile): seq[Tweets] = @@ -51,10 +51,6 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname} #let urlPrefix = getUrlPrefix(cfg) #let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix)

${text.replace("\n", "
\n")}

-#if tweet.quote.isSome and get(tweet.quote).available: -# let quoteLink = getLink(get(tweet.quote)) -

${cfg.hostname}${quoteLink}

-#end if #if tweet.photos.len > 0: # for photo in tweet.photos: @@ -72,6 +68,20 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname} # end if #end if +#if tweet.quote.isSome and get(tweet.quote).available: +# let quoteTweet = get(tweet.quote) +# let quoteLink = urlPrefix & getLink(quoteTweet) +
+
+${quoteTweet.user.fullname} (@${quoteTweet.user.username}) +

+${renderRssTweet(quoteTweet, cfg)} +

+ +
+#end if #end proc # #proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string = From 404b06b5f35ae66f7f25bcc62edd5611ff00d77a Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 25 Nov 2025 01:03:45 +0100 Subject: [PATCH 012/161] Include "Video" and link for video tweets in RSS (#1315) Fixes #836 --- src/views/rss.nimf | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/views/rss.nimf b/src/views/rss.nimf index a6f069c..23744e5 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -56,7 +56,10 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)} # end for #elif tweet.video.isSome: - + +
Video
+ +
#elif tweet.gif.isSome: # let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}" # let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}" From b83227aaf5acbd8e8803c85dfd2b8f311a021604 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 25 Nov 2025 17:23:04 +0100 Subject: [PATCH 013/161] Implement temp fix for cookie sessions Fixes #1319 --- src/api.nim | 47 ++++++++++++++++------------ src/apiutils.nim | 2 +- src/consts.nim | 24 ++++++++------ src/experimental/parser/graphql.nim | 24 +++++++++++--- src/experimental/parser/user.nim | 4 ++- src/experimental/types/graphuser.nim | 13 ++++++-- src/parser.nim | 2 +- 7 files changed, 77 insertions(+), 39 deletions(-) diff --git a/src/api.nim b/src/api.nim index ef3a0f9..fb9c516 100644 --- a/src/api.nim +++ b/src/api.nim @@ -13,47 +13,54 @@ proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] = proc mediaUrl(id: string; cursor: string): SessionAwareUrl = let - cookieVariables = userMediaVariables % [id, cursor] - oauthVariables = restIdVariables % [id, cursor] + cookieVars = userMediaVars % [id, cursor] + oauthVars = restIdVars % [id, cursor] result = SessionAwareUrl( - cookieUrl: graphUserMedia ? genParams(cookieVariables), - oauthUrl: graphUserMediaV2 ? genParams(oauthVariables) + cookieUrl: graphUserMedia ? genParams(cookieVars), + oauthUrl: graphUserMediaV2 ? genParams(oauthVars) ) proc userTweetsUrl(id: string; cursor: string): SessionAwareUrl = let - cookieVariables = userTweetsVariables % [id, cursor] - oauthVariables = restIdVariables % [id, cursor] + cookieVars = userTweetsVars % [id, cursor] + oauthVars = restIdVars % [id, cursor] result = SessionAwareUrl( - # cookieUrl: graphUserTweets ? genParams(cookieVariables, fieldToggles), - oauthUrl: graphUserTweetsV2 ? genParams(oauthVariables) + # cookieUrl: graphUserTweets ? genParams(cookieVars, userTweetsFieldToggles), + oauthUrl: graphUserTweetsV2 ? genParams(oauthVars) ) # might change this in the future pending testing result.cookieUrl = result.oauthUrl proc userTweetsAndRepliesUrl(id: string; cursor: string): SessionAwareUrl = let - cookieVariables = userTweetsAndRepliesVariables % [id, cursor] - oauthVariables = restIdVariables % [id, cursor] + cookieVars = userTweetsAndRepliesVars % [id, cursor] + oauthVars = restIdVars % [id, cursor] result = SessionAwareUrl( - cookieUrl: graphUserTweetsAndReplies ? genParams(cookieVariables, fieldToggles), - oauthUrl: graphUserTweetsAndRepliesV2 ? genParams(oauthVariables) + cookieUrl: graphUserTweetsAndReplies ? genParams(cookieVars, userTweetsFieldToggles), + oauthUrl: graphUserTweetsAndRepliesV2 ? genParams(oauthVars) ) proc tweetDetailUrl(id: string; cursor: string): SessionAwareUrl = let - cookieVariables = tweetDetailVariables % [id, cursor] - oauthVariables = tweetVariables % [id, cursor] + cookieVars = tweetDetailVars % [id, cursor] + oauthVars = tweetVars % [id, cursor] result = SessionAwareUrl( - cookieUrl: graphTweetDetail ? genParams(cookieVariables, tweetDetailFieldToggles), - oauthUrl: graphTweet ? genParams(oauthVariables) + cookieUrl: graphTweetDetail ? genParams(cookieVars, tweetDetailFieldToggles), + oauthUrl: graphTweet ? genParams(oauthVars) + ) + +proc userUrl(username: string): SessionAwareUrl = + let + cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username + oauthVars = """{"screen_name": "$1"}""" % username + result = SessionAwareUrl( + cookieUrl: graphUser ? genParams(cookieVars, tweetDetailFieldToggles), + oauthUrl: graphUserV2 ? genParams(oauthVars) ) proc getGraphUser*(username: string): Future[User] {.async.} = if username.len == 0: return - let - url = graphUser ? genParams("""{"screen_name": "$1"}""" % username) - js = await fetchRaw(url, Api.userScreenName) + let js = await fetchRaw(userUrl(username), Api.userScreenName) result = parseGraphUser(js) proc getGraphUserById*(id: string): Future[User] {.async.} = @@ -80,7 +87,7 @@ proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - url = graphListTweets ? genParams(restIdVariables % [id, cursor]) + url = graphListTweets ? genParams(restIdVars % [id, cursor]) result = parseGraphTimeline(await fetch(url, Api.listTweets), after).tweets proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = diff --git a/src/apiutils.nim b/src/apiutils.nim index defffd1..94d4e8a 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -50,7 +50,7 @@ proc genHeaders*(session: Session, url: string): HttpHeaders = of SessionKind.oauth: result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret) of SessionKind.cookie: - result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF" + result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F" result["x-twitter-auth-type"] = "OAuth2Session" result["x-csrf-token"] = session.ct0 result["cookie"] = getCookieHeader(session.authToken, session.ct0) diff --git a/src/consts.nim b/src/consts.nim index 792a519..e55ba0e 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -7,7 +7,8 @@ const gql = parseUri("https://api.x.com") / "graphql" - graphUser* = gql / "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery" + graphUser* = gql / "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName" + graphUserV2* = gql / "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery" graphUserById* = gql / "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery" graphUserTweetsV2* = gql / "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2" graphUserTweetsAndRepliesV2* = gql / "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2" @@ -97,10 +98,14 @@ const "grok_translations_community_note_auto_translation_is_enabled": false, "grok_translations_post_auto_translation_is_enabled": false, "grok_translations_community_note_translation_is_enabled": false, - "grok_translations_timeline_user_bio_auto_translation_is_enabled": false + "grok_translations_timeline_user_bio_auto_translation_is_enabled": false, + "subscriptions_feature_can_gift_premium": false, + "responsive_web_twitter_article_notes_tab_enabled": false, + "subscriptions_verification_info_is_identity_verified_enabled": false, + "hidden_profile_subscriptions_enabled": false }""".replace(" ", "").replace("\n", "") - tweetVariables* = """{ + tweetVars* = """{ "postId": "$1", $2 "includeHasBirdwatchNotes": false, @@ -110,7 +115,7 @@ const "withV2Timeline": true }""".replace(" ", "").replace("\n", "") - tweetDetailVariables* = """{ + tweetDetailVars* = """{ "focalTweetId": "$1", $2 "referrer": "profile", @@ -123,12 +128,12 @@ const "withVoice": true }""".replace(" ", "").replace("\n", "") - restIdVariables* = """{ + restIdVars* = """{ "rest_id": "$1", $2 "count": 20 }""" - userMediaVariables* = """{ + userMediaVars* = """{ "userId": "$1", $2 "count": 20, "includePromotedContent": false, @@ -137,7 +142,7 @@ const "withVoice": true }""".replace(" ", "").replace("\n", "") - userTweetsVariables* = """{ + userTweetsVars* = """{ "userId": "$1", $2 "count": 20, "includePromotedContent": false, @@ -145,7 +150,7 @@ const "withVoice": true }""".replace(" ", "").replace("\n", "") - userTweetsAndRepliesVariables* = """{ + userTweetsAndRepliesVars* = """{ "userId": "$1", $2 "count": 20, "includePromotedContent": false, @@ -153,5 +158,6 @@ const "withVoice": true }""".replace(" ", "").replace("\n", "") - fieldToggles* = """{"withArticlePlainText":false}""" + userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}""" + userTweetsFieldToggles* = """{"withArticlePlainText":false}""" tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}""" diff --git a/src/experimental/parser/graphql.nim b/src/experimental/parser/graphql.nim index 045a5d6..65ebc3d 100644 --- a/src/experimental/parser/graphql.nim +++ b/src/experimental/parser/graphql.nim @@ -1,6 +1,6 @@ import options, strutils import jsony -import user, ../types/[graphuser, graphlistmembers] +import user, utils, ../types/[graphuser, graphlistmembers] from ../../types import User, VerifiedType, Result, Query, QueryKind proc parseUserResult*(userResult: UserResult): User = @@ -15,22 +15,36 @@ proc parseUserResult*(userResult: UserResult): User = result.fullname = userResult.core.name result.userPic = userResult.avatar.imageUrl.replace("_normal", "") + if userResult.privacy.isSome: + result.protected = userResult.privacy.get.protected + + if userResult.location.isSome: + result.location = userResult.location.get.location + + if userResult.core.createdAt.len > 0: + result.joinDate = parseTwitterDate(userResult.core.createdAt) + if userResult.verification.isSome: let v = userResult.verification.get if v.verifiedType != VerifiedType.none: result.verifiedType = v.verifiedType - if userResult.profileBio.isSome: + if userResult.profileBio.isSome and result.bio.len == 0: result.bio = userResult.profileBio.get.description proc parseGraphUser*(json: string): User = if json.len == 0 or json[0] != '{': return - let raw = json.fromJson(GraphUser) - let userResult = raw.data.userResult.result + let + raw = json.fromJson(GraphUser) + userResult = + if raw.data.userResult.isSome: raw.data.userResult.get.result + elif raw.data.user.isSome: raw.data.user.get.result + else: UserResult() - if userResult.unavailableReason.get("") == "Suspended": + if userResult.unavailableReason.get("") == "Suspended" or + userResult.reason.get("") == "Suspended": return User(suspended: true) result = parseUserResult(userResult) diff --git a/src/experimental/parser/user.nim b/src/experimental/parser/user.nim index 498757a..8517bdc 100644 --- a/src/experimental/parser/user.nim +++ b/src/experimental/parser/user.nim @@ -58,11 +58,13 @@ proc toUser*(raw: RawUser): User = media: raw.mediaCount, verifiedType: raw.verifiedType, protected: raw.protected, - joinDate: parseTwitterDate(raw.createdAt), banner: getBanner(raw), userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "") ) + if raw.createdAt.len > 0: + result.joinDate = parseTwitterDate(raw.createdAt) + if raw.pinnedTweetIdsStr.len > 0: result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0]) diff --git a/src/experimental/types/graphuser.nim b/src/experimental/types/graphuser.nim index d732b4e..62c6612 100644 --- a/src/experimental/types/graphuser.nim +++ b/src/experimental/types/graphuser.nim @@ -3,7 +3,7 @@ from ../../types import User, VerifiedType type GraphUser* = object - data*: tuple[userResult: UserData] + data*: tuple[userResult: Option[UserData], user: Option[UserData]] UserData* = object result*: UserResult @@ -22,15 +22,24 @@ type Verification* = object verifiedType*: VerifiedType + Location* = object + location*: string + + Privacy* = object + protected*: bool + UserResult* = object legacy*: User restId*: string isBlueVerified*: bool - unavailableReason*: Option[string] core*: UserCore avatar*: UserAvatar + unavailableReason*: Option[string] + reason*: Option[string] + privacy*: Option[Privacy] profileBio*: Option[UserBio] verification*: Option[Verification] + location*: Option[Location] proc enumHook*(s: string; v: var VerifiedType) = v = try: diff --git a/src/parser.nim b/src/parser.nim index 5bf2b0b..7b1e1c0 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -21,7 +21,7 @@ proc parseUser(js: JsonNode; id=""): User = tweets: js{"statuses_count"}.getInt, likes: js{"favourites_count"}.getInt, media: js{"media_count"}.getInt, - protected: js{"protected"}.getBool, + protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool), joinDate: js{"created_at"}.getTime ) From 8516ebe2b7ac772f4f33ee141f2e03fdf4d21467 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 29 Nov 2025 00:36:44 +0100 Subject: [PATCH 014/161] Fix 'key not found in object: expanded_url' error Fixes #1318 --- src/parser.nim | 4 ++-- src/parserutils.nim | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index 7b1e1c0..9b4dc2f 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -184,7 +184,7 @@ proc parseMediaEntities(js: JsonNode; result: var Tweet) = # Remove media URLs from text with mediaList, js{"legacy", "entities", "media"}: for url in mediaList: - let expandedUrl = url{"expanded_url"}.getStr + let expandedUrl = url.getExpandedUrl if result.text.endsWith(expandedUrl): result.text.removeSuffix(expandedUrl) result.text = result.text.strip() @@ -267,7 +267,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card = for u in ? urls: if u{"url"}.getStr == result.url: - result.url = u{"expanded_url"}.getStr + result.url = u.getExpandedUrl(result.url) break if kind in {videoDirectMessage, imageDirectMessage}: diff --git a/src/parserutils.nim b/src/parserutils.nim index 72c50e1..b6ccd52 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -112,6 +112,9 @@ proc getImageStr*(js: JsonNode): string = template getImageVal*(js: JsonNode): string = js{"image_value", "url"}.getImageStr +template getExpandedUrl*(js: JsonNode; fallback=""): string = + js{"expanded_url"}.getStr(js{"url"}.getStr(fallback)) + proc getCardUrl*(js: JsonNode; kind: CardKind): string = result = js{"website_url"}.getStrVal if kind == promoVideoConvo: @@ -177,7 +180,7 @@ proc extractSlice(js: JsonNode): Slice[int] = proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode; textLen: int; hideTwitter = false) = let - url = js["expanded_url"].getStr + url = js.getExpandedUrl slice = js.extractSlice if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl: @@ -238,7 +241,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) = ent = ? js{"entities"} with urls, ent{"url", "urls"}: - user.website = urls[0]{"expanded_url"}.getStr + user.website = urls[0].getExpandedUrl var replacements = newSeq[ReplaceSlice]() @@ -268,7 +271,7 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink) if hasCard and u{"url"}.getStr == get(tweet.card).url: - get(tweet.card).url = u{"expanded_url"}.getStr + get(tweet.card).url = u.getExpandedUrl with media, entities{"media"}: for m in media: From dae68b4f13c773ca0394e30dc87d7b001a74431f Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 29 Nov 2025 01:05:57 +0100 Subject: [PATCH 015/161] Ignore null errors, they're internal API errors --- src/apiutils.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index 94d4e8a..f48cda2 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -8,7 +8,7 @@ const rlRemaining = "x-rate-limit-remaining" rlReset = "x-rate-limit-reset" rlLimit = "x-rate-limit-limit" - errorsToSkip = {doesntExist, tweetNotFound, timeout, unauthorized, badRequest} + errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest} var pool: HttpPool From 31d210ca47f09a756d761699bd41a35cabb4c3ab Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 29 Nov 2025 01:13:08 +0100 Subject: [PATCH 016/161] Add experimental x-client-transaction-id support (#1324) * Add experimental x-client-transaction-id support * Remove broken test --- nitter.example.conf | 1 + src/api.nim | 131 ++++++++++++++++---------------- src/apiutils.nim | 74 ++++++++++-------- src/auth.nim | 32 +++++--- src/config.nim | 3 +- src/consts.nim | 40 +++++----- src/experimental/parser/tid.nim | 8 ++ src/experimental/types/tid.nim | 4 + src/nitter.nim | 3 +- src/tid.nim | 62 +++++++++++++++ src/types.nim | 29 +++---- tests/test_card.py | 7 +- tools/create_session_browser.py | 2 +- 13 files changed, 239 insertions(+), 157 deletions(-) create mode 100644 src/experimental/parser/tid.nim create mode 100644 src/experimental/types/tid.nim create mode 100644 src/tid.nim diff --git a/nitter.example.conf b/nitter.example.conf index bddb9a4..dfdaf50 100644 --- a/nitter.example.conf +++ b/nitter.example.conf @@ -26,6 +26,7 @@ enableRSS = true # set this to false to disable RSS feeds enableDebug = false # enable request logs and debug endpoints (/.sessions) proxy = "" # http/https url, SOCKS proxies are not supported proxyAuth = "" +disableTid = false # enable this if cookie-based auth is failing # Change default preferences here, see src/prefs_impl.nim for a complete list [Preferences] diff --git a/src/api.nim b/src/api.nim index fb9c516..e97b4e0 100644 --- a/src/api.nim +++ b/src/api.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, httpclient, uri, strutils, sequtils, sugar, tables +import asyncdispatch, httpclient, strutils, sequtils, sugar import packedjson import types, query, formatters, consts, apiutils, parser import experimental/parser as newParser @@ -11,95 +11,91 @@ proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] = if fieldToggles.len > 0: result.add ("fieldToggles", fieldToggles) -proc mediaUrl(id: string; cursor: string): SessionAwareUrl = - let - cookieVars = userMediaVars % [id, cursor] - oauthVars = restIdVars % [id, cursor] - result = SessionAwareUrl( - cookieUrl: graphUserMedia ? genParams(cookieVars), - oauthUrl: graphUserMediaV2 ? genParams(oauthVars) +proc apiUrl(endpoint, variables: string; fieldToggles = ""): ApiUrl = + return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles)) + +proc apiReq(endpoint, variables: string; fieldToggles = ""): ApiReq = + let url = apiUrl(endpoint, variables, fieldToggles) + return ApiReq(cookie: url, oauth: url) + +proc mediaUrl(id: string; cursor: string): ApiReq = + result = ApiReq( + cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor]), + oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor]) ) -proc userTweetsUrl(id: string; cursor: string): SessionAwareUrl = - let - cookieVars = userTweetsVars % [id, cursor] - oauthVars = restIdVars % [id, cursor] - result = SessionAwareUrl( - # cookieUrl: graphUserTweets ? genParams(cookieVars, userTweetsFieldToggles), - oauthUrl: graphUserTweetsV2 ? genParams(oauthVars) +proc userTweetsUrl(id: string; cursor: string): ApiReq = + result = ApiReq( + # cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles), + oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor]) ) # might change this in the future pending testing - result.cookieUrl = result.oauthUrl + result.cookie = result.oauth -proc userTweetsAndRepliesUrl(id: string; cursor: string): SessionAwareUrl = - let - cookieVars = userTweetsAndRepliesVars % [id, cursor] - oauthVars = restIdVars % [id, cursor] - result = SessionAwareUrl( - cookieUrl: graphUserTweetsAndReplies ? genParams(cookieVars, userTweetsFieldToggles), - oauthUrl: graphUserTweetsAndRepliesV2 ? genParams(oauthVars) +proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq = + let cookieVars = userTweetsAndRepliesVars % [id, cursor] + result = ApiReq( + cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles), + oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor]) ) -proc tweetDetailUrl(id: string; cursor: string): SessionAwareUrl = - let - cookieVars = tweetDetailVars % [id, cursor] - oauthVars = tweetVars % [id, cursor] - result = SessionAwareUrl( - cookieUrl: graphTweetDetail ? genParams(cookieVars, tweetDetailFieldToggles), - oauthUrl: graphTweet ? genParams(oauthVars) +proc tweetDetailUrl(id: string; cursor: string): ApiReq = + let cookieVars = tweetDetailVars % [id, cursor] + result = ApiReq( + cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles), + oauth: apiUrl(graphTweet, tweetVars % [id, cursor]) ) -proc userUrl(username: string): SessionAwareUrl = - let - cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username - oauthVars = """{"screen_name": "$1"}""" % username - result = SessionAwareUrl( - cookieUrl: graphUser ? genParams(cookieVars, tweetDetailFieldToggles), - oauthUrl: graphUserV2 ? genParams(oauthVars) +proc userUrl(username: string): ApiReq = + let cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username + result = ApiReq( + cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles), + oauth: apiUrl(graphUserV2, """{"screen_name": "$1"}""" % username) ) proc getGraphUser*(username: string): Future[User] {.async.} = if username.len == 0: return - let js = await fetchRaw(userUrl(username), Api.userScreenName) + let js = await fetchRaw(userUrl(username)) result = parseGraphUser(js) proc getGraphUserById*(id: string): Future[User] {.async.} = if id.len == 0 or id.any(c => not c.isDigit): return let - url = graphUserById ? genParams("""{"rest_id": "$1"}""" % id) - js = await fetchRaw(url, Api.userRestId) + url = apiReq(graphUserById, """{"rest_id": "$1"}""" % id) + js = await fetchRaw(url) result = parseGraphUser(js) proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} = if id.len == 0: return let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - js = case kind - of TimelineKind.tweets: - await fetch(userTweetsUrl(id, cursor), Api.userTweets) - of TimelineKind.replies: - await fetch(userTweetsAndRepliesUrl(id, cursor), Api.userTweetsAndReplies) - of TimelineKind.media: - await fetch(mediaUrl(id, cursor), Api.userMedia) + url = case kind + of TimelineKind.tweets: userTweetsUrl(id, cursor) + of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor) + of TimelineKind.media: mediaUrl(id, cursor) + js = await fetch(url) result = parseGraphTimeline(js, after) proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - url = graphListTweets ? genParams(restIdVars % [id, cursor]) - result = parseGraphTimeline(await fetch(url, Api.listTweets), after).tweets + url = apiReq(graphListTweets, restIdVars % [id, cursor]) + js = await fetch(url) + result = parseGraphTimeline(js, after).tweets proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = let variables = %*{"screenName": name, "listSlug": list} - url = graphListBySlug ? genParams($variables) - result = parseGraphList(await fetch(url, Api.listBySlug)) + url = apiReq(graphListBySlug, $variables) + js = await fetch(url) + result = parseGraphList(js) proc getGraphList*(id: string): Future[List] {.async.} = - let - url = graphListById ? genParams("""{"listId": "$1"}""" % id) - result = parseGraphList(await fetch(url, Api.list)) + let + url = apiReq(graphListById, """{"listId": "$1"}""" % id) + js = await fetch(url) + result = parseGraphList(js) proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} = if list.id.len == 0: return @@ -113,22 +109,23 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} } if after.len > 0: variables["cursor"] = % after - let url = graphListMembers ? genParams($variables) - result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after) + let + url = apiReq(graphListMembers, $variables) + js = await fetchRaw(url) + result = parseGraphListMembers(js, after) proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = if id.len == 0: return let - variables = """{"rest_id": "$1"}""" % id - params = {"variables": variables, "features": gqlFeatures} - js = await fetch(graphTweetResult ? params, Api.tweetResult) + url = apiReq(graphTweetResult, """{"rest_id": "$1"}""" % id) + js = await fetch(url) result = parseGraphTweetResult(js) proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} = if id.len == 0: return let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - js = await fetch(tweetDetailUrl(id, cursor), Api.tweetDetail) + js = await fetch(tweetDetailUrl(id, cursor)) result = parseGraphConversation(js, id) proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} = @@ -157,8 +154,10 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = } if after.len > 0: variables["cursor"] = % after - let url = graphSearchTimeline ? genParams($variables) - result = parseGraphSearch[Tweets](await fetch(url, Api.search), after) + let + url = apiReq(graphSearchTimeline, $variables) + js = await fetch(url) + result = parseGraphSearch[Tweets](js, after) result.query = query proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} = @@ -179,13 +178,15 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} variables["cursor"] = % after result.beginning = false - let url = graphSearchTimeline ? genParams($variables) - result = parseGraphSearch[User](await fetch(url, Api.search), after) + let + url = apiReq(graphSearchTimeline, $variables) + js = await fetch(url) + result = parseGraphSearch[User](js, after) result.query = query proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} = if id.len == 0: return - let js = await fetch(mediaUrl(id, ""), Api.userMedia) + let js = await fetch(mediaUrl(id, "")) result = parseGraphPhotoRail(js) proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = diff --git a/src/apiutils.nim b/src/apiutils.nim index f48cda2..b288141 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only import httpclient, asyncdispatch, options, strutils, uri, times, math, tables import jsony, packedjson, zippy, oauth1 -import types, auth, consts, parserutils, http_pool +import types, auth, consts, parserutils, http_pool, tid import experimental/types/common const @@ -10,7 +10,21 @@ const rlLimit = "x-rate-limit-limit" errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest} -var pool: HttpPool +var + pool: HttpPool + disableTid: bool + +proc setDisableTid*(disable: bool) = + disableTid = disable + +proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri = + case sessionKind + of oauth: + let o = req.oauth + parseUri("https://api.x.com/graphql") / o.endpoint ? o.params + of cookie: + let c = req.cookie + parseUri("https://x.com/i/api/graphql") / c.endpoint ? c.params proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = let @@ -32,15 +46,15 @@ proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = proc getCookieHeader(authToken, ct0: string): string = "auth_token=" & authToken & "; ct0=" & ct0 -proc genHeaders*(session: Session, url: string): HttpHeaders = +proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} = result = newHttpHeaders({ "connection": "keep-alive", "content-type": "application/json", "x-twitter-active-user": "yes", "x-twitter-client-language": "en", - "authority": "api.x.com", + "origin": "https://x.com", "accept-encoding": "gzip", - "accept-language": "en-US,en;q=0.9", + "accept-language": "en-US,en;q=0.5", "accept": "*/*", "DNT": "1", "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" @@ -48,15 +62,20 @@ proc genHeaders*(session: Session, url: string): HttpHeaders = case session.kind of SessionKind.oauth: - result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret) + result["authority"] = "api.x.com" + result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret) of SessionKind.cookie: - result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F" result["x-twitter-auth-type"] = "OAuth2Session" result["x-csrf-token"] = session.ct0 result["cookie"] = getCookieHeader(session.authToken, session.ct0) + if disableTid: + result["authorization"] = bearerToken2 + else: + result["authorization"] = bearerToken + result["x-client-transaction-id"] = await genTid(url.path) -proc getAndValidateSession*(api: Api): Future[Session] {.async.} = - result = await getSession(api) +proc getAndValidateSession*(req: ApiReq): Future[Session] {.async.} = + result = await getSession(req) case result.kind of SessionKind.oauth: if result.oauthToken.len == 0: @@ -73,7 +92,7 @@ template fetchImpl(result, fetchBody) {.dirty.} = try: var resp: AsyncResponse - pool.use(genHeaders(session, $url)): + pool.use(await genHeaders(session, url)): template getContent = resp = await c.get($url) result = await resp.body @@ -89,7 +108,7 @@ template fetchImpl(result, fetchBody) {.dirty.} = remaining = parseInt(resp.headers[rlRemaining]) reset = parseInt(resp.headers[rlReset]) limit = parseInt(resp.headers[rlLimit]) - session.setRateLimit(api, remaining, reset, limit) + session.setRateLimit(req, remaining, reset, limit) if result.len > 0: if resp.headers.getOrDefault("content-encoding") == "gzip": @@ -98,24 +117,22 @@ template fetchImpl(result, fetchBody) {.dirty.} = if result.startsWith("{\"errors"): let errors = result.fromJson(Errors) if errors notin errorsToSkip: - echo "Fetch error, API: ", api, ", errors: ", errors + echo "Fetch error, API: ", url.path, ", errors: ", errors if errors in {expiredToken, badToken, locked}: invalidate(session) raise rateLimitError() elif errors in {rateLimited}: # rate limit hit, resets after 24 hours - setLimited(session, api) + setLimited(session, req) raise rateLimitError() elif result.startsWith("429 Too Many Requests"): - echo "[sessions] 429 error, API: ", api, ", session: ", session.pretty - session.apis[api].remaining = 0 - # rate limit hit, resets after the 15 minute window + echo "[sessions] 429 error, API: ", url.path, ", session: ", session.pretty raise rateLimitError() fetchBody if resp.status == $Http400: - echo "ERROR 400, ", api, ": ", result + echo "ERROR 400, ", url.path, ": ", result raise newException(InternalError, $url) except InternalError as e: raise e @@ -134,19 +151,16 @@ template retry(bod) = try: bod except RateLimitError: - echo "[sessions] Rate limited, retrying ", api, " request..." + echo "[sessions] Rate limited, retrying ", req.cookie.endpoint, " request..." bod -proc fetch*(url: Uri | SessionAwareUrl; api: Api): Future[JsonNode] {.async.} = +proc fetch*(req: ApiReq): Future[JsonNode] {.async.} = retry: var body: string - session = await getAndValidateSession(api) + session = await getAndValidateSession(req) - when url is SessionAwareUrl: - let url = case session.kind - of SessionKind.oauth: url.oauthUrl - of SessionKind.cookie: url.cookieUrl + let url = req.toUrl(session.kind) fetchImpl body: if body.startsWith('{') or body.startsWith('['): @@ -157,19 +171,15 @@ proc fetch*(url: Uri | SessionAwareUrl; api: Api): Future[JsonNode] {.async.} = let error = result.getError if error != null and error notin errorsToSkip: - echo "Fetch error, API: ", api, ", error: ", error + echo "Fetch error, API: ", url.path, ", error: ", error if error in {expiredToken, badToken, locked}: invalidate(session) raise rateLimitError() -proc fetchRaw*(url: Uri | SessionAwareUrl; api: Api): Future[string] {.async.} = +proc fetchRaw*(req: ApiReq): Future[string] {.async.} = retry: - var session = await getAndValidateSession(api) - - when url is SessionAwareUrl: - let url = case session.kind - of SessionKind.oauth: url.oauthUrl - of SessionKind.cookie: url.cookieUrl + var session = await getAndValidateSession(req) + let url = req.toUrl(session.kind) fetchImpl result: if not (result.startsWith('{') or result.startsWith('[')): diff --git a/src/auth.nim b/src/auth.nim index 734b43e..5d7ef0e 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -1,6 +1,6 @@ #SPDX-License-Identifier: AGPL-3.0-only -import std/[asyncdispatch, times, json, random, sequtils, strutils, tables, packedsets, os] -import types +import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os] +import types, consts import experimental/parser/session # max requests at a time per session to avoid race conditions @@ -15,6 +15,11 @@ var template log(str: varargs[string, `$`]) = echo "[sessions] ", str.join("") +proc endpoint(req: ApiReq; session: Session): string = + case session.kind + of oauth: req.oauth.endpoint + of cookie: req.cookie.endpoint + proc pretty*(session: Session): string = if session.isNil: return "" @@ -122,11 +127,12 @@ proc rateLimitError*(): ref RateLimitError = proc noSessionsError*(): ref NoSessionsError = newException(NoSessionsError, "no sessions available") -proc isLimited(session: Session; api: Api): bool = +proc isLimited(session: Session; req: ApiReq): bool = if session.isNil: return true - if session.limited and api != Api.userTweets: + let api = req.endpoint(session) + if session.limited and api != graphUserTweetsV2: if (epochTime().int - session.limitedAt) > hourInSeconds: session.limited = false log "resetting limit: ", session.pretty @@ -140,8 +146,8 @@ proc isLimited(session: Session; api: Api): bool = else: return false -proc isReady(session: Session; api: Api): bool = - not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(api)) +proc isReady(session: Session; req: ApiReq): bool = + not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(req)) proc invalidate*(session: var Session) = if session.isNil: return @@ -156,24 +162,26 @@ proc release*(session: Session) = if session.isNil: return dec session.pending -proc getSession*(api: Api): Future[Session] {.async.} = +proc getSession*(req: ApiReq): Future[Session] {.async.} = for i in 0 ..< sessionPool.len: - if result.isReady(api): break + if result.isReady(req): break result = sessionPool.sample() - if not result.isNil and result.isReady(api): + if not result.isNil and result.isReady(req): inc result.pending else: - log "no sessions available for API: ", api + log "no sessions available for API: ", req.cookie.endpoint raise noSessionsError() -proc setLimited*(session: Session; api: Api) = +proc setLimited*(session: Session; req: ApiReq) = + let api = req.endpoint(session) session.limited = true session.limitedAt = epochTime().int log "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", ", session.pretty -proc setRateLimit*(session: Session; api: Api; remaining, reset, limit: int) = +proc setRateLimit*(session: Session; req: ApiReq; remaining, reset, limit: int) = # avoid undefined behavior in race conditions + let api = req.endpoint(session) if api in session.apis: let rateLimit = session.apis[api] if rateLimit.reset >= reset and rateLimit.remaining < remaining: diff --git a/src/config.nim b/src/config.nim index 1b05ffe..571508b 100644 --- a/src/config.nim +++ b/src/config.nim @@ -40,7 +40,8 @@ proc getConfig*(path: string): (Config, parseCfg.Config) = enableRss: cfg.get("Config", "enableRSS", true), enableDebug: cfg.get("Config", "enableDebug", false), proxy: cfg.get("Config", "proxy", ""), - proxyAuth: cfg.get("Config", "proxyAuth", "") + proxyAuth: cfg.get("Config", "proxyAuth", ""), + disableTid: cfg.get("Config", "disableTid", false) ) return (conf, cfg) diff --git a/src/consts.nim b/src/consts.nim index e55ba0e..6456efc 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -1,29 +1,29 @@ # SPDX-License-Identifier: AGPL-3.0-only -import uri, strutils +import strutils const consumerKey* = "3nVuSoBZnx6U4vzUxf5w" consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys" + bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" + bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F" - gql = parseUri("https://api.x.com") / "graphql" - - graphUser* = gql / "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName" - graphUserV2* = gql / "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery" - graphUserById* = gql / "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery" - graphUserTweetsV2* = gql / "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2" - graphUserTweetsAndRepliesV2* = gql / "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2" - graphUserTweets* = gql / "oRJs8SLCRNRbQzuZG93_oA/UserTweets" - graphUserTweetsAndReplies* = gql / "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies" - graphUserMedia* = gql / "36oKqyQ7E_9CmtONGjJRsA/UserMedia" - graphUserMediaV2* = gql / "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2" - graphTweet* = gql / "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline" - graphTweetDetail* = gql / "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail" - graphTweetResult* = gql / "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery" - graphSearchTimeline* = gql / "bshMIjqDk8LTXTq4w91WKw/SearchTimeline" - graphListById* = gql / "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId" - graphListBySlug* = gql / "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug" - graphListMembers* = gql / "fuVHh5-gFn8zDBBxb8wOMA/ListMembers" - graphListTweets* = gql / "VQf8_XQynI3WzH6xopOMMQ/ListTimeline" + graphUser* = "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName" + graphUserV2* = "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery" + graphUserById* = "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery" + graphUserTweetsV2* = "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2" + graphUserTweetsAndRepliesV2* = "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2" + graphUserTweets* = "oRJs8SLCRNRbQzuZG93_oA/UserTweets" + graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies" + graphUserMedia* = "36oKqyQ7E_9CmtONGjJRsA/UserMedia" + graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2" + graphTweet* = "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline" + graphTweetDetail* = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail" + graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery" + graphSearchTimeline* = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline" + graphListById* = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId" + graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug" + graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers" + graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline" gqlFeatures* = """{ "android_ad_formats_media_component_render_overlay_enabled": false, diff --git a/src/experimental/parser/tid.nim b/src/experimental/parser/tid.nim new file mode 100644 index 0000000..28fccea --- /dev/null +++ b/src/experimental/parser/tid.nim @@ -0,0 +1,8 @@ +import jsony +import ../types/tid +export TidPair + +proc parseTidPairs*(raw: string): seq[TidPair] = + result = raw.fromJson(seq[TidPair]) + if result.len == 0: + raise newException(ValueError, "Parsing pairs failed: " & raw) diff --git a/src/experimental/types/tid.nim b/src/experimental/types/tid.nim new file mode 100644 index 0000000..ad036d9 --- /dev/null +++ b/src/experimental/types/tid.nim @@ -0,0 +1,4 @@ +type + TidPair* = object + animationKey*: string + verification*: string diff --git a/src/nitter.nim b/src/nitter.nim index f81dc1c..e6d66ab 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -6,7 +6,7 @@ from os import getEnv import jester -import types, config, prefs, formatters, redis_cache, http_pool, auth +import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils import views/[general, about] import routes/[ preferences, timeline, status, media, search, rss, list, debug, @@ -37,6 +37,7 @@ setHmacKey(cfg.hmacKey) setProxyEncoding(cfg.base64Media) setMaxHttpConns(cfg.httpMaxConns) setHttpProxy(cfg.proxy, cfg.proxyAuth) +setDisableTid(cfg.disableTid) initAboutPage(cfg.staticDir) waitFor initRedisPool(cfg) diff --git a/src/tid.nim b/src/tid.nim new file mode 100644 index 0000000..7b453fb --- /dev/null +++ b/src/tid.nim @@ -0,0 +1,62 @@ +import std/[asyncdispatch, base64, httpclient, random, strutils, sequtils, times] +import nimcrypto +import experimental/parser/tid + +randomize() + +const defaultKeyword = "obfiowerehiring"; +const pairsUrl = + "https://raw.githubusercontent.com/fa0311/x-client-transaction-id-pair-dict/refs/heads/main/pair.json"; + +var + cachedPairs: seq[TidPair] = @[] + lastCached = 0 + # refresh every hour + ttlSec = 60 * 60 + +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) + + return sample(cachedPairs) + +proc encodeSha256(text: string): array[32, byte] = + let + data = cast[ptr byte](addr text[0]) + dataLen = uint(len(text)) + digest = sha256.digest(data, dataLen) + return digest.data + +proc encodeBase64[T](data: T): string = + return encode(data).replace("=", "") + +proc decodeBase64(data: string): seq[byte] = + return cast[seq[byte]](decode(data)) + +proc genTid*(path: string): Future[string] {.async.} = + let + pair = await getPair() + + timeNow = int(epochTime() - 1682924400) + timeNowBytes = @[ + byte(timeNow and 0xff), + byte((timeNow shr 8) and 0xff), + byte((timeNow shr 16) and 0xff), + byte((timeNow shr 24) and 0xff) + ] + + data = "GET!" & path & "!" & $timeNow & defaultKeyword & pair.animationKey + hashBytes = encodeSha256(data) + keyBytes = decodeBase64(pair.verification) + bytesArr = keyBytes & timeNowBytes & hashBytes[0 ..< 16] & @[3'u8] + randomNum = byte(rand(256)) + tid = @[randomNum] & bytesArr.mapIt(it xor randomNum) + + return encodeBase64(tid) diff --git a/src/types.nim b/src/types.nim index 20a49c9..815e223 100644 --- a/src/types.nim +++ b/src/types.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import times, sequtils, options, tables, uri +import times, sequtils, options, tables import prefs_impl genPrefsType() @@ -13,19 +13,13 @@ type TimelineKind* {.pure.} = enum tweets, replies, media - Api* {.pure.} = enum - tweetDetail - tweetResult - search - list - listBySlug - listMembers - listTweets - userRestId - userScreenName - userTweets - userTweetsAndReplies - userMedia + ApiUrl* = object + endpoint*: string + params*: seq[(string, string)] + + ApiReq* = object + oauth*: ApiUrl + cookie*: ApiUrl RateLimit* = object limit*: int @@ -42,7 +36,7 @@ type pending*: int limited*: bool limitedAt*: int - apis*: Table[Api, RateLimit] + apis*: Table[string, RateLimit] case kind*: SessionKind of oauth: oauthToken*: string @@ -51,10 +45,6 @@ type authToken*: string ct0*: string - SessionAwareUrl* = object - oauthUrl*: Uri - cookieUrl*: Uri - Error* = enum null = 0 noUserMatches = 17 @@ -285,6 +275,7 @@ type enableDebug*: bool proxy*: string proxyAuth*: string + disableTid*: bool rssCacheTime*: int listCacheTime*: int diff --git a/tests/test_card.py b/tests/test_card.py index 504c079..129e65a 100644 --- a/tests/test_card.py +++ b/tests/test_card.py @@ -11,12 +11,7 @@ card = [ ['voidtarget/status/1094632512926605312', 'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too)', 'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim', - 'gist.github.com', True], - - ['nim_lang/status/1082989146040340480', - 'Nim in 2018: A short recap', - 'There were several big news in the Nim world in 2018 – two new major releases, partnership with Status, and much more. But let us go chronologically.', - 'nim-lang.org', True] + 'gist.github.com', True] ] no_thumb = [ diff --git a/tools/create_session_browser.py b/tools/create_session_browser.py index 40e3dcd..3a05cb1 100644 --- a/tools/create_session_browser.py +++ b/tools/create_session_browser.py @@ -94,7 +94,7 @@ async def login_and_get_cookies(username, password, totp_seed=None, headless=Fal async def main(): if len(sys.argv) < 3: - print('Usage: python3 twitter-auth.py username password [totp_seed] [--append sessions.jsonl] [--headless]') + print('Usage: python3 create_session_browser.py username password [totp_seed] [--append file.jsonl] [--headless]') sys.exit(1) username = sys.argv[1] From 7a08a9e1321a511a35bee7ca736db035f9fce52a Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 29 Nov 2025 03:36:21 +0100 Subject: [PATCH 017/161] Format css --- src/sass/general.scss | 56 +++--- src/sass/include/_variables.scss | 49 +++-- src/sass/index.scss | 242 ++++++++++++------------ src/sass/inputs.scss | 258 ++++++++++++------------- src/sass/navbar.scss | 121 ++++++------ src/sass/search.scss | 170 ++++++++--------- src/sass/timeline.scss | 208 ++++++++++----------- src/sass/tweet/_base.scss | 310 ++++++++++++++++--------------- src/sass/tweet/embed.scss | 26 +-- src/sass/tweet/media.scss | 146 +++++++-------- src/sass/tweet/poll.scss | 48 ++--- src/sass/tweet/quote.scss | 149 +++++++-------- src/sass/tweet/thread.scss | 195 +++++++++---------- src/sass/tweet/video.scss | 97 +++++----- 14 files changed, 1041 insertions(+), 1034 deletions(-) diff --git a/src/sass/general.scss b/src/sass/general.scss index 9feb3d3..ce97564 100644 --- a/src/sass/general.scss +++ b/src/sass/general.scss @@ -1,39 +1,39 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; .panel-container { - margin: auto; - font-size: 130%; + margin: auto; + font-size: 130%; } .error-panel { - @include center-panel(var(--error_red)); - text-align: center; + @include center-panel(var(--error_red)); + text-align: center; } .search-bar > form { - @include center-panel(var(--darkest_grey)); + @include center-panel(var(--darkest_grey)); - button { - background: var(--bg_elements); - color: var(--fg_color); - border: 0; - border-radius: 3px; - cursor: pointer; - font-weight: bold; - width: 30px; - height: 30px; - } + button { + background: var(--bg_elements); + color: var(--fg_color); + border: 0; + border-radius: 3px; + cursor: pointer; + font-weight: bold; + width: 30px; + height: 30px; + } - input { - font-size: 16px; - width: 100%; - background: var(--bg_elements); - color: var(--fg_color); - border: 0; - border-radius: 4px; - padding: 4px; - margin-right: 8px; - height: unset; - } + input { + font-size: 16px; + width: 100%; + background: var(--bg_elements); + color: var(--fg_color); + border: 0; + border-radius: 4px; + padding: 4px; + margin-right: 8px; + height: unset; + } } diff --git a/src/sass/include/_variables.scss b/src/sass/include/_variables.scss index 0c95ff6..127cccb 100644 --- a/src/sass/include/_variables.scss +++ b/src/sass/include/_variables.scss @@ -1,46 +1,43 @@ // colors -$bg_color: #0F0F0F; -$fg_color: #F8F8F2; -$fg_faded: #F8F8F2CF; -$fg_dark: #FF6C60; -$fg_nav: #FF6C60; +$bg_color: #0f0f0f; +$fg_color: #f8f8f2; +$fg_faded: #f8f8f2cf; +$fg_dark: #ff6c60; +$fg_nav: #ff6c60; $bg_panel: #161616; $bg_elements: #121212; -$bg_overlays: #1F1F1F; -$bg_hover: #1A1A1A; +$bg_overlays: #1f1f1f; +$bg_hover: #1a1a1a; $grey: #888889; $dark_grey: #404040; $darker_grey: #282828; $darkest_grey: #222222; -$border_grey: #3E3E35; +$border_grey: #3e3e35; -$accent: #FF6C60; -$accent_light: #FFACA0; -$accent_dark: #8A3731; -$accent_border: #FF6C6091; +$accent: #ff6c60; +$accent_light: #ffaca0; +$accent_dark: #8a3731; +$accent_border: #ff6c6091; -$play_button: #D8574D; -$play_button_hover: #FF6C60; +$play_button: #d8574d; +$play_button_hover: #ff6c60; -$more_replies_dots: #AD433B; -$error_red: #420A05; +$more_replies_dots: #ad433b; +$error_red: #420a05; -$verified_blue: #1DA1F2; -$verified_business: #FAC82B; -$verified_government: #C1B6A4; +$verified_blue: #1da1f2; +$verified_business: #fac82b; +$verified_government: #c1b6a4; $icon_text: $fg_color; $tab: $fg_color; $tab_selected: $accent; -$shadow: rgba(0,0,0,.6); -$shadow_dark: rgba(0,0,0,.2); +$shadow: rgba(0, 0, 0, 0.6); +$shadow_dark: rgba(0, 0, 0, 0.2); //fonts -$font_0: Helvetica Neue; -$font_1: Helvetica; -$font_2: Arial; -$font_3: sans-serif; -$font_4: fontello; +$font_0: sans-serif; +$font_1: fontello; diff --git a/src/sass/index.scss b/src/sass/index.scss index 36a8a93..a19165a 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -1,180 +1,182 @@ -@import '_variables'; +@import "_variables"; -@import 'tweet/_base'; -@import 'profile/_base'; -@import 'general'; -@import 'navbar'; -@import 'inputs'; -@import 'timeline'; -@import 'search'; +@import "tweet/_base"; +@import "profile/_base"; +@import "general"; +@import "navbar"; +@import "inputs"; +@import "timeline"; +@import "search"; body { - // colors - --bg_color: #{$bg_color}; - --fg_color: #{$fg_color}; - --fg_faded: #{$fg_faded}; - --fg_dark: #{$fg_dark}; - --fg_nav: #{$fg_nav}; + // colors + --bg_color: #{$bg_color}; + --fg_color: #{$fg_color}; + --fg_faded: #{$fg_faded}; + --fg_dark: #{$fg_dark}; + --fg_nav: #{$fg_nav}; - --bg_panel: #{$bg_panel}; - --bg_elements: #{$bg_elements}; - --bg_overlays: #{$bg_overlays}; - --bg_hover: #{$bg_hover}; + --bg_panel: #{$bg_panel}; + --bg_elements: #{$bg_elements}; + --bg_overlays: #{$bg_overlays}; + --bg_hover: #{$bg_hover}; - --grey: #{$grey}; - --dark_grey: #{$dark_grey}; - --darker_grey: #{$darker_grey}; - --darkest_grey: #{$darkest_grey}; - --border_grey: #{$border_grey}; + --grey: #{$grey}; + --dark_grey: #{$dark_grey}; + --darker_grey: #{$darker_grey}; + --darkest_grey: #{$darkest_grey}; + --border_grey: #{$border_grey}; - --accent: #{$accent}; - --accent_light: #{$accent_light}; - --accent_dark: #{$accent_dark}; - --accent_border: #{$accent_border}; + --accent: #{$accent}; + --accent_light: #{$accent_light}; + --accent_dark: #{$accent_dark}; + --accent_border: #{$accent_border}; - --play_button: #{$play_button}; - --play_button_hover: #{$play_button_hover}; + --play_button: #{$play_button}; + --play_button_hover: #{$play_button_hover}; - --more_replies_dots: #{$more_replies_dots}; - --error_red: #{$error_red}; + --more_replies_dots: #{$more_replies_dots}; + --error_red: #{$error_red}; - --verified_blue: #{$verified_blue}; - --verified_business: #{$verified_business}; - --verified_government: #{$verified_government}; - --icon_text: #{$icon_text}; + --verified_blue: #{$verified_blue}; + --verified_business: #{$verified_business}; + --verified_government: #{$verified_government}; + --icon_text: #{$icon_text}; - --tab: #{$fg_color}; - --tab_selected: #{$accent}; + --tab: #{$fg_color}; + --tab_selected: #{$accent}; - --profile_stat: #{$fg_color}; + --profile_stat: #{$fg_color}; - background-color: var(--bg_color); - color: var(--fg_color); - font-family: $font_0, $font_1, $font_2, $font_3; - font-size: 15px; - line-height: 1.3; - margin: 0; + background-color: var(--bg_color); + color: var(--fg_color); + font-family: $font_0, $font_1, $font_2, $font_3; + font-size: 15px; + line-height: 1.3; + margin: 0; } * { - outline: unset; - margin: 0; - text-decoration: none; + outline: unset; + margin: 0; + text-decoration: none; } h1 { - display: inline; + display: inline; } -h2, h3 { - font-weight: normal; +h2, +h3 { + font-weight: normal; } p { - margin: 14px 0; + margin: 14px 0; } a { - color: var(--accent); + color: var(--accent); - &:hover { - text-decoration: underline; - } + &:hover { + text-decoration: underline; + } } fieldset { - border: 0; - padding: 0; - margin-top: -0.6em; + border: 0; + padding: 0; + margin-top: -0.6em; } legend { - width: 100%; - padding: .6em 0 .3em 0; - border: 0; - font-size: 16px; - font-weight: 600; - border-bottom: 1px solid var(--border_grey); - margin-bottom: 8px; + width: 100%; + padding: 0.6em 0 0.3em 0; + border: 0; + font-size: 16px; + font-weight: 600; + border-bottom: 1px solid var(--border_grey); + margin-bottom: 8px; } .preferences .note { - border-top: 1px solid var(--border_grey); - border-bottom: 1px solid var(--border_grey); - padding: 6px 0 8px 0; - margin-bottom: 8px; - margin-top: 16px; + border-top: 1px solid var(--border_grey); + border-bottom: 1px solid var(--border_grey); + padding: 6px 0 8px 0; + margin-bottom: 8px; + margin-top: 16px; } ul { - padding-left: 1.3em; + padding-left: 1.3em; } .container { - display: flex; - flex-wrap: wrap; - box-sizing: border-box; - padding-top: 50px; - margin: auto; - min-height: 100vh; + display: flex; + flex-wrap: wrap; + box-sizing: border-box; + padding-top: 50px; + margin: auto; + min-height: 100vh; } .icon-container { - display: inline; + display: inline; } .overlay-panel { - max-width: 600px; - width: 100%; - margin: 0 auto; - margin-top: 10px; - background-color: var(--bg_overlays); - padding: 10px 15px; - align-self: start; + max-width: 600px; + width: 100%; + margin: 0 auto; + margin-top: 10px; + background-color: var(--bg_overlays); + padding: 10px 15px; + align-self: start; - ul { - margin-bottom: 14px; - } + ul { + margin-bottom: 14px; + } - p { - word-break: break-word; - } + p { + word-break: break-word; + } } .verified-icon { - color: var(--icon_text); - border-radius: 50%; - flex-shrink: 0; - margin: 2px 0 3px 3px; - padding-top: 3px; - height: 11px; - width: 14px; - font-size: 8px; - display: inline-block; - text-align: center; - vertical-align: middle; + color: var(--icon_text); + border-radius: 50%; + flex-shrink: 0; + margin: 2px 0 3px 3px; + padding-top: 3px; + height: 11px; + width: 14px; + font-size: 8px; + display: inline-block; + text-align: center; + vertical-align: middle; - &.blue { - background-color: var(--verified_blue); - } + &.blue { + background-color: var(--verified_blue); + } - &.business { - color: var(--bg_panel); - background-color: var(--verified_business); - } + &.business { + color: var(--bg_panel); + background-color: var(--verified_business); + } - &.government { - color: var(--bg_panel); - background-color: var(--verified_government); - } + &.government { + color: var(--bg_panel); + background-color: var(--verified_government); + } } -@media(max-width: 600px) { - .preferences-container { - max-width: 95vw; - } +@media (max-width: 600px) { + .preferences-container { + max-width: 95vw; + } - .nav-item, .nav-item .icon-container { - font-size: 16px; - } + .nav-item, + .nav-item .icon-container { + font-size: 16px; + } } diff --git a/src/sass/inputs.scss b/src/sass/inputs.scss index d6cbb1d..aafa5b8 100644 --- a/src/sass/inputs.scss +++ b/src/sass/inputs.scss @@ -1,203 +1,203 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; button { - @include input-colors; - background-color: var(--bg_elements); - color: var(--fg_color); - border: 1px solid var(--accent_border); - padding: 3px 6px; - font-size: 14px; - cursor: pointer; - float: right; + @include input-colors; + background-color: var(--bg_elements); + color: var(--fg_color); + border: 1px solid var(--accent_border); + padding: 3px 6px; + font-size: 14px; + cursor: pointer; + float: right; } input[type="text"], input[type="date"], input[type="number"], select { - @include input-colors; - background-color: var(--bg_elements); - padding: 1px 4px; - color: var(--fg_color); - border: 1px solid var(--accent_border); - border-radius: 0; - font-size: 14px; + @include input-colors; + background-color: var(--bg_elements); + padding: 1px 4px; + color: var(--fg_color); + border: 1px solid var(--accent_border); + border-radius: 0; + font-size: 14px; } input[type="number"] { - -moz-appearance: textfield; + -moz-appearance: textfield; } input[type="text"], input[type="number"] { - height: 16px; + height: 16px; } select { - height: 20px; - padding: 0 2px; - line-height: 1; + height: 20px; + padding: 0 2px; + line-height: 1; } input[type="date"]::-webkit-inner-spin-button { - display: none; + display: none; } input[type="number"] { - -moz-appearance: textfield; + -moz-appearance: textfield; } input[type="number"]::-webkit-inner-spin-button, input[type="number"]::-webkit-outer-spin-button { - display: none; - -webkit-appearance: none; - margin: 0; + display: none; + -webkit-appearance: none; + margin: 0; } input[type="date"]::-webkit-clear-button { - margin-left: 17px; - filter: grayscale(100%); - filter: hue-rotate(120deg); + margin-left: 17px; + filter: grayscale(100%); + filter: hue-rotate(120deg); } input::-webkit-calendar-picker-indicator { - opacity: 0; + opacity: 0; } input::-webkit-datetime-edit-day-field:focus, input::-webkit-datetime-edit-month-field:focus, input::-webkit-datetime-edit-year-field:focus { - background-color: var(--accent); - color: var(--fg_color); - outline: none; + background-color: var(--accent); + color: var(--fg_color); + outline: none; } .date-range { - .date-input { - display: inline-block; - position: relative; - } + .date-input { + display: inline-block; + position: relative; + } - .icon-container { - pointer-events: none; - position: absolute; - top: 2px; - right: 5px; - } + .icon-container { + pointer-events: none; + position: absolute; + top: 2px; + right: 5px; + } - .search-title { - margin: 0 2px; - } + .search-title { + margin: 0 2px; + } } .icon-button button { - color: var(--accent); - text-decoration: none; - background: none; - border: none; - float: none; - padding: unset; - padding-left: 4px; + color: var(--accent); + text-decoration: none; + background: none; + border: none; + float: none; + padding: unset; + padding-left: 4px; - &:hover { - color: var(--accent_light); - } + &:hover { + color: var(--accent_light); + } } .checkbox { - position: absolute; - top: 1px; - right: 0; - height: 17px; - width: 17px; - background-color: var(--bg_elements); - border: 1px solid var(--accent_border); + position: absolute; + top: 1px; + right: 0; + height: 17px; + width: 17px; + background-color: var(--bg_elements); + border: 1px solid var(--accent_border); - &:after { - content: ""; - position: absolute; - display: none; - } + &:after { + content: ""; + position: absolute; + display: none; + } } .checkbox-container { - display: block; - position: relative; - margin-bottom: 5px; + display: block; + position: relative; + margin-bottom: 5px; + cursor: pointer; + user-select: none; + padding-right: 22px; + + input { + position: absolute; + opacity: 0; cursor: pointer; - user-select: none; - padding-right: 22px; + height: 0; + width: 0; - input { - position: absolute; - opacity: 0; - cursor: pointer; - height: 0; - width: 0; - - &:checked ~ .checkbox:after { - display: block; - } + &:checked ~ .checkbox:after { + display: block; } + } - &:hover input ~ .checkbox { - border-color: var(--accent); - } + &:hover input ~ .checkbox { + border-color: var(--accent); + } - &:active input ~ .checkbox { - border-color: var(--accent_light); - } + &:active input ~ .checkbox { + border-color: var(--accent_light); + } - .checkbox:after { - left: 2px; - bottom: 0; - font-size: 13px; - font-family: $font_4; - content: '\e803'; - } + .checkbox:after { + left: 2px; + bottom: 0; + font-size: 13px; + font-family: $font_1; + content: "\e811"; + } } .pref-group { - display: inline; + display: inline; } .preferences { - button { - margin: 6px 0 3px 0; - } + button { + margin: 6px 0 3px 0; + } - label { - padding-right: 150px; - } + label { + padding-right: 150px; + } - select { - position: absolute; - top: 0; - right: 0; - display: block; - -moz-appearance: none; - -webkit-appearance: none; - appearance: none; - } + select { + position: absolute; + top: 0; + right: 0; + display: block; + -moz-appearance: none; + -webkit-appearance: none; + appearance: none; + } - input[type="text"], - input[type="number"] { - position: absolute; - right: 0; - max-width: 140px; - } + input[type="text"], + input[type="number"] { + position: absolute; + right: 0; + max-width: 140px; + } - .pref-group { - display: block; - } + .pref-group { + display: block; + } - .pref-input { - position: relative; - margin-bottom: 6px; - } + .pref-input { + position: relative; + margin-bottom: 6px; + } - .pref-reset { - float: left; - } + .pref-reset { + float: left; + } } diff --git a/src/sass/navbar.scss b/src/sass/navbar.scss index 47a8765..4e150e0 100644 --- a/src/sass/navbar.scss +++ b/src/sass/navbar.scss @@ -1,89 +1,90 @@ -@import '_variables'; +@import "_variables"; nav { - display: flex; - align-items: center; - position: fixed; - background-color: var(--bg_overlays); - box-shadow: 0 0 4px $shadow; - padding: 0; - width: 100%; - height: 50px; - z-index: 1000; - font-size: 16px; + display: flex; + align-items: center; + position: fixed; + background-color: var(--bg_overlays); + box-shadow: 0 0 4px $shadow; + padding: 0; + width: 100%; + height: 50px; + z-index: 1000; + font-size: 16px; - a, .icon-button button { - color: var(--fg_nav); - } + a, + .icon-button button { + color: var(--fg_nav); + } } .inner-nav { - margin: auto; - box-sizing: border-box; - padding: 0 10px; - display: flex; - align-items: center; - flex-basis: 920px; - height: 50px; + margin: auto; + box-sizing: border-box; + padding: 0 10px; + display: flex; + align-items: center; + flex-basis: 920px; + height: 50px; } .site-name { - font-size: 15px; - font-weight: 600; - line-height: 1; + font-size: 15px; + font-weight: 600; + line-height: 1; - &:hover { - color: var(--accent_light); - text-decoration: unset; - } + &:hover { + color: var(--accent_light); + text-decoration: unset; + } } .site-logo { - display: block; - width: 35px; - height: 35px; + display: block; + width: 35px; + height: 35px; } .nav-item { - display: flex; - flex: 1; - line-height: 50px; - height: 50px; - overflow: hidden; - flex-wrap: wrap; - align-items: center; + display: flex; + flex: 1; + line-height: 50px; + height: 50px; + overflow: hidden; + flex-wrap: wrap; + align-items: center; - &.right { - text-align: right; - justify-content: flex-end; - } - - &.right a { - padding-left: 4px; - - &:hover { - color: var(--accent_light); - text-decoration: unset; - } + &.right { + text-align: right; + justify-content: flex-end; + } + + &.right a { + padding-left: 4px; + + &:hover { + color: var(--accent_light); + text-decoration: unset; } + } } .lp { - height: 14px; - display: inline-block; - position: relative; - top: 2px; - fill: var(--fg_nav); + height: 14px; + display: inline-block; + position: relative; + top: 2px; + fill: var(--fg_nav); - &:hover { - fill: var(--accent_light); - } + &:hover { + fill: var(--accent_light); + } } .icon-info:before { - margin: 0 -3px; + margin: 0 -3px; } .icon-cog { - font-size: 15px; + font-size: 15px; } diff --git a/src/sass/search.scss b/src/sass/search.scss index 234d677..444f9bb 100644 --- a/src/sass/search.scss +++ b/src/sass/search.scss @@ -1,120 +1,120 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; .search-title { - font-weight: bold; - display: inline-block; - margin-top: 4px; + font-weight: bold; + display: inline-block; + margin-top: 4px; } .search-field { + display: flex; + flex-wrap: wrap; + + button { + margin: 0 2px 0 0; + height: 23px; display: flex; - flex-wrap: wrap; + align-items: center; + } - button { - margin: 0 2px 0 0; - height: 23px; - display: flex; - align-items: center; - } + .pref-input { + margin: 0 4px 0 0; + flex-grow: 1; + height: 23px; + } - .pref-input { - margin: 0 4px 0 0; - flex-grow: 1; - height: 23px; - } + input[type="text"], + input[type="number"] { + height: calc(100% - 4px); + width: calc(100% - 8px); + } - input[type="text"], - input[type="number"] { - height: calc(100% - 4px); - width: calc(100% - 8px); - } + > label { + display: inline; + background-color: var(--bg_elements); + color: var(--fg_color); + border: 1px solid var(--accent_border); + padding: 1px 6px 2px 6px; + font-size: 14px; + cursor: pointer; + margin-bottom: 2px; - > label { - display: inline; - background-color: var(--bg_elements); - color: var(--fg_color); - border: 1px solid var(--accent_border); - padding: 1px 6px 2px 6px; - font-size: 14px; - cursor: pointer; - margin-bottom: 2px; + @include input-colors; + } - @include input-colors; - } - - @include create-toggle(search-panel, 380px); + @include create-toggle(search-panel, 380px); } .search-panel { - width: 100%; - max-height: 0; - overflow: hidden; - transition: max-height 0.4s; + width: 100%; + max-height: 0; + overflow: hidden; + transition: max-height 0.4s; - flex-grow: 1; - font-weight: initial; - text-align: left; + flex-grow: 1; + font-weight: initial; + text-align: left; - > div { - line-height: 1.7em; - } + > div { + line-height: 1.7em; + } - .checkbox-container { - display: inline; - padding-right: unset; - margin-bottom: unset; - margin-left: 23px; - } + .checkbox-container { + display: inline; + padding-right: unset; + margin-bottom: unset; + margin-left: 23px; + } - .checkbox { - right: unset; - left: -22px; - } + .checkbox { + right: unset; + left: -22px; + } - .checkbox-container .checkbox:after { - top: -4px; - } + .checkbox-container .checkbox:after { + top: -4px; + } } .search-row { - display: flex; - flex-wrap: wrap; - line-height: unset; + display: flex; + flex-wrap: wrap; + line-height: unset; - > div { - flex-grow: 1; - flex-shrink: 1; - } + > div { + flex-grow: 1; + flex-shrink: 1; + } + + input { + height: 21px; + } + + .pref-input { + display: block; + padding-bottom: 5px; input { - height: 21px; - } - - .pref-input { - display: block; - padding-bottom: 5px; - - input { - height: 21px; - margin-top: 1px; - } + height: 21px; + margin-top: 1px; } + } } .search-toggles { - flex-grow: 1; - display: grid; - grid-template-columns: repeat(5, auto); - grid-column-gap: 10px; + flex-grow: 1; + display: grid; + grid-template-columns: repeat(5, auto); + grid-column-gap: 10px; } .profile-tabs { - @include search-resize(820px, 5); - @include search-resize(715px, 4); - @include search-resize(700px, 5); - @include search-resize(485px, 4); - @include search-resize(410px, 3); + @include search-resize(820px, 5); + @include search-resize(715px, 4); + @include search-resize(700px, 5); + @include search-resize(485px, 4); + @include search-resize(410px, 3); } @include search-resize(700px, 5); diff --git a/src/sass/timeline.scss b/src/sass/timeline.scss index c8ce309..40882b2 100644 --- a/src/sass/timeline.scss +++ b/src/sass/timeline.scss @@ -1,162 +1,162 @@ -@import '_variables'; +@import "_variables"; .timeline-container { - @include panel(100%, 600px); + @include panel(100%, 600px); } .timeline { - background-color: var(--bg_panel); + background-color: var(--bg_panel); - > div:not(:first-child) { - border-top: 1px solid var(--border_grey); - } + > div:not(:first-child) { + border-top: 1px solid var(--border_grey); + } } .timeline-header { - width: 100%; - background-color: var(--bg_panel); - text-align: center; - padding: 8px; - display: block; - font-weight: bold; - margin-bottom: 5px; - box-sizing: border-box; + width: 100%; + background-color: var(--bg_panel); + text-align: center; + padding: 8px; + display: block; + font-weight: bold; + margin-bottom: 5px; + box-sizing: border-box; - button { - float: unset; - } + button { + float: unset; + } } .timeline-banner img { - width: 100%; + width: 100%; } .timeline-description { - font-weight: normal; + font-weight: normal; } .tab { - align-items: center; - display: flex; - flex-wrap: wrap; - list-style: none; - margin: 0 0 5px 0; - background-color: var(--bg_panel); - padding: 0; + align-items: center; + display: flex; + flex-wrap: wrap; + list-style: none; + margin: 0 0 5px 0; + background-color: var(--bg_panel); + padding: 0; } .tab-item { - flex: 1 1 0; - text-align: center; - margin-top: 0; + flex: 1 1 0; + text-align: center; + margin-top: 0; - a { - border-bottom: .1rem solid transparent; - color: var(--tab); - display: block; - padding: 8px 0; - text-decoration: none; - font-weight: bold; + a { + border-bottom: 0.1rem solid transparent; + color: var(--tab); + display: block; + padding: 8px 0; + text-decoration: none; + font-weight: bold; - &:hover { - text-decoration: none; - } - - &.active { - border-bottom-color: var(--tab_selected); - color: var(--tab_selected); - } + &:hover { + text-decoration: none; } - &.active a { - border-bottom-color: var(--tab_selected); - color: var(--tab_selected); + &.active { + border-bottom-color: var(--tab_selected); + color: var(--tab_selected); } + } - &.wide { - flex-grow: 1.2; - flex-basis: 50px; - } + &.active a { + border-bottom-color: var(--tab_selected); + color: var(--tab_selected); + } + + &.wide { + flex-grow: 1.2; + flex-basis: 50px; + } } .timeline-footer { - background-color: var(--bg_panel); - padding: 6px 0; + background-color: var(--bg_panel); + padding: 6px 0; } .timeline-protected { - text-align: center; + text-align: center; - p { - margin: 8px 0; - } + p { + margin: 8px 0; + } - h2 { - color: var(--accent); - font-size: 20px; - font-weight: 600; - } -} - -.timeline-none { + h2 { color: var(--accent); font-size: 20px; font-weight: 600; - text-align: center; + } +} + +.timeline-none { + color: var(--accent); + font-size: 20px; + font-weight: 600; + text-align: center; } .timeline-end { - background-color: var(--bg_panel); - color: var(--accent); - font-size: 16px; - font-weight: 600; - text-align: center; + background-color: var(--bg_panel); + color: var(--accent); + font-size: 16px; + font-weight: 600; + text-align: center; } .show-more { - background-color: var(--bg_panel); - text-align: center; - padding: .75em 0; - display: block !important; + background-color: var(--bg_panel); + text-align: center; + padding: 0.75em 0; + display: block !important; - a { - background-color: var(--darkest_grey); - display: inline-block; - height: 2em; - padding: 0 2em; - line-height: 2em; + a { + background-color: var(--darkest_grey); + display: inline-block; + height: 2em; + padding: 0 2em; + line-height: 2em; - &:hover { - background-color: var(--darker_grey); - } + &:hover { + background-color: var(--darker_grey); } + } } .top-ref { - background-color: var(--bg_color); - border-top: none !important; + background-color: var(--bg_color); + border-top: none !important; - .icon-down { - font-size: 20px; - display: flex; - justify-content: center; - text-decoration: none; + .icon-down { + font-size: 20px; + display: flex; + justify-content: center; + text-decoration: none; - &:hover { - color: var(--accent_light); - } - - &::before { - transform: rotate(180deg) translateY(-1px); - } + &:hover { + color: var(--accent_light); } + + &::before { + transform: rotate(180deg) translateY(-1px); + } + } } .timeline-item { - overflow-wrap: break-word; - border-left-width: 0; - min-width: 0; - padding: .75em; - display: flex; - position: relative; + overflow-wrap: break-word; + border-left-width: 0; + min-width: 0; + padding: 0.75em; + display: flex; + position: relative; } diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 69f51c0..7f2d931 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -1,240 +1,244 @@ -@import '_variables'; -@import '_mixins'; -@import 'thread'; -@import 'media'; -@import 'video'; -@import 'embed'; -@import 'card'; -@import 'poll'; -@import 'quote'; +@import "_variables"; +@import "_mixins"; +@import "thread"; +@import "media"; +@import "video"; +@import "embed"; +@import "card"; +@import "poll"; +@import "quote"; .tweet-body { - flex: 1; - min-width: 0; - margin-left: 58px; - pointer-events: none; - z-index: 1; + flex: 1; + min-width: 0; + margin-left: 58px; + pointer-events: none; + z-index: 1; } .tweet-content { - font-family: $font_3; - line-height: 1.3em; - pointer-events: all; - display: inline; + line-height: 1.3em; + pointer-events: all; + display: inline; } .tweet-bidi { - display: block !important; + display: block !important; } .tweet-header { - padding: 0; - vertical-align: bottom; - flex-basis: 100%; - margin-bottom: .2em; + padding: 0; + vertical-align: bottom; + flex-basis: 100%; + margin-bottom: 0.2em; - a { - display: inline-block; - word-break: break-all; - max-width: 100%; - pointer-events: all; - } + a { + display: inline-block; + word-break: break-all; + max-width: 100%; + pointer-events: all; + } } .tweet-name-row { - padding: 0; - display: flex; - justify-content: space-between; + padding: 0; + display: flex; + justify-content: space-between; } .fullname-and-username { - display: flex; - min-width: 0; + display: flex; + min-width: 0; } .fullname { - @include ellipsis; - flex-shrink: 2; - max-width: 80%; - font-size: 14px; - font-weight: 700; - color: var(--fg_color); + @include ellipsis; + flex-shrink: 2; + max-width: 80%; + font-size: 14px; + font-weight: 700; + color: var(--fg_color); } .username { - @include ellipsis; - min-width: 1.6em; - margin-left: .4em; - word-wrap: normal; + @include ellipsis; + min-width: 1.6em; + margin-left: 0.4em; + word-wrap: normal; } .tweet-date { - display: flex; - flex-shrink: 0; - margin-left: 4px; + display: flex; + flex-shrink: 0; + margin-left: 4px; } -.tweet-date a, .username, .show-more a { - color: var(--fg_dark); +.tweet-date a, +.username, +.show-more a { + color: var(--fg_dark); } .tweet-published { - margin: 0; - margin-top: 5px; - color: var(--grey); - pointer-events: all; + margin: 0; + margin-top: 5px; + color: var(--grey); + pointer-events: all; } .tweet-avatar { - display: contents !important; + display: contents !important; - img { - float: left; - margin-top: 3px; - margin-left: -58px; - width: 48px; - height: 48px; - } + img { + float: left; + margin-top: 3px; + margin-left: -58px; + width: 48px; + height: 48px; + } } .avatar { - &.round { - border-radius: 50%; - -webkit-user-select: none; - } - - &.mini { - position: unset; - margin-right: 5px; - margin-top: -1px; - width: 20px; - height: 20px; - } + &.round { + border-radius: 50%; + -webkit-user-select: none; + } + + &.mini { + position: unset; + margin-right: 5px; + margin-top: -1px; + width: 20px; + height: 20px; + } } .tweet-embed { + display: flex; + flex-direction: column; + justify-content: center; + height: 100%; + background-color: var(--bg_panel); + + .tweet-content { + font-size: 18px; + } + + .tweet-body { display: flex; flex-direction: column; - justify-content: center; - height: 100%; - background-color: var(--bg_panel); + max-height: calc(100vh - 0.75em * 2); + } - .tweet-content { - font-size: 18px; - } - - .tweet-body { - display: flex; - flex-direction: column; - max-height: calc(100vh - 0.75em * 2); - } + .card-image img { + height: auto; + } - .card-image img { - height: auto; - } - - .avatar { - position: absolute; - } + .avatar { + position: absolute; + } } .attribution { - display: flex; - pointer-events: all; - margin: 5px 0; + display: flex; + pointer-events: all; + margin: 5px 0; - strong { - color: var(--fg_color); - } + strong { + color: var(--fg_color); + } } .media-tag-block { - padding-top: 5px; - pointer-events: all; + padding-top: 5px; + pointer-events: all; + color: var(--fg_faded); + + .icon-container { + padding-right: 2px; + } + + .media-tag, + .icon-container { color: var(--fg_faded); - - .icon-container { - padding-right: 2px; - } - - .media-tag, .icon-container { - color: var(--fg_faded); - } + } } .timeline-container .media-tag-block { - font-size: 13px; + font-size: 13px; } .tweet-geo { - color: var(--fg_faded); + color: var(--fg_faded); } .replying-to { - color: var(--fg_faded); - margin: -2px 0 4px; + color: var(--fg_faded); + margin: -2px 0 4px; - a { - pointer-events: all; - } + a { + pointer-events: all; + } } -.retweet-header, .pinned, .tweet-stats { - align-content: center; - color: var(--grey); - display: flex; - flex-shrink: 0; - flex-wrap: wrap; - font-size: 14px; - font-weight: 600; - line-height: 22px; +.retweet-header, +.pinned, +.tweet-stats { + align-content: center; + color: var(--grey); + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + font-size: 14px; + font-weight: 600; + line-height: 22px; - span { - @include ellipsis; - } + span { + @include ellipsis; + } } .retweet-header { - margin-top: -5px !important; + margin-top: -5px !important; } .tweet-stats { - margin-bottom: -3px; - -webkit-user-select: none; + margin-bottom: -3px; + -webkit-user-select: none; } .tweet-stat { - padding-top: 5px; - min-width: 1em; - margin-right: 0.8em; + padding-top: 5px; + min-width: 1em; + margin-right: 0.8em; } .show-thread { - display: block; - pointer-events: all; - padding-top: 2px; + display: block; + pointer-events: all; + padding-top: 2px; } .unavailable-box { - width: 100%; - height: 100%; - padding: 12px; - border: solid 1px var(--dark_grey); - box-sizing: border-box; - border-radius: 10px; - background-color: var(--bg_color); - z-index: 2; + width: 100%; + height: 100%; + padding: 12px; + border: solid 1px var(--dark_grey); + box-sizing: border-box; + border-radius: 10px; + background-color: var(--bg_color); + z-index: 2; } .tweet-link { - height: 100%; - width: 100%; - left: 0; - top: 0; - position: absolute; - -webkit-user-select: none; + height: 100%; + width: 100%; + left: 0; + top: 0; + position: absolute; + -webkit-user-select: none; - &:hover { - background-color: var(--bg_hover); - } + &:hover { + background-color: var(--bg_hover); + } } diff --git a/src/sass/tweet/embed.scss b/src/sass/tweet/embed.scss index 227fc5e..fbdbd41 100644 --- a/src/sass/tweet/embed.scss +++ b/src/sass/tweet/embed.scss @@ -1,17 +1,17 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; .embed-video { - .gallery-video { - width: 100%; - height: 100%; - position: absolute; - background-color: black; - top: 0%; - left: 0%; - } + .gallery-video { + width: 100%; + height: 100%; + position: absolute; + background-color: black; + top: 0%; + left: 0%; + } - .video-container { - max-height: unset; - } + .video-container { + max-height: unset; + } } diff --git a/src/sass/tweet/media.scss b/src/sass/tweet/media.scss index 91c9dab..66a300f 100644 --- a/src/sass/tweet/media.scss +++ b/src/sass/tweet/media.scss @@ -1,76 +1,76 @@ -@import '_variables'; +@import "_variables"; .gallery-row { - display: flex; - flex-direction: row; - flex-wrap: nowrap; - align-items: center; - overflow: hidden; - flex-grow: 1; - max-height: 379.5px; - max-width: 533px; - pointer-events: all; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + overflow: hidden; + flex-grow: 1; + max-height: 379.5px; + max-width: 533px; + pointer-events: all; - .still-image { - width: 100%; - display: flex; - } + .still-image { + width: 100%; + display: flex; + } } .attachments { - margin-top: .35em; - display: flex; - flex-direction: row; - width: 100%; - max-height: 600px; - border-radius: 7px; - overflow: hidden; - flex-flow: column; - background-color: var(--bg_color); - align-items: center; - pointer-events: all; + margin-top: 0.35em; + display: flex; + flex-direction: row; + width: 100%; + max-height: 600px; + border-radius: 7px; + overflow: hidden; + flex-flow: column; + background-color: var(--bg_color); + align-items: center; + pointer-events: all; - .image-attachment { - width: 100%; - } + .image-attachment { + width: 100%; + } } .attachment { - position: relative; - line-height: 0; - overflow: hidden; - margin: 0 .25em 0 0; - flex-grow: 1; - box-sizing: border-box; - min-width: 2em; + position: relative; + line-height: 0; + overflow: hidden; + margin: 0 0.25em 0 0; + flex-grow: 1; + box-sizing: border-box; + min-width: 2em; - &:last-child { - margin: 0; - max-height: 530px; - } + &:last-child { + margin: 0; + max-height: 530px; + } } .gallery-gif video { - max-height: 530px; - background-color: #101010; + max-height: 530px; + background-color: #101010; } .still-image { - max-height: 379.5px; - max-width: 533px; - justify-content: center; + max-height: 379.5px; + max-width: 533px; + justify-content: center; - img { - object-fit: cover; - max-width: 100%; - max-height: 379.5px; - flex-basis: 300px; - flex-grow: 1; - } + img { + object-fit: cover; + max-width: 100%; + max-height: 379.5px; + flex-basis: 300px; + flex-grow: 1; + } } .image { - display: inline-block; + display: inline-block; } // .single-image { @@ -86,34 +86,34 @@ // } .overlay-circle { - border-radius: 50%; - background-color: var(--dark_grey); - width: 40px; - height: 40px; - align-items: center; - display: flex; - border-width: 5px; - border-color: var(--play_button); - border-style: solid; + border-radius: 50%; + background-color: var(--dark_grey); + width: 40px; + height: 40px; + align-items: center; + display: flex; + border-width: 5px; + border-color: var(--play_button); + border-style: solid; } .overlay-triangle { - width: 0; - height: 0; - border-style: solid; - border-width: 12px 0 12px 17px; - border-color: transparent transparent transparent var(--play_button); - margin-left: 14px; + width: 0; + height: 0; + border-style: solid; + border-width: 12px 0 12px 17px; + border-color: transparent transparent transparent var(--play_button); + margin-left: 14px; } .media-gif { - display: table; - background-color: unset; - width: unset; + display: table; + background-color: unset; + width: unset; } .media-body { - flex: 1; - padding: 0; - white-space: pre-wrap; + flex: 1; + padding: 0; + white-space: pre-wrap; } diff --git a/src/sass/tweet/poll.scss b/src/sass/tweet/poll.scss index 57590c8..6d54e00 100644 --- a/src/sass/tweet/poll.scss +++ b/src/sass/tweet/poll.scss @@ -1,42 +1,42 @@ -@import '_variables'; +@import "_variables"; .poll-meter { - overflow: hidden; - position: relative; - margin: 6px 0; - height: 26px; - background: var(--bg_color); - border-radius: 5px; - display: flex; - align-items: center; + overflow: hidden; + position: relative; + margin: 6px 0; + height: 26px; + background: var(--bg_color); + border-radius: 5px; + display: flex; + align-items: center; } .poll-choice-bar { - height: 100%; - position: absolute; - background: var(--dark_grey); + height: 100%; + position: absolute; + background: var(--dark_grey); } .poll-choice-value { - position: relative; - font-weight: bold; - margin-left: 5px; - margin-right: 6px; - min-width: 30px; - text-align: right; - pointer-events: all; + position: relative; + font-weight: bold; + margin-left: 5px; + margin-right: 6px; + min-width: 30px; + text-align: right; + pointer-events: all; } .poll-choice-option { - position: relative; - pointer-events: all; + position: relative; + pointer-events: all; } .poll-info { - color: var(--grey); - pointer-events: all; + color: var(--grey); + pointer-events: all; } .leader .poll-choice-bar { - background: var(--accent_dark); + background: var(--accent_dark); } diff --git a/src/sass/tweet/quote.scss b/src/sass/tweet/quote.scss index b4bc60e..1db4f7e 100644 --- a/src/sass/tweet/quote.scss +++ b/src/sass/tweet/quote.scss @@ -1,94 +1,95 @@ -@import '_variables'; +@import "_variables"; .quote { - margin-top: 10px; - border: solid 1px var(--dark_grey); - border-radius: 10px; - background-color: var(--bg_elements); + margin-top: 10px; + border: solid 1px var(--dark_grey); + border-radius: 10px; + background-color: var(--bg_elements); + overflow: hidden; + pointer-events: all; + position: relative; + width: 100%; + + &:hover { + border-color: var(--grey); + } + + &.unavailable:hover { + border-color: var(--dark_grey); + } + + .tweet-name-row { + padding: 6px 8px; + margin-top: 1px; + } + + .quote-text { overflow: hidden; - pointer-events: all; - position: relative; - width: 100%; + white-space: pre-wrap; + word-wrap: break-word; + padding: 0px 8px 8px 8px; + } - &:hover { - border-color: var(--grey); - } + .show-thread { + padding: 0px 8px 6px 8px; + margin-top: -6px; + } - &.unavailable:hover { - border-color: var(--dark_grey); - } - - .tweet-name-row { - padding: 6px 8px; - margin-top: 1px; - } - - .quote-text { - overflow: hidden; - white-space: pre-wrap; - word-wrap: break-word; - padding: 0px 8px 8px 8px; - } - - .show-thread { - padding: 0px 8px 6px 8px; - margin-top: -6px; - } - - .replying-to { - padding: 0px 8px; - margin: unset; - } + .replying-to { + padding: 0px 8px; + margin: unset; + } } .unavailable-quote { - padding: 12px; + padding: 12px; } .quote-link { - width: 100%; - height: 100%; - left: 0; - top: 0; - position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + position: absolute; } .quote-media-container { - max-height: 300px; + max-height: 300px; + display: flex; + + .card { + margin: unset; + } + + .attachments { + border-radius: 0; + } + + .media-gif { + width: 100%; display: flex; + justify-content: center; + } - .card { - margin: unset; + .gallery-gif .attachment { + display: flex; + justify-content: center; + background-color: var(--bg_color); + + video { + height: unset; + width: unset; + max-height: 100%; + max-width: 100%; } + } - .attachments { - border-radius: 0; - } + .gallery-video, + .gallery-gif { + max-height: 300px; + } - .media-gif { - width: 100%; - display: flex; - justify-content: center; - } - - .gallery-gif .attachment { - display: flex; - justify-content: center; - background-color: var(--bg_color); - - video { - height: unset; - width: unset; - max-height: 100%; - max-width: 100%; - } - } - - .gallery-video, .gallery-gif { - max-height: 300px; - } - - .still-image img { - max-height: 250px - } + .still-image img { + max-height: 250px; + } } diff --git a/src/sass/tweet/thread.scss b/src/sass/tweet/thread.scss index 19fb3e0..9d2fb64 100644 --- a/src/sass/tweet/thread.scss +++ b/src/sass/tweet/thread.scss @@ -1,138 +1,139 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; .conversation { - @include panel(100%, 600px); + @include panel(100%, 600px); - .show-more { - margin-bottom: 10px; - } + .show-more { + margin-bottom: 10px; + } } .main-thread { - margin-bottom: 20px; - background-color: var(--bg_panel); + margin-bottom: 20px; + background-color: var(--bg_panel); } -.main-tweet, .replies { - padding-top: 50px; - margin-top: -50px; +.main-tweet, +.replies { + padding-top: 50px; + margin-top: -50px; } .main-tweet .tweet-content { - font-size: 18px; + font-size: 18px; } -@media(max-width: 600px) { - .main-tweet .tweet-content { - font-size: 16px; - } +@media (max-width: 600px) { + .main-tweet .tweet-content { + font-size: 16px; + } } .reply { - background-color: var(--bg_panel); - margin-bottom: 10px; + background-color: var(--bg_panel); + margin-bottom: 10px; } .thread-line { - .timeline-item::before, - &.timeline-item::before { - background: var(--accent_dark); - content: ''; - position: relative; - min-width: 3px; - width: 3px; - left: 26px; - border-radius: 2px; - margin-left: -3px; - margin-bottom: 37px; - top: 56px; - z-index: 1; - pointer-events: none; - } + .timeline-item::before, + &.timeline-item::before { + background: var(--accent_dark); + content: ""; + position: relative; + min-width: 3px; + width: 3px; + left: 26px; + border-radius: 2px; + margin-left: -3px; + margin-bottom: 37px; + top: 56px; + z-index: 1; + pointer-events: none; + } - .with-header:not(:first-child)::after { - background: var(--accent_dark); - content: ''; - position: relative; - float: left; - min-width: 3px; - width: 3px; - right: calc(100% - 26px); - border-radius: 2px; - margin-left: -3px; - margin-bottom: 37px; - bottom: 10px; - height: 30px; - z-index: 1; - pointer-events: none; - } + .with-header:not(:first-child)::after { + background: var(--accent_dark); + content: ""; + position: relative; + float: left; + min-width: 3px; + width: 3px; + right: calc(100% - 26px); + border-radius: 2px; + margin-left: -3px; + margin-bottom: 37px; + bottom: 10px; + height: 30px; + z-index: 1; + pointer-events: none; + } - .unavailable::before { - top: 48px; - margin-bottom: 28px; - } + .unavailable::before { + top: 48px; + margin-bottom: 28px; + } - .more-replies::before { - content: '...'; - background: unset; - color: var(--more_replies_dots); - font-weight: bold; - font-size: 20px; - line-height: 0.25em; - left: 1.2em; - width: 5px; - top: 2px; - margin-bottom: 0; - margin-left: -2.5px; - } + .more-replies::before { + content: "..."; + background: unset; + color: var(--more_replies_dots); + font-weight: bold; + font-size: 20px; + line-height: 0.25em; + left: 1.2em; + width: 5px; + top: 2px; + margin-bottom: 0; + margin-left: -2.5px; + } - .earlier-replies { - padding-bottom: 0; - margin-bottom: -5px; - } + .earlier-replies { + padding-bottom: 0; + margin-bottom: -5px; + } } .timeline-item.thread-last::before { - background: unset; - min-width: unset; - width: 0; - margin: 0; + background: unset; + min-width: unset; + width: 0; + margin: 0; } .more-replies { - padding-top: 0.3em !important; + padding-top: 0.3em !important; } .more-replies-text { - @include ellipsis; - display: block; - margin-left: 58px; - padding: 7px 0; + @include ellipsis; + display: block; + margin-left: 58px; + padding: 7px 0; } .timeline-item.thread.more-replies-thread { - padding: 0 0.75em; + padding: 0 0.75em; + + &::before { + top: 40px; + margin-bottom: 31px; + } + + .more-replies { + display: flex; + padding-top: unset !important; + margin-top: 8px; &::before { - top: 40px; - margin-bottom: 31px; + display: inline-block; + position: relative; + top: -1px; + line-height: 0.4em; } - .more-replies { - display: flex; - padding-top: unset !important; - margin-top: 8px; - - &::before { - display: inline-block; - position: relative; - top: -1px; - line-height: 0.4em; - } - - .more-replies-text { - display: inline; - } + .more-replies-text { + display: inline; } + } } diff --git a/src/sass/tweet/video.scss b/src/sass/tweet/video.scss index 98a1c29..1e00d39 100644 --- a/src/sass/tweet/video.scss +++ b/src/sass/tweet/video.scss @@ -1,68 +1,69 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; video { - max-height: 100%; - width: 100%; + max-height: 100%; + width: 100%; } .gallery-video { - display: flex; - overflow: hidden; + display: flex; + overflow: hidden; } .gallery-video.card-container { - flex-direction: column; + flex-direction: column; } .video-container { - min-height: 80px; - min-width: 200px; - max-height: 530px; - margin: 0; - display: flex; - align-items: center; - justify-content: center; + min-height: 80px; + min-width: 200px; + max-height: 530px; + margin: 0; + display: flex; + align-items: center; + justify-content: center; - img { - max-height: 100%; - max-width: 100%; - } + img { + max-height: 100%; + max-width: 100%; + } } .video-overlay { - @include play-button; - background-color: $shadow; + @include play-button; + background-color: $shadow; - p { - position: relative; - z-index: 0; - text-align: center; - top: calc(50% - 20px); - font-size: 20px; - line-height: 1.3; - margin: 0 20px; - } + p { + position: relative; + z-index: 0; + text-align: center; + top: calc(50% - 20px); + font-size: 20px; + line-height: 1.3; + margin: 0 20px; + } - div { - position: relative; - z-index: 0; - top: calc(50% - 20px); - margin: 0 auto; - width: 40px; - height: 40px; - } + .overlay-circle { + position: relative; + z-index: 0; + top: calc(50% - 20px); + margin: 0 auto; + width: 40px; + height: 40px; + } - form { - width: 100%; - height: 100%; - align-items: center; - justify-content: center; - display: flex; - } - button { - padding: 5px 8px; - font-size: 16px; - } + form { + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + display: flex; + } + + button { + padding: 5px 8px; + font-size: 16px; + } } From 96ec75fc7f358471dd0390b4f9a0d9f547e3dacd Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 29 Nov 2025 03:38:40 +0100 Subject: [PATCH 018/161] Add video duration to overlay Fixes #498 --- src/formatters.nim | 13 +++++++++++++ src/sass/tweet/video.scss | 10 ++++++++++ src/views/tweet.nim | 1 + 3 files changed, 24 insertions(+) diff --git a/src/formatters.nim b/src/formatters.nim index e491928..3ad1da6 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -154,6 +154,19 @@ proc getShortTime*(tweet: Tweet): string = else: result = "now" +proc getDuration*(video: Video): string = + let + ms = video.durationMs + sec = int(ms / 1000) + min = int(sec / 60) + hour = int(min / 60) + if hour > 1: + return &"{hour}:{min mod 60}:{sec mod 60:02}" + elif min > 1: + return &"{min mod 60}:{sec mod 60:02}" + else: + return &"0:{sec mod 60:02}" + proc getLink*(tweet: Tweet; focus=true): string = if tweet.id == 0: return var username = tweet.user.username diff --git a/src/sass/tweet/video.scss b/src/sass/tweet/video.scss index 1e00d39..790b3da 100644 --- a/src/sass/tweet/video.scss +++ b/src/sass/tweet/video.scss @@ -53,6 +53,16 @@ video { height: 40px; } + .overlay-duration { + position: absolute; + bottom: 8px; + left: 8px; + background-color: #0000007a; + line-height: 1em; + padding: 4px 6px 4px 6px; + border-radius: 5px; + font-weight: bold; + } form { width: 100%; diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 552ab89..58d03a9 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -109,6 +109,7 @@ proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode = video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos) verbatim "
" tdiv(class="overlay-circle"): span(class="overlay-triangle") + tdiv(class="overlay-duration"): text getDuration(video) verbatim "
" if container.len > 0: tdiv(class="card-content"): From 436a873e4b495e40c7394b3eedaf240ac79dcfe5 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 29 Nov 2025 03:39:29 +0100 Subject: [PATCH 019/161] Improve verified checkmark icon, css improvements --- public/css/fontello.css | 145 ++++++++++++++++++++++++++++-------- public/fonts/fontello.eot | Bin 9368 -> 9544 bytes public/fonts/fontello.svg | 6 +- public/fonts/fontello.ttf | Bin 9200 -> 9376 bytes public/fonts/fontello.woff | Bin 5812 -> 5924 bytes public/fonts/fontello.woff2 | Bin 4832 -> 4896 bytes src/sass/general.scss | 1 + src/sass/index.scss | 52 +++++++++---- src/sass/navbar.scss | 13 ++-- src/sass/search.scss | 10 +-- src/sass/tweet/video.scss | 6 +- src/views/general.nim | 4 +- src/views/renderutils.nim | 4 +- 13 files changed, 172 insertions(+), 69 deletions(-) diff --git a/public/css/fontello.css b/public/css/fontello.css index 2453575..52362d8 100644 --- a/public/css/fontello.css +++ b/public/css/fontello.css @@ -1,53 +1,138 @@ @font-face { - font-family: 'fontello'; - src: url('/fonts/fontello.eot?61663884'); - src: url('/fonts/fontello.eot?61663884#iefix') format('embedded-opentype'), - url('/fonts/fontello.woff2?61663884') format('woff2'), - url('/fonts/fontello.woff?61663884') format('woff'), - url('/fonts/fontello.ttf?61663884') format('truetype'), - url('/fonts/fontello.svg?61663884#fontello') format('svg'); + font-family: "fontello"; + src: url("/fonts/fontello.eot?77185648"); + src: + url("/fonts/fontello.eot?77185648#iefix") format("embedded-opentype"), + url("/fonts/fontello.woff2?77185648") format("woff2"), + url("/fonts/fontello.woff?77185648") format("woff"), + url("/fonts/fontello.ttf?77185648") format("truetype"), + url("/fonts/fontello.svg?77185648#fontello") format("svg"); font-weight: normal; font-style: normal; } -[class^="icon-"]:before, [class*=" icon-"]:before { + +[class^="icon-"]:before, +[class*=" icon-"]:before { font-family: "fontello"; font-style: normal; font-weight: normal; speak: never; - + display: inline-block; text-decoration: inherit; width: 1em; + margin-right: 0.2em; text-align: center; /* For safety - reset parent styles, that can break glyph codes*/ font-variant: normal; text-transform: none; - + /* fix buttons height, for twitter bootstrap */ line-height: 1em; - + /* Font smoothing. That was taken from TWBS */ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } -.icon-views:before { content: '\e800'; } /* '' */ -.icon-heart:before { content: '\e801'; } /* '' */ -.icon-quote:before { content: '\e802'; } /* '' */ -.icon-comment:before { content: '\e803'; } /* '' */ -.icon-ok:before { content: '\e804'; } /* '' */ -.icon-play:before { content: '\e805'; } /* '' */ -.icon-link:before { content: '\e806'; } /* '' */ -.icon-calendar:before { content: '\e807'; } /* '' */ -.icon-location:before { content: '\e808'; } /* '' */ -.icon-picture:before { content: '\e809'; } /* '' */ -.icon-lock:before { content: '\e80a'; } /* '' */ -.icon-down:before { content: '\e80b'; } /* '' */ -.icon-retweet:before { content: '\e80c'; } /* '' */ -.icon-search:before { content: '\e80d'; } /* '' */ -.icon-pin:before { content: '\e80e'; } /* '' */ -.icon-cog:before { content: '\e80f'; } /* '' */ -.icon-rss:before { content: '\e810'; } /* '' */ -.icon-info:before { content: '\f128'; } /* '' */ -.icon-bird:before { content: '\f309'; } /* '' */ +.icon-views:before { + content: "\e800"; +} + +/* '' */ +.icon-heart:before { + content: "\e801"; +} + +/* '' */ +.icon-quote:before { + content: "\e802"; +} + +/* '' */ +.icon-comment:before { + content: "\e803"; +} + +/* '' */ +.icon-play:before { + content: "\e805"; +} + +/* '' */ +.icon-link:before { + content: "\e806"; +} + +/* '' */ +.icon-calendar:before { + content: "\e807"; +} + +/* '' */ +.icon-location:before { + content: "\e808"; +} + +/* '' */ +.icon-picture:before { + content: "\e809"; +} + +/* '' */ +.icon-lock:before { + content: "\e80a"; +} + +/* '' */ +.icon-down:before { + content: "\e80b"; +} + +/* '' */ +.icon-retweet:before { + content: "\e80c"; +} + +/* '' */ +.icon-search:before { + content: "\e80d"; +} + +/* '' */ +.icon-pin:before { + content: "\e80e"; +} + +/* '' */ +.icon-cog:before { + content: "\e80f"; +} + +/* '' */ +.icon-rss:before { + content: "\e810"; +} + +/* '' */ +.icon-ok:before { + content: "\e811"; +} + +/* '' */ +.icon-circle:before { + content: "\f111"; +} + +/* '' */ +.icon-info:before { + content: "\f128"; +} + +/* '' */ +.icon-bird:before { + content: "\f309"; +} + +/* '' */ diff --git a/public/fonts/fontello.eot b/public/fonts/fontello.eot index 2b2982a5711bffac0cb1aad4690f661e34e308c4..8671134d7bcae39b2c2b05872362415a5a3bfbe7 100644 GIT binary patch delta 957 zcmYL|Ur19?9LIm>oO`b`b@y)WE(`x^J%p1z^bm*;*_@jjxj)V8G9OeT zd+H&X4`WbBB?!G#10OvuA5dwpH2rfQ^!-9h5?RW0gI^%P`_xn4)o!{@AyX6C` zJyLfYz+CH$2_KY4uRRS|%^2B3v-R%-@xs5**0njT6Fcz=%rsK3xw4~0h3 z7+27LLA4DH-;Z{WKkfz)f&euTi-ZQx?)20EIM9PHhJlI)>^b^}=v!k6ZR}dF@dy0^ z`WwTka3IuZKNiIR2BAF>8cUN4_5y&@gT6T#N<`M5);FNXB6K~S8qq%VZS`UUb-2Eg z&P37^i(QMjw~(77AA1WxFb*&a%zSD`J%q^OGWOg#cH{0=QOK09Ts@wgGyj z1#rbCIlNiD$^y7nTVN}o%KaY2@e+qB9&NhwND24a+rH>{sk;XxgK zd*{C@YAu!Ou!x8-L#)pz(=OEfex?s@ z!!y_>Hu8vkr(U|ifce;x076vQ5`AJ)d?M9LQ)U6R=yg^xqUJ8Do(AFuF&mHE8xiq; tk-JL&5i&fIg(eV=>Z+qzbaDaA1G zt8-O}a?~C0k#fE3sbiWAvA~oJOwqSrE`<#{@ellS4le4@i~orH|buA z_H_1kYW(A=p8)RxoC}#`JaP6-_ybLOLiuQhjFx5j5$R8)!`vL1?usJ#_%Tqoa7p9Glh-AJv~D#XMAYp!FIk07GIkWSME zim@w6%nfHVS8x7-HeKTVOVogAHDRMD0EM^!T8InKo-hT_N?gFI%oKowxPWz@DS*So z1)vfa=!1)dfb}A#06d_U_5m+Yqr)tq&8YJo*8NO2Wl*JxVUt;3w`U;8f~>3jnEkEn zO_UigSJ*#d6x&n93j2VScG0pvrdS^xk5 diff --git a/public/fonts/fontello.svg b/public/fonts/fontello.svg index 2a64343..31bd38c 100644 --- a/public/fonts/fontello.svg +++ b/public/fonts/fontello.svg @@ -14,8 +14,6 @@ - - @@ -40,6 +38,10 @@ + + + + diff --git a/public/fonts/fontello.ttf b/public/fonts/fontello.ttf index ef775f87308c85319ecd0d8c36791622adf5c7eb..0c04c6c934c632486fc0fc56ce07b794a1fcba07 100644 GIT binary patch delta 918 zcmYL{Pe>F|9LIm}z1eYfb^hqgX63)^-&UK5*$^0I9m2z_kJ_K-+OPiZ_bBi zS|^XMy(s{Yw*WLB9nCd?bLkd}6weGM`&)jG1L(VGD+ePZ8H_9FzoB{uhabkf#-DZp z2w{LKNW>xo=eKWF0jTJ~NMNAsF?)sn3HlR>WNz$6kNpSzBKq6I>1Zg@ARUWi0D}-r zM#eJal9UIi4xn#JMUt_N7j^aMu?XGBq(^e^dpCQqfm)nj%VcAjiKUJu{I>9H_hU~U z2!;V>fjLh>)Cyc(9t22Wyb`=XL9m#m7S&>4r`)yyEU-NPo%22)2xeEqS$>-p^o&Qt zkZ4|`yJk1_$(3%(ezs&<^o*3F4zZHek{A@wlYf^&B z-j*zfQD2i3j$wuR{Wu}Z#P3*Tfut2BECaDi_B+TLMX#Y^Ef$eTa|^vf z+8PM!&8}JNDRF$|=kf%(`(QPfTixPAVO_FFgYe;Vs87E93uIpE$U5o=+D6*d1iv^sB~o9%8+AiFTmQ?PnTr4_?9+@sersgSOB`2Fze9 z0tj(o(_?s2o*A)DeB>0+3)8BP5#4;Q1t{^G%i2-%ix$#o`o0()%0`D{!cZ!n7W#*> K1BIZzD*XkEM#mNa delta 811 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 MX=MP&xXu~zKlz+IHvj+t delta 3326 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 S*`!;CpE=t5KRzi04o(09?GiZv literal 4832 zcmV<65+Ch%Pew8T0RR91021H;4*&oF03+}K01}A+0RR9100000000000000000000 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 GNo@dwG#q~b diff --git a/src/sass/general.scss b/src/sass/general.scss index ce97564..e6247d6 100644 --- a/src/sass/general.scss +++ b/src/sass/general.scss @@ -23,6 +23,7 @@ font-weight: bold; width: 30px; height: 30px; + padding: 0px 5px 1px 8px; } input { diff --git a/src/sass/index.scss b/src/sass/index.scss index a19165a..3f4b123 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -50,7 +50,7 @@ body { background-color: var(--bg_color); color: var(--fg_color); - font-family: $font_0, $font_1, $font_2, $font_3; + font-family: $font_0, $font_1; font-size: 15px; line-height: 1.3; margin: 0; @@ -143,30 +143,50 @@ ul { } .verified-icon { - color: var(--icon_text); - border-radius: 50%; - flex-shrink: 0; - margin: 2px 0 3px 3px; - padding-top: 3px; - height: 11px; - width: 14px; - font-size: 8px; display: inline-block; - text-align: center; - vertical-align: middle; + width: 14px; + height: 14px; + margin-left: 2px; + + .verified-icon-circle { + position: absolute; + font-size: 15px; + } + + .verified-icon-check { + position: absolute; + font-size: 9px; + margin: 5px 3px; + } &.blue { - background-color: var(--verified_blue); + .verified-icon-circle { + color: var(--verified_blue); + } + + .verified-icon-check { + color: var(--icon_text); + } } &.business { - color: var(--bg_panel); - background-color: var(--verified_business); + .verified-icon-circle { + color: var(--verified_business); + } + + .verified-icon-check { + color: var(--bg_panel); + } } &.government { - color: var(--bg_panel); - background-color: var(--verified_government); + .verified-icon-circle { + color: var(--verified_government); + } + + .verified-icon-check { + color: var(--bg_panel); + } } } diff --git a/src/sass/navbar.scss b/src/sass/navbar.scss index 4e150e0..86bfbe7 100644 --- a/src/sass/navbar.scss +++ b/src/sass/navbar.scss @@ -59,13 +59,9 @@ nav { justify-content: flex-end; } - &.right a { - padding-left: 4px; - - &:hover { - color: var(--accent_light); - text-decoration: unset; - } + &.right a:hover { + color: var(--accent_light); + text-decoration: unset; } } @@ -81,10 +77,11 @@ nav { } } -.icon-info:before { +.icon-info { margin: 0 -3px; } .icon-cog { font-size: 15px; + padding-left: 0 !important; } diff --git a/src/sass/search.scss b/src/sass/search.scss index 444f9bb..c2adaf7 100644 --- a/src/sass/search.scss +++ b/src/sass/search.scss @@ -13,6 +13,7 @@ button { margin: 0 2px 0 0; + padding: 0px 1px 1px 4px; height: 23px; display: flex; align-items: center; @@ -35,7 +36,7 @@ background-color: var(--bg_elements); color: var(--fg_color); border: 1px solid var(--accent_border); - padding: 1px 6px 2px 6px; + padding: 1px 1px 2px 4px; font-size: 14px; cursor: pointer; margin-bottom: 2px; @@ -56,20 +57,17 @@ font-weight: initial; text-align: left; - > div { - line-height: 1.7em; - } - .checkbox-container { display: inline; padding-right: unset; - margin-bottom: unset; + margin-bottom: 5px; margin-left: 23px; } .checkbox { right: unset; left: -22px; + line-height: 1.6em; } .checkbox-container .checkbox:after { diff --git a/src/sass/tweet/video.scss b/src/sass/tweet/video.scss index 790b3da..ba77b14 100644 --- a/src/sass/tweet/video.scss +++ b/src/sass/tweet/video.scss @@ -2,7 +2,7 @@ @import "_mixins"; video { - max-height: 100%; + height: 100%; width: 100%; } @@ -13,6 +13,7 @@ video { .gallery-video.card-container { flex-direction: column; + width: 100%; } .video-container { @@ -20,9 +21,6 @@ video { min-width: 200px; max-height: 530px; margin: 0; - display: flex; - align-items: center; - justify-content: center; img { max-height: 100%; diff --git a/src/views/general.nim b/src/views/general.nim index 23681b5..2525841 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -52,8 +52,8 @@ 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=21") - link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=22") + link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4") if theme.len > 0: link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css")) diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index fcdf06f..377a443 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -26,7 +26,9 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode = template verifiedIcon*(user: User): untyped {.dirty.} = if user.verifiedType != VerifiedType.none: let lower = ($user.verifiedType).toLowerAscii() - icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account") + buildHtml(tdiv(class=(&"verified-icon {lower}"))): + icon "circle", class="verified-icon-circle", title=(&"Verified {lower} account") + icon "ok", class="verified-icon-check", title=(&"Verified {lower} account") else: text "" From 71e65c84d767ebb1d788e138d853cf0567fc970c Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 29 Nov 2025 04:34:04 +0100 Subject: [PATCH 020/161] Round video duration properly --- src/formatters.nim | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/formatters.nim b/src/formatters.nim index 3ad1da6..fc0e197 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen +import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen, math import std/[enumerate, re] import types, utils, query @@ -157,15 +157,13 @@ proc getShortTime*(tweet: Tweet): string = proc getDuration*(video: Video): string = let ms = video.durationMs - sec = int(ms / 1000) - min = int(sec / 60) - hour = int(min / 60) - if hour > 1: + sec = int(round(ms / 1000)) + min = floorDiv(sec, 60) + hour = floorDiv(min, 60) + if hour > 0: return &"{hour}:{min mod 60}:{sec mod 60:02}" - elif min > 1: - return &"{min mod 60}:{sec mod 60:02}" else: - return &"0:{sec mod 60:02}" + return &"{min mod 60}:{sec mod 60:02}" proc getLink*(tweet: Tweet; focus=true): string = if tweet.id == 0: return From 064ec8808022abb071f93f0fc976a8aa123699dc Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 30 Nov 2025 02:56:19 +0100 Subject: [PATCH 021/161] Transition to ID-only RSS GUIDs on Dec 14, 2025 Fixes #447 --- src/views/rss.nimf | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/views/rss.nimf b/src/views/rss.nimf index 23744e5..717ad99 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -2,6 +2,9 @@ ## SPDX-License-Identifier: AGPL-3.0-only #import strutils, xmltree, strformat, options, unicode #import ../types, ../utils, ../formatters, ../prefs +## Snowflake ID cutoff for RSS GUID format transition +## Corresponds to approximately December 14, 2025 UTC +#const guidCutoff = 2000000000000000000'i64 # #proc getTitle(tweet: Tweet; retweet: string): string = #if tweet.pinned: result = "Pinned: " @@ -101,12 +104,17 @@ ${renderRssTweet(quoteTweet, cfg)} # if link in links: continue # end if # links.add link +# let useGlobalGuid = tweet.id >= guidCutoff ${getTitle(tweet, retweet)} @${tweet.user.username} ${getRfc822Time(tweet)} +#if useGlobalGuid: + ${tweet.id} +#else: ${urlPrefix & link} +#end if ${urlPrefix & link} # end for From 4b9aec6fdebbadfaf91786e934e43679242f5c13 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 30 Nov 2025 02:57:34 +0100 Subject: [PATCH 022/161] Use graphTweet for cookie sessions for now --- src/api.nim | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/api.nim b/src/api.nim index e97b4e0..acd25f1 100644 --- a/src/api.nim +++ b/src/api.nim @@ -42,7 +42,8 @@ proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq = proc tweetDetailUrl(id: string; cursor: string): ApiReq = let cookieVars = tweetDetailVars % [id, cursor] result = ApiReq( - cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles), + # cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles), + cookie: apiUrl(graphTweet, tweetVars % [id, cursor]), oauth: apiUrl(graphTweet, tweetVars % [id, cursor]) ) From a62ec9cbb40066c2e05b5e81cb8aa61f921644d1 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 30 Nov 2025 03:58:43 +0100 Subject: [PATCH 023/161] Normalize headers --- src/apiutils.nim | 14 ++++++-------- src/routes/media.nim | 8 ++++---- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index b288141..b392ef7 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -48,21 +48,19 @@ proc getCookieHeader(authToken, ct0: string): string = proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} = result = newHttpHeaders({ + "accept": "*/*", + "accept-encoding": "gzip", + "accept-language": "en-US,en;q=0.9", "connection": "keep-alive", "content-type": "application/json", - "x-twitter-active-user": "yes", - "x-twitter-client-language": "en", "origin": "https://x.com", - "accept-encoding": "gzip", - "accept-language": "en-US,en;q=0.5", - "accept": "*/*", - "DNT": "1", - "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36", + "x-twitter-active-user": "yes", + "x-twitter-client-language": "en" }) case session.kind of SessionKind.oauth: - result["authority"] = "api.x.com" result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret) of SessionKind.cookie: result["x-twitter-auth-type"] = "OAuth2Session" diff --git a/src/routes/media.nim b/src/routes/media.nim index de51061..186b8d8 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -52,10 +52,10 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} = "" let headers = newHttpHeaders({ - "Content-Type": res.headers["content-type", 0], - "Content-Length": contentLength, - "Cache-Control": maxAge, - "ETag": hashed + "content-type": res.headers["content-type", 0], + "content-length": contentLength, + "cache-control": maxAge, + "etag": hashed }) respond(request, headers) From 7734d976f7a6aee4d1abf16a14f0174500f04e7c Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 30 Nov 2025 04:12:38 +0100 Subject: [PATCH 024/161] Add username validation Fixes #1317 --- src/routes/timeline.nim | 1 + tests/test_profile.py | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 49c7ce2..0894d83 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -114,6 +114,7 @@ proc createTimelineRouter*(cfg: Config) = get "/@name/?@tab?/?": cond '.' notin @"name" cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] + cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'}) cond @"tab" in ["with_replies", "media", "search", ""] let prefs = cookiePrefs() diff --git a/tests/test_profile.py b/tests/test_profile.py index ea05add..cbf0256 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -15,7 +15,19 @@ protected = [ ['Poop', 'Randy', 'Social media fanatic.'] ] -invalid = [['thisprofiledoesntexist'], ['%']] +invalid = [['thisprofiledoesntexist']] + +malformed = [ + ['${userId}'], + ['$%7BuserId%7D'], # URL encoded version + ['%'], # Percent sign is invalid + ['user@name'], + ['user.name'], + ['user-name'], + ['user$name'], + ['user{name}'], + ['user name'], # space +] banner_image = [ ['mobile_test', 'profile_banners%2F82135242%2F1384108037%2F1500x500'] @@ -65,6 +77,13 @@ class ProfileTest(BaseTestCase): self.open_nitter(username) self.assert_text(f'User "{username}" not found') + @parameterized.expand(malformed) + def test_malformed_username(self, username): + """Test that malformed usernames (with invalid characters) return 404""" + self.open_nitter(username) + # Malformed usernames should return 404 page not found, not try to fetch from Twitter + self.assert_text('Page not found') + def test_suspended(self): self.open_nitter('suspendme') self.assert_text('User "suspendme" has been suspended') From 693a1894625e0ba035554d23b3e9b94e99dab8a4 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 30 Nov 2025 05:43:17 +0100 Subject: [PATCH 025/161] Add heuristics to detect when to show "Load more" Fixes #1328 --- src/views/status.nim | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/views/status.nim b/src/views/status.nim index 71c2c67..96af807 100644 --- a/src/views/status.nim +++ b/src/views/status.nim @@ -28,14 +28,19 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode = if thread.hasMore: renderMoreReplies(thread) -proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode = +proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: Tweet = nil): VNode = buildHtml(tdiv(class="replies", id="r")): + var hasReplies = false + var replyCount = 0 for thread in replies.content: if thread.content.len == 0: continue + hasReplies = true + replyCount += thread.content.len renderReplyThread(thread, prefs, path) - if replies.bottom.len > 0: - renderMore(Query(), replies.bottom, focus="#r") + 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") proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode = let hasAfter = conv.after.content.len > 0 @@ -70,6 +75,6 @@ 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) + renderReplies(conv.replies, prefs, path, conv.tweet) renderToTop(focus="#m") From e7413858286cc4c188aef14433dbd9e76256180c Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 30 Nov 2025 18:06:22 +0100 Subject: [PATCH 026/161] Allow , in username to support multiple users Fixes #1329 --- src/routes/timeline.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 0894d83..4d8561b 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -114,7 +114,7 @@ proc createTimelineRouter*(cfg: Config) = get "/@name/?@tab?/?": cond '.' notin @"name" cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] - cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'}) + cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ","}) cond @"tab" in ["with_replies", "media", "search", ""] let prefs = cookiePrefs() From 17fc2628f91f70b9bfda1915c76e94708a5197bf Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 30 Nov 2025 18:07:13 +0100 Subject: [PATCH 027/161] Minor fix --- src/routes/timeline.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 4d8561b..2ac87bb 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -114,7 +114,7 @@ proc createTimelineRouter*(cfg: Config) = get "/@name/?@tab?/?": cond '.' notin @"name" cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] - cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ","}) + cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','}) cond @"tab" in ["with_replies", "media", "search", ""] let prefs = cookiePrefs() From 663f5a52e13783075287e779b1b947b862259e7e Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 6 Dec 2025 05:00:34 +0100 Subject: [PATCH 028/161] Improve headers --- src/apiutils.nim | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index b392ef7..7a49e3e 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -56,7 +56,8 @@ proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} = "origin": "https://x.com", "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36", "x-twitter-active-user": "yes", - "x-twitter-client-language": "en" + "x-twitter-client-language": "en", + "priority": "u=1, i" }) case session.kind @@ -66,6 +67,12 @@ proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} = result["x-twitter-auth-type"] = "OAuth2Session" result["x-csrf-token"] = session.ct0 result["cookie"] = getCookieHeader(session.authToken, session.ct0) + result["sec-ch-ua"] = """"Google Chrome";v="142", "Chromium";v="142", "Not A(Brand";v="24"""" + result["sec-ch-ua-mobile"] = "?0" + result["sec-ch-ua-platform"] = "Windows" + result["sec-fetch-dest"] = "empty" + result["sec-fetch-mode"] = "cors" + result["sec-fetch-site"] = "same-site" if disableTid: result["authorization"] = bearerToken2 else: From 51b54852dcf617c6131376a469c537ab6b8326e3 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 6 Dec 2025 05:15:01 +0100 Subject: [PATCH 029/161] Add preliminary support for nitter-proxy --- nitter.example.conf | 1 + src/apiutils.nim | 13 ++++++++++++- src/config.nim | 1 + src/nitter.nim | 1 + src/types.nim | 1 + 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/nitter.example.conf b/nitter.example.conf index dfdaf50..7e4c846 100644 --- a/nitter.example.conf +++ b/nitter.example.conf @@ -26,6 +26,7 @@ enableRSS = true # set this to false to disable RSS feeds enableDebug = false # enable request logs and debug endpoints (/.sessions) proxy = "" # http/https url, SOCKS proxies are not supported proxyAuth = "" +apiProxy = "" # nitter-proxy host, e.g. localhost:7000 disableTid = false # enable this if cookie-based auth is failing # Change default preferences here, see src/prefs_impl.nim for a complete list diff --git a/src/apiutils.nim b/src/apiutils.nim index 7a49e3e..ddb5027 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -13,10 +13,17 @@ const var pool: HttpPool disableTid: bool + apiProxy: string proc setDisableTid*(disable: bool) = disableTid = disable +proc setApiProxy*(url: string) = + if url.len > 0: + apiProxy = url.strip(chars={'/'}) & "/" + if "http" notin apiProxy: + apiProxy = "http://" & apiProxy + proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri = case sessionKind of oauth: @@ -99,7 +106,11 @@ template fetchImpl(result, fetchBody) {.dirty.} = var resp: AsyncResponse pool.use(await genHeaders(session, url)): template getContent = - resp = await c.get($url) + # TODO: this is a temporary simple implementation + if apiProxy.len > 0: + resp = await c.get(($url).replace("https://", apiProxy)) + else: + resp = await c.get($url) result = await resp.body getContent() diff --git a/src/config.nim b/src/config.nim index 571508b..2b38d86 100644 --- a/src/config.nim +++ b/src/config.nim @@ -41,6 +41,7 @@ proc getConfig*(path: string): (Config, parseCfg.Config) = enableDebug: cfg.get("Config", "enableDebug", false), proxy: cfg.get("Config", "proxy", ""), proxyAuth: cfg.get("Config", "proxyAuth", ""), + apiProxy: cfg.get("Config", "apiProxy", ""), disableTid: cfg.get("Config", "disableTid", false) ) diff --git a/src/nitter.nim b/src/nitter.nim index e6d66ab..91a3a9f 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -37,6 +37,7 @@ setHmacKey(cfg.hmacKey) setProxyEncoding(cfg.base64Media) setMaxHttpConns(cfg.httpMaxConns) setHttpProxy(cfg.proxy, cfg.proxyAuth) +setApiProxy(cfg.apiProxy) setDisableTid(cfg.disableTid) initAboutPage(cfg.staticDir) diff --git a/src/types.nim b/src/types.nim index 815e223..c994148 100644 --- a/src/types.nim +++ b/src/types.nim @@ -275,6 +275,7 @@ type enableDebug*: bool proxy*: string proxyAuth*: string + apiProxy*: string disableTid*: bool rssCacheTime*: int From baeaf685d32098cd90ae1424cf8a19f3de9b0e2c Mon Sep 17 00:00:00 2001 From: jackyzy823 Date: Mon, 8 Dec 2025 17:05:08 +0800 Subject: [PATCH 030/161] Make maxConcurrentReqs configurable (#1341) --- nitter.example.conf | 1 + src/auth.nim | 11 +++++++---- src/config.nim | 3 ++- src/nitter.nim | 1 + src/types.nim | 1 + 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/nitter.example.conf b/nitter.example.conf index 7e4c846..4a6a026 100644 --- a/nitter.example.conf +++ b/nitter.example.conf @@ -28,6 +28,7 @@ proxy = "" # http/https url, SOCKS proxies are not supported proxyAuth = "" apiProxy = "" # nitter-proxy host, e.g. localhost:7000 disableTid = false # enable this if cookie-based auth is failing +maxConcurrentReqs = 2 # max requests at a time per session to avoid race conditions # Change default preferences here, see src/prefs_impl.nim for a complete list [Preferences] diff --git a/src/auth.nim b/src/auth.nim index 5d7ef0e..d801489 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -3,14 +3,17 @@ import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os import types, consts import experimental/parser/session -# max requests at a time per session to avoid race conditions -const - maxConcurrentReqs = 2 - hourInSeconds = 60 * 60 +const hourInSeconds = 60 * 60 var sessionPool: seq[Session] enableLogging = false + # max requests at a time per session to avoid race conditions + maxConcurrentReqs = 2 + +proc setMaxConcurrentReqs*(reqs: int) = + if reqs > 0: + maxConcurrentReqs = reqs template log(str: varargs[string, `$`]) = echo "[sessions] ", str.join("") diff --git a/src/config.nim b/src/config.nim index 2b38d86..8cb334a 100644 --- a/src/config.nim +++ b/src/config.nim @@ -42,7 +42,8 @@ proc getConfig*(path: string): (Config, parseCfg.Config) = proxy: cfg.get("Config", "proxy", ""), proxyAuth: cfg.get("Config", "proxyAuth", ""), apiProxy: cfg.get("Config", "apiProxy", ""), - disableTid: cfg.get("Config", "disableTid", false) + disableTid: cfg.get("Config", "disableTid", false), + maxConcurrentReqs: cfg.get("Config", "maxConcurrentReqs", 2) ) return (conf, cfg) diff --git a/src/nitter.nim b/src/nitter.nim index 91a3a9f..e2d6bec 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -39,6 +39,7 @@ setMaxHttpConns(cfg.httpMaxConns) setHttpProxy(cfg.proxy, cfg.proxyAuth) setApiProxy(cfg.apiProxy) setDisableTid(cfg.disableTid) +setMaxConcurrentReqs(cfg.maxConcurrentReqs) initAboutPage(cfg.staticDir) waitFor initRedisPool(cfg) diff --git a/src/types.nim b/src/types.nim index c994148..90487ab 100644 --- a/src/types.nim +++ b/src/types.nim @@ -277,6 +277,7 @@ type proxyAuth*: string apiProxy*: string disableTid*: bool + maxConcurrentReqs*: int rssCacheTime*: int listCacheTime*: int From a92e79ebc3581702dc427434a782a5fc1d28cc91 Mon Sep 17 00:00:00 2001 From: yav <150280490+796176@users.noreply.github.com> Date: Wed, 24 Dec 2025 10:22:20 +0300 Subject: [PATCH 031/161] Fix the checkmark position (#1347) Co-authored-by: yav <796176@protonmail.com> --- src/views/profile.nim | 1 + src/views/renderutils.nim | 1 - src/views/timeline.nim | 1 + src/views/tweet.nim | 2 ++ 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/views/profile.nim b/src/views/profile.nim index 2b2e410..ee3f71d 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -26,6 +26,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode = tdiv(class="profile-card-tabs-name"): linkUser(user, class="profile-card-fullname") + verifiedIcon(user) linkUser(user, class="profile-card-username") tdiv(class="profile-card-extra"): diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index 377a443..a5fe3b2 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -42,7 +42,6 @@ proc linkUser*(user: User, class=""): VNode = buildHtml(a(href=href, class=class, title=nameText)): text nameText if isName: - verifiedIcon(user) if user.protected: text " " icon "lock", title="Protected account" diff --git a/src/views/timeline.nim b/src/views/timeline.nim index a205c04..fee45bc 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -66,6 +66,7 @@ proc renderUser(user: User; prefs: Prefs): VNode = tdiv(class="tweet-name-row"): tdiv(class="fullname-and-username"): linkUser(user, class="fullname") + verifiedIcon(user) linkUser(user, class="username") tdiv(class="tweet-content media-body", dir="auto"): diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 58d03a9..d680509 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -31,6 +31,7 @@ proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VN tdiv(class="tweet-name-row"): tdiv(class="fullname-and-username"): linkUser(tweet.user, class="fullname") + verifiedIcon(tweet.user) linkUser(tweet.user, class="username") span(class="tweet-date"): @@ -235,6 +236,7 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = tdiv(class="fullname-and-username"): renderMiniAvatar(quote.user, prefs) linkUser(quote.user, class="fullname") + verifiedIcon(quote.user) linkUser(quote.user, class="username") span(class="tweet-date"): From a45227b8835719dfb443600052d69374db8b515c Mon Sep 17 00:00:00 2001 From: cmj <129799+cmj@users.noreply.github.com> Date: Thu, 29 Jan 2026 08:27:41 -0800 Subject: [PATCH 032/161] Add user-agent to guest_token request (#1359) --- tools/get_session.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/get_session.py b/tools/get_session.py index 9f91483..da03322 100644 --- a/tools/get_session.py +++ b/tools/get_session.py @@ -21,7 +21,10 @@ def auth(username, password, otp_secret): guest_token = requests.post( "https://api.twitter.com/1.1/guest/activate.json", - headers={'Authorization': bearer_token} + headers={ + 'Authorization': bearer_token, + "User-Agent": "TwitterAndroid/10.21.0-release.0 (310210000-r-0) ONEPLUS+A3010/9" + } ).json().get('guest_token') if not guest_token: From 33dd9b66683e6838d6da16f5563c20de5d32ef5a Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 6 Feb 2026 20:32:44 +0100 Subject: [PATCH 033/161] Fix /pic/ exploit --- src/routes/media.nim | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/routes/media.nim b/src/routes/media.nim index 186b8d8..011d0f3 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -93,6 +93,8 @@ proc createMediaRouter*(cfg: Config) = get re"^\/pic\/orig\/(enc)?\/?(.+)": var url = decoded(request, 1) + cond "amplify_video" notin url + if "twimg.com" notin url: url.insert(twimg) if not url.startsWith(https): @@ -107,6 +109,8 @@ proc createMediaRouter*(cfg: Config) = get re"^\/pic\/(enc)?\/?(.+)": var url = decoded(request, 1) + cond "amplify_video" notin url + if "twimg.com" notin url: url.insert(twimg) if not url.startsWith(https): From 0a6e79e6263b0c3aba2c0fec3b6af906d71a1622 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 9 Feb 2026 02:55:07 +0100 Subject: [PATCH 034/161] Add bulk script create_sessions_browser.py --- tools/create_session_browser.py | 135 ++++++++++++------- tools/create_sessions_browser.py | 219 +++++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+), 47 deletions(-) create mode 100644 tools/create_sessions_browser.py diff --git a/tools/create_session_browser.py b/tools/create_session_browser.py index 3a05cb1..eb3936b 100644 --- a/tools/create_session_browser.py +++ b/tools/create_session_browser.py @@ -20,73 +20,112 @@ Output: {"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."} """ -import sys -import json import asyncio -import pyotp -import nodriver as uc +import json import os +import sys + +import nodriver as uc +import pyotp async def login_and_get_cookies(username, password, totp_seed=None, headless=False): """Authenticate with X.com and extract session cookies""" # Note: headless mode may increase detection risk from bot-detection systems browser = await uc.start(headless=headless) - tab = await browser.get('https://x.com/i/flow/login') + tab = await browser.get("https://x.com/i/flow/login") try: # Enter username - print('[*] Entering username...', file=sys.stderr) - username_input = await tab.find('input[autocomplete="username"]', timeout=10) - await username_input.send_keys(username + '\n') - await asyncio.sleep(1) + print(f"[*] Entering username {username}...", file=sys.stderr) + + retry = 0 + while retry < 5: + username_input = await tab.find( + 'input[autocomplete="username"]', timeout=10 + ) + + pos = await username_input.get_position() + await tab.mouse_move(pos.x, pos.y, steps=50, flash=True) + await asyncio.sleep(0.1) + + await username_input.click() + await asyncio.sleep(0.5) + await username_input.send_keys(username) + await asyncio.sleep(0.2) + await username_input.send_keys("\n") + await asyncio.sleep(2) + + page_content = await tab.get_content() + if "Could not log you in" in page_content: + retry += 1 + wait = retry * 10 + print(f"Retrying in {wait} seconds...") + await asyncio.sleep(wait) + else: + break # Enter password - print('[*] Entering password...', file=sys.stderr) - password_input = await tab.find('input[autocomplete="current-password"]', timeout=15) - await password_input.send_keys(password + '\n') - await asyncio.sleep(2) + print("[*] Entering password...", file=sys.stderr) + pretry = 0 + while pretry < 5: + password_input = await tab.find( + 'input[autocomplete="current-password"]', timeout=15 + ) + await password_input.click() + await asyncio.sleep(0.5) + await password_input.send_keys(password) + await asyncio.sleep(0.2) + await password_input.send_keys("\n") + await asyncio.sleep(2) + + page_content = await tab.get_content() + if "Could not log you in" in page_content: + pretry += 1 + wait = pretry * 10 + print(f"Retrying in {wait} seconds...") + await asyncio.sleep(wait) + else: + break # Handle 2FA if needed page_content = await tab.get_content() - if 'verification code' in page_content or 'Enter code' in page_content: + if "verification code" in page_content or "Enter code" in page_content: if not totp_seed: - raise Exception('2FA required but no TOTP seed provided') + raise Exception("2FA required but no TOTP seed provided") - print('[*] 2FA detected, entering code...', file=sys.stderr) + print("[*] 2FA detected, entering code...", file=sys.stderr) totp_code = pyotp.TOTP(totp_seed).now() code_input = await tab.select('input[type="text"]') - await code_input.send_keys(totp_code + '\n') + await code_input.send_keys(totp_code + "\n") await asyncio.sleep(3) # Get cookies - print('[*] Retrieving cookies...', file=sys.stderr) + print("[*] Retrieving cookies...", file=sys.stderr) for _ in range(20): # 20 second timeout cookies = await browser.cookies.get_all() cookies_dict = {cookie.name: cookie.value for cookie in cookies} - if 'auth_token' in cookies_dict and 'ct0' in cookies_dict: - print('[*] Found both cookies', file=sys.stderr) - + if "auth_token" in cookies_dict and "ct0" in cookies_dict: # Extract ID from twid cookie (may be URL-encoded) user_id = None - if 'twid' in cookies_dict: - twid = cookies_dict['twid'] + if "twid" in cookies_dict: + twid = cookies_dict["twid"] # Try to extract the ID from twid (format: u%3D or u=) - if 'u%3D' in twid: - user_id = twid.split('u%3D')[1].split('&')[0].strip('"') - elif 'u=' in twid: - user_id = twid.split('u=')[1].split('&')[0].strip('"') + if "u%3D" in twid: + user_id = twid.split("u%3D")[1].split("&")[0].strip('"') + elif "u=" in twid: + user_id = twid.split("u=")[1].split("&")[0].strip('"') - cookies_dict['username'] = username + cookies_dict["username"] = username if user_id: - cookies_dict['id'] = user_id + cookies_dict["id"] = user_id return cookies_dict await asyncio.sleep(1) - raise Exception('Timeout waiting for cookies') + raise Exception("Timeout waiting for cookies") finally: browser.stop() @@ -94,7 +133,9 @@ async def login_and_get_cookies(username, password, totp_seed=None, headless=Fal async def main(): if len(sys.argv) < 3: - print('Usage: python3 create_session_browser.py username password [totp_seed] [--append file.jsonl] [--headless]') + print( + "Usage: python3 create_session_browser.py username password [totp_seed] [--append file.jsonl] [--headless]" + ) sys.exit(1) username = sys.argv[1] @@ -107,49 +148,49 @@ async def main(): i = 3 while i < len(sys.argv): arg = sys.argv[i] - if arg == '--append': + if arg == "--append": if i + 1 < len(sys.argv): append_file = sys.argv[i + 1] i += 2 # Skip '--append' and filename else: - print('[!] Error: --append requires a filename', file=sys.stderr) + print("[!] Error: --append requires a filename", file=sys.stderr) sys.exit(1) - elif arg == '--headless': + elif arg == "--headless": headless = True i += 1 - elif not arg.startswith('--'): - if totp_seed is None: + elif not arg.startswith("--"): + if totp_seed is None: totp_seed = arg i += 1 else: # Unkown args - print(f'[!] Warning: Unknown argument: {arg}', file=sys.stderr) + print(f"[!] Warning: Unknown argument: {arg}", file=sys.stderr) i += 1 try: cookies = await login_and_get_cookies(username, password, totp_seed, headless) session = { - 'kind': 'cookie', - 'username': cookies['username'], - 'id': cookies.get('id'), - 'auth_token': cookies['auth_token'], - 'ct0': cookies['ct0'] + "kind": "cookie", + "username": cookies["username"], + "id": cookies.get("id"), + "auth_token": cookies["auth_token"], + "ct0": cookies["ct0"], } output = json.dumps(session) if append_file: - with open(append_file, 'a') as f: - f.write(output + '\n') - print(f'✓ Session appended to {append_file}', file=sys.stderr) + with open(append_file, "a") as f: + f.write(output + "\n") + print(f"✓ Session appended to {append_file}", file=sys.stderr) else: print(output) os._exit(0) except Exception as error: - print(f'[!] Error: {error}', file=sys.stderr) + print(f"[!] Error: {error}", file=sys.stderr) sys.exit(1) -if __name__ == '__main__': +if __name__ == "__main__": asyncio.run(main()) diff --git a/tools/create_sessions_browser.py b/tools/create_sessions_browser.py new file mode 100644 index 0000000..003eec3 --- /dev/null +++ b/tools/create_sessions_browser.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +""" +Requirements: + pip install -r tools/requirements.txt + +Usage: + python3 tools/create_sessions_browser.py [--append sessions.jsonl] [--headless] [--delay] + +Examples: + # Output to terminal + python3 tools/create_sessions_browser.py + + # Append to sessions.jsonl + python3 tools/create_sessions_browser.py --append sessions.jsonl + + # Add 5 second delay between sessions (default: 1) + python3 tools/create_sessions_browser.py --delay 5 + + # Headless mode (may increase detection risk) + python3 tools/create_sessions_browser.py --headless + +Input (accounts_file): + [{"username": "user", "password": "pass", "totp": "totp_code"}, {...}, ...] + +Output: + {"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."} + {"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."} + ... +""" + +import asyncio +import json +import sys +from time import sleep + +import nodriver as uc +import pyotp + + +async def login_and_get_cookies(account, headless=False): + """Authenticate with X.com and extract session cookies""" + # Note: headless mode may increase detection risk from bot-detection systems + browser = await uc.start(headless=headless) + tab = await browser.get("https://x.com/i/flow/login") + + username = account["username"] + password = account["password"] + totp_seed = account["totp"] + + try: + # Enter username + print(f"[*] Entering username {username}...", file=sys.stderr) + + retry = 0 + while retry < 5: + username_input = await tab.find( + 'input[autocomplete="username"]', timeout=10 + ) + + pos = await username_input.get_position() + await tab.mouse_move(pos.x, pos.y, steps=50, flash=True) + await asyncio.sleep(0.1) + + await username_input.click() + await asyncio.sleep(0.5) + await username_input.send_keys(username) + await asyncio.sleep(0.2) + await username_input.send_keys("\n") + await asyncio.sleep(2) + + page_content = await tab.get_content() + if "Could not log you in" in page_content: + retry += 1 + wait = retry * 10 + print(f"Retrying in {wait} seconds...") + await asyncio.sleep(wait) + else: + break + + # Enter password + print("[*] Entering password...", file=sys.stderr) + pretry = 0 + while pretry < 5: + password_input = await tab.find( + 'input[autocomplete="current-password"]', timeout=15 + ) + await password_input.click() + await asyncio.sleep(0.5) + await password_input.send_keys(password) + await asyncio.sleep(0.2) + await password_input.send_keys("\n") + await asyncio.sleep(2) + + page_content = await tab.get_content() + if "Could not log you in" in page_content: + pretry += 1 + wait = pretry * 10 + print(f"Retrying in {wait} seconds...") + await asyncio.sleep(wait) + else: + break + + # Handle 2FA if needed + page_content = await tab.get_content() + if "verification code" in page_content or "Enter code" in page_content: + if not totp_seed: + raise Exception("2FA required but no TOTP seed provided") + + print("[*] 2FA detected, entering code...", file=sys.stderr) + totp_code = pyotp.TOTP(totp_seed).now() + code_input = await tab.select('input[type="text"]') + await code_input.send_keys(totp_code + "\n") + await asyncio.sleep(3) + + # Get cookies + print("[*] Retrieving cookies...", file=sys.stderr) + for _ in range(20): # 20 second timeout + cookies = await browser.cookies.get_all() + cookies_dict = {cookie.name: cookie.value for cookie in cookies} + + if "auth_token" in cookies_dict and "ct0" in cookies_dict: + # Extract ID from twid cookie (may be URL-encoded) + user_id = None + if "twid" in cookies_dict: + twid = cookies_dict["twid"] + # Try to extract the ID from twid (format: u%3D or u=) + if "u%3D" in twid: + user_id = twid.split("u%3D")[1].split("&")[0].strip('"') + elif "u=" in twid: + user_id = twid.split("u=")[1].split("&")[0].strip('"') + + cookies_dict["username"] = username + if user_id: + cookies_dict["id"] = user_id + + return cookies_dict + + await asyncio.sleep(1) + + raise Exception("Timeout waiting for cookies") + + finally: + browser.stop() + + +async def main(): + if len(sys.argv) < 2: + print( + "Usage: python3 create_sessions_browser.py [--append sessions.jsonl] [--headless]" + ) + sys.exit(1) + + input = sys.argv[1] + append_file = None + headless = False + delay = 1 + + # Parse optional arguments + i = 2 + while i < len(sys.argv): + arg = sys.argv[i] + if arg == "--append": + if i + 1 < len(sys.argv): + append_file = sys.argv[i + 1] + i += 2 # Skip '--append' and filename + else: + print("[!] Error: --append requires a filename", file=sys.stderr) + sys.exit(1) + elif arg == "--headless": + headless = True + i += 1 + elif arg == "--delay": + delay = int(sys.argv[i + 1]) + i += 2 + else: + # Unkown args + print(f"[!] Warning: Unknown argument: {arg}", file=sys.stderr) + i += 1 + + accounts = [] + with open(input) as f: + accounts = json.load(f) + + if len(accounts) == 0: + print("no accounts in file") + sys.exit(0) + + sessions = 0 + for acc in accounts: + sessions += 1 + try: + cookies = await login_and_get_cookies(acc, headless) + session = { + "kind": "cookie", + "username": cookies["username"], + "id": cookies.get("id"), + "auth_token": cookies["auth_token"], + "ct0": cookies["ct0"], + } + + if append_file: + with open(append_file, "a") as f: + f.write(json.dumps(session) + "\n") + else: + print(json.dumps(session)) + + print(f"Progress: {sessions} / {len(accounts)}") + if sessions < len(accounts): + print("Waiting", delay, "seconds") + sleep(delay) + except Exception as error: + print( + f"[!] Error getting session for {acc["username"]}, skipping: {error}", + file=sys.stderr, + ) + + +if __name__ == "__main__": + asyncio.run(main()) From 5d28bd18c631417129a6c6a1ed45e20ef4528269 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 9 Feb 2026 17:32:03 +0100 Subject: [PATCH 035/161] Add preference for configuring sticky navbar Fixes #1354 --- src/nitter.nim | 4 ++-- src/prefs_impl.nim | 3 +++ src/routes/router_utils.nim | 7 +------ src/routes/unsupported.nim | 2 +- src/sass/index.scss | 5 ++++- src/sass/navbar.scss | 5 ++++- src/sass/profile/_base.scss | 6 +++++- src/sass/tweet/thread.scss | 6 ++++-- src/views/general.nim | 3 ++- 9 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/nitter.nim b/src/nitter.nim index e2d6bec..9e20ecb 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -66,10 +66,10 @@ settings: routes: get "/": - resp renderMain(renderSearch(), request, cfg, themePrefs()) + resp renderMain(renderSearch(), request, cfg, cookiePrefs()) get "/about": - resp renderMain(renderAbout(), request, cfg, themePrefs()) + resp renderMain(renderAbout(), request, cfg, cookiePrefs()) get "/explore": redirect("/about") diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index 8e2ac8f..e55c2b8 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -60,6 +60,9 @@ genPrefs: stickyProfile(checkbox, true): "Make profile sidebar stick to top" + stickyNav(checkbox, true): + "Keep navbar fixed to top" + bidiSupport(checkbox, false): "Support bidirectional text (makes clicking on tweets harder)" diff --git a/src/routes/router_utils.nim b/src/routes/router_utils.nim index a071a0d..34fd163 100644 --- a/src/routes/router_utils.nim +++ b/src/routes/router_utils.nim @@ -17,13 +17,8 @@ template cookiePrefs*(): untyped {.dirty.} = template cookiePref*(pref): untyped {.dirty.} = getPref(cookies(request), pref) -template themePrefs*(): Prefs = - var res = defaultPrefs - res.theme = cookiePref(theme) - res - template showError*(error: string; cfg: Config): string = - renderMain(renderError(error), request, cfg, themePrefs(), "Error") + renderMain(renderError(error), request, cfg, cookiePrefs(), "Error") template getPath*(): untyped {.dirty.} = $(parseUri(request.path) ? filterParams(request.params)) diff --git a/src/routes/unsupported.nim b/src/routes/unsupported.nim index 362b36b..e06a183 100644 --- a/src/routes/unsupported.nim +++ b/src/routes/unsupported.nim @@ -10,7 +10,7 @@ export feature proc createUnsupportedRouter*(cfg: Config) = router unsupported: template feature {.dirty.} = - resp renderMain(renderFeature(), request, cfg, themePrefs()) + resp renderMain(renderFeature(), request, cfg, cookiePrefs()) get "/about/feature": feature() get "/login/?@i?": feature() diff --git a/src/sass/index.scss b/src/sass/index.scss index 3f4b123..c12b814 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -115,11 +115,14 @@ ul { display: flex; flex-wrap: wrap; box-sizing: border-box; - padding-top: 50px; margin: auto; min-height: 100vh; } +body.fixed-nav .container { + padding-top: 50px; +} + .icon-container { display: inline; } diff --git a/src/sass/navbar.scss b/src/sass/navbar.scss index 86bfbe7..c999022 100644 --- a/src/sass/navbar.scss +++ b/src/sass/navbar.scss @@ -3,7 +3,6 @@ nav { display: flex; align-items: center; - position: fixed; background-color: var(--bg_overlays); box-shadow: 0 0 4px $shadow; padding: 0; @@ -16,6 +15,10 @@ nav { .icon-button button { color: var(--fg_nav); } + + body.fixed-nav & { + position: fixed; + } } .inner-nav { diff --git a/src/sass/profile/_base.scss b/src/sass/profile/_base.scss index b7f33e6..3abc736 100644 --- a/src/sass/profile/_base.scss +++ b/src/sass/profile/_base.scss @@ -39,7 +39,11 @@ text-align: left; vertical-align: top; max-width: 32%; - top: 50px; + top: 0; + + body.fixed-nav & { + top: 50px; + } } .profile-result { diff --git a/src/sass/tweet/thread.scss b/src/sass/tweet/thread.scss index 9d2fb64..d9bc457 100644 --- a/src/sass/tweet/thread.scss +++ b/src/sass/tweet/thread.scss @@ -16,8 +16,10 @@ .main-tweet, .replies { - padding-top: 50px; - margin-top: -50px; + body.fixed-nav & { + padding-top: 50px; + margin-top: -50px; + } } .main-tweet .tweet-content { diff --git a/src/views/general.nim b/src/views/general.nim index 2525841..3bae9d3 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -131,7 +131,8 @@ proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, rss, twitterLink) - body: + let bodyClass = if prefs.stickyNav: "fixed-nav" else: "" + body(class=bodyClass): renderNavbar(cfg, req, rss, twitterLink) tdiv(class="container"): From db36f75519b2295c184d107f29210141bdd01082 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 9 Feb 2026 20:23:31 +0100 Subject: [PATCH 036/161] Support restoring preferences via new prefs param Fixes #1352 Fixes #553 Fixes #249 --- src/nitter.nim | 5 +++++ src/prefs.nim | 9 +++++++-- src/prefs_impl.nim | 30 ++++++++++++++++++++++++++++++ src/routes/preferences.nim | 4 +++- src/routes/router_utils.nim | 32 +++++++++++++++++++++++++++++--- src/sass/index.scss | 18 ++++++++++++------ src/sass/inputs.scss | 12 ++++++++++++ src/utils.nim | 2 +- src/views/preferences.nim | 9 ++++++++- 9 files changed, 107 insertions(+), 14 deletions(-) diff --git a/src/nitter.nim b/src/nitter.nim index 9e20ecb..442a8c0 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -65,6 +65,11 @@ settings: reusePort = true routes: + before: + # skip all file URLs + cond "." notin request.path + applyUrlPrefs() + get "/": resp renderMain(renderSearch(), request, cfg, cookiePrefs()) diff --git a/src/prefs.nim b/src/prefs.nim index fa40a6d..573ccae 100644 --- a/src/prefs.nim +++ b/src/prefs.nim @@ -1,10 +1,10 @@ # SPDX-License-Identifier: AGPL-3.0-only -import tables +import tables, strutils, base64 import types, prefs_impl from config import get from parsecfg import nil -export genUpdatePrefs, genResetPrefs +export genUpdatePrefs, genResetPrefs, genApplyPrefs var defaultPrefs*: Prefs @@ -20,3 +20,8 @@ template getPref*(cookies: Table[string, string], pref): untyped = var res = defaultPrefs.`pref` genCookiePref(cookies, pref, res) res + +proc encodePrefs*(prefs: Prefs): string = + var encPairs: seq[string] + genEncodePrefs(prefs) + encode(encPairs.join("&"), safe=true) diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index e55c2b8..2faf8ef 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -205,6 +205,36 @@ macro genResetPrefs*(): untyped = result.add quote do: savePref(`name`, "", `req`, expire=true) +macro genEncodePrefs*(prefs): untyped = + result = nnkStmtList.newTree() + for pref in allPrefs(): + let + name = newLit(pref.name) + ident = ident(pref.name) + kind = newLit(pref.kind) + defaultIdent = nnkDotExpr.newTree(ident("defaultPrefs"), ident(pref.name)) + + result.add quote do: + when `kind` == checkbox: + if `prefs`.`ident` != `defaultIdent`: + if `prefs`.`ident`: + encPairs.add `name` & "=on" + else: + encPairs.add `name` & "=" + else: + if `prefs`.`ident` != `defaultIdent`: + encPairs.add `name` & "=" & `prefs`.`ident` + +macro genApplyPrefs*(params, req): untyped = + result = nnkStmtList.newTree() + for pref in allPrefs(): + let name = newLit(pref.name) + result.add quote do: + if `name` in `params`: + savePref(`name`, `params`[`name`], `req`) + else: + savePref(`name`, "", `req`, expire=true) + macro genPrefsType*(): untyped = let name = nnkPostfix.newTree(ident("*"), ident("Prefs")) result = quote do: diff --git a/src/routes/preferences.nim b/src/routes/preferences.nim index b8af03d..345ff34 100644 --- a/src/routes/preferences.nim +++ b/src/routes/preferences.nim @@ -20,7 +20,9 @@ proc createPrefRouter*(cfg: Config) = get "/settings": let prefs = cookiePrefs() - html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir)) + prefsCode = encodePrefs(prefs) + prefsUrl = getUrlPrefix(cfg) & "/?prefs=" & prefsCode + html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir), prefsUrl) resp renderMain(html, request, cfg, prefs, "Preferences") get "/settings/@i?": diff --git a/src/routes/router_utils.nim b/src/routes/router_utils.nim index 34fd163..2ef248a 100644 --- a/src/routes/router_utils.nim +++ b/src/routes/router_utils.nim @@ -1,15 +1,15 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, sequtils, uri, tables, json +import strutils, sequtils, uri, tables, json, base64 from jester import Request, cookies import ../views/general import ".."/[utils, prefs, types] -export utils, prefs, types, uri +export utils, prefs, types, uri, base64 template savePref*(pref, value: string; req: Request; expire=false) = if not expire or pref in cookies(req): setCookie(pref, value, daysForward(when expire: -10 else: 360), - httpOnly=true, secure=cfg.useHttps, sameSite=None) + httpOnly=true, secure=cfg.useHttps, sameSite=None, path="/") template cookiePrefs*(): untyped {.dirty.} = getPrefs(cookies(request)) @@ -38,5 +38,31 @@ template getCursor*(req: Request): string = proc getNames*(name: string): seq[string] = name.strip(chars={'/'}).split(",").filterIt(it.len > 0) +template applyUrlPrefs*() {.dirty.} = + if @"prefs".len > 0: + try: + let decoded = decode(@"prefs") + var params = initTable[string, string]() + for pair in decoded.split('&'): + let kv = pair.split('=', maxsplit=1) + if kv.len == 2: + params[kv[0]] = kv[1] + elif kv.len == 1 and kv[0].len > 0: + params[kv[0]] = "" + genApplyPrefs(params, request) + except: discard + + # Rebuild URL without prefs param + var params: seq[(string, string)] + for k, v in request.params: + if k != "prefs": + params.add (k, v) + + if params.len > 0: + let cleanUrl = request.getNativeReq.url ? params + redirect($cleanUrl) + else: + redirect(request.path) + template respJson*(node: JsonNode) = resp $node, "application/json" diff --git a/src/sass/index.scss b/src/sass/index.scss index c12b814..4ca4f3d 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -99,12 +99,18 @@ legend { margin-bottom: 8px; } -.preferences .note { - border-top: 1px solid var(--border_grey); - border-bottom: 1px solid var(--border_grey); - padding: 6px 0 8px 0; - margin-bottom: 8px; - margin-top: 16px; +.preferences { + .note { + border-top: 1px solid var(--border_grey); + border-bottom: 1px solid var(--border_grey); + padding: 6px 0 8px 0; + margin-bottom: 8px; + margin-top: 16px; + } + + .bookmark-note { + margin: 0; + } } ul { diff --git a/src/sass/inputs.scss b/src/sass/inputs.scss index aafa5b8..7ea2b0a 100644 --- a/src/sass/inputs.scss +++ b/src/sass/inputs.scss @@ -200,4 +200,16 @@ input::-webkit-datetime-edit-year-field:focus { .pref-reset { float: left; } + + .prefs-code { + background-color: var(--bg_elements); + border: 1px solid var(--accent_border); + color: var(--fg_color); + font-size: 12px; + padding: 6px 8px; + margin: 4px 0; + word-break: break-all; + white-space: pre-wrap; + user-select: all; + } } diff --git a/src/utils.nim b/src/utils.nim index c96a6dd..667299c 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -9,7 +9,7 @@ var const https* = "https://" twimg* = "pbs.twimg.com/" - nitterParams = ["name", "tab", "id", "list", "referer", "scroll"] + nitterParams* = ["name", "tab", "id", "list", "referer", "scroll", "prefs"] twitterDomains = @[ "twitter.com", "pic.twitter.com", diff --git a/src/views/preferences.nim b/src/views/preferences.nim index 1787704..40e9e11 100644 --- a/src/views/preferences.nim +++ b/src/views/preferences.nim @@ -32,7 +32,8 @@ macro renderPrefs*(): untyped = result[2].add stmt -proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode = +proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]; + prefsUrl: string): VNode = buildHtml(tdiv(class="overlay-panel")): fieldset(class="preferences"): form(`method`="post", action="/saveprefs", autocomplete="off"): @@ -40,6 +41,12 @@ proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode renderPrefs() + legend: text "Bookmark" + p(class="bookmark-note"): + text "Save this URL to restore your preferences (?prefs works on all pages)" + pre(class="prefs-code"): + text prefsUrl + h4(class="note"): text "Preferences are stored client-side using cookies without any personal information." From b85e8c5d7d7af91a2d86a497265142b6f6a4f490 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 9 Feb 2026 21:54:57 +0100 Subject: [PATCH 037/161] Support preference overrides using URL params Fixes #186 --- src/nitter.nim | 6 +++--- src/prefs.nim | 15 +++++---------- src/prefs_impl.nim | 34 +++++++--------------------------- src/routes/embed.nim | 2 +- src/routes/list.nim | 4 ++-- src/routes/media.nim | 2 +- src/routes/preferences.nim | 2 +- src/routes/resolver.nim | 4 ++-- src/routes/router_utils.nim | 32 +++++++++++++------------------- src/routes/rss.nim | 19 ++++++++++++------- src/routes/search.nim | 2 +- src/routes/status.nim | 2 +- src/routes/timeline.nim | 2 +- src/routes/unsupported.nim | 2 +- src/sass/index.scss | 1 + src/sass/inputs.scss | 2 +- src/views/general.nim | 4 +--- src/views/preferences.nim | 2 ++ src/views/renderutils.nim | 6 +++--- src/views/rss.nimf | 22 +++++++++++----------- 20 files changed, 70 insertions(+), 95 deletions(-) diff --git a/src/nitter.nim b/src/nitter.nim index 442a8c0..ec2decf 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -71,10 +71,10 @@ routes: applyUrlPrefs() get "/": - resp renderMain(renderSearch(), request, cfg, cookiePrefs()) + resp renderMain(renderSearch(), request, cfg, requestPrefs()) get "/about": - resp renderMain(renderAbout(), request, cfg, cookiePrefs()) + resp renderMain(renderAbout(), request, cfg, requestPrefs()) get "/explore": redirect("/about") @@ -85,7 +85,7 @@ routes: get "/i/redirect": let url = decodeUrl(@"url") if url.len == 0: resp Http404 - redirect(replaceUrls(url, cookiePrefs())) + redirect(replaceUrls(url, requestPrefs())) error Http404: resp Http404, showError("Page not found", cfg) diff --git a/src/prefs.nim b/src/prefs.nim index 573ccae..1a75f75 100644 --- a/src/prefs.nim +++ b/src/prefs.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import tables, strutils, base64 +import tables, strutils import types, prefs_impl from config import get from parsecfg import nil @@ -11,17 +11,12 @@ var defaultPrefs*: Prefs proc updateDefaultPrefs*(cfg: parsecfg.Config) = genDefaultPrefs() -proc getPrefs*(cookies: Table[string, string]): Prefs = +proc getPrefs*(cookies, params: Table[string, string]): Prefs = result = defaultPrefs - genCookiePrefs(cookies) - -template getPref*(cookies: Table[string, string], pref): untyped = - bind genCookiePref - var res = defaultPrefs.`pref` - genCookiePref(cookies, pref, res) - res + genParsePrefs(cookies) + genParsePrefs(params) proc encodePrefs*(prefs: Prefs): string = var encPairs: seq[string] genEncodePrefs(prefs) - encode(encPairs.join("&"), safe=true) + encPairs.join(",") diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index 2faf8ef..149eadf 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -130,7 +130,7 @@ macro genDefaultPrefs*(): untyped = result.add quote do: defaultPrefs.`ident` = cfg.get("Preferences", `name`, `default`) -macro genCookiePrefs*(cookies): untyped = +macro genParsePrefs*(prefs): untyped = result = nnkStmtList.newTree() for pref in allPrefs(): let @@ -140,37 +140,17 @@ macro genCookiePrefs*(cookies): untyped = options = pref.options result.add quote do: - if `name` in `cookies`: + if `name` in `prefs`: when `kind` == input or `name` == "theme": - result.`ident` = `cookies`[`name`] + result.`ident` = `prefs`[`name`] elif `kind` == checkbox: - result.`ident` = `cookies`[`name`] == "on" + result.`ident` = `prefs`[`name`] == "on" or + `prefs`[`name`] == "true" or + `prefs`[`name`] == "1" else: - let value = `cookies`[`name`] + let value = `prefs`[`name`] if value in `options`: result.`ident` = value -macro genCookiePref*(cookies, prefName, res): untyped = - result = nnkStmtList.newTree() - for pref in allPrefs(): - let ident = ident(pref.name) - if ident != prefName: - continue - - let - name = pref.name - kind = newLit(pref.kind) - options = pref.options - - result.add quote do: - if `name` in `cookies`: - when `kind` == input or `name` == "theme": - `res` = `cookies`[`name`] - elif `kind` == checkbox: - `res` = `cookies`[`name`] == "on" - else: - let value = `cookies`[`name`] - if value in `options`: `res` = value - macro genUpdatePrefs*(): untyped = result = nnkStmtList.newTree() let req = ident("request") diff --git a/src/routes/embed.nim b/src/routes/embed.nim index 994364b..0527d3d 100644 --- a/src/routes/embed.nim +++ b/src/routes/embed.nim @@ -19,7 +19,7 @@ proc createEmbedRouter*(cfg: Config) = get "/@user/status/@id/embed": let tweet = await getGraphTweetResult(@"id") - prefs = cookiePrefs() + prefs = requestPrefs() path = getPath() if tweet == nil: diff --git a/src/routes/list.nim b/src/routes/list.nim index ac3e97e..7dadc22 100644 --- a/src/routes/list.nim +++ b/src/routes/list.nim @@ -36,7 +36,7 @@ proc createListRouter*(cfg: Config) = get "/i/lists/@id/?": cond '.' notin @"id" let - prefs = cookiePrefs() + prefs = requestPrefs() list = await getCachedList(id=(@"id")) timeline = await getGraphListTweets(list.id, getCursor()) vnode = renderTimelineTweets(timeline, prefs, request.path) @@ -45,7 +45,7 @@ proc createListRouter*(cfg: Config) = get "/i/lists/@id/members": cond '.' notin @"id" let - prefs = cookiePrefs() + prefs = requestPrefs() list = await getCachedList(id=(@"id")) members = await getGraphListMembers(list, getCursor()) respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path)) diff --git a/src/routes/media.nim b/src/routes/media.nim index 011d0f3..b3e5374 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -143,6 +143,6 @@ proc createMediaRouter*(cfg: Config) = if ".m3u8" in url: let vid = await safeFetch(url) - content = proxifyVideo(vid, cookiePref(proxyVideos)) + content = proxifyVideo(vid, requestPrefs().proxyVideos) resp content, m3u8Mime diff --git a/src/routes/preferences.nim b/src/routes/preferences.nim index 345ff34..5886c0e 100644 --- a/src/routes/preferences.nim +++ b/src/routes/preferences.nim @@ -19,7 +19,7 @@ proc createPrefRouter*(cfg: Config) = router preferences: get "/settings": let - prefs = cookiePrefs() + prefs = requestPrefs() prefsCode = encodePrefs(prefs) prefsUrl = getUrlPrefix(cfg) & "/?prefs=" & prefsCode html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir), prefsUrl) diff --git a/src/routes/resolver.nim b/src/routes/resolver.nim index 1baf873..5f074a5 100644 --- a/src/routes/resolver.nim +++ b/src/routes/resolver.nim @@ -18,8 +18,8 @@ proc createResolverRouter*(cfg: Config) = router resolver: get "/cards/@card/@id": let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"] - respResolved(await resolve(url, cookiePrefs()), "card") + respResolved(await resolve(url, requestPrefs()), "card") get "/t.co/@url": let url = "https://t.co/" & @"url" - respResolved(await resolve(url, cookiePrefs()), "t.co") + respResolved(await resolve(url, requestPrefs()), "t.co") diff --git a/src/routes/router_utils.nim b/src/routes/router_utils.nim index 2ef248a..379280c 100644 --- a/src/routes/router_utils.nim +++ b/src/routes/router_utils.nim @@ -1,24 +1,21 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, sequtils, uri, tables, json, base64 +import strutils, sequtils, uri, tables, json from jester import Request, cookies import ../views/general import ".."/[utils, prefs, types] -export utils, prefs, types, uri, base64 +export utils, prefs, types, uri template savePref*(pref, value: string; req: Request; expire=false) = if not expire or pref in cookies(req): setCookie(pref, value, daysForward(when expire: -10 else: 360), httpOnly=true, secure=cfg.useHttps, sameSite=None, path="/") -template cookiePrefs*(): untyped {.dirty.} = - getPrefs(cookies(request)) - -template cookiePref*(pref): untyped {.dirty.} = - getPref(cookies(request), pref) +template requestPrefs*(): untyped {.dirty.} = + getPrefs(cookies(request), params(request)) template showError*(error: string; cfg: Config): string = - renderMain(renderError(error), request, cfg, cookiePrefs(), "Error") + renderMain(renderError(error), request, cfg, requestPrefs(), "Error") template getPath*(): untyped {.dirty.} = $(parseUri(request.path) ? filterParams(request.params)) @@ -40,17 +37,14 @@ proc getNames*(name: string): seq[string] = template applyUrlPrefs*() {.dirty.} = if @"prefs".len > 0: - try: - let decoded = decode(@"prefs") - var params = initTable[string, string]() - for pair in decoded.split('&'): - let kv = pair.split('=', maxsplit=1) - if kv.len == 2: - params[kv[0]] = kv[1] - elif kv.len == 1 and kv[0].len > 0: - params[kv[0]] = "" - genApplyPrefs(params, request) - except: discard + var prefParams = initTable[string, string]() + for pair in @"prefs".split(','): + let kv = pair.split('=', maxsplit=1) + if kv.len == 2: + prefParams[kv[0]] = kv[1] + elif kv.len == 1 and kv[0].len > 0: + prefParams[kv[0]] = "" + genApplyPrefs(prefParams, request) # Rebuild URL without prefs param var params: seq[(string, string)] diff --git a/src/routes/rss.nim b/src/routes/rss.nim index b0e781d..6902001 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -15,7 +15,7 @@ proc redisKey*(page, name, cursor: string): string = if cursor.len > 0: result &= ":" & cursor -proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.} = +proc timelineRss*(req: Request; cfg: Config; query: Query; prefs: Prefs): Future[Rss] {.async.} = var profile: Profile let name = req.params.getOrDefault("name") @@ -39,7 +39,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async. return Rss(feed: profile.user.username, cursor: "suspended") if profile.user.fullname.len > 0: - let rss = renderTimelineRss(profile, cfg, multi=(names.len > 1)) + let rss = renderTimelineRss(profile, cfg, prefs, multi=(names.len > 1)) return Rss(feed: rss, cursor: profile.tweets.bottom) template respRss*(rss, page) = @@ -64,7 +64,9 @@ proc createRssRouter*(cfg: Config) = if @"q".len > 200: resp Http400, showError("Search input too long.", cfg) - let query = initQuery(params(request)) + let + prefs = requestPrefs() + query = initQuery(params(request)) if query.kind != tweets: resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg) @@ -78,7 +80,7 @@ proc createRssRouter*(cfg: Config) = let tweets = await getGraphTweetSearch(query, cursor) rss.cursor = tweets.bottom - rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg) + rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg, prefs) await cacheRss(key, rss) respRss(rss, "Search") @@ -87,6 +89,7 @@ proc createRssRouter*(cfg: Config) = cond cfg.enableRss cond '.' notin @"name" let + prefs = requestPrefs() name = @"name" key = redisKey("twitter", name, getCursor()) @@ -94,7 +97,7 @@ proc createRssRouter*(cfg: Config) = if rss.cursor.len > 0: respRss(rss, "User") - rss = await timelineRss(request, cfg, Query(fromUser: @[name])) + rss = await timelineRss(request, cfg, Query(fromUser: @[name]), prefs) await cacheRss(key, rss) respRss(rss, "User") @@ -104,6 +107,7 @@ proc createRssRouter*(cfg: Config) = cond '.' notin @"name" cond @"tab" in ["with_replies", "media", "search"] let + prefs = requestPrefs() name = @"name" tab = @"tab" query = @@ -122,7 +126,7 @@ proc createRssRouter*(cfg: Config) = if rss.cursor.len > 0: respRss(rss, "User") - rss = await timelineRss(request, cfg, query) + rss = await timelineRss(request, cfg, query, prefs) await cacheRss(key, rss) respRss(rss, "User") @@ -147,6 +151,7 @@ proc createRssRouter*(cfg: Config) = get "/i/lists/@id/rss": cond cfg.enableRss let + prefs = requestPrefs() id = @"id" cursor = getCursor() key = redisKey("lists", id, cursor) @@ -159,7 +164,7 @@ proc createRssRouter*(cfg: Config) = list = await getCachedList(id=id) timeline = await getGraphListTweets(list.id, cursor) rss.cursor = timeline.bottom - rss.feed = renderListRss(timeline.content, list, cfg) + rss.feed = renderListRss(timeline.content, list, cfg, prefs) await cacheRss(key, rss) respRss(rss, "List") diff --git a/src/routes/search.nim b/src/routes/search.nim index e9f991d..4220427 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -19,7 +19,7 @@ proc createSearchRouter*(cfg: Config) = resp Http400, showError("Search input too long.", cfg) let - prefs = cookiePrefs() + prefs = requestPrefs() query = initQuery(params(request)) title = "Search" & (if q.len > 0: " (" & q & ")" else: "") diff --git a/src/routes/status.nim b/src/routes/status.nim index 0168dac..838b327 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -21,7 +21,7 @@ proc createStatusRouter*(cfg: Config) = if id.len > 19 or id.any(c => not c.isDigit): resp Http404, showError("Invalid tweet ID", cfg) - let prefs = cookiePrefs() + let prefs = requestPrefs() # used for the infinite scroll feature if @"scroll".len > 0: diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 2ac87bb..d6d8c21 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -117,7 +117,7 @@ proc createTimelineRouter*(cfg: Config) = cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','}) cond @"tab" in ["with_replies", "media", "search", ""] let - prefs = cookiePrefs() + prefs = requestPrefs() after = getCursor() names = getNames(@"name") diff --git a/src/routes/unsupported.nim b/src/routes/unsupported.nim index e06a183..345dee7 100644 --- a/src/routes/unsupported.nim +++ b/src/routes/unsupported.nim @@ -10,7 +10,7 @@ export feature proc createUnsupportedRouter*(cfg: Config) = router unsupported: template feature {.dirty.} = - resp renderMain(renderFeature(), request, cfg, cookiePrefs()) + resp renderMain(renderFeature(), request, cfg, requestPrefs()) get "/about/feature": feature() get "/login/?@i?": feature() diff --git a/src/sass/index.scss b/src/sass/index.scss index 4ca4f3d..a85002e 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -110,6 +110,7 @@ legend { .bookmark-note { margin: 0; + margin-bottom: 10px; } } diff --git a/src/sass/inputs.scss b/src/sass/inputs.scss index 7ea2b0a..c69711a 100644 --- a/src/sass/inputs.scss +++ b/src/sass/inputs.scss @@ -205,7 +205,7 @@ input::-webkit-datetime-edit-year-field:focus { background-color: var(--bg_elements); border: 1px solid var(--accent_border); color: var(--fg_color); - font-size: 12px; + font-size: 13px; padding: 6px 8px; margin: 4px 0; word-break: break-all; diff --git a/src/views/general.nim b/src/views/general.nim index 3bae9d3..74ddcb4 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -39,9 +39,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 = - var theme = prefs.theme.toTheme - if "theme" in req.params: - theme = req.params["theme"].toTheme + let theme = prefs.theme.toTheme let ogType = if video.len > 0: "video" diff --git a/src/views/preferences.nim b/src/views/preferences.nim index 40e9e11..b051a01 100644 --- a/src/views/preferences.nim +++ b/src/views/preferences.nim @@ -46,6 +46,8 @@ proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]; text "Save this URL to restore your preferences (?prefs works on all pages)" pre(class="prefs-code"): text prefsUrl + p(class="bookmark-note"): + verbatim "You can override preferences with query parameters (e.g. ?hlsPlayback=on). These overrides aren't saved to cookies, and links won't retain the parameters. Intended for configuring RSS feeds and other cookieless environments. Hover over a preference to see its name." h4(class="note"): text "Preferences are stored client-side using cookies without any personal information." diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index a5fe3b2..6753c5a 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -65,20 +65,20 @@ proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNod text text proc genCheckbox*(pref, label: string; state: bool): VNode = - buildHtml(label(class="pref-group checkbox-container")): + buildHtml(label(class="pref-group checkbox-container", title=pref)): text label input(name=pref, `type`="checkbox", checked=state) span(class="checkbox") proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode = let p = placeholder - buildHtml(tdiv(class=("pref-group pref-input " & class))): + buildHtml(tdiv(class=("pref-group pref-input " & class), title=pref)): if label.len > 0: label(`for`=pref): text label input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0)) proc genSelect*(pref, label, state: string; options: seq[string]): VNode = - buildHtml(tdiv(class="pref-group pref-input")): + buildHtml(tdiv(class="pref-group pref-input", title=pref)): label(`for`=pref): text label select(name=pref): for opt in options: diff --git a/src/views/rss.nimf b/src/views/rss.nimf index 717ad99..46d7eaf 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -49,10 +49,10 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)} #end if #end proc # -#proc renderRssTweet(tweet: Tweet; cfg: Config): string = +#proc renderRssTweet(tweet: Tweet; cfg: Config; prefs: Prefs): string = #let tweet = tweet.retweet.get(tweet) #let urlPrefix = getUrlPrefix(cfg) -#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix) +#let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix)

${text.replace("\n", "
\n")}

#if tweet.photos.len > 0: # for photo in tweet.photos: @@ -81,7 +81,7 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
${quoteTweet.user.fullname} (@${quoteTweet.user.username})

-${renderRssTweet(quoteTweet, cfg)} +${renderRssTweet(quoteTweet, cfg, prefs)}