1
0

18 Commits

Author SHA1 Message Date
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
Zed 0fefcf9917 Update gif class in tests 2026-03-13 06:19:09 +01:00
Zed 35a929c415 Implement mixed-media tweet support
Fixes #697 #1101
2026-03-13 05:47:37 +01:00
Zed 4bf3df94f8 Fix segfault 2026-03-04 17:27:15 +01:00
53 changed files with 1965 additions and 460 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
@@ -13,3 +13,5 @@ nitter.conf
guest_accounts.json* guest_accounts.json*
sessions.json* sessions.json*
dump.rdb dump.rdb
*.bak
/tools/*.json*
+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
+45 -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,8 @@ 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 after[0..<64] == result.bottom[0..<64]: if after.len > 0 and result.bottom.len > 0 and maxId.len == 0 and
after[0..<64] == result.bottom[0..<64]:
result.content.setLen(0) result.content.setLen(0)
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} = proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
@@ -199,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.} =
+33 -15
View File
@@ -10,28 +10,38 @@ 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 = ""
if url.len > 0: if url.len > 0:
apiProxy = url.strip(chars={'/'}) & "/" apiProxy = url.strip(chars={'/'}) & "/"
if "http" notin apiProxy: if "http" notin apiProxy:
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
@@ -80,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
@@ -107,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)
@@ -119,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])
@@ -164,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,
+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, ""
+153 -26
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
@@ -140,27 +203,37 @@ proc parseVideo(js: JsonNode): Video =
result.variants = parseVideoVariants(js{"video_info", "variants"}) result.variants = parseVideoVariants(js{"video_info", "variants"})
proc addMedia(media: var MediaEntities; photo: Photo) =
media.add Media(kind: photoMedia, photo: photo)
proc addMedia(media: var MediaEntities; video: Video) =
media.add Media(kind: videoMedia, video: video)
proc addMedia(media: var MediaEntities; gif: Gif) =
media.add Media(kind: gifMedia, gif: gif)
proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) = proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
with jsMedia, js{"extended_entities", "media"}: with jsMedia, js{"extended_entities", "media"}:
for m in jsMedia: for m in jsMedia:
case m.getTypeName: case m.getTypeName:
of "photo": of "photo":
result.photos.add Photo( result.media.addMedia(Photo(
url: m{"media_url_https"}.getImageStr, url: m{"media_url_https"}.getImageStr,
altText: m{"ext_alt_text"}.getStr altText: m{"ext_alt_text"}.getStr
) ))
of "video": of "video":
result.video = some(parseVideo(m)) result.media.addMedia(parseVideo(m))
with user, m{"additional_media_info", "source_user"}: with user, m{"additional_media_info", "source_user"}:
if user{"id"}.getInt > 0: if user{"id"}.getInt > 0:
result.attribution = some(parseUser(user)) result.attribution = some(parseUser(user))
else: else:
result.attribution = some(parseGraphUser(user)) result.attribution = some(parseGraphUser(user))
of "animated_gif": of "animated_gif":
result.gif = some Gif( result.media.addMedia(Gif(
url: m{"video_info", "variants"}[0]{"url"}.getImageStr, url: m{"video_info", "variants"}[0]{"url"}.getImageStr,
thumb: m{"media_url_https"}.getImageStr thumb: m{"media_url_https"}.getImageStr,
) altText: m{"ext_alt_text"}.getStr
))
else: discard else: discard
with url, m{"url"}: with url, m{"url"}:
@@ -170,29 +243,41 @@ proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
proc parseMediaEntities(js: JsonNode; result: var Tweet) = proc parseMediaEntities(js: JsonNode; result: var Tweet) =
with mediaEntities, js{"media_entities"}: with mediaEntities, js{"media_entities"}:
var parsedMedia: MediaEntities
for mediaEntity in mediaEntities: for mediaEntity in mediaEntities:
with mediaInfo, mediaEntity{"media_results", "result", "media_info"}: with mediaInfo, mediaEntity{"media_results", "result", "media_info"}:
case mediaInfo.getTypeName case mediaInfo.getTypeName
of "ApiImage": of "ApiImage":
result.photos.add Photo( parsedMedia.addMedia(Photo(
url: mediaInfo{"original_img_url"}.getImageStr, url: mediaInfo{"original_img_url"}.getImageStr,
altText: mediaInfo{"alt_text"}.getStr altText: mediaInfo{"alt_text"}.getStr
) ))
of "ApiVideo": of "ApiVideo":
let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"} let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"}
result.video = some Video( parsedMedia.addMedia(Video(
available: status.getStr == "Available", available: status.getStr == "Available",
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr, thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
title: mediaInfo{"alt_text"}.getStr,
durationMs: mediaInfo{"duration_millis"}.getInt, durationMs: mediaInfo{"duration_millis"}.getInt,
variants: parseVideoVariants(mediaInfo{"variants"}) variants: parseVideoVariants(mediaInfo{"variants"})
) ))
of "ApiGif": of "ApiGif":
result.gif = some Gif( parsedMedia.addMedia(Gif(
url: mediaInfo{"variants"}[0]{"url"}.getImageStr, url: mediaInfo{"variants"}[0]{"url"}.getImageStr,
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
) altText: mediaInfo{"alt_text"}.getStr
))
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:
result.media = parsedMedia
# Remove media URLs from text # Remove media URLs from text
with mediaList, js{"legacy", "entities", "media"}: with mediaList, js{"legacy", "entities", "media"}:
for url in mediaList: for url in mediaList:
@@ -222,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 =
@@ -291,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
@@ -348,13 +442,13 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
let name = jsCard{"name"}.getStr let name = jsCard{"name"}.getStr
if "poll" in name: if "poll" in name:
if "image" in name: if "image" in name:
result.photos.add Photo( result.media.addMedia(Photo(
url: jsCard{"binding_values", "image_large"}.getImageVal url: jsCard{"binding_values", "image_large"}.getImageVal
) ))
result.poll = some parsePoll(jsCard) result.poll = some parsePoll(jsCard)
elif name == "amplify": elif name == "amplify":
result.video = some parsePromoVideo(jsCard{"binding_values"}) result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
else: else:
result.card = some parseCard(jsCard, js{"entities", "urls"}) result.card = some parseCard(jsCard, js{"entities", "urls"})
@@ -393,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"})
@@ -416,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 -3
View File
@@ -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"}
@@ -352,9 +412,7 @@ proc expandBirdwatchEntities*(text: string; entities: JsonNode): string =
proc extractGalleryPhoto*(t: Tweet): GalleryPhoto = proc extractGalleryPhoto*(t: Tweet): GalleryPhoto =
let url = let url =
if t.photos.len > 0: t.photos[0].url if t.media.len > 0: t.media[0].getThumb
elif t.video.isSome: get(t.video).thumb
elif t.gif.isSome: get(t.gif).thumb
elif t.card.isSome: get(t.card).image elif t.card.isSome: get(t.card).image
else: "" else: ""
+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
+1 -1
View File
@@ -11,7 +11,7 @@ proc createEmbedRouter*(cfg: Config) =
router embed: router embed:
get "/i/videos/tweet/@id": get "/i/videos/tweet/@id":
let tweet = await getGraphTweetResult(@"id") let tweet = await getGraphTweetResult(@"id")
if tweet == nil or tweet.video.isNone: if tweet == nil or not tweet.hasVideos:
resp Http404 resp Http404
resp renderVideoEmbed(tweet, cfg, request) resp renderVideoEmbed(tweet, cfg, request)
+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
+10 -6
View File
@@ -44,15 +44,19 @@ proc createStatusRouter*(cfg: Config) =
desc = conv.tweet.text desc = conv.tweet.text
var var
images = conv.tweet.photos.mapIt(it.url) images = conv.tweet.getPhotos.mapIt(it.url)
video = "" video = ""
if conv.tweet.video.isSome(): let
images = @[get(conv.tweet.video).thumb] firstMediaKind = if conv.tweet.media.len > 0: conv.tweet.media[0].kind
else: photoMedia
if firstMediaKind == videoMedia:
images = @[conv.tweet.media[0].getThumb]
video = getVideoEmbed(cfg, conv.tweet.id) video = getVideoEmbed(cfg, conv.tweet.id)
elif conv.tweet.gif.isSome(): elif firstMediaKind == gifMedia:
images = @[get(conv.tweet.gif).thumb] images = @[conv.tweet.media[0].getThumb]
video = getPicUrl(get(conv.tweet.gif).url) video = getPicUrl(conv.tweet.media[0].gif.url)
elif conv.tweet.card.isSome(): elif conv.tweet.card.isSome():
let card = conv.tweet.card.get() let card = conv.tweet.card.get()
if card.image.len > 0: if card.image.len > 0:
+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;
}
}
+1 -1
View File
@@ -11,7 +11,7 @@
left: 0%; left: 0%;
} }
.video-container { .gallery-video > .attachment {
max-height: unset; max-height: unset;
} }
} }
+49 -30
View File
@@ -10,9 +10,47 @@
max-width: 533px; max-width: 533px;
pointer-events: all; pointer-events: all;
.still-image { &.mixed-row {
width: 100%; .attachment {
align-self: center; min-width: 0;
min-height: 0;
flex: 1 1 0;
max-height: 379.5px;
display: flex;
align-items: center;
justify-content: center;
background-color: #101010;
}
.still-image,
.still-image img,
.attachment > video,
.attachment > img {
width: 100%;
height: 100%;
max-width: none;
max-height: none;
}
.still-image {
display: flex;
align-self: stretch;
}
.still-image img {
flex-basis: auto;
flex-grow: 0;
object-fit: cover;
}
.attachment > video,
.attachment > img {
object-fit: cover;
}
.attachment > video {
object-fit: contain;
}
} }
} }
@@ -28,10 +66,6 @@
background-color: var(--bg_color); background-color: var(--bg_color);
align-items: center; align-items: center;
pointer-events: all; pointer-events: all;
.image-attachment {
width: 100%;
}
} }
.attachment { .attachment {
@@ -49,7 +83,14 @@
} }
} }
.gallery-gif video { .media-gif {
display: table;
background-color: unset;
width: unset;
max-height: unset;
}
.media-gif video {
max-height: 530px; max-height: 530px;
background-color: #101010; background-color: #101010;
} }
@@ -96,22 +137,6 @@
transition-property: max-height; transition-property: max-height;
} }
.image {
display: flex;
}
// .single-image {
// display: inline-block;
// width: 100%;
// max-height: 600px;
// .attachments {
// width: unset;
// max-height: unset;
// display: inherit;
// }
// }
.overlay-circle { .overlay-circle {
border-radius: 50%; border-radius: 50%;
background-color: var(--dark_grey); background-color: var(--dark_grey);
@@ -133,12 +158,6 @@
margin-left: 14px; margin-left: 14px;
} }
.media-gif {
display: table;
background-color: unset;
width: unset;
}
.media-body { .media-body {
flex: 1; flex: 1;
padding: 0; padding: 0;
+4 -3
View File
@@ -95,7 +95,7 @@
justify-content: center; justify-content: center;
} }
.gallery-gif .attachment { .media-gif > .attachment {
display: flex; display: flex;
justify-content: center; justify-content: center;
background-color: var(--bg_color); background-color: var(--bg_color);
@@ -108,8 +108,9 @@
} }
} }
.gallery-video, .gallery-row .attachment,
.gallery-gif { .gallery-row .attachment > video,
.gallery-row .attachment > img {
max-height: 300px; max-height: 300px;
} }
+14 -14
View File
@@ -9,22 +9,22 @@ video {
.gallery-video { .gallery-video {
display: flex; display: flex;
overflow: hidden; overflow: hidden;
}
&.card-container {
flex-direction: column;
width: 100%;
}
.gallery-video.card-container { > .attachment {
flex-direction: column; min-height: 80px;
width: 100%; min-width: 200px;
} max-height: 530px;
margin: 0;
.video-container { img {
min-height: 80px; max-height: 100%;
min-width: 200px; max-width: 100%;
max-height: 530px; }
margin: 0;
img {
max-height: 100%;
max-width: 100%;
} }
} }
+76 -3
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]
@@ -136,11 +168,28 @@ type
Gif* = object Gif* = object
url*: string url*: string
thumb*: string thumb*: string
altText*: string
Photo* = object Photo* = object
url*: string url*: string
altText*: string altText*: string
MediaKind* = enum
photoMedia
videoMedia
gifMedia
Media* = object
case kind*: MediaKind
of photoMedia:
photo*: Photo
of videoMedia:
video*: Video
of gifMedia:
gif*: Gif
MediaEntities* = seq[Media]
GalleryPhoto* = object GalleryPhoto* = object
url*: string url*: string
tweetId*: string tweetId*: string
@@ -219,11 +268,11 @@ type
quote*: Option[Tweet] quote*: Option[Tweet]
card*: Option[Card] card*: Option[Card]
poll*: Option[Poll] poll*: Option[Poll]
gif*: Option[Gif] media*: MediaEntities
video*: Option[Video]
photos*: seq[Photo]
history*: seq[int64] history*: seq[int64]
note*: string note*: string
isAd*: bool
isAI*: bool
Tweets* = seq[Tweet] Tweets* = seq[Tweet]
@@ -255,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
@@ -292,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
@@ -310,3 +362,24 @@ proc contains*(thread: Chain; tweet: Tweet): bool =
proc add*(timeline: var seq[Tweets]; tweet: Tweet) = proc add*(timeline: var seq[Tweets]; tweet: Tweet) =
timeline.add @[tweet] timeline.add @[tweet]
proc getPhotos*(tweet: Tweet): seq[Photo] =
tweet.media.filterIt(it.kind == photoMedia).mapIt(it.photo)
proc getVideos*(tweet: Tweet): seq[Video] =
tweet.media.filterIt(it.kind == videoMedia).mapIt(it.video)
proc hasPhotos*(tweet: Tweet): bool =
tweet.media.anyIt(it.kind == photoMedia)
proc hasVideos*(tweet: Tweet): bool =
tweet.media.anyIt(it.kind == videoMedia)
proc hasGifs*(tweet: Tweet): bool =
tweet.media.anyIt(it.kind == gifMedia)
proc getThumb*(media: Media): string =
case media.kind
of photoMedia: media.photo.url
of videoMedia: media.video.thumb
of gifMedia: media.gif.thumb
+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
+7 -4
View File
@@ -9,14 +9,17 @@ import general, tweet
const doctype = "<!DOCTYPE html>\n" const doctype = "<!DOCTYPE html>\n"
proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
let thumb = get(tweet.video).thumb let
let vidUrl = getVideoEmbed(cfg, tweet.id) video = tweet.getVideos()[0]
let prefs = Prefs(hlsPlayback: true, mp4Playback: true) thumb = video.thumb
vidUrl = getVideoEmbed(cfg, tweet.id)
prefs = Prefs(hlsPlayback: true, mp4Playback: true)
let node = buildHtml(html(lang="en")): let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb])) renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb]))
body: body:
tdiv(class="embed-video"): tdiv(class="embed-video"):
renderVideo(get(tweet.video), prefs, "") renderVideo(video, prefs, "")
result = doctype & $node result = doctype & $node
+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=28") 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}"
+44 -25
View File
@@ -1,29 +1,38 @@
#? stdtmpl(subsChar = '$', metaChar = '#') #? stdtmpl(subsChar = '$', metaChar = '#')
## SPDX-License-Identifier: AGPL-3.0-only ## SPDX-License-Identifier: AGPL-3.0-only
#import strutils, xmltree, strformat, options, unicode #import strutils, sequtils, xmltree, strformat, options, unicode
#import ../types, ../utils, ../formatters, ../prefs #import ../types, ../utils, ../formatters, ../prefs
## Snowflake ID cutoff for RSS GUID format transition ## Snowflake ID cutoff for RSS GUID format transition
## Corresponds to approximately December 14, 2025 UTC ## Corresponds to approximately December 14, 2025 UTC
#const guidCutoff = 2000000000000000000'i64 #const guidCutoff = 2000000000000000000'i64
# #
#proc getTitle(tweet: Tweet; retweet: string): string = #proc getTitle(tweet: Tweet; retweet: string): string =
#if tweet.pinned: result = "Pinned: " #var prefix = ""
#elif retweet.len > 0: result = &"RT by @{retweet}: " #if tweet.pinned: prefix = "Pinned: "
#elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: " #elif retweet.len > 0: prefix = &"RT by @{retweet}: "
#elif tweet.reply.len > 0: prefix = &"R to @{tweet.reply[0]}: "
#end if #end if
#var text = stripHtml(tweet.text) #var text = stripHtml(tweet.text)
##if unicode.runeLen(text) > 32: ##if unicode.runeLen(text) > 32:
## text = unicode.runeSubStr(text, 0, 32) & "..." ## text = unicode.runeSubStr(text, 0, 32) & "..."
##end if ##end if
#result &= xmltree.escape(text) #text = xmltree.escape(text)
#if result.len > 0: return #if text.len > 0:
# result = prefix & text
# return
#end if #end if
#if tweet.photos.len > 0: #if tweet.media.len > 0:
# result &= "Image" # result = prefix
#elif tweet.video.isSome: # let firstKind = tweet.media[0].kind
# result &= "Video" # if tweet.media.anyIt(it.kind != firstKind):
#elif tweet.gif.isSome: # result &= "Media"
# result &= "Gif" # else:
# case firstKind
# of photoMedia: result &= "Image"
# of videoMedia: result &= "Video"
# of gifMedia: result &= "Gif"
# end case
# end if
#end if #end if
#end proc #end proc
# #
@@ -31,6 +40,26 @@
Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)} Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
#end proc #end proc
# #
#proc renderRssMedia(media: Media; tweet: Tweet; urlPrefix: string): string =
#case media.kind
#of photoMedia:
# let photo = media.photo
<img src="${urlPrefix}${getPicUrl(photo.url)}" style="max-width:250px;" />
#of videoMedia:
# let video = media.video
<a href="${urlPrefix}${tweet.getLink}">
<br>Video<br>
<img src="${urlPrefix}${getPicUrl(video.thumb)}" style="max-width:250px;" />
</a>
#of gifMedia:
# let gif = media.gif
# let thumb = &"{urlPrefix}{getPicUrl(gif.thumb)}"
# let url = &"{urlPrefix}{getPicUrl(gif.url)}"
<video poster="${thumb}" autoplay muted loop style="max-width:250px;">
<source src="${url}" type="video/mp4"></video>
#end case
#end proc
#
#proc getTweetsWithPinned(profile: Profile): seq[Tweets] = #proc getTweetsWithPinned(profile: Profile): seq[Tweets] =
#result = profile.tweets.content #result = profile.tweets.content
#if profile.pinned.isSome and result.len > 0: #if profile.pinned.isSome and result.len > 0:
@@ -54,20 +83,10 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
#let urlPrefix = getUrlPrefix(cfg) #let urlPrefix = getUrlPrefix(cfg)
#let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix) #let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix)
<p>${text.replace("\n", "<br>\n")}</p> <p>${text.replace("\n", "<br>\n")}</p>
#if tweet.photos.len > 0: #if tweet.media.len > 0:
# for photo in tweet.photos: # for media in tweet.media:
<img src="${urlPrefix}${getPicUrl(photo.url)}" style="max-width:250px;" /> ${renderRssMedia(media, tweet, urlPrefix)}
# end for # end for
#elif tweet.video.isSome:
<a href="${urlPrefix}${tweet.getLink}">
<br>Video<br>
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
</a>
#elif tweet.gif.isSome:
# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
<video poster="${thumb}" autoplay muted loop style="max-width:250px;">
<source src="${url}" type="video/mp4"></video>
#elif tweet.card.isSome: #elif tweet.card.isSome:
# let card = tweet.card.get() # let card = tweet.card.get()
# if card.image.len > 0: # if card.image.len > 0:
+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()
+117 -71
View File
@@ -38,24 +38,21 @@ proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VN
a(href=getLink(tweet), title=tweet.getTime): a(href=getLink(tweet), title=tweet.getTime):
text tweet.getShortTime text tweet.getShortTime
proc renderAlbum(tweet: Tweet): VNode = proc renderAltText(altText: string): VNode =
let buildHtml(p(class="alt-text")):
groups = if tweet.photos.len < 3: @[tweet.photos] text "ALT " & altText
else: tweet.photos.distribute(2)
buildHtml(tdiv(class="attachments")): proc renderPhotoAttachment(photo: Photo; bigThumb=false): VNode =
for i, photos in groups: buildHtml(tdiv(class="attachment")):
let margin = if i > 0: ".25em" else: "" let
tdiv(class="gallery-row", style={marginTop: margin}): named = "name=" in photo.url
for photo in photos: thumb = if named: photo.url
tdiv(class="attachment image"): elif bigThumb: photo.url & mediumWebp
let else: photo.url & smallWebp
named = "name=" in photo.url a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"):
small = if named: photo.url else: photo.url & smallWebp genImg(thumb, alt=photo.altText)
a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"): if photo.altText.len > 0:
genImg(small, alt=photo.altText) renderAltText(photo.altText)
if photo.altText.len > 0:
p(class="alt-text"): text "ALT " & photo.altText
proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool = proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
case playbackType case playbackType
@@ -65,7 +62,7 @@ proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
proc hasMp4Url(video: Video): bool = proc hasMp4Url(video: Video): bool =
video.variants.anyIt(it.contentType == mp4) video.variants.anyIt(it.contentType == mp4)
proc renderVideoDisabled(playbackType: VideoType; path: string): VNode = proc renderVideoDisabled(playbackType: VideoType; path=""): VNode =
buildHtml(tdiv(class="video-overlay")): buildHtml(tdiv(class="video-overlay")):
case playbackType case playbackType
of mp4: of mp4:
@@ -81,52 +78,98 @@ proc renderVideoUnavailable(video: Video): VNode =
else: else:
p: text "This media is unavailable" p: text "This media is unavailable"
proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode = proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""; bigThumb=false): VNode =
let let
container = if video.description.len == 0 and video.title.len == 0: "" playbackType = if not prefs.proxyVideos and videoData.hasMp4Url: mp4
else: " card-container" else: videoData.playbackType
playbackType = if not prefs.proxyVideos and video.hasMp4Url: mp4 thumb = if bigThumb: getMediumPic(videoData.thumb) else: getSmallPic(videoData.thumb)
else: video.playbackType
buildHtml(tdiv(class="attachment")):
if not videoData.available:
img(src=thumb, loading="lazy")
renderVideoUnavailable(videoData)
elif not prefs.isPlaybackEnabled(playbackType):
img(src=thumb, loading="lazy")
renderVideoDisabled(playbackType, path)
else:
let
vars = videoData.variants.filterIt(it.contentType == playbackType)
vidUrl = vars.sortedByIt(it.resolution)[^1].url
source = if prefs.proxyVideos and vidUrl.startsWith("http"):
getVidUrl(vidUrl) else: vidUrl
case playbackType
of mp4:
video(poster=thumb, controls="", muted=prefs.muteVideos):
source(src=source, `type`="video/mp4")
of m3u8, vmap:
video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos)
verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
tdiv(class="overlay-circle"): span(class="overlay-triangle")
if videoData.durationMs > 0:
tdiv(class="overlay-duration"): text getDuration(videoData)
verbatim "</div>"
proc renderVideo*(video: Video; prefs: Prefs; path: string; bigThumb=false): VNode =
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" & container): tdiv(class=("gallery-video" & (if hasCardContent: " card-container" else: ""))):
tdiv(class="attachment video-container"): renderVideoAttachment(video, prefs, path, bigThumb)
let thumb = getSmallPic(video.thumb) if hasCardContent:
if not video.available:
img(src=thumb, loading="lazy")
renderVideoUnavailable(video)
elif not prefs.isPlaybackEnabled(playbackType):
img(src=thumb, loading="lazy")
renderVideoDisabled(playbackType, path)
else:
let
vars = video.variants.filterIt(it.contentType == playbackType)
vidUrl = vars.sortedByIt(it.resolution)[^1].url
source = if prefs.proxyVideos: getVidUrl(vidUrl)
else: vidUrl
case playbackType
of mp4:
video(poster=thumb, controls="", muted=prefs.muteVideos):
source(src=source, `type`="video/mp4")
of m3u8, vmap:
video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos)
verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
tdiv(class="overlay-circle"): span(class="overlay-triangle")
tdiv(class="overlay-duration"): text getDuration(video)
verbatim "</div>"
if container.len > 0:
tdiv(class="card-content"): tdiv(class="card-content"):
h2(class="card-title"): text video.title h2(class="card-title"): text video.title
if video.description.len > 0: if video.description.len > 0:
p(class="card-description"): text video.description p(class="card-description"): text video.description
proc renderGifAttachment(gif: Gif; prefs: Prefs): VNode =
let thumb = getSmallPic(gif.thumb)
buildHtml(tdiv(class="attachment")):
if not prefs.mp4Playback:
img(src=thumb, loading="lazy")
renderVideoDisabled(mp4)
elif prefs.autoplayGifs:
video(class="gif", poster=thumb, autoplay="", muted="", loop=""):
source(src=getPicUrl(gif.url), `type`="video/mp4")
else:
video(class="gif", poster=thumb, controls="", muted="", loop=""):
source(src=getPicUrl(gif.url), `type`="video/mp4")
if gif.altText.len > 0:
renderAltText(gif.altText)
proc renderGif(gif: Gif; prefs: Prefs): VNode = proc renderGif(gif: Gif; prefs: Prefs): VNode =
buildHtml(tdiv(class="attachments media-gif")): buildHtml(tdiv(class="attachments media-gif")):
tdiv(class="gallery-gif", style={maxHeight: "unset"}): renderGifAttachment(gif, prefs)
tdiv(class="attachment"):
video(class="gif", poster=getSmallPic(gif.thumb), autoplay=prefs.autoplayGifs, proc renderMedia(media: seq[Media]; prefs: Prefs; path: string; bigThumb=false): VNode =
controls="", muted="", loop=""): if media.len == 0:
source(src=getPicUrl(gif.url), `type`="video/mp4") return nil
if media.len == 1:
let item = media[0]
if item.kind == videoMedia:
return renderVideo(item.video, prefs, path, bigThumb)
if item.kind == gifMedia:
return renderGif(item.gif, prefs)
let
groups = if media.len < 3: @[media]
else: media.distribute(2)
buildHtml(tdiv(class="attachments")):
for i, mediaGroup in groups:
let margin = if i > 0: ".25em" else: ""
let rowClass = "gallery-row" &
(if mediaGroup.allIt(it.kind == photoMedia): "" else: " mixed-row")
tdiv(class=rowClass, style={marginTop: margin}):
for mediaItem in mediaGroup:
case mediaItem.kind
of photoMedia:
renderPhotoAttachment(mediaItem.photo, bigThumb)
of videoMedia:
renderVideoAttachment(mediaItem.video, prefs, path, bigThumb)
of gifMedia:
renderGifAttachment(mediaItem.gif, prefs)
proc renderPoll(poll: Poll): VNode = proc renderPoll(poll: Poll): VNode =
buildHtml(tdiv(class="poll")): buildHtml(tdiv(class="poll")):
@@ -217,15 +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")):
if quote.photos.len > 0:
renderAlbum(quote)
elif quote.video.isSome:
renderVideo(quote.video.get(), prefs, path)
elif quote.gif.isSome:
renderGif(quote.gif.get(), prefs)
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"):
@@ -234,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")):
@@ -266,7 +304,7 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
tdiv(class="quote-text", dir="auto"): tdiv(class="quote-text", dir="auto"):
verbatim replaceUrls(quote.text, prefs) verbatim replaceUrls(quote.text, prefs)
if quote.photos.len > 0 or quote.video.isSome or quote.gif.isSome: if quote.media.len > 0:
renderQuoteMedia(quote, prefs, path) renderQuoteMedia(quote, prefs, path)
if quote.note.len > 0 and not prefs.hideCommunityNotes: if quote.note.len > 0 and not prefs.hideCommunityNotes:
@@ -280,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
@@ -292,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
@@ -344,12 +391,8 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
if tweet.card.isSome and tweet.card.get().kind != hidden: if tweet.card.isSome and tweet.card.get().kind != hidden:
renderCard(tweet.card.get(), prefs, path) renderCard(tweet.card.get(), prefs, path)
if tweet.photos.len > 0: if tweet.media.len > 0:
renderAlbum(tweet) renderMedia(tweet.media, prefs, path, bigThumb)
elif tweet.video.isSome:
renderVideo(tweet.video.get(), prefs, path)
elif tweet.gif.isSome:
renderGif(tweet.gif.get(), prefs)
if tweet.poll.isSome: if tweet.poll.isSome:
renderPoll(tweet.poll.get()) renderPoll(tweet.poll.get())
@@ -360,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)
+8 -1
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):
@@ -79,7 +86,7 @@ class Media(object):
row = '.gallery-row' row = '.gallery-row'
image = '.still-image' image = '.still-image'
video = '.gallery-video' video = '.gallery-video'
gif = '.gallery-gif' gif = '.media-gif'
class BaseTestCase(BaseCase): class BaseTestCase(BaseCase):
+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)