1
0
mirror of https://github.com/zedeus/nitter.git synced 2026-05-02 02:22:09 -04:00

69 Commits

Author SHA1 Message Date
Zed
74f5ff8acc Fix thread test 2026-04-16 02:46:43 +02:00
Zed
4e38317582 Fix verified type enum parsing error
Fixes #1387
2026-04-16 02:31:52 +02:00
Zed
8114eefa19 Add support for broadcasts
Fixes #303
2026-03-31 07:28:45 +02:00
Zed
7d431781c3 Increase CI reruns 2026-03-30 01:42:34 +02:00
Zed
0c7583432c Increase CI test maxRetries 2026-03-30 01:20:33 +02:00
Zed
e7e7050c6e Add support for viewing account info
Fixes #1381
2026-03-30 00:58:02 +02:00
Zed
741060c78b Increase maxRetries in CI conf 2026-03-21 20:28:41 +01:00
Zed
3429667414 Fix null legacy tweet crash
Fixes #1383
2026-03-21 18:59:05 +01:00
Zed
fea6f59005 Fix mobile gallery and grid, add size preference
Fixes #1379
2026-03-21 11:29:42 +01:00
Zed
b6ccea0c7a Add configurable retry logic
Fixes #1382
2026-03-21 08:30:07 +01:00
Zed
b726767df4 Bump css 2026-03-21 06:55:55 +01:00
Zed
33bf2c2397 Support tweet content disclosures (AI and ads)
Fixes #1374
2026-03-21 06:52:51 +01:00
Zed
7ce29bd8f1 Add new media grid and gallery views
Fixes #199
Fixes #1342
2026-03-15 09:31:55 +01:00
Zed
91ff936cb3 Add workaround for broken "until" search filter
Fixes #1372
2026-03-14 04:04:18 +01:00
Zed
0fefcf9917 Update gif class in tests 2026-03-13 06:19:09 +01:00
Zed
35a929c415 Implement mixed-media tweet support
Fixes #697 #1101
2026-03-13 05:47:37 +01:00
Zed
4bf3df94f8 Fix segfault 2026-03-04 17:27:15 +01:00
Zed
2898efab6b Fix search repeating when the end has been reached 2026-03-04 11:56:53 +01:00
Zed
b0773dd934 Fix incorrect multi-user search query
Fixes #1373
2026-03-04 11:08:42 +01:00
Zed
d187b1cc3f Fix video thumbnails not loading
Fixes #1371
2026-02-22 07:02:45 +01:00
Zed
95a9ee8dc5 Update and speed up GitHub workflows (#1368)
* Update actions and switch to GitHub runners

* Bump workflow Python version to 3.14

* Reuse nitter build for integration test

* Add missing libpcre3 installation to workflow

* Consolidate workflow runtime deps install

* Make nitter binary executable

* Run nimble md and scss simultaneously in workflow

* Run tests with 4 workers in workflow

* Rerun failing integration tests

* Bump integration test workers to 5

* Improve python dep install and run less workers

* Use native GitHub Actions Redis service

* Lower integration test workers to 2

* Switch to poetry to cache venv

* Ensure poetry is installed before setup-python

* Fix poetry sync command

* Switch back to 3 workers

* Cache poetry install

* WIP

* WIP

* Fix poetry/pipx caching

* Speed up integration test significantly

* WIP

* Cleanup
2026-02-19 06:56:20 +01:00
Zed
61b6748d97 Add community notes to RSS 2026-02-19 02:03:10 +01:00
Zed
2bd664ae7d Add community notes support
Fixes #727
Fixes #1023
2026-02-19 01:44:50 +01:00
Zed
a15d1ce16b Add full support for tweet edit history
Fixes #700
2026-02-16 00:52:17 +01:00
Zed
f257ce53ae Bump style version 2026-02-14 02:38:10 +01:00
Zed
d45545cd53 Fix "Replying to" parsing 2026-02-14 02:24:24 +01:00
Zed
90b664ffb7 Make "Tweet unavailable" clickable and consistent 2026-02-14 02:19:53 +01:00
Zed
cbce620692 Add dynamic-range-limit to prevent HDR jumpscares
Fixes #1345
2026-02-12 20:16:50 +01:00
Zed
05b6dd2a43 Add config options to enable subset of RSS feeds
Fixes #1363
2026-02-11 23:49:50 +01:00
Zed
dcec1eb458 Fix invalid search link formatting 2026-02-10 22:53:54 +01:00
Zed
1c06a67afd Support image alt text
Fixes #559
2026-02-10 22:43:10 +01:00
Zed
40b1ba4e4e Bump css version 2026-02-09 22:08:09 +01:00
Zed
b85e8c5d7d Support preference overrides using URL params
Fixes #186
2026-02-09 21:54:57 +01:00
Zed
db36f75519 Support restoring preferences via new prefs param
Fixes #1352
Fixes #553
Fixes #249
2026-02-09 20:23:31 +01:00
Zed
5d28bd18c6 Add preference for configuring sticky navbar
Fixes #1354
2026-02-09 17:38:14 +01:00
Zed
0a6e79e626 Add bulk script create_sessions_browser.py 2026-02-09 02:55:07 +01:00
Zed
33dd9b6668 Fix /pic/ exploit 2026-02-06 20:44:37 +01:00
cmj
a45227b883 Add user-agent to guest_token request (#1359) 2026-01-29 17:27:41 +01:00
yav
a92e79ebc3 Fix the checkmark position (#1347)
Co-authored-by: yav <796176@protonmail.com>
2025-12-24 02:22:20 -05:00
jackyzy823
baeaf685d3 Make maxConcurrentReqs configurable (#1341) 2025-12-08 04:05:08 -05:00
Zed
51b54852dc Add preliminary support for nitter-proxy 2025-12-06 05:15:01 +01:00
Zed
663f5a52e1 Improve headers 2025-12-06 05:00:34 +01:00
Zed
17fc2628f9 Minor fix 2025-11-30 18:07:27 +01:00
Zed
e741385828 Allow , in username to support multiple users
Fixes #1329
2025-11-30 18:06:22 +01:00
Zed
693a189462 Add heuristics to detect when to show "Load more"
Fixes #1328
2025-11-30 05:43:17 +01:00
Zed
7734d976f7 Add username validation
Fixes #1317
2025-11-30 04:12:38 +01:00
Zed
a62ec9cbb4 Normalize headers 2025-11-30 03:58:43 +01:00
Zed
4b9aec6fde Use graphTweet for cookie sessions for now 2025-11-30 02:57:34 +01:00
Zed
064ec88080 Transition to ID-only RSS GUIDs on Dec 14, 2025
Fixes #447
2025-11-30 02:56:19 +01:00
Zed
71e65c84d7 Round video duration properly 2025-11-29 04:34:04 +01:00
Zed
436a873e4b Improve verified checkmark icon, css improvements 2025-11-29 04:33:49 +01:00
Zed
96ec75fc7f Add video duration to overlay
Fixes #498
2025-11-29 03:38:40 +01:00
Zed
7a08a9e132 Format css 2025-11-29 03:36:21 +01:00
Zed
31d210ca47 Add experimental x-client-transaction-id support (#1324)
* Add experimental x-client-transaction-id support

* Remove broken test
2025-11-29 01:13:08 +01:00
Zed
dae68b4f13 Ignore null errors, they're internal API errors 2025-11-29 01:05:57 +01:00
Zed
8516ebe2b7 Fix 'key not found in object: expanded_url' error
Fixes #1318
2025-11-29 00:37:45 +01:00
Zed
b83227aaf5 Implement temp fix for cookie sessions
Fixes #1319
2025-11-26 01:03:27 +01:00
Zed
404b06b5f3 Include "Video" and link for video tweets in RSS (#1315)
Fixes #836
2025-11-25 01:03:45 +01:00
Zed
2b922c049a Embed quote tweet in RSS (#1316)
Fixes #132
Closes #820
2025-11-25 01:02:45 +01:00
Zed
78101df2cc Style number input field 2025-11-24 23:04:25 +01:00
Zed
12bbddf204 Update search panel grid layout and animation 2025-11-24 23:04:25 +01:00
Zed
4979d07f2e Add spaces filter, remove broken filters 2025-11-24 23:04:25 +01:00
Zed
f038b53fa2 Fix body font size to match x.com
Fixes #711
2025-11-24 23:04:25 +01:00
Zed
4748311f8d Fix intent/follow URL redirect
Fixes #629
2025-11-24 23:04:25 +01:00
Zed
d47eb8f0eb Fix double slashes in url replacements
Fixes #520
2025-11-24 23:04:25 +01:00
Zed
1657eeb769 Fix canonical link causing redirects to Twitter
Fixes #526
2025-11-24 23:04:25 +01:00
Zed
25df682094 Expose username as HTML attribute
Fixes #551
2025-11-24 23:04:25 +01:00
Zed
53edbbc4e9 Fix broken tweet pagination ("Load more" button)
Fixes #1277
2025-11-23 20:00:10 +01:00
Zed
5b4a3fe691 Redirect /i/status/id/history to /i/status/id
Fixes #1231
2025-11-23 19:27:13 +01:00
90 changed files with 6364 additions and 2047 deletions

View File

@@ -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 }}

View File

@@ -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
View File

@@ -13,3 +13,5 @@ nitter.conf
guest_accounts.json*
sessions.json*
dump.rdb
*.bak
/tools/*.json*

View File

@@ -1,31 +1,41 @@
[Server]
hostname = "nitter.net" # for generating links, change this to your own domain/ip
hostname = "nitter.net" # for generating links, change this to your own domain/ip
title = "nitter"
address = "0.0.0.0"
port = 8080
https = false # disable to enable cookies when not using https
https = false # disable to enable cookies when not using https
httpMaxConnections = 100
staticDir = "./public"
[Cache]
listMinutes = 240 # how long to cache list info (not the tweets, so keep it high)
rssMinutes = 10 # how long to cache rss queries
redisHost = "localhost" # Change to "nitter-redis" if using docker-compose
listMinutes = 240 # how long to cache list info (not the tweets, so keep it high)
rssMinutes = 10 # how long to cache rss queries
redisHost = "localhost" # Change to "nitter-redis" if using docker-compose
redisPort = 6379
redisPassword = ""
redisConnections = 20 # minimum open connections in pool
redisConnections = 20 # minimum open connections in pool
redisMaxConnections = 30
# new connections are opened when none are available, but if the pool size
# goes above this, they're closed when released. don't worry about this unless
# you receive tons of requests per second
[Config]
hmacKey = "secretkey" # random key for cryptographic signing of video urls
base64Media = false # use base64 encoding for proxied media urls
enableRSS = true # set this to false to disable RSS feeds
enableDebug = false # enable request logs and debug endpoints (/.sessions)
proxy = "" # http/https url, SOCKS proxies are not supported
hmacKey = "secretkey" # random key for cryptographic signing of video urls
base64Media = false # use base64 encoding for proxied media urls
enableRSS = true # master switch, set to false to disable all RSS feeds
enableRSSUserTweets = true # /@user/rss
enableRSSUserReplies = true # /@user/with_replies/rss
enableRSSUserMedia = true # /@user/media/rss
enableRSSSearch = true # /search/rss and /@user/search/rss
enableRSSList = true # list RSS feeds
enableDebug = false # enable request logs and debug endpoints (/.sessions)
proxy = "" # http/https url, SOCKS proxies are not supported
proxyAuth = ""
apiProxy = "" # nitter-proxy host, e.g. localhost:7000
disableTid = false # enable this if cookie-based auth is failing
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]

View File

@@ -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"

View File

@@ -1,53 +1,148 @@
@font-face {
font-family: 'fontello';
src: url('/fonts/fontello.eot?61663884');
src: url('/fonts/fontello.eot?61663884#iefix') format('embedded-opentype'),
url('/fonts/fontello.woff2?61663884') format('woff2'),
url('/fonts/fontello.woff?61663884') format('woff'),
url('/fonts/fontello.ttf?61663884') format('truetype'),
url('/fonts/fontello.svg?61663884#fontello') format('svg');
font-family: "fontello";
src: url("/fonts/fontello.eot?49059696");
src:
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;
}
[class^="icon-"]:before, [class*=" icon-"]:before {
[class^="icon-"]:before,
[class*=" icon-"]:before {
font-family: "fontello";
font-style: normal;
font-weight: normal;
speak: never;
display: inline-block;
text-decoration: inherit;
width: 1em;
margin-right: 0.2em;
text-align: center;
/* For safety - reset parent styles, that can break glyph codes*/
font-variant: normal;
text-transform: none;
/* fix buttons height, for twitter bootstrap */
line-height: 1em;
/* Font smoothing. That was taken from TWBS */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-views:before { content: '\e800'; } /* '' */
.icon-heart:before { content: '\e801'; } /* '' */
.icon-quote:before { content: '\e802'; } /* '' */
.icon-comment:before { content: '\e803'; } /* '' */
.icon-ok:before { content: '\e804'; } /* '' */
.icon-play:before { content: '\e805'; } /* '' */
.icon-link:before { content: '\e806'; } /* '' */
.icon-calendar:before { content: '\e807'; } /* '' */
.icon-location:before { content: '\e808'; } /* '' */
.icon-picture:before { content: '\e809'; } /* '' */
.icon-lock:before { content: '\e80a'; } /* '' */
.icon-down:before { content: '\e80b'; } /* '' */
.icon-retweet:before { content: '\e80c'; } /* '' */
.icon-search:before { content: '\e80d'; } /* '' */
.icon-pin:before { content: '\e80e'; } /* '' */
.icon-cog:before { content: '\e80f'; } /* '' */
.icon-rss:before { content: '\e810'; } /* '' */
.icon-info:before { content: '\f128'; } /* '' */
.icon-bird:before { content: '\f309'; } /* '' */
.icon-views:before {
content: "\e800";
}
/* '' */
.icon-heart:before {
content: "\e801";
}
/* '' */
.icon-quote:before {
content: "\e802";
}
/* '' */
.icon-comment:before {
content: "\e803";
}
/* '' */
.icon-group:before {
content: "\e804";
}
/* '' */
.icon-play:before {
content: "\e805";
}
/* '' */
.icon-link:before {
content: "\e806";
}
/* '' */
.icon-calendar:before {
content: "\e807";
}
/* '' */
.icon-location:before {
content: "\e808";
}
/* '' */
.icon-picture:before {
content: "\e809";
}
/* '' */
.icon-lock:before {
content: "\e80a";
}
/* '' */
.icon-down:before {
content: "\e80b";
}
/* '' */
.icon-retweet:before {
content: "\e80c";
}
/* '' */
.icon-search:before {
content: "\e80d";
}
/* '' */
.icon-pin:before {
content: "\e80e";
}
/* '' */
.icon-cog:before {
content: "\e80f";
}
/* '' */
.icon-rss:before {
content: "\e810";
}
/* '' */
.icon-ok:before {
content: "\e811";
}
/* '' */
.icon-attention:before {
content: "\e812";
}
/* '' */
.icon-circle:before {
content: "\f111";
}
/* '' */
.icon-info:before {
content: "\f128";
}
/* '' */
.icon-bird:before {
content: "\f309";
}
/* '' */

Binary file not shown.

View File

@@ -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,7 +14,7 @@
<glyph glyph-name="comment" unicode="&#xe803;" 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="ok" unicode="&#xe804;" d="M0 260l162 162 166-164 508 510 164-164-510-510-162-162-162 164z" horiz-adv-x="1000" />
<glyph glyph-name="group" unicode="&#xe804;" 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="&#xe805;" 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" />
@@ -40,6 +40,12 @@
<glyph glyph-name="rss" unicode="&#xe810;" d="M184 93c0-51-43-91-93-91s-91 40-91 91c0 50 41 91 91 91s93-41 93-91z m261-85l-125 0c0 174-140 323-315 323l0 118c231 0 440-163 440-441z m259 0l-136 0c0 300-262 561-563 561l0 129c370 0 699-281 699-690z" horiz-adv-x="704" />
<glyph glyph-name="ok" unicode="&#xe811;" d="M933 534q0-22-16-38l-404-404-76-76q-16-15-38-15t-38 15l-76 76-202 202q-15 16-15 38t15 38l76 76q16 16 38 16t38-16l164-165 366 367q16 16 38 16t38-16l76-76q16-15 16-38z" horiz-adv-x="1000" />
<glyph glyph-name="attention-circled" unicode="&#xe812;" d="M429 779q116 0 215-58t156-156 57-215-57-215-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58z m71-696v106q0 8-5 13t-12 5h-107q-8 0-13-5t-6-13v-106q0-8 6-13t13-6h107q7 0 12 6t5 13z m-1 192l10 346q0 7-6 10-5 5-13 5h-123q-8 0-13-5-6-3-6-10l10-346q0-6 5-10t14-4h103q8 0 13 4t6 10z" horiz-adv-x="857.1" />
<glyph glyph-name="circle" unicode="&#xf111;" d="M857 350q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" />
<glyph glyph-name="info" unicode="&#xf128;" d="M393 149v-134q0-9-7-15t-15-7h-134q-9 0-16 7t-7 15v134q0 9 7 16t16 6h134q9 0 15-6t7-16z m176 335q0-30-8-56t-20-43-31-33-32-25-34-19q-23-13-38-37t-15-37q0-10-7-18t-16-9h-134q-8 0-14 11t-6 20v26q0 46 37 87t79 60q33 16 47 32t14 42q0 24-26 41t-60 18q-36 0-60-16-20-14-60-64-7-9-17-9-7 0-14 4l-91 70q-8 6-9 14t3 16q89 148 259 148 45 0 90-17t81-46 59-72 23-88z" horiz-adv-x="571.4" />
<glyph glyph-name="bird" unicode="&#xf309;" d="M920 636q-36-54-94-98l0-24q0-130-60-250t-186-203-290-83q-160 0-290 84 14-2 46-2 132 0 234 80-62 2-110 38t-66 94q10-4 34-4 26 0 50 6-66 14-108 66t-42 120l0 2q36-20 84-24-84 58-84 158 0 48 26 94 154-188 390-196-6 18-6 42 0 78 55 133t135 55q82 0 136-58 60 12 120 44-20-66-82-104 56 8 108 30z" horiz-adv-x="920" />

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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();
});
}

View File

@@ -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

View File

@@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, httpclient, uri, strutils, sequtils, sugar, tables
import asyncdispatch, httpclient, strutils, sequtils, sugar
import packedjson
import types, query, formatters, consts, apiutils, parser
import types, query, formatters, consts, apiutils, parser, utils
import experimental/parser as newParser
# Helper to generate params object for GraphQL requests
@@ -11,88 +11,118 @@ proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
if fieldToggles.len > 0:
result.add ("fieldToggles", fieldToggles)
proc mediaUrl(id: string; cursor: string): SessionAwareUrl =
let
cookieVariables = userMediaVariables % [id, cursor]
oauthVariables = restIdVariables % [id, cursor]
result = SessionAwareUrl(
cookieUrl: graphUserMedia ? genParams(cookieVariables),
oauthUrl: graphUserMediaV2 ? genParams(oauthVariables)
proc apiUrl(endpoint, variables: string; fieldToggles = ""): ApiUrl =
return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles))
proc apiReq(endpoint, variables: string; fieldToggles = ""): ApiReq =
let url = apiUrl(endpoint, variables, fieldToggles)
return ApiReq(cookie: url, oauth: url)
proc mediaUrl(id, cursor: string; count=20): ApiReq =
result = ApiReq(
cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor, $count]),
oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor, $count])
)
proc userTweetsUrl(id: string; cursor: string): SessionAwareUrl =
let
cookieVariables = userTweetsVariables % [id, cursor]
oauthVariables = restIdVariables % [id, cursor]
result = SessionAwareUrl(
# cookieUrl: graphUserTweets ? genParams(cookieVariables, fieldToggles),
oauthUrl: graphUserTweetsV2 ? genParams(oauthVariables)
proc userTweetsUrl(id: string; cursor: string): ApiReq =
result = ApiReq(
# cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles),
oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor, "20"])
)
# might change this in the future pending testing
result.cookieUrl = result.oauthUrl
result.cookie = result.oauth
proc userTweetsAndRepliesUrl(id: string; cursor: string): SessionAwareUrl =
let
cookieVariables = userTweetsAndRepliesVariables % [id, cursor]
oauthVariables = restIdVariables % [id, cursor]
result = SessionAwareUrl(
cookieUrl: graphUserTweetsAndReplies ? genParams(cookieVariables, fieldToggles),
oauthUrl: graphUserTweetsAndRepliesV2 ? genParams(oauthVariables)
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, "20"])
)
proc tweetDetailUrl(id: string; cursor: string): SessionAwareUrl =
let
cookieVariables = tweetDetailVariables % [id, cursor]
oauthVariables = tweetVariables % [id, cursor]
result = SessionAwareUrl(
cookieUrl: graphTweetDetail ? genParams(cookieVariables, tweetDetailFieldToggles),
oauthUrl: graphTweet ? genParams(oauthVariables)
proc tweetDetailUrl(id: string; cursor: string): ApiReq =
let cookieVars = tweetDetailVars % [id, cursor]
result = ApiReq(
# cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles),
cookie: apiUrl(graphTweet, tweetVars % [id, cursor]),
oauth: apiUrl(graphTweet, tweetVars % [id, cursor])
)
proc userUrl(username: string): ApiReq =
let cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username
result = ApiReq(
cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles),
oauth: apiUrl(graphUserV2, """{"screen_name": "$1"}""" % username)
)
proc getGraphUser*(username: string): Future[User] {.async.} =
if username.len == 0: return
let
url = graphUser ? genParams("""{"screen_name": "$1"}""" % username)
js = await fetchRaw(url, Api.userScreenName)
let js = await fetchRaw(userUrl(username))
result = parseGraphUser(js)
proc getGraphUserById*(id: string): Future[User] {.async.} =
if id.len == 0 or id.any(c => not c.isDigit): return
let
url = graphUserById ? genParams("""{"rest_id": "$1"}""" % id)
js = await fetchRaw(url, Api.userRestId)
url = apiReq(graphUserById, """{"rest_id": "$1"}""" % id)
js = await fetchRaw(url)
result = parseGraphUser(js)
proc 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
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
js = case kind
of TimelineKind.tweets:
await fetch(userTweetsUrl(id, cursor), Api.userTweets)
of TimelineKind.replies:
await fetch(userTweetsAndRepliesUrl(id, cursor), Api.userTweetsAndReplies)
of TimelineKind.media:
await fetch(mediaUrl(id, cursor), Api.userMedia)
url = case kind
of TimelineKind.tweets: userTweetsUrl(id, cursor)
of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
of TimelineKind.media: mediaUrl(id, cursor, 100)
js = await fetch(url)
result = parseGraphTimeline(js, after)
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
url = graphListTweets ? genParams(restIdVariables % [id, cursor])
result = parseGraphTimeline(await fetch(url, Api.listTweets), after).tweets
url = apiReq(graphListTweets, restIdVars % [id, cursor, "20"])
js = await fetch(url)
result = parseGraphTimeline(js, after).tweets
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
let
variables = %*{"screenName": name, "listSlug": list}
url = graphListBySlug ? genParams($variables)
result = parseGraphList(await fetch(url, Api.listBySlug))
url = apiReq(graphListBySlug, $variables)
js = await fetch(url)
result = parseGraphList(js)
proc getGraphList*(id: string): Future[List] {.async.} =
let
url = graphListById ? genParams("""{"listId": "$1"}""" % id)
result = parseGraphList(await fetch(url, Api.list))
let
url = apiReq(graphListById, """{"listId": "$1"}""" % id)
js = await fetch(url)
result = parseGraphList(js)
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
if list.id.len == 0: return
@@ -106,22 +136,23 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
}
if after.len > 0:
variables["cursor"] = % after
let url = graphListMembers ? genParams($variables)
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after)
let
url = apiReq(graphListMembers, $variables)
js = await fetchRaw(url)
result = parseGraphListMembers(js, after)
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
if id.len == 0: return
let
variables = """{"rest_id": "$1"}""" % id
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphTweetResult ? params, Api.tweetResult)
url = apiReq(graphTweetResult, """{"rest_id": "$1"}""" % id)
js = await fetch(url)
result = parseGraphTweetResult(js)
proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
js = await fetch(tweetDetailUrl(id, cursor), Api.tweetDetail)
js = await fetch(tweetDetailUrl(id, cursor))
result = parseGraphConversation(js, id)
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
@@ -133,8 +164,20 @@ proc getTweet*(id: string; after=""): Future[Conversation] {.async.} =
if after.len > 0:
result.replies = await getReplies(id, after)
proc getGraphEditHistory*(id: string): Future[EditHistory] {.async.} =
if id.len == 0: return
let
url = apiReq(graphTweetEditHistory, tweetEditHistoryVars % id)
js = await fetch(url)
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)
@@ -148,12 +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 url = graphSearchTimeline ? genParams($variables)
result = parseGraphSearch[Tweets](await fetch(url, Api.search), after)
let
url = apiReq(graphSearchTimeline, $variables)
js = await fetch(url)
result = parseGraphSearch[Tweets](js, after)
result.query = query
# 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)
@@ -172,13 +223,15 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.}
variables["cursor"] = % after
result.beginning = false
let url = graphSearchTimeline ? genParams($variables)
result = parseGraphSearch[User](await fetch(url, Api.search), after)
let
url = apiReq(graphSearchTimeline, $variables)
js = await fetch(url)
result = parseGraphSearch[User](js, after)
result.query = query
proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
if id.len == 0: return
let js = await fetch(mediaUrl(id, ""), Api.userMedia)
let js = await fetch(mediaUrl(id, "", 30))
result = parseGraphPhotoRail(js)
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =

View File

@@ -1,16 +1,47 @@
# SPDX-License-Identifier: AGPL-3.0-only
import httpclient, asyncdispatch, options, strutils, uri, times, math, tables
import jsony, packedjson, zippy, oauth1
import types, auth, consts, parserutils, http_pool
import types, auth, consts, parserutils, http_pool, tid
import experimental/types/common
const
rlRemaining = "x-rate-limit-remaining"
rlReset = "x-rate-limit-reset"
rlLimit = "x-rate-limit-limit"
errorsToSkip = {doesntExist, tweetNotFound, timeout, unauthorized, badRequest}
errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest}
var pool: HttpPool
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 =
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
@@ -32,31 +63,41 @@ proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
proc getCookieHeader(authToken, ct0: string): string =
"auth_token=" & authToken & "; ct0=" & ct0
proc genHeaders*(session: Session, url: string): HttpHeaders =
proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} =
result = newHttpHeaders({
"connection": "keep-alive",
"content-type": "application/json",
"x-twitter-active-user": "yes",
"x-twitter-client-language": "en",
"authority": "api.x.com",
"accept": "*/*",
"accept-encoding": "gzip",
"accept-language": "en-US,en;q=0.9",
"accept": "*/*",
"DNT": "1",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
"connection": "keep-alive",
"content-type": "application/json",
"origin": "https://x.com",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
"x-twitter-active-user": "yes",
"x-twitter-client-language": "en",
"priority": "u=1, i"
})
case session.kind
of SessionKind.oauth:
result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret)
result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret)
of SessionKind.cookie:
result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF"
result["x-twitter-auth-type"] = "OAuth2Session"
result["x-csrf-token"] = session.ct0
result["cookie"] = getCookieHeader(session.authToken, session.ct0)
result["sec-ch-ua"] = """"Google Chrome";v="142", "Chromium";v="142", "Not A(Brand";v="24""""
result["sec-ch-ua-mobile"] = "?0"
result["sec-ch-ua-platform"] = "Windows"
result["sec-fetch-dest"] = "empty"
result["sec-fetch-mode"] = "cors"
result["sec-fetch-site"] = "same-site"
if disableTid or "/1.1/" in url.path:
result["authorization"] = bearerToken2
else:
result["authorization"] = bearerToken
result["x-client-transaction-id"] = await genTid(url.path)
proc getAndValidateSession*(api: Api): Future[Session] {.async.} =
result = await getSession(api)
proc getAndValidateSession*(req: ApiReq): Future[Session] {.async.} =
result = await getSession(req)
case result.kind
of SessionKind.oauth:
if result.oauthToken.len == 0:
@@ -73,9 +114,13 @@ template fetchImpl(result, fetchBody) {.dirty.} =
try:
var resp: AsyncResponse
pool.use(genHeaders(session, $url)):
pool.use(await genHeaders(session, url)):
template getContent =
resp = await c.get($url)
# TODO: this is a temporary simple implementation
if apiProxy.len > 0 and "/1.1/" notin url.path:
resp = await c.get(($url).replace("https://", apiProxy))
else:
resp = await c.get($url)
result = await resp.body
getContent()
@@ -84,12 +129,16 @@ 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])
reset = parseInt(resp.headers[rlReset])
limit = parseInt(resp.headers[rlLimit])
session.setRateLimit(api, remaining, reset, limit)
session.setRateLimit(req, remaining, reset, limit)
if result.len > 0:
if resp.headers.getOrDefault("content-encoding") == "gzip":
@@ -98,24 +147,22 @@ template fetchImpl(result, fetchBody) {.dirty.} =
if result.startsWith("{\"errors"):
let errors = result.fromJson(Errors)
if errors notin errorsToSkip:
echo "Fetch error, API: ", api, ", errors: ", errors
echo "Fetch error, API: ", url.path, ", errors: ", errors
if errors in {expiredToken, badToken, locked}:
invalidate(session)
raise rateLimitError()
elif errors in {rateLimited}:
# rate limit hit, resets after 24 hours
setLimited(session, api)
setLimited(session, req)
raise rateLimitError()
elif result.startsWith("429 Too Many Requests"):
echo "[sessions] 429 error, API: ", api, ", session: ", session.pretty
session.apis[api].remaining = 0
# rate limit hit, resets after the 15 minute window
echo "[sessions] 429 error, API: ", url.path, ", session: ", session.pretty
raise rateLimitError()
fetchBody
if resp.status == $Http400:
echo "ERROR 400, ", api, ": ", result
echo "ERROR 400, ", url.path, ": ", result
raise newException(InternalError, $url)
except InternalError as e:
raise e
@@ -131,22 +178,23 @@ template fetchImpl(result, fetchBody) {.dirty.} =
release(session)
template retry(bod) =
try:
bod
except RateLimitError:
echo "[sessions] Rate limited, retrying ", api, " 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*(url: Uri | SessionAwareUrl; api: Api): Future[JsonNode] {.async.} =
proc fetch*(req: ApiReq): Future[JsonNode] {.async.} =
retry:
var
body: string
session = await getAndValidateSession(api)
session = await getAndValidateSession(req)
when url is SessionAwareUrl:
let url = case session.kind
of SessionKind.oauth: url.oauthUrl
of SessionKind.cookie: url.cookieUrl
let url = req.toUrl(session.kind)
fetchImpl body:
if body.startsWith('{') or body.startsWith('['):
@@ -157,19 +205,15 @@ proc fetch*(url: Uri | SessionAwareUrl; api: Api): Future[JsonNode] {.async.} =
let error = result.getError
if error != null and error notin errorsToSkip:
echo "Fetch error, API: ", api, ", error: ", error
echo "Fetch error, API: ", url.path, ", error: ", error
if error in {expiredToken, badToken, locked}:
invalidate(session)
raise rateLimitError()
proc fetchRaw*(url: Uri | SessionAwareUrl; api: Api): Future[string] {.async.} =
proc fetchRaw*(req: ApiReq): Future[string] {.async.} =
retry:
var session = await getAndValidateSession(api)
when url is SessionAwareUrl:
let url = case session.kind
of SessionKind.oauth: url.oauthUrl
of SessionKind.cookie: url.cookieUrl
var session = await getAndValidateSession(req)
let url = req.toUrl(session.kind)
fetchImpl result:
if not (result.startsWith('{') or result.startsWith('[')):

View File

@@ -1,20 +1,28 @@
#SPDX-License-Identifier: AGPL-3.0-only
import std/[asyncdispatch, times, json, random, sequtils, strutils, tables, packedsets, os]
import types
import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os]
import types, consts
import experimental/parser/session
# max requests at a time per session to avoid race conditions
const
maxConcurrentReqs = 2
hourInSeconds = 60 * 60
const hourInSeconds = 60 * 60
var
sessionPool: seq[Session]
enableLogging = false
# max requests at a time per session to avoid race conditions
maxConcurrentReqs = 2
proc setMaxConcurrentReqs*(reqs: int) =
if reqs > 0:
maxConcurrentReqs = reqs
template log(str: varargs[string, `$`]) =
echo "[sessions] ", str.join("")
proc endpoint(req: ApiReq; session: Session): string =
case session.kind
of oauth: req.oauth.endpoint
of cookie: req.cookie.endpoint
proc pretty*(session: Session): string =
if session.isNil:
return "<null>"
@@ -122,11 +130,12 @@ proc rateLimitError*(): ref RateLimitError =
proc noSessionsError*(): ref NoSessionsError =
newException(NoSessionsError, "no sessions available")
proc isLimited(session: Session; api: Api): bool =
proc isLimited(session: Session; req: ApiReq): bool =
if session.isNil:
return true
if session.limited and api != Api.userTweets:
let api = req.endpoint(session)
if session.limited and api != graphUserTweetsV2:
if (epochTime().int - session.limitedAt) > hourInSeconds:
session.limited = false
log "resetting limit: ", session.pretty
@@ -140,8 +149,8 @@ proc isLimited(session: Session; api: Api): bool =
else:
return false
proc isReady(session: Session; api: Api): bool =
not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(api))
proc isReady(session: Session; req: ApiReq): bool =
not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(req))
proc invalidate*(session: var Session) =
if session.isNil: return
@@ -156,24 +165,26 @@ proc release*(session: Session) =
if session.isNil: return
dec session.pending
proc getSession*(api: Api): Future[Session] {.async.} =
proc getSession*(req: ApiReq): Future[Session] {.async.} =
for i in 0 ..< sessionPool.len:
if result.isReady(api): break
if result.isReady(req): break
result = sessionPool.sample()
if not result.isNil and result.isReady(api):
if not result.isNil and result.isReady(req):
inc result.pending
else:
log "no sessions available for API: ", api
log "no sessions available for API: ", req.cookie.endpoint
raise noSessionsError()
proc setLimited*(session: Session; api: Api) =
proc setLimited*(session: Session; req: ApiReq) =
let api = req.endpoint(session)
session.limited = true
session.limitedAt = epochTime().int
log "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", ", session.pretty
proc setRateLimit*(session: Session; api: Api; remaining, reset, limit: int) =
proc setRateLimit*(session: Session; req: ApiReq; remaining, reset, limit: int) =
# avoid undefined behavior in race conditions
let api = req.endpoint(session)
if api in session.apis:
let rateLimit = session.apis[api]
if rateLimit.reset >= reset and rateLimit.remaining < remaining:

View File

@@ -13,6 +13,8 @@ proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
proc getConfig*(path: string): (Config, parseCfg.Config) =
var cfg = loadConfig(path)
let masterRss = cfg.get("Config", "enableRSS", true)
let conf = Config(
# Server
address: cfg.get("Server", "address", "0.0.0.0"),
@@ -37,10 +39,19 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
hmacKey: cfg.get("Config", "hmacKey", "secretkey"),
base64Media: cfg.get("Config", "base64Media", false),
minTokens: cfg.get("Config", "tokenCount", 10),
enableRss: cfg.get("Config", "enableRSS", true),
enableRSSUserTweets: masterRss and cfg.get("Config", "enableRSSUserTweets", true),
enableRSSUserReplies: masterRss and cfg.get("Config", "enableRSSUserReplies", true),
enableRSSUserMedia: masterRss and cfg.get("Config", "enableRSSUserMedia", true),
enableRSSSearch: masterRss and cfg.get("Config", "enableRSSSearch", true),
enableRSSList: masterRss and cfg.get("Config", "enableRSSList", true),
enableDebug: cfg.get("Config", "enableDebug", false),
proxy: cfg.get("Config", "proxy", ""),
proxyAuth: cfg.get("Config", "proxyAuth", "")
proxyAuth: cfg.get("Config", "proxyAuth", ""),
apiProxy: cfg.get("Config", "apiProxy", ""),
disableTid: cfg.get("Config", "disableTid", false),
maxConcurrentReqs: cfg.get("Config", "maxConcurrentReqs", 2),
maxRetries: cfg.get("Config", "maxRetries", 1),
retryDelayMs: cfg.get("Config", "retryDelayMs", 150)
)
return (conf, cfg)

View File

@@ -1,62 +1,100 @@
# SPDX-License-Identifier: AGPL-3.0-only
import uri, strutils
import strutils
const
consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F"
gql = parseUri("https://api.x.com") / "graphql"
graphUser* = "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName"
graphUserV2* = "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery"
graphUserById* = "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery"
graphUserTweetsV2* = "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2"
graphUserTweetsAndRepliesV2* = "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2"
graphUserTweets* = "oRJs8SLCRNRbQzuZG93_oA/UserTweets"
graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
graphUserMedia* = "36oKqyQ7E_9CmtONGjJRsA/UserMedia"
graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2"
graphTweet* = "b4pV7sWOe97RncwHcGESUA/ConversationTimeline"
graphTweetDetail* = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail"
graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery"
graphTweetEditHistory* = "upS9teTSG45aljmP9oTuXA/TweetEditHistory"
graphSearchTimeline* = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline"
graphListById* = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId"
graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
graphAboutAccount* = "zs_jFPFT78rBpXv9Z3U2YQ/AboutAccountQuery"
graphUser* = gql / "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery"
graphUserById* = gql / "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery"
graphUserTweetsV2* = gql / "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2"
graphUserTweetsAndRepliesV2* = gql / "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2"
graphUserTweets* = gql / "oRJs8SLCRNRbQzuZG93_oA/UserTweets"
graphUserTweetsAndReplies* = gql / "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
graphUserMedia* = gql / "36oKqyQ7E_9CmtONGjJRsA/UserMedia"
graphUserMediaV2* = gql / "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2"
graphTweet* = gql / "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline"
graphTweetDetail* = gql / "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail"
graphTweetResult* = gql / "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery"
graphSearchTimeline* = gql / "bshMIjqDk8LTXTq4w91WKw/SearchTimeline"
graphListById* = gql / "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId"
graphListBySlug* = gql / "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
graphListMembers* = gql / "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
graphListTweets* = gql / "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
graphBroadcast* = "0nMmbMh-_JwwRRFNXkyH3Q/BroadcastQuery"
restLiveStream* = "1.1/live_video_stream/status/"
gqlFeatures* = """{
"android_ad_formats_media_component_render_overlay_enabled": false,
"android_graphql_skip_api_media_color_palette": false,
"android_professional_link_spotlight_display_enabled": false,
"articles_api_enabled": false,
"articles_preview_enabled": true,
"blue_business_profile_image_shape_enabled": false,
"c9s_tweet_anatomy_moderator_badge_enabled": true,
"commerce_android_shop_module_enabled": false,
"communities_web_enable_tweet_community_results_fetch": true,
"creator_subscriptions_quote_tweet_preview_enabled": false,
"creator_subscriptions_subscription_count_enabled": false,
"creator_subscriptions_tweet_preview_api_enabled": true,
"freedom_of_speech_not_reach_fetch_enabled": true,
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
"grok_android_analyze_trend_fetch_enabled": false,
"grok_translations_community_note_auto_translation_is_enabled": false,
"grok_translations_community_note_translation_is_enabled": false,
"grok_translations_post_auto_translation_is_enabled": false,
"grok_translations_timeline_user_bio_auto_translation_is_enabled": false,
"hidden_profile_likes_enabled": false,
"highlights_tweets_tab_ui_enabled": false,
"immersive_video_status_linkable_timestamps": false,
"interactive_text_enabled": false,
"longform_notetweets_consumption_enabled": true,
"longform_notetweets_inline_media_enabled": true,
"longform_notetweets_rich_text_read_enabled": true,
"longform_notetweets_richtext_consumption_enabled": true,
"longform_notetweets_rich_text_read_enabled": true,
"mobile_app_spotlight_module_enabled": false,
"payments_enabled": false,
"post_ctas_fetch_enabled": true,
"premium_content_api_read_enabled": false,
"profile_label_improvements_pcf_label_in_post_enabled": true,
"profile_label_improvements_pcf_label_in_profile_enabled": false,
"responsive_web_edit_tweet_api_enabled": true,
"responsive_web_enhance_cards_enabled": false,
"responsive_web_graphql_exclude_directive_enabled": true,
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
"responsive_web_graphql_timeline_navigation_enabled": true,
"responsive_web_grok_analysis_button_from_backend": true,
"responsive_web_grok_analyze_button_fetch_trends_enabled": false,
"responsive_web_grok_analyze_post_followups_enabled": true,
"responsive_web_grok_annotations_enabled": true,
"responsive_web_grok_community_note_auto_translation_is_enabled": false,
"responsive_web_grok_image_annotation_enabled": true,
"responsive_web_grok_imagine_annotation_enabled": true,
"responsive_web_grok_share_attachment_enabled": true,
"responsive_web_grok_show_grok_translated_post": false,
"responsive_web_jetfuel_frame": true,
"responsive_web_media_download_video_enabled": false,
"responsive_web_profile_redirect_enabled": false,
"responsive_web_text_conversations_enabled": false,
"responsive_web_twitter_article_notes_tab_enabled": false,
"responsive_web_twitter_article_tweet_consumption_enabled": true,
"unified_cards_destination_url_params_enabled": false,
"responsive_web_twitter_blue_verified_badge_is_enabled": true,
"rweb_lists_timeline_redesign_enabled": true,
"rweb_tipjar_consumption_enabled": true,
"rweb_video_screen_enabled": false,
"rweb_video_timestamps_enabled": false,
"spaces_2022_h2_clipping": true,
"spaces_2022_h2_spaces_communities": true,
"standardized_nudges_misinfo": true,
"subscriptions_feature_can_gift_premium": false,
"subscriptions_verification_info_enabled": true,
"subscriptions_verification_info_is_identity_verified_enabled": false,
"subscriptions_verification_info_reason_enabled": true,
"subscriptions_verification_info_verified_since_enabled": true,
"super_follow_badge_privacy_enabled": false,
@@ -67,50 +105,24 @@ const
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
"tweetypie_unmention_optimization_enabled": false,
"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
"unified_cards_destination_url_params_enabled": false,
"verified_phone_label_enabled": false,
"vibe_api_enabled": false,
"view_counts_everywhere_api_enabled": true,
"premium_content_api_read_enabled": false,
"communities_web_enable_tweet_community_results_fetch": true,
"responsive_web_jetfuel_frame": true,
"responsive_web_grok_analyze_button_fetch_trends_enabled": false,
"responsive_web_grok_image_annotation_enabled": true,
"responsive_web_grok_imagine_annotation_enabled": true,
"rweb_tipjar_consumption_enabled": true,
"profile_label_improvements_pcf_label_in_post_enabled": true,
"creator_subscriptions_quote_tweet_preview_enabled": false,
"c9s_tweet_anatomy_moderator_badge_enabled": true,
"responsive_web_grok_analyze_post_followups_enabled": true,
"rweb_video_timestamps_enabled": false,
"responsive_web_grok_share_attachment_enabled": true,
"articles_preview_enabled": true,
"immersive_video_status_linkable_timestamps": false,
"articles_api_enabled": false,
"responsive_web_grok_analysis_button_from_backend": true,
"rweb_video_screen_enabled": false,
"payments_enabled": false,
"responsive_web_profile_redirect_enabled": false,
"responsive_web_grok_show_grok_translated_post": false,
"responsive_web_grok_community_note_auto_translation_is_enabled": false,
"profile_label_improvements_pcf_label_in_profile_enabled": false,
"grok_android_analyze_trend_fetch_enabled": false,
"grok_translations_community_note_auto_translation_is_enabled": false,
"grok_translations_post_auto_translation_is_enabled": false,
"grok_translations_community_note_translation_is_enabled": false,
"grok_translations_timeline_user_bio_auto_translation_is_enabled": false
"hidden_profile_subscriptions_enabled": false
}""".replace(" ", "").replace("\n", "")
tweetVariables* = """{
tweetVars* = """{
"postId": "$1",
$2
"includeHasBirdwatchNotes": false,
"includePromotedContent": false,
"withBirdwatchNotes": false,
"withBirdwatchNotes": true,
"withVoice": false,
"withV2Timeline": true
}""".replace(" ", "").replace("\n", "")
tweetDetailVariables* = """{
tweetDetailVars* = """{
"focalTweetId": "$1",
$2
"referrer": "profile",
@@ -123,21 +135,26 @@ const
"withVoice": true
}""".replace(" ", "").replace("\n", "")
restIdVariables* = """{
tweetEditHistoryVars* = """{
"tweetId": "$1",
"withQuickPromoteEligibilityTweetFields": true
}""".replace(" ", "").replace("\n", "")
restIdVars* = """{
"rest_id": "$1", $2
"count": 20
"count": $3
}"""
userMediaVariables* = """{
userMediaVars* = """{
"userId": "$1", $2
"count": 20,
"count": $3,
"includePromotedContent": false,
"withClientEventToken": false,
"withBirdwatchNotes": false,
"withVoice": true
}""".replace(" ", "").replace("\n", "")
userTweetsVariables* = """{
userTweetsVars* = """{
"userId": "$1", $2
"count": 20,
"includePromotedContent": false,
@@ -145,7 +162,7 @@ const
"withVoice": true
}""".replace(" ", "").replace("\n", "")
userTweetsAndRepliesVariables* = """{
userTweetsAndRepliesVars* = """{
"userId": "$1", $2
"count": 20,
"includePromotedContent": false,
@@ -153,5 +170,6 @@ const
"withVoice": true
}""".replace(" ", "").replace("\n", "")
fieldToggles* = """{"withArticlePlainText":false}"""
userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
userTweetsFieldToggles* = """{"withArticlePlainText":false}"""
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""

View File

@@ -1,6 +1,6 @@
import options, strutils
import jsony
import user, ../types/[graphuser, graphlistmembers]
import user, utils, ../types/[graphuser, graphlistmembers]
from ../../types import User, VerifiedType, Result, Query, QueryKind
proc parseUserResult*(userResult: UserResult): User =
@@ -15,22 +15,36 @@ proc parseUserResult*(userResult: UserResult): User =
result.fullname = userResult.core.name
result.userPic = userResult.avatar.imageUrl.replace("_normal", "")
if userResult.privacy.isSome:
result.protected = userResult.privacy.get.protected
if userResult.location.isSome:
result.location = userResult.location.get.location
if userResult.core.createdAt.len > 0:
result.joinDate = parseTwitterDate(userResult.core.createdAt)
if userResult.verification.isSome:
let v = userResult.verification.get
if v.verifiedType != VerifiedType.none:
result.verifiedType = v.verifiedType
if userResult.profileBio.isSome:
if userResult.profileBio.isSome and result.bio.len == 0:
result.bio = userResult.profileBio.get.description
proc parseGraphUser*(json: string): User =
if json.len == 0 or json[0] != '{':
return
let raw = json.fromJson(GraphUser)
let userResult = raw.data.userResult.result
let
raw = json.fromJson(GraphUser)
userResult =
if raw.data.userResult.isSome: raw.data.userResult.get.result
elif raw.data.user.isSome: raw.data.user.get.result
else: UserResult()
if userResult.unavailableReason.get("") == "Suspended":
if userResult.unavailableReason.get("") == "Suspended" or
userResult.reason.get("") == "Suspended":
return User(suspended: true)
result = parseUserResult(userResult)

View File

@@ -54,7 +54,7 @@ proc replacedWith*(runes: seq[Rune]; repls: openArray[ReplaceSlice];
let
name = $runes[rep.slice.a.succ .. rep.slice.b]
symbol = $runes[rep.slice.a]
result.add a(symbol & name, href = "/search?q=%23" & name)
result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name)
of rkMention:
result.add a($runes[rep.slice], href = rep.url, title = rep.display)
of rkUrl:

View File

@@ -0,0 +1,8 @@
import jsony
import ../types/tid
export TidPair
proc parseTidPairs*(raw: string): seq[TidPair] =
result = raw.fromJson(seq[TidPair])
if result.len == 0:
raise newException(ValueError, "Parsing pairs failed: " & raw)

View File

@@ -9,7 +9,7 @@ let
unReplace = "$1<a href=\"/$2\">@$2</a>"
htRegex = nre.re"""(*U)(^|[^\w-_.?])([#$])([\w_]*+)(?!</a>|">|#)"""
htReplace = "$1<a href=\"/search?q=%23$3\">$2$3</a>"
htReplace = "$1<a href=\"/search?f=tweets&q=%23$3\">$2$3</a>"
proc expandUserEntities(user: var User; raw: RawUser) =
let
@@ -58,11 +58,13 @@ proc toUser*(raw: RawUser): User =
media: raw.mediaCount,
verifiedType: raw.verifiedType,
protected: raw.protected,
joinDate: parseTwitterDate(raw.createdAt),
banner: getBanner(raw),
userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "")
)
if raw.createdAt.len > 0:
result.joinDate = parseTwitterDate(raw.createdAt)
if raw.pinnedTweetIdsStr.len > 0:
result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0])

View File

@@ -3,7 +3,7 @@ from ../../types import User, VerifiedType
type
GraphUser* = object
data*: tuple[userResult: UserData]
data*: tuple[userResult: Option[UserData], user: Option[UserData]]
UserData* = object
result*: UserResult
@@ -22,15 +22,24 @@ type
Verification* = object
verifiedType*: VerifiedType
Location* = object
location*: string
Privacy* = object
protected*: bool
UserResult* = object
legacy*: User
restId*: string
isBlueVerified*: bool
unavailableReason*: Option[string]
core*: UserCore
avatar*: UserAvatar
unavailableReason*: Option[string]
reason*: Option[string]
privacy*: Option[Privacy]
profileBio*: Option[UserBio]
verification*: Option[Verification]
location*: Option[Location]
proc enumHook*(s: string; v: var VerifiedType) =
v = try:

View File

@@ -0,0 +1,4 @@
type
TidPair* = object
animationKey*: string
verification*: string

View File

@@ -1,12 +1,12 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen
import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen, math
import std/[enumerate, re]
import types, utils, query
const
cards = "cards.twitter.com/cards"
tco = "https://t.co"
twitter = parseUri("https://twitter.com")
twitter = parseUri("https://x.com")
let
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
@@ -59,25 +59,28 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
result = body
if prefs.replaceYouTube.len > 0 and "youtu" in result:
result = result.replace(ytRegex, prefs.replaceYouTube)
let youtubeHost = strip(prefs.replaceYouTube, chars={'/'})
result = result.replace(ytRegex, youtubeHost)
if prefs.replaceTwitter.len > 0:
let twitterHost = strip(prefs.replaceTwitter, chars={'/'})
if tco in result:
result = result.replace(tco, https & prefs.replaceTwitter & "/t.co")
result = result.replace(tco, https & twitterHost & "/t.co")
if "x.com" in result:
result = result.replace(xRegex, prefs.replaceTwitter)
result = result.replace(xRegex, twitterHost)
result = result.replacef(xLinkRegex, a(
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
twitterHost & "$2", href = https & twitterHost & "$1"))
if "twitter.com" in result:
result = result.replace(cards, prefs.replaceTwitter & "/cards")
result = result.replace(twRegex, prefs.replaceTwitter)
result = result.replace(cards, twitterHost & "/cards")
result = result.replace(twRegex, twitterHost)
result = result.replacef(twLinkRegex, a(
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
twitterHost & "$2", href = https & twitterHost & "$1"))
if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result):
result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/")
result = result.replace(rdRegex, prefs.replaceReddit)
if prefs.replaceReddit in result and "/gallery/" in result:
let redditHost = strip(prefs.replaceReddit, chars={'/'})
result = result.replace(rdShortRegex, redditHost & "/comments/")
result = result.replace(rdRegex, redditHost)
if redditHost in result and "/gallery/" in result:
result = result.replace("/gallery/", "/comments/")
if absolute.len > 0 and "href" in result:
@@ -88,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 =
@@ -96,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 =
@@ -151,13 +168,30 @@ proc getShortTime*(tweet: Tweet): string =
else:
result = "now"
proc getDuration*(ms: int): string =
let
sec = int(round(ms / 1000))
min = floorDiv(sec, 60)
hour = floorDiv(min, 60)
if hour > 0:
&"{hour}:{min mod 60:02}:{sec mod 60:02}"
else:
&"{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
if username.len == 0:
username = "i"
result = &"/{username}/status/{id}"
if focus: result &= "#m"
proc getLink*(tweet: Tweet; focus=true): string =
if tweet.id == 0: return
var username = tweet.user.username
if username.len == 0:
username = "i"
result = &"/{username}/status/{tweet.id}"
if focus: result &= "#m"
return getLink(tweet.id, username, focus)
proc getTwitterLink*(path: string; params: Table[string, string]): string =
var
@@ -185,7 +219,7 @@ proc getTwitterLink*(path: string; params: Table[string, string]): string =
proc getLocation*(u: User | Tweet): (string, string) =
if "://" in u.location: return (u.location, "")
let loc = u.location.split(":")
let url = if loc.len > 1: "/search?q=place:" & loc[1] else: ""
let url = if loc.len > 1: "/search?f=tweets&q=place:" & loc[1] else: ""
(loc[0], url)
proc getSuspended*(username: string): string =

View File

@@ -6,11 +6,11 @@ from os import getEnv
import jester
import types, config, prefs, formatters, redis_cache, http_pool, auth
import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
import views/[general, about]
import routes/[
preferences, timeline, status, media, search, rss, list, debug,
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"
@@ -37,6 +37,11 @@ setHmacKey(cfg.hmacKey)
setProxyEncoding(cfg.base64Media)
setMaxHttpConns(cfg.httpMaxConns)
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)
@@ -53,6 +58,7 @@ createSearchRouter(cfg)
createMediaRouter(cfg)
createEmbedRouter(cfg)
createRssRouter(cfg)
createBroadcastRouter(cfg)
createDebugRouter(cfg)
settings:
@@ -62,11 +68,16 @@ settings:
reusePort = true
routes:
before:
# skip all file URLs
cond "." notin request.path
applyUrlPrefs()
get "/":
resp renderMain(renderSearch(), request, cfg, themePrefs())
resp renderMain(renderSearch(), request, cfg, requestPrefs())
get "/about":
resp renderMain(renderAbout(), request, cfg, themePrefs())
resp renderMain(renderAbout(), request, cfg, requestPrefs())
get "/explore":
redirect("/about")
@@ -77,7 +88,7 @@ routes:
get "/i/redirect":
let url = decodeUrl(@"url")
if url.len == 0: resp Http404
redirect(replaceUrls(url, cookiePrefs()))
redirect(replaceUrls(url, requestPrefs()))
error Http404:
resp Http404, showError("Page not found", cfg)
@@ -111,5 +122,6 @@ routes:
extend preferences, ""
extend resolver, ""
extend embed, ""
extend broadcastRoute, ""
extend debug, ""
extend unsupported, ""

View File

@@ -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(
@@ -21,7 +31,7 @@ proc parseUser(js: JsonNode; id=""): User =
tweets: js{"statuses_count"}.getInt,
likes: js{"favourites_count"}.getInt,
media: js{"media_count"}.getInt,
protected: js{"protected"}.getBool,
protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool),
joinDate: js{"created_at"}.getTime
)
@@ -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,24 +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 m{"media_url_https"}.getImageStr
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"}:
@@ -161,30 +243,45 @@ 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 mediaInfo{"original_img_url"}.getImageStr
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:
let expandedUrl = url{"expanded_url"}.getStr
let expandedUrl = url.getExpandedUrl
if result.text.endsWith(expandedUrl):
result.text.removeSuffix(expandedUrl)
result.text = result.text.strip()
@@ -210,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 =
@@ -267,7 +373,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
for u in ? urls:
if u{"url"}.getStr == result.url:
result.url = u{"expanded_url"}.getStr
result.url = u.getExpandedUrl(result.url)
break
if kind in {videoDirectMessage, imageDirectMessage}:
@@ -277,8 +383,9 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
result.url.len == 0 or result.url.startsWith("card://"):
result.url = getPicUrl(result.image)
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
if js.isNull: return
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
replyId: int64 = 0): Tweet =
if js.isNull: return Tweet()
let time =
if js{"created_at"}.notNull: js{"created_at"}.getTime
@@ -301,6 +408,9 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
)
)
if result.replyId == 0:
result.replyId = replyId
# fix for pinned threads
if result.hasThread and result.threadId == 0:
result.threadId = js{"self_thread", "id_str"}.getId
@@ -332,11 +442,13 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
let name = jsCard{"name"}.getStr
if "poll" in name:
if "image" in name:
result.photos.add jsCard{"binding_values", "image_large"}.getImageVal
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"})
@@ -375,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"})
@@ -394,12 +506,50 @@ proc parseGraphTweet(js: JsonNode): Tweet =
"binding_values": %bindingObj
}
result = parseTweet(js{"legacy"}, jsCard)
result.id = js{"rest_id"}.getId
var replyId = 0
with restId, js{"reply_to_results", "rest_id"}:
replyId = restId.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.replyId == 0:
result.replyId = js{"reply_to_results", "rest_id"}.getId
if result.reply.len == 0:
with replyTo, js{"reply_to_user_results", "result", "core", "screen_name"}:
result.reply = @[replyTo.getStr]
with count, js{"views", "count"}:
result.stats.views = count.getStr("0").parseInt
@@ -409,21 +559,28 @@ proc parseGraphTweet(js: JsonNode): Tweet =
parseMediaEntities(js, result)
if result.quote.isSome:
result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}))
with quoted, js{"quotedPostResults", "result"}:
with quoted, js{"quoted_status_result", "result"}:
result.quote = some(parseGraphTweet(quoted))
with quoted, js{"quotedPostResults"}:
if "result" in quoted:
result.quote = some(parseGraphTweet(quoted{"result"}))
else:
result.quote = some Tweet(id: js{"legacy", "quoted_status_id_str"}.getId)
with ids, js{"edit_control", "edit_control_initial", "edit_tweet_ids"}:
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
if "cursor-showmore" in entryId:
let cursor = t{"item", "content", "value"}
result.thread.cursor = cursor.getStr
result.thread.hasMore = true
elif "tweet" in entryId and "promoted" notin entryId:
with tweet, t.getTweetResult("item"):
if "tweet-" in entryId and "promoted" notin entryId:
let tweet = t.getTweetResult("item")
if tweet.notNull:
result.thread.content.add parseGraphTweet(tweet)
let tweetDisplayType = select(
@@ -432,6 +589,12 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
)
if tweetDisplayType.getStr == "SelfThread":
result.self = true
else:
result.thread.content.add Tweet(id: entryId.getId)
elif "cursor-showmore" in entryId:
let cursor = t{"item", "content", "value"}
result.thread.cursor = cursor.getStr
result.thread.hasMore = true
proc parseGraphTweetResult*(js: JsonNode): Tweet =
with tweet, js{"data", "tweet_result", "result"}:
@@ -452,7 +615,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
if i.getTypeName == "TimelineAddEntries":
for e in i{"entries"}:
let entryId = e.getEntryId
if entryId.startsWith("tweet"):
if entryId.startsWith("tweet-"):
let tweetResult = getTweetResult(e)
if tweetResult.notNull:
let tweet = parseGraphTweet(tweetResult)
@@ -460,10 +623,12 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
if not tweet.available:
tweet.id = entryId.getId
if $tweet.id == tweetId:
if entryId.endsWith(tweetId):
result.tweet = tweet
else:
result.before.content.add tweet
elif not entryId.endsWith(tweetId):
result.before.content.add Tweet(id: entryId.getId)
elif entryId.startsWith("conversationthread"):
let (thread, self) = parseGraphThread(e)
if self:
@@ -485,11 +650,35 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
result.before.content.add tweet
elif entryId.startsWith("cursor-bottom"):
var cursorValue = select(
e{"content", "value"},
e{"content", "content", "value"},
e{"content", "itemContent", "value"}
)
result.replies.bottom = cursorValue.getStr
proc parseGraphEditHistory*(js: JsonNode; tweetId: string): EditHistory =
let instructions = ? js{
"data", "tweet_result_by_rest_id", "result",
"edit_history_timeline", "timeline", "instructions"
}
if instructions.len == 0:
return
for i in instructions:
if i.getTypeName == "TimelineAddEntries":
for e in i{"entries"}:
let entryId = e.getEntryId
if entryId == "latestTweet":
with item, e{"content", "items"}[0]:
let tweetResult = item.getTweetResult("item")
if tweetResult.notNull:
result.latest = parseGraphTweet(tweetResult)
elif entryId == "staleTweets":
for item in e{"content", "items"}:
let tweetResult = item.getTweetResult("item")
if tweetResult.notNull:
result.history.add parseGraphTweet(tweetResult)
proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] =
with tweetResult, getTweetResult(e):
var tweet = parseGraphTweet(tweetResult)

View File

@@ -17,7 +17,7 @@ let
unReplace = "$1<a href=\"/$2\">@$2</a>"
htRegex = re"(^|[^\w-_./?])([#$]|)([\w_]+)"
htReplace = "$1<a href=\"/search?q=%23$3\">$2$3</a>"
htReplace = "$1<a href=\"/search?f=tweets&q=%23$3\">$2$3</a>"
type
ReplaceSliceKind = enum
@@ -72,7 +72,6 @@ template getTypeName*(js: JsonNode): string =
template getEntryId*(e: JsonNode): string =
e{"entryId"}.getStr(e{"entry_id"}.getStr)
template parseTime(time: string; f: static string; flen: int): DateTime =
if time.len != flen: return
parse(time, f, utc())
@@ -89,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:
@@ -112,6 +119,9 @@ proc getImageStr*(js: JsonNode): string =
template getImageVal*(js: JsonNode): string =
js{"image_value", "url"}.getImageStr
template getExpandedUrl*(js: JsonNode; fallback=""): string =
js{"expanded_url"}.getStr(js{"url"}.getStr(fallback))
proc getCardUrl*(js: JsonNode; kind: CardKind): string =
result = js{"website_url"}.getStrVal
if kind == promoVideoConvo:
@@ -177,7 +187,7 @@ proc extractSlice(js: JsonNode): Slice[int] =
proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
textLen: int; hideTwitter = false) =
let
url = js["expanded_url"].getStr
url = js.getExpandedUrl
slice = js.extractSlice
if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
@@ -204,7 +214,7 @@ proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice];
let
name = $runes[rep.slice.a.succ .. rep.slice.b]
symbol = $runes[rep.slice.a]
result.add a(symbol & name, href = "/search?q=%23" & name)
result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name)
of rkMention:
result.add a($runes[rep.slice], href = rep.url, title = rep.display)
of rkUrl:
@@ -238,7 +248,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
ent = ? js{"entities"}
with urls, ent{"url", "urls"}:
user.website = urls[0]{"expanded_url"}.getStr
user.website = urls[0].getExpandedUrl
var replacements = newSeq[ReplaceSlice]()
@@ -268,7 +278,7 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink)
if hasCard and u{"url"}.getStr == get(tweet.card).url:
get(tweet.card).url = u{"expanded_url"}.getStr
get(tweet.card).url = u.getExpandedUrl
with media, entities{"media"}:
for m in media:
@@ -318,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"}
@@ -328,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]
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: ""

View File

@@ -1,22 +1,22 @@
# SPDX-License-Identifier: AGPL-3.0-only
import tables
import tables, strutils
import types, prefs_impl
from config import get
from parsecfg import nil
export genUpdatePrefs, genResetPrefs
export genUpdatePrefs, genResetPrefs, genApplyPrefs
var defaultPrefs*: Prefs
proc updateDefaultPrefs*(cfg: parsecfg.Config) =
genDefaultPrefs()
proc getPrefs*(cookies: Table[string, string]): Prefs =
proc getPrefs*(cookies, params: Table[string, string]): Prefs =
result = defaultPrefs
genCookiePrefs(cookies)
genParsePrefs(cookies)
genParsePrefs(params)
template getPref*(cookies: Table[string, string], pref): untyped =
bind genCookiePref
var res = defaultPrefs.`pref`
genCookiePref(cookies, pref, res)
res
proc encodePrefs*(prefs: Prefs): string =
var encPairs: seq[string]
genEncodePrefs(prefs)
encPairs.join(",")

View File

@@ -60,6 +60,9 @@ genPrefs:
stickyProfile(checkbox, true):
"Make profile sidebar stick to top"
stickyNav(checkbox, true):
"Keep navbar fixed to top"
bidiSupport(checkbox, false):
"Support bidirectional text (makes clicking on tweets harder)"
@@ -75,6 +78,9 @@ genPrefs:
hideReplies(checkbox, false):
"Hide tweet replies"
hideCommunityNotes(checkbox, false):
"Hide community notes"
squareAvatars(checkbox, false):
"Square profile pictures"
@@ -94,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"
@@ -127,7 +144,7 @@ macro genDefaultPrefs*(): untyped =
result.add quote do:
defaultPrefs.`ident` = cfg.get("Preferences", `name`, `default`)
macro genCookiePrefs*(cookies): untyped =
macro genParsePrefs*(prefs): untyped =
result = nnkStmtList.newTree()
for pref in allPrefs():
let
@@ -137,37 +154,17 @@ macro genCookiePrefs*(cookies): untyped =
options = pref.options
result.add quote do:
if `name` in `cookies`:
if `name` in `prefs`:
when `kind` == input or `name` == "theme":
result.`ident` = `cookies`[`name`]
result.`ident` = `prefs`[`name`]
elif `kind` == checkbox:
result.`ident` = `cookies`[`name`] == "on"
result.`ident` = `prefs`[`name`] == "on" or
`prefs`[`name`] == "true" or
`prefs`[`name`] == "1"
else:
let value = `cookies`[`name`]
let value = `prefs`[`name`]
if value in `options`: result.`ident` = value
macro genCookiePref*(cookies, prefName, res): untyped =
result = nnkStmtList.newTree()
for pref in allPrefs():
let ident = ident(pref.name)
if ident != prefName:
continue
let
name = pref.name
kind = newLit(pref.kind)
options = pref.options
result.add quote do:
if `name` in `cookies`:
when `kind` == input or `name` == "theme":
`res` = `cookies`[`name`]
elif `kind` == checkbox:
`res` = `cookies`[`name`] == "on"
else:
let value = `cookies`[`name`]
if value in `options`: `res` = value
macro genUpdatePrefs*(): untyped =
result = nnkStmtList.newTree()
let req = ident("request")
@@ -202,6 +199,36 @@ macro genResetPrefs*(): untyped =
result.add quote do:
savePref(`name`, "", `req`, expire=true)
macro genEncodePrefs*(prefs): untyped =
result = nnkStmtList.newTree()
for pref in allPrefs():
let
name = newLit(pref.name)
ident = ident(pref.name)
kind = newLit(pref.kind)
defaultIdent = nnkDotExpr.newTree(ident("defaultPrefs"), ident(pref.name))
result.add quote do:
when `kind` == checkbox:
if `prefs`.`ident` != `defaultIdent`:
if `prefs`.`ident`:
encPairs.add `name` & "=on"
else:
encPairs.add `name` & "="
else:
if `prefs`.`ident` != `defaultIdent`:
encPairs.add `name` & "=" & `prefs`.`ident`
macro genApplyPrefs*(params, req): untyped =
result = nnkStmtList.newTree()
for pref in allPrefs():
let name = newLit(pref.name)
result.add quote do:
if `name` in `params`:
savePref(`name`, `params`[`name`], `req`)
else:
savePref(`name`, "", `req`, expire=true)
macro genPrefsType*(): untyped =
let name = nnkPostfix.newTree(ident("*"), ident("Prefs"))
result = quote do:

View File

@@ -1,15 +1,14 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, sequtils, tables, uri
import types
import types, utils
const
validFilters* = @[
"media", "images", "twimg", "videos",
"native_video", "consumer_video", "pro_video",
"native_video", "consumer_video", "spaces",
"links", "news", "quote", "mentions",
"replies", "retweets", "nativeretweets",
"verified", "safe"
"replies", "retweets", "nativeretweets"
]
emptyQuery* = "include:nativeretweets"
@@ -21,12 +20,13 @@ template `@`(param: string): untyped =
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),
since: @"since",
until: @"until",
near: @"near"
minLikes: validateNumber(@"min_faves")
)
if name.len > 0:
@@ -46,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
@@ -55,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
@@ -73,38 +78,49 @@ 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.near.len > 0:
result &= &" near:\"{query.near}\" within:15mi"
if query.minLikes.len > 0:
result &= " min_faves:" & query.minLikes
if query.text.len > 0:
if result.len > 0:
result &= " " & query.text
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.near.len > 0:
params.add "near=" & query.near
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("&")

View File

@@ -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
View File

@@ -0,0 +1,44 @@
# SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, strutils
import jester
import router_utils
import ".."/[types, formatters, redis_cache]
import ../views/[general, broadcast]
import media
export broadcast
proc createBroadcastRouter*(cfg: Config) =
router broadcastRoute:
get "/i/broadcasts/@id":
cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
var bc: Broadcast
try:
bc = await getCachedBroadcast(@"id")
except:
discard
if bc.id.len == 0:
resp Http404, showError("Broadcast not found", cfg)
let prefs = requestPrefs()
resp renderMain(renderBroadcast(bc, prefs, request.path), request, cfg, prefs,
bc.title, ogTitle=bc.title)
get "/i/broadcasts/@id/stream":
cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
var bc: Broadcast
try:
bc = await getCachedBroadcast(@"id")
except:
discard
if bc.m3u8Url.len == 0:
resp Http404
let manifest = await safeFetch(bc.m3u8Url)
if manifest.len == 0:
resp Http502
resp proxifyVideo(manifest, requestPrefs().proxyVideos, bc.m3u8Url), m3u8Mime

View File

@@ -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)
@@ -19,7 +19,7 @@ proc createEmbedRouter*(cfg: Config) =
get "/@user/status/@id/embed":
let
tweet = await getGraphTweetResult(@"id")
prefs = cookiePrefs()
prefs = requestPrefs()
path = getPath()
if tweet == nil:

View File

@@ -13,7 +13,7 @@ template respList*(list, timeline, title, vnode: typed) =
let
html = renderList(vnode, timeline.query, list)
rss = &"""/i/lists/{@"id"}/rss"""
rss = if cfg.enableRSSList: &"""/i/lists/{@"id"}/rss""" else: ""
resp renderMain(html, request, cfg, prefs, titleText=title, rss=rss, banner=list.banner)
@@ -36,7 +36,7 @@ proc createListRouter*(cfg: Config) =
get "/i/lists/@id/?":
cond '.' notin @"id"
let
prefs = cookiePrefs()
prefs = requestPrefs()
list = await getCachedList(id=(@"id"))
timeline = await getGraphListTweets(list.id, getCursor())
vnode = renderTimelineTweets(timeline, prefs, request.path)
@@ -45,7 +45,7 @@ proc createListRouter*(cfg: Config) =
get "/i/lists/@id/members":
cond '.' notin @"id"
let
prefs = cookiePrefs()
prefs = requestPrefs()
list = await getCachedList(id=(@"id"))
members = await getGraphListMembers(list, getCursor())
respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path))

View File

@@ -52,10 +52,10 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
""
let headers = newHttpHeaders({
"Content-Type": res.headers["content-type", 0],
"Content-Length": contentLength,
"Cache-Control": maxAge,
"ETag": hashed
"content-type": res.headers["content-type", 0],
"content-length": contentLength,
"cache-control": maxAge,
"etag": hashed
})
respond(request, headers)
@@ -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,10 +99,8 @@ proc createMediaRouter*(cfg: Config) =
get re"^\/pic\/orig\/(enc)?\/?(.+)":
var url = decoded(request, 1)
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)
@@ -107,10 +111,8 @@ proc createMediaRouter*(cfg: Config) =
get re"^\/pic\/(enc)?\/?(.+)":
var url = decoded(request, 1)
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
@@ -139,6 +141,6 @@ proc createMediaRouter*(cfg: Config) =
if ".m3u8" in url:
let vid = await safeFetch(url)
content = proxifyVideo(vid, cookiePref(proxyVideos))
content = proxifyVideo(vid, requestPrefs().proxyVideos, url)
resp content, m3u8Mime

View File

@@ -19,8 +19,10 @@ proc createPrefRouter*(cfg: Config) =
router preferences:
get "/settings":
let
prefs = cookiePrefs()
html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir))
prefs = requestPrefs()
prefsCode = encodePrefs(prefs)
prefsUrl = getUrlPrefix(cfg) & "/?prefs=" & prefsCode
html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir), prefsUrl)
resp renderMain(html, request, cfg, prefs, "Preferences")
get "/settings/@i?":

View File

@@ -18,8 +18,8 @@ proc createResolverRouter*(cfg: Config) =
router resolver:
get "/cards/@card/@id":
let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"]
respResolved(await resolve(url, cookiePrefs()), "card")
respResolved(await resolve(url, requestPrefs()), "card")
get "/t.co/@url":
let url = "https://t.co/" & @"url"
respResolved(await resolve(url, cookiePrefs()), "t.co")
respResolved(await resolve(url, requestPrefs()), "t.co")

View File

@@ -9,21 +9,13 @@ export utils, prefs, types, uri
template savePref*(pref, value: string; req: Request; expire=false) =
if not expire or pref in cookies(req):
setCookie(pref, value, daysForward(when expire: -10 else: 360),
httpOnly=true, secure=cfg.useHttps, sameSite=None)
httpOnly=true, secure=cfg.useHttps, sameSite=None, path="/")
template cookiePrefs*(): untyped {.dirty.} =
getPrefs(cookies(request))
template cookiePref*(pref): untyped {.dirty.} =
getPref(cookies(request), pref)
template themePrefs*(): Prefs =
var res = defaultPrefs
res.theme = cookiePref(theme)
res
template requestPrefs*(): untyped {.dirty.} =
getPrefs(cookies(request), params(request))
template showError*(error: string; cfg: Config): string =
renderMain(renderError(error), request, cfg, themePrefs(), "Error")
renderMain(renderError(error), request, cfg, requestPrefs(), "Error")
template getPath*(): untyped {.dirty.} =
$(parseUri(request.path) ? filterParams(request.params))
@@ -43,5 +35,28 @@ template getCursor*(req: Request): string =
proc getNames*(name: string): seq[string] =
name.strip(chars={'/'}).split(",").filterIt(it.len > 0)
template applyUrlPrefs*() {.dirty.} =
if @"prefs".len > 0:
var prefParams = initTable[string, string]()
for pair in @"prefs".split(','):
let kv = pair.split('=', maxsplit=1)
if kv.len == 2:
prefParams[kv[0]] = kv[1]
elif kv.len == 1 and kv[0].len > 0:
prefParams[kv[0]] = ""
genApplyPrefs(prefParams, request)
# Rebuild URL without prefs param
var params: seq[(string, string)]
for k, v in request.params:
if k != "prefs":
params.add (k, v)
if params.len > 0:
let cleanUrl = request.getNativeReq.url ? params
redirect($cleanUrl)
else:
redirect(request.path)
template respJson*(node: JsonNode) =
resp $node, "application/json"

View File

@@ -15,7 +15,7 @@ proc redisKey*(page, name, cursor: string): string =
if cursor.len > 0:
result &= ":" & cursor
proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.} =
proc timelineRss*(req: Request; cfg: Config; query: Query; prefs: Prefs): Future[Rss] {.async.} =
var profile: Profile
let
name = req.params.getOrDefault("name")
@@ -39,7 +39,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.
return Rss(feed: profile.user.username, cursor: "suspended")
if profile.user.fullname.len > 0:
let rss = renderTimelineRss(profile, cfg, multi=(names.len > 1))
let rss = renderTimelineRss(profile, cfg, prefs, multi=(names.len > 1))
return Rss(feed: rss, cursor: profile.tweets.bottom)
template respRss*(rss, page) =
@@ -60,11 +60,14 @@ template respRss*(rss, page) =
proc createRssRouter*(cfg: Config) =
router rss:
get "/search/rss":
cond cfg.enableRss
if not cfg.enableRSSSearch:
resp Http403, showError("RSS feed is disabled", cfg)
if @"q".len > 200:
resp Http400, showError("Search input too long.", cfg)
let query = initQuery(params(request))
let
prefs = requestPrefs()
query = initQuery(params(request))
if query.kind != tweets:
resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg)
@@ -78,15 +81,17 @@ proc createRssRouter*(cfg: Config) =
let tweets = await getGraphTweetSearch(query, cursor)
rss.cursor = tweets.bottom
rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg)
rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg, prefs)
await cacheRss(key, rss)
respRss(rss, "Search")
get "/@name/rss":
cond cfg.enableRss
cond '.' notin @"name"
if not cfg.enableRSSUserTweets:
resp Http403, showError("RSS feed is disabled", cfg)
let
prefs = requestPrefs()
name = @"name"
key = redisKey("twitter", name, getCursor())
@@ -94,16 +99,23 @@ proc createRssRouter*(cfg: Config) =
if rss.cursor.len > 0:
respRss(rss, "User")
rss = await timelineRss(request, cfg, Query(fromUser: @[name]))
rss = await timelineRss(request, cfg, Query(fromUser: @[name]), prefs)
await cacheRss(key, rss)
respRss(rss, "User")
get "/@name/@tab/rss":
cond cfg.enableRss
cond '.' notin @"name"
cond @"tab" in ["with_replies", "media", "search"]
let rssEnabled = case @"tab"
of "with_replies": cfg.enableRSSUserReplies
of "media": cfg.enableRSSUserMedia
of "search": cfg.enableRSSSearch
else: false
if not rssEnabled:
resp Http403, showError("RSS feed is disabled", cfg)
let
prefs = requestPrefs()
name = @"name"
tab = @"tab"
query =
@@ -122,14 +134,15 @@ proc createRssRouter*(cfg: Config) =
if rss.cursor.len > 0:
respRss(rss, "User")
rss = await timelineRss(request, cfg, query)
rss = await timelineRss(request, cfg, query, prefs)
await cacheRss(key, rss)
respRss(rss, "User")
get "/@name/lists/@slug/rss":
cond cfg.enableRss
cond @"name" != "i"
if not cfg.enableRSSList:
resp Http403, showError("RSS feed is disabled", cfg)
let
slug = decodeUrl(@"slug")
list = await getCachedList(@"name", slug)
@@ -145,8 +158,10 @@ proc createRssRouter*(cfg: Config) =
redirect(url)
get "/i/lists/@id/rss":
cond cfg.enableRss
if not cfg.enableRSSList:
resp Http403, showError("RSS feed is disabled", cfg)
let
prefs = requestPrefs()
id = @"id"
cursor = getCursor()
key = redisKey("lists", id, cursor)
@@ -159,7 +174,7 @@ proc createRssRouter*(cfg: Config) =
list = await getCachedList(id=id)
timeline = await getGraphListTweets(list.id, cursor)
rss.cursor = timeline.bottom
rss.feed = renderListRss(timeline.content, list, cfg)
rss.feed = renderListRss(timeline.content, list, cfg, prefs)
await cacheRss(key, rss)
respRss(rss, "List")

View File

@@ -19,7 +19,7 @@ proc createSearchRouter*(cfg: Config) =
resp Http400, showError("Search input too long.", cfg)
let
prefs = cookiePrefs()
prefs = requestPrefs()
query = initQuery(params(request))
title = "Search" & (if q.len > 0: " (" & q & ")" else: "")
@@ -36,16 +36,16 @@ proc createSearchRouter*(cfg: Config) =
of tweets:
let
tweets = await getGraphTweetSearch(query, getCursor())
rss = "/search/rss?" & genQueryUrl(query)
rss = if cfg.enableRSSSearch: "/search/rss?" & genQueryUrl(query) else: ""
resp renderMain(renderTweetSearch(tweets, prefs, getPath()),
request, cfg, prefs, title, rss=rss)
else:
resp Http404, showError("Invalid search", cfg)
get "/hashtag/@hash":
redirect("/search?q=" & encodeUrl("#" & @"hash"))
redirect("/search?f=tweets&q=" & encodeUrl("#" & @"hash"))
get "/opensearch":
let url = getUrlPrefix(cfg) & "/search?q="
let url = getUrlPrefix(cfg) & "/search?f=tweets&q="
resp Http200, {"Content-Type": "application/opensearchdescription+xml"},
generateOpenSearchXML(cfg.title, cfg.hostname, url)

View File

@@ -21,18 +21,16 @@ proc createStatusRouter*(cfg: Config) =
if id.len > 19 or id.any(c => not c.isDigit):
resp Http404, showError("Invalid tweet ID", cfg)
let prefs = cookiePrefs()
let prefs = requestPrefs()
# used for the infinite scroll feature
if @"scroll".len > 0:
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())
if conv == nil:
echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
var error = "Tweet not found"
@@ -46,15 +44,19 @@ proc createStatusRouter*(cfg: Config) =
desc = conv.tweet.text
var
images = conv.tweet.photos
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:
@@ -66,6 +68,26 @@ proc createStatusRouter*(cfg: Config) =
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
images=images, video=video)
get "/@name/status/@id/history/?":
cond '.' notin @"name"
let id = @"id"
if id.len > 19 or id.any(c => not c.isDigit):
resp Http404, showError("Invalid tweet ID", cfg)
let edits = await getGraphEditHistory(id)
if edits.latest == nil or edits.latest.id == 0:
resp Http404, showError("Tweet history not found", cfg)
let
prefs = requestPrefs()
title = "History for " & pageTitle(edits.latest)
ogTitle = "Edit History for " & pageTitle(edits.latest.user)
desc = edits.latest.text
let html = renderEditHistory(edits, prefs, getPath())
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle)
get "/@name/@s/@id/@m/?@i?":
cond @"s" in ["status", "statuses"]
cond @"m" in ["video", "photo"]
@@ -76,6 +98,6 @@ proc createStatusRouter*(cfg: Config) =
get "/i/web/status/@id":
redirect("/i/status/" & @"id")
get "/@name/thread/@id/?":
redirect("/$1/status/$2" % [@"name", @"id"])

View File

@@ -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
@@ -105,16 +115,37 @@ proc createTimelineRouter*(cfg: Config) =
get "/intent/user":
respUserId()
get "/intent/follow/?":
let username = request.params.getOrDefault("screen_name")
if username.len == 0:
resp Http400, showError("Missing screen_name parameter", cfg)
redirect("/" & username)
get "/@name/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"]
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','})
cond @"tab" in ["with_replies", "media", "search", ""]
let
prefs = cookiePrefs()
prefs = requestPrefs()
after = getCursor()
names = getNames(@"name")
var query = request.getQuery(@"tab", @"name")
var query = request.getQuery(@"tab", @"name", prefs)
if names.len != 1:
query.fromUser = names
@@ -122,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:
@@ -131,8 +163,17 @@ proc createTimelineRouter*(cfg: Config) =
profile.tweets.beginning = true
resp $renderTimelineTweets(profile.tweets, prefs, getPath())
let rssEnabled =
if @"tab".len == 0: cfg.enableRSSUserTweets
elif @"tab" == "with_replies": cfg.enableRSSUserReplies
elif @"tab" == "media": cfg.enableRSSUserMedia
elif @"tab" == "search": cfg.enableRSSSearch
else: false
let rss =
if @"tab".len == 0:
if not rssEnabled:
""
elif @"tab".len == 0:
"/$1/rss" % @"name"
elif @"tab" == "search":
"/$1/search/rss?$2" % [@"name", genQueryUrl(query)]

View File

@@ -10,14 +10,14 @@ export feature
proc createUnsupportedRouter*(cfg: Config) =
router unsupported:
template feature {.dirty.} =
resp renderMain(renderFeature(), request, cfg, themePrefs())
resp renderMain(renderFeature(), request, cfg, requestPrefs())
get "/about/feature": feature()
get "/login/?@i?": feature()
get "/@name/lists/?": feature()
get "/intent/?@i?":
cond @"i" notin ["user"]
cond @"i" notin ["user", "follow"]
feature()
get "/i/@i?/?@j?":

75
src/sass/_broadcast.scss Normal file
View File

@@ -0,0 +1,75 @@
.broadcast-page {
max-width: 800px;
width: 100%;
margin: 20px auto 0;
}
.broadcast-panel {
background-color: var(--bg_panel);
border: 1px solid var(--border_grey);
border-radius: 8px;
overflow: hidden;
}
.broadcast-player {
position: relative;
background: black;
video,
img {
display: block;
width: 100%;
}
}
.broadcast-info {
padding: 14px 16px;
}
.broadcast-title {
font-size: 18px;
font-weight: bold;
margin: 0 0 12px;
}
.broadcast-user-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.broadcast-user {
display: flex;
align-items: center;
gap: 10px;
color: var(--fg_color);
img {
width: 40px;
height: 40px;
border-radius: 50%;
}
}
.broadcast-username {
color: var(--fg_dark);
}
.broadcast-meta {
color: var(--fg_faded);
font-size: 14px;
display: flex;
flex-direction: column;
align-items: flex-end;
flex-shrink: 0;
line-height: 1.5em;
}
.broadcast-live {
background: #e0245e;
color: white;
padding: 1px 6px;
border-radius: 3px;
font-weight: bold;
font-size: 12px;
}

View File

@@ -1,39 +1,40 @@
@import '_variables';
@import '_mixins';
@import "_variables";
@import "_mixins";
.panel-container {
margin: auto;
font-size: 130%;
margin: auto;
font-size: 130%;
}
.error-panel {
@include center-panel(var(--error_red));
text-align: center;
@include center-panel(var(--error_red));
text-align: center;
}
.search-bar > form {
@include center-panel(var(--darkest_grey));
@include center-panel(var(--darkest_grey));
button {
background: var(--bg_elements);
color: var(--fg_color);
border: 0;
border-radius: 3px;
cursor: pointer;
font-weight: bold;
width: 30px;
height: 30px;
}
button {
background: var(--bg_elements);
color: var(--fg_color);
border: 0;
border-radius: 3px;
cursor: pointer;
font-weight: bold;
width: 30px;
height: 30px;
padding: 0px 5px 1px 8px;
}
input {
font-size: 16px;
width: 100%;
background: var(--bg_elements);
color: var(--fg_color);
border: 0;
border-radius: 4px;
padding: 4px;
margin-right: 8px;
height: unset;
}
input {
font-size: 16px;
width: 100%;
background: var(--bg_elements);
color: var(--fg_color);
border: 0;
border-radius: 4px;
padding: 4px;
margin-right: 8px;
height: unset;
}
}

View File

@@ -66,18 +66,7 @@
}
#search-panel-toggle:checked ~ .search-panel {
@if $rows == 6 {
max-height: 200px !important;
}
@if $rows == 5 {
max-height: 300px !important;
}
@if $rows == 4 {
max-height: 300px !important;
}
@if $rows == 3 {
max-height: 365px !important;
}
max-height: 380px !important;
}
}
}

View File

@@ -1,46 +1,43 @@
// colors
$bg_color: #0F0F0F;
$fg_color: #F8F8F2;
$fg_faded: #F8F8F2CF;
$fg_dark: #FF6C60;
$fg_nav: #FF6C60;
$bg_color: #0f0f0f;
$fg_color: #f8f8f2;
$fg_faded: #f8f8f2cf;
$fg_dark: #ff6c60;
$fg_nav: #ff6c60;
$bg_panel: #161616;
$bg_elements: #121212;
$bg_overlays: #1F1F1F;
$bg_hover: #1A1A1A;
$bg_overlays: #1f1f1f;
$bg_hover: #1a1a1a;
$grey: #888889;
$dark_grey: #404040;
$darker_grey: #282828;
$darkest_grey: #222222;
$border_grey: #3E3E35;
$border_grey: #3e3e35;
$accent: #FF6C60;
$accent_light: #FFACA0;
$accent_dark: #8A3731;
$accent_border: #FF6C6091;
$accent: #ff6c60;
$accent_light: #ffaca0;
$accent_dark: #8a3731;
$accent_border: #ff6c6091;
$play_button: #D8574D;
$play_button_hover: #FF6C60;
$play_button: #d8574d;
$play_button_hover: #ff6c60;
$more_replies_dots: #AD433B;
$error_red: #420A05;
$more_replies_dots: #ad433b;
$error_red: #420a05;
$verified_blue: #1DA1F2;
$verified_business: #FAC82B;
$verified_government: #C1B6A4;
$verified_blue: #1da1f2;
$verified_business: #fac82b;
$verified_government: #c1b6a4;
$icon_text: $fg_color;
$tab: $fg_color;
$tab_selected: $accent;
$shadow: rgba(0,0,0,.6);
$shadow_dark: rgba(0,0,0,.2);
$shadow: rgba(0, 0, 0, 0.6);
$shadow_dark: rgba(0, 0, 0, 0.2);
//fonts
$font_0: Helvetica Neue;
$font_1: Helvetica;
$font_2: Arial;
$font_3: sans-serif;
$font_4: fontello;
$font_0: sans-serif;
$font_1: fontello;

View File

@@ -1,180 +1,217 @@
@import '_variables';
@import "_variables";
@import 'tweet/_base';
@import 'profile/_base';
@import 'general';
@import 'navbar';
@import 'inputs';
@import 'timeline';
@import 'search';
@import "tweet/_base";
@import "profile/_base";
@import "general";
@import "navbar";
@import "inputs";
@import "timeline";
@import "search";
@import "broadcast";
body {
// colors
--bg_color: #{$bg_color};
--fg_color: #{$fg_color};
--fg_faded: #{$fg_faded};
--fg_dark: #{$fg_dark};
--fg_nav: #{$fg_nav};
// colors
--bg_color: #{$bg_color};
--fg_color: #{$fg_color};
--fg_faded: #{$fg_faded};
--fg_dark: #{$fg_dark};
--fg_nav: #{$fg_nav};
--bg_panel: #{$bg_panel};
--bg_elements: #{$bg_elements};
--bg_overlays: #{$bg_overlays};
--bg_hover: #{$bg_hover};
--bg_panel: #{$bg_panel};
--bg_elements: #{$bg_elements};
--bg_overlays: #{$bg_overlays};
--bg_hover: #{$bg_hover};
--grey: #{$grey};
--dark_grey: #{$dark_grey};
--darker_grey: #{$darker_grey};
--darkest_grey: #{$darkest_grey};
--border_grey: #{$border_grey};
--grey: #{$grey};
--dark_grey: #{$dark_grey};
--darker_grey: #{$darker_grey};
--darkest_grey: #{$darkest_grey};
--border_grey: #{$border_grey};
--accent: #{$accent};
--accent_light: #{$accent_light};
--accent_dark: #{$accent_dark};
--accent_border: #{$accent_border};
--accent: #{$accent};
--accent_light: #{$accent_light};
--accent_dark: #{$accent_dark};
--accent_border: #{$accent_border};
--play_button: #{$play_button};
--play_button_hover: #{$play_button_hover};
--play_button: #{$play_button};
--play_button_hover: #{$play_button_hover};
--more_replies_dots: #{$more_replies_dots};
--error_red: #{$error_red};
--more_replies_dots: #{$more_replies_dots};
--error_red: #{$error_red};
--verified_blue: #{$verified_blue};
--verified_business: #{$verified_business};
--verified_government: #{$verified_government};
--icon_text: #{$icon_text};
--verified_blue: #{$verified_blue};
--verified_business: #{$verified_business};
--verified_government: #{$verified_government};
--icon_text: #{$icon_text};
--tab: #{$fg_color};
--tab_selected: #{$accent};
--tab: #{$fg_color};
--tab_selected: #{$accent};
--profile_stat: #{$fg_color};
--profile_stat: #{$fg_color};
background-color: var(--bg_color);
color: var(--fg_color);
font-family: $font_0, $font_1, $font_2, $font_3;
font-size: 14px;
line-height: 1.3;
margin: 0;
background-color: var(--bg_color);
color: var(--fg_color);
font-family: $font_0, $font_1;
font-size: 15px;
line-height: 1.3;
margin: 0;
}
* {
outline: unset;
margin: 0;
text-decoration: none;
outline: unset;
margin: 0;
text-decoration: none;
}
img {
dynamic-range-limit: standard;
}
h1 {
display: inline;
display: inline;
}
h2, h3 {
font-weight: normal;
h2,
h3 {
font-weight: normal;
}
p {
margin: 14px 0;
margin: 14px 0;
}
a {
color: var(--accent);
color: var(--accent);
&:hover {
text-decoration: underline;
}
&:hover {
text-decoration: underline;
}
}
fieldset {
border: 0;
padding: 0;
margin-top: -0.6em;
border: 0;
padding: 0;
margin-top: -0.6em;
}
legend {
width: 100%;
padding: .6em 0 .3em 0;
border: 0;
font-size: 16px;
font-weight: 600;
border-bottom: 1px solid var(--border_grey);
margin-bottom: 8px;
width: 100%;
padding: 0.6em 0 0.3em 0;
border: 0;
font-size: 16px;
font-weight: 600;
border-bottom: 1px solid var(--border_grey);
margin-bottom: 8px;
}
.preferences .note {
.preferences {
.note {
border-top: 1px solid var(--border_grey);
border-bottom: 1px solid var(--border_grey);
padding: 6px 0 8px 0;
margin-bottom: 8px;
margin-top: 16px;
}
.bookmark-note {
margin: 0;
margin-bottom: 10px;
}
}
ul {
padding-left: 1.3em;
padding-left: 1.3em;
}
.container {
display: flex;
flex-wrap: wrap;
box-sizing: border-box;
padding-top: 50px;
margin: auto;
min-height: 100vh;
display: flex;
flex-wrap: wrap;
box-sizing: border-box;
margin: auto;
min-height: 100vh;
}
body.fixed-nav .container {
padding-top: 50px;
}
.icon-container {
display: inline;
display: inline;
}
.overlay-panel {
max-width: 600px;
width: 100%;
margin: 0 auto;
margin-top: 10px;
background-color: var(--bg_overlays);
padding: 10px 15px;
align-self: start;
max-width: 600px;
width: 100%;
margin: 0 auto;
margin-top: 10px;
background-color: var(--bg_overlays);
padding: 10px 15px;
align-self: start;
ul {
margin-bottom: 14px;
}
ul {
margin-bottom: 14px;
}
p {
word-break: break-word;
}
p {
word-break: break-word;
}
}
.verified-icon {
color: var(--icon_text);
border-radius: 50%;
flex-shrink: 0;
margin: 2px 0 3px 3px;
padding-top: 3px;
height: 11px;
width: 14px;
font-size: 8px;
display: inline-block;
text-align: center;
vertical-align: middle;
display: inline-block;
width: 14px;
height: 14px;
margin-bottom: 2px;
&.blue {
background-color: var(--verified_blue);
.verified-icon-circle {
position: absolute;
font-size: 15px;
}
.verified-icon-check {
position: absolute;
font-size: 9px;
margin: 5px 3px;
}
&.blue {
.verified-icon-circle {
color: var(--verified_blue);
}
&.business {
color: var(--bg_panel);
background-color: var(--verified_business);
.verified-icon-check {
color: var(--icon_text);
}
}
&.business {
.verified-icon-circle {
color: var(--verified_business);
}
&.government {
color: var(--bg_panel);
background-color: var(--verified_government);
.verified-icon-check {
color: var(--bg_panel);
}
}
&.government {
.verified-icon-circle {
color: var(--verified_government);
}
.verified-icon-check {
color: var(--bg_panel);
}
}
}
@media(max-width: 600px) {
.preferences-container {
max-width: 95vw;
}
@media (max-width: 600px) {
.preferences-container {
max-width: 95vw;
}
.nav-item, .nav-item .icon-container {
font-size: 16px;
}
.nav-item,
.nav-item .icon-container {
font-size: 16px;
}
}

View File

@@ -1,185 +1,216 @@
@import '_variables';
@import '_mixins';
@import "_variables";
@import "_mixins";
button {
@include input-colors;
background-color: var(--bg_elements);
color: var(--fg_color);
border: 1px solid var(--accent_border);
padding: 3px 6px;
font-size: 14px;
cursor: pointer;
float: right;
@include input-colors;
background-color: var(--bg_elements);
color: var(--fg_color);
border: 1px solid var(--accent_border);
padding: 3px 6px;
font-size: 14px;
cursor: pointer;
float: right;
}
input[type="text"],
input[type="date"],
input[type="number"],
select {
@include input-colors;
background-color: var(--bg_elements);
padding: 1px 4px;
color: var(--fg_color);
border: 1px solid var(--accent_border);
border-radius: 0;
font-size: 14px;
@include input-colors;
background-color: var(--bg_elements);
padding: 1px 4px;
color: var(--fg_color);
border: 1px solid var(--accent_border);
border-radius: 0;
font-size: 14px;
}
input[type="text"] {
height: 16px;
input[type="number"] {
-moz-appearance: textfield;
}
input[type="text"],
input[type="number"] {
height: 16px;
}
select {
height: 20px;
padding: 0 2px;
line-height: 1;
height: 20px;
padding: 0 2px;
line-height: 1;
}
input[type="date"]::-webkit-inner-spin-button {
display: none;
display: none;
}
input[type="number"] {
-moz-appearance: textfield;
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
display: none;
-webkit-appearance: none;
margin: 0;
}
input[type="date"]::-webkit-clear-button {
margin-left: 17px;
filter: grayscale(100%);
filter: hue-rotate(120deg);
margin-left: 17px;
filter: grayscale(100%);
filter: hue-rotate(120deg);
}
input::-webkit-calendar-picker-indicator {
opacity: 0;
opacity: 0;
}
input::-webkit-datetime-edit-day-field:focus,
input::-webkit-datetime-edit-month-field:focus,
input::-webkit-datetime-edit-year-field:focus {
background-color: var(--accent);
color: var(--fg_color);
outline: none;
background-color: var(--accent);
color: var(--fg_color);
outline: none;
}
.date-range {
.date-input {
display: inline-block;
position: relative;
}
.date-input {
display: inline-block;
position: relative;
}
.icon-container {
pointer-events: none;
position: absolute;
top: 2px;
right: 5px;
}
.icon-container {
pointer-events: none;
position: absolute;
top: 2px;
right: 5px;
}
.search-title {
margin: 0 2px;
}
.search-title {
margin: 0 2px;
}
}
.icon-button button {
color: var(--accent);
text-decoration: none;
background: none;
border: none;
float: none;
padding: unset;
padding-left: 4px;
color: var(--accent);
text-decoration: none;
background: none;
border: none;
float: none;
padding: unset;
padding-left: 4px;
&:hover {
color: var(--accent_light);
}
&:hover {
color: var(--accent_light);
}
}
.checkbox {
position: absolute;
top: 1px;
right: 0;
height: 17px;
width: 17px;
background-color: var(--bg_elements);
border: 1px solid var(--accent_border);
position: absolute;
top: 1px;
right: 0;
height: 17px;
width: 17px;
background-color: var(--bg_elements);
border: 1px solid var(--accent_border);
&:after {
content: "";
position: absolute;
display: none;
}
&:after {
content: "";
position: absolute;
display: none;
}
}
.checkbox-container {
display: block;
position: relative;
margin-bottom: 5px;
display: block;
position: relative;
margin-bottom: 5px;
cursor: pointer;
user-select: none;
padding-right: 22px;
input {
position: absolute;
opacity: 0;
cursor: pointer;
user-select: none;
padding-right: 22px;
height: 0;
width: 0;
input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
&:checked ~ .checkbox:after {
display: block;
}
&:checked ~ .checkbox:after {
display: block;
}
}
&:hover input ~ .checkbox {
border-color: var(--accent);
}
&:hover input ~ .checkbox {
border-color: var(--accent);
}
&:active input ~ .checkbox {
border-color: var(--accent_light);
}
&:active input ~ .checkbox {
border-color: var(--accent_light);
}
.checkbox:after {
left: 2px;
bottom: 0;
font-size: 13px;
font-family: $font_4;
content: '\e803';
}
.checkbox:after {
left: 2px;
bottom: 0;
font-size: 13px;
font-family: $font_1;
content: "\e811";
}
}
.pref-group {
display: inline;
display: inline;
}
.preferences {
button {
margin: 6px 0 3px 0;
}
button {
margin: 6px 0 3px 0;
}
label {
padding-right: 150px;
}
label {
padding-right: 150px;
}
select {
position: absolute;
top: 0;
right: 0;
display: block;
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
}
select {
position: absolute;
top: 0;
right: 0;
display: block;
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
min-width: 100px;
}
input[type="text"] {
position: absolute;
right: 0;
max-width: 140px;
}
input[type="text"],
input[type="number"] {
position: absolute;
right: 0;
max-width: 140px;
}
.pref-group {
display: block;
}
.pref-group {
display: block;
}
.pref-input {
position: relative;
margin-bottom: 6px;
}
.pref-input {
position: relative;
margin-bottom: 6px;
}
.pref-reset {
float: left;
}
.pref-reset {
float: left;
}
.prefs-code {
background-color: var(--bg_elements);
border: 1px solid var(--accent_border);
color: var(--fg_color);
font-size: 13px;
padding: 6px 8px;
margin: 4px 0;
word-break: break-all;
white-space: pre-wrap;
user-select: all;
}
}

View File

@@ -1,89 +1,90 @@
@import '_variables';
@import "_variables";
nav {
display: flex;
align-items: center;
position: fixed;
background-color: var(--bg_overlays);
box-shadow: 0 0 4px $shadow;
padding: 0;
width: 100%;
height: 50px;
z-index: 1000;
font-size: 16px;
display: flex;
align-items: center;
background-color: var(--bg_overlays);
box-shadow: 0 0 4px $shadow;
padding: 0;
width: 100%;
height: 50px;
z-index: 1000;
font-size: 16px;
a, .icon-button button {
color: var(--fg_nav);
}
a,
.icon-button button {
color: var(--fg_nav);
}
body.fixed-nav & {
position: fixed;
}
}
.inner-nav {
margin: auto;
box-sizing: border-box;
padding: 0 10px;
display: flex;
align-items: center;
flex-basis: 920px;
height: 50px;
margin: auto;
box-sizing: border-box;
padding: 0 10px;
display: flex;
align-items: center;
flex-basis: 920px;
height: 50px;
}
.site-name {
font-size: 15px;
font-weight: 600;
line-height: 1;
font-size: 15px;
font-weight: 600;
line-height: 1;
&:hover {
color: var(--accent_light);
text-decoration: unset;
}
&:hover {
color: var(--accent_light);
text-decoration: unset;
}
}
.site-logo {
display: block;
width: 35px;
height: 35px;
display: block;
width: 35px;
height: 35px;
}
.nav-item {
display: flex;
flex: 1;
line-height: 50px;
height: 50px;
overflow: hidden;
flex-wrap: wrap;
align-items: center;
display: flex;
flex: 1;
line-height: 50px;
height: 50px;
overflow: hidden;
flex-wrap: wrap;
align-items: center;
&.right {
text-align: right;
justify-content: flex-end;
}
&.right {
text-align: right;
justify-content: flex-end;
}
&.right a {
padding-left: 4px;
&:hover {
color: var(--accent_light);
text-decoration: unset;
}
}
&.right a:hover {
color: var(--accent_light);
text-decoration: unset;
}
}
.lp {
height: 14px;
display: inline-block;
position: relative;
top: 2px;
fill: var(--fg_nav);
height: 14px;
display: inline-block;
position: relative;
top: 2px;
fill: var(--fg_nav);
&:hover {
fill: var(--accent_light);
}
&:hover {
fill: var(--accent_light);
}
}
.icon-info:before {
margin: 0 -3px;
.icon-info {
margin: 0 -3px;
}
.icon-cog {
font-size: 15px;
font-size: 15px;
padding-left: 0 !important;
}

View File

@@ -1,83 +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%;
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;
}
}
.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;
}
}

View File

@@ -0,0 +1,71 @@
@import '_variables';
.about-account {
max-width: 500px;
width: 100%;
margin: 20px auto 0;
align-self: flex-start;
background: var(--bg_panel);
border-radius: 4px;
padding: 12px 20px 20px;
}
.about-account-header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 16px;
padding-bottom: 14px;
border-bottom: 1px solid var(--border_grey);
}
.about-account-avatar img {
width: 72px;
height: 72px;
border-radius: 50%;
margin-bottom: 4px;
}
.about-account-name {
@include breakable;
font-weight: bold;
}
.about-account-body {
display: flex;
flex-direction: column;
gap: 14px;
}
.about-account-at {
font-size: 18px;
font-weight: bold;
}
.about-account-row {
display: flex;
align-items: center;
gap: 10px;
> span:first-child {
color: var(--fg_faded);
flex-shrink: 0;
}
> div {
display: flex;
flex-direction: column;
}
}
.about-account-label {
color: var(--fg_faded);
font-size: 13px;
}
@media(max-width: 700px) {
.about-account {
max-width: none;
margin: 10px;
}
}

View File

@@ -1,122 +1,120 @@
@import '_variables';
@import '_mixins';
@import "_variables";
@import "_mixins";
.search-title {
font-weight: bold;
display: inline-block;
margin-top: 4px;
font-weight: bold;
display: inline-block;
margin-top: 4px;
}
.search-field {
display: flex;
flex-wrap: wrap;
button {
margin: 0 2px 0 0;
padding: 0px 1px 1px 4px;
height: 23px;
display: flex;
flex-wrap: wrap;
align-items: center;
}
button {
margin: 0 2px 0 0;
height: 23px;
display: flex;
align-items: center;
}
.pref-input {
margin: 0 4px 0 0;
flex-grow: 1;
height: 23px;
}
.pref-input {
margin: 0 4px 0 0;
flex-grow: 1;
height: 23px;
}
input[type="text"],
input[type="number"] {
height: calc(100% - 4px);
width: calc(100% - 8px);
}
input[type="text"] {
height: calc(100% - 4px);
width: calc(100% - 8px);
}
> label {
display: inline;
background-color: var(--bg_elements);
color: var(--fg_color);
border: 1px solid var(--accent_border);
padding: 1px 1px 2px 4px;
font-size: 14px;
cursor: pointer;
margin-bottom: 2px;
> label {
display: inline;
background-color: var(--bg_elements);
color: var(--fg_color);
border: 1px solid var(--accent_border);
padding: 1px 6px 2px 6px;
font-size: 14px;
cursor: pointer;
margin-bottom: 2px;
@include input-colors;
}
@include input-colors;
}
@include create-toggle(search-panel, 200px);
@include create-toggle(search-panel, 380px);
}
.search-panel {
width: 100%;
max-height: 0;
overflow: hidden;
transition: max-height 0.4s;
width: 100%;
max-height: 0;
overflow: hidden;
transition: max-height 0.4s;
flex-grow: 1;
font-weight: initial;
text-align: left;
flex-grow: 1;
font-weight: initial;
text-align: left;
> div {
line-height: 1.7em;
}
.checkbox-container {
display: inline;
padding-right: unset;
margin-bottom: 5px;
margin-left: 23px;
}
.checkbox-container {
display: inline;
padding-right: unset;
margin-bottom: unset;
margin-left: 23px;
}
.checkbox {
right: unset;
left: -22px;
line-height: 1.6em;
}
.checkbox {
right: unset;
left: -22px;
}
.checkbox-container .checkbox:after {
top: -4px;
}
.checkbox-container .checkbox:after {
top: -4px;
}
}
.search-row {
display: flex;
flex-wrap: wrap;
line-height: unset;
display: flex;
flex-wrap: wrap;
line-height: unset;
> div {
flex-grow: 1;
flex-shrink: 1;
}
> div {
flex-grow: 1;
flex-shrink: 1;
}
input {
height: 21px;
}
.pref-input {
display: block;
padding-bottom: 5px;
input {
height: 21px;
}
.pref-input {
display: block;
padding-bottom: 5px;
input {
height: 21px;
margin-top: 1px;
}
height: 21px;
margin-top: 1px;
}
}
}
.search-toggles {
flex-grow: 1;
display: grid;
grid-template-columns: repeat(6, auto);
grid-column-gap: 10px;
flex-grow: 1;
display: grid;
grid-template-columns: repeat(5, auto);
grid-column-gap: 10px;
}
.profile-tabs {
@include search-resize(820px, 5);
@include search-resize(725px, 4);
@include search-resize(600px, 6);
@include search-resize(560px, 5);
@include search-resize(480px, 4);
@include search-resize(410px, 3);
@include search-resize(820px, 5);
@include search-resize(715px, 4);
@include search-resize(700px, 5);
@include search-resize(485px, 4);
@include search-resize(410px, 3);
}
@include search-resize(560px, 5);
@include search-resize(480px, 4);
@include search-resize(700px, 5);
@include search-resize(485px, 4);
@include search-resize(410px, 3);

View File

@@ -1,162 +1,485 @@
@import '_variables';
@import "_variables";
.timeline-container {
@include panel(100%, 600px);
@include panel(100%, 600px);
}
.timeline {
background-color: var(--bg_panel);
> div:not(:first-child) {
border-top: 1px solid var(--border_grey);
}
.timeline > div:not(:first-child) {
border-top: 1px solid var(--border_grey);
}
.timeline-header {
width: 100%;
background-color: var(--bg_panel);
text-align: center;
padding: 8px;
display: block;
font-weight: bold;
margin-bottom: 5px;
box-sizing: border-box;
width: 100%;
background-color: var(--bg_panel);
text-align: center;
padding: 8px;
display: block;
font-weight: bold;
margin-bottom: 4px;
box-sizing: border-box;
button {
float: unset;
}
button {
float: unset;
}
}
.timeline-banner img {
width: 100%;
width: 100%;
}
.timeline-description {
font-weight: normal;
font-weight: normal;
}
.tab {
align-items: center;
display: flex;
flex-wrap: wrap;
list-style: none;
margin: 0 0 5px 0;
background-color: var(--bg_panel);
padding: 0;
align-items: center;
display: flex;
flex-wrap: wrap;
list-style: none;
margin: 0 0 4px 0;
background-color: var(--bg_panel);
padding: 0;
}
.tab-item {
flex: 1 1 0;
text-align: center;
margin-top: 0;
flex: 1 1 0;
text-align: center;
margin-top: 0;
a {
border-bottom: .1rem solid transparent;
color: var(--tab);
display: block;
padding: 8px 0;
text-decoration: none;
font-weight: bold;
a {
border-bottom: 0.1rem solid transparent;
color: var(--tab);
display: block;
padding: 8px 0;
text-decoration: none;
font-weight: bold;
&:hover {
text-decoration: none;
}
&.active {
border-bottom-color: var(--tab_selected);
color: var(--tab_selected);
}
&:hover {
text-decoration: none;
}
&.active a {
border-bottom-color: var(--tab_selected);
color: var(--tab_selected);
&.active {
border-bottom-color: var(--tab_selected);
color: var(--tab_selected);
}
}
&.wide {
flex-grow: 1.2;
flex-basis: 50px;
}
&.active a {
border-bottom-color: var(--tab_selected);
color: var(--tab_selected);
}
&.wide {
flex-grow: 1.2;
flex-basis: 50px;
}
}
.timeline-footer {
background-color: var(--bg_panel);
padding: 6px 0;
background-color: var(--bg_panel);
padding: 6px 0;
}
.timeline-protected {
text-align: center;
text-align: center;
p {
margin: 8px 0;
}
p {
margin: 8px 0;
}
h2 {
color: var(--accent);
font-size: 20px;
font-weight: 600;
}
}
.timeline-none {
h2 {
color: var(--accent);
font-size: 20px;
font-weight: 600;
text-align: center;
}
}
.timeline-none {
color: var(--accent);
font-size: 20px;
font-weight: 600;
text-align: center;
}
.timeline-end {
background-color: var(--bg_panel);
color: var(--accent);
font-size: 16px;
font-weight: 600;
text-align: center;
background-color: var(--bg_panel);
color: var(--accent);
font-size: 16px;
font-weight: 600;
text-align: center;
}
.show-more {
background-color: var(--bg_panel);
text-align: center;
padding: .75em 0;
display: block !important;
background-color: var(--bg_panel);
text-align: center;
padding: 0.75em 0;
display: block !important;
a {
background-color: var(--darkest_grey);
display: inline-block;
height: 2em;
padding: 0 2em;
line-height: 2em;
a {
background-color: var(--darkest_grey);
display: inline-block;
height: 2em;
padding: 0 2em;
line-height: 2em;
&:hover {
background-color: var(--darker_grey);
}
&:hover {
background-color: var(--darker_grey);
}
}
}
.top-ref {
background-color: var(--bg_color);
border-top: none !important;
background-color: var(--bg_color);
border-top: none !important;
.icon-down {
font-size: 20px;
display: flex;
justify-content: center;
text-decoration: none;
.icon-down {
font-size: 20px;
display: flex;
justify-content: center;
text-decoration: none;
&:hover {
color: var(--accent_light);
}
&::before {
transform: rotate(180deg) translateY(-1px);
}
&:hover {
color: var(--accent_light);
}
&::before {
transform: rotate(180deg) translateY(-1px);
}
}
}
.timeline-item {
overflow-wrap: break-word;
border-left-width: 0;
min-width: 0;
padding: .75em;
display: flex;
position: relative;
overflow-wrap: break-word;
border-left-width: 0;
min-width: 0;
padding: 0.75em;
display: flex;
position: relative;
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;
}
}

View File

@@ -1,240 +1,311 @@
@import '_variables';
@import '_mixins';
@import 'thread';
@import 'media';
@import 'video';
@import 'embed';
@import 'card';
@import 'poll';
@import 'quote';
@import "_variables";
@import "_mixins";
@import "thread";
@import "media";
@import "video";
@import "embed";
@import "card";
@import "poll";
@import "quote";
.tweet-body {
flex: 1;
min-width: 0;
margin-left: 58px;
pointer-events: none;
z-index: 1;
flex: 1;
min-width: 0;
margin-left: 58px;
pointer-events: none;
z-index: 1;
}
.tweet-content {
font-family: $font_3;
line-height: 1.3em;
pointer-events: all;
display: inline;
line-height: 1.3em;
pointer-events: all;
display: inline;
}
.tweet-bidi {
display: block !important;
display: block !important;
}
.tweet-header {
padding: 0;
vertical-align: bottom;
flex-basis: 100%;
margin-bottom: .2em;
padding: 0;
vertical-align: bottom;
flex-basis: 100%;
margin-bottom: 0.2em;
a {
display: inline-block;
word-break: break-all;
max-width: 100%;
pointer-events: all;
}
a {
display: inline-block;
word-break: break-all;
max-width: 100%;
pointer-events: all;
}
}
.tweet-name-row {
padding: 0;
display: flex;
justify-content: space-between;
padding: 0;
display: flex;
justify-content: space-between;
.verified-icon {
margin-left: 2px;
}
}
.fullname-and-username {
display: flex;
min-width: 0;
display: flex;
min-width: 0;
}
.fullname {
@include ellipsis;
flex-shrink: 2;
max-width: 80%;
font-size: 14px;
font-weight: 700;
color: var(--fg_color);
@include ellipsis;
flex-shrink: 2;
max-width: 80%;
font-size: 14px;
font-weight: 700;
color: var(--fg_color);
}
.username {
@include ellipsis;
min-width: 1.6em;
margin-left: .4em;
word-wrap: normal;
@include ellipsis;
min-width: 1.6em;
margin-left: 0.4em;
word-wrap: normal;
}
.tweet-date {
display: flex;
flex-shrink: 0;
margin-left: 4px;
display: flex;
flex-shrink: 0;
margin-left: 4px;
}
.tweet-date a, .username, .show-more a {
color: var(--fg_dark);
.tweet-date a,
.username,
.show-more a {
color: var(--fg_dark);
}
.tweet-published {
margin: 0;
margin-top: 5px;
color: var(--grey);
pointer-events: all;
margin-top: 6px;
margin-bottom: 0px;
color: var(--grey);
pointer-events: all;
}
.tweet-avatar {
display: contents !important;
display: contents !important;
img {
float: left;
margin-top: 3px;
margin-left: -58px;
width: 48px;
height: 48px;
}
img {
float: left;
margin-top: 3px;
margin-left: -58px;
width: 48px;
height: 48px;
}
}
.avatar {
&.round {
border-radius: 50%;
-webkit-user-select: none;
}
&.mini {
position: unset;
margin-right: 5px;
margin-top: -1px;
width: 20px;
height: 20px;
}
&.round {
border-radius: 50%;
user-select: none;
-webkit-user-select: none;
}
&.mini {
position: unset;
margin-right: 5px;
margin-top: -1px;
width: 20px;
height: 20px;
}
}
.tweet-embed {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
background-color: var(--bg_panel);
.tweet-content {
font-size: 18px;
}
.tweet-body {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
background-color: var(--bg_panel);
max-height: calc(100vh - 0.75em * 2);
}
.tweet-content {
font-size: 18px;
}
.tweet-body {
display: flex;
flex-direction: column;
max-height: calc(100vh - 0.75em * 2);
}
.card-image img {
height: auto;
}
.card-image img {
height: auto;
}
.avatar {
position: absolute;
}
.avatar {
position: absolute;
}
}
.attribution {
display: flex;
pointer-events: all;
margin: 5px 0;
display: flex;
pointer-events: all;
margin: 5px 0;
strong {
color: var(--fg_color);
}
strong {
color: var(--fg_color);
}
}
.media-tag-block {
padding-top: 5px;
pointer-events: all;
padding-top: 5px;
pointer-events: all;
color: var(--fg_faded);
.icon-container {
padding-right: 2px;
}
.media-tag,
.icon-container {
color: var(--fg_faded);
.icon-container {
padding-right: 2px;
}
.media-tag, .icon-container {
color: var(--fg_faded);
}
}
}
.timeline-container .media-tag-block {
font-size: 13px;
font-size: 13px;
}
.tweet-geo {
color: var(--fg_faded);
color: var(--fg_faded);
}
.replying-to {
color: var(--fg_faded);
margin: -2px 0 4px;
color: var(--fg_faded);
margin: -2px 0 4px;
a {
pointer-events: all;
}
a {
pointer-events: all;
}
}
.retweet-header, .pinned, .tweet-stats {
align-content: center;
color: var(--grey);
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
font-size: 14px;
font-weight: 600;
line-height: 22px;
.retweet-header,
.pinned,
.tweet-stats {
align-content: center;
color: var(--grey);
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
font-size: 14px;
font-weight: 600;
line-height: 22px;
span {
@include ellipsis;
}
span {
@include ellipsis;
}
}
.retweet-header {
margin-top: -5px !important;
margin-top: -5px !important;
}
.tweet-stats {
margin-bottom: -3px;
-webkit-user-select: none;
margin-bottom: -3px;
user-select: none;
-webkit-user-select: none;
}
.tweet-stat {
padding-top: 5px;
min-width: 1em;
margin-right: 0.8em;
padding-top: 5px;
min-width: 1em;
margin-right: 0.8em;
}
.show-thread {
display: block;
pointer-events: all;
padding-top: 2px;
display: block;
pointer-events: all;
padding-top: 2px;
}
.unavailable-box {
width: 100%;
height: 100%;
padding: 12px;
border: solid 1px var(--dark_grey);
box-sizing: border-box;
border-radius: 10px;
background-color: var(--bg_color);
z-index: 2;
width: 100%;
height: 100%;
padding: 12px;
border: solid 1px var(--dark_grey);
box-sizing: border-box;
border-radius: 10px;
background-color: var(--bg_color);
z-index: 2;
}
.tweet-link {
height: 100%;
width: 100%;
left: 0;
top: 0;
position: absolute;
-webkit-user-select: none;
height: 100%;
width: 100%;
left: 0;
top: 0;
position: absolute;
user-select: none;
-webkit-user-select: none;
&:hover {
background-color: var(--bg_hover);
}
&:hover {
background-color: var(--bg_hover);
}
}
.latest-post-version {
border-bottom: 1px solid var(--dark_grey);
border-top: 1px solid var(--dark_grey);
padding: 01ch 0px;
margin: 1ch 0px;
color: var(--grey);
a {
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;
}
}

View File

@@ -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;
}
}

View File

@@ -1,17 +1,17 @@
@import '_variables';
@import '_mixins';
@import "_variables";
@import "_mixins";
.embed-video {
.gallery-video {
width: 100%;
height: 100%;
position: absolute;
background-color: black;
top: 0%;
left: 0%;
}
.gallery-video {
width: 100%;
height: 100%;
position: absolute;
background-color: black;
top: 0%;
left: 0%;
}
.video-container {
max-height: unset;
}
.gallery-video > .attachment {
max-height: unset;
}
}

View File

@@ -1,119 +1,165 @@
@import '_variables';
@import "_variables";
.gallery-row {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
overflow: hidden;
flex-grow: 1;
max-height: 379.5px;
max-width: 533px;
pointer-events: all;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
overflow: hidden;
flex-grow: 1;
max-height: 379.5px;
max-width: 533px;
pointer-events: all;
&.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 {
width: 100%;
display: flex;
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;
}
}
}
.attachments {
margin-top: .35em;
display: flex;
flex-direction: row;
width: 100%;
max-height: 600px;
border-radius: 7px;
overflow: hidden;
flex-flow: column;
background-color: var(--bg_color);
align-items: center;
pointer-events: all;
.image-attachment {
width: 100%;
}
margin-top: 0.35em;
display: flex;
flex-direction: row;
width: 100%;
max-height: 600px;
border-radius: 7px;
overflow: hidden;
flex-flow: column;
background-color: var(--bg_color);
align-items: center;
pointer-events: all;
}
.attachment {
position: relative;
line-height: 0;
overflow: hidden;
margin: 0 .25em 0 0;
flex-grow: 1;
box-sizing: border-box;
min-width: 2em;
position: relative;
line-height: 0;
overflow: hidden;
margin: 0 0.25em 0 0;
flex-grow: 1;
box-sizing: border-box;
min-width: 2em;
&:last-child {
margin: 0;
max-height: 530px;
}
}
.gallery-gif video {
&:last-child {
margin: 0;
max-height: 530px;
background-color: #101010;
}
.still-image {
max-height: 379.5px;
max-width: 533px;
justify-content: center;
img {
object-fit: cover;
max-width: 100%;
max-height: 379.5px;
flex-basis: 300px;
flex-grow: 1;
}
}
.image {
display: inline-block;
}
// .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);
width: 40px;
height: 40px;
align-items: center;
display: flex;
border-width: 5px;
border-color: var(--play_button);
border-style: solid;
}
.overlay-triangle {
width: 0;
height: 0;
border-style: solid;
border-width: 12px 0 12px 17px;
border-color: transparent transparent transparent var(--play_button);
margin-left: 14px;
}
}
.media-gif {
display: table;
background-color: unset;
width: unset;
display: table;
background-color: unset;
width: unset;
max-height: unset;
}
.media-gif video {
max-height: 530px;
background-color: #101010;
}
.still-image {
max-height: 379.5px;
max-width: 533px;
img {
object-fit: cover;
max-width: 100%;
max-height: 379.5px;
flex-basis: 300px;
flex-grow: 1;
}
}
.alt-text {
margin: 0px;
padding: 11px 7px;
box-sizing: border-box;
position: absolute;
bottom: 10px;
left: 10px;
width: 2.98em;
max-height: 25px;
white-space: pre;
overflow: hidden;
border-radius: 10px;
color: var(--fg_color);
font-size: 12px;
font-weight: bold;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(12px);
}
.alt-text:hover {
padding: 7px;
width: Min(230px, calc(100% - 10px * 2));
max-height: calc(100% - 10px);
line-height: 1.2em;
white-space: pre-wrap;
transition-duration: 0.4s;
transition-property: max-height;
}
.overlay-circle {
border-radius: 50%;
background-color: var(--dark_grey);
width: 40px;
height: 40px;
align-items: center;
display: flex;
border-width: 5px;
border-color: var(--play_button);
border-style: solid;
}
.overlay-triangle {
width: 0;
height: 0;
border-style: solid;
border-width: 12px 0 12px 17px;
border-color: transparent transparent transparent var(--play_button);
margin-left: 14px;
}
.media-body {
flex: 1;
padding: 0;
white-space: pre-wrap;
flex: 1;
padding: 0;
white-space: pre-wrap;
}

View File

@@ -1,42 +1,42 @@
@import '_variables';
@import "_variables";
.poll-meter {
overflow: hidden;
position: relative;
margin: 6px 0;
height: 26px;
background: var(--bg_color);
border-radius: 5px;
display: flex;
align-items: center;
overflow: hidden;
position: relative;
margin: 6px 0;
height: 26px;
background: var(--bg_color);
border-radius: 5px;
display: flex;
align-items: center;
}
.poll-choice-bar {
height: 100%;
position: absolute;
background: var(--dark_grey);
height: 100%;
position: absolute;
background: var(--dark_grey);
}
.poll-choice-value {
position: relative;
font-weight: bold;
margin-left: 5px;
margin-right: 6px;
min-width: 30px;
text-align: right;
pointer-events: all;
position: relative;
font-weight: bold;
margin-left: 5px;
margin-right: 6px;
min-width: 30px;
text-align: right;
pointer-events: all;
}
.poll-choice-option {
position: relative;
pointer-events: all;
position: relative;
pointer-events: all;
}
.poll-info {
color: var(--grey);
pointer-events: all;
color: var(--grey);
pointer-events: all;
}
.leader .poll-choice-bar {
background: var(--accent_dark);
background: var(--accent_dark);
}

View File

@@ -1,94 +1,120 @@
@import '_variables';
@import "_variables";
.quote {
margin-top: 10px;
border: solid 1px var(--dark_grey);
border-radius: 10px;
background-color: var(--bg_elements);
margin-top: 10px;
border: solid 1px var(--dark_grey);
border-radius: 10px;
background-color: var(--bg_elements);
overflow: hidden;
pointer-events: all;
position: relative;
width: 100%;
&:hover {
border-color: var(--grey);
}
&.unavailable:hover {
border-color: var(--dark_grey);
}
.tweet-name-row {
padding: 8px 10px 6px 10px;
}
.quote-text {
overflow: hidden;
pointer-events: all;
position: relative;
width: 100%;
white-space: pre-wrap;
word-wrap: break-word;
padding: 10px;
padding-top: 0;
}
.show-thread {
padding: 0px 10px 6px 10px;
margin-top: -6px;
}
.quote-latest {
padding: 0px 10px 6px 10px;
color: var(--grey);
}
.replying-to {
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-color: var(--grey);
border-top-color: var(--grey);
}
&.unavailable:hover {
border-color: var(--dark_grey);
}
.tweet-name-row {
padding: 6px 8px;
margin-top: 1px;
}
.quote-text {
overflow: hidden;
white-space: pre-wrap;
word-wrap: break-word;
padding: 0px 8px 8px 8px;
}
.show-thread {
padding: 0px 8px 6px 8px;
margin-top: -6px;
}
.replying-to {
padding: 0px 8px;
margin: unset;
.community-note-header {
background-color: var(--bg_panel);
padding-bottom: 0;
}
}
}
.unavailable-quote {
padding: 12px;
padding: 12px;
display: block;
}
.quote-link {
width: 100%;
height: 100%;
left: 0;
top: 0;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
position: absolute;
}
.quote-media-container {
max-height: 300px;
max-height: 300px;
display: flex;
.card {
margin: unset;
}
.attachments {
border-radius: 0;
}
.media-gif {
width: 100%;
display: flex;
justify-content: center;
}
.card {
margin: unset;
.media-gif > .attachment {
display: flex;
justify-content: center;
background-color: var(--bg_color);
video {
height: unset;
width: unset;
max-height: 100%;
max-width: 100%;
}
}
.attachments {
border-radius: 0;
}
.gallery-row .attachment,
.gallery-row .attachment > video,
.gallery-row .attachment > img {
max-height: 300px;
}
.media-gif {
width: 100%;
display: flex;
justify-content: center;
}
.gallery-gif .attachment {
display: flex;
justify-content: center;
background-color: var(--bg_color);
video {
height: unset;
width: unset;
max-height: 100%;
max-width: 100%;
}
}
.gallery-video, .gallery-gif {
max-height: 300px;
}
.still-image img {
max-height: 250px
}
.still-image img {
max-height: 250px;
}
}

View File

@@ -1,138 +1,154 @@
@import '_variables';
@import '_mixins';
@import "_variables";
@import "_mixins";
.conversation {
@include panel(100%, 600px);
.conversation,
.edit-history {
@include panel(100%, 600px);
.show-more {
margin-bottom: 10px;
}
.show-more {
margin-bottom: 10px;
}
}
.main-thread {
margin-bottom: 20px;
background-color: var(--bg_panel);
}
.main-tweet, .replies {
padding-top: 50px;
margin-top: -50px;
}
.main-tweet .tweet-content {
font-size: 18px;
}
@media(max-width: 600px) {
.main-tweet .tweet-content {
font-size: 16px;
}
.main-thread,
.latest-edit {
margin-bottom: 20px;
}
.reply {
background-color: var(--bg_panel);
margin-bottom: 10px;
margin-bottom: 10px;
}
.main-tweet,
.replies,
.edit-history > div {
body.fixed-nav & {
padding-top: 50px;
margin-top: -50px;
}
}
.edit-history-header {
padding: 10px;
margin-bottom: 5px;
font-size: 16px;
font-weight: bold;
background-color: var(--bg_panel);
}
.tweet-edit {
margin-bottom: 5px;
}
.main-tweet .tweet-content {
font-size: 18px;
}
@media (max-width: 600px) {
.main-tweet .tweet-content {
font-size: 16px;
}
}
.thread-line {
.timeline-item::before,
&.timeline-item::before {
background: var(--accent_dark);
content: '';
position: relative;
min-width: 3px;
width: 3px;
left: 26px;
border-radius: 2px;
margin-left: -3px;
margin-bottom: 37px;
top: 56px;
z-index: 1;
pointer-events: none;
}
.timeline-item::before,
&.timeline-item::before {
background: var(--accent_dark);
content: "";
position: relative;
min-width: 3px;
width: 3px;
left: 26px;
border-radius: 2px;
margin-left: -3px;
margin-bottom: 37px;
top: 56px;
z-index: 1;
pointer-events: none;
}
.with-header:not(:first-child)::after {
background: var(--accent_dark);
content: '';
position: relative;
float: left;
min-width: 3px;
width: 3px;
right: calc(100% - 26px);
border-radius: 2px;
margin-left: -3px;
margin-bottom: 37px;
bottom: 10px;
height: 30px;
z-index: 1;
pointer-events: none;
}
.with-header:not(:first-child)::after {
background: var(--accent_dark);
content: "";
position: relative;
float: left;
min-width: 3px;
width: 3px;
right: calc(100% - 26px);
border-radius: 2px;
margin-left: -3px;
margin-bottom: 37px;
bottom: 10px;
height: 30px;
z-index: 1;
pointer-events: none;
}
.unavailable::before {
top: 48px;
margin-bottom: 28px;
}
.unavailable::before {
top: 48px;
margin-bottom: 28px;
}
.more-replies::before {
content: '...';
background: unset;
color: var(--more_replies_dots);
font-weight: bold;
font-size: 20px;
line-height: 0.25em;
left: 1.2em;
width: 5px;
top: 2px;
margin-bottom: 0;
margin-left: -2.5px;
}
.more-replies::before {
content: "...";
background: unset;
color: var(--more_replies_dots);
font-weight: bold;
font-size: 20px;
line-height: 0.25em;
left: 1.2em;
width: 5px;
top: 2px;
margin-bottom: 0;
margin-left: -2.5px;
}
.earlier-replies {
padding-bottom: 0;
margin-bottom: -5px;
}
.earlier-replies {
padding-bottom: 0;
margin-bottom: -5px;
}
}
.timeline-item.thread-last::before {
background: unset;
min-width: unset;
width: 0;
margin: 0;
background: unset;
min-width: unset;
width: 0;
margin: 0;
}
.more-replies {
padding-top: 0.3em !important;
padding-top: 0.3em !important;
}
.more-replies-text {
@include ellipsis;
display: block;
margin-left: 58px;
padding: 7px 0;
@include ellipsis;
display: block;
margin-left: 58px;
padding: 7px 0;
}
.timeline-item.thread.more-replies-thread {
padding: 0 0.75em;
padding: 0 0.75em;
&::before {
top: 40px;
margin-bottom: 31px;
}
.more-replies {
display: flex;
padding-top: unset !important;
margin-top: 8px;
&::before {
top: 40px;
margin-bottom: 31px;
display: inline-block;
position: relative;
top: -1px;
line-height: 0.4em;
}
.more-replies {
display: flex;
padding-top: unset !important;
margin-top: 8px;
&::before {
display: inline-block;
position: relative;
top: -1px;
line-height: 0.4em;
}
.more-replies-text {
display: inline;
}
.more-replies-text {
display: inline;
}
}
}

View File

@@ -1,68 +1,77 @@
@import '_variables';
@import '_mixins';
@import "_variables";
@import "_mixins";
video {
max-height: 100%;
width: 100%;
height: 100%;
width: 100%;
}
.gallery-video {
display: flex;
overflow: hidden;
}
.gallery-video.card-container {
display: flex;
overflow: hidden;
&.card-container {
flex-direction: column;
}
width: 100%;
}
.video-container {
> .attachment {
min-height: 80px;
min-width: 200px;
max-height: 530px;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
img {
max-height: 100%;
max-width: 100%;
max-height: 100%;
max-width: 100%;
}
}
}
.video-overlay {
@include play-button;
background-color: $shadow;
@include play-button;
background-color: $shadow;
p {
position: relative;
z-index: 0;
text-align: center;
top: calc(50% - 20px);
font-size: 20px;
line-height: 1.3;
margin: 0 20px;
}
p {
position: relative;
z-index: 0;
text-align: center;
top: calc(50% - 20px);
font-size: 20px;
line-height: 1.3;
margin: 0 20px;
}
div {
position: relative;
z-index: 0;
top: calc(50% - 20px);
margin: 0 auto;
width: 40px;
height: 40px;
}
.overlay-circle {
position: relative;
z-index: 0;
top: calc(50% - 20px);
margin: 0 auto;
width: 40px;
height: 40px;
}
form {
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
display: flex;
}
.overlay-duration {
position: absolute;
bottom: 8px;
left: 8px;
background-color: #0000007a;
line-height: 1em;
padding: 4px 6px 4px 6px;
border-radius: 5px;
font-weight: bold;
}
button {
padding: 5px 8px;
font-size: 16px;
}
form {
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
display: flex;
}
button {
padding: 5px 8px;
font-size: 16px;
}
}

62
src/tid.nim Normal file
View File

@@ -0,0 +1,62 @@
import std/[asyncdispatch, base64, httpclient, random, strutils, sequtils, times]
import nimcrypto
import experimental/parser/tid
randomize()
const defaultKeyword = "obfiowerehiring";
const pairsUrl =
"https://raw.githubusercontent.com/fa0311/x-client-transaction-id-pair-dict/refs/heads/main/pair.json";
var
cachedPairs: seq[TidPair] = @[]
lastCached = 0
# refresh every hour
ttlSec = 60 * 60
proc getPair(): Future[TidPair] {.async.} =
if cachedPairs.len == 0 or int(epochTime()) - lastCached > ttlSec:
lastCached = int(epochTime())
let client = newAsyncHttpClient()
defer: client.close()
let resp = await client.get(pairsUrl)
if resp.status == $Http200:
cachedPairs = parseTidPairs(await resp.body)
return sample(cachedPairs)
proc encodeSha256(text: string): array[32, byte] =
let
data = cast[ptr byte](addr text[0])
dataLen = uint(len(text))
digest = sha256.digest(data, dataLen)
return digest.data
proc encodeBase64[T](data: T): string =
return encode(data).replace("=", "")
proc decodeBase64(data: string): seq[byte] =
return cast[seq[byte]](decode(data))
proc genTid*(path: string): Future[string] {.async.} =
let
pair = await getPair()
timeNow = int(epochTime() - 1682924400)
timeNowBytes = @[
byte(timeNow and 0xff),
byte((timeNow shr 8) and 0xff),
byte((timeNow shr 16) and 0xff),
byte((timeNow shr 24) and 0xff)
]
data = "GET!" & path & "!" & $timeNow & defaultKeyword & pair.animationKey
hashBytes = encodeSha256(data)
keyBytes = decodeBase64(pair.verification)
bytesArr = keyBytes & timeNowBytes & hashBytes[0 ..< 16] & @[3'u8]
randomNum = byte(rand(256))
tid = @[randomNum] & bytesArr.mapIt(it xor randomNum)
return encodeBase64(tid)

View File

@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only
import times, sequtils, options, tables, uri
import times, sequtils, options, tables
import prefs_impl
genPrefsType()
@@ -13,19 +13,13 @@ type
TimelineKind* {.pure.} = enum
tweets, replies, media
Api* {.pure.} = enum
tweetDetail
tweetResult
search
list
listBySlug
listMembers
listTweets
userRestId
userScreenName
userTweets
userTweetsAndReplies
userMedia
ApiUrl* = object
endpoint*: string
params*: seq[(string, string)]
ApiReq* = object
oauth*: ApiUrl
cookie*: ApiUrl
RateLimit* = object
limit*: int
@@ -42,7 +36,7 @@ type
pending*: int
limited*: bool
limitedAt*: int
apis*: Table[Api, RateLimit]
apis*: Table[string, RateLimit]
case kind*: SessionKind
of oauth:
oauthToken*: string
@@ -51,10 +45,6 @@ type
authToken*: string
ct0*: string
SessionAwareUrl* = object
oauthUrl*: Uri
cookieUrl*: Uri
Error* = enum
null = 0
noUserMatches = 17
@@ -106,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"
@@ -133,6 +154,7 @@ type
Query* = object
kind*: QueryKind
view*: string
text*: string
filters*: seq[string]
includes*: seq[string]
@@ -140,12 +162,33 @@ type
fromUser*: seq[string]
since*: string
until*: string
near*: string
minLikes*: string
sep*: string
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
@@ -225,9 +268,11 @@ type
quote*: Option[Tweet]
card*: Option[Card]
poll*: Option[Poll]
gif*: Option[Gif]
video*: Option[Video]
photos*: seq[string]
media*: MediaEntities
history*: seq[int64]
note*: string
isAd*: bool
isAI*: bool
Tweets* = seq[Tweet]
@@ -248,6 +293,10 @@ type
after*: Chain
replies*: Result[Chain]
EditHistory* = object
latest*: Tweet
history*: Tweets
Timeline* = Result[Tweets]
Profile* = object
@@ -255,6 +304,7 @@ type
photoRail*: PhotoRail
pinned*: Option[Tweet]
tweets*: Timeline
accountInfo*: AccountInfo
List* = object
id*: string
@@ -281,10 +331,19 @@ type
hmacKey*: string
base64Media*: bool
minTokens*: int
enableRss*: bool
enableRSSUserTweets*: bool
enableRSSUserReplies*: bool
enableRSSUserMedia*: bool
enableRSSSearch*: bool
enableRSSList*: bool
enableDebug*: bool
proxy*: string
proxyAuth*: string
apiProxy*: string
disableTid*: bool
maxConcurrentReqs*: int
maxRetries*: int
retryDelayMs*: int
rssCacheTime*: int
listCacheTime*: int
@@ -303,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

View File

@@ -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
@@ -9,7 +9,7 @@ var
const
https* = "https://"
twimg* = "pbs.twimg.com/"
nitterParams = ["name", "tab", "id", "list", "referer", "scroll"]
nitterParams* = ["name", "tab", "id", "list", "referer", "scroll", "prefs"]
twitterDomains = @[
"twitter.com",
"pic.twitter.com",
@@ -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

View File

@@ -0,0 +1,93 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, times
import karax/[karaxdsl, vdom]
import renderutils
import ".."/[types, formatters]
proc renderAboutAccount*(info: AccountInfo): VNode =
let user = User(
username: info.username,
fullname: info.fullname,
userPic: info.userPic,
verifiedType: info.verifiedType
)
buildHtml(tdiv(class="about-account")):
tdiv(class="about-account-header"):
a(class="about-account-avatar", href=(&"/{info.username}")):
genImg(getUserPic(info.userPic, "_200x200"))
tdiv(class="about-account-name"):
linkUser(user, class="profile-card-fullname")
verifiedIcon(user)
linkUser(user, class="profile-card-username")
tdiv(class="about-account-body"):
tdiv(class="about-account-row"):
span: icon "calendar"
tdiv:
span(class="about-account-label"): text "Date joined"
span(class="about-account-value"):
text info.joinDate.format("MMMM YYYY")
if info.basedIn.len > 0:
tdiv(class="about-account-row"):
span: icon "location"
tdiv:
span(class="about-account-label"): text "Account based in"
span(class="about-account-value"): text info.basedIn
if info.verifiedType != VerifiedType.none:
if info.overrideVerifiedYear != 0:
tdiv(class="about-account-row"):
span: icon "ok"
tdiv:
span(class="about-account-label"): text "Verified"
span(class="about-account-value"):
let year = abs(info.overrideVerifiedYear)
let era = if info.overrideVerifiedYear < 0: " BCE" else: ""
text "Since " & $year & era
elif info.verifiedSince.year > 0:
tdiv(class="about-account-row"):
span: icon "ok"
tdiv:
span(class="about-account-label"): text "Verified"
span(class="about-account-value"):
text "Since " & info.verifiedSince.format("MMMM YYYY")
if info.isIdentityVerified:
tdiv(class="about-account-row"):
span: icon "ok"
tdiv:
span(class="about-account-label"): text "ID Verified"
span(class="about-account-value"): text "Yes"
if info.affiliateUsername.len > 0:
tdiv(class="about-account-row"):
span: icon "group"
tdiv:
span(class="about-account-label"): text "An affiliate of"
span(class="about-account-value"):
a(href=(&"/{info.affiliateUsername}")):
if info.affiliateLabel.len > 0:
text info.affiliateLabel & " (@" & info.affiliateUsername & ")"
else:
text "@" & info.affiliateUsername
if info.usernameChanges > 0:
tdiv(class="about-account-row"):
span(class="about-account-at"): text "@"
tdiv:
span(class="about-account-label"):
text $info.usernameChanges & " username change"
if info.usernameChanges > 1: text "s"
if info.lastUsernameChange.year > 0:
span(class="about-account-value"):
text "Last on " & info.lastUsernameChange.format("MMMM YYYY")
if info.source.len > 0:
tdiv(class="about-account-row"):
span: icon "link"
tdiv:
span(class="about-account-label"): text "Connected via"
span(class="about-account-value"): text info.source

75
src/views/broadcast.nim Normal file
View File

@@ -0,0 +1,75 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, times
import karax/[karaxdsl, vdom]
import renderutils
import ".."/[types, utils, formatters]
proc renderBroadcast*(bc: Broadcast; prefs: Prefs; path: string): VNode =
let
isLive = bc.state == "RUNNING"
thumb = getPicUrl(bc.thumb)
source = if prefs.proxyVideos and bc.m3u8Url.startsWith("http"):
getVidUrl(bc.m3u8Url) else: bc.m3u8Url
stateText =
if isLive: "LIVE"
elif bc.endTime.year > 1: "Ended " & bc.endTime.format("MMM d, YYYY")
elif bc.state.len > 0: bc.state
else: "Ended"
durationMs =
if bc.startTime.year > 1 and bc.endTime.year > 1:
int((bc.endTime - bc.startTime).inMilliseconds) - bc.replayStart * 1000
else: 0
duration = if durationMs > 0: getDuration(durationMs) else: ""
buildHtml(tdiv(class="broadcast-page")):
tdiv(class="broadcast-panel"):
tdiv(class="broadcast-player"):
if bc.m3u8Url.len > 0 and prefs.hlsPlayback:
video(poster=thumb, data-url=source, data-autoload="false",
data-start=($bc.replayStart), muted=prefs.muteVideos)
verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
tdiv(class="overlay-circle"): span(class="overlay-triangle")
if isLive:
tdiv(class="broadcast-live"): text "LIVE"
elif duration.len > 0:
tdiv(class="overlay-duration"): text duration
verbatim "</div>"
elif bc.m3u8Url.len > 0:
img(src=thumb, alt=bc.title)
tdiv(class="video-overlay"):
buttonReferer "/enablehls", "Enable hls playback", path
if isLive:
tdiv(class="broadcast-live"): text "LIVE"
elif duration.len > 0:
tdiv(class="overlay-duration"): text duration
elif bc.thumb.len > 0:
img(src=thumb, alt=bc.title)
tdiv(class="video-overlay"):
if bc.availableForReplay:
p: text "Stream unavailable"
else:
p: text "Replay is not available"
else:
tdiv(class="video-overlay"):
p: text "Broadcast not found"
tdiv(class="broadcast-info"):
h2(class="broadcast-title"): text bc.title
tdiv(class="broadcast-user-row"):
a(class="broadcast-user", href=("/" & bc.user.username)):
genImg(getUserPic(bc.user.userPic, "_bigger"))
tdiv:
tdiv:
strong: text bc.user.fullname
verifiedIcon(bc.user)
span(class="broadcast-username"): text "@" & bc.user.username
tdiv(class="broadcast-meta"):
if bc.totalWatched > 0:
span: text insertSep($bc.totalWatched, ',') & " views"
if isLive:
span(class="broadcast-live"): text stateText
else:
span: text stateText

View File

@@ -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

View File

@@ -29,19 +29,17 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
tdiv(class="nav-item right"):
icon "search", title="Search", href="/search"
if cfg.enableRss and rss.len > 0:
if rss.len > 0:
icon "rss", title="RSS Feed", href=rss
icon "bird", title="Open in Twitter", href=canonical
icon "bird", title="Open in X", href=canonical
a(href="https://liberapay.com/zedeus"): verbatim lp
icon "info", title="About", href="/about"
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
rss=""; canonical=""): VNode =
var theme = prefs.theme.toTheme
if "theme" in req.params:
theme = req.params["theme"].toTheme
rss=""; alternate=""): VNode =
let theme = prefs.theme.toTheme
let ogType =
if video.len > 0: "video"
@@ -52,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=19")
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3")
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"))
@@ -66,10 +64,10 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
href=opensearchUrl)
if canonical.len > 0:
link(rel="canonical", href=canonical)
if alternate.len > 0:
link(rel="alternate", href=alternate, title="View on X")
if cfg.enableRss and rss.len > 0:
if rss.len > 0:
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
if prefs.hlsPlayback:
@@ -125,14 +123,15 @@ proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
titleText=""; desc=""; ogTitle=""; rss=""; video="";
images: seq[string] = @[]; banner=""): string =
let canonical = getTwitterLink(req.path, req.params)
let twitterLink = getTwitterLink(req.path, req.params)
let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
rss, canonical)
rss, twitterLink)
body:
renderNavbar(cfg, req, rss, canonical)
let bodyClass = if prefs.stickyNav: "fixed-nav" else: ""
body(class=bodyClass):
renderNavbar(cfg, req, rss, twitterLink)
tdiv(class="container"):
body

View File

@@ -32,7 +32,8 @@ macro renderPrefs*(): untyped =
result[2].add stmt
proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode =
proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string];
prefsUrl: string): VNode =
buildHtml(tdiv(class="overlay-panel")):
fieldset(class="preferences"):
form(`method`="post", action="/saveprefs", autocomplete="off"):
@@ -40,6 +41,14 @@ proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode
renderPrefs()
legend: text "Bookmark"
p(class="bookmark-note"):
text "Save this URL to restore your preferences (?prefs works on all pages)"
pre(class="prefs-code"):
text prefsUrl
p(class="bookmark-note"):
verbatim "You can override preferences with query parameters (e.g. <code>?hlsPlayback=on</code>). These overrides aren't saved to cookies, and links won't retain the parameters. Intended for configuring RSS feeds and other cookieless environments. Hover over a preference to see its name."
h4(class="note"):
text "Preferences are stored client-side using cookies without any personal information."

View File

@@ -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
@@ -26,6 +26,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
tdiv(class="profile-card-tabs-name"):
linkUser(user, class="profile-card-fullname")
verifiedIcon(user)
linkUser(user, class="profile-card-username")
tdiv(class="profile-card-extra"):
@@ -45,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:
@@ -53,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"):
@@ -101,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)

View File

@@ -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}"
@@ -26,7 +33,9 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
template verifiedIcon*(user: User): untyped {.dirty.} =
if user.verifiedType != VerifiedType.none:
let lower = ($user.verifiedType).toLowerAscii()
icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account")
buildHtml(tdiv(class=(&"verified-icon {lower}"))):
icon "circle", class="verified-icon-circle", title=(&"Verified {lower} account")
icon "ok", class="verified-icon-check", title=(&"Verified {lower} account")
else:
text ""
@@ -40,7 +49,6 @@ proc linkUser*(user: User, class=""): VNode =
buildHtml(a(href=href, class=class, title=nameText)):
text nameText
if isName:
verifiedIcon(user)
if user.protected:
text " "
icon "lock", title="Protected account"
@@ -64,20 +72,20 @@ proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNod
text text
proc genCheckbox*(pref, label: string; state: bool): VNode =
buildHtml(label(class="pref-group checkbox-container")):
buildHtml(label(class="pref-group checkbox-container", title=pref)):
text label
input(name=pref, `type`="checkbox", checked=state)
span(class="checkbox")
proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode =
let p = placeholder
buildHtml(tdiv(class=("pref-group pref-input " & class))):
buildHtml(tdiv(class=("pref-group pref-input " & class), title=pref)):
if label.len > 0:
label(`for`=pref): text label
input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0))
proc genSelect*(pref, label, state: string; options: seq[string]): VNode =
buildHtml(tdiv(class="pref-group pref-input")):
buildHtml(tdiv(class="pref-group pref-input", title=pref)):
label(`for`=pref): text label
select(name=pref):
for opt in options:
@@ -89,9 +97,16 @@ proc genDate*(pref, state: string): VNode =
input(name=pref, `type`="date", value=state)
icon "calendar"
proc genImg*(url: string; class=""): VNode =
proc genNumberInput*(pref, label, state, placeholder: string; class=""; autofocus=true; min="0"): VNode =
let p = placeholder
buildHtml(tdiv(class=("pref-group pref-input " & class))):
if label.len > 0:
label(`for`=pref): text label
input(name=pref, `type`="number", placeholder=p, value=state, autofocus=(autofocus and state.len == 0), min=min, step="1")
proc genImg*(url: string; class=""; alt=""): VNode =
buildHtml():
img(src=getPicUrl(url), class=class, alt="", loading="lazy")
img(src=getPicUrl(url), class=class, alt=alt, loading="lazy")
proc getTabClass*(query: Query; tab: QueryKind): string =
if query.kind == tab: "tab-item active"

View File

@@ -1,31 +1,63 @@
#? 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
#
#proc getDescription(desc: string; cfg: Config): string =
Twitter feed for: ${desc}. Generated by ${cfg.hostname}
Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
#end proc
#
#proc 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] =
@@ -46,35 +78,41 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
#end if
#end proc
#
#proc renderRssTweet(tweet: Tweet; cfg: Config): string =
#proc renderRssTweet(tweet: Tweet; cfg: Config; prefs: Prefs): string =
#let tweet = tweet.retweet.get(tweet)
#let urlPrefix = getUrlPrefix(cfg)
#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix)
#let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix)
<p>${text.replace("\n", "<br>\n")}</p>
#if tweet.quote.isSome and get(tweet.quote).available:
# let quoteLink = getLink(get(tweet.quote))
<p><a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p>
#end if
#if tweet.photos.len > 0:
# for photo in tweet.photos:
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" />
#if tweet.media.len > 0:
# for media in tweet.media:
${renderRssMedia(media, tweet, urlPrefix)}
# end for
#elif tweet.video.isSome:
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
#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)
<hr/>
<blockquote>
<b>${quoteTweet.user.fullname} (@${quoteTweet.user.username})</b>
<p>
${renderRssTweet(quoteTweet, cfg, prefs)}
</p>
<footer>
— <cite><a href="${quoteLink}">${quoteLink}</a>
</footer>
</blockquote>
#end if
#end proc
#
#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string =
#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; prefs: Prefs; userId=""): string =
#let urlPrefix = getUrlPrefix(cfg)
#var links: seq[string]
#for thread in tweets:
@@ -88,19 +126,24 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
# if link in links: continue
# end if
# links.add link
# let useGlobalGuid = tweet.id >= guidCutoff
<item>
<title>${getTitle(tweet, retweet)}</title>
<dc:creator>@${tweet.user.username}</dc:creator>
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
<description><![CDATA[${renderRssTweet(tweet, cfg, prefs).strip(chars={'\n'})}]]></description>
<pubDate>${getRfc822Time(tweet)}</pubDate>
#if useGlobalGuid:
<guid isPermaLink="false">${tweet.id}</guid>
#else:
<guid>${urlPrefix & link}</guid>
#end if
<link>${urlPrefix & link}</link>
</item>
# end for
#end for
#end proc
#
#proc renderTimelineRss*(profile: Profile; cfg: Config; multi=false): string =
#proc renderTimelineRss*(profile: Profile; cfg: Config; prefs: Prefs; multi=false): string =
#let urlPrefix = getUrlPrefix(cfg)
#result = ""
#let handle = (if multi: "" else: "@") & profile.user.username
@@ -126,13 +169,13 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
</image>
#let tweetsList = getTweetsWithPinned(profile)
#if tweetsList.len > 0:
${renderRssTweets(tweetsList, cfg, userId=profile.user.id)}
${renderRssTweets(tweetsList, cfg, prefs, userId=profile.user.id)}
#end if
</channel>
</rss>
#end proc
#
#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config): string =
#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config; prefs: Prefs): string =
#let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}"
#result = ""
<?xml version="1.0" encoding="UTF-8"?>
@@ -144,12 +187,12 @@ ${renderRssTweets(tweetsList, cfg, userId=profile.user.id)}
<description>${getDescription(&"{list.name} by @{list.username}", cfg)}</description>
<language>en-us</language>
<ttl>40</ttl>
${renderRssTweets(tweets, cfg)}
${renderRssTweets(tweets, cfg, prefs)}
</channel>
</rss>
#end proc
#
#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config): string =
#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config; prefs: Prefs): string =
#let link = &"{getUrlPrefix(cfg)}/search"
#let escName = xmltree.escape(name)
#result = ""
@@ -162,7 +205,7 @@ ${renderRssTweets(tweets, cfg)}
<description>${getDescription(&"Search \"{escName}\"", cfg)}</description>
<language>en-us</language>
<ttl>40</ttl>
${renderRssTweets(tweets, cfg)}
${renderRssTweets(tweets, cfg, prefs)}
</channel>
</rss>
#end proc

View File

@@ -10,14 +10,12 @@ const toggles = {
"media": "Media",
"videos": "Videos",
"news": "News",
"verified": "Verified",
"native_video": "Native videos",
"replies": "Replies",
"links": "Links",
"images": "Images",
"safe": "Safe",
"quote": "Quotes",
"pro_video": "Pro videos"
"spaces": "Spaces"
}.toOrderedTable
proc renderSearch*(): VNode =
@@ -41,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")):
@@ -53,7 +64,7 @@ proc renderSearchTabs*(query: Query): VNode =
proc isPanelOpen(q: Query): bool =
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or
@[q.near, q.until, q.since].anyIt(it.len > 0))
@[q.minLikes, q.until, q.since].anyIt(it.len > 0))
proc renderSearchPanel*(query: Query): VNode =
let user = query.fromUser.join(",")
@@ -85,8 +96,8 @@ proc renderSearchPanel*(query: Query): VNode =
span(class="search-title"): text "-"
genDate("until", query.until)
tdiv:
span(class="search-title"): text "Near"
genInput("near", "", query.near, "Location...", autofocus=false)
span(class="search-title"): text "Minimum likes"
genNumberInput("min_faves", "", query.minLikes, "Number...", autofocus=false)
proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
pinned=none(Tweet)): VNode =
@@ -97,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"):

View File

@@ -28,14 +28,19 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode =
if thread.hasMore:
renderMoreReplies(thread)
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode =
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: Tweet = nil): VNode =
buildHtml(tdiv(class="replies", id="r")):
var hasReplies = false
var replyCount = 0
for thread in replies.content:
if thread.content.len == 0: continue
hasReplies = true
replyCount += thread.content.len
renderReplyThread(thread, prefs, path)
if replies.bottom.len > 0:
renderMore(Query(), replies.bottom, focus="#r")
if hasReplies and replies.bottom.len > 0:
if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies:
renderMore(Query(), replies.bottom, focus="#r")
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode =
let hasAfter = conv.after.content.len > 0
@@ -70,6 +75,20 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
if not conv.replies.beginning:
renderNewer(Query(), getLink(conv.tweet), focus="#r")
if conv.replies.content.len > 0 or conv.replies.bottom.len > 0:
renderReplies(conv.replies, prefs, path)
renderReplies(conv.replies, prefs, path, conv.tweet)
renderToTop(focus="#m")
proc renderEditHistory*(edits: EditHistory; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="edit-history")):
tdiv(class="latest-edit"):
tdiv(class="edit-history-header"):
text "Latest post"
renderTweet(edits.latest, prefs, path)
tdiv(class="previous-edits"):
tdiv(class="edit-history-header"):
text "Version history"
for tweet in edits.history:
tdiv(class="tweet-edit"):
renderTweet(tweet, prefs, path)

View File

@@ -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,10 +79,10 @@ 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), showThread=show)
index=i, last=(i == thread.high), bigThumb=bigThumb)
proc renderUser(user: User; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline-item")):
buildHtml(tdiv(class="timeline-item", data-username=user.username)):
a(class="tweet-link", href=("/" & user.username))
tdiv(class="tweet-body profile-result"):
tdiv(class="tweet-header"):
@@ -66,6 +92,7 @@ proc renderUser(user: User; prefs: Prefs): VNode =
tdiv(class="tweet-name-row"):
tdiv(class="fullname-and-username"):
linkUser(user, class="fullname")
verifiedIcon(user)
linkUser(user, class="username")
tdiv(class="tweet-content media-body", dir="auto"):
@@ -87,15 +114,28 @@ 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)
if not prefs.hidePins and pinned.isSome:
let tweet = get pinned
renderTweet(tweet, prefs, path, showThread=tweet.hasThread)
renderTweet(tweet, prefs, path)
if results.content.len == 0:
if not results.beginning:
@@ -103,26 +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
var hasThread = tweet.hasThread
if retweetId != 0 and tweet.retweet.isSome:
retweets &= retweetId
hasThread = get(tweet.retweet).hasThread
renderTweet(tweet, prefs, path, showThread=hasThread)
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()

View File

@@ -31,28 +31,28 @@ proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VN
tdiv(class="tweet-name-row"):
tdiv(class="fullname-and-username"):
linkUser(tweet.user, class="fullname")
verifiedIcon(tweet.user)
linkUser(tweet.user, class="username")
span(class="tweet-date"):
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
small = if named: photo else: photo & smallWebp
a(href=getOrigPicUrl(photo), class="still-image", target="_blank"):
genImg(small)
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
@@ -62,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:
@@ -78,51 +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")
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")):
@@ -207,19 +254,28 @@ proc renderMediaTags(tags: seq[User]): VNode =
if i < tags.high:
text ", "
proc renderLatestPost(username: string; id: int64): VNode =
buildHtml(tdiv(class="latest-post-version")):
text "There's a new version of this post. "
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:
return buildHtml(tdiv(class="quote unavailable")):
tdiv(class="unavailable-quote"):
a(class="unavailable-quote", href=getLink(quote, focus=false)):
if quote.tombstone.len > 0:
text quote.tombstone
elif quote.text.len > 0:
@@ -234,6 +290,7 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
tdiv(class="fullname-and-username"):
renderMiniAvatar(quote.user, prefs)
linkUser(quote.user, class="fullname")
verifiedIcon(quote.user)
linkUser(quote.user, class="username")
span(class="tweet-date"):
@@ -247,12 +304,28 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
tdiv(class="quote-text", dir="auto"):
verbatim replaceUrls(quote.text, prefs)
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"
if quote.photos.len > 0 or quote.video.isSome or quote.gif.isSome:
renderQuoteMedia(quote, prefs, path)
if quote.history.len > 0 and quote.id != max(quote.history):
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()
@@ -266,14 +339,14 @@ proc renderLocation*(tweet: Tweet): string =
return $node
proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
last=false; showThread=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
if not tweet.available:
return buildHtml(tdiv(class=divClass & "unavailable timeline-item")):
tdiv(class="unavailable-box"):
return buildHtml(tdiv(class=divClass & "unavailable timeline-item", data-username=tweet.user.username)):
a(class="unavailable-box", href=getLink(tweet)):
if tweet.tombstone.len > 0:
text tweet.tombstone
elif tweet.text.len > 0:
@@ -294,7 +367,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
tweet = tweet.retweet.get
retweet = fullTweet.user.fullname
buildHtml(tdiv(class=("timeline-item " & divClass))):
buildHtml(tdiv(class=("timeline-item " & divClass), data-username=tweet.user.username)):
if not mainTweet:
a(class="tweet-link", href=getLink(tweet))
@@ -302,7 +375,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
renderHeader(tweet, retweet, pinned, prefs)
if not afterTweet and index == 0 and tweet.reply.len > 0 and
(tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username):
(tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username or pinned):
renderReply(tweet)
var tweetClass = "tweet-content media-body"
@@ -318,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())
@@ -331,8 +400,26 @@ 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)
if mainTweet:
p(class="tweet-published"): text &"{getTime(tweet)}"
p(class="tweet-published"):
if hasEdits and isLatest:
a(href=(getLink(tweet, focus=false) & "/history")):
text &"Last edited {getTime(tweet)}"
else:
text &"{getTime(tweet)}"
if hasEdits and not isLatest:
renderLatestPost(tweet.user.username, max(tweet.history))
if tweet.mediaTags.len > 0:
renderMediaTags(tweet.mediaTags)
@@ -340,10 +427,6 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
if not prefs.hideTweetStats:
renderStats(tweet.stats)
if showThread:
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
text "Show this thread"
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req)

View File

@@ -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

File diff suppressed because it is too large Load Diff

2
tests/poetry.toml Normal file
View File

@@ -0,0 +1,2 @@
[virtualenvs]
in-project = true

8
tests/pyproject.toml Normal file
View 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"

View File

@@ -1 +1 @@
seleniumbase
seleniumbase==4.46.5

View File

@@ -11,12 +11,7 @@ card = [
['voidtarget/status/1094632512926605312',
'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too)',
'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim',
'gist.github.com', True],
['nim_lang/status/1082989146040340480',
'Nim in 2018: A short recap',
'There were several big news in the Nim world in 2018 two new major releases, partnership with Status, and much more. But let us go chronologically.',
'nim-lang.org', True]
'gist.github.com', True]
]
no_thumb = [

View File

@@ -15,7 +15,19 @@ protected = [
['Poop', 'Randy', 'Social media fanatic.']
]
invalid = [['thisprofiledoesntexist'], ['%']]
invalid = [['thisprofiledoesntexist']]
malformed = [
['${userId}'],
['$%7BuserId%7D'], # URL encoded version
['%'], # Percent sign is invalid
['user@name'],
['user.name'],
['user-name'],
['user$name'],
['user{name}'],
['user name'], # space
]
banner_image = [
['mobile_test', 'profile_banners%2F82135242%2F1384108037%2F1500x500']
@@ -65,6 +77,13 @@ class ProfileTest(BaseTestCase):
self.open_nitter(username)
self.assert_text(f'User "{username}" not found')
@parameterized.expand(malformed)
def test_malformed_username(self, username):
"""Test that malformed usernames (with invalid characters) return 404"""
self.open_nitter(username)
# Malformed usernames should return 404 page not found, not try to fetch from Twitter
self.assert_text('Page not found')
def test_suspended(self):
self.open_nitter('suspendme')
self.assert_text('User "suspendme" has been suspended')

View File

@@ -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"]],
],
]

View File

@@ -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)

View File

@@ -20,73 +20,112 @@ Output:
{"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."}
"""
import sys
import json
import asyncio
import pyotp
import nodriver as uc
import json
import os
import sys
import nodriver as uc
import pyotp
async def login_and_get_cookies(username, password, totp_seed=None, headless=False):
"""Authenticate with X.com and extract session cookies"""
# Note: headless mode may increase detection risk from bot-detection systems
browser = await uc.start(headless=headless)
tab = await browser.get('https://x.com/i/flow/login')
tab = await browser.get("https://x.com/i/flow/login")
try:
# Enter username
print('[*] Entering username...', file=sys.stderr)
username_input = await tab.find('input[autocomplete="username"]', timeout=10)
await username_input.send_keys(username + '\n')
await asyncio.sleep(1)
print(f"[*] Entering username {username}...", file=sys.stderr)
retry = 0
while retry < 5:
username_input = await tab.find(
'input[autocomplete="username"]', timeout=10
)
pos = await username_input.get_position()
await tab.mouse_move(pos.x, pos.y, steps=50, flash=True)
await asyncio.sleep(0.1)
await username_input.click()
await asyncio.sleep(0.5)
await username_input.send_keys(username)
await asyncio.sleep(0.2)
await username_input.send_keys("\n")
await asyncio.sleep(2)
page_content = await tab.get_content()
if "Could not log you in" in page_content:
retry += 1
wait = retry * 10
print(f"Retrying in {wait} seconds...")
await asyncio.sleep(wait)
else:
break
# Enter password
print('[*] Entering password...', file=sys.stderr)
password_input = await tab.find('input[autocomplete="current-password"]', timeout=15)
await password_input.send_keys(password + '\n')
await asyncio.sleep(2)
print("[*] Entering password...", file=sys.stderr)
pretry = 0
while pretry < 5:
password_input = await tab.find(
'input[autocomplete="current-password"]', timeout=15
)
await password_input.click()
await asyncio.sleep(0.5)
await password_input.send_keys(password)
await asyncio.sleep(0.2)
await password_input.send_keys("\n")
await asyncio.sleep(2)
page_content = await tab.get_content()
if "Could not log you in" in page_content:
pretry += 1
wait = pretry * 10
print(f"Retrying in {wait} seconds...")
await asyncio.sleep(wait)
else:
break
# Handle 2FA if needed
page_content = await tab.get_content()
if 'verification code' in page_content or 'Enter code' in page_content:
if "verification code" in page_content or "Enter code" in page_content:
if not totp_seed:
raise Exception('2FA required but no TOTP seed provided')
raise Exception("2FA required but no TOTP seed provided")
print('[*] 2FA detected, entering code...', file=sys.stderr)
print("[*] 2FA detected, entering code...", file=sys.stderr)
totp_code = pyotp.TOTP(totp_seed).now()
code_input = await tab.select('input[type="text"]')
await code_input.send_keys(totp_code + '\n')
await code_input.send_keys(totp_code + "\n")
await asyncio.sleep(3)
# Get cookies
print('[*] Retrieving cookies...', file=sys.stderr)
print("[*] Retrieving cookies...", file=sys.stderr)
for _ in range(20): # 20 second timeout
cookies = await browser.cookies.get_all()
cookies_dict = {cookie.name: cookie.value for cookie in cookies}
if 'auth_token' in cookies_dict and 'ct0' in cookies_dict:
print('[*] Found both cookies', file=sys.stderr)
if "auth_token" in cookies_dict and "ct0" in cookies_dict:
# Extract ID from twid cookie (may be URL-encoded)
user_id = None
if 'twid' in cookies_dict:
twid = cookies_dict['twid']
if "twid" in cookies_dict:
twid = cookies_dict["twid"]
# Try to extract the ID from twid (format: u%3D<id> or u=<id>)
if 'u%3D' in twid:
user_id = twid.split('u%3D')[1].split('&')[0].strip('"')
elif 'u=' in twid:
user_id = twid.split('u=')[1].split('&')[0].strip('"')
if "u%3D" in twid:
user_id = twid.split("u%3D")[1].split("&")[0].strip('"')
elif "u=" in twid:
user_id = twid.split("u=")[1].split("&")[0].strip('"')
cookies_dict['username'] = username
cookies_dict["username"] = username
if user_id:
cookies_dict['id'] = user_id
cookies_dict["id"] = user_id
return cookies_dict
await asyncio.sleep(1)
raise Exception('Timeout waiting for cookies')
raise Exception("Timeout waiting for cookies")
finally:
browser.stop()
@@ -94,7 +133,9 @@ async def login_and_get_cookies(username, password, totp_seed=None, headless=Fal
async def main():
if len(sys.argv) < 3:
print('Usage: python3 twitter-auth.py username password [totp_seed] [--append sessions.jsonl] [--headless]')
print(
"Usage: python3 create_session_browser.py username password [totp_seed] [--append file.jsonl] [--headless]"
)
sys.exit(1)
username = sys.argv[1]
@@ -107,49 +148,49 @@ async def main():
i = 3
while i < len(sys.argv):
arg = sys.argv[i]
if arg == '--append':
if arg == "--append":
if i + 1 < len(sys.argv):
append_file = sys.argv[i + 1]
i += 2 # Skip '--append' and filename
else:
print('[!] Error: --append requires a filename', file=sys.stderr)
print("[!] Error: --append requires a filename", file=sys.stderr)
sys.exit(1)
elif arg == '--headless':
elif arg == "--headless":
headless = True
i += 1
elif not arg.startswith('--'):
if totp_seed is None:
elif not arg.startswith("--"):
if totp_seed is None:
totp_seed = arg
i += 1
else:
# Unkown args
print(f'[!] Warning: Unknown argument: {arg}', file=sys.stderr)
print(f"[!] Warning: Unknown argument: {arg}", file=sys.stderr)
i += 1
try:
cookies = await login_and_get_cookies(username, password, totp_seed, headless)
session = {
'kind': 'cookie',
'username': cookies['username'],
'id': cookies.get('id'),
'auth_token': cookies['auth_token'],
'ct0': cookies['ct0']
"kind": "cookie",
"username": cookies["username"],
"id": cookies.get("id"),
"auth_token": cookies["auth_token"],
"ct0": cookies["ct0"],
}
output = json.dumps(session)
if append_file:
with open(append_file, 'a') as f:
f.write(output + '\n')
print(f'✓ Session appended to {append_file}', file=sys.stderr)
with open(append_file, "a") as f:
f.write(output + "\n")
print(f"✓ Session appended to {append_file}", file=sys.stderr)
else:
print(output)
os._exit(0)
except Exception as error:
print(f'[!] Error: {error}', file=sys.stderr)
print(f"[!] Error: {error}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""
Requirements:
pip install -r tools/requirements.txt
Usage:
python3 tools/create_sessions_browser.py <accounts_file> [--append sessions.jsonl] [--headless] [--delay]
Examples:
# Output to terminal
python3 tools/create_sessions_browser.py <accounts_file>
# Append to sessions.jsonl
python3 tools/create_sessions_browser.py <accounts_file> --append sessions.jsonl
# Add 5 second delay between sessions (default: 1)
python3 tools/create_sessions_browser.py <accounts_file> --delay 5
# Headless mode (may increase detection risk)
python3 tools/create_sessions_browser.py <accounts_file> --headless
Input (accounts_file):
[{"username": "user", "password": "pass", "totp": "totp_code"}, {...}, ...]
Output:
{"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."}
{"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."}
...
"""
import asyncio
import json
import sys
from time import sleep
import nodriver as uc
import pyotp
async def login_and_get_cookies(account, headless=False):
"""Authenticate with X.com and extract session cookies"""
# Note: headless mode may increase detection risk from bot-detection systems
browser = await uc.start(headless=headless)
tab = await browser.get("https://x.com/i/flow/login")
username = account["username"]
password = account["password"]
totp_seed = account["totp"]
try:
# Enter username
print(f"[*] Entering username {username}...", file=sys.stderr)
retry = 0
while retry < 5:
username_input = await tab.find(
'input[autocomplete="username"]', timeout=10
)
pos = await username_input.get_position()
await tab.mouse_move(pos.x, pos.y, steps=50, flash=True)
await asyncio.sleep(0.1)
await username_input.click()
await asyncio.sleep(0.5)
await username_input.send_keys(username)
await asyncio.sleep(0.2)
await username_input.send_keys("\n")
await asyncio.sleep(2)
page_content = await tab.get_content()
if "Could not log you in" in page_content:
retry += 1
wait = retry * 10
print(f"Retrying in {wait} seconds...")
await asyncio.sleep(wait)
else:
break
# Enter password
print("[*] Entering password...", file=sys.stderr)
pretry = 0
while pretry < 5:
password_input = await tab.find(
'input[autocomplete="current-password"]', timeout=15
)
await password_input.click()
await asyncio.sleep(0.5)
await password_input.send_keys(password)
await asyncio.sleep(0.2)
await password_input.send_keys("\n")
await asyncio.sleep(2)
page_content = await tab.get_content()
if "Could not log you in" in page_content:
pretry += 1
wait = pretry * 10
print(f"Retrying in {wait} seconds...")
await asyncio.sleep(wait)
else:
break
# Handle 2FA if needed
page_content = await tab.get_content()
if "verification code" in page_content or "Enter code" in page_content:
if not totp_seed:
raise Exception("2FA required but no TOTP seed provided")
print("[*] 2FA detected, entering code...", file=sys.stderr)
totp_code = pyotp.TOTP(totp_seed).now()
code_input = await tab.select('input[type="text"]')
await code_input.send_keys(totp_code + "\n")
await asyncio.sleep(3)
# Get cookies
print("[*] Retrieving cookies...", file=sys.stderr)
for _ in range(20): # 20 second timeout
cookies = await browser.cookies.get_all()
cookies_dict = {cookie.name: cookie.value for cookie in cookies}
if "auth_token" in cookies_dict and "ct0" in cookies_dict:
# Extract ID from twid cookie (may be URL-encoded)
user_id = None
if "twid" in cookies_dict:
twid = cookies_dict["twid"]
# Try to extract the ID from twid (format: u%3D<id> or u=<id>)
if "u%3D" in twid:
user_id = twid.split("u%3D")[1].split("&")[0].strip('"')
elif "u=" in twid:
user_id = twid.split("u=")[1].split("&")[0].strip('"')
cookies_dict["username"] = username
if user_id:
cookies_dict["id"] = user_id
return cookies_dict
await asyncio.sleep(1)
raise Exception("Timeout waiting for cookies")
finally:
browser.stop()
async def main():
if len(sys.argv) < 2:
print(
"Usage: python3 create_sessions_browser.py <accounts_file> [--append sessions.jsonl] [--headless]"
)
sys.exit(1)
input = sys.argv[1]
append_file = None
headless = False
delay = 1
# Parse optional arguments
i = 2
while i < len(sys.argv):
arg = sys.argv[i]
if arg == "--append":
if i + 1 < len(sys.argv):
append_file = sys.argv[i + 1]
i += 2 # Skip '--append' and filename
else:
print("[!] Error: --append requires a filename", file=sys.stderr)
sys.exit(1)
elif arg == "--headless":
headless = True
i += 1
elif arg == "--delay":
delay = int(sys.argv[i + 1])
i += 2
else:
# Unkown args
print(f"[!] Warning: Unknown argument: {arg}", file=sys.stderr)
i += 1
accounts = []
with open(input) as f:
accounts = json.load(f)
if len(accounts) == 0:
print("no accounts in file")
sys.exit(0)
sessions = 0
for acc in accounts:
sessions += 1
try:
cookies = await login_and_get_cookies(acc, headless)
session = {
"kind": "cookie",
"username": cookies["username"],
"id": cookies.get("id"),
"auth_token": cookies["auth_token"],
"ct0": cookies["ct0"],
}
if append_file:
with open(append_file, "a") as f:
f.write(json.dumps(session) + "\n")
else:
print(json.dumps(session))
print(f"Progress: {sessions} / {len(accounts)}")
if sessions < len(accounts):
print("Waiting", delay, "seconds")
sleep(delay)
except Exception as error:
print(
f"[!] Error getting session for {acc["username"]}, skipping: {error}",
file=sys.stderr,
)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -21,7 +21,10 @@ def auth(username, password, otp_secret):
guest_token = requests.post(
"https://api.twitter.com/1.1/guest/activate.json",
headers={'Authorization': bearer_token}
headers={
'Authorization': bearer_token,
"User-Agent": "TwitterAndroid/10.21.0-release.0 (310210000-r-0) ONEPLUS+A3010/9"
}
).json().get('guest_token')
if not guest_token: