mirror of
https://github.com/zedeus/nitter.git
synced 2026-05-03 02:52:12 -04:00
Compare commits
23 Commits
a15d1ce16b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74f5ff8acc | ||
|
|
4e38317582 | ||
|
|
8114eefa19 | ||
|
|
7d431781c3 | ||
|
|
0c7583432c | ||
|
|
e7e7050c6e | ||
|
|
741060c78b | ||
|
|
3429667414 | ||
|
|
fea6f59005 | ||
|
|
b6ccea0c7a | ||
|
|
b726767df4 | ||
|
|
33bf2c2397 | ||
|
|
7ce29bd8f1 | ||
|
|
91ff936cb3 | ||
|
|
0fefcf9917 | ||
|
|
35a929c415 | ||
|
|
4bf3df94f8 | ||
|
|
2898efab6b | ||
|
|
b0773dd934 | ||
|
|
d187b1cc3f | ||
|
|
95a9ee8dc5 | ||
|
|
61b6748d97 | ||
|
|
2bd664ae7d |
22
.github/workflows/build-docker.yml
vendored
22
.github/workflows/build-docker.yml
vendored
@@ -11,20 +11,19 @@ jobs:
|
||||
tests:
|
||||
uses: ./.github/workflows/run-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
build-docker-amd64:
|
||||
needs: [tests]
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
version: latest
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -36,20 +35,19 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }}
|
||||
|
||||
build-docker-arm64:
|
||||
needs: [tests]
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204-arm
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
version: latest
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
103
.github/workflows/run-tests.yml
vendored
103
.github/workflows/run-tests.yml
vendored
@@ -20,19 +20,17 @@ defaults:
|
||||
jobs:
|
||||
build-test:
|
||||
name: Build and test
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
nim: ["2.0.x", "2.2.x", "devel"]
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Cache Nimble Dependencies
|
||||
id: cache-nimble
|
||||
uses: buildjet/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.nimble
|
||||
key: ${{ matrix.nim }}-nimble-v2-${{ hashFiles('*.nimble') }}
|
||||
@@ -47,62 +45,101 @@ jobs:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Project
|
||||
run: nimble build -d:release -Y
|
||||
run: nimble build -Y
|
||||
|
||||
- name: Upload 2.2.x build artifact
|
||||
if: matrix.nim == '2.2.x'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: nitter-linux-nim-2.2.x-${{ github.sha }}
|
||||
path: |
|
||||
./nitter
|
||||
if-no-files-found: error
|
||||
|
||||
integration-test:
|
||||
needs: [build-test]
|
||||
name: Integration test
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
steps:
|
||||
- name: Install runtime deps
|
||||
run: |
|
||||
sudo apt-get install -y --no-install-recommends libsass-dev libpcre3
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Cache pipx (poetry)
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
path: |
|
||||
~/.local/pipx
|
||||
~/.local/bin
|
||||
key: pipx-poetry-${{ runner.os }}
|
||||
|
||||
- name: Install poetry
|
||||
env:
|
||||
PIPX_HOME: ~/.local/pipx
|
||||
PIPX_BIN_DIR: ~/.local/bin
|
||||
run: command -v poetry >/dev/null 2>&1 || pipx install poetry
|
||||
|
||||
- name: Setup Python (3.14) with Poetry cache
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
cache: poetry
|
||||
cache-dependency-path: tests/poetry.lock
|
||||
|
||||
- name: Install Python deps
|
||||
working-directory: tests
|
||||
run: poetry sync
|
||||
|
||||
- name: Cache Nimble Dependencies
|
||||
id: cache-nimble
|
||||
uses: buildjet/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.nimble
|
||||
key: devel-nimble-v2-${{ hashFiles('*.nimble') }}
|
||||
key: 2.2.x-nimble-v2-${{ hashFiles('*.nimble') }}
|
||||
restore-keys: |
|
||||
devel-nimble-v2-
|
||||
|
||||
- name: Setup Python (3.10) with pip cache
|
||||
uses: buildjet/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: pip
|
||||
2.2.x-nimble-v2-
|
||||
|
||||
- name: Setup Nim
|
||||
uses: jiro4989/setup-nim-action@v2
|
||||
with:
|
||||
nim-version: devel
|
||||
nim-version: 2.2.x
|
||||
use-nightlies: true
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Project
|
||||
run: nimble build -d:release -Y
|
||||
- name: Download 2.2.x build artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: nitter-linux-nim-2.2.x-${{ github.sha }}
|
||||
path: .
|
||||
|
||||
- name: Install SeleniumBase and Chromedriver
|
||||
run: |
|
||||
pip install seleniumbase
|
||||
seleniumbase install chromedriver
|
||||
|
||||
- name: Start Redis Service
|
||||
uses: supercharge/redis-github-action@1.5.0
|
||||
- name: Make nitter binary executable
|
||||
run: chmod +x ./nitter
|
||||
|
||||
- name: Prepare Nitter Environment
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y libsass-dev
|
||||
cp nitter.example.conf nitter.conf
|
||||
sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf
|
||||
nimble md
|
||||
nimble scss
|
||||
sed -i 's/maxRetries = 1/maxRetries = 10/g' nitter.conf
|
||||
|
||||
# Run both Nimble tasks concurrently
|
||||
nim r tools/rendermd.nim &
|
||||
nim r tools/gencss.nim &
|
||||
wait
|
||||
|
||||
echo '${{ secrets.SESSIONS }}' | head -n1
|
||||
echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
./nitter &
|
||||
pytest -n1 tests
|
||||
cd tests
|
||||
poetry run pytest -n3 --reruns=5 --rs .
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,3 +13,5 @@ nitter.conf
|
||||
guest_accounts.json*
|
||||
sessions.json*
|
||||
dump.rdb
|
||||
*.bak
|
||||
/tools/*.json*
|
||||
|
||||
@@ -34,6 +34,8 @@ proxyAuth = ""
|
||||
apiProxy = "" # nitter-proxy host, e.g. localhost:7000
|
||||
disableTid = false # enable this if cookie-based auth is failing
|
||||
maxConcurrentReqs = 2 # max requests at a time per session to avoid race conditions
|
||||
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
|
||||
[Preferences]
|
||||
|
||||
@@ -28,7 +28,7 @@ requires "oauth#b8c163b"
|
||||
# Tasks
|
||||
|
||||
task scss, "Generate css":
|
||||
exec "nimble c --hint[Processing]:off -d:danger -r tools/gencss"
|
||||
exec "nim r --hint[Processing]:off tools/gencss"
|
||||
|
||||
task md, "Render md":
|
||||
exec "nimble c --hint[Processing]:off -d:danger -r tools/rendermd"
|
||||
exec "nim r --hint[Processing]:off tools/rendermd"
|
||||
|
||||
22
public/css/fontello.css
vendored
22
public/css/fontello.css
vendored
@@ -1,12 +1,12 @@
|
||||
@font-face {
|
||||
font-family: "fontello";
|
||||
src: url("/fonts/fontello.eot?77185648");
|
||||
src: url("/fonts/fontello.eot?49059696");
|
||||
src:
|
||||
url("/fonts/fontello.eot?77185648#iefix") format("embedded-opentype"),
|
||||
url("/fonts/fontello.woff2?77185648") format("woff2"),
|
||||
url("/fonts/fontello.woff?77185648") format("woff"),
|
||||
url("/fonts/fontello.ttf?77185648") format("truetype"),
|
||||
url("/fonts/fontello.svg?77185648#fontello") format("svg");
|
||||
url("/fonts/fontello.eot?49059696#iefix") format("embedded-opentype"),
|
||||
url("/fonts/fontello.woff2?49059696") format("woff2"),
|
||||
url("/fonts/fontello.woff?49059696") format("woff"),
|
||||
url("/fonts/fontello.ttf?49059696") format("truetype"),
|
||||
url("/fonts/fontello.svg?49059696#fontello") format("svg");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
@@ -56,6 +56,11 @@
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-group:before {
|
||||
content: "\e804";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-play:before {
|
||||
content: "\e805";
|
||||
}
|
||||
@@ -121,6 +126,11 @@
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-attention:before {
|
||||
content: "\e812";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-circle:before {
|
||||
content: "\f111";
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<metadata>Copyright (C) 2025 by original authors @ fontello.com</metadata>
|
||||
<metadata>Copyright (C) 2026 by original authors @ fontello.com</metadata>
|
||||
<defs>
|
||||
<font id="fontello" horiz-adv-x="1000" >
|
||||
<font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
<glyph glyph-name="comment" unicode="" d="M1000 350q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12-10-1-17 5t-10 16v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 73 40 139t106 114 160 76 194 28q136 0 251-48t182-130 67-179z" horiz-adv-x="1000" />
|
||||
|
||||
<glyph glyph-name="group" unicode="" d="M0 106l0 134q0 26 18 32l171 80q-66 39-68 131 0 56 35 103 37 41 90 43 31 0 63-19-49-125 23-237-12-11-25-19l-114-55q-48-23-52-84l0-143-114 0q-25 0-27 34z m193-59l0 168q0 27 22 37l152 70 57 28q-37 23-60 66t-22 94q0 76 46 130t110 54 109-54 45-130q0-105-78-158l61-30 146-70q24-10 24-37l0-168q-2-37-37-41l-541 0q-14 2-24 14t-10 27z m473 330q68 106 22 231 31 19 66 21 49 0 90-43 35-41 35-103 0-82-65-131l168-80q18-10 18-32l0-134q0-32-27-34l-118 0 0 143q0 57-50 84l-110 53q-15 8-29 25z" horiz-adv-x="1000" />
|
||||
|
||||
<glyph glyph-name="play" unicode="" d="M772 333l-741-412q-13-7-22-2t-9 20v822q0 14 9 20t22-2l741-412q13-7 13-17t-13-17z" horiz-adv-x="785.7" />
|
||||
|
||||
<glyph glyph-name="link" unicode="" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" />
|
||||
@@ -40,6 +42,8 @@
|
||||
|
||||
<glyph glyph-name="ok" unicode="" 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="" 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="" 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="" 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.4 KiB After Width: | Height: | Size: 7.3 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,6 +3,7 @@
|
||||
function playVideo(overlay) {
|
||||
const video = overlay.parentElement.querySelector('video');
|
||||
const url = video.getAttribute("data-url");
|
||||
const startTime = parseFloat(video.getAttribute("data-start") || "0");
|
||||
video.setAttribute("controls", "");
|
||||
overlay.style.display = "none";
|
||||
|
||||
@@ -12,12 +13,13 @@ function playVideo(overlay) {
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, function () {
|
||||
hls.loadLevel = hls.levels.length - 1;
|
||||
hls.startLoad();
|
||||
hls.startLoad(startTime);
|
||||
video.play();
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = url;
|
||||
video.addEventListener('canplay', function() {
|
||||
if (startTime > 0) video.currentTime = startTime;
|
||||
video.play();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,77 +1,225 @@
|
||||
// @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
function insertBeforeLast(node, elem) {
|
||||
node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]);
|
||||
node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]);
|
||||
}
|
||||
|
||||
function getLoadMore(doc) {
|
||||
return doc.querySelector(".show-more:not(.timeline-item)");
|
||||
return doc.querySelector(".show-more:not(.timeline-item)");
|
||||
}
|
||||
|
||||
function isDuplicate(item, itemClass) {
|
||||
const tweet = item.querySelector(".tweet-link");
|
||||
if (tweet == null) return false;
|
||||
const href = tweet.getAttribute("href");
|
||||
return document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != null;
|
||||
function getHrefs(selector) {
|
||||
return new Set([...document.querySelectorAll(selector)].map(el => el.getAttribute("href")));
|
||||
}
|
||||
|
||||
window.onload = function () {
|
||||
const url = window.location.pathname;
|
||||
const isTweet = url.indexOf("/status/") !== -1;
|
||||
const containerClass = isTweet ? ".replies" : ".timeline";
|
||||
const itemClass = containerClass + " > div:not(.top-ref)";
|
||||
function getTweetId(item) {
|
||||
const m = item.querySelector(".tweet-link")?.getAttribute("href")?.match(/\/status\/(\d+)/);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
|
||||
var html = document.querySelector("html");
|
||||
var container = document.querySelector(containerClass);
|
||||
var loading = false;
|
||||
function isDuplicate(item, hrefs) {
|
||||
return hrefs.has(item.querySelector(".tweet-link")?.getAttribute("href"));
|
||||
}
|
||||
|
||||
function handleScroll(failed) {
|
||||
if (loading) return;
|
||||
const GAP = 10;
|
||||
|
||||
if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) {
|
||||
loading = true;
|
||||
var loadMore = getLoadMore(document);
|
||||
if (loadMore == null) return;
|
||||
class Masonry {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
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);
|
||||
url.searchParams.append("scroll", "true");
|
||||
// Re-sync positions whenever images finish loading and items grow taller.
|
||||
// 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()).then(function (response) {
|
||||
if (response.status === 404) throw "error";
|
||||
this._rebuild();
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}).then(function (html) {
|
||||
var parser = new DOMParser();
|
||||
var doc = parser.parseFromString(html, "text/html");
|
||||
loadMore.remove();
|
||||
// Reveal all items and gallery siblings (show-more, top-ref). Idempotent.
|
||||
_revealAll() {
|
||||
clearTimeout(this._revealTimer);
|
||||
for (const item of this._items) item.classList.add("masonry-visible");
|
||||
for (const el of this.container.parentElement.querySelectorAll(":scope > .show-more, :scope > .top-ref, :scope > .timeline-footer"))
|
||||
el.classList.add("masonry-visible");
|
||||
}
|
||||
|
||||
for (var item of doc.querySelectorAll(itemClass)) {
|
||||
if (item.className == "timeline-item show-more") continue;
|
||||
if (isDuplicate(item, itemClass)) continue;
|
||||
if (isTweet) container.appendChild(item);
|
||||
else insertBeforeLast(container, item);
|
||||
}
|
||||
// Height-primary, count-as-tiebreaker: handles both tall tweets and unloaded images.
|
||||
_pickCol() {
|
||||
return this.colHeights.reduce((min, h, i) => {
|
||||
const m = this.colHeights[min];
|
||||
return (h < m || (h === m && this.colCounts[i] < this.colCounts[min])) ? i : min;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
loading = false;
|
||||
const newLoadMore = getLoadMore(doc);
|
||||
if (newLoadMore == null) return;
|
||||
if (isTweet) container.appendChild(newLoadMore);
|
||||
else insertBeforeLast(container, newLoadMore);
|
||||
}).catch(function (err) {
|
||||
console.warn("Something went wrong.", err);
|
||||
if (failed > 3) {
|
||||
loadMore.children[0].text = "Error";
|
||||
return;
|
||||
}
|
||||
// Position items using current column state. Updates colHeights, colCounts, container height.
|
||||
_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`;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
handleScroll((failed || 0) + 1);
|
||||
});
|
||||
}
|
||||
// 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")];
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", () => handleScroll());
|
||||
};
|
||||
// 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());
|
||||
});
|
||||
// @license-end
|
||||
|
||||
61
src/api.nim
61
src/api.nim
@@ -1,7 +1,7 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import asyncdispatch, httpclient, strutils, sequtils, sugar
|
||||
import packedjson
|
||||
import types, query, formatters, consts, apiutils, parser
|
||||
import types, query, formatters, consts, apiutils, parser, utils
|
||||
import experimental/parser as newParser
|
||||
|
||||
# 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)
|
||||
return ApiReq(cookie: url, oauth: url)
|
||||
|
||||
proc mediaUrl(id: string; cursor: string): ApiReq =
|
||||
proc mediaUrl(id, cursor: string; count=20): ApiReq =
|
||||
result = ApiReq(
|
||||
cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor]),
|
||||
oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor])
|
||||
cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor, $count]),
|
||||
oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor, $count])
|
||||
)
|
||||
|
||||
proc userTweetsUrl(id: string; cursor: string): ApiReq =
|
||||
result = ApiReq(
|
||||
# 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
|
||||
result.cookie = result.oauth
|
||||
@@ -36,7 +36,7 @@ proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
|
||||
let cookieVars = userTweetsAndRepliesVars % [id, cursor]
|
||||
result = ApiReq(
|
||||
cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles),
|
||||
oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor])
|
||||
oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"])
|
||||
)
|
||||
|
||||
proc tweetDetailUrl(id: string; cursor: string): ApiReq =
|
||||
@@ -66,6 +66,32 @@ proc getGraphUserById*(id: string): Future[User] {.async.} =
|
||||
js = await fetchRaw(url)
|
||||
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.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
@@ -73,7 +99,7 @@ proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profi
|
||||
url = case kind
|
||||
of TimelineKind.tweets: userTweetsUrl(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)
|
||||
result = parseGraphTimeline(js, after)
|
||||
|
||||
@@ -81,7 +107,7 @@ proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
url = apiReq(graphListTweets, restIdVars % [id, cursor])
|
||||
url = apiReq(graphListTweets, restIdVars % [id, cursor, "20"])
|
||||
js = await fetch(url)
|
||||
result = parseGraphTimeline(js, after).tweets
|
||||
|
||||
@@ -146,7 +172,12 @@ proc getGraphEditHistory*(id: string): Future[EditHistory] {.async.} =
|
||||
result = parseGraphEditHistory(js, id)
|
||||
|
||||
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:
|
||||
return Timeline(query: query, beginning: true)
|
||||
|
||||
@@ -160,14 +191,20 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false
|
||||
}
|
||||
if after.len > 0:
|
||||
if after.len > 0 and maxId.len == 0:
|
||||
variables["cursor"] = % after
|
||||
let
|
||||
let
|
||||
url = apiReq(graphSearchTimeline, $variables)
|
||||
js = await fetch(url)
|
||||
result = parseGraphSearch[Tweets](js, after)
|
||||
result.query = query
|
||||
|
||||
# when no more items are available the API just returns the last page in
|
||||
# full. this detects that and clears the page instead.
|
||||
if after.len > 0 and result.bottom.len > 0 and maxId.len == 0 and
|
||||
after[0..<64] == result.bottom[0..<64]:
|
||||
result.content.setLen(0)
|
||||
|
||||
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
|
||||
if query.text.len == 0:
|
||||
return Result[User](query: query, beginning: true)
|
||||
@@ -194,7 +231,7 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.}
|
||||
|
||||
proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
|
||||
if id.len == 0: return
|
||||
let js = await fetch(mediaUrl(id, ""))
|
||||
let js = await fetch(mediaUrl(id, "", 30))
|
||||
result = parseGraphPhotoRail(js)
|
||||
|
||||
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
|
||||
|
||||
@@ -10,28 +10,38 @@ const
|
||||
rlLimit = "x-rate-limit-limit"
|
||||
errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest}
|
||||
|
||||
var
|
||||
var
|
||||
pool: HttpPool
|
||||
disableTid: bool
|
||||
apiProxy: string
|
||||
maxRetries: int
|
||||
retryDelayMs: int
|
||||
|
||||
proc setDisableTid*(disable: bool) =
|
||||
disableTid = disable
|
||||
|
||||
proc setMaxRetries*(n: int) =
|
||||
maxRetries = n
|
||||
|
||||
proc setRetryDelayMs*(ms: int) =
|
||||
retryDelayMs = ms
|
||||
|
||||
proc setApiProxy*(url: string) =
|
||||
apiProxy = ""
|
||||
if url.len > 0:
|
||||
apiProxy = url.strip(chars={'/'}) & "/"
|
||||
if "http" notin apiProxy:
|
||||
apiProxy = "http://" & apiProxy
|
||||
|
||||
proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri =
|
||||
case sessionKind
|
||||
of oauth:
|
||||
let o = req.oauth
|
||||
parseUri("https://api.x.com/graphql") / o.endpoint ? o.params
|
||||
of cookie:
|
||||
let c = req.cookie
|
||||
parseUri("https://x.com/i/api/graphql") / c.endpoint ? c.params
|
||||
let url = case sessionKind
|
||||
of oauth: req.oauth
|
||||
of cookie: req.cookie
|
||||
let base = case sessionKind
|
||||
of oauth: "https://api.x.com"
|
||||
of cookie: "https://x.com/i/api"
|
||||
let prefix = if url.endpoint.startsWith("1.1/"): "" else: "graphql/"
|
||||
parseUri(base) / (prefix & url.endpoint) ? url.params
|
||||
|
||||
proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
|
||||
let
|
||||
@@ -80,7 +90,7 @@ proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} =
|
||||
result["sec-fetch-dest"] = "empty"
|
||||
result["sec-fetch-mode"] = "cors"
|
||||
result["sec-fetch-site"] = "same-site"
|
||||
if disableTid:
|
||||
if disableTid or "/1.1/" in url.path:
|
||||
result["authorization"] = bearerToken2
|
||||
else:
|
||||
result["authorization"] = bearerToken
|
||||
@@ -107,7 +117,7 @@ template fetchImpl(result, fetchBody) {.dirty.} =
|
||||
pool.use(await genHeaders(session, url)):
|
||||
template getContent =
|
||||
# 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))
|
||||
else:
|
||||
resp = await c.get($url)
|
||||
@@ -119,6 +129,10 @@ template fetchImpl(result, fetchBody) {.dirty.} =
|
||||
badClient = true
|
||||
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):
|
||||
let
|
||||
remaining = parseInt(resp.headers[rlRemaining])
|
||||
@@ -164,11 +178,15 @@ template fetchImpl(result, fetchBody) {.dirty.} =
|
||||
release(session)
|
||||
|
||||
template retry(bod) =
|
||||
try:
|
||||
bod
|
||||
except RateLimitError:
|
||||
echo "[sessions] Rate limited, retrying ", req.cookie.endpoint, " request..."
|
||||
bod
|
||||
for i in 0 ..< maxRetries:
|
||||
try:
|
||||
bod
|
||||
break
|
||||
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.} =
|
||||
retry:
|
||||
|
||||
@@ -49,7 +49,9 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
|
||||
proxyAuth: cfg.get("Config", "proxyAuth", ""),
|
||||
apiProxy: cfg.get("Config", "apiProxy", ""),
|
||||
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)
|
||||
|
||||
@@ -16,7 +16,7 @@ const
|
||||
graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
|
||||
graphUserMedia* = "36oKqyQ7E_9CmtONGjJRsA/UserMedia"
|
||||
graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2"
|
||||
graphTweet* = "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline"
|
||||
graphTweet* = "b4pV7sWOe97RncwHcGESUA/ConversationTimeline"
|
||||
graphTweetDetail* = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail"
|
||||
graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery"
|
||||
graphTweetEditHistory* = "upS9teTSG45aljmP9oTuXA/TweetEditHistory"
|
||||
@@ -25,6 +25,10 @@ const
|
||||
graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
|
||||
graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
|
||||
graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
|
||||
graphAboutAccount* = "zs_jFPFT78rBpXv9Z3U2YQ/AboutAccountQuery"
|
||||
|
||||
graphBroadcast* = "0nMmbMh-_JwwRRFNXkyH3Q/BroadcastQuery"
|
||||
restLiveStream* = "1.1/live_video_stream/status/"
|
||||
|
||||
gqlFeatures* = """{
|
||||
"android_ad_formats_media_component_render_overlay_enabled": false,
|
||||
@@ -113,7 +117,7 @@ const
|
||||
$2
|
||||
"includeHasBirdwatchNotes": false,
|
||||
"includePromotedContent": false,
|
||||
"withBirdwatchNotes": false,
|
||||
"withBirdwatchNotes": true,
|
||||
"withVoice": false,
|
||||
"withV2Timeline": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
@@ -138,12 +142,12 @@ const
|
||||
|
||||
restIdVars* = """{
|
||||
"rest_id": "$1", $2
|
||||
"count": 20
|
||||
"count": $3
|
||||
}"""
|
||||
|
||||
userMediaVars* = """{
|
||||
"userId": "$1", $2
|
||||
"count": 20,
|
||||
"count": $3,
|
||||
"includePromotedContent": false,
|
||||
"withClientEventToken": false,
|
||||
"withBirdwatchNotes": false,
|
||||
|
||||
@@ -91,7 +91,17 @@ proc getM3u8Url*(content: string): string =
|
||||
if re.find(content, m3u8Regex, matches) != -1:
|
||||
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)]
|
||||
for line in manifest.splitLines:
|
||||
let url =
|
||||
@@ -99,9 +109,13 @@ proc proxifyVideo*(manifest: string; proxy: bool): string =
|
||||
elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line:
|
||||
line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))]
|
||||
else: line
|
||||
if url.startsWith('/'):
|
||||
let path = "https://video.twimg.com" & url
|
||||
replacements.add (url, if proxy: path.getVidUrl else: path)
|
||||
let resolved =
|
||||
if url.startsWith('/'): baseUrl & url
|
||||
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)
|
||||
|
||||
proc getUserPic*(userPic: string; style=""): string =
|
||||
@@ -154,16 +168,18 @@ proc getShortTime*(tweet: Tweet): string =
|
||||
else:
|
||||
result = "now"
|
||||
|
||||
proc getDuration*(video: Video): string =
|
||||
let
|
||||
ms = video.durationMs
|
||||
proc getDuration*(ms: int): string =
|
||||
let
|
||||
sec = int(round(ms / 1000))
|
||||
min = floorDiv(sec, 60)
|
||||
hour = floorDiv(min, 60)
|
||||
if hour > 0:
|
||||
return &"{hour}:{min mod 60}:{sec mod 60:02}"
|
||||
&"{hour}:{min mod 60:02}:{sec mod 60:02}"
|
||||
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 =
|
||||
var username = username
|
||||
|
||||
@@ -10,7 +10,7 @@ import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
|
||||
import views/[general, about]
|
||||
import routes/[
|
||||
preferences, timeline, status, media, search, rss, list, debug,
|
||||
unsupported, embed, resolver, router_utils]
|
||||
unsupported, embed, resolver, broadcast, router_utils]
|
||||
|
||||
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
||||
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
||||
@@ -40,6 +40,8 @@ setHttpProxy(cfg.proxy, cfg.proxyAuth)
|
||||
setApiProxy(cfg.apiProxy)
|
||||
setDisableTid(cfg.disableTid)
|
||||
setMaxConcurrentReqs(cfg.maxConcurrentReqs)
|
||||
setMaxRetries(cfg.maxRetries)
|
||||
setRetryDelayMs(cfg.retryDelayMs)
|
||||
initAboutPage(cfg.staticDir)
|
||||
|
||||
waitFor initRedisPool(cfg)
|
||||
@@ -56,6 +58,7 @@ createSearchRouter(cfg)
|
||||
createMediaRouter(cfg)
|
||||
createEmbedRouter(cfg)
|
||||
createRssRouter(cfg)
|
||||
createBroadcastRouter(cfg)
|
||||
createDebugRouter(cfg)
|
||||
|
||||
settings:
|
||||
@@ -119,5 +122,6 @@ routes:
|
||||
extend preferences, ""
|
||||
extend resolver, ""
|
||||
extend embed, ""
|
||||
extend broadcastRoute, ""
|
||||
extend debug, ""
|
||||
extend unsupported, ""
|
||||
|
||||
188
src/parser.nim
188
src/parser.nim
@@ -6,6 +6,16 @@ import experimental/parser/unifiedcard
|
||||
|
||||
proc parseGraphTweet(js: JsonNode): Tweet
|
||||
|
||||
proc parseVerifiedType(s: string; current: VerifiedType): VerifiedType =
|
||||
try: parseEnum[VerifiedType](s)
|
||||
except ValueError: current
|
||||
|
||||
proc parseCommunityNote(js: JsonNode): string =
|
||||
let subtitle = js{"subtitle"}
|
||||
result = subtitle{"text"}.getStr
|
||||
with entities, subtitle{"entities"}:
|
||||
result = expandBirdwatchEntities(result, entities)
|
||||
|
||||
proc parseUser(js: JsonNode; id=""): User =
|
||||
if js.isNull: return
|
||||
result = User(
|
||||
@@ -29,7 +39,7 @@ proc parseUser(js: JsonNode; id=""): User =
|
||||
result.verifiedType = blue
|
||||
|
||||
with verifiedType, js{"verified_type"}:
|
||||
result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
|
||||
result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType)
|
||||
|
||||
result.expandUserEntities(js)
|
||||
|
||||
@@ -55,11 +65,70 @@ proc parseGraphUser(js: JsonNode): User =
|
||||
result.fullname = user{"core", "name"}.getStr
|
||||
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
|
||||
|
||||
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 =
|
||||
if js.isNull: return
|
||||
@@ -134,27 +203,37 @@ proc parseVideo(js: JsonNode): Video =
|
||||
|
||||
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) =
|
||||
with jsMedia, js{"extended_entities", "media"}:
|
||||
for m in jsMedia:
|
||||
case m.getTypeName:
|
||||
of "photo":
|
||||
result.photos.add Photo(
|
||||
result.media.addMedia(Photo(
|
||||
url: m{"media_url_https"}.getImageStr,
|
||||
altText: m{"ext_alt_text"}.getStr
|
||||
)
|
||||
))
|
||||
of "video":
|
||||
result.video = some(parseVideo(m))
|
||||
result.media.addMedia(parseVideo(m))
|
||||
with user, m{"additional_media_info", "source_user"}:
|
||||
if user{"id"}.getInt > 0:
|
||||
result.attribution = some(parseUser(user))
|
||||
else:
|
||||
result.attribution = some(parseGraphUser(user))
|
||||
of "animated_gif":
|
||||
result.gif = some Gif(
|
||||
result.media.addMedia(Gif(
|
||||
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
|
||||
|
||||
with url, m{"url"}:
|
||||
@@ -164,29 +243,41 @@ proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
|
||||
|
||||
proc parseMediaEntities(js: JsonNode; result: var Tweet) =
|
||||
with mediaEntities, js{"media_entities"}:
|
||||
var parsedMedia: MediaEntities
|
||||
for mediaEntity in mediaEntities:
|
||||
with mediaInfo, mediaEntity{"media_results", "result", "media_info"}:
|
||||
case mediaInfo.getTypeName
|
||||
of "ApiImage":
|
||||
result.photos.add Photo(
|
||||
parsedMedia.addMedia(Photo(
|
||||
url: mediaInfo{"original_img_url"}.getImageStr,
|
||||
altText: mediaInfo{"alt_text"}.getStr
|
||||
)
|
||||
))
|
||||
of "ApiVideo":
|
||||
let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"}
|
||||
result.video = some Video(
|
||||
parsedMedia.addMedia(Video(
|
||||
available: status.getStr == "Available",
|
||||
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
|
||||
title: mediaInfo{"alt_text"}.getStr,
|
||||
durationMs: mediaInfo{"duration_millis"}.getInt,
|
||||
variants: parseVideoVariants(mediaInfo{"variants"})
|
||||
)
|
||||
))
|
||||
of "ApiGif":
|
||||
result.gif = some Gif(
|
||||
parsedMedia.addMedia(Gif(
|
||||
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
|
||||
|
||||
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
|
||||
with mediaList, js{"legacy", "entities", "media"}:
|
||||
for url in mediaList:
|
||||
@@ -216,14 +307,23 @@ proc parsePromoVideo(js: JsonNode): Video =
|
||||
result.variants.add variant
|
||||
|
||||
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(
|
||||
kind: broadcast,
|
||||
url: js{"broadcast_url"}.getStrVal,
|
||||
url: "/i/broadcasts/" & broadcastId,
|
||||
title: js{"broadcaster_display_name"}.getStrVal,
|
||||
text: js{"broadcast_title"}.getStrVal,
|
||||
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 =
|
||||
@@ -285,7 +385,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
||||
|
||||
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
|
||||
replyId: int64 = 0): Tweet =
|
||||
if js.isNull: return
|
||||
if js.isNull: return Tweet()
|
||||
|
||||
let time =
|
||||
if js{"created_at"}.notNull: js{"created_at"}.getTime
|
||||
@@ -342,13 +442,13 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
|
||||
let name = jsCard{"name"}.getStr
|
||||
if "poll" in name:
|
||||
if "image" in name:
|
||||
result.photos.add Photo(
|
||||
result.media.addMedia(Photo(
|
||||
url: jsCard{"binding_values", "image_large"}.getImageVal
|
||||
)
|
||||
))
|
||||
|
||||
result.poll = some parsePoll(jsCard)
|
||||
elif name == "amplify":
|
||||
result.video = some parsePromoVideo(jsCard{"binding_values"})
|
||||
result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
|
||||
else:
|
||||
result.card = some parseCard(jsCard, js{"entities", "urls"})
|
||||
|
||||
@@ -387,7 +487,7 @@ proc parseGraphTweet(js: JsonNode): Tweet =
|
||||
else:
|
||||
discard
|
||||
|
||||
if not js.hasKey("legacy"):
|
||||
if "legacy" notin js and "rest_id" notin js:
|
||||
return Tweet()
|
||||
|
||||
var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"})
|
||||
@@ -410,8 +510,41 @@ proc parseGraphTweet(js: JsonNode): Tweet =
|
||||
with restId, js{"reply_to_results", "rest_id"}:
|
||||
replyId = restId.getId
|
||||
|
||||
result = parseTweet(js{"legacy"}, jsCard, replyId)
|
||||
result.id = js{"rest_id"}.getId
|
||||
if "details" in js:
|
||||
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"})
|
||||
|
||||
if result.reply.len == 0:
|
||||
@@ -439,6 +572,9 @@ proc parseGraphTweet(js: JsonNode): Tweet =
|
||||
for id in ids:
|
||||
result.history.add parseBiggestInt(id.getStr)
|
||||
|
||||
with birdwatch, js{"birdwatch_pivot"}:
|
||||
result.note = parseCommunityNote(birdwatch)
|
||||
|
||||
proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
|
||||
for t in ? js{"content", "items"}:
|
||||
let entryId = t.getEntryId
|
||||
|
||||
@@ -88,6 +88,14 @@ proc getTimeFromMs*(js: JsonNode): DateTime =
|
||||
let seconds = ms div 1000
|
||||
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.} =
|
||||
let start = id.rfind("-")
|
||||
if start < 0:
|
||||
@@ -320,6 +328,58 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
|
||||
|
||||
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) =
|
||||
let
|
||||
entities = ? js{"entity_set"}
|
||||
@@ -330,11 +390,29 @@ proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
|
||||
|
||||
tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose))
|
||||
|
||||
proc expandBirdwatchEntities*(text: string; entities: JsonNode): string =
|
||||
let runes = text.toRunes
|
||||
var replacements: seq[ReplaceSlice]
|
||||
|
||||
for entity in entities:
|
||||
let
|
||||
fromIdx = entity{"from_index"}.getInt
|
||||
toIdx = entity{"to_index"}.getInt
|
||||
url = entity{"ref", "url"}.getStr
|
||||
if url.len > 0:
|
||||
replacements.add ReplaceSlice(
|
||||
kind: rkUrl,
|
||||
slice: fromIdx ..< toIdx,
|
||||
url: url,
|
||||
display: $runes[fromIdx ..< min(toIdx, runes.len)]
|
||||
)
|
||||
|
||||
replacements.sort(cmp)
|
||||
result = runes.replacedWith(replacements, 0 ..< runes.len)
|
||||
|
||||
proc extractGalleryPhoto*(t: Tweet): GalleryPhoto =
|
||||
let url =
|
||||
if t.photos.len > 0: t.photos[0].url
|
||||
elif t.video.isSome: get(t.video).thumb
|
||||
elif t.gif.isSome: get(t.gif).thumb
|
||||
if t.media.len > 0: t.media[0].getThumb
|
||||
elif t.card.isSome: get(t.card).image
|
||||
else: ""
|
||||
|
||||
|
||||
@@ -78,6 +78,9 @@ genPrefs:
|
||||
hideReplies(checkbox, false):
|
||||
"Hide tweet replies"
|
||||
|
||||
hideCommunityNotes(checkbox, false):
|
||||
"Hide community notes"
|
||||
|
||||
squareAvatars(checkbox, false):
|
||||
"Square profile pictures"
|
||||
|
||||
@@ -97,6 +100,17 @@ genPrefs:
|
||||
autoplayGifs(checkbox, true):
|
||||
"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)":
|
||||
replaceTwitter(input, ""):
|
||||
"Twitter -> Nitter"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat, sequtils, tables, uri
|
||||
|
||||
import types
|
||||
import types, utils
|
||||
|
||||
const
|
||||
validFilters* = @[
|
||||
@@ -17,14 +17,10 @@ template `@`(param: string): untyped =
|
||||
if param in pms: pms[param]
|
||||
else: ""
|
||||
|
||||
proc validateNumber(value: string): string =
|
||||
if value.anyIt(not it.isDigit):
|
||||
return ""
|
||||
return value
|
||||
|
||||
proc initQuery*(pms: Table[string, string]; name=""): Query =
|
||||
result = Query(
|
||||
kind: parseEnum[QueryKind](@"f", tweets),
|
||||
view: @"view",
|
||||
text: @"q",
|
||||
filters: validFilters.filterIt("f-" & it in pms),
|
||||
excludes: validFilters.filterIt("e-" & it in pms),
|
||||
@@ -50,7 +46,7 @@ proc getReplyQuery*(name: string): Query =
|
||||
fromUser: @[name]
|
||||
)
|
||||
|
||||
proc genQueryParam*(query: Query): string =
|
||||
proc genQueryParam*(query: Query; maxId=""): string =
|
||||
var
|
||||
filters: seq[string]
|
||||
param: string
|
||||
@@ -59,15 +55,20 @@ proc genQueryParam*(query: Query): string =
|
||||
return query.text
|
||||
|
||||
for i, user in query.fromUser:
|
||||
param &= &"from:{user} "
|
||||
if i == 0:
|
||||
param = "("
|
||||
|
||||
param &= &"from:{user}"
|
||||
if i < query.fromUser.high:
|
||||
param &= "OR "
|
||||
param &= " OR "
|
||||
else:
|
||||
param &= ")"
|
||||
|
||||
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)"
|
||||
|
||||
if "nativeretweets" notin query.excludes:
|
||||
param &= "include:nativeretweets "
|
||||
param &= " include:nativeretweets"
|
||||
|
||||
for f in query.filters:
|
||||
filters.add "filter:" & f
|
||||
@@ -77,10 +78,14 @@ proc genQueryParam*(query: Query): string =
|
||||
for i in query.includes:
|
||||
filters.add "include:" & i
|
||||
|
||||
result = strip(param & filters.join(&" {query.sep} "))
|
||||
if filters.len > 0:
|
||||
result = strip(param & " (" & filters.join(&" {query.sep} ") & ")")
|
||||
else:
|
||||
result = strip(param)
|
||||
|
||||
if query.since.len > 0:
|
||||
result &= " since:" & query.since
|
||||
if query.until.len > 0:
|
||||
if query.until.len > 0 and maxId.len == 0:
|
||||
result &= " until:" & query.until
|
||||
if query.minLikes.len > 0:
|
||||
result &= " min_faves:" & query.minLikes
|
||||
@@ -90,25 +95,32 @@ proc genQueryParam*(query: Query): string =
|
||||
else:
|
||||
result = query.text
|
||||
|
||||
if result.len > 0 and maxId.len > 0:
|
||||
result &= " max_id:" & maxId
|
||||
|
||||
proc genQueryUrl*(query: Query): string =
|
||||
if query.kind notin {tweets, users}: return
|
||||
var params: seq[string]
|
||||
|
||||
var params = @[&"f={query.kind}"]
|
||||
if query.text.len > 0:
|
||||
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.view.len > 0:
|
||||
params.add "view=" & encodeUrl(query.view)
|
||||
|
||||
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 query.kind in {tweets, users}:
|
||||
params.add &"f={query.kind}"
|
||||
if query.text.len > 0:
|
||||
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:
|
||||
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:
|
||||
result &= params.join("&")
|
||||
|
||||
@@ -158,6 +158,33 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
|
||||
# if not result.isNil:
|
||||
# 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.} =
|
||||
if id.len == 0: return
|
||||
let rail = await get("pr2:" & toLower(id))
|
||||
|
||||
44
src/routes/broadcast.nim
Normal file
44
src/routes/broadcast.nim
Normal 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
|
||||
@@ -11,7 +11,7 @@ proc createEmbedRouter*(cfg: Config) =
|
||||
router embed:
|
||||
get "/i/videos/tweet/@id":
|
||||
let tweet = await getGraphTweetResult(@"id")
|
||||
if tweet == nil or tweet.video.isNone:
|
||||
if tweet == nil or not tweet.hasVideos:
|
||||
resp Http404
|
||||
|
||||
resp renderVideoEmbed(tweet, cfg, request)
|
||||
|
||||
@@ -86,6 +86,12 @@ proc decoded*(req: jester.Request; index: int): string =
|
||||
if based: decode(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) =
|
||||
router media:
|
||||
get "/pic/?":
|
||||
@@ -93,12 +99,8 @@ proc createMediaRouter*(cfg: Config) =
|
||||
|
||||
get re"^\/pic\/orig\/(enc)?\/?(.+)":
|
||||
var url = decoded(request, 1)
|
||||
cond "amplify_video" notin url
|
||||
|
||||
if "twimg.com" notin url:
|
||||
url.insert(twimg)
|
||||
if not url.startsWith(https):
|
||||
url.insert(https)
|
||||
cond "/amplify_video/" notin url
|
||||
normalizeImgUrl(url)
|
||||
url.add("?name=orig")
|
||||
|
||||
let uri = parseUri(url)
|
||||
@@ -109,12 +111,8 @@ proc createMediaRouter*(cfg: Config) =
|
||||
|
||||
get re"^\/pic\/(enc)?\/?(.+)":
|
||||
var url = decoded(request, 1)
|
||||
cond "amplify_video" notin url
|
||||
|
||||
if "twimg.com" notin url:
|
||||
url.insert(twimg)
|
||||
if not url.startsWith(https):
|
||||
url.insert(https)
|
||||
cond "/amplify_video/" notin url
|
||||
normalizeImgUrl(url)
|
||||
|
||||
let uri = parseUri(url)
|
||||
cond isTwitterUrl(uri) == true
|
||||
@@ -143,6 +141,6 @@ proc createMediaRouter*(cfg: Config) =
|
||||
|
||||
if ".m3u8" in url:
|
||||
let vid = await safeFetch(url)
|
||||
content = proxifyVideo(vid, requestPrefs().proxyVideos)
|
||||
content = proxifyVideo(vid, requestPrefs().proxyVideos, url)
|
||||
|
||||
resp content, m3u8Mime
|
||||
|
||||
@@ -27,7 +27,7 @@ proc createStatusRouter*(cfg: Config) =
|
||||
if @"scroll".len > 0:
|
||||
let replies = await getReplies(id, getCursor())
|
||||
if replies.content.len == 0:
|
||||
resp Http404, ""
|
||||
resp Http204
|
||||
resp $renderReplies(replies, prefs, getPath())
|
||||
|
||||
let conv = await getTweet(id, getCursor())
|
||||
@@ -44,15 +44,19 @@ proc createStatusRouter*(cfg: Config) =
|
||||
desc = conv.tweet.text
|
||||
|
||||
var
|
||||
images = conv.tweet.photos.mapIt(it.url)
|
||||
images = conv.tweet.getPhotos.mapIt(it.url)
|
||||
video = ""
|
||||
|
||||
if conv.tweet.video.isSome():
|
||||
images = @[get(conv.tweet.video).thumb]
|
||||
let
|
||||
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)
|
||||
elif conv.tweet.gif.isSome():
|
||||
images = @[get(conv.tweet.gif).thumb]
|
||||
video = getPicUrl(get(conv.tweet.gif).url)
|
||||
elif firstMediaKind == gifMedia:
|
||||
images = @[conv.tweet.media[0].getThumb]
|
||||
video = getPicUrl(conv.tweet.media[0].gif.url)
|
||||
elif conv.tweet.card.isSome():
|
||||
let card = conv.tweet.card.get()
|
||||
if card.image.len > 0:
|
||||
|
||||
@@ -4,20 +4,28 @@ import jester, karax/vdom
|
||||
|
||||
import router_utils
|
||||
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 uri, sequtils
|
||||
export router_utils
|
||||
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
|
||||
of "with_replies": getReplyQuery(name)
|
||||
of "media": getMediaQuery(name)
|
||||
of "search": initQuery(params(request), name=name)
|
||||
else: Query(fromUser: @[name])
|
||||
of "with_replies":
|
||||
result = getReplyQuery(name)
|
||||
of "media":
|
||||
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] =
|
||||
if cond:
|
||||
@@ -49,6 +57,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile]
|
||||
getCachedPhotoRail(userId)
|
||||
|
||||
user = getCachedUser(name)
|
||||
info = getCachedAccountInfo(name, fetch=false)
|
||||
|
||||
result =
|
||||
case query.kind
|
||||
@@ -59,6 +68,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile]
|
||||
|
||||
result.user = await user
|
||||
result.photoRail = await rail
|
||||
result.accountInfo = await info
|
||||
|
||||
result.tweets.query = query
|
||||
|
||||
@@ -111,6 +121,20 @@ proc createTimelineRouter*(cfg: Config) =
|
||||
resp Http400, showError("Missing screen_name parameter", cfg)
|
||||
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?/?":
|
||||
cond '.' notin @"name"
|
||||
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
|
||||
@@ -121,7 +145,7 @@ proc createTimelineRouter*(cfg: Config) =
|
||||
after = getCursor()
|
||||
names = getNames(@"name")
|
||||
|
||||
var query = request.getQuery(@"tab", @"name")
|
||||
var query = request.getQuery(@"tab", @"name", prefs)
|
||||
if names.len != 1:
|
||||
query.fromUser = names
|
||||
|
||||
@@ -129,7 +153,8 @@ proc createTimelineRouter*(cfg: Config) =
|
||||
if @"scroll".len > 0:
|
||||
if query.fromUser.len != 1:
|
||||
var timeline = await getGraphTweetSearch(query, after)
|
||||
if timeline.content.len == 0: resp Http404
|
||||
if timeline.content.len == 0:
|
||||
resp Http204
|
||||
timeline.beginning = true
|
||||
resp $renderTweetSearch(timeline, prefs, getPath())
|
||||
else:
|
||||
|
||||
75
src/sass/_broadcast.scss
Normal file
75
src/sass/_broadcast.scss
Normal 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;
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
@import "inputs";
|
||||
@import "timeline";
|
||||
@import "search";
|
||||
@import "broadcast";
|
||||
|
||||
body {
|
||||
// colors
|
||||
@@ -160,7 +161,7 @@ body.fixed-nav .container {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-left: 2px;
|
||||
margin-bottom: 2px;
|
||||
|
||||
.verified-icon-circle {
|
||||
position: absolute;
|
||||
|
||||
@@ -179,6 +179,7 @@ input::-webkit-datetime-edit-year-field:focus {
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
|
||||
@@ -1,87 +1,117 @@
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
@import 'card';
|
||||
@import 'photo-rail';
|
||||
@import "card";
|
||||
@import "about-account";
|
||||
@import "photo-rail";
|
||||
|
||||
.profile-tabs {
|
||||
@include panel(auto, 900px);
|
||||
@include panel(auto, 900px);
|
||||
|
||||
.timeline-container {
|
||||
float: right;
|
||||
width: 68% !important;
|
||||
max-width: unset;
|
||||
}
|
||||
.timeline-container {
|
||||
float: right;
|
||||
width: 68% !important;
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-banner {
|
||||
margin-bottom: 4px;
|
||||
background-color: var(--bg_panel);
|
||||
margin-bottom: 4px;
|
||||
background-color: var(--bg_panel);
|
||||
|
||||
a {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding: 33.34% 0 0 0;
|
||||
}
|
||||
a {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding: 33.34% 0 0 0;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-tab {
|
||||
padding: 0 4px 0 0;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
max-width: 32%;
|
||||
top: 0;
|
||||
padding: 0 4px 0 0;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
max-width: 32%;
|
||||
top: 0;
|
||||
|
||||
body.fixed-nav & {
|
||||
top: 50px;
|
||||
}
|
||||
body.fixed-nav & {
|
||||
top: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-result {
|
||||
min-height: 54px;
|
||||
min-height: 54px;
|
||||
|
||||
.username {
|
||||
margin: 0 !important;
|
||||
}
|
||||
.username {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.tweet-header {
|
||||
margin-bottom: unset;
|
||||
}
|
||||
.tweet-header {
|
||||
margin-bottom: unset;
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width: 700px) {
|
||||
.profile-tabs {
|
||||
width: 100vw;
|
||||
max-width: 600px;
|
||||
.profile-tabs.media-only {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
|
||||
.timeline-container {
|
||||
width: 100% !important;
|
||||
.timeline-container {
|
||||
float: none;
|
||||
width: 100% !important;
|
||||
max-width: none;
|
||||
padding: 0 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tab-item wide {
|
||||
flex-grow: 1.4;
|
||||
}
|
||||
}
|
||||
.timeline-container > .tab {
|
||||
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 {
|
||||
width: 100%;
|
||||
max-width: unset;
|
||||
position: initial !important;
|
||||
padding: 0;
|
||||
.profile-tabs.media-only {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
|
||||
.timeline-container {
|
||||
width: 100vw !important;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-tab {
|
||||
width: 100%;
|
||||
max-width: unset;
|
||||
position: initial !important;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-height: 900px) {
|
||||
.profile-tab.sticky {
|
||||
position: sticky;
|
||||
}
|
||||
.profile-tab.sticky {
|
||||
position: sticky;
|
||||
}
|
||||
}
|
||||
|
||||
71
src/sass/profile/about-account.scss
Normal file
71
src/sass/profile/about-account.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
padding: 8px;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
margin-bottom: 4px;
|
||||
box-sizing: border-box;
|
||||
|
||||
button {
|
||||
@@ -36,7 +36,7 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
list-style: none;
|
||||
margin: 0 0 5px 0;
|
||||
margin: 0 0 4px 0;
|
||||
background-color: var(--bg_panel);
|
||||
padding: 0;
|
||||
}
|
||||
@@ -157,3 +157,329 @@
|
||||
position: relative;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.verified-icon {
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.fullname-and-username {
|
||||
@@ -80,8 +84,8 @@
|
||||
}
|
||||
|
||||
.tweet-published {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 3px;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 0px;
|
||||
color: var(--grey);
|
||||
pointer-events: all;
|
||||
}
|
||||
@@ -101,6 +105,7 @@
|
||||
.avatar {
|
||||
&.round {
|
||||
border-radius: 50%;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
@@ -204,6 +209,7 @@
|
||||
|
||||
.tweet-stats {
|
||||
margin-bottom: -3px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
@@ -236,6 +242,7 @@
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
&:hover {
|
||||
@@ -254,3 +261,51 @@
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
.community-note {
|
||||
background-color: var(--bg_elements);
|
||||
margin-top: 10px;
|
||||
border: solid 1px var(--dark_grey);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
pointer-events: all;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg_panel);
|
||||
border-color: var(--grey);
|
||||
}
|
||||
}
|
||||
|
||||
.community-note-header {
|
||||
background-color: var(--bg_hover);
|
||||
font-weight: 700;
|
||||
padding: 8px 10px;
|
||||
padding-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
.icon-container {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.community-note-text {
|
||||
white-space: pre-line;
|
||||
padding: 10px 10px;
|
||||
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,119 +1,119 @@
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
.card {
|
||||
margin: 5px 0;
|
||||
pointer-events: all;
|
||||
max-height: unset;
|
||||
margin: 5px 0;
|
||||
pointer-events: all;
|
||||
max-height: unset;
|
||||
}
|
||||
|
||||
.card-container {
|
||||
border-radius: 10px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: var(--dark_grey);
|
||||
background-color: var(--bg_elements);
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
text-decoration: none !important;
|
||||
border: solid 1px var(--dark_grey);
|
||||
border-radius: 10px;
|
||||
background-color: var(--bg_elements);
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
text-decoration: none !important;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--grey);
|
||||
}
|
||||
&:hover {
|
||||
border-color: var(--grey);
|
||||
}
|
||||
|
||||
.attachments {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.attachments {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 0.5em;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@include ellipsis;
|
||||
white-space: unset;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
@include ellipsis;
|
||||
white-space: unset;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
margin: 0.3em 0;
|
||||
white-space: pre-wrap;
|
||||
margin: 0.3em 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.card-destination {
|
||||
@include ellipsis;
|
||||
color: var(--grey);
|
||||
display: block;
|
||||
@include ellipsis;
|
||||
color: var(--grey);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-content-container {
|
||||
color: unset;
|
||||
overflow: auto;
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
color: unset;
|
||||
overflow: auto;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.card-image-container {
|
||||
width: 98px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
padding-top: 100%;
|
||||
}
|
||||
width: 98px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
padding-top: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.card-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background-color: var(--bg_overlays);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background-color: var(--bg_overlays);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 400px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 400px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.card-overlay {
|
||||
@include play-button;
|
||||
opacity: 0.8;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@include play-button;
|
||||
opacity: 0.8;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.large {
|
||||
.card-container {
|
||||
display: block;
|
||||
}
|
||||
.card-container {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-image-container {
|
||||
width: unset;
|
||||
.card-image-container {
|
||||
width: unset;
|
||||
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.card-image {
|
||||
position: unset;
|
||||
border-style: solid;
|
||||
border-color: var(--dark_grey);
|
||||
border-width: 0;
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
.card-image {
|
||||
position: unset;
|
||||
border-style: solid;
|
||||
border-color: var(--dark_grey);
|
||||
border-width: 0;
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
left: 0%;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
.gallery-video > .attachment {
|
||||
max-height: unset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,47 @@
|
||||
max-width: 533px;
|
||||
pointer-events: all;
|
||||
|
||||
.still-image {
|
||||
width: 100%;
|
||||
align-self: center;
|
||||
&.mixed-row {
|
||||
.attachment {
|
||||
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);
|
||||
align-items: center;
|
||||
pointer-events: all;
|
||||
|
||||
.image-attachment {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
background-color: #101010;
|
||||
}
|
||||
@@ -96,22 +137,6 @@
|
||||
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 {
|
||||
border-radius: 50%;
|
||||
background-color: var(--dark_grey);
|
||||
@@ -133,12 +158,6 @@
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.media-gif {
|
||||
display: table;
|
||||
background-color: unset;
|
||||
width: unset;
|
||||
}
|
||||
|
||||
.media-body {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
|
||||
@@ -19,31 +19,49 @@
|
||||
}
|
||||
|
||||
.tweet-name-row {
|
||||
padding: 6px 8px;
|
||||
margin-top: 1px;
|
||||
padding: 8px 10px 6px 10px;
|
||||
}
|
||||
|
||||
.quote-text {
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
padding: 0px 8px 8px 8px;
|
||||
padding: 10px;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.show-thread {
|
||||
padding: 0px 8px 6px 8px;
|
||||
padding: 0px 10px 6px 10px;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.quote-latest {
|
||||
padding: 0px 8px 6px 8px;
|
||||
padding: 0px 10px 6px 10px;
|
||||
color: var(--grey);
|
||||
}
|
||||
|
||||
.replying-to {
|
||||
padding: 0px 8px;
|
||||
padding: 0px 10px;
|
||||
padding-bottom: 4px;
|
||||
margin: unset;
|
||||
}
|
||||
|
||||
.community-note {
|
||||
background-color: var(--bg_panel);
|
||||
border: unset;
|
||||
border-top: solid 1px var(--dark_grey);
|
||||
border-radius: unset;
|
||||
margin-top: 0;
|
||||
|
||||
&:hover {
|
||||
border-top-color: var(--grey);
|
||||
}
|
||||
|
||||
.community-note-header {
|
||||
background-color: var(--bg_panel);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.unavailable-quote {
|
||||
@@ -77,7 +95,7 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gallery-gif .attachment {
|
||||
.media-gif > .attachment {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: var(--bg_color);
|
||||
@@ -90,8 +108,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-video,
|
||||
.gallery-gif {
|
||||
.gallery-row .attachment,
|
||||
.gallery-row .attachment > video,
|
||||
.gallery-row .attachment > img {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,22 +9,22 @@ video {
|
||||
.gallery-video {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&.card-container {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gallery-video.card-container {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
> .attachment {
|
||||
min-height: 80px;
|
||||
min-width: 200px;
|
||||
max-height: 530px;
|
||||
margin: 0;
|
||||
|
||||
.video-container {
|
||||
min-height: 80px;
|
||||
min-width: 200px;
|
||||
max-height: 530px;
|
||||
margin: 0;
|
||||
|
||||
img {
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
img {
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,37 @@ type
|
||||
suspended*: bool
|
||||
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
|
||||
m3u8 = "application/x-mpegURL"
|
||||
mp4 = "video/mp4"
|
||||
@@ -123,6 +154,7 @@ type
|
||||
|
||||
Query* = object
|
||||
kind*: QueryKind
|
||||
view*: string
|
||||
text*: string
|
||||
filters*: seq[string]
|
||||
includes*: seq[string]
|
||||
@@ -136,11 +168,28 @@ type
|
||||
Gif* = object
|
||||
url*: string
|
||||
thumb*: string
|
||||
altText*: string
|
||||
|
||||
Photo* = object
|
||||
url*: 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
|
||||
url*: string
|
||||
tweetId*: string
|
||||
@@ -219,10 +268,11 @@ type
|
||||
quote*: Option[Tweet]
|
||||
card*: Option[Card]
|
||||
poll*: Option[Poll]
|
||||
gif*: Option[Gif]
|
||||
video*: Option[Video]
|
||||
photos*: seq[Photo]
|
||||
media*: MediaEntities
|
||||
history*: seq[int64]
|
||||
note*: string
|
||||
isAd*: bool
|
||||
isAI*: bool
|
||||
|
||||
Tweets* = seq[Tweet]
|
||||
|
||||
@@ -254,6 +304,7 @@ type
|
||||
photoRail*: PhotoRail
|
||||
pinned*: Option[Tweet]
|
||||
tweets*: Timeline
|
||||
accountInfo*: AccountInfo
|
||||
|
||||
List* = object
|
||||
id*: string
|
||||
@@ -291,6 +342,8 @@ type
|
||||
apiProxy*: string
|
||||
disableTid*: bool
|
||||
maxConcurrentReqs*: int
|
||||
maxRetries*: int
|
||||
retryDelayMs*: int
|
||||
|
||||
rssCacheTime*: int
|
||||
listCacheTime*: int
|
||||
@@ -309,3 +362,24 @@ proc contains*(thread: Chain; tweet: Tweet): bool =
|
||||
|
||||
proc add*(timeline: var seq[Tweets]; tweet: 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat, uri, tables, base64
|
||||
import sequtils, strutils, strformat, uri, tables, base64
|
||||
import nimcrypto
|
||||
|
||||
var
|
||||
@@ -17,7 +17,9 @@ const
|
||||
"abs.twimg.com",
|
||||
"pbs.twimg.com",
|
||||
"video.twimg.com",
|
||||
"x.com"
|
||||
"x.com",
|
||||
"pscp.tv",
|
||||
"video.pscp.tv"
|
||||
]
|
||||
|
||||
proc setHmacKey*(key: string) =
|
||||
@@ -55,7 +57,13 @@ proc filterParams*(params: Table): seq[(string, string)] =
|
||||
result.add p
|
||||
|
||||
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 =
|
||||
isTwitterUrl(parseUri(url))
|
||||
|
||||
proc validateNumber*(value: string): string =
|
||||
if value.anyIt(not it.isDigit):
|
||||
return ""
|
||||
return value
|
||||
|
||||
93
src/views/about_account.nim
Normal file
93
src/views/about_account.nim
Normal 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
src/views/broadcast.nim
Normal file
75
src/views/broadcast.nim
Normal 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
|
||||
@@ -9,14 +9,17 @@ import general, tweet
|
||||
const doctype = "<!DOCTYPE html>\n"
|
||||
|
||||
proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
|
||||
let thumb = get(tweet.video).thumb
|
||||
let vidUrl = getVideoEmbed(cfg, tweet.id)
|
||||
let prefs = Prefs(hlsPlayback: true, mp4Playback: true)
|
||||
let
|
||||
video = tweet.getVideos()[0]
|
||||
thumb = video.thumb
|
||||
vidUrl = getVideoEmbed(cfg, tweet.id)
|
||||
prefs = Prefs(hlsPlayback: true, mp4Playback: true)
|
||||
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb]))
|
||||
|
||||
body:
|
||||
tdiv(class="embed-video"):
|
||||
renderVideo(get(tweet.video), prefs, "")
|
||||
renderVideo(video, prefs, "")
|
||||
|
||||
result = doctype & $node
|
||||
|
||||
@@ -50,8 +50,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
||||
|
||||
buildHtml(head):
|
||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=27")
|
||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4")
|
||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=35")
|
||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5")
|
||||
|
||||
if theme.len > 0:
|
||||
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
|
||||
|
||||
@@ -12,7 +12,7 @@ proc renderStat(num: int; class: string; text=""): VNode =
|
||||
span(class="profile-stat-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")):
|
||||
tdiv(class="profile-card-info"):
|
||||
let
|
||||
@@ -46,6 +46,11 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
||||
else:
|
||||
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:
|
||||
tdiv(class="profile-website"):
|
||||
span:
|
||||
@@ -54,7 +59,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
||||
a(href=url): text url.shortLink
|
||||
|
||||
tdiv(class="profile-joindate"):
|
||||
span(title=getJoinDateFull(user)):
|
||||
a(href=(&"/{user.username}/about"), title=getJoinDateFull(user)):
|
||||
icon "calendar", getJoinDate(user)
|
||||
|
||||
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 =
|
||||
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")):
|
||||
if not prefs.hideBanner:
|
||||
buildHtml(tdiv(class=("profile-tabs" & viewClass))):
|
||||
if not isGalleryView and not prefs.hideBanner:
|
||||
tdiv(class="profile-banner"):
|
||||
renderBanner(profile.user.banner)
|
||||
|
||||
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
||||
tdiv(class=("profile-tab" & sticky)):
|
||||
renderUserCard(profile.user, prefs)
|
||||
if profile.photoRail.len > 0:
|
||||
renderPhotoRail(profile)
|
||||
if not isGalleryView:
|
||||
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
||||
tdiv(class=("profile-tab" & sticky)):
|
||||
renderUserCard(profile.user, prefs, profile.accountInfo)
|
||||
if profile.photoRail.len > 0:
|
||||
renderPhotoRail(profile)
|
||||
|
||||
if profile.user.protected:
|
||||
renderProtected(profile.user.username)
|
||||
|
||||
@@ -4,6 +4,7 @@ import karax/[karaxdsl, vdom, vstyles]
|
||||
import ".."/[types, utils]
|
||||
|
||||
const smallWebp* = "?name=small&format=webp"
|
||||
const mediumWebp* = "?name=medium&format=webp"
|
||||
|
||||
proc getSmallPic*(url: string): string =
|
||||
result = url
|
||||
@@ -11,6 +12,12 @@ proc getSmallPic*(url: string): string =
|
||||
result &= smallWebp
|
||||
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 =
|
||||
var c = "icon-" & icon
|
||||
if class.len > 0: c = &"{c} {class}"
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
#? stdtmpl(subsChar = '$', metaChar = '#')
|
||||
## 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
|
||||
## Snowflake ID cutoff for RSS GUID format transition
|
||||
## Corresponds to approximately December 14, 2025 UTC
|
||||
#const guidCutoff = 2000000000000000000'i64
|
||||
#
|
||||
#proc getTitle(tweet: Tweet; retweet: string): string =
|
||||
#if tweet.pinned: result = "Pinned: "
|
||||
#elif retweet.len > 0: result = &"RT by @{retweet}: "
|
||||
#elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: "
|
||||
#var prefix = ""
|
||||
#if tweet.pinned: prefix = "Pinned: "
|
||||
#elif retweet.len > 0: prefix = &"RT by @{retweet}: "
|
||||
#elif tweet.reply.len > 0: prefix = &"R to @{tweet.reply[0]}: "
|
||||
#end if
|
||||
#var text = stripHtml(tweet.text)
|
||||
##if unicode.runeLen(text) > 32:
|
||||
## text = unicode.runeSubStr(text, 0, 32) & "..."
|
||||
##end if
|
||||
#result &= xmltree.escape(text)
|
||||
#if result.len > 0: return
|
||||
#text = xmltree.escape(text)
|
||||
#if text.len > 0:
|
||||
# result = prefix & text
|
||||
# return
|
||||
#end if
|
||||
#if tweet.photos.len > 0:
|
||||
# result &= "Image"
|
||||
#elif tweet.video.isSome:
|
||||
# result &= "Video"
|
||||
#elif tweet.gif.isSome:
|
||||
# result &= "Gif"
|
||||
#if tweet.media.len > 0:
|
||||
# result = prefix
|
||||
# let firstKind = tweet.media[0].kind
|
||||
# if tweet.media.anyIt(it.kind != firstKind):
|
||||
# result &= "Media"
|
||||
# else:
|
||||
# case firstKind
|
||||
# of photoMedia: result &= "Image"
|
||||
# of videoMedia: result &= "Video"
|
||||
# of gifMedia: result &= "Gif"
|
||||
# end case
|
||||
# end if
|
||||
#end if
|
||||
#end proc
|
||||
#
|
||||
@@ -31,6 +40,26 @@
|
||||
Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
|
||||
#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] =
|
||||
#result = profile.tweets.content
|
||||
#if profile.pinned.isSome and result.len > 0:
|
||||
@@ -54,26 +83,19 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
|
||||
#let urlPrefix = getUrlPrefix(cfg)
|
||||
#let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix)
|
||||
<p>${text.replace("\n", "<br>\n")}</p>
|
||||
#if tweet.photos.len > 0:
|
||||
# for photo in tweet.photos:
|
||||
<img src="${urlPrefix}${getPicUrl(photo.url)}" style="max-width:250px;" />
|
||||
#if tweet.media.len > 0:
|
||||
# for media in tweet.media:
|
||||
${renderRssMedia(media, tweet, urlPrefix)}
|
||||
# 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:
|
||||
# let card = tweet.card.get()
|
||||
# if card.image.len > 0:
|
||||
<img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" />
|
||||
# end if
|
||||
#end if
|
||||
#if tweet.note.len > 0 and not prefs.hideCommunityNotes:
|
||||
<p><b>Community note:</b> ${replaceUrls(tweet.note, prefs, absolute=urlPrefix)}</p>
|
||||
#end if
|
||||
#if tweet.quote.isSome and get(tweet.quote).available:
|
||||
# let quoteTweet = get(tweet.quote)
|
||||
# let quoteLink = urlPrefix & getLink(quoteTweet)
|
||||
|
||||
@@ -39,6 +39,19 @@ proc renderProfileTabs*(query: Query; username: string): VNode =
|
||||
li(class=query.getTabClass(tweets)):
|
||||
a(href=(link & "/search")): text "Search"
|
||||
|
||||
proc renderMediaViewTabs*(query: Query; username: string): VNode =
|
||||
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 =
|
||||
var q = query
|
||||
buildHtml(ul(class="tab")):
|
||||
@@ -95,7 +108,10 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
|
||||
text query.fromUser.join(" | ")
|
||||
|
||||
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:
|
||||
tdiv(class="timeline-header"):
|
||||
|
||||
@@ -5,12 +5,38 @@ import karax/[karaxdsl, vdom]
|
||||
import ".."/[types, query, formatters]
|
||||
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 =
|
||||
if query.kind != posts:
|
||||
result = genQueryUrl(query)
|
||||
if result.len > 0:
|
||||
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 =
|
||||
buildHtml(tdiv(class="top-ref")):
|
||||
icon "down", href=focus
|
||||
@@ -39,7 +65,7 @@ proc renderNoneFound(): VNode =
|
||||
h2(class="timeline-none"):
|
||||
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")):
|
||||
let sortedThread = thread.sortedByIt(it.id)
|
||||
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 header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: ""
|
||||
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 =
|
||||
buildHtml(tdiv(class="timeline-item", data-username=user.username)):
|
||||
@@ -88,9 +114,22 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode =
|
||||
else:
|
||||
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;
|
||||
pinned=none(Tweet)): VNode =
|
||||
buildHtml(tdiv(class="timeline")):
|
||||
buildHtml(tdiv(class=results.query.timelineViewClass)):
|
||||
if not results.beginning:
|
||||
renderNewer(results.query, parseUri(path).path)
|
||||
|
||||
@@ -104,24 +143,23 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
|
||||
else:
|
||||
renderNoneFound()
|
||||
else:
|
||||
var retweets: seq[int64]
|
||||
let filtered = filterThreads(results.content, prefs)
|
||||
|
||||
for thread in results.content:
|
||||
if thread.len == 1:
|
||||
let
|
||||
tweet = thread[0]
|
||||
retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
|
||||
if results.query.view == "gallery":
|
||||
let bigThumb = prefs.gallerySize == "Large"
|
||||
let galClass = if prefs.compactGallery: "gallery-masonry compact" else: "gallery-masonry"
|
||||
tdiv(class=galClass, `data-col-size`=prefs.gallerySize.toLowerAscii):
|
||||
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
|
||||
tweet.pinned and prefs.hidePins:
|
||||
continue
|
||||
|
||||
if retweetId != 0 and tweet.retweet.isSome:
|
||||
retweets &= retweetId
|
||||
renderTweet(tweet, prefs, path)
|
||||
else:
|
||||
renderThread(thread, prefs, path)
|
||||
|
||||
if results.bottom.len > 0:
|
||||
var cursor = getSearchMaxId(results, path)
|
||||
if cursor.len > 0:
|
||||
renderMore(results.query, cursor)
|
||||
elif results.bottom.len > 0:
|
||||
renderMore(results.query, results.bottom)
|
||||
renderToTop()
|
||||
|
||||
@@ -38,24 +38,21 @@ proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VN
|
||||
a(href=getLink(tweet), title=tweet.getTime):
|
||||
text tweet.getShortTime
|
||||
|
||||
proc renderAlbum(tweet: Tweet): VNode =
|
||||
let
|
||||
groups = if tweet.photos.len < 3: @[tweet.photos]
|
||||
else: tweet.photos.distribute(2)
|
||||
proc renderAltText(altText: string): VNode =
|
||||
buildHtml(p(class="alt-text")):
|
||||
text "ALT " & altText
|
||||
|
||||
buildHtml(tdiv(class="attachments")):
|
||||
for i, photos in groups:
|
||||
let margin = if i > 0: ".25em" else: ""
|
||||
tdiv(class="gallery-row", style={marginTop: margin}):
|
||||
for photo in photos:
|
||||
tdiv(class="attachment image"):
|
||||
let
|
||||
named = "name=" in photo.url
|
||||
small = if named: photo.url else: photo.url & smallWebp
|
||||
a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"):
|
||||
genImg(small, alt=photo.altText)
|
||||
if photo.altText.len > 0:
|
||||
p(class="alt-text"): text "ALT " & photo.altText
|
||||
proc renderPhotoAttachment(photo: Photo; bigThumb=false): VNode =
|
||||
buildHtml(tdiv(class="attachment")):
|
||||
let
|
||||
named = "name=" in photo.url
|
||||
thumb = if named: photo.url
|
||||
elif bigThumb: photo.url & mediumWebp
|
||||
else: photo.url & smallWebp
|
||||
a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"):
|
||||
genImg(thumb, alt=photo.altText)
|
||||
if photo.altText.len > 0:
|
||||
renderAltText(photo.altText)
|
||||
|
||||
proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
|
||||
case playbackType
|
||||
@@ -65,7 +62,7 @@ proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
|
||||
proc hasMp4Url(video: Video): bool =
|
||||
video.variants.anyIt(it.contentType == mp4)
|
||||
|
||||
proc renderVideoDisabled(playbackType: VideoType; path: string): VNode =
|
||||
proc renderVideoDisabled(playbackType: VideoType; path=""): VNode =
|
||||
buildHtml(tdiv(class="video-overlay")):
|
||||
case playbackType
|
||||
of mp4:
|
||||
@@ -81,52 +78,98 @@ proc renderVideoUnavailable(video: Video): VNode =
|
||||
else:
|
||||
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
|
||||
container = if video.description.len == 0 and video.title.len == 0: ""
|
||||
else: " card-container"
|
||||
playbackType = if not prefs.proxyVideos and video.hasMp4Url: mp4
|
||||
else: video.playbackType
|
||||
playbackType = if not prefs.proxyVideos and videoData.hasMp4Url: mp4
|
||||
else: videoData.playbackType
|
||||
thumb = if bigThumb: getMediumPic(videoData.thumb) else: getSmallPic(videoData.thumb)
|
||||
|
||||
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")):
|
||||
tdiv(class="gallery-video" & container):
|
||||
tdiv(class="attachment video-container"):
|
||||
let thumb = getSmallPic(video.thumb)
|
||||
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=("gallery-video" & (if hasCardContent: " card-container" else: ""))):
|
||||
renderVideoAttachment(video, prefs, path, bigThumb)
|
||||
if hasCardContent:
|
||||
tdiv(class="card-content"):
|
||||
h2(class="card-title"): text video.title
|
||||
if video.description.len > 0:
|
||||
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 =
|
||||
buildHtml(tdiv(class="attachments media-gif")):
|
||||
tdiv(class="gallery-gif", style={maxHeight: "unset"}):
|
||||
tdiv(class="attachment"):
|
||||
video(class="gif", poster=getSmallPic(gif.thumb), autoplay=prefs.autoplayGifs,
|
||||
controls="", muted="", loop=""):
|
||||
source(src=getPicUrl(gif.url), `type`="video/mp4")
|
||||
renderGifAttachment(gif, prefs)
|
||||
|
||||
proc renderMedia(media: seq[Media]; prefs: Prefs; path: string; bigThumb=false): VNode =
|
||||
if media.len == 0:
|
||||
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 =
|
||||
buildHtml(tdiv(class="poll")):
|
||||
@@ -217,14 +260,17 @@ proc renderLatestPost(username: string; id: int64): VNode =
|
||||
a(href=getLink(id, username)):
|
||||
text "See the latest post"
|
||||
|
||||
proc renderCommunityNote(note: string; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv(class="community-note")):
|
||||
tdiv(class="community-note-header"):
|
||||
icon "group"
|
||||
span: text "Community note"
|
||||
tdiv(class="community-note-text", dir="auto"):
|
||||
verbatim replaceUrls(note, prefs)
|
||||
|
||||
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)
|
||||
renderMedia(quote.media, prefs, path)
|
||||
|
||||
proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
|
||||
if not quote.available:
|
||||
@@ -258,9 +304,12 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
|
||||
tdiv(class="quote-text", dir="auto"):
|
||||
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)
|
||||
|
||||
if quote.note.len > 0 and not prefs.hideCommunityNotes:
|
||||
renderCommunityNote(quote.note, prefs)
|
||||
|
||||
if quote.hasThread:
|
||||
a(class="show-thread", href=getLink(quote)):
|
||||
text "Show this thread"
|
||||
@@ -269,6 +318,15 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
|
||||
tdiv(class="quote-latest"):
|
||||
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 =
|
||||
let (place, url) = tweet.getLocation()
|
||||
if place.len == 0: return
|
||||
@@ -281,7 +339,7 @@ proc renderLocation*(tweet: Tweet): string =
|
||||
return $node
|
||||
|
||||
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
|
||||
if index == -1 or last:
|
||||
divClass = "thread-last " & class
|
||||
@@ -333,12 +391,8 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||
if tweet.card.isSome and tweet.card.get().kind != hidden:
|
||||
renderCard(tweet.card.get(), prefs, path)
|
||||
|
||||
if tweet.photos.len > 0:
|
||||
renderAlbum(tweet)
|
||||
elif tweet.video.isSome:
|
||||
renderVideo(tweet.video.get(), prefs, path)
|
||||
elif tweet.gif.isSome:
|
||||
renderGif(tweet.gif.get(), prefs)
|
||||
if tweet.media.len > 0:
|
||||
renderMedia(tweet.media, prefs, path, bigThumb)
|
||||
|
||||
if tweet.poll.isSome:
|
||||
renderPoll(tweet.poll.get())
|
||||
@@ -346,6 +400,12 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||
if tweet.quote.isSome:
|
||||
renderQuote(tweet.quote.get(), prefs, path)
|
||||
|
||||
if tweet.note.len > 0 and not prefs.hideCommunityNotes:
|
||||
renderCommunityNote(tweet.note, prefs)
|
||||
|
||||
if tweet.isAI or tweet.isAd:
|
||||
renderDisclosures(tweet)
|
||||
|
||||
let
|
||||
hasEdits = tweet.history.len > 1
|
||||
isLatest = hasEdits and tweet.id == max(tweet.history)
|
||||
|
||||
@@ -54,6 +54,13 @@ class Timeline(object):
|
||||
none = '.timeline-none'
|
||||
protected = '.timeline-protected'
|
||||
photo_rail = '.photo-rail-grid'
|
||||
media_view_tabs = '.media-view-tabs'
|
||||
media_view_timeline = '.media-view-tabs a[href$="media?view=timeline"]'
|
||||
media_view_grid = '.media-view-tabs a[href$="media?view=grid"]'
|
||||
media_view_gallery = '.media-view-tabs a[href$="media?view=gallery"]'
|
||||
media_view_active = '.media-view-tabs .tab-item.active a'
|
||||
grid_view = '.timeline.media-grid-view'
|
||||
gallery_view = '.timeline.media-gallery-view'
|
||||
|
||||
|
||||
class Conversation(object):
|
||||
@@ -79,7 +86,7 @@ class Media(object):
|
||||
row = '.gallery-row'
|
||||
image = '.still-image'
|
||||
video = '.gallery-video'
|
||||
gif = '.gallery-gif'
|
||||
gif = '.media-gif'
|
||||
|
||||
|
||||
class BaseTestCase(BaseCase):
|
||||
|
||||
1716
tests/poetry.lock
generated
Normal file
1716
tests/poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
tests/poetry.toml
Normal file
2
tests/poetry.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[virtualenvs]
|
||||
in-project = true
|
||||
8
tests/pyproject.toml
Normal file
8
tests/pyproject.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[tool.poetry]
|
||||
name = "nitter-tests"
|
||||
version = "0.0.0"
|
||||
package-mode = false
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.14"
|
||||
seleniumbase = "4.46.5"
|
||||
@@ -1 +1 @@
|
||||
seleniumbase
|
||||
seleniumbase==4.46.5
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
from base import BaseTestCase, Conversation
|
||||
from parameterized import parameterized
|
||||
|
||||
from base import BaseTestCase, Conversation
|
||||
|
||||
thread = [
|
||||
['octonion/status/975253897697611777', [], 'Based', ['Crystal', 'Julia'], [
|
||||
['For', 'Then', 'Okay,', 'Python', 'Speed', 'Java', 'Coding', 'I', 'You'],
|
||||
['yeah,']
|
||||
]],
|
||||
|
||||
['octonion/status/975254452625002496', ['Based'], 'Crystal', ['Julia'], []],
|
||||
|
||||
['octonion/status/975256058384887808', ['Based', 'Crystal'], 'Julia', [], []],
|
||||
|
||||
['gauravssnl/status/975364889039417344',
|
||||
['Based', 'For', 'Then', 'Okay,', 'Python'], 'Speed', [], [
|
||||
['Java', 'Coding', 'I', 'You'], ['JAVA!']
|
||||
]],
|
||||
|
||||
['d0m96/status/1141811379407425537', [], 'I\'m',
|
||||
['The', 'The', 'Today', 'Some', 'If', 'There', 'Above'],
|
||||
[['Thank', 'Also,']]],
|
||||
|
||||
['gmpreussner/status/999766552546299904', [], 'A', [],
|
||||
[['I', 'Especially'], ['I']]]
|
||||
[
|
||||
"octonion/status/975253897697611777",
|
||||
[],
|
||||
"Based",
|
||||
["Crystal", "Julia"],
|
||||
[["yeah,"]],
|
||||
],
|
||||
["octonion/status/975254452625002496", ["Based"], "Crystal", ["Julia"], []],
|
||||
["octonion/status/975256058384887808", ["Based", "Crystal"], "Julia", [], []],
|
||||
[
|
||||
"gauravssnl/status/975364889039417344",
|
||||
["Based", "For", "Then", "Okay,", "Python"],
|
||||
"Speed",
|
||||
[],
|
||||
[["Java", "Coding", "I", "You"], ["JAVA!"]],
|
||||
],
|
||||
[
|
||||
"d0m96/status/1141811379407425537",
|
||||
[],
|
||||
"I'm",
|
||||
["The", "The", "Today", "Some", "If", "There", "Above"],
|
||||
[["Thank", "Also,"]],
|
||||
],
|
||||
[
|
||||
"gmpreussner/status/999766552546299904",
|
||||
[],
|
||||
"A",
|
||||
[],
|
||||
[["I", "Especially"], ["I"]],
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,28 @@ class TweetTest(BaseTestCase):
|
||||
self.assert_element_absent(Timeline.older)
|
||||
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)
|
||||
#def test_photo_rail(self, username, images):
|
||||
#self.open_nitter(username)
|
||||
|
||||
Reference in New Issue
Block a user