16 Commits

Author SHA1 Message Date
Salastil 85a03a575e Regex fixes, nre is not compiling 2026-05-15 17:05:46 -04:00
Salastil a78d4655cd Add cashtag filter option 2026-05-15 16:52:01 -04:00
Zed 74f5ff8acc Fix thread test 2026-04-16 02:46:43 +02:00
Zed 4e38317582 Fix verified type enum parsing error
Fixes #1387
2026-04-16 02:31:52 +02:00
Zed 8114eefa19 Add support for broadcasts
Fixes #303
2026-03-31 07:28:45 +02:00
Zed 7d431781c3 Increase CI reruns 2026-03-30 01:42:34 +02:00
Zed 0c7583432c Increase CI test maxRetries 2026-03-30 01:20:33 +02:00
Zed e7e7050c6e Add support for viewing account info
Fixes #1381
2026-03-30 00:58:02 +02:00
Zed 741060c78b Increase maxRetries in CI conf 2026-03-21 20:28:41 +01:00
Zed 3429667414 Fix null legacy tweet crash
Fixes #1383
2026-03-21 18:59:05 +01:00
Zed fea6f59005 Fix mobile gallery and grid, add size preference
Fixes #1379
2026-03-21 11:29:42 +01:00
Zed b6ccea0c7a Add configurable retry logic
Fixes #1382
2026-03-21 08:30:07 +01:00
Zed b726767df4 Bump css 2026-03-21 06:55:55 +01:00
Zed 33bf2c2397 Support tweet content disclosures (AI and ads)
Fixes #1374
2026-03-21 06:52:51 +01:00
Zed 7ce29bd8f1 Add new media grid and gallery views
Fixes #199
Fixes #1342
2026-03-15 09:31:55 +01:00
Zed 91ff936cb3 Add workaround for broken "until" search filter
Fixes #1372
2026-03-14 04:04:18 +01:00
45 changed files with 1680 additions and 306 deletions
+2 -1
View File
@@ -128,6 +128,7 @@ jobs:
run: | run: |
cp nitter.example.conf nitter.conf cp nitter.example.conf nitter.conf
sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf
sed -i 's/maxRetries = 1/maxRetries = 10/g' nitter.conf
# Run both Nimble tasks concurrently # Run both Nimble tasks concurrently
nim r tools/rendermd.nim & nim r tools/rendermd.nim &
@@ -141,4 +142,4 @@ jobs:
run: | run: |
./nitter & ./nitter &
cd tests cd tests
poetry run pytest -n3 --reruns=3 --rs . poetry run pytest -n3 --reruns=5 --rs .
+2
View File
@@ -34,6 +34,8 @@ proxyAuth = ""
apiProxy = "" # nitter-proxy host, e.g. localhost:7000 apiProxy = "" # nitter-proxy host, e.g. localhost:7000
disableTid = false # enable this if cookie-based auth is failing disableTid = false # enable this if cookie-based auth is failing
maxConcurrentReqs = 2 # max requests at a time per session to avoid race conditions maxConcurrentReqs = 2 # max requests at a time per session to avoid race conditions
maxRetries = 1 # max number of retries on rate limit errors
retryDelayMs = 150 # delay in ms between retries
# Change default preferences here, see src/prefs_impl.nim for a complete list # Change default preferences here, see src/prefs_impl.nim for a complete list
[Preferences] [Preferences]
+11 -6
View File
@@ -1,12 +1,12 @@
@font-face { @font-face {
font-family: "fontello"; font-family: "fontello";
src: url("/fonts/fontello.eot?42791196"); src: url("/fonts/fontello.eot?49059696");
src: src:
url("/fonts/fontello.eot?42791196#iefix") format("embedded-opentype"), url("/fonts/fontello.eot?49059696#iefix") format("embedded-opentype"),
url("/fonts/fontello.woff2?42791196") format("woff2"), url("/fonts/fontello.woff2?49059696") format("woff2"),
url("/fonts/fontello.woff?42791196") format("woff"), url("/fonts/fontello.woff?49059696") format("woff"),
url("/fonts/fontello.ttf?42791196") format("truetype"), url("/fonts/fontello.ttf?49059696") format("truetype"),
url("/fonts/fontello.svg?42791196#fontello") format("svg"); url("/fonts/fontello.svg?49059696#fontello") format("svg");
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
@@ -126,6 +126,11 @@
} }
/* '' */ /* '' */
.icon-attention:before {
content: "\e812";
}
/* '' */
.icon-circle:before { .icon-circle:before {
content: "\f111"; content: "\f111";
} }
Binary file not shown.
+2
View File
@@ -42,6 +42,8 @@
<glyph glyph-name="ok" unicode="&#xe811;" d="M933 534q0-22-16-38l-404-404-76-76q-16-15-38-15t-38 15l-76 76-202 202q-15 16-15 38t15 38l76 76q16 16 38 16t38-16l164-165 366 367q16 16 38 16t38-16l76-76q16-15 16-38z" horiz-adv-x="1000" /> <glyph glyph-name="ok" unicode="&#xe811;" d="M933 534q0-22-16-38l-404-404-76-76q-16-15-38-15t-38 15l-76 76-202 202q-15 16-15 38t15 38l76 76q16 16 38 16t38-16l164-165 366 367q16 16 38 16t38-16l76-76q16-15 16-38z" horiz-adv-x="1000" />
<glyph glyph-name="attention-circled" unicode="&#xe812;" d="M429 779q116 0 215-58t156-156 57-215-57-215-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58z m71-696v106q0 8-5 13t-12 5h-107q-8 0-13-5t-6-13v-106q0-8 6-13t13-6h107q7 0 12 6t5 13z m-1 192l10 346q0 7-6 10-5 5-13 5h-123q-8 0-13-5-6-3-6-10l10-346q0-6 5-10t14-4h103q8 0 13 4t6 10z" horiz-adv-x="857.1" />
<glyph glyph-name="circle" unicode="&#xf111;" d="M857 350q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" /> <glyph glyph-name="circle" unicode="&#xf111;" d="M857 350q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" />
<glyph glyph-name="info" unicode="&#xf128;" d="M393 149v-134q0-9-7-15t-15-7h-134q-9 0-16 7t-7 15v134q0 9 7 16t16 6h134q9 0 15-6t7-16z m176 335q0-30-8-56t-20-43-31-33-32-25-34-19q-23-13-38-37t-15-37q0-10-7-18t-16-9h-134q-8 0-14 11t-6 20v26q0 46 37 87t79 60q33 16 47 32t14 42q0 24-26 41t-60 18q-36 0-60-16-20-14-60-64-7-9-17-9-7 0-14 4l-91 70q-8 6-9 14t3 16q89 148 259 148 45 0 90-17t81-46 59-72 23-88z" horiz-adv-x="571.4" /> <glyph glyph-name="info" unicode="&#xf128;" d="M393 149v-134q0-9-7-15t-15-7h-134q-9 0-16 7t-7 15v134q0 9 7 16t16 6h134q9 0 15-6t7-16z m176 335q0-30-8-56t-20-43-31-33-32-25-34-19q-23-13-38-37t-15-37q0-10-7-18t-16-9h-134q-8 0-14 11t-6 20v26q0 46 37 87t79 60q33 16 47 32t14 42q0 24-26 41t-60 18q-36 0-60-16-20-14-60-64-7-9-17-9-7 0-14 4l-91 70q-8 6-9 14t3 16q89 148 259 148 45 0 90-17t81-46 59-72 23-88z" horiz-adv-x="571.4" />

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -1
View File
@@ -3,6 +3,7 @@
function playVideo(overlay) { function playVideo(overlay) {
const video = overlay.parentElement.querySelector('video'); const video = overlay.parentElement.querySelector('video');
const url = video.getAttribute("data-url"); const url = video.getAttribute("data-url");
const startTime = parseFloat(video.getAttribute("data-start") || "0");
video.setAttribute("controls", ""); video.setAttribute("controls", "");
overlay.style.display = "none"; overlay.style.display = "none";
@@ -12,12 +13,13 @@ function playVideo(overlay) {
hls.attachMedia(video); hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function () { hls.on(Hls.Events.MANIFEST_PARSED, function () {
hls.loadLevel = hls.levels.length - 1; hls.loadLevel = hls.levels.length - 1;
hls.startLoad(); hls.startLoad(startTime);
video.play(); video.play();
}); });
} else if (video.canPlayType('application/vnd.apple.mpegurl')) { } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url; video.src = url;
video.addEventListener('canplay', function() { video.addEventListener('canplay', function() {
if (startTime > 0) video.currentTime = startTime;
video.play(); video.play();
}); });
} }
+199 -56
View File
@@ -1,5 +1,6 @@
// @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0 // @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
function insertBeforeLast(node, elem) { function insertBeforeLast(node, elem) {
node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]); node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]);
} }
@@ -8,75 +9,217 @@ function getLoadMore(doc) {
return doc.querySelector(".show-more:not(.timeline-item)"); return doc.querySelector(".show-more:not(.timeline-item)");
} }
function isDuplicate(item, itemClass) { function getHrefs(selector) {
const tweet = item.querySelector(".tweet-link"); return new Set([...document.querySelectorAll(selector)].map(el => el.getAttribute("href")));
if (tweet == null) return false;
const href = tweet.getAttribute("href");
return (
document.querySelector(itemClass + " .tweet-link[href='" + href + "']") !=
null
);
} }
window.onload = function () { function getTweetId(item) {
const url = window.location.pathname; const m = item.querySelector(".tweet-link")?.getAttribute("href")?.match(/\/status\/(\d+)/);
const isTweet = url.indexOf("/status/") !== -1; return m ? m[1] : "";
const containerClass = isTweet ? ".replies" : ".timeline"; }
const itemClass = containerClass + " > div:not(.top-ref)";
var html = document.querySelector("html"); function isDuplicate(item, hrefs) {
var container = document.querySelector(containerClass); return hrefs.has(item.querySelector(".tweet-link")?.getAttribute("href"));
var loading = false; }
function handleScroll(failed) { const GAP = 10;
if (loading) return;
if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) { class Masonry {
loading = true; constructor(container) {
var loadMore = getLoadMore(document); this.container = container;
if (loadMore == null) return; const colSizes = {
small: w => Math.max(130, w * 0.11),
medium: w => Math.max(190, Math.min(350, w * 0.22)),
large: w => Math.max(350, Math.min(480, w * 0.22)),
};
const size = container.dataset.colSize || "medium";
this._targetWidth = colSizes[size] || colSizes.medium;
this.colHeights = [];
this.colCounts = [];
this.colCount = 0;
this._lastWidth = 0;
this._colWidthCache = 0;
this._items = [];
this._revealTimer = null;
this.container.classList.add("masonry-active");
loadMore.children[0].text = "Loading..."; let resizeTimer;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => this._rebuild(), 50);
});
var url = new URL(loadMore.children[0].href); // Re-sync positions whenever images finish loading and items grow taller.
url.searchParams.append("scroll", "true"); // Must be set up before _rebuild() so initial items get observed on first pass.
let syncTimer;
this._observer = window.ResizeObserver ? new ResizeObserver(() => {
clearTimeout(syncTimer);
syncTimer = setTimeout(() => this.syncHeights(), 100);
}) : null;
fetch(url.toString()) this._rebuild();
.then(function (response) { }
if (response.status > 299) throw "error";
return response.text();
})
.then(function (html) {
var parser = new DOMParser();
var doc = parser.parseFromString(html, "text/html");
loadMore.remove();
for (var item of doc.querySelectorAll(itemClass)) { // Reveal all items and gallery siblings (show-more, top-ref). Idempotent.
if (item.className == "timeline-item show-more") continue; _revealAll() {
if (isDuplicate(item, itemClass)) continue; clearTimeout(this._revealTimer);
if (isTweet) container.appendChild(item); for (const item of this._items) item.classList.add("masonry-visible");
else insertBeforeLast(container, item); for (const el of this.container.parentElement.querySelectorAll(":scope > .show-more, :scope > .top-ref, :scope > .timeline-footer"))
} el.classList.add("masonry-visible");
}
loading = false; // Height-primary, count-as-tiebreaker: handles both tall tweets and unloaded images.
const newLoadMore = getLoadMore(doc); _pickCol() {
if (newLoadMore == null) return; return this.colHeights.reduce((min, h, i) => {
if (isTweet) container.appendChild(newLoadMore); const m = this.colHeights[min];
else insertBeforeLast(container, newLoadMore); return (h < m || (h === m && this.colCounts[i] < this.colCounts[min])) ? i : min;
}) }, 0);
.catch(function (err) { }
console.warn("Something went wrong.", err);
if (failed > 3) {
loadMore.children[0].text = "Error";
return;
}
loading = false; // Position items using current column state. Updates colHeights, colCounts, container height.
handleScroll((failed || 0) + 1); _position(items, heights, colWidth) {
}); for (let i = 0; i < items.length; i++) {
const col = this._pickCol();
items[i].style.left = `${col * (colWidth + GAP)}px`;
items[i].style.top = `${this.colHeights[col]}px`;
this.colHeights[col] += heights[i] + GAP;
this.colCounts[col]++;
}
this.container.style.height = `${Math.max(0, ...this.colHeights)}px`;
}
// Full reset and re-place all items.
_place(items, heights, n, colWidth) {
this.colHeights = new Array(n).fill(0);
this.colCounts = new Array(n).fill(0);
this.colCount = n;
this._position(items, heights, colWidth);
}
_rebuild() {
const w = this.container.clientWidth;
const n = Math.max(1, Math.floor(w / this._targetWidth(w)));
if (n === this.colCount && w === this._lastWidth) return;
const isFirst = this.colCount === 0;
if (isFirst) {
this._items = [...this.container.querySelectorAll(".timeline-item")];
}
// Sort newest-first by tweet ID (snowflake IDs exceed Number precision, compare as strings).
this._items.sort((a, b) => {
const idA = getTweetId(a), idB = getTweetId(b);
if (idA.length !== idB.length) return idB.length - idA.length;
return idB < idA ? -1 : idB > idA ? 1 : 0;
});
// Pre-set widths BEFORE reading heights so measurements reflect the new column width.
const colWidth = this._colWidthCache = Math.floor((w - GAP * (n - 1)) / n);
for (const item of this._items) item.style.width = `${colWidth}px`;
this._place(this._items, this._items.map(item => item.offsetHeight), n, colWidth);
this._lastWidth = w;
if (isFirst) {
if (this._observer) this._items.forEach(item => this._observer.observe(item));
// Reveal immediately if all images are cached, else wait for syncHeights.
const hasUnloaded = this._items.some(item =>
[...item.querySelectorAll("img")].some(img => !img.complete));
if (hasUnloaded) {
this._revealTimer = setTimeout(() => this._revealAll(), 1000);
} else {
this._revealAll();
}
} }
} }
// Re-read actual heights and re-place all items. Fixes drift after images load.
syncHeights() {
this._place(this._items, this._items.map(item => item.offsetHeight), this.colCount, this._colWidthCache);
this._revealAll();
}
// Batch-add items in three phases to avoid O(N) reflows:
// 1. writes: set widths, append all — no reads, no reflows
// 2. one read: batch offsetHeight
// 3. writes: assign columns, set left/top
addAll(newItems) {
if (!newItems.length) return;
const colWidth = this._colWidthCache;
for (const item of newItems) {
item.style.width = `${colWidth}px`;
this.container.appendChild(item);
}
this._position(newItems, newItems.map(item => item.offsetHeight), colWidth);
this._items.push(...newItems);
if (this._observer) newItems.forEach(item => this._observer.observe(item));
}
}
document.addEventListener("DOMContentLoaded", function () {
const isTweet = location.pathname.includes("/status/");
const containerClass = isTweet ? ".replies" : ".timeline";
const itemClass = containerClass + " > div:not(.top-ref)";
const html = document.documentElement;
const container = document.querySelector(containerClass);
const masonryEl = container?.querySelector(".gallery-masonry");
const masonry = masonryEl ? new Masonry(masonryEl) : null;
let loading = false;
function handleScroll(failed) {
if (loading || html.scrollTop + html.clientHeight < html.scrollHeight - 3000) return;
const loadMore = getLoadMore(document);
if (!loadMore) return;
loading = true;
loadMore.children[0].text = "Loading...";
const url = new URL(loadMore.children[0].href);
url.searchParams.append("scroll", "true");
fetch(url)
.then(r => {
if (r.status > 299) throw new Error("error");
return r.text();
})
.then(responseText => {
const doc = new DOMParser().parseFromString(responseText, "text/html");
loadMore.remove();
if (masonry) {
masonry.syncHeights();
const newMasonry = doc.querySelector(".gallery-masonry");
if (newMasonry) {
const knownHrefs = getHrefs(".gallery-masonry .tweet-link");
masonry.addAll([...newMasonry.querySelectorAll(".timeline-item")].filter(item => !isDuplicate(item, knownHrefs)));
}
} else {
const knownHrefs = getHrefs(`${itemClass} .tweet-link`);
for (const item of doc.querySelectorAll(itemClass)) {
if (item.className === "timeline-item show-more" || isDuplicate(item, knownHrefs)) continue;
isTweet ? container.appendChild(item) : insertBeforeLast(container, item);
}
}
loading = false;
const newLoadMore = getLoadMore(doc);
if (newLoadMore) {
isTweet ? container.appendChild(newLoadMore) : insertBeforeLast(container, newLoadMore);
if (masonry) newLoadMore.classList.add("masonry-visible");
}
})
.catch(err => {
console.warn("Something went wrong.", err);
if (failed > 3) { loadMore.children[0].text = "Error"; return; }
loading = false;
handleScroll((failed || 0) + 1);
});
}
window.addEventListener("scroll", () => handleScroll()); window.addEventListener("scroll", () => handleScroll());
}; });
// @license-end // @license-end
+44 -13
View File
@@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, httpclient, strutils, sequtils, sugar import asyncdispatch, httpclient, strutils, sequtils, sugar
import packedjson import packedjson
import types, query, formatters, consts, apiutils, parser import types, query, formatters, consts, apiutils, parser, utils
import experimental/parser as newParser import experimental/parser as newParser
# Helper to generate params object for GraphQL requests # Helper to generate params object for GraphQL requests
@@ -18,16 +18,16 @@ proc apiReq(endpoint, variables: string; fieldToggles = ""): ApiReq =
let url = apiUrl(endpoint, variables, fieldToggles) let url = apiUrl(endpoint, variables, fieldToggles)
return ApiReq(cookie: url, oauth: url) return ApiReq(cookie: url, oauth: url)
proc mediaUrl(id: string; cursor: string): ApiReq = proc mediaUrl(id, cursor: string; count=20): ApiReq =
result = ApiReq( result = ApiReq(
cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor]), cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor, $count]),
oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor]) oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor, $count])
) )
proc userTweetsUrl(id: string; cursor: string): ApiReq = proc userTweetsUrl(id: string; cursor: string): ApiReq =
result = ApiReq( result = ApiReq(
# cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles), # cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles),
oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor]) oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor, "20"])
) )
# might change this in the future pending testing # might change this in the future pending testing
result.cookie = result.oauth result.cookie = result.oauth
@@ -36,7 +36,7 @@ proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
let cookieVars = userTweetsAndRepliesVars % [id, cursor] let cookieVars = userTweetsAndRepliesVars % [id, cursor]
result = ApiReq( result = ApiReq(
cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles), cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles),
oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor]) oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"])
) )
proc tweetDetailUrl(id: string; cursor: string): ApiReq = proc tweetDetailUrl(id: string; cursor: string): ApiReq =
@@ -66,6 +66,32 @@ proc getGraphUserById*(id: string): Future[User] {.async.} =
js = await fetchRaw(url) js = await fetchRaw(url)
result = parseGraphUser(js) result = parseGraphUser(js)
proc getAboutAccount*(username: string): Future[AccountInfo] {.async.} =
if username.len == 0: return
let
url = apiReq(graphAboutAccount, """{"screenName":"$1"}""" % username)
js = await fetch(url)
result = parseAboutAccount(js)
proc restReq(endpoint: string; params: seq[(string, string)] = @[]): ApiReq =
let url = ApiUrl(endpoint: endpoint, params: params)
ApiReq(cookie: url, oauth: url)
proc getBroadcastInfo*(id: string): Future[Broadcast] {.async.} =
if id.len == 0: return
let
req = apiReq(graphBroadcast, """{"id":"$1"}""" % id)
js = await fetch(req)
result = parseBroadcastInfo(js)
proc fetchBroadcastStream*(mediaKey: string): Future[string] {.async.} =
if mediaKey.len == 0: return
let
streamReq = restReq(restLiveStream & mediaKey)
streamJs = await fetch(streamReq)
result = streamJs{"source", "noRedirectPlaybackUrl"}.getStr(
streamJs{"source", "location"}.getStr)
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} = proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
@@ -73,7 +99,7 @@ proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profi
url = case kind url = case kind
of TimelineKind.tweets: userTweetsUrl(id, cursor) of TimelineKind.tweets: userTweetsUrl(id, cursor)
of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor) of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
of TimelineKind.media: mediaUrl(id, cursor) of TimelineKind.media: mediaUrl(id, cursor, 100)
js = await fetch(url) js = await fetch(url)
result = parseGraphTimeline(js, after) result = parseGraphTimeline(js, after)
@@ -81,7 +107,7 @@ proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
url = apiReq(graphListTweets, restIdVars % [id, cursor]) url = apiReq(graphListTweets, restIdVars % [id, cursor, "20"])
js = await fetch(url) js = await fetch(url)
result = parseGraphTimeline(js, after).tweets result = parseGraphTimeline(js, after).tweets
@@ -146,7 +172,12 @@ proc getGraphEditHistory*(id: string): Future[EditHistory] {.async.} =
result = parseGraphEditHistory(js, id) result = parseGraphEditHistory(js, id)
proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
let q = genQueryParam(query) # workaround for #1372
let maxId =
if not after.startsWith("maxid:"): ""
else: validateNumber(after[6..^1])
let q = genQueryParam(query, maxId)
if q.len == 0 or q == emptyQuery: if q.len == 0 or q == emptyQuery:
return Timeline(query: query, beginning: true) return Timeline(query: query, beginning: true)
@@ -160,9 +191,9 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
"withReactionsMetadata": false, "withReactionsMetadata": false,
"withReactionsPerspective": false "withReactionsPerspective": false
} }
if after.len > 0: if after.len > 0 and maxId.len == 0:
variables["cursor"] = % after variables["cursor"] = % after
let let
url = apiReq(graphSearchTimeline, $variables) url = apiReq(graphSearchTimeline, $variables)
js = await fetch(url) js = await fetch(url)
result = parseGraphSearch[Tweets](js, after) result = parseGraphSearch[Tweets](js, after)
@@ -170,7 +201,7 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
# when no more items are available the API just returns the last page in # when no more items are available the API just returns the last page in
# full. this detects that and clears the page instead. # full. this detects that and clears the page instead.
if after.len > 0 and result.bottom.len > 0 and if after.len > 0 and result.bottom.len > 0 and maxId.len == 0 and
after[0..<64] == result.bottom[0..<64]: after[0..<64] == result.bottom[0..<64]:
result.content.setLen(0) result.content.setLen(0)
@@ -200,7 +231,7 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.}
proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} = proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
if id.len == 0: return if id.len == 0: return
let js = await fetch(mediaUrl(id, "")) let js = await fetch(mediaUrl(id, "", 30))
result = parseGraphPhotoRail(js) result = parseGraphPhotoRail(js)
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
+32 -15
View File
@@ -10,14 +10,22 @@ const
rlLimit = "x-rate-limit-limit" rlLimit = "x-rate-limit-limit"
errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest} errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest}
var var
pool: HttpPool pool: HttpPool
disableTid: bool disableTid: bool
apiProxy: string apiProxy: string
maxRetries: int
retryDelayMs: int
proc setDisableTid*(disable: bool) = proc setDisableTid*(disable: bool) =
disableTid = disable disableTid = disable
proc setMaxRetries*(n: int) =
maxRetries = n
proc setRetryDelayMs*(ms: int) =
retryDelayMs = ms
proc setApiProxy*(url: string) = proc setApiProxy*(url: string) =
apiProxy = "" apiProxy = ""
if url.len > 0: if url.len > 0:
@@ -26,13 +34,14 @@ proc setApiProxy*(url: string) =
apiProxy = "http://" & apiProxy apiProxy = "http://" & apiProxy
proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri = proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri =
case sessionKind let url = case sessionKind
of oauth: of oauth: req.oauth
let o = req.oauth of cookie: req.cookie
parseUri("https://api.x.com/graphql") / o.endpoint ? o.params let base = case sessionKind
of cookie: of oauth: "https://api.x.com"
let c = req.cookie of cookie: "https://x.com/i/api"
parseUri("https://x.com/i/api/graphql") / c.endpoint ? c.params let prefix = if url.endpoint.startsWith("1.1/"): "" else: "graphql/"
parseUri(base) / (prefix & url.endpoint) ? url.params
proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
let let
@@ -81,7 +90,7 @@ proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} =
result["sec-fetch-dest"] = "empty" result["sec-fetch-dest"] = "empty"
result["sec-fetch-mode"] = "cors" result["sec-fetch-mode"] = "cors"
result["sec-fetch-site"] = "same-site" result["sec-fetch-site"] = "same-site"
if disableTid: if disableTid or "/1.1/" in url.path:
result["authorization"] = bearerToken2 result["authorization"] = bearerToken2
else: else:
result["authorization"] = bearerToken result["authorization"] = bearerToken
@@ -108,7 +117,7 @@ template fetchImpl(result, fetchBody) {.dirty.} =
pool.use(await genHeaders(session, url)): pool.use(await genHeaders(session, url)):
template getContent = template getContent =
# TODO: this is a temporary simple implementation # TODO: this is a temporary simple implementation
if apiProxy.len > 0: if apiProxy.len > 0 and "/1.1/" notin url.path:
resp = await c.get(($url).replace("https://", apiProxy)) resp = await c.get(($url).replace("https://", apiProxy))
else: else:
resp = await c.get($url) resp = await c.get($url)
@@ -120,6 +129,10 @@ template fetchImpl(result, fetchBody) {.dirty.} =
badClient = true badClient = true
raise newException(BadClientError, "Bad client") raise newException(BadClientError, "Bad client")
if resp.status == $Http404 and result.len == 0:
echo "[sessions] transient 404 (empty body), retrying: ", url.path
raise rateLimitError()
if resp.headers.hasKey(rlRemaining): if resp.headers.hasKey(rlRemaining):
let let
remaining = parseInt(resp.headers[rlRemaining]) remaining = parseInt(resp.headers[rlRemaining])
@@ -165,11 +178,15 @@ template fetchImpl(result, fetchBody) {.dirty.} =
release(session) release(session)
template retry(bod) = template retry(bod) =
try: for i in 0 ..< maxRetries:
bod try:
except RateLimitError: bod
echo "[sessions] Rate limited, retrying ", req.cookie.endpoint, " request..." break
bod except RateLimitError:
echo "[sessions] Rate limited, retrying ", req.cookie.endpoint,
" request (", i, "/", maxRetries, ")..."
if retryDelayMs > 0:
await sleepAsync(retryDelayMs)
proc fetch*(req: ApiReq): Future[JsonNode] {.async.} = proc fetch*(req: ApiReq): Future[JsonNode] {.async.} =
retry: retry:
+3 -1
View File
@@ -49,7 +49,9 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
proxyAuth: cfg.get("Config", "proxyAuth", ""), proxyAuth: cfg.get("Config", "proxyAuth", ""),
apiProxy: cfg.get("Config", "apiProxy", ""), apiProxy: cfg.get("Config", "apiProxy", ""),
disableTid: cfg.get("Config", "disableTid", false), disableTid: cfg.get("Config", "disableTid", false),
maxConcurrentReqs: cfg.get("Config", "maxConcurrentReqs", 2) maxConcurrentReqs: cfg.get("Config", "maxConcurrentReqs", 2),
maxRetries: cfg.get("Config", "maxRetries", 1),
retryDelayMs: cfg.get("Config", "retryDelayMs", 150)
) )
return (conf, cfg) return (conf, cfg)
+7 -3
View File
@@ -16,7 +16,7 @@ const
graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies" graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
graphUserMedia* = "36oKqyQ7E_9CmtONGjJRsA/UserMedia" graphUserMedia* = "36oKqyQ7E_9CmtONGjJRsA/UserMedia"
graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2" graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2"
graphTweet* = "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline" graphTweet* = "b4pV7sWOe97RncwHcGESUA/ConversationTimeline"
graphTweetDetail* = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail" graphTweetDetail* = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail"
graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery" graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery"
graphTweetEditHistory* = "upS9teTSG45aljmP9oTuXA/TweetEditHistory" graphTweetEditHistory* = "upS9teTSG45aljmP9oTuXA/TweetEditHistory"
@@ -25,6 +25,10 @@ const
graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug" graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers" graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline" graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
graphAboutAccount* = "zs_jFPFT78rBpXv9Z3U2YQ/AboutAccountQuery"
graphBroadcast* = "0nMmbMh-_JwwRRFNXkyH3Q/BroadcastQuery"
restLiveStream* = "1.1/live_video_stream/status/"
gqlFeatures* = """{ gqlFeatures* = """{
"android_ad_formats_media_component_render_overlay_enabled": false, "android_ad_formats_media_component_render_overlay_enabled": false,
@@ -138,12 +142,12 @@ const
restIdVars* = """{ restIdVars* = """{
"rest_id": "$1", $2 "rest_id": "$1", $2
"count": 20 "count": $3
}""" }"""
userMediaVars* = """{ userMediaVars* = """{
"userId": "$1", $2 "userId": "$1", $2
"count": 20, "count": $3,
"includePromotedContent": false, "includePromotedContent": false,
"withClientEventToken": false, "withClientEventToken": false,
"withBirdwatchNotes": false, "withBirdwatchNotes": false,
+3 -3
View File
@@ -1,4 +1,4 @@
import std/[algorithm, unicode, re, strutils, strformat, options, nre] import std/[algorithm, unicode, re, strutils, strformat, options]
import jsony import jsony
import utils, slices import utils, slices
import ../types/user as userType import ../types/user as userType
@@ -8,7 +8,7 @@ let
unRegex = re.re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})" unRegex = re.re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})"
unReplace = "$1<a href=\"/$2\">@$2</a>" unReplace = "$1<a href=\"/$2\">@$2</a>"
htRegex = nre.re"""(*U)(^|[^\w-_.?])([#$])([\w_]*+)(?!</a>|">|#)""" htRegex = re.re"(^|[^a-zA-Z0-9_-_.?])([#$])([a-zA-Z0-9_]+)"
htReplace = "$1<a href=\"/search?f=tweets&q=%23$3\">$2$3</a>" htReplace = "$1<a href=\"/search?f=tweets&q=%23$3\">$2$3</a>"
proc expandUserEntities(user: var User; raw: RawUser) = proc expandUserEntities(user: var User; raw: RawUser) =
@@ -29,7 +29,7 @@ proc expandUserEntities(user: var User; raw: RawUser) =
user.bio = orig.replacedWith(replacements, 0 .. orig.len) user.bio = orig.replacedWith(replacements, 0 .. orig.len)
.replacef(unRegex, unReplace) .replacef(unRegex, unReplace)
.replace(htRegex, htReplace) .replacef(htRegex, htReplace)
proc getBanner(user: RawUser): string = proc getBanner(user: RawUser): string =
if user.profileBannerUrl.len > 0: if user.profileBannerUrl.len > 0:
+25 -9
View File
@@ -91,7 +91,17 @@ proc getM3u8Url*(content: string): string =
if re.find(content, m3u8Regex, matches) != -1: if re.find(content, m3u8Regex, matches) != -1:
result = matches[0] result = matches[0]
proc proxifyVideo*(manifest: string; proxy: bool): string = proc proxifyVideo*(manifest: string; proxy: bool; manifestUrl = ""): string =
let (baseUrl, basePath) =
if manifestUrl.len > 0:
let
u = parseUri(manifestUrl)
origin = u.scheme & "://" & u.hostname
idx = manifestUrl.rfind('/')
dirPath = if idx > 8: manifestUrl[0 .. idx] else: ""
(origin, dirPath)
else:
("https://video.twimg.com", "")
var replacements: seq[(string, string)] var replacements: seq[(string, string)]
for line in manifest.splitLines: for line in manifest.splitLines:
let url = let url =
@@ -99,9 +109,13 @@ proc proxifyVideo*(manifest: string; proxy: bool): string =
elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line: elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line:
line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))] line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))]
else: line else: line
if url.startsWith('/'): let resolved =
let path = "https://video.twimg.com" & url if url.startsWith('/'): baseUrl & url
replacements.add (url, if proxy: path.getVidUrl else: path) elif basePath.len > 0 and url.len > 0 and not url.startsWith('#') and
not url.startsWith("http") and ('.' in url): basePath & url
else: ""
if resolved.len > 0:
replacements.add (url, if proxy: resolved.getVidUrl else: resolved)
return manifest.multiReplace(replacements) return manifest.multiReplace(replacements)
proc getUserPic*(userPic: string; style=""): string = proc getUserPic*(userPic: string; style=""): string =
@@ -154,16 +168,18 @@ proc getShortTime*(tweet: Tweet): string =
else: else:
result = "now" result = "now"
proc getDuration*(video: Video): string = proc getDuration*(ms: int): string =
let let
ms = video.durationMs
sec = int(round(ms / 1000)) sec = int(round(ms / 1000))
min = floorDiv(sec, 60) min = floorDiv(sec, 60)
hour = floorDiv(min, 60) hour = floorDiv(min, 60)
if hour > 0: if hour > 0:
return &"{hour}:{min mod 60}:{sec mod 60:02}" &"{hour}:{min mod 60:02}:{sec mod 60:02}"
else: else:
return &"{min mod 60}:{sec mod 60:02}" &"{min mod 60}:{sec mod 60:02}"
proc getDuration*(video: Video): string =
getDuration(video.durationMs)
proc getLink*(id: int64; username="i"; focus=true): string = proc getLink*(id: int64; username="i"; focus=true): string =
var username = username var username = username
+5 -1
View File
@@ -10,7 +10,7 @@ import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
import views/[general, about] import views/[general, about]
import routes/[ import routes/[
preferences, timeline, status, media, search, rss, list, debug, preferences, timeline, status, media, search, rss, list, debug,
unsupported, embed, resolver, router_utils] unsupported, embed, resolver, broadcast, router_utils]
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
const issuesUrl = "https://github.com/zedeus/nitter/issues" const issuesUrl = "https://github.com/zedeus/nitter/issues"
@@ -40,6 +40,8 @@ setHttpProxy(cfg.proxy, cfg.proxyAuth)
setApiProxy(cfg.apiProxy) setApiProxy(cfg.apiProxy)
setDisableTid(cfg.disableTid) setDisableTid(cfg.disableTid)
setMaxConcurrentReqs(cfg.maxConcurrentReqs) setMaxConcurrentReqs(cfg.maxConcurrentReqs)
setMaxRetries(cfg.maxRetries)
setRetryDelayMs(cfg.retryDelayMs)
initAboutPage(cfg.staticDir) initAboutPage(cfg.staticDir)
waitFor initRedisPool(cfg) waitFor initRedisPool(cfg)
@@ -56,6 +58,7 @@ createSearchRouter(cfg)
createMediaRouter(cfg) createMediaRouter(cfg)
createEmbedRouter(cfg) createEmbedRouter(cfg)
createRssRouter(cfg) createRssRouter(cfg)
createBroadcastRouter(cfg)
createDebugRouter(cfg) createDebugRouter(cfg)
settings: settings:
@@ -119,5 +122,6 @@ routes:
extend preferences, "" extend preferences, ""
extend resolver, "" extend resolver, ""
extend embed, "" extend embed, ""
extend broadcastRoute, ""
extend debug, "" extend debug, ""
extend unsupported, "" extend unsupported, ""
+121 -10
View File
@@ -6,6 +6,10 @@ import experimental/parser/unifiedcard
proc parseGraphTweet(js: JsonNode): Tweet proc parseGraphTweet(js: JsonNode): Tweet
proc parseVerifiedType(s: string; current: VerifiedType): VerifiedType =
try: parseEnum[VerifiedType](s)
except ValueError: current
proc parseCommunityNote(js: JsonNode): string = proc parseCommunityNote(js: JsonNode): string =
let subtitle = js{"subtitle"} let subtitle = js{"subtitle"}
result = subtitle{"text"}.getStr result = subtitle{"text"}.getStr
@@ -35,7 +39,7 @@ proc parseUser(js: JsonNode; id=""): User =
result.verifiedType = blue result.verifiedType = blue
with verifiedType, js{"verified_type"}: with verifiedType, js{"verified_type"}:
result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr) result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType)
result.expandUserEntities(js) result.expandUserEntities(js)
@@ -61,11 +65,70 @@ proc parseGraphUser(js: JsonNode): User =
result.fullname = user{"core", "name"}.getStr result.fullname = user{"core", "name"}.getStr
result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "") result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "")
if user{"is_blue_verified"}.getBool(false): if user{"is_blue_verified"}.getBool(
user{"verification", "is_blue_verified"}.getBool(false)):
result.verifiedType = blue result.verifiedType = blue
with verifiedType, user{"verification", "verified_type"}: with verifiedType, user{"verification", "verified_type"}:
result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr) result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType)
proc parseAboutAccount*(js: JsonNode): AccountInfo =
if js.isNull: return
let user = ? js{"data", "user_result_by_screen_name", "result"}
if user{"unavailable_reason"}.getStr == "Suspended":
result.suspended = true
return
result = AccountInfo(
username: user{"core", "screen_name"}.getStr,
fullname: user{"core", "name"}.getStr,
joinDate: user{"core", "created_at"}.getTime,
userPic: user{"avatar", "image_url"}.getImageStr.replace("_normal", ""),
affiliateLabel: user{"identity_profile_labels_highlighted_label", "label", "description"}.getStr,
)
if user{"is_blue_verified"}.getBool(false):
result.verifiedType = blue
with verifiedType, user{"verification", "verified_type"}:
result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType)
with about, user{"about_profile"}:
result.basedIn = about{"account_based_in"}.getStr
result.source = about{"source"}.getStr
result.affiliateUsername = about{"affiliate_username"}.getStr
try:
result.usernameChanges = about{"username_changes", "count"}.getStr("0").parseInt
except ValueError:
discard
with lastChange, about{"username_changes", "last_changed_at_msec"}:
result.lastUsernameChange = lastChange.getTimeFromMsStr
with info, user{"verification_info"}:
result.isIdentityVerified = info{"is_identity_verified"}.getBool
with reason, info{"reason"}:
result.overrideVerifiedYear = reason{"override_verified_year"}.getInt
with since, reason{"verified_since_msec"}:
result.verifiedSince = since.getTimeFromMsStr
proc parseBroadcastInfo*(js: JsonNode): Broadcast =
let bc = ? js{"data", "broadcast"}
result = Broadcast(
id: bc{"broadcast_id"}.getStr,
title: bc{"status"}.getStr,
state: bc{"state"}.getStr.toUpperAscii,
thumb: bc{"image_url"}.getStr,
mediaKey: bc{"media_key"}.getStr,
totalWatched: bc{"total_watched"}.getInt,
startTime: bc{"start_time"}.getTimeFromMs,
endTime: bc{"end_time"}.getTimeFromMs,
replayStart: bc{"edited_replay", "start_time"}.getInt,
availableForReplay: bc{"available_for_replay"}.getBool,
user: parseGraphUser(bc)
)
proc parseGraphList*(js: JsonNode): List = proc parseGraphList*(js: JsonNode): List =
if js.isNull: return if js.isNull: return
@@ -206,6 +269,12 @@ proc parseMediaEntities(js: JsonNode; result: var Tweet) =
)) ))
else: discard else: discard
if "expanded_url" in mediaEntity:
let expandedUrl = js.getExpandedUrl
if result.text.endsWith(expandedUrl):
result.text.removeSuffix(expandedUrl)
result.text = result.text.strip()
if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len: if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len:
result.media = parsedMedia result.media = parsedMedia
@@ -238,14 +307,23 @@ proc parsePromoVideo(js: JsonNode): Video =
result.variants.add variant result.variants.add variant
proc parseBroadcast(js: JsonNode): Card = proc parseBroadcast(js: JsonNode): Card =
let image = js{"broadcast_thumbnail_large"}.getImageVal let
image = js{"broadcast_thumbnail_large"}.getImageVal
broadcastUrl = js{"broadcast_url"}.getStrVal
broadcastId = broadcastUrl.rsplit('/', maxsplit=1)[^1]
streamUrl = "/i/broadcasts/" & broadcastId & "/stream"
result = Card( result = Card(
kind: broadcast, kind: broadcast,
url: js{"broadcast_url"}.getStrVal, url: "/i/broadcasts/" & broadcastId,
title: js{"broadcaster_display_name"}.getStrVal, title: js{"broadcaster_display_name"}.getStrVal,
text: js{"broadcast_title"}.getStrVal, text: js{"broadcast_title"}.getStrVal,
image: image, image: image,
video: some Video(thumb: image) video: some Video(
thumb: image,
available: true,
playbackType: m3u8,
variants: @[VideoVariant(contentType: m3u8, url: streamUrl)]
)
) )
proc parseCard(js: JsonNode; urls: JsonNode): Card = proc parseCard(js: JsonNode; urls: JsonNode): Card =
@@ -307,7 +385,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull(); proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
replyId: int64 = 0): Tweet = replyId: int64 = 0): Tweet =
if js.isNull: return if js.isNull: return Tweet()
let time = let time =
if js{"created_at"}.notNull: js{"created_at"}.getTime if js{"created_at"}.notNull: js{"created_at"}.getTime
@@ -409,7 +487,7 @@ proc parseGraphTweet(js: JsonNode): Tweet =
else: else:
discard discard
if not js.hasKey("legacy"): if "legacy" notin js and "rest_id" notin js:
return Tweet() return Tweet()
var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"}) var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"})
@@ -432,8 +510,41 @@ proc parseGraphTweet(js: JsonNode): Tweet =
with restId, js{"reply_to_results", "rest_id"}: with restId, js{"reply_to_results", "rest_id"}:
replyId = restId.getId replyId = restId.getId
result = parseTweet(js{"legacy"}, jsCard, replyId) if "details" in js:
result.id = js{"rest_id"}.getId result = Tweet(
id: js{"rest_id"}.getId,
available: true,
text: js{"details", "full_text"}.getStr,
time: js{"details", "created_at_ms"}.getTimeFromMs,
replyId: js{"reply_to_results", "rest_id"}.getId,
isAd: js{"content_disclosure", "advertising_disclosure", "is_paid_promotion"}.getBool,
isAI: js{"content_disclosure", "ai_generated_disclosure", "has_ai_generated_media"}.getBool,
stats: TweetStats(
replies: js{"counts", "reply_count"}.getInt,
retweets: js{"counts", "retweet_count"}.getInt,
likes: js{"counts", "favorite_count"}.getInt,
)
)
if jsCard.kind != JNull:
let name = jsCard{"name"}.getStr
if "poll" in name:
if "image" in name:
result.media.addMedia(Photo(
url: jsCard{"binding_values", "image_large"}.getImageVal
))
result.poll = some parsePoll(jsCard)
elif name == "amplify":
result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
else:
result.card = some parseCard(jsCard, js{"url_entities"})
result.expandTweetEntitiesV2(js)
else:
result = parseTweet(js{"legacy"}, jsCard, replyId)
result.id = js{"rest_id"}.getId
result.user = parseGraphUser(js{"core"}) result.user = parseGraphUser(js{"core"})
if result.reply.len == 0: if result.reply.len == 0:
+61 -1
View File
@@ -16,7 +16,7 @@ let
unRegex = re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})" unRegex = re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})"
unReplace = "$1<a href=\"/$2\">@$2</a>" unReplace = "$1<a href=\"/$2\">@$2</a>"
htRegex = re"(^|[^\w-_./?])([#$]|)([\w_]+)" htRegex = re"(^|[^a-zA-Z0-9_-_.?])([#$]|)([a-zA-Z0-9_]+)"
htReplace = "$1<a href=\"/search?f=tweets&q=%23$3\">$2$3</a>" htReplace = "$1<a href=\"/search?f=tweets&q=%23$3\">$2$3</a>"
type type
@@ -88,6 +88,14 @@ proc getTimeFromMs*(js: JsonNode): DateTime =
let seconds = ms div 1000 let seconds = ms div 1000
return fromUnix(seconds).utc() return fromUnix(seconds).utc()
proc getTimeFromMsStr*(js: JsonNode): DateTime =
var ms: int64
try: ms = parseBiggestInt(js.getStr("0"))
except ValueError: return
if ms == 0: return
let seconds = ms div 1000
return fromUnix(seconds).utc()
proc getId*(id: string): int64 {.inline.} = proc getId*(id: string): int64 {.inline.} =
let start = id.rfind("-") let start = id.rfind("-")
if start < 0: if start < 0:
@@ -320,6 +328,58 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote or hasJobCard) tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote or hasJobCard)
proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int];
hasRedundantLink=false) =
let hasCard = tweet.card.isSome
var replacements = newSeq[ReplaceSlice]()
with urls, js{"url_entities"}:
for u in urls:
let urlStr = u["url"].getStr
if urlStr.len == 0 or urlStr notin text:
continue
replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink)
if hasCard and u{"url"}.getStr == get(tweet.card).url:
get(tweet.card).url = u.getExpandedUrl
with hashtags, js{"details", "hashtag_entities"}:
for hashtag in hashtags:
replacements.extractHashtags(hashtag)
with cashtags, js{"details", "cashtag_entities"}:
for cashtag in cashtags:
replacements.extractHashtags(cashtag)
with mentions, js{"mention_entities"}:
for mention in mentions:
let
name = mention{"screen_name"}.getStr
slice = mention.extractSlice
idx = tweet.reply.find(name)
if slice.a >= textSlice.a:
replacements.add ReplaceSlice(kind: rkMention, slice: slice,
url: "/" & name, display: mention["name"].getStr)
elif idx == -1 and tweet.replyId != 0:
tweet.reply.add name
replacements.deduplicate
replacements.sort(cmp)
tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false)
proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode) =
let
textRange = js{"details", "display_text_range"}
textSlice = textRange{0}.getInt .. textRange{1}.getInt
hasQuote = "quoted_tweet_results" in js
hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
tweet.expandTextEntitiesV2(js, tweet.text, textSlice, hasQuote or hasJobCard)
proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) = proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
let let
entities = ? js{"entity_set"} entities = ? js{"entity_set"}
+11
View File
@@ -100,6 +100,17 @@ genPrefs:
autoplayGifs(checkbox, true): autoplayGifs(checkbox, true):
"Autoplay gifs" "Autoplay gifs"
compactGallery(checkbox, false):
"Compact media gallery (no profile info or text)"
gallerySize(select, "Medium"):
"Gallery column size"
options: @["Small", "Medium", "Large"]
mediaView(select, "Timeline"):
"Default media view"
options: @["Timeline", "Grid", "Gallery"]
"Link replacements (blank to disable)": "Link replacements (blank to disable)":
replaceTwitter(input, ""): replaceTwitter(input, ""):
"Twitter -> Nitter" "Twitter -> Nitter"
+33 -27
View File
@@ -1,14 +1,14 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, sequtils, tables, uri import strutils, strformat, sequtils, tables, uri
import types import types, utils
const const
validFilters* = @[ validFilters* = @[
"media", "images", "twimg", "videos", "media", "images", "twimg", "videos",
"native_video", "consumer_video", "spaces", "native_video", "consumer_video", "spaces",
"links", "news", "quote", "mentions", "links", "news", "quote", "mentions",
"replies", "retweets", "nativeretweets" "replies", "retweets", "nativeretweets", "cashtags"
] ]
emptyQuery* = "include:nativeretweets" emptyQuery* = "include:nativeretweets"
@@ -17,14 +17,10 @@ template `@`(param: string): untyped =
if param in pms: pms[param] if param in pms: pms[param]
else: "" else: ""
proc validateNumber(value: string): string =
if value.anyIt(not it.isDigit):
return ""
return value
proc initQuery*(pms: Table[string, string]; name=""): Query = proc initQuery*(pms: Table[string, string]; name=""): Query =
result = Query( result = Query(
kind: parseEnum[QueryKind](@"f", tweets), kind: parseEnum[QueryKind](@"f", tweets),
view: @"view",
text: @"q", text: @"q",
filters: validFilters.filterIt("f-" & it in pms), filters: validFilters.filterIt("f-" & it in pms),
excludes: validFilters.filterIt("e-" & it in pms), excludes: validFilters.filterIt("e-" & it in pms),
@@ -50,7 +46,7 @@ proc getReplyQuery*(name: string): Query =
fromUser: @[name] fromUser: @[name]
) )
proc genQueryParam*(query: Query): string = proc genQueryParam*(query: Query; maxId=""): string =
var var
filters: seq[string] filters: seq[string]
param: string param: string
@@ -58,12 +54,15 @@ proc genQueryParam*(query: Query): string =
if query.kind == users: if query.kind == users:
return query.text return query.text
param = "("
for i, user in query.fromUser: for i, user in query.fromUser:
if i == 0:
param = "("
param &= &"from:{user}" param &= &"from:{user}"
if i < query.fromUser.high: if i < query.fromUser.high:
param &= " OR " param &= " OR "
param &= ")" else:
param &= ")"
if query.fromUser.len > 0 and query.kind in {posts, media}: if query.fromUser.len > 0 and query.kind in {posts, media}:
param &= " (filter:self_threads OR -filter:replies)" param &= " (filter:self_threads OR -filter:replies)"
@@ -86,7 +85,7 @@ proc genQueryParam*(query: Query): string =
if query.since.len > 0: if query.since.len > 0:
result &= " since:" & query.since result &= " since:" & query.since
if query.until.len > 0: if query.until.len > 0 and maxId.len == 0:
result &= " until:" & query.until result &= " until:" & query.until
if query.minLikes.len > 0: if query.minLikes.len > 0:
result &= " min_faves:" & query.minLikes result &= " min_faves:" & query.minLikes
@@ -96,25 +95,32 @@ proc genQueryParam*(query: Query): string =
else: else:
result = query.text result = query.text
if result.len > 0 and maxId.len > 0:
result &= " max_id:" & maxId
proc genQueryUrl*(query: Query): string = proc genQueryUrl*(query: Query): string =
if query.kind notin {tweets, users}: return var params: seq[string]
var params = @[&"f={query.kind}"] if query.view.len > 0:
if query.text.len > 0: params.add "view=" & encodeUrl(query.view)
params.add "q=" & encodeUrl(query.text)
for f in query.filters:
params.add &"f-{f}=on"
for e in query.excludes:
params.add &"e-{e}=on"
for i in query.includes.filterIt(it != "nativeretweets"):
params.add &"i-{i}=on"
if query.since.len > 0: if query.kind in {tweets, users}:
params.add "since=" & query.since params.add &"f={query.kind}"
if query.until.len > 0: if query.text.len > 0:
params.add "until=" & query.until params.add "q=" & encodeUrl(query.text)
if query.minLikes.len > 0: for f in query.filters:
params.add "min_faves=" & query.minLikes params.add &"f-{f}=on"
for e in query.excludes:
params.add &"e-{e}=on"
for i in query.includes.filterIt(it != "nativeretweets"):
params.add &"i-{i}=on"
if query.since.len > 0:
params.add "since=" & query.since
if query.until.len > 0:
params.add "until=" & query.until
if query.minLikes.len > 0:
params.add "min_faves=" & query.minLikes
if params.len > 0: if params.len > 0:
result &= params.join("&") result &= params.join("&")
+27
View File
@@ -158,6 +158,33 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
# if not result.isNil: # if not result.isNil:
# await cache(result) # await cache(result)
proc cache*(data: Broadcast) {.async.} =
if data.id.len == 0: return
await setEx("bc:" & data.id, baseCacheTime, compress(toFlatty(data)))
proc getCachedBroadcast*(id: string): Future[Broadcast] {.async.} =
if id.len == 0: return
let cached = await get("bc:" & id)
if cached != redisNil:
cached.deserialize(Broadcast)
else:
result = await getBroadcastInfo(id)
await cache(result)
result.m3u8Url = await fetchBroadcastStream(result.mediaKey)
proc cache*(data: AccountInfo; name: string) {.async.} =
await setEx("ai:" & toLower(name), baseCacheTime * 24, compress(toFlatty(data)))
proc getCachedAccountInfo*(username: string; fetch=true): Future[AccountInfo] {.async.} =
if username.len == 0: return
let name = toLower(username)
let cached = await get("ai:" & name)
if cached != redisNil:
cached.deserialize(AccountInfo)
elif fetch:
result = await getAboutAccount(username)
await cache(result, name)
proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} = proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} =
if id.len == 0: return if id.len == 0: return
let rail = await get("pr2:" & toLower(id)) let rail = await get("pr2:" & toLower(id))
+44
View File
@@ -0,0 +1,44 @@
# SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, strutils
import jester
import router_utils
import ".."/[types, formatters, redis_cache]
import ../views/[general, broadcast]
import media
export broadcast
proc createBroadcastRouter*(cfg: Config) =
router broadcastRoute:
get "/i/broadcasts/@id":
cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
var bc: Broadcast
try:
bc = await getCachedBroadcast(@"id")
except:
discard
if bc.id.len == 0:
resp Http404, showError("Broadcast not found", cfg)
let prefs = requestPrefs()
resp renderMain(renderBroadcast(bc, prefs, request.path), request, cfg, prefs,
bc.title, ogTitle=bc.title)
get "/i/broadcasts/@id/stream":
cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
var bc: Broadcast
try:
bc = await getCachedBroadcast(@"id")
except:
discard
if bc.m3u8Url.len == 0:
resp Http404
let manifest = await safeFetch(bc.m3u8Url)
if manifest.len == 0:
resp Http502
resp proxifyVideo(manifest, requestPrefs().proxyVideos, bc.m3u8Url), m3u8Mime
+9 -11
View File
@@ -86,6 +86,12 @@ proc decoded*(req: jester.Request; index: int): string =
if based: decode(encoded) if based: decode(encoded)
else: decodeUrl(encoded) else: decodeUrl(encoded)
proc normalizeImgUrl*(url: var string) =
if not url.startsWith("http"):
if "twimg.com" notin url:
url.insert(twimg)
url.insert(https)
proc createMediaRouter*(cfg: Config) = proc createMediaRouter*(cfg: Config) =
router media: router media:
get "/pic/?": get "/pic/?":
@@ -94,11 +100,7 @@ proc createMediaRouter*(cfg: Config) =
get re"^\/pic\/orig\/(enc)?\/?(.+)": get re"^\/pic\/orig\/(enc)?\/?(.+)":
var url = decoded(request, 1) var url = decoded(request, 1)
cond "/amplify_video/" notin url cond "/amplify_video/" notin url
normalizeImgUrl(url)
if "twimg.com" notin url:
url.insert(twimg)
if not url.startsWith(https):
url.insert(https)
url.add("?name=orig") url.add("?name=orig")
let uri = parseUri(url) let uri = parseUri(url)
@@ -110,11 +112,7 @@ proc createMediaRouter*(cfg: Config) =
get re"^\/pic\/(enc)?\/?(.+)": get re"^\/pic\/(enc)?\/?(.+)":
var url = decoded(request, 1) var url = decoded(request, 1)
cond "/amplify_video/" notin url cond "/amplify_video/" notin url
normalizeImgUrl(url)
if "twimg.com" notin url:
url.insert(twimg)
if not url.startsWith(https):
url.insert(https)
let uri = parseUri(url) let uri = parseUri(url)
cond isTwitterUrl(uri) == true cond isTwitterUrl(uri) == true
@@ -143,6 +141,6 @@ proc createMediaRouter*(cfg: Config) =
if ".m3u8" in url: if ".m3u8" in url:
let vid = await safeFetch(url) let vid = await safeFetch(url)
content = proxifyVideo(vid, requestPrefs().proxyVideos) content = proxifyVideo(vid, requestPrefs().proxyVideos, url)
resp content, m3u8Mime resp content, m3u8Mime
+32 -8
View File
@@ -4,20 +4,28 @@ import jester, karax/vdom
import router_utils import router_utils
import ".."/[types, redis_cache, formatters, query, api] import ".."/[types, redis_cache, formatters, query, api]
import ../views/[general, profile, timeline, status, search] import ../views/[general, profile, timeline, status, search, about_account]
export vdom export vdom
export uri, sequtils export uri, sequtils
export router_utils export router_utils
export redis_cache, formatters, query, api export redis_cache, formatters, query, api
export profile, timeline, status export profile, timeline, status, about_account
proc getQuery*(request: Request; tab, name: string): Query = proc getQuery*(request: Request; tab, name: string; prefs: Prefs): Query =
let view = request.params.getOrDefault("view")
case tab case tab
of "with_replies": getReplyQuery(name) of "with_replies":
of "media": getMediaQuery(name) result = getReplyQuery(name)
of "search": initQuery(params(request), name=name) of "media":
else: Query(fromUser: @[name]) result = getMediaQuery(name)
result.view =
if view in ["timeline", "grid", "gallery"]: view
else: prefs.mediaView.toLowerAscii
of "search":
result = initQuery(params(request), name=name)
else:
result = Query(fromUser: @[name])
template skipIf[T](cond: bool; default; body: Future[T]): Future[T] = template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
if cond: if cond:
@@ -49,6 +57,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile]
getCachedPhotoRail(userId) getCachedPhotoRail(userId)
user = getCachedUser(name) user = getCachedUser(name)
info = getCachedAccountInfo(name, fetch=false)
result = result =
case query.kind case query.kind
@@ -59,6 +68,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile]
result.user = await user result.user = await user
result.photoRail = await rail result.photoRail = await rail
result.accountInfo = await info
result.tweets.query = query result.tweets.query = query
@@ -111,6 +121,20 @@ proc createTimelineRouter*(cfg: Config) =
resp Http400, showError("Missing screen_name parameter", cfg) resp Http400, showError("Missing screen_name parameter", cfg)
redirect("/" & username) redirect("/" & username)
get "/@name/about/?":
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'})
let
prefs = requestPrefs()
name = @"name"
info = await getCachedAccountInfo(name)
if info.suspended:
resp showError(getSuspended(name), cfg)
if info.username.len == 0:
resp Http404, showError("User \"" & name & "\" not found", cfg)
let aboutHtml = renderAboutAccount(info)
resp renderMain(aboutHtml, request, cfg, prefs,
"About @" & info.username)
get "/@name/?@tab?/?": get "/@name/?@tab?/?":
cond '.' notin @"name" cond '.' notin @"name"
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
@@ -121,7 +145,7 @@ proc createTimelineRouter*(cfg: Config) =
after = getCursor() after = getCursor()
names = getNames(@"name") names = getNames(@"name")
var query = request.getQuery(@"tab", @"name") var query = request.getQuery(@"tab", @"name", prefs)
if names.len != 1: if names.len != 1:
query.fromUser = names query.fromUser = names
+75
View File
@@ -0,0 +1,75 @@
.broadcast-page {
max-width: 800px;
width: 100%;
margin: 20px auto 0;
}
.broadcast-panel {
background-color: var(--bg_panel);
border: 1px solid var(--border_grey);
border-radius: 8px;
overflow: hidden;
}
.broadcast-player {
position: relative;
background: black;
video,
img {
display: block;
width: 100%;
}
}
.broadcast-info {
padding: 14px 16px;
}
.broadcast-title {
font-size: 18px;
font-weight: bold;
margin: 0 0 12px;
}
.broadcast-user-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.broadcast-user {
display: flex;
align-items: center;
gap: 10px;
color: var(--fg_color);
img {
width: 40px;
height: 40px;
border-radius: 50%;
}
}
.broadcast-username {
color: var(--fg_dark);
}
.broadcast-meta {
color: var(--fg_faded);
font-size: 14px;
display: flex;
flex-direction: column;
align-items: flex-end;
flex-shrink: 0;
line-height: 1.5em;
}
.broadcast-live {
background: #e0245e;
color: white;
padding: 1px 6px;
border-radius: 3px;
font-weight: bold;
font-size: 12px;
}
+2 -1
View File
@@ -7,6 +7,7 @@
@import "inputs"; @import "inputs";
@import "timeline"; @import "timeline";
@import "search"; @import "search";
@import "broadcast";
body { body {
// colors // colors
@@ -160,7 +161,7 @@ body.fixed-nav .container {
display: inline-block; display: inline-block;
width: 14px; width: 14px;
height: 14px; height: 14px;
margin-left: 2px; margin-bottom: 2px;
.verified-icon-circle { .verified-icon-circle {
position: absolute; position: absolute;
+1
View File
@@ -179,6 +179,7 @@ input::-webkit-datetime-edit-year-field:focus {
-moz-appearance: none; -moz-appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
min-width: 100px;
} }
input[type="text"], input[type="text"],
+88 -58
View File
@@ -1,87 +1,117 @@
@import '_variables'; @import "_variables";
@import '_mixins'; @import "_mixins";
@import 'card'; @import "card";
@import 'photo-rail'; @import "about-account";
@import "photo-rail";
.profile-tabs { .profile-tabs {
@include panel(auto, 900px); @include panel(auto, 900px);
.timeline-container { .timeline-container {
float: right; float: right;
width: 68% !important; width: 68% !important;
max-width: unset; max-width: unset;
} }
} }
.profile-banner { .profile-banner {
margin-bottom: 4px; margin-bottom: 4px;
background-color: var(--bg_panel); background-color: var(--bg_panel);
a { a {
display: block; display: block;
position: relative; position: relative;
padding: 33.34% 0 0 0; padding: 33.34% 0 0 0;
} }
img { img {
max-width: 100%; max-width: 100%;
position: absolute; position: absolute;
top: 0; top: 0;
} }
} }
.profile-tab { .profile-tab {
padding: 0 4px 0 0; padding: 0 4px 0 0;
box-sizing: border-box; box-sizing: border-box;
display: inline-block; display: inline-block;
font-size: 14px; font-size: 14px;
text-align: left; text-align: left;
vertical-align: top; vertical-align: top;
max-width: 32%; max-width: 32%;
top: 0; top: 0;
body.fixed-nav & { body.fixed-nav & {
top: 50px; top: 50px;
} }
} }
.profile-result { .profile-result {
min-height: 54px; min-height: 54px;
.username { .username {
margin: 0 !important; margin: 0 !important;
} }
.tweet-header { .tweet-header {
margin-bottom: unset; margin-bottom: unset;
} }
} }
@media(max-width: 700px) { .profile-tabs.media-only {
.profile-tabs { max-width: none;
width: 100vw; width: 100%;
max-width: 600px;
.timeline-container { .timeline-container {
width: 100% !important; float: none;
width: 100% !important;
max-width: none;
padding: 0 10px;
box-sizing: border-box;
}
.tab-item wide { .timeline-container > .tab {
flex-grow: 1.4; max-width: 900px;
} margin-left: auto;
} margin-right: auto;
}
}
@media (max-width: 700px) {
.profile-tabs {
width: 100vw;
max-width: 600px;
.timeline-container {
width: 100% !important;
.tab-item wide {
flex-grow: 1.4;
}
} }
}
.profile-tab { .profile-tabs.media-only {
width: 100%; width: 100%;
max-width: unset; max-width: none;
position: initial !important;
padding: 0; .timeline-container {
width: 100vw !important;
padding: 0;
} }
}
.profile-tab {
width: 100%;
max-width: unset;
position: initial !important;
padding: 0;
}
} }
@media (min-height: 900px) { @media (min-height: 900px) {
.profile-tab.sticky { .profile-tab.sticky {
position: sticky; position: sticky;
} }
} }
+71
View File
@@ -0,0 +1,71 @@
@import '_variables';
.about-account {
max-width: 500px;
width: 100%;
margin: 20px auto 0;
align-self: flex-start;
background: var(--bg_panel);
border-radius: 4px;
padding: 12px 20px 20px;
}
.about-account-header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 16px;
padding-bottom: 14px;
border-bottom: 1px solid var(--border_grey);
}
.about-account-avatar img {
width: 72px;
height: 72px;
border-radius: 50%;
margin-bottom: 4px;
}
.about-account-name {
@include breakable;
font-weight: bold;
}
.about-account-body {
display: flex;
flex-direction: column;
gap: 14px;
}
.about-account-at {
font-size: 18px;
font-weight: bold;
}
.about-account-row {
display: flex;
align-items: center;
gap: 10px;
> span:first-child {
color: var(--fg_faded);
flex-shrink: 0;
}
> div {
display: flex;
flex-direction: column;
}
}
.about-account-label {
color: var(--fg_faded);
font-size: 13px;
}
@media(max-width: 700px) {
.about-account {
max-width: none;
margin: 10px;
}
}
+328 -2
View File
@@ -15,7 +15,7 @@
padding: 8px; padding: 8px;
display: block; display: block;
font-weight: bold; font-weight: bold;
margin-bottom: 5px; margin-bottom: 4px;
box-sizing: border-box; box-sizing: border-box;
button { button {
@@ -36,7 +36,7 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
list-style: none; list-style: none;
margin: 0 0 5px 0; margin: 0 0 4px 0;
background-color: var(--bg_panel); background-color: var(--bg_panel);
padding: 0; padding: 0;
} }
@@ -157,3 +157,329 @@
position: relative; position: relative;
background-color: var(--bg_panel); background-color: var(--bg_panel);
} }
.timeline.media-grid-view,
.timeline.media-gallery-view {
> div:not(:first-child) {
border-top: none;
}
.timeline-item::before {
display: none;
}
}
.timeline.media-grid-view,
.timeline.media-gallery-view .gallery-masonry.compact {
.tweet-header,
.replying-to,
.retweet-header,
.pinned,
.tweet-stats,
.attribution,
.poll,
.quote,
.community-note,
.media-tag-block,
.tweet-content,
.card-content {
display: none;
}
.card {
margin: unset;
.card-container {
border: unset;
border-radius: unset;
.card-image-container {
width: 100%;
min-height: 100%;
}
.card-content-container {
display: none;
}
}
}
}
.timeline.media-grid-view {
display: grid;
gap: 4px;
grid-template-columns: repeat(3, minmax(0, 1fr));
> div:not(:first-child) {
margin-top: 0;
}
.timeline-item {
padding: 0;
}
.tweet-link {
z-index: 1000;
&:hover {
background-color: unset;
}
}
> .show-more,
> .top-ref,
> .timeline-footer,
> .timeline-header {
grid-column: 1 / -1;
}
.tweet-body {
height: 100%;
margin-left: 0;
padding: 0;
position: relative;
aspect-ratio: 1/1;
}
.gallery-row + .gallery-row {
margin-top: 0.25em !important;
}
.attachments {
background-color: var(--darkest_grey);
border-radius: 0;
margin: 0;
max-height: none;
}
.attachments,
.gallery-row,
.still-image {
height: 100%;
width: 100%;
}
.still-image img,
.attachment > video,
.attachment > img {
object-fit: cover;
height: 100%;
width: 100%;
}
.attachment {
display: flex;
align-items: center;
}
.gallery-video {
height: 100%;
}
.media-gif {
display: flex;
}
.timeline-item:hover {
opacity: 0.85;
}
.alt-text {
display: none;
}
}
.timeline.media-gallery-view {
.gallery-masonry {
margin: 10px 0;
column-gap: 10px;
column-width: unquote("clamp(190px, 22vw, 350px)");
&[data-col-size="small"] {
column-width: unquote("max(130px, 11vw)");
}
&[data-col-size="large"] {
column-width: unquote("clamp(350px, 22vw, 480px)");
}
&.masonry-active {
column-width: unset;
column-gap: unset;
position: relative;
.timeline-item {
animation: none;
position: absolute;
box-sizing: border-box;
margin-bottom: 0;
}
}
&.compact {
.tweet-body {
padding: 0;
> .attachments {
margin: 0;
}
}
.card-image-container img {
max-height: unset;
}
}
}
@keyframes masonry-init {
to {
opacity: 1;
pointer-events: auto;
}
}
// Start hidden. CSS animation reveals after a delay as a no-JS fallback.
// With JS, masonry-active cancels the animation and masonry-visible reveals.
.gallery-masonry .timeline-item,
> .show-more,
> .top-ref,
> .timeline-footer {
opacity: 0;
pointer-events: none;
animation: masonry-init 0.2s 0.3s forwards;
}
.gallery-masonry.masonry-active .timeline-item.masonry-visible,
> .show-more.masonry-visible,
> .top-ref.masonry-visible,
> .timeline-footer.masonry-visible {
opacity: 1;
pointer-events: auto;
transition: opacity 0.15s ease;
animation: none;
}
.timeline-item {
margin-bottom: 10px;
break-inside: avoid;
flex-direction: column;
padding: 0;
}
> .show-more,
> .top-ref,
> .timeline-footer,
> .timeline-header {
margin-left: auto;
margin-right: auto;
max-width: 900px;
}
> .show-more {
padding: 0;
margin-top: 8px;
background-color: unset;
}
.tweet-content {
margin: 3px 0;
}
.tweet-body {
display: flex;
flex-direction: column;
height: 100%;
margin-left: 0;
padding: 10px;
> .attachments {
align-self: stretch;
border-radius: 0;
margin: -10px -10px 10px;
max-height: none;
order: -1;
width: auto;
background-color: var(--bg_elements);
.gallery-row {
max-height: none;
max-width: none;
align-items: center;
}
.still-image img,
.attachment > video,
.attachment > img {
max-height: none;
width: 100%;
}
.attachment:last-child {
max-height: none;
}
.card-container {
border: unset;
border-radius: unset;
}
}
.tweet-stat {
padding-top: unset;
}
.quote {
margin-bottom: 5px;
margin-top: 5px;
}
.replying-to {
margin: 0;
}
}
.tweet-header {
align-items: flex-start;
display: flex;
gap: 0.75em;
margin-bottom: 0;
.tweet-avatar {
img {
float: none;
height: 42px;
margin: 0;
width: 42px;
}
}
.tweet-name-row {
flex: 1;
}
.fullname-and-username {
flex-wrap: wrap;
}
.fullname {
max-width: calc(100% - 18px);
}
.verified-icon {
margin-left: 4px;
margin-top: 1px;
}
.username {
display: block;
flex-basis: 100%;
margin-left: 0;
}
}
}
@media (max-width: 520px) {
.timeline.media-gallery-view {
padding: 8px 0;
}
}
+22 -2
View File
@@ -44,6 +44,10 @@
padding: 0; padding: 0;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
.verified-icon {
margin-left: 2px;
}
} }
.fullname-and-username { .fullname-and-username {
@@ -80,8 +84,8 @@
} }
.tweet-published { .tweet-published {
margin-top: 10px; margin-top: 6px;
margin-bottom: 3px; margin-bottom: 0px;
color: var(--grey); color: var(--grey);
pointer-events: all; pointer-events: all;
} }
@@ -101,6 +105,7 @@
.avatar { .avatar {
&.round { &.round {
border-radius: 50%; border-radius: 50%;
user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
} }
@@ -204,6 +209,7 @@
.tweet-stats { .tweet-stats {
margin-bottom: -3px; margin-bottom: -3px;
user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
} }
@@ -236,6 +242,7 @@
left: 0; left: 0;
top: 0; top: 0;
position: absolute; position: absolute;
user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
&:hover { &:hover {
@@ -289,3 +296,16 @@
padding: 10px 10px; padding: 10px 10px;
padding-top: 6px; padding-top: 6px;
} }
.disclosures {
display: flex;
flex-direction: column;
color: var(--grey);
font-size: 14px;
margin-top: 4px;
margin-bottom: -2px;
.icon-attention {
margin-right: -3px;
}
}
+37
View File
@@ -96,6 +96,37 @@ type
suspended*: bool suspended*: bool
joinDate*: DateTime joinDate*: DateTime
AccountInfo* = object
username*: string
fullname*: string
userPic*: string
joinDate*: DateTime
verifiedType*: VerifiedType
suspended*: bool
basedIn*: string
source*: string
usernameChanges*: int
lastUsernameChange*: DateTime
affiliateUsername*: string
affiliateLabel*: string
isIdentityVerified*: bool
verifiedSince*: DateTime
overrideVerifiedYear*: int
Broadcast* = object
id*: string
title*: string
state*: string
thumb*: string
mediaKey*: string
m3u8Url*: string
totalWatched*: int
startTime*: DateTime
endTime*: DateTime
replayStart*: int
availableForReplay*: bool
user*: User
VideoType* = enum VideoType* = enum
m3u8 = "application/x-mpegURL" m3u8 = "application/x-mpegURL"
mp4 = "video/mp4" mp4 = "video/mp4"
@@ -123,6 +154,7 @@ type
Query* = object Query* = object
kind*: QueryKind kind*: QueryKind
view*: string
text*: string text*: string
filters*: seq[string] filters*: seq[string]
includes*: seq[string] includes*: seq[string]
@@ -239,6 +271,8 @@ type
media*: MediaEntities media*: MediaEntities
history*: seq[int64] history*: seq[int64]
note*: string note*: string
isAd*: bool
isAI*: bool
Tweets* = seq[Tweet] Tweets* = seq[Tweet]
@@ -270,6 +304,7 @@ type
photoRail*: PhotoRail photoRail*: PhotoRail
pinned*: Option[Tweet] pinned*: Option[Tweet]
tweets*: Timeline tweets*: Timeline
accountInfo*: AccountInfo
List* = object List* = object
id*: string id*: string
@@ -307,6 +342,8 @@ type
apiProxy*: string apiProxy*: string
disableTid*: bool disableTid*: bool
maxConcurrentReqs*: int maxConcurrentReqs*: int
maxRetries*: int
retryDelayMs*: int
rssCacheTime*: int rssCacheTime*: int
listCacheTime*: int listCacheTime*: int
+11 -3
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, uri, tables, base64 import sequtils, strutils, strformat, uri, tables, base64
import nimcrypto import nimcrypto
var var
@@ -17,7 +17,9 @@ const
"abs.twimg.com", "abs.twimg.com",
"pbs.twimg.com", "pbs.twimg.com",
"video.twimg.com", "video.twimg.com",
"x.com" "x.com",
"pscp.tv",
"video.pscp.tv"
] ]
proc setHmacKey*(key: string) = proc setHmacKey*(key: string) =
@@ -55,7 +57,13 @@ proc filterParams*(params: Table): seq[(string, string)] =
result.add p result.add p
proc isTwitterUrl*(uri: Uri): bool = proc isTwitterUrl*(uri: Uri): bool =
uri.hostname in twitterDomains uri.hostname in twitterDomains or
uri.hostname.endsWith(".video.pscp.tv")
proc isTwitterUrl*(url: string): bool = proc isTwitterUrl*(url: string): bool =
isTwitterUrl(parseUri(url)) isTwitterUrl(parseUri(url))
proc validateNumber*(value: string): string =
if value.anyIt(not it.isDigit):
return ""
return value
+93
View File
@@ -0,0 +1,93 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, times
import karax/[karaxdsl, vdom]
import renderutils
import ".."/[types, formatters]
proc renderAboutAccount*(info: AccountInfo): VNode =
let user = User(
username: info.username,
fullname: info.fullname,
userPic: info.userPic,
verifiedType: info.verifiedType
)
buildHtml(tdiv(class="about-account")):
tdiv(class="about-account-header"):
a(class="about-account-avatar", href=(&"/{info.username}")):
genImg(getUserPic(info.userPic, "_200x200"))
tdiv(class="about-account-name"):
linkUser(user, class="profile-card-fullname")
verifiedIcon(user)
linkUser(user, class="profile-card-username")
tdiv(class="about-account-body"):
tdiv(class="about-account-row"):
span: icon "calendar"
tdiv:
span(class="about-account-label"): text "Date joined"
span(class="about-account-value"):
text info.joinDate.format("MMMM YYYY")
if info.basedIn.len > 0:
tdiv(class="about-account-row"):
span: icon "location"
tdiv:
span(class="about-account-label"): text "Account based in"
span(class="about-account-value"): text info.basedIn
if info.verifiedType != VerifiedType.none:
if info.overrideVerifiedYear != 0:
tdiv(class="about-account-row"):
span: icon "ok"
tdiv:
span(class="about-account-label"): text "Verified"
span(class="about-account-value"):
let year = abs(info.overrideVerifiedYear)
let era = if info.overrideVerifiedYear < 0: " BCE" else: ""
text "Since " & $year & era
elif info.verifiedSince.year > 0:
tdiv(class="about-account-row"):
span: icon "ok"
tdiv:
span(class="about-account-label"): text "Verified"
span(class="about-account-value"):
text "Since " & info.verifiedSince.format("MMMM YYYY")
if info.isIdentityVerified:
tdiv(class="about-account-row"):
span: icon "ok"
tdiv:
span(class="about-account-label"): text "ID Verified"
span(class="about-account-value"): text "Yes"
if info.affiliateUsername.len > 0:
tdiv(class="about-account-row"):
span: icon "group"
tdiv:
span(class="about-account-label"): text "An affiliate of"
span(class="about-account-value"):
a(href=(&"/{info.affiliateUsername}")):
if info.affiliateLabel.len > 0:
text info.affiliateLabel & " (@" & info.affiliateUsername & ")"
else:
text "@" & info.affiliateUsername
if info.usernameChanges > 0:
tdiv(class="about-account-row"):
span(class="about-account-at"): text "@"
tdiv:
span(class="about-account-label"):
text $info.usernameChanges & " username change"
if info.usernameChanges > 1: text "s"
if info.lastUsernameChange.year > 0:
span(class="about-account-value"):
text "Last on " & info.lastUsernameChange.format("MMMM YYYY")
if info.source.len > 0:
tdiv(class="about-account-row"):
span: icon "link"
tdiv:
span(class="about-account-label"): text "Connected via"
span(class="about-account-value"): text info.source
+75
View File
@@ -0,0 +1,75 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, times
import karax/[karaxdsl, vdom]
import renderutils
import ".."/[types, utils, formatters]
proc renderBroadcast*(bc: Broadcast; prefs: Prefs; path: string): VNode =
let
isLive = bc.state == "RUNNING"
thumb = getPicUrl(bc.thumb)
source = if prefs.proxyVideos and bc.m3u8Url.startsWith("http"):
getVidUrl(bc.m3u8Url) else: bc.m3u8Url
stateText =
if isLive: "LIVE"
elif bc.endTime.year > 1: "Ended " & bc.endTime.format("MMM d, YYYY")
elif bc.state.len > 0: bc.state
else: "Ended"
durationMs =
if bc.startTime.year > 1 and bc.endTime.year > 1:
int((bc.endTime - bc.startTime).inMilliseconds) - bc.replayStart * 1000
else: 0
duration = if durationMs > 0: getDuration(durationMs) else: ""
buildHtml(tdiv(class="broadcast-page")):
tdiv(class="broadcast-panel"):
tdiv(class="broadcast-player"):
if bc.m3u8Url.len > 0 and prefs.hlsPlayback:
video(poster=thumb, data-url=source, data-autoload="false",
data-start=($bc.replayStart), muted=prefs.muteVideos)
verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
tdiv(class="overlay-circle"): span(class="overlay-triangle")
if isLive:
tdiv(class="broadcast-live"): text "LIVE"
elif duration.len > 0:
tdiv(class="overlay-duration"): text duration
verbatim "</div>"
elif bc.m3u8Url.len > 0:
img(src=thumb, alt=bc.title)
tdiv(class="video-overlay"):
buttonReferer "/enablehls", "Enable hls playback", path
if isLive:
tdiv(class="broadcast-live"): text "LIVE"
elif duration.len > 0:
tdiv(class="overlay-duration"): text duration
elif bc.thumb.len > 0:
img(src=thumb, alt=bc.title)
tdiv(class="video-overlay"):
if bc.availableForReplay:
p: text "Stream unavailable"
else:
p: text "Replay is not available"
else:
tdiv(class="video-overlay"):
p: text "Broadcast not found"
tdiv(class="broadcast-info"):
h2(class="broadcast-title"): text bc.title
tdiv(class="broadcast-user-row"):
a(class="broadcast-user", href=("/" & bc.user.username)):
genImg(getUserPic(bc.user.userPic, "_bigger"))
tdiv:
tdiv:
strong: text bc.user.fullname
verifiedIcon(bc.user)
span(class="broadcast-username"): text "@" & bc.user.username
tdiv(class="broadcast-meta"):
if bc.totalWatched > 0:
span: text insertSep($bc.totalWatched, ',') & " views"
if isLive:
span(class="broadcast-live"): text stateText
else:
span: text stateText
+2 -2
View File
@@ -50,8 +50,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
buildHtml(head): buildHtml(head):
link(rel="stylesheet", type="text/css", href="/css/style.css?v=29") link(rel="stylesheet", type="text/css", href="/css/style.css?v=35")
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5")
if theme.len > 0: if theme.len > 0:
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css")) link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
+19 -9
View File
@@ -12,7 +12,7 @@ proc renderStat(num: int; class: string; text=""): VNode =
span(class="profile-stat-num"): span(class="profile-stat-num"):
text insertSep($num, ',') text insertSep($num, ',')
proc renderUserCard*(user: User; prefs: Prefs): VNode = proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
buildHtml(tdiv(class="profile-card")): buildHtml(tdiv(class="profile-card")):
tdiv(class="profile-card-info"): tdiv(class="profile-card-info"):
let let
@@ -46,6 +46,11 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
else: else:
span: text place span: text place
if info.basedIn.len > 0:
tdiv(class="profile-location"):
span: icon "location"
span: text "Based in " & info.basedIn
if user.website.len > 0: if user.website.len > 0:
tdiv(class="profile-website"): tdiv(class="profile-website"):
span: span:
@@ -54,7 +59,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
a(href=url): text url.shortLink a(href=url): text url.shortLink
tdiv(class="profile-joindate"): tdiv(class="profile-joindate"):
span(title=getJoinDateFull(user)): a(href=(&"/{user.username}/about"), title=getJoinDateFull(user)):
icon "calendar", getJoinDate(user) icon "calendar", getJoinDate(user)
tdiv(class="profile-card-extra-links"): tdiv(class="profile-card-extra-links"):
@@ -102,17 +107,22 @@ proc renderProtected(username: string): VNode =
proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode = proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
profile.tweets.query.fromUser = @[profile.user.username] profile.tweets.query.fromUser = @[profile.user.username]
let
isGalleryView = profile.tweets.query.kind == media and
profile.tweets.query.view == "gallery"
viewClass = if isGalleryView: " media-only" else: ""
buildHtml(tdiv(class="profile-tabs")): buildHtml(tdiv(class=("profile-tabs" & viewClass))):
if not prefs.hideBanner: if not isGalleryView and not prefs.hideBanner:
tdiv(class="profile-banner"): tdiv(class="profile-banner"):
renderBanner(profile.user.banner) renderBanner(profile.user.banner)
let sticky = if prefs.stickyProfile: " sticky" else: "" if not isGalleryView:
tdiv(class=("profile-tab" & sticky)): let sticky = if prefs.stickyProfile: " sticky" else: ""
renderUserCard(profile.user, prefs) tdiv(class=("profile-tab" & sticky)):
if profile.photoRail.len > 0: renderUserCard(profile.user, prefs, profile.accountInfo)
renderPhotoRail(profile) if profile.photoRail.len > 0:
renderPhotoRail(profile)
if profile.user.protected: if profile.user.protected:
renderProtected(profile.user.username) renderProtected(profile.user.username)
+7
View File
@@ -4,6 +4,7 @@ import karax/[karaxdsl, vdom, vstyles]
import ".."/[types, utils] import ".."/[types, utils]
const smallWebp* = "?name=small&format=webp" const smallWebp* = "?name=small&format=webp"
const mediumWebp* = "?name=medium&format=webp"
proc getSmallPic*(url: string): string = proc getSmallPic*(url: string): string =
result = url result = url
@@ -11,6 +12,12 @@ proc getSmallPic*(url: string): string =
result &= smallWebp result &= smallWebp
result = getPicUrl(result) result = getPicUrl(result)
proc getMediumPic*(url: string): string =
result = url
if "?" notin url and not url.endsWith("placeholder.png"):
result &= mediumWebp
result = getPicUrl(result)
proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode = proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
var c = "icon-" & icon var c = "icon-" & icon
if class.len > 0: c = &"{c} {class}" if class.len > 0: c = &"{c} {class}"
+19 -2
View File
@@ -15,7 +15,8 @@ const toggles = {
"links": "Links", "links": "Links",
"images": "Images", "images": "Images",
"quote": "Quotes", "quote": "Quotes",
"spaces": "Spaces" "spaces": "Spaces",
"cashtags": "Cashtags"
}.toOrderedTable }.toOrderedTable
proc renderSearch*(): VNode = proc renderSearch*(): VNode =
@@ -39,6 +40,19 @@ proc renderProfileTabs*(query: Query; username: string): VNode =
li(class=query.getTabClass(tweets)): li(class=query.getTabClass(tweets)):
a(href=(link & "/search")): text "Search" a(href=(link & "/search")): text "Search"
proc renderMediaViewTabs*(query: Query; username: string): VNode =
let currentView = if query.view.len > 0: query.view else: "timeline"
let base = "/" & username & "/media?view="
func cls(view: string): string =
if currentView == view: "tab-item active" else: "tab-item"
buildHtml(ul(class="tab media-view-tabs")):
li(class=cls("timeline")):
a(href=(base & "timeline")): text "Timeline"
li(class=cls("grid")):
a(href=(base & "grid")): text "Grid"
li(class=cls("gallery")):
a(href=(base & "gallery")): text "Gallery"
proc renderSearchTabs*(query: Query): VNode = proc renderSearchTabs*(query: Query): VNode =
var q = query var q = query
buildHtml(ul(class="tab")): buildHtml(ul(class="tab")):
@@ -95,7 +109,10 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
text query.fromUser.join(" | ") text query.fromUser.join(" | ")
if query.fromUser.len > 0: if query.fromUser.len > 0:
renderProfileTabs(query, query.fromUser.join(",")) if query.kind != media or query.view != "gallery":
renderProfileTabs(query, query.fromUser.join(","))
if query.kind == media and query.fromUser.len == 1:
renderMediaViewTabs(query, query.fromUser[0])
if query.fromUser.len == 0 or query.kind == tweets: if query.fromUser.len == 0 or query.kind == tweets:
tdiv(class="timeline-header"): tdiv(class="timeline-header"):
+58 -20
View File
@@ -5,12 +5,38 @@ import karax/[karaxdsl, vdom]
import ".."/[types, query, formatters] import ".."/[types, query, formatters]
import tweet, renderutils import tweet, renderutils
proc timelineViewClass(query: Query): string =
if query.kind != media:
return "timeline"
case query.view
of "grid": "timeline media-grid-view"
of "gallery": "timeline media-gallery-view"
else: "timeline"
proc getQuery(query: Query): string = proc getQuery(query: Query): string =
if query.kind != posts: if query.kind != posts:
result = genQueryUrl(query) result = genQueryUrl(query)
if result.len > 0: if result.len > 0:
result &= "&" result &= "&"
proc getSearchMaxId(results: Timeline; path: string): string =
if results.query.kind != tweets or results.content.len == 0 or
results.query.until.len == 0:
return
let lastThread = results.content[^1]
if lastThread.len == 0 or lastThread[^1].id == 0:
return
# 2000000 is the minimum decrement to guarantee no result overlap
var maxId = lastThread[^1].id - 2_000_000'i64
if maxId <= 0:
maxId = lastThread[^1].id - 1
if maxId > 0:
return "maxid:" & $maxId
proc renderToTop*(focus="#"): VNode = proc renderToTop*(focus="#"): VNode =
buildHtml(tdiv(class="top-ref")): buildHtml(tdiv(class="top-ref")):
icon "down", href=focus icon "down", href=focus
@@ -39,7 +65,7 @@ proc renderNoneFound(): VNode =
h2(class="timeline-none"): h2(class="timeline-none"):
text "No items found" text "No items found"
proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode = proc renderThread(thread: Tweets; prefs: Prefs; path: string; bigThumb=false): VNode =
buildHtml(tdiv(class="thread-line")): buildHtml(tdiv(class="thread-line")):
let sortedThread = thread.sortedByIt(it.id) let sortedThread = thread.sortedByIt(it.id)
for i, tweet in sortedThread: for i, tweet in sortedThread:
@@ -53,7 +79,7 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
let show = i == thread.high and sortedThread[0].id != tweet.threadId let show = i == thread.high and sortedThread[0].id != tweet.threadId
let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: "" let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: ""
renderTweet(tweet, prefs, path, class=(header & "thread"), renderTweet(tweet, prefs, path, class=(header & "thread"),
index=i, last=(i == thread.high)) index=i, last=(i == thread.high), bigThumb=bigThumb)
proc renderUser(user: User; prefs: Prefs): VNode = proc renderUser(user: User; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline-item", data-username=user.username)): buildHtml(tdiv(class="timeline-item", data-username=user.username)):
@@ -88,9 +114,22 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode =
else: else:
renderNoMore() renderNoMore()
proc filterThreads(threads: seq[Tweets]; prefs: Prefs): seq[Tweets] =
var retweets: seq[int64]
for thread in threads:
if thread.len == 1:
let tweet = thread[0]
let retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
if retweetId in retweets or tweet.id in retweets or
tweet.pinned and prefs.hidePins:
continue
if retweetId != 0 and tweet.retweet.isSome:
retweets &= retweetId
result.add(thread)
proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string; proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
pinned=none(Tweet)): VNode = pinned=none(Tweet)): VNode =
buildHtml(tdiv(class="timeline")): buildHtml(tdiv(class=results.query.timelineViewClass)):
if not results.beginning: if not results.beginning:
renderNewer(results.query, parseUri(path).path) renderNewer(results.query, parseUri(path).path)
@@ -104,24 +143,23 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
else: else:
renderNoneFound() renderNoneFound()
else: else:
var retweets: seq[int64] let filtered = filterThreads(results.content, prefs)
for thread in results.content: if results.query.view == "gallery":
if thread.len == 1: let bigThumb = prefs.gallerySize == "Large"
let let galClass = if prefs.compactGallery: "gallery-masonry compact" else: "gallery-masonry"
tweet = thread[0] tdiv(class=galClass, `data-col-size`=prefs.gallerySize.toLowerAscii):
retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0 for thread in filtered:
if thread.len == 1: renderTweet(thread[0], prefs, path, bigThumb=bigThumb)
else: renderThread(thread, prefs, path, bigThumb)
else:
for thread in filtered:
if thread.len == 1: renderTweet(thread[0], prefs, path)
else: renderThread(thread, prefs, path)
if retweetId in retweets or tweet.id in retweets or var cursor = getSearchMaxId(results, path)
tweet.pinned and prefs.hidePins: if cursor.len > 0:
continue renderMore(results.query, cursor)
elif results.bottom.len > 0:
if retweetId != 0 and tweet.retweet.isSome:
retweets &= retweetId
renderTweet(tweet, prefs, path)
else:
renderThread(thread, prefs, path)
if results.bottom.len > 0:
renderMore(results.query, results.bottom) renderMore(results.query, results.bottom)
renderToTop() renderToTop()
+35 -20
View File
@@ -42,13 +42,15 @@ proc renderAltText(altText: string): VNode =
buildHtml(p(class="alt-text")): buildHtml(p(class="alt-text")):
text "ALT " & altText text "ALT " & altText
proc renderPhotoAttachment(photo: Photo): VNode = proc renderPhotoAttachment(photo: Photo; bigThumb=false): VNode =
buildHtml(tdiv(class="attachment")): buildHtml(tdiv(class="attachment")):
let let
named = "name=" in photo.url named = "name=" in photo.url
small = if named: photo.url else: photo.url & smallWebp thumb = if named: photo.url
elif bigThumb: photo.url & mediumWebp
else: photo.url & smallWebp
a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"): a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"):
genImg(small, alt=photo.altText) genImg(thumb, alt=photo.altText)
if photo.altText.len > 0: if photo.altText.len > 0:
renderAltText(photo.altText) renderAltText(photo.altText)
@@ -76,11 +78,11 @@ proc renderVideoUnavailable(video: Video): VNode =
else: else:
p: text "This media is unavailable" p: text "This media is unavailable"
proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""): VNode = proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""; bigThumb=false): VNode =
let let
playbackType = if not prefs.proxyVideos and videoData.hasMp4Url: mp4 playbackType = if not prefs.proxyVideos and videoData.hasMp4Url: mp4
else: videoData.playbackType else: videoData.playbackType
thumb = getSmallPic(videoData.thumb) thumb = if bigThumb: getMediumPic(videoData.thumb) else: getSmallPic(videoData.thumb)
buildHtml(tdiv(class="attachment")): buildHtml(tdiv(class="attachment")):
if not videoData.available: if not videoData.available:
@@ -93,8 +95,8 @@ proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""): VNode =
let let
vars = videoData.variants.filterIt(it.contentType == playbackType) vars = videoData.variants.filterIt(it.contentType == playbackType)
vidUrl = vars.sortedByIt(it.resolution)[^1].url vidUrl = vars.sortedByIt(it.resolution)[^1].url
source = if prefs.proxyVideos: getVidUrl(vidUrl) source = if prefs.proxyVideos and vidUrl.startsWith("http"):
else: vidUrl getVidUrl(vidUrl) else: vidUrl
case playbackType case playbackType
of mp4: of mp4:
video(poster=thumb, controls="", muted=prefs.muteVideos): video(poster=thumb, controls="", muted=prefs.muteVideos):
@@ -103,15 +105,16 @@ proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""): VNode =
video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos) video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos)
verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">" verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
tdiv(class="overlay-circle"): span(class="overlay-triangle") tdiv(class="overlay-circle"): span(class="overlay-triangle")
tdiv(class="overlay-duration"): text getDuration(videoData) if videoData.durationMs > 0:
tdiv(class="overlay-duration"): text getDuration(videoData)
verbatim "</div>" verbatim "</div>"
proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode = proc renderVideo*(video: Video; prefs: Prefs; path: string; bigThumb=false): VNode =
let hasCardContent = video.description.len > 0 or video.title.len > 0 let hasCardContent = video.description.len > 0 or video.title.len > 0
buildHtml(tdiv(class="attachments card")): buildHtml(tdiv(class="attachments card")):
tdiv(class=("gallery-video" & (if hasCardContent: " card-container" else: ""))): tdiv(class=("gallery-video" & (if hasCardContent: " card-container" else: ""))):
renderVideoAttachment(video, prefs, path) renderVideoAttachment(video, prefs, path, bigThumb)
if hasCardContent: if hasCardContent:
tdiv(class="card-content"): tdiv(class="card-content"):
h2(class="card-title"): text video.title h2(class="card-title"): text video.title
@@ -138,14 +141,14 @@ proc renderGif(gif: Gif; prefs: Prefs): VNode =
buildHtml(tdiv(class="attachments media-gif")): buildHtml(tdiv(class="attachments media-gif")):
renderGifAttachment(gif, prefs) renderGifAttachment(gif, prefs)
proc renderMedia(media: seq[Media]; prefs: Prefs; path: string): VNode = proc renderMedia(media: seq[Media]; prefs: Prefs; path: string; bigThumb=false): VNode =
if media.len == 0: if media.len == 0:
return nil return nil
if media.len == 1: if media.len == 1:
let item = media[0] let item = media[0]
if item.kind == videoMedia: if item.kind == videoMedia:
return renderVideo(item.video, prefs, path) return renderVideo(item.video, prefs, path, bigThumb)
if item.kind == gifMedia: if item.kind == gifMedia:
return renderGif(item.gif, prefs) return renderGif(item.gif, prefs)
@@ -162,9 +165,9 @@ proc renderMedia(media: seq[Media]; prefs: Prefs; path: string): VNode =
for mediaItem in mediaGroup: for mediaItem in mediaGroup:
case mediaItem.kind case mediaItem.kind
of photoMedia: of photoMedia:
renderPhotoAttachment(mediaItem.photo) renderPhotoAttachment(mediaItem.photo, bigThumb)
of videoMedia: of videoMedia:
renderVideoAttachment(mediaItem.video, prefs, path) renderVideoAttachment(mediaItem.video, prefs, path, bigThumb)
of gifMedia: of gifMedia:
renderGifAttachment(mediaItem.gif, prefs) renderGifAttachment(mediaItem.gif, prefs)
@@ -257,10 +260,6 @@ proc renderLatestPost(username: string; id: int64): VNode =
a(href=getLink(id, username)): a(href=getLink(id, username)):
text "See the latest post" text "See the latest post"
proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="quote-media-container")):
renderMedia(quote.media, prefs, path)
proc renderCommunityNote(note: string; prefs: Prefs): VNode = proc renderCommunityNote(note: string; prefs: Prefs): VNode =
buildHtml(tdiv(class="community-note")): buildHtml(tdiv(class="community-note")):
tdiv(class="community-note-header"): tdiv(class="community-note-header"):
@@ -269,6 +268,10 @@ proc renderCommunityNote(note: string; prefs: Prefs): VNode =
tdiv(class="community-note-text", dir="auto"): tdiv(class="community-note-text", dir="auto"):
verbatim replaceUrls(note, prefs) verbatim replaceUrls(note, prefs)
proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="quote-media-container")):
renderMedia(quote.media, prefs, path)
proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
if not quote.available: if not quote.available:
return buildHtml(tdiv(class="quote unavailable")): return buildHtml(tdiv(class="quote unavailable")):
@@ -315,6 +318,15 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
tdiv(class="quote-latest"): tdiv(class="quote-latest"):
text "There's a new version of this post" text "There's a new version of this post"
proc renderDisclosures*(tweet: Tweet): VNode =
buildHtml(tdiv(class="disclosures")):
if tweet.isAI:
span(data-disclosure="ai"):
icon "attention", "Made with AI"
if tweet.isAd:
span(data-disclosure="ad"):
icon "attention", "Paid partnership (ad)"
proc renderLocation*(tweet: Tweet): string = proc renderLocation*(tweet: Tweet): string =
let (place, url) = tweet.getLocation() let (place, url) = tweet.getLocation()
if place.len == 0: return if place.len == 0: return
@@ -327,7 +339,7 @@ proc renderLocation*(tweet: Tweet): string =
return $node return $node
proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
last=false; mainTweet=false; afterTweet=false): VNode = last=false; mainTweet=false; afterTweet=false; bigThumb=false): VNode =
var divClass = class var divClass = class
if index == -1 or last: if index == -1 or last:
divClass = "thread-last " & class divClass = "thread-last " & class
@@ -380,7 +392,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
renderCard(tweet.card.get(), prefs, path) renderCard(tweet.card.get(), prefs, path)
if tweet.media.len > 0: if tweet.media.len > 0:
renderMedia(tweet.media, prefs, path) renderMedia(tweet.media, prefs, path, bigThumb)
if tweet.poll.isSome: if tweet.poll.isSome:
renderPoll(tweet.poll.get()) renderPoll(tweet.poll.get())
@@ -391,6 +403,9 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
if tweet.note.len > 0 and not prefs.hideCommunityNotes: if tweet.note.len > 0 and not prefs.hideCommunityNotes:
renderCommunityNote(tweet.note, prefs) renderCommunityNote(tweet.note, prefs)
if tweet.isAI or tweet.isAd:
renderDisclosures(tweet)
let let
hasEdits = tweet.history.len > 1 hasEdits = tweet.history.len > 1
isLatest = hasEdits and tweet.id == max(tweet.history) isLatest = hasEdits and tweet.id == max(tweet.history)
+7
View File
@@ -54,6 +54,13 @@ class Timeline(object):
none = '.timeline-none' none = '.timeline-none'
protected = '.timeline-protected' protected = '.timeline-protected'
photo_rail = '.photo-rail-grid' photo_rail = '.photo-rail-grid'
media_view_tabs = '.media-view-tabs'
media_view_timeline = '.media-view-tabs a[href$="media?view=timeline"]'
media_view_grid = '.media-view-tabs a[href$="media?view=grid"]'
media_view_gallery = '.media-view-tabs a[href$="media?view=gallery"]'
media_view_active = '.media-view-tabs .tab-item.active a'
grid_view = '.timeline.media-grid-view'
gallery_view = '.timeline.media-gallery-view'
class Conversation(object): class Conversation(object):
+32 -21
View File
@@ -1,27 +1,38 @@
from base import BaseTestCase, Conversation
from parameterized import parameterized from parameterized import parameterized
from base import BaseTestCase, Conversation
thread = [ thread = [
['octonion/status/975253897697611777', [], 'Based', ['Crystal', 'Julia'], [ [
['For', 'Then', 'Okay,', 'Python', 'Speed', 'Java', 'Coding', 'I', 'You'], "octonion/status/975253897697611777",
['yeah,'] [],
]], "Based",
["Crystal", "Julia"],
['octonion/status/975254452625002496', ['Based'], 'Crystal', ['Julia'], []], [["yeah,"]],
],
['octonion/status/975256058384887808', ['Based', 'Crystal'], 'Julia', [], []], ["octonion/status/975254452625002496", ["Based"], "Crystal", ["Julia"], []],
["octonion/status/975256058384887808", ["Based", "Crystal"], "Julia", [], []],
['gauravssnl/status/975364889039417344', [
['Based', 'For', 'Then', 'Okay,', 'Python'], 'Speed', [], [ "gauravssnl/status/975364889039417344",
['Java', 'Coding', 'I', 'You'], ['JAVA!'] ["Based", "For", "Then", "Okay,", "Python"],
]], "Speed",
[],
['d0m96/status/1141811379407425537', [], 'I\'m', [["Java", "Coding", "I", "You"], ["JAVA!"]],
['The', 'The', 'Today', 'Some', 'If', 'There', 'Above'], ],
[['Thank', 'Also,']]], [
"d0m96/status/1141811379407425537",
['gmpreussner/status/999766552546299904', [], 'A', [], [],
[['I', 'Especially'], ['I']]] "I'm",
["The", "The", "Today", "Some", "If", "There", "Above"],
[["Thank", "Also,"]],
],
[
"gmpreussner/status/999766552546299904",
[],
"A",
[],
[["I", "Especially"], ["I"]],
],
] ]
+22
View File
@@ -55,6 +55,28 @@ class TweetTest(BaseTestCase):
self.assert_element_absent(Timeline.older) self.assert_element_absent(Timeline.older)
self.assert_element_absent(Timeline.end) self.assert_element_absent(Timeline.end)
def test_media_view_tabs(self):
self.open_nitter('mobile_test/media')
self.assert_element_present(Timeline.media_view_tabs)
self.assert_text('Timeline', Timeline.media_view_timeline)
self.assert_text('Grid', Timeline.media_view_grid)
self.assert_text('Gallery', Timeline.media_view_gallery)
self.assert_text('Timeline', Timeline.media_view_active)
def test_media_view_grid_tab(self):
self.open_nitter('mobile_test/media?view=grid')
self.assert_element_present(Timeline.grid_view)
self.assert_text('Grid', Timeline.media_view_active)
def test_media_view_gallery_tab(self):
self.open_nitter('mobile_test/media?view=gallery')
self.assert_element_present(Timeline.gallery_view)
self.assert_text('Gallery', Timeline.media_view_active)
def test_media_view_tabs_not_on_posts(self):
self.open_nitter('mobile_test')
self.assert_element_absent(Timeline.media_view_tabs)
#@parameterized.expand(photo_rail) #@parameterized.expand(photo_rail)
#def test_photo_rail(self, username, images): #def test_photo_rail(self, username, images):
#self.open_nitter(username) #self.open_nitter(username)