Compare commits

...
Author SHA1 Message Date
Zed
e4aefbc210 Unhide donation methods in readme 2026-08-26 18:18:52 +07:00
Zed
f75bf5872e
Add legal note 2026-08-26 03:33:30 +07:00
Zed
8142bab1a9 Add Articles tab to profile pages
Fixes #1426
2026-08-24 23:06:48 +07:00
Zed
a68b57629a Skip TweetResultByRestId tests on CI (CF block) 2026-08-22 20:28:40 +07:00
Zed
d8c1c98329 Use UserTweetsAndReplies for cookie sessions
Fixes #1425
2026-08-22 20:10:35 +07:00
Zed
f9b8e11665 Improve CI suite stability 2026-08-22 08:46:46 +07:00
Zed
11d4eb9e39 Deduplicate UserMedia requests for proxy caching
Fixes #1440
2026-08-22 08:33:33 +07:00
Zed
0bc9702854 Detect and handle Cloudflare error responses
Fixes #1434
2026-08-19 07:12:57 +07:00
Zed
2ccebaf22a Skip id-redirect tests on CI (Cloudflare block) 2026-08-19 05:41:13 +07:00
Zed
28b55a5017 Fix CF block 2026-08-19 02:39:25 +07:00
Zed
5858809a08 Refresh remaining stale GraphQL endpoint hashes 2026-08-17 12:56:24 +07:00
Zed
62aa2f2823 Parse user counts/bio from modern GraphQL shape 2026-08-17 09:05:53 +07:00
Zed
fcaab5079e Refresh stale web GraphQL endpoint hashes 2026-08-17 07:30:23 +07:00
Zed
0ac9bc2678 Replace restricted UserResultByIdQuery endpoint
Fixes #1433
2026-08-17 06:30:18 +07:00
Zed
206622ea38 Add session validation tool 2026-08-17 05:45:33 +07:00
Zed
607765c463 Rewrite session scripts for new login flow
Fixes #1421
2026-08-17 05:45:33 +07:00
Zed
f74b2282be Set CI hostname to localhost for oEmbed tests 2026-08-15 00:35:48 +07:00
Zed
06e59f0615 Fix oEmbed CI: accept any host in URL parsing 2026-08-15 00:23:53 +07:00
Zed
7f6c083dce Add oEmbed discovery and standard support
Fixes #696
2026-08-15 00:00:10 +07:00
Zed
989b1f5f64 Fix endless test reruns 2026-08-13 22:43:59 +07:00
Zed
756beab8a6 Clean up embedResize.js 2026-08-13 02:37:51 +07:00
Zed
1d61f8cf07 Respect instance proxyVideos config in video embeds 2026-08-13 02:01:59 +07:00
Zed
5fa4eefb9d Improve video embed overlay and proxy behavior 2026-08-12 01:36:40 +07:00
Zed
b76e3c3913 Cap embed iframe height at 10000px 2026-08-10 22:51:17 +07:00
Zed
67f620c83c Improve video and error embeds 2026-08-10 22:51:12 +07:00
Zed
ca19250a62 Remove embedTweet.js, use <base target="_blank"> instead 2026-08-10 20:27:38 +07:00
Zed
fe8f482d5c Add video-only embed support and clean up widgets.js 2026-08-10 20:17:14 +07:00
Zed
5733297f92 Enhance widgets.js Twitter replacement 2026-08-09 02:11:50 +07:00
Zed
a00c6cc11d Simplify embed hover CSS with wrapper element 2026-08-09 00:58:35 +07:00
Zed
530437167e Improve flaky tests 2026-08-09 00:15:58 +07:00
Zed
a39e17abbe Add embed test suite 2026-08-08 23:09:39 +07:00
Zed
3bfbb7b3fd Clean up embed JS and add Tweet.html compatibility 2026-08-08 23:00:47 +07:00
Zed
dc66bd6375 Fix embed click-through with CSS-only approach 2026-08-08 23:00:36 +07:00
Zed
ccde22aa0b Use TweetResultByRestId for embeds 2026-08-08 23:00:19 +07:00
Zed
6d41157603 Fix JS concatenation in tweet embeds
Fixes #586
2026-07-19 22:30:52 +02:00
Zed
9be0b8f826 Add tweet embed and oEmbed API support 2026-07-18 12:46:43 +02:00
Zed
06c52473ab Escape HTML in mention and URL display text 2026-07-18 12:46:30 +02:00
Zed
3bc78801b3 Increase test reruns and timeout for proxy media 2026-07-11 07:54:00 +02:00
Zed
5100290f76 Speed up pytest with eager page loads 2026-07-11 04:37:13 +02:00
Zed
092397cd6f Show related tweets under replies
Fixes #1334
2026-07-11 00:11:46 +02:00
Zed
8394b044ef Qualify QueryKind.media to fix Nim 2.0.x build
Fixes #1422
2026-07-09 11:32:31 +02:00
Zed
61246df9de Support Top, Latest, Media, and Lists search
Fixes #1335
2026-07-08 18:56:12 +02:00
Zed
0375145ea2 Fix crash when TID pair fetch fails 2026-07-08 01:55:25 +02:00
Zed
6b86aaa27f Triple pytest timeout multiplier for cold loads 2026-07-08 00:11:52 +02:00
Zed
2f07fa6151 Sort replies by relevance, recency, or likes
Fixes #1336
2026-07-08 00:11:52 +02:00
Zed
7f7092fbf5 Support captions in articles
Fixes #1418
2026-07-01 00:53:16 +02:00
Zed
4b3ad16cc8
Revert "Exclude git from docker context (#1412)" (#1417)
This reverts commit 446f06f9d6.
2026-06-26 22:42:02 +07:00
Zed
279fa09771 Add video download button
Fixes #263
2026-06-21 20:37:29 +02:00
Zed
0344b99b69 Fix mp4 overflow issue
Fixes #1416
2026-06-21 15:49:34 +02:00
Zed
3e290eab6a Render article cards and polls in RSS feed
Fixes #679
2026-06-21 09:53:09 +02:00
Zed
c136e47748 Add link preview text to RSS feed
Fixes #679
2026-06-21 09:53:09 +02:00
ふぁ
446f06f9d6
Exclude git from docker context (#1412)
* Exclude .git from Docker build context

* Add last line
2026-06-21 06:41:04 +02:00
Eric Park
d0c351403d
Update Docker Compose configuration file (#1244)
According to the official documentation, Docker Compose files are now
supposed to be named `compose.yml`:
https://docs.docker.com/compose/intro/compose-application-model/

The `version:` line has been deprecated:
https://docs.docker.com/reference/compose-file/version-and-name/#version-top-level-element-obsolete
2026-06-21 06:40:00 +02:00
Zed
55a331cc50 Add followers and following pages
Fixes #187
2026-06-21 06:12:24 +02:00
Zed
1e03595784 Follow same-host redirects in media proxy
Fixes #1415
2026-06-21 03:22:29 +02:00
Zed
9d16db8903 Fix video test flakiness from HLS cookie leakage
Clear hlsPlayback cookie before video_m3u8 tests to ensure consistent
state regardless of test execution order. The Space tests set this
cookie which can persist and break video tests when they run after.
2026-06-20 18:37:07 +02:00
Zed
327e0161f1 Update thread test again 2026-06-20 18:14:12 +02:00
Zed
57d831f82a Update thread test 2026-06-20 17:51:47 +02:00
Zed
5a2df5df2e Skip x-ratelimit headers when served from cache 2026-06-20 12:55:32 +02:00
Zed
1639315699 Bump hlsPlayback.js 2026-06-19 22:19:47 +02:00
Zed
96a850c963 Add Space tests and fix media test selectors 2026-06-19 22:02:24 +02:00
Zed
db5a229bfe Add Twitter Spaces support
Fixes #459
2026-06-19 22:02:24 +02:00
Zed
17ad0fec34 Add audio playback support to HLS.js handler 2026-06-19 22:00:31 +02:00
Zed
581c6f1714 Add retry logic and timeout to media proxy 2026-06-19 22:00:28 +02:00
Zed
bfcf75af7f Fix invalid/empty image hrefs
Fixes #1413
2026-06-18 15:42:17 +02:00
Zed
44b2f096f6 Fix SSRF in /video proxy and API JSON injection
Validate the target host on the /video media route with
isTwitterUrl() (mirroring /pic) and reject non-http(s) schemes,
and stop the media proxy following redirects off the validated
host. JSON-escape user-controlled GraphQL cursors and build id
variables with packedjson so untrusted input can't break out of
the query. Warn on startup when the insecure default hmacKey is
in use.

Fixes #1411
2026-06-18 15:01:48 +02:00
Zed
7b27c2c629 Add support for Communities
Fixes #1270
2026-06-18 13:32:20 +02:00
Zed
35882ed88d Add Twitter Article rendering
Fixes #646
2026-06-16 15:47:13 +02:00
Zed
bd9d492d36 Fix bounds checking
Fixes #1410
2026-06-11 23:39:48 +02:00
Zed
7eed720894 Fix entity expansion crash, simplify URL handling
Fixes #1409
2026-06-10 16:07:13 +02:00
Zed
ac5ba9469e Fix incorrectly shown Twitter video cards
Fixes #1407
2026-06-10 14:28:01 +02:00
Zed
63891a04ff Update tests 2026-06-10 06:20:08 +02:00
Zed
ac2f93b361 Add skipTid field for cookie session TID handling 2026-06-10 06:20:05 +02:00
Zed
1e33ca045d Fix restIdVars whitespace handling 2026-06-10 06:20:01 +02:00
Zed
5a4c8dd12c Improve retry failure handling
Fixes #1388
2026-06-08 23:59:50 +02:00
Zed
fb9107cff6 Fix crash on malformed request paths 2026-06-08 06:14:18 +02:00
Zed
60cb10229f Fix SameSite cookie handling for HTTP 2026-06-08 06:12:58 +02:00
Zed
ef1de42593 Update dependencies to version tags 2026-06-08 06:12:52 +02:00
Zed
9b9c86a15c Use Nimble local deps to fix deployment issue
Fixes #1405
2026-06-08 01:08:19 +02:00
Zed
55d067957c Fix arm32 compilation
Fixes #1389
2026-06-07 23:17:43 +02:00
Zed
a8bc1bbb2d Fix nimble dependency caching in CI 2026-06-07 20:35:16 +02:00
Zed
f629507537 Publish a single multi-arch Docker image
Build amd64 and arm64 natively (no emulation), push each by
digest, then merge them into one multi-arch manifest so
`zedeus/nitter:latest` and `:<sha>` resolve to the right image
on any CPU. Replaces the separate `latest-arm64` tag, which is
no longer needed. Update the README notes accordingly.
2026-06-07 01:24:03 +02:00
Zed
40d17bf042 Use Nim 2.2.6 for Docker builds, unify arm64
nimlang/nim's alpine-regular images cap at 2.2.6 and are now
multi-arch, while Alpine's apk nim is stuck at the segfaulting
2.2.0. Base both arches on 2.2.6-alpine-regular, drop the
separate Dockerfile.arm64, and build ./Dockerfile in the arm64
CI job.

Fixes #1404
2026-06-07 00:43:29 +02:00
Zed
6ab2143df0 Add note about creating nitter.conf for Docker
Fixes #1392
2026-06-07 00:14:43 +02:00
Zed
a86be15f85 Fix broken 2.2.x build 2026-06-06 00:14:28 +02:00
Zed
c956f7c373 Add /<acc>/about tests 2026-06-05 23:54:39 +02:00
Zed
e4e6dd13e6 Update API endpoints 2026-06-05 22:26:46 +02:00
Zed
083d65a8cf Update tests 2026-06-05 22:26:41 +02:00
Zed
1d57f1f432 Add video attribution link support 2026-06-05 22:26:38 +02:00
Zed
d5ff410c5d Add same-origin referrer policy
Fixes #1346
2026-06-02 23:59:00 +02:00
Ian Brown
5a4faa0367
Fix OpenSearch response crash (#1400) 2026-06-02 22:31:40 +02:00
Zed
82099de55b Include session.kind in all debug output
Fixes #1330
2026-06-02 17:21:20 +02:00
Zed
74f5ff8acc Fix thread test 2026-04-16 02:46:43 +02:00
Zed
4e38317582 Fix verified type enum parsing error
Fixes #1387
2026-04-16 02:31:52 +02:00
Zed
8114eefa19 Add support for broadcasts
Fixes #303
2026-03-31 07:28:45 +02:00
Zed
7d431781c3 Increase CI reruns 2026-03-30 01:42:34 +02:00
Zed
0c7583432c Increase CI test maxRetries 2026-03-30 01:20:33 +02:00
Zed
e7e7050c6e Add support for viewing account info
Fixes #1381
2026-03-30 00:58:02 +02:00
Zed
741060c78b Increase maxRetries in CI conf 2026-03-21 20:28:41 +01:00
Zed
3429667414 Fix null legacy tweet crash
Fixes #1383
2026-03-21 18:59:05 +01:00
Zed
fea6f59005 Fix mobile gallery and grid, add size preference
Fixes #1379
2026-03-21 11:29:42 +01:00
Zed
b6ccea0c7a Add configurable retry logic
Fixes #1382
2026-03-21 08:30:07 +01:00
Zed
b726767df4 Bump css 2026-03-21 06:55:55 +01:00
Zed
33bf2c2397 Support tweet content disclosures (AI and ads)
Fixes #1374
2026-03-21 06:52:51 +01:00
Zed
7ce29bd8f1 Add new media grid and gallery views
Fixes #199
Fixes #1342
2026-03-15 09:31:55 +01:00
Zed
91ff936cb3 Add workaround for broken "until" search filter
Fixes #1372
2026-03-14 04:04:18 +01:00
Zed
0fefcf9917 Update gif class in tests 2026-03-13 06:19:09 +01:00
Zed
35a929c415 Implement mixed-media tweet support
Fixes #697 #1101
2026-03-13 05:47:37 +01:00
Zed
4bf3df94f8 Fix segfault 2026-03-04 17:27:15 +01:00
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
129 changed files with 11832 additions and 2525 deletions

View file

@ -7,57 +7,105 @@ on:
branches: branches:
- master - master
concurrency:
group: docker-publish-${{ github.ref }}
cancel-in-progress: true
env:
IMAGE: zedeus/nitter
jobs: jobs:
tests: tests:
uses: ./.github/workflows/run-tests.yml uses: ./.github/workflows/run-tests.yml
secrets: inherit secrets: inherit
build-docker-amd64:
build:
needs: [tests] needs: [tests]
runs-on: buildjet-2vcpu-ubuntu-2204 strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
platform: linux/amd64
- runner: ubuntu-24.04-arm
platform: linux/arm64
runs-on: ${{ matrix.runner }}
steps: steps:
- uses: actions/checkout@v3 - name: Prepare platform name
with: run: echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
fetch-depth: 0 env:
platform: ${{ matrix.platform }}
- uses: actions/checkout@v6
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v2
with: with:
version: latest version: latest
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push AMD64 Docker image
uses: docker/build-push-action@v3 - name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64 platforms: ${{ matrix.platform }}
push: true outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }} provenance: false
build-docker-arm64: sbom: false
needs: [tests]
runs-on: buildjet-2vcpu-ubuntu-2204-arm - name: Export digest
steps: run: |
- uses: actions/checkout@v3 mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with: with:
fetch-depth: 0 name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
# Combine the per-arch digests into one multi-arch manifest so that
# `docker pull zedeus/nitter:latest` serves the right image on any CPU.
merge:
needs: [build]
runs-on: ubuntu-24.04
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v2
with: with:
version: latest version: latest
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push ARM64 Docker image
uses: docker/build-push-action@v3 - name: Create manifest list and push
with: working-directory: ${{ runner.temp }}/digests
context: . run: |
file: ./Dockerfile.arm64 docker buildx imagetools create \
platforms: linux/arm64 -t ${{ env.IMAGE }}:latest \
push: true -t ${{ env.IMAGE }}:latest-arm64 \
tags: zedeus/nitter:latest-arm64,zedeus/nitter:${{ github.sha }}-arm64 -t ${{ env.IMAGE }}:${{ github.sha }} \
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
- name: Inspect image
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ github.sha }}

View file

@ -20,24 +20,24 @@ defaults:
jobs: jobs:
build-test: build-test:
name: Build and test name: Build and test
runs-on: buildjet-2vcpu-ubuntu-2204 runs-on: ubuntu-24.04
strategy: strategy:
matrix: matrix:
nim: ["2.0.x", "2.2.x", "devel"] nim: ["2.0.x", "2.2.x", "devel"]
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Cache Nimble Dependencies - name: Cache Nimble Dependencies
id: cache-nimble id: cache-nimble
uses: buildjet/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.nimble path: |
key: ${{ matrix.nim }}-nimble-v2-${{ hashFiles('*.nimble') }} ~/.nimble/pkgcache
~/.nimble/packages_official.json
key: ${{ matrix.nim }}-nimble-v6-${{ hashFiles('*.nimble') }}
restore-keys: | restore-keys: |
${{ matrix.nim }}-nimble-v2- ${{ matrix.nim }}-nimble-v6-
- name: Setup Nim - name: Setup Nim
uses: jiro4989/setup-nim-action@v2 uses: jiro4989/setup-nim-action@v2
@ -47,62 +47,106 @@ jobs:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build Project - 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: integration-test:
needs: [build-test] needs: [build-test]
name: Integration test name: Integration test
runs-on: buildjet-2vcpu-ubuntu-2204 runs-on: ubuntu-24.04
timeout-minutes: 30
services:
redis:
image: redis:7
ports:
- 6379:6379
steps: steps:
- name: Install runtime deps
run: |
sudo apt-get install -y --no-install-recommends libsass-dev libpcre3
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Cache pipx (poetry)
uses: actions/cache@v5
with: 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 - name: Cache Nimble Dependencies
id: cache-nimble uses: actions/cache@v5
uses: buildjet/cache@v4
with: with:
path: ~/.nimble path: |
key: devel-nimble-v2-${{ hashFiles('*.nimble') }} ~/.nimble/pkgcache
~/.nimble/packages_official.json
key: 2.2.x-nimble-v6-${{ hashFiles('*.nimble') }}
restore-keys: | restore-keys: |
devel-nimble-v2- 2.2.x-nimble-v6-
- name: Setup Python (3.10) with pip cache
uses: buildjet/setup-python@v4
with:
python-version: "3.10"
cache: pip
- name: Setup Nim - name: Setup Nim
uses: jiro4989/setup-nim-action@v2 uses: jiro4989/setup-nim-action@v2
with: with:
nim-version: devel nim-version: 2.2.x
use-nightlies: true use-nightlies: true
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build Project - name: Install Nimble dependencies
run: nimble build -d:release -Y run: nimble install -y --depsOnly
- name: Install SeleniumBase and Chromedriver - name: Download 2.2.x build artifact
run: | uses: actions/download-artifact@v4
pip install seleniumbase with:
seleniumbase install chromedriver name: nitter-linux-nim-2.2.x-${{ github.sha }}
path: .
- name: Start Redis Service - name: Make nitter binary executable
uses: supercharge/redis-github-action@1.5.0 run: chmod +x ./nitter
- name: Prepare Nitter Environment - name: Prepare Nitter Environment
run: | run: |
sudo apt-get update && sudo apt-get install -y libsass-dev
cp nitter.example.conf nitter.conf cp nitter.example.conf nitter.conf
sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf
nimble md sed -i 's/maxRetries = 1/maxRetries = 3/g' nitter.conf
nimble scss sed -i 's/hostname = "nitter.net"/hostname = "localhost:8080"/g' nitter.conf
nim r tools/rendermd.nim
nim r tools/gencss.nim
echo '${{ secrets.SESSIONS }}' | head -n1 echo '${{ secrets.SESSIONS }}' | head -n1
echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl
- name: Run Tests - name: Run Tests
run: | run: |
./nitter & ./nitter &
pytest -n1 tests cd tests
poetry run pytest -n2 --rs .

5
.gitignore vendored
View file

@ -13,3 +13,8 @@ nitter.conf
guest_accounts.json* guest_accounts.json*
sessions.json* sessions.json*
dump.rdb dump.rdb
*.bak
/tools/*.json*
nimbledeps/
nimble.paths
nimble.develop

View file

@ -1,4 +1,4 @@
FROM nimlang/nim:2.2.0-alpine-regular as nim FROM nimlang/nim:2.2.6-alpine-regular as nim
LABEL maintainer="setenforce@protonmail.com" LABEL maintainer="setenforce@protonmail.com"
RUN apk --no-cache add libsass-dev pcre RUN apk --no-cache add libsass-dev pcre
@ -15,7 +15,7 @@ RUN nimble build -d:danger -d:lto -d:strip --mm:refc \
FROM alpine:latest FROM alpine:latest
WORKDIR /src/ WORKDIR /src/
RUN apk --no-cache add pcre ca-certificates RUN apk --no-cache add pcre ca-certificates openssl
COPY --from=nim /src/nitter/nitter ./ COPY --from=nim /src/nitter/nitter ./
COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf
COPY --from=nim /src/nitter/public ./public COPY --from=nim /src/nitter/public ./public

View file

@ -1,25 +0,0 @@
FROM alpine:3.20.6 as nim
LABEL maintainer="setenforce@protonmail.com"
RUN apk --no-cache add libsass-dev pcre gcc git libc-dev nim nimble
WORKDIR /src/nitter
COPY nitter.nimble .
RUN nimble install -y --depsOnly
COPY . .
RUN nimble build -d:danger -d:lto -d:strip --mm:refc \
&& nimble scss \
&& nimble md
FROM alpine:3.20.6
WORKDIR /src/
RUN apk --no-cache add pcre ca-certificates openssl
COPY --from=nim /src/nitter/nitter ./
COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf
COPY --from=nim /src/nitter/public ./public
EXPOSE 8080
RUN adduser -h /src/ -D -s /bin/sh nitter
USER nitter
CMD ./nitter

View file

@ -1,18 +1,26 @@
# Nitter # Nitter
[![Test Matrix](https://github.com/zedeus/nitter/workflows/Tests/badge.svg)](https://github.com/zedeus/nitter/actions/workflows/run-tests.yml)
[![Test Matrix](https://github.com/zedeus/nitter/workflows/Docker/badge.svg)](https://github.com/zedeus/nitter/actions/workflows/build-docker.yml)
[![License](https://img.shields.io/github/license/zedeus/nitter?style=flat)](#license)
> [!NOTE] > [!NOTE]
> Running a Nitter instance now requires real accounts, since Twitter removed the previous methods. \ > On 24 August 2026 cease and desist letters were sent by X Corp. demanding a permanent takedown of Nitter instances and the project's repository.
> This does not affect users. \
> For instructions on how to obtain session tokens, see [Creating session tokens](https://github.com/zedeus/nitter/wiki/Creating-session-tokens).
A free and open source alternative Twitter front-end focused on privacy and A free and open source alternative Twitter front-end focused on privacy and
performance. \ performance. \
Inspired by the [Invidious](https://github.com/iv-org/invidious) project. Inspired by the [Invidious](https://github.com/iv-org/invidious) project.
## Donations
**Liberapay**: https://liberapay.com/zedeus<br>
**Patreon**: https://patreon.com/nitter<br>
**Ko-fi**: https://ko-fi.com/zedeus<br>
**BTC**: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55<br>
**ETH**: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460<br>
**XMR**: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL<br>
**SOL**: FF5bheiD5AqPEdc3eyjymJ8AoMRF1hS78Ht6FiSZZF1t<br>
**$Nitter**: 4fSxCKc91ELQYVdv3tmHW8R15KoALPwEngyoQe1Xpump<br>
**ZEC**: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt
## Features
- No JavaScript or ads - No JavaScript or ads
- All requests go through the backend, client never talks to Twitter - All requests go through the backend, client never talks to Twitter
- Prevents Twitter from tracking your IP or JavaScript fingerprint - Prevents Twitter from tracking your IP or JavaScript fingerprint
@ -23,17 +31,6 @@ Inspired by the [Invidious](https://github.com/iv-org/invidious) project.
- Mobile support (responsive design) - Mobile support (responsive design)
- AGPLv3 licensed, no proprietary instances permitted - AGPLv3 licensed, no proprietary instances permitted
<details>
<summary>Donations</summary>
Liberapay: https://liberapay.com/zedeus<br>
Patreon: https://patreon.com/nitter<br>
BTC: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55<br>
ETH: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460<br>
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL<br>
SOL: ANsyGNXFo6osuFwr1YnUqif2RdoYRhc27WdyQNmmETSW<br>
ZEC: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt
</details>
## Roadmap ## Roadmap
- Embeds - Embeds
@ -104,9 +101,9 @@ along with the scss and md files.
# su nitter # su nitter
$ git clone https://github.com/zedeus/nitter $ git clone https://github.com/zedeus/nitter
$ cd nitter $ cd nitter
$ nimble build -d:danger --mm:refc $ nimble -l build -d:danger --mm:refc
$ nimble scss $ nimble -l scss
$ nimble md $ nimble -l md
$ cp nitter.example.conf nitter.conf $ cp nitter.example.conf nitter.conf
``` ```
@ -123,12 +120,23 @@ performance reasons.
Page for the Docker image: https://hub.docker.com/r/zedeus/nitter Page for the Docker image: https://hub.docker.com/r/zedeus/nitter
#### NOTE: For ARM64 support, please use the separate ARM64 docker image: [`zedeus/nitter:latest-arm64`](https://hub.docker.com/r/zedeus/nitter/tags). #### NOTE: The published image is multi-arch — `zedeus/nitter:latest` runs natively on both `amd64` and `arm64`.
To run Nitter with Docker, you'll need to install and run Redis separately To run Nitter with Docker, you'll need to install and run Redis separately
before you can run the container. See below for how to also run Redis using before you can run the container. See below for how to also run Redis using
Docker. Docker.
First create your config file. The Docker commands mount it into the container,
so it has to exist on the host beforehand. If you've cloned the repo:
```bash
cp nitter.example.conf nitter.conf
```
If you're using the prebuilt image without a local clone, download
[`nitter.example.conf`](https://raw.githubusercontent.com/zedeus/nitter/master/nitter.example.conf)
and save it as `nitter.conf` instead.
To build and run Nitter in Docker: To build and run Nitter in Docker:
```bash ```bash
@ -136,8 +144,6 @@ docker build -t nitter:latest .
docker run -v $(pwd)/nitter.conf:/src/nitter.conf -d --network host nitter:latest docker run -v $(pwd)/nitter.conf:/src/nitter.conf -d --network host nitter:latest
``` ```
Note: For ARM64, use this Dockerfile: [`Dockerfile.arm64`](https://github.com/zedeus/nitter/blob/master/Dockerfile.arm64).
A prebuilt Docker image is provided as well: A prebuilt Docker image is provided as well:
```bash ```bash
@ -151,8 +157,11 @@ Change `redisHost` from `localhost` to `nitter-redis` in `nitter.conf`, then run
docker-compose up -d docker-compose up -d
``` ```
Note the Docker commands expect a `nitter.conf` file in the directory you run Note the Docker commands mount `nitter.conf` (and `sessions.jsonl` for
them. docker-compose) from the directory you run them in. If a mounted file doesn't
exist, Docker silently creates a directory in its place and the container fails
with `not a directory: Are you trying to mount a directory onto a file`. Remove
that directory and create the file as shown above.
### systemd ### systemd
@ -197,3 +206,5 @@ lines). If you're running the Docker image, you can do this:
Feel free to join our [Matrix channel](https://matrix.to/#/#nitter:matrix.org). Feel free to join our [Matrix channel](https://matrix.to/#/#nitter:matrix.org).
You can email me at zedeus@pm.me if you wish to contact me personally. You can email me at zedeus@pm.me if you wish to contact me personally.
For legal inquiries, contact legal@poast.org

View file

@ -1,5 +1,3 @@
version: "3"
services: services:
nitter: nitter:

View file

@ -11,3 +11,7 @@ warning("HoleEnumConv", off)
hint("XDeclaredButNotUsed", off) hint("XDeclaredButNotUsed", off)
hint("XCannotRaiseY", off) hint("XCannotRaiseY", off)
hint("User", off) hint("User", off)
# begin Nimble config (version 2)
when withDir(thisDir(), system.fileExists("nimble.paths")):
include "nimble.paths"
# end Nimble config

View file

@ -1,31 +1,42 @@
[Server] [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" title = "nitter"
address = "0.0.0.0" address = "0.0.0.0"
port = 8080 port = 8080
https = false # disable to enable cookies when not using https https = false # disable to enable cookies when not using https
httpMaxConnections = 100 httpMaxConnections = 100
staticDir = "./public" staticDir = "./public"
[Cache] [Cache]
listMinutes = 240 # how long to cache list info (not the tweets, so keep it high) listMinutes = 240 # how long to cache list info (not the tweets, so keep it high)
rssMinutes = 10 # how long to cache rss queries rssMinutes = 10 # how long to cache rss queries
redisHost = "localhost" # Change to "nitter-redis" if using docker-compose redisHost = "localhost" # Change to "nitter-redis" if using docker-compose
redisPort = 6379 redisPort = 6379
redisPassword = "" redisPassword = ""
redisConnections = 20 # minimum open connections in pool redisConnections = 20 # minimum open connections in pool
redisMaxConnections = 30 redisMaxConnections = 30
# new connections are opened when none are available, but if the pool size # 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 # goes above this, they're closed when released. don't worry about this unless
# you receive tons of requests per second # you receive tons of requests per second
[Config] [Config]
hmacKey = "secretkey" # random key for cryptographic signing of video urls hmacKey = "secretkey" # CHANGE THIS to a unique random value (e.g. `openssl rand -hex 32`); signs media urls
base64Media = false # use base64 encoding for proxied media urls base64Media = false # use base64 encoding for proxied media urls
enableRSS = true # set this to false to disable RSS feeds enableRSS = true # master switch, set to false to disable all RSS feeds
enableDebug = false # enable request logs and debug endpoints (/.sessions) enableRSSUserTweets = true # /@user/rss
proxy = "" # http/https url, SOCKS proxies are not supported enableRSSUserReplies = true # /@user/with_replies/rss
enableRSSUserMedia = true # /@user/media/rss
enableRSSUserArticles = true # /@user/articles/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 = "" 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 # Change default preferences here, see src/prefs_impl.nim for a complete list
[Preferences] [Preferences]

View file

@ -11,24 +11,23 @@ bin = @["nitter"]
# Dependencies # Dependencies
requires "nim >= 2.0.0" requires "nim >= 2.0.0"
requires "jester#baca3f" requires "jester == 0.6.0"
requires "karax#5cf360c" requires "karax == 1.5.0"
requires "sass#7dfdd03" requires "sass == 0.2.0"
requires "nimcrypto#a079df9" requires "nimcrypto == 0.7.3"
requires "markdown#158efe3" requires "markdown == 0.8.8"
requires "packedjson#9e6fbb6" requires "packedjson#9e6fbb6"
requires "supersnappy#6c94198" requires "supersnappy == 2.1.4"
requires "redpool#8b7c1db" requires "redpool == 0.2.2"
requires "https://github.com/zedeus/redis#d0a0e6f" requires "zippy == 0.10.19"
requires "zippy#ca5989a" requires "flatty == 0.4.0"
requires "flatty#e668085" requires "jsony == 1.1.6"
requires "jsony#1de1f08" requires "oauth == 0.11"
requires "oauth#b8c163b"
# Tasks # Tasks
task scss, "Generate css": 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": 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,153 @@
@font-face { @font-face {
font-family: 'fontello'; font-family: "fontello";
src: url('/fonts/fontello.eot?61663884'); src: url("/fonts/fontello.eot?59696369");
src: url('/fonts/fontello.eot?61663884#iefix') format('embedded-opentype'), src:
url('/fonts/fontello.woff2?61663884') format('woff2'), url("/fonts/fontello.eot?59696369#iefix") format("embedded-opentype"),
url('/fonts/fontello.woff?61663884') format('woff'), url("/fonts/fontello.woff2?59696369") format("woff2"),
url('/fonts/fontello.ttf?61663884') format('truetype'), url("/fonts/fontello.woff?59696369") format("woff"),
url('/fonts/fontello.svg?61663884#fontello') format('svg'); url("/fonts/fontello.ttf?59696369") format("truetype"),
url("/fonts/fontello.svg?59696369#fontello") format("svg");
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
[class^="icon-"]:before, [class*=" icon-"]:before {
[class^="icon-"]:before,
[class*=" icon-"]:before {
font-family: "fontello"; font-family: "fontello";
font-style: normal; font-style: normal;
font-weight: normal; font-weight: normal;
speak: never; speak: never;
display: inline-block; display: inline-block;
text-decoration: inherit; text-decoration: inherit;
width: 1em; width: 1em;
margin-right: 0.2em;
text-align: center; text-align: center;
/* For safety - reset parent styles, that can break glyph codes*/ /* For safety - reset parent styles, that can break glyph codes*/
font-variant: normal; font-variant: normal;
text-transform: none; text-transform: none;
/* fix buttons height, for twitter bootstrap */ /* fix buttons height, for twitter bootstrap */
line-height: 1em; line-height: 1em;
/* Font smoothing. That was taken from TWBS */ /* Font smoothing. That was taken from TWBS */
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
.icon-views:before { content: '\e800'; } /* '' */ .icon-group:before {
.icon-heart:before { content: '\e801'; } /* '' */ content: "\e0c3";
.icon-quote:before { content: '\e802'; } /* '' */ }
.icon-comment:before { content: '\e803'; } /* '' */
.icon-ok:before { content: '\e804'; } /* '' */ /* '' */
.icon-play:before { content: '\e805'; } /* '' */ .icon-views:before {
.icon-link:before { content: '\e806'; } /* '' */ content: "\e800";
.icon-calendar:before { content: '\e807'; } /* '' */ }
.icon-location:before { content: '\e808'; } /* '' */
.icon-picture:before { content: '\e809'; } /* '' */ /* '' */
.icon-lock:before { content: '\e80a'; } /* '' */ .icon-heart:before {
.icon-down:before { content: '\e80b'; } /* '' */ content: "\e801";
.icon-retweet:before { content: '\e80c'; } /* '' */ }
.icon-search:before { content: '\e80d'; } /* '' */
.icon-pin:before { content: '\e80e'; } /* '' */ /* '' */
.icon-cog:before { content: '\e80f'; } /* '' */ .icon-quote:before {
.icon-rss:before { content: '\e810'; } /* '' */ content: "\e802";
.icon-info:before { content: '\f128'; } /* '' */ }
.icon-bird:before { content: '\f309'; } /* '' */
/* '' */
.icon-comment:before {
content: "\e803";
}
/* '' */
.icon-play:before {
content: "\e805";
}
/* '' */
.icon-link:before {
content: "\e806";
}
/* '' */
.icon-calendar:before {
content: "\e807";
}
/* '' */
.icon-location:before {
content: "\e808";
}
/* '' */
.icon-picture:before {
content: "\e809";
}
/* '' */
.icon-lock:before {
content: "\e80a";
}
/* '' */
.icon-down:before {
content: "\e80b";
}
/* '' */
.icon-retweet:before {
content: "\e80c";
}
/* '' */
.icon-search:before {
content: "\e80d";
}
/* '' */
.icon-pin:before {
content: "\e80e";
}
/* '' */
.icon-cog:before {
content: "\e80f";
}
/* '' */
.icon-rss:before {
content: "\e810";
}
/* '' */
.icon-ok:before {
content: "\e811";
}
/* '' */
.icon-attention-circled:before {
content: "\e812";
}
/* '' */
.icon-download-alt:before {
content: "\e813";
}
/* '' */
.icon-circle:before {
content: "\f111";
}
/* '' */
.icon-info:before {
content: "\f128";
}
/* '' */
.icon-bird:before {
content: "\f309";
}
/* '' */

Binary file not shown.

View file

@ -1,11 +1,13 @@
<?xml version="1.0" standalone="no"?> <?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"> <!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"> <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> <defs>
<font id="fontello" horiz-adv-x="1000" > <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" /> <font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
<missing-glyph horiz-adv-x="1000" /> <missing-glyph horiz-adv-x="1000" />
<glyph glyph-name="group" unicode="&#xe0c3;" 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="views" unicode="&#xe800;" d="M180 516l0-538-180 0 0 538 180 0z m250-138l0-400-180 0 0 400 180 0z m250 344l0-744-180 0 0 744 180 0z" horiz-adv-x="680" /> <glyph glyph-name="views" unicode="&#xe800;" d="M180 516l0-538-180 0 0 538 180 0z m250-138l0-400-180 0 0 400 180 0z m250 344l0-744-180 0 0 744 180 0z" horiz-adv-x="680" />
<glyph glyph-name="heart" unicode="&#xe801;" d="M790 644q70-64 70-156t-70-158l-360-330-360 330q-70 66-70 158t70 156q62 58 151 58t153-58l56-52 58 52q62 58 150 58t152-58z" horiz-adv-x="860" /> <glyph glyph-name="heart" unicode="&#xe801;" d="M790 644q70-64 70-156t-70-158l-360-330-360 330q-70 66-70 158t70 156q62 58 151 58t153-58l56-52 58 52q62 58 150 58t152-58z" horiz-adv-x="860" />
@ -14,8 +16,6 @@
<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="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="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" /> <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" />
<glyph glyph-name="link" unicode="&#xe806;" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" /> <glyph glyph-name="link" unicode="&#xe806;" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" />
@ -40,6 +40,14 @@
<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="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="download-alt" unicode="&#xe813;" d="M0-150l0 135 1000 0 0-135-1000 0z m88 586l228 0 0 414 370 0 0-414 228 0-414-385z" horiz-adv-x="1000" />
<glyph glyph-name="circle" unicode="&#xf111;" d="M857 350q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" />
<glyph glyph-name="info" unicode="&#xf128;" d="M393 149v-134q0-9-7-15t-15-7h-134q-9 0-16 7t-7 15v134q0 9 7 16t16 6h134q9 0 15-6t7-16z m176 335q0-30-8-56t-20-43-31-33-32-25-34-19q-23-13-38-37t-15-37q0-10-7-18t-16-9h-134q-8 0-14 11t-6 20v26q0 46 37 87t79 60q33 16 47 32t14 42q0 24-26 41t-60 18q-36 0-60-16-20-14-60-64-7-9-17-9-7 0-14 4l-91 70q-8 6-9 14t3 16q89 148 259 148 45 0 90-17t81-46 59-72 23-88z" horiz-adv-x="571.4" /> <glyph glyph-name="info" unicode="&#xf128;" d="M393 149v-134q0-9-7-15t-15-7h-134q-9 0-16 7t-7 15v134q0 9 7 16t16 6h134q9 0 15-6t7-16z m176 335q0-30-8-56t-20-43-31-33-32-25-34-19q-23-13-38-37t-15-37q0-10-7-18t-16-9h-134q-8 0-14 11t-6 20v26q0 46 37 87t79 60q33 16 47 32t14 42q0 24-26 41t-60 18q-36 0-60-16-20-14-60-64-7-9-17-9-7 0-14 4l-91 70q-8 6-9 14t3 16q89 148 259 148 45 0 90-17t81-46 59-72 23-88z" horiz-adv-x="571.4" />
<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" /> <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.4 KiB

Before After
Before After

Binary file not shown.

Binary file not shown.

Binary file not shown.

34
public/js/embedResize.js Normal file
View file

@ -0,0 +1,34 @@
(function () {
var embed = document.querySelector(".embed-wrapper, .embed-video");
if (!embed) return;
var video = embed.querySelector("video");
if (video) {
video.onplay = function () {
embed.classList.add("video-playing");
};
video.onpause = video.onended = function () {
embed.classList.remove("video-playing");
};
}
var lastHeight = 0;
function sendHeight() {
var h = embed.offsetHeight;
if (h !== lastHeight && h > 0) {
lastHeight = h;
window.parent.postMessage(["resizeIframe", { h: h }], "*");
}
}
// MessageChannel height request (used by oEmbed)
window.addEventListener("message", function (e) {
if (e.source === window.parent && e.ports && e.ports[0]) {
e.ports[0].postMessage(embed.offsetHeight);
}
});
window.addEventListener("load", sendHeight);
new ResizeObserver(sendHeight).observe(embed);
})();

View file

@ -1,25 +1,30 @@
// @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0 // @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
function playVideo(overlay) { function playMedia(overlay, tagName) {
const video = overlay.parentElement.querySelector('video'); const media = overlay.parentElement.querySelector(tagName);
const url = video.getAttribute("data-url"); const url = media.getAttribute("data-url");
video.setAttribute("controls", ""); const startTime = parseFloat(media.getAttribute("data-start") || "0");
media.setAttribute("controls", "");
overlay.style.display = "none"; overlay.style.display = "none";
if (Hls.isSupported()) { if (Hls.isSupported()) {
var hls = new Hls({autoStartLoad: false}); var hls = new Hls({autoStartLoad: false});
hls.loadSource(url); hls.loadSource(url);
hls.attachMedia(video); hls.attachMedia(media);
hls.on(Hls.Events.MANIFEST_PARSED, function () { hls.on(Hls.Events.MANIFEST_PARSED, function () {
hls.loadLevel = hls.levels.length - 1; hls.loadLevel = hls.levels.length - 1;
hls.startLoad(); hls.startLoad(startTime);
video.play(); media.play();
}); });
} else if (video.canPlayType('application/vnd.apple.mpegurl')) { } else if (media.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url; media.src = url;
video.addEventListener('canplay', function() { media.addEventListener('canplay', function() {
video.play(); if (startTime > 0) media.currentTime = startTime;
media.play();
}); });
} }
} }
function playVideo(overlay) { playMedia(overlay, 'video'); }
function playAudio(overlay) { playMedia(overlay, 'audio'); }
// @license-end // @license-end

View file

@ -1,77 +1,225 @@
// @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0 // @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
function insertBeforeLast(node, elem) { function insertBeforeLast(node, elem) {
node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]); node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]);
} }
function getLoadMore(doc) { function getLoadMore(doc) {
return doc.querySelector(".show-more:not(.timeline-item)"); return doc.querySelector(".show-more:not(.timeline-item)");
} }
function isDuplicate(item, itemClass) { function getHrefs(selector) {
const tweet = item.querySelector(".tweet-link"); return new Set([...document.querySelectorAll(selector)].map(el => el.getAttribute("href")));
if (tweet == null) return false;
const href = tweet.getAttribute("href");
return document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != null;
} }
window.onload = function () { function getTweetId(item) {
const url = window.location.pathname; const m = item.querySelector(".tweet-link")?.getAttribute("href")?.match(/\/status\/(\d+)/);
const isTweet = url.indexOf("/status/") !== -1; return m ? m[1] : "";
const containerClass = isTweet ? ".replies" : ".timeline"; }
const itemClass = containerClass + " > div:not(.top-ref)";
var html = document.querySelector("html"); function isDuplicate(item, hrefs) {
var container = document.querySelector(containerClass); return hrefs.has(item.querySelector(".tweet-link")?.getAttribute("href"));
var loading = false; }
function handleScroll(failed) { const GAP = 10;
if (loading) return;
if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) { class Masonry {
loading = true; constructor(container) {
var loadMore = getLoadMore(document); this.container = container;
if (loadMore == null) return; const colSizes = {
small: w => Math.max(130, w * 0.11),
medium: w => Math.max(190, Math.min(350, w * 0.22)),
large: w => Math.max(350, Math.min(480, w * 0.22)),
};
const size = container.dataset.colSize || "medium";
this._targetWidth = colSizes[size] || colSizes.medium;
this.colHeights = [];
this.colCounts = [];
this.colCount = 0;
this._lastWidth = 0;
this._colWidthCache = 0;
this._items = [];
this._revealTimer = null;
this.container.classList.add("masonry-active");
loadMore.children[0].text = "Loading..."; let resizeTimer;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => this._rebuild(), 50);
});
var url = new URL(loadMore.children[0].href); // Re-sync positions whenever images finish loading and items grow taller.
url.searchParams.append("scroll", "true"); // Must be set up before _rebuild() so initial items get observed on first pass.
let syncTimer;
this._observer = window.ResizeObserver ? new ResizeObserver(() => {
clearTimeout(syncTimer);
syncTimer = setTimeout(() => this.syncHeights(), 100);
}) : null;
fetch(url.toString()).then(function (response) { this._rebuild();
if (response.status === 404) throw "error"; }
return response.text(); // Reveal all items and gallery siblings (show-more, top-ref). Idempotent.
}).then(function (html) { _revealAll() {
var parser = new DOMParser(); clearTimeout(this._revealTimer);
var doc = parser.parseFromString(html, "text/html"); for (const item of this._items) item.classList.add("masonry-visible");
loadMore.remove(); 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)) { // Height-primary, count-as-tiebreaker: handles both tall tweets and unloaded images.
if (item.className == "timeline-item show-more") continue; _pickCol() {
if (isDuplicate(item, itemClass)) continue; return this.colHeights.reduce((min, h, i) => {
if (isTweet) container.appendChild(item); const m = this.colHeights[min];
else insertBeforeLast(container, item); return (h < m || (h === m && this.colCounts[i] < this.colCounts[min])) ? i : min;
} }, 0);
}
loading = false; // Position items using current column state. Updates colHeights, colCounts, container height.
const newLoadMore = getLoadMore(doc); _position(items, heights, colWidth) {
if (newLoadMore == null) return; for (let i = 0; i < items.length; i++) {
if (isTweet) container.appendChild(newLoadMore); const col = this._pickCol();
else insertBeforeLast(container, newLoadMore); items[i].style.left = `${col * (colWidth + GAP)}px`;
}).catch(function (err) { items[i].style.top = `${this.colHeights[col]}px`;
console.warn("Something went wrong.", err); this.colHeights[col] += heights[i] + GAP;
if (failed > 3) { this.colCounts[col]++;
loadMore.children[0].text = "Error"; }
return; this.container.style.height = `${Math.max(0, ...this.colHeights)}px`;
} }
loading = false; // Full reset and re-place all items.
handleScroll((failed || 0) + 1); _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 // @license-end

221
public/js/widgets.js Normal file
View file

@ -0,0 +1,221 @@
/**
* Drop-in replacement for Twitter's widgets.js
* Redirects twitter-tweet blockquotes to Nitter embeds
*/
(function () {
"use strict";
if (window.__nitterWidgets) return;
window.__nitterWidgets = true;
var NITTER = new URL(document.currentScript.src).origin;
var TWEET_RE = /(?:twitter\.com|x\.com)\/([^\/]+)\/status\/(\d+)/i;
var SELECTOR = "blockquote.twitter-tweet, blockquote.twitter-video";
var readyCallbacks = [];
var eventCallbacks = {};
var isReady = false;
function safeCall(fn, arg) {
try {
fn(arg);
} catch (e) { }
}
function fireEvent(name, data) {
(eventCallbacks[name] || []).forEach(function (cb) {
safeCall(cb, data);
});
}
function parseTweetUrl(url) {
if (!url) return null;
var m = TWEET_RE.exec(url);
if (m) return { user: m[1], id: m[2] };
m = url.match(/(\d{15,})/);
return m ? { user: null, id: m[1] } : null;
}
function createIframe(tweet, opts) {
var url;
if (opts.videoOnly) {
url = NITTER + "/i/videos/tweet/" + tweet.id;
} else {
var path = tweet.user ? "/" + tweet.user : "/i";
url = NITTER + path + "/status/" + tweet.id + "/embed";
if (opts.theme) {
var theme =
opts.theme === "dark"
? "nitter"
: opts.theme === "light"
? "twitter"
: opts.theme;
url += "?theme=" + encodeURIComponent(theme);
}
}
var iframe = document.createElement("iframe");
iframe.src = url;
iframe.className = "nitter-embed-frame";
iframe.loading = "lazy";
iframe.setAttribute("allowtransparency", "true");
iframe.setAttribute("frameborder", "0");
iframe.setAttribute("scrolling", "no");
if (opts.videoOnly) iframe.setAttribute("allowfullscreen", "true");
var width = opts.width || 550;
var margin =
opts.align === "center"
? "10px auto"
: opts.align === "right"
? "10px 0 10px auto"
: "10px 0";
iframe.style.cssText =
"width:100%;max-width:" +
width +
"px;height:300px;" +
"border:none;display:block;margin:" +
margin;
iframe.addEventListener("load", function () {
fireEvent("rendered", { target: iframe });
});
return iframe;
}
function processBlockquote(bq) {
if (bq.dataset.nitterProcessed) return false;
bq.dataset.nitterProcessed = "true";
var tweet = null;
var links = bq.querySelectorAll("a[href]");
for (var i = 0; i < links.length && !tweet; i++) {
tweet = parseTweetUrl(links[i].href);
}
if (!tweet) return false;
var d = bq.dataset;
var iframe = createIframe(tweet, {
width: d.mediaMaxWidth || d.width,
align: d.align,
theme: d.theme,
videoOnly: d.mediaMaxWidth !== undefined,
});
bq.style.display = "none";
bq.parentNode.insertBefore(iframe, bq.nextSibling);
return true;
}
function processEmbeds(root) {
var bqs = (root || document).querySelectorAll(
SELECTOR + ":not([data-nitter-processed])",
);
for (var i = 0; i < bqs.length; i++) processBlockquote(bqs[i]);
}
function handleResize(e) {
if (!Array.isArray(e.data) || e.data[0] !== "resizeIframe") return;
var h = e.data[1] && e.data[1].h;
if (!h || h <= 0 || h > 10000) return; // Cap at 10000px for sanity
var frames = document.querySelectorAll("iframe.nitter-embed-frame");
for (var i = 0; i < frames.length; i++) {
if (frames[i].contentWindow === e.source) {
frames[i].style.height = h + "px";
return;
}
}
}
function observeDOM() {
if (!window.MutationObserver || !document.body) return;
function matches(el) {
return el.matches(SELECTOR) || el.querySelector(SELECTOR);
}
new MutationObserver(function (muts) {
var found = muts.some(function (mut) {
return Array.prototype.some.call(mut.addedNodes, function (n) {
return n.nodeType === 1 && matches(n);
});
});
if (found) processEmbeds();
}).observe(document.body, { childList: true, subtree: true });
}
function embedTweet(id, container, opts) {
if (!container) return Promise.reject("No container");
var iframe = createIframe({ id: id, user: null }, opts || {});
container.appendChild(iframe);
return Promise.resolve(iframe);
}
var prevTwttr = window.twttr;
window.twttr = {
widgets: {
load: processEmbeds,
createTweet: embedTweet,
createTweetEmbed: embedTweet,
createVideo: embedTweet,
loaded: true,
},
events: {
bind: function (name, cb) {
if (typeof cb !== "function") return;
if (!eventCallbacks[name]) eventCallbacks[name] = [];
eventCallbacks[name].push(cb);
},
unbind: function (name, cb) {
if (!eventCallbacks[name]) return;
eventCallbacks[name] = cb
? eventCallbacks[name].filter(function (f) {
return f !== cb;
})
: [];
},
},
ready: function (cb) {
if (typeof cb !== "function") return;
if (isReady) cb(window.twttr);
else readyCallbacks.push(cb);
},
_e: [],
};
// Process callbacks queued before load (twttr._e pattern)
if (prevTwttr && prevTwttr._e) {
prevTwttr._e.forEach(function (cb) {
safeCall(cb);
});
}
// Remove any Twitter scripts that snuck through
document
.querySelectorAll(
'script[src*="platform.twitter.com"], script[src*="platform.x.com"]',
)
.forEach(function (s) {
s.remove();
});
function init() {
window.addEventListener("message", handleResize);
processEmbeds();
observeDOM();
isReady = true;
readyCallbacks.forEach(function (cb) {
safeCall(cb, window.twttr);
});
readyCallbacks = [];
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

View file

@ -1,8 +1,8 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, httpclient, uri, strutils, sequtils, sugar, tables import asyncdispatch, httpclient, strutils, sequtils, sugar
import packedjson import packedjson
import types, query, formatters, consts, apiutils, parser import types, query, formatters, consts, apiutils, parser, utils
import experimental/parser as newParser import experimental/parser
# Helper to generate params object for GraphQL requests # Helper to generate params object for GraphQL requests
proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] = proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
@ -11,88 +11,188 @@ proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
if fieldToggles.len > 0: if fieldToggles.len > 0:
result.add ("fieldToggles", fieldToggles) result.add ("fieldToggles", fieldToggles)
proc mediaUrl(id: string; cursor: string): SessionAwareUrl = proc apiUrl(endpoint, variables: string; fieldToggles = ""; skipTid = false): ApiUrl =
let return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles), skipTid: skipTid)
cookieVariables = userMediaVariables % [id, cursor]
oauthVariables = restIdVariables % [id, cursor] proc apiReq(endpoint, variables: string; fieldToggles = ""; skipTid = false): ApiReq =
result = SessionAwareUrl( let url = apiUrl(endpoint, variables, fieldToggles, skipTid)
cookieUrl: graphUserMedia ? genParams(cookieVariables), return ApiReq(cookie: url, oauth: url)
oauthUrl: graphUserMediaV2 ? genParams(oauthVariables)
proc cursorParam(after: string): string =
## JSON-escape the user-supplied cursor so it cannot break out of the GraphQL
## variables object (same input-validation class as the #1411 media SSRF).
if after.len > 0: "\"cursor\":" & $(%after) & "," else: ""
proc mediaUrl(id, cursor: string): ApiReq =
result = ApiReq(
cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor, "100"]),
oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor, "100"])
) )
proc userTweetsUrl(id: string; cursor: string): SessionAwareUrl = proc userTweetsUrl(id: string; cursor: string): ApiReq =
let return apiReq(graphUserTweetsV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles)
cookieVariables = userTweetsVariables % [id, cursor]
oauthVariables = restIdVariables % [id, cursor]
result = SessionAwareUrl(
# cookieUrl: graphUserTweets ? genParams(cookieVariables, fieldToggles),
oauthUrl: graphUserTweetsV2 ? genParams(oauthVariables)
)
# might change this in the future pending testing
result.cookieUrl = result.oauthUrl
proc userTweetsAndRepliesUrl(id: string; cursor: string): SessionAwareUrl = proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
let result = ApiReq(
cookieVariables = userTweetsAndRepliesVariables % [id, cursor] cookie: apiUrl(graphUserTweetsAndReplies, userTweetsAndRepliesVars % [id, cursor], userTweetsFieldToggles),
oauthVariables = restIdVariables % [id, cursor] oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles, skipTid=true)
result = SessionAwareUrl(
cookieUrl: graphUserTweetsAndReplies ? genParams(cookieVariables, fieldToggles),
oauthUrl: graphUserTweetsAndRepliesV2 ? genParams(oauthVariables)
) )
proc tweetDetailUrl(id: string; cursor: string): SessionAwareUrl = proc userArticlesUrl(id: string; cursor: string): ApiReq =
let result = ApiReq(
cookieVariables = tweetDetailVariables % [id, cursor] cookie: apiUrl(graphUserArticles, userArticlesVars % [id, cursor], userTweetsFieldToggles),
oauthVariables = tweetVariables % [id, cursor] oauth: apiUrl(graphUserArticlesV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles)
result = SessionAwareUrl( )
cookieUrl: graphTweetDetail ? genParams(cookieVariables, tweetDetailFieldToggles),
oauthUrl: graphTweet ? genParams(oauthVariables) proc tweetDetailUrl(id, cursor: string; mode = Relevance): ApiReq =
return apiReq(graphTweet, tweetVars % [id, cursor, $mode])
# let cookieVars = tweetDetailVars % [id, cursor]
# result = ApiReq(
# cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles),
# oauth: apiUrl(graphTweet, tweetVars % [id, cursor])
# )
proc userUrl(username: string): ApiReq =
let cookieVars = $(%*{"screen_name": username, "withGrokTranslatedBio": false})
result = ApiReq(
cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles),
oauth: apiUrl(graphUserV2, $(%*{"screen_name": username}))
) )
proc getGraphUser*(username: string): Future[User] {.async.} = proc getGraphUser*(username: string): Future[User] {.async.} =
if username.len == 0: return if username.len == 0: return
let let js = await fetchRaw(userUrl(username))
url = graphUser ? genParams("""{"screen_name": "$1"}""" % username)
js = await fetchRaw(url, Api.userScreenName)
result = parseGraphUser(js) result = parseGraphUser(js)
proc getGraphUserById*(id: string): Future[User] {.async.} = proc getGraphUserById*(id: string): Future[User] {.async.} =
if id.len == 0 or id.any(c => not c.isDigit): return if id.len == 0 or id.any(c => not c.isDigit): return
let let
url = graphUserById ? genParams("""{"rest_id": "$1"}""" % id) url = apiReq(graphUserById, userByRestIdVars % id)
js = await fetchRaw(url, Api.userRestId) js = await fetchRaw(url)
result = parseGraphUser(js) result = parseGraphUser(js)
proc getAboutAccount*(username: string): Future[AccountInfo] {.async.} =
if username.len == 0: return
let
url = apiReq(graphAboutAccount, $(%*{"screenName": 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": 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 getAudioSpace*(id: string): Future[AudioSpace] {.async.} =
if id.len == 0: return
let
variables = %*{
"id": id,
"isMetatagsQuery": false,
"withReplays": true,
"withListeners": true
}
req = apiReq(graphAudioSpace, $variables)
js = await fetch(req)
result = parseAudioSpace(js)
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} = proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = cursorParam(after)
js = case kind url = case kind
of TimelineKind.tweets: of TimelineKind.tweets: userTweetsUrl(id, cursor)
await fetch(userTweetsUrl(id, cursor), Api.userTweets) of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
of TimelineKind.replies: of TimelineKind.media: mediaUrl(id, cursor)
await fetch(userTweetsAndRepliesUrl(id, cursor), Api.userTweetsAndReplies) of TimelineKind.articles: userArticlesUrl(id, cursor)
of TimelineKind.media: js = await fetch(url)
await fetch(mediaUrl(id, cursor), Api.userMedia)
result = parseGraphTimeline(js, after) result = parseGraphTimeline(js, after)
proc getGraphCommunity*(id: string): Future[Community] {.async.} =
if id.len == 0: return
let
url = apiReq(graphCommunity, $(%*{"communityId": id}))
js = await fetch(url)
result = parseGraphCommunity(js)
proc getGraphCommunityTweets*(id: string; rankingMode: string; after=""): Future[Timeline] {.async.} =
if id.len == 0: return
let
cursor = cursorParam(after)
url = apiReq(graphCommunityTweets, communityTweetsVars % [id, cursor, rankingMode])
js = await fetch(url)
result = parseGraphCommunityTimeline(js, after)
proc getGraphCommunityMedia*(id: string; after=""): Future[Timeline] {.async.} =
if id.len == 0: return
let
cursor = cursorParam(after)
url = apiReq(graphCommunityMedia, communityMediaVars % [id, cursor])
js = await fetch(url)
result = parseGraphCommunityTimeline(js, after)
proc communitySliceReq(endpoint, variables: string): ApiReq =
let url = ApiUrl(endpoint: endpoint, params: @[("variables", variables)])
ApiReq(cookie: url, oauth: url)
proc getGraphCommunityMembers*(id: string; after=""): Future[Result[User]] {.async.} =
if id.len == 0: return
let
cursor = if after.len > 0: $(%after) else: "null"
url = communitySliceReq(graphCommunityMembers, communityMembersVars % [id, cursor])
js = await fetch(url)
result = parseGraphCommunityMembers(js, after)
proc getGraphCommunityModerators*(id: string): Future[Result[User]] {.async.} =
if id.len == 0: return
let
url = communitySliceReq(graphCommunityModerators, communityMembersVars % [id, "null"])
js = await fetch(url)
result = parseGraphCommunityMembers(js)
proc getGraphCommunityHashtags*(id, hashtag: string; after=""): Future[Timeline] {.async.} =
if id.len == 0 or hashtag.len == 0: return
let
safeTag = multiReplace(hashtag, ("\"", ""), ("\\", ""))
cursor = cursorParam(after)
url = apiReq(graphCommunityHashtags, communityHashtagsVars % [id, cursor, safeTag])
js = await fetch(url)
result = parseGraphCommunityTimeline(js, after)
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = cursorParam(after)
url = graphListTweets ? genParams(restIdVariables % [id, cursor]) url = apiReq(graphListTweets, restIdVars % [id, cursor, "20"])
result = parseGraphTimeline(await fetch(url, Api.listTweets), after).tweets js = await fetch(url)
result = parseGraphTimeline(js, after).tweets
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
let let
variables = %*{"screenName": name, "listSlug": list} variables = %*{"screenName": name, "listSlug": list}
url = graphListBySlug ? genParams($variables) url = apiReq(graphListBySlug, $variables)
result = parseGraphList(await fetch(url, Api.listBySlug)) js = await fetch(url)
result = parseGraphList(js)
proc getGraphList*(id: string): Future[List] {.async.} = proc getGraphList*(id: string): Future[List] {.async.} =
let let
url = graphListById ? genParams("""{"listId": "$1"}""" % id) url = apiReq(graphListById, $(%*{"listId": id}))
result = parseGraphList(await fetch(url, Api.list)) js = await fetch(url)
result = parseGraphList(js)
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} = proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
if list.id.len == 0: return if list.id.len == 0: return
@ -106,81 +206,161 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
} }
if after.len > 0: if after.len > 0:
variables["cursor"] = % after variables["cursor"] = % after
let url = graphListMembers ? genParams($variables) let
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after) url = apiReq(graphListMembers, $variables)
js = await fetchRaw(url)
result = parseGraphListMembers(js, after)
proc getGraphUserConnections(userId: string; endpoint: string; kind: QueryKind;
after=""): Future[Result[User]] {.async.} =
if userId.len == 0: return
var variables = %*{
"userId": userId,
"count": 20,
"includePromotedContent": false,
"withGrokTranslatedBio": true
}
if after.len > 0:
variables["cursor"] = %after
let
url = apiReq(endpoint, $variables)
js = await fetchRaw(url)
result = parseGraphFollowers(js, after, kind)
proc getGraphFollowers*(userId: string; after=""): Future[Result[User]] {.async.} =
result = await getGraphUserConnections(userId, graphFollowers, followers, after)
proc getGraphFollowing*(userId: string; after=""): Future[Result[User]] {.async.} =
result = await getGraphUserConnections(userId, graphFollowing, following, after)
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
variables = """{"rest_id": "$1"}""" % id url = apiReq(graphTweetResult, $(%*{"rest_id": id}))
params = {"variables": variables, "features": gqlFeatures} js = await fetch(url)
js = await fetch(graphTweetResult ? params, Api.tweetResult)
result = parseGraphTweetResult(js) result = parseGraphTweetResult(js)
proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} = proc getTweetByRestId*(id: string): Future[Tweet] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" url = apiReq(graphTweetResultByRestId, tweetByRestIdVars % id, articleFieldToggles)
js = await fetch(tweetDetailUrl(id, cursor), Api.tweetDetail) js = await fetch(url)
result = parseTweetByRestId(js)
proc getGraphTweet(id: string; after=""; mode = Relevance): Future[Conversation] {.async.} =
if id.len == 0: return
let
cursor = cursorParam(after)
js = await fetch(tweetDetailUrl(id, cursor, mode))
result = parseGraphConversation(js, id) result = parseGraphConversation(js, id)
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} = proc getReplies*(id, after: string; mode = Relevance): Future[Result[Chain]] {.async.} =
result = (await getGraphTweet(id, after)).replies result = (await getGraphTweet(id, after, mode)).replies
result.beginning = after.len == 0 result.beginning = after.len == 0
proc getTweet*(id: string; after=""): Future[Conversation] {.async.} = proc getTweet*(id: string; after=""; mode = Relevance): Future[Conversation] {.async.} =
result = await getGraphTweet(id) result = await getGraphTweet(id, mode=mode)
if after.len > 0: if after.len > 0:
result.replies = await getReplies(id, after) result.replies = await getReplies(id, after, mode)
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.} = proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
let q = genQueryParam(query) # workaround for #1372
let maxId =
if not after.startsWith("maxid:"): ""
else: validateNumber(after[6..^1])
let q = genQueryParam(query, maxId)
if q.len == 0 or q == emptyQuery: if q.len == 0 or q == emptyQuery:
return Timeline(query: query, beginning: true) return Timeline(query: query, beginning: true)
let product =
case query.kind
of top: "Top"
# profile media feeds (RSS, multi-user timelines) must stay chronological
of media: (if query.fromUser.len == 0: "Media" else: "Latest")
else: "Latest"
var var
variables = %*{ variables = %*{
"rawQuery": q, "rawQuery": q,
"query_source": "typedQuery",
"count": 20, "count": 20,
"product": "Latest", "querySource": "typed_query",
"withDownvotePerspective": false, "product": product,
"withReactionsMetadata": false, "withGrokTranslatedBio":true,
"withReactionsPerspective": false "withQuickPromoteEligibilityTweetFields":false
} }
if after.len > 0:
if after.len > 0 and maxId.len == 0:
variables["cursor"] = % after variables["cursor"] = % after
let url = graphSearchTimeline ? genParams($variables) let
result = parseGraphSearch[Tweets](await fetch(url, Api.search), after) url = apiReq(graphSearchTimeline, $variables)
js = await fetch(url)
result = parseGraphSearch[Tweets](js, after)
result.query = query result.query = query
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} = # when no more items are available the API just returns the last page in
# full. this detects that and clears the page instead.
let prefix = min(64, min(after.len, result.bottom.len))
if prefix > 0 and maxId.len == 0 and
after[0..<prefix] == result.bottom[0..<prefix]:
result.content.setLen(0)
proc getGraphProductSearch[T](query: Query; product: string;
after=""): Future[Result[T]] {.async.} =
if query.text.len == 0: if query.text.len == 0:
return Result[User](query: query, beginning: true) return Result[T](query: query, beginning: true)
var var
variables = %*{ variables = %*{
"rawQuery": query.text, "rawQuery": query.text,
"query_source": "typedQuery",
"count": 20, "count": 20,
"product": "People", "querySource": "typed_query",
"withDownvotePerspective": false, "product": product,
"withReactionsMetadata": false, "withGrokTranslatedBio":true,
"withReactionsPerspective": false "withQuickPromoteEligibilityTweetFields":false
} }
if after.len > 0: if after.len > 0:
variables["cursor"] = % after variables["cursor"] = % after
result.beginning = false
let url = graphSearchTimeline ? genParams($variables) let
result = parseGraphSearch[User](await fetch(url, Api.search), after) url = apiReq(graphSearchTimeline, $variables)
js = await fetch(url)
result = parseGraphSearch[T](js, after)
result.query = query result.query = query
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] =
getGraphProductSearch[User](query, "People", after)
proc getGraphListSearch*(query: Query; after=""): Future[Result[ListSearchResult]] =
getGraphProductSearch[ListSearchResult](query, "Lists", after)
proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} = proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
if id.len == 0: return if id.len == 0: return
let js = await fetch(mediaUrl(id, ""), Api.userMedia) let js = await fetch(mediaUrl(id, ""))
result = parseGraphPhotoRail(js) result = parseGraphPhotoRail(js)
proc getGraphArticle*(id: string): Future[Article] {.async.} =
if id.len == 0: return
let
url = apiReq(graphTweetResultByRestId, articleVars % id, articleFieldToggles)
json = await fetchRaw(url)
result = parseGraphArticle(json)
proc getGraphTweetResults*(ids: seq[string]): Future[seq[Tweet]] {.async.} =
if ids.len == 0: return
let
idsJson = "[" & ids.mapIt("\"" & it & "\"").join(",") & "]"
url = apiReq(graphTweetResultsByRestIds, articleBatchVars % idsJson, articleFieldToggles)
js = await fetch(url)
result = parseGraphTweetResults(js)
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
let client = newAsyncHttpClient(maxRedirects=0) let client = newAsyncHttpClient(maxRedirects=0)
try: try:

View file

@ -1,16 +1,62 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import httpclient, asyncdispatch, options, strutils, uri, times, math, tables import httpclient, asyncdispatch, options, strutils, uri, times, math, tables
import jsony, packedjson, zippy, oauth1 import jsony, packedjson, zippy, oauth/oauth1
import types, auth, consts, parserutils, http_pool import types, auth, consts, parserutils, http_pool, tid
import experimental/types/common import experimental/types/common
const const
rlRemaining = "x-rate-limit-remaining" rlRemaining = "x-rate-limit-remaining"
rlReset = "x-rate-limit-reset" rlReset = "x-rate-limit-reset"
rlLimit = "x-rate-limit-limit" rlLimit = "x-rate-limit-limit"
errorsToSkip = {doesntExist, tweetNotFound, timeout, unauthorized, badRequest} npCache = "x-np-cache"
errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest}
var pool: HttpPool proc isCloudflareHtml*(body: string): bool =
## Detect Cloudflare HTML error pages returned instead of JSON
if body.len < 14 or body[0] != '<': return false
body[0 ..< 14].toLowerAscii() == "<!doctype html" and "Cloudflare" in body
proc cfTitle*(body: string): string =
## Extract <title> from Cloudflare HTML for log diagnostics
let start = body.find("<title>")
if start < 0: return "unknown"
let contentStart = start + 7
let stop = body.find("</title>", contentStart)
if stop < 0: return "unknown"
body[contentStart ..< stop].splitWhitespace().join(" ")
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 = proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
let let
@ -32,31 +78,41 @@ proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
proc getCookieHeader(authToken, ct0: string): string = proc getCookieHeader(authToken, ct0: string): string =
"auth_token=" & authToken & "; ct0=" & ct0 "auth_token=" & authToken & "; ct0=" & ct0
proc genHeaders*(session: Session, url: string): HttpHeaders = proc genHeaders*(session: Session, url: Uri, skipTid: bool): Future[HttpHeaders] {.async.} =
result = newHttpHeaders({ result = newHttpHeaders({
"connection": "keep-alive", "accept": "*/*",
"content-type": "application/json",
"x-twitter-active-user": "yes",
"x-twitter-client-language": "en",
"authority": "api.x.com",
"accept-encoding": "gzip", "accept-encoding": "gzip",
"accept-language": "en-US,en;q=0.9", "accept-language": "en-US,en;q=0.9",
"accept": "*/*", "content-type": "application/json",
"DNT": "1", "origin": "https://x.com",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
}) "x-twitter-active-user": "yes",
"x-twitter-client-language": "en",
"priority": "u=1, i"
}, titleCase=true)
case session.kind case session.kind
of SessionKind.oauth: of SessionKind.oauth:
result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret) result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret)
of SessionKind.cookie: of SessionKind.cookie:
result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF"
result["x-twitter-auth-type"] = "OAuth2Session" result["x-twitter-auth-type"] = "OAuth2Session"
result["x-csrf-token"] = session.ct0 result["x-csrf-token"] = session.ct0
result["cookie"] = getCookieHeader(session.authToken, session.ct0) result["cookie"] = getCookieHeader(session.authToken, session.ct0)
result["referer"] = "https://x.com/"
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-origin"
if disableTid or skipTid 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.} = proc getAndValidateSession*(req: ApiReq): Future[Session] {.async.} =
result = await getSession(api) result = await getSession(req)
case result.kind case result.kind
of SessionKind.oauth: of SessionKind.oauth:
if result.oauthToken.len == 0: if result.oauthToken.len == 0:
@ -73,9 +129,18 @@ template fetchImpl(result, fetchBody) {.dirty.} =
try: try:
var resp: AsyncResponse var resp: AsyncResponse
pool.use(genHeaders(session, $url)): let skipTid = case session.kind
of oauth: req.oauth.skipTid
of cookie: req.cookie.skipTid
let headers = await genHeaders(session, url, skipTid)
pool.use(headers):
template getContent = 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 result = await resp.body
getContent() getContent()
@ -84,38 +149,45 @@ template fetchImpl(result, fetchBody) {.dirty.} =
badClient = true badClient = true
raise newException(BadClientError, "Bad client") raise newException(BadClientError, "Bad client")
if resp.headers.hasKey(rlRemaining): if resp.status == $Http404 and result.len == 0:
echo "[sessions] transient 404 (empty body), retrying: ", url.path, ", session: ", session.pretty
raise rateLimitError()
let cacheStatus = resp.headers.getOrDefault(npCache)
if cacheStatus notin ["HIT", "STALE"] and resp.headers.hasKey(rlRemaining):
let let
remaining = parseInt(resp.headers[rlRemaining]) remaining = parseInt(resp.headers[rlRemaining])
reset = parseInt(resp.headers[rlReset]) reset = parseInt(resp.headers[rlReset])
limit = parseInt(resp.headers[rlLimit]) limit = parseInt(resp.headers[rlLimit])
session.setRateLimit(api, remaining, reset, limit) session.setRateLimit(req, remaining, reset, limit)
if result.len > 0: if result.len > 0:
if resp.headers.getOrDefault("content-encoding") == "gzip": if resp.headers.getOrDefault("content-encoding") == "gzip":
result = uncompress(result, dfGzip) result = uncompress(result, dfGzip)
if isCloudflareHtml(result):
echo "[cloudflare] ", resp.status, " (", cfTitle(result), "), API: ", url.path, ", session: ", session.pretty
raise rateLimitError()
if result.startsWith("{\"errors"): if result.startsWith("{\"errors"):
let errors = result.fromJson(Errors) let errors = result.fromJson(Errors)
if errors notin errorsToSkip: if errors notin errorsToSkip:
echo "Fetch error, API: ", api, ", errors: ", errors echo "Fetch error, API: ", url.path, ", errors: ", errors, ", session: ", session.pretty
if errors in {expiredToken, badToken, locked}: if errors in {expiredToken, badToken, locked}:
invalidate(session) invalidate(session)
raise rateLimitError() raise rateLimitError()
elif errors in {rateLimited}: elif errors in {rateLimited}:
# rate limit hit, resets after 24 hours # rate limit hit, resets after 24 hours
setLimited(session, api) setLimited(session, req)
raise rateLimitError() raise rateLimitError()
elif result.startsWith("429 Too Many Requests"): elif result.startsWith("429 Too Many Requests"):
echo "[sessions] 429 error, API: ", api, ", session: ", session.pretty echo "[sessions] 429 error, API: ", url.path, ", session: ", session.pretty
session.apis[api].remaining = 0
# rate limit hit, resets after the 15 minute window
raise rateLimitError() raise rateLimitError()
fetchBody fetchBody
if resp.status == $Http400: if resp.status == $Http400:
echo "ERROR 400, ", api, ": ", result echo "ERROR 400, ", url.path, ": ", result, ", session: ", session.pretty
raise newException(InternalError, $url) raise newException(InternalError, $url)
except InternalError as e: except InternalError as e:
raise e raise e
@ -130,48 +202,57 @@ template fetchImpl(result, fetchBody) {.dirty.} =
finally: finally:
release(session) release(session)
template retry(bod) = template retry(bod) {.dirty.} =
try: var session: Session
bod var retrySuccess = false
except RateLimitError: for i in 0 ..< maxRetries:
echo "[sessions] Rate limited, retrying ", api, " request..." try:
bod session = nil
bod
retrySuccess = true
break
except RateLimitError:
let api = if session.isNil: req.cookie.endpoint
else: req.endpoint(session)
if session.isNil:
echo "[sessions] Rate limited, retrying ", api,
" request (", i, "/", maxRetries, ")..."
else:
echo "[sessions] Rate limited, retrying ", api,
" request (", i, "/", maxRetries, ")..., session: ", session.pretty
session = nil
if retryDelayMs > 0:
await sleepAsync(retryDelayMs)
if not retrySuccess:
raise rateLimitError()
proc fetch*(url: Uri | SessionAwareUrl; api: Api): Future[JsonNode] {.async.} = proc fetch*(req: ApiReq): Future[JsonNode] {.async.} =
retry: retry:
var var body: string
body: string session = await getAndValidateSession(req)
session = await getAndValidateSession(api)
when url is SessionAwareUrl: let url = req.toUrl(session.kind)
let url = case session.kind
of SessionKind.oauth: url.oauthUrl
of SessionKind.cookie: url.cookieUrl
fetchImpl body: fetchImpl body:
if body.startsWith('{') or body.startsWith('['): if body.startsWith('{') or body.startsWith('['):
result = parseJson(body) result = parseJson(body)
else: else:
echo resp.status, ": ", body, " --- url: ", url echo resp.status, ": ", body, " --- url: ", url, ", session: ", session.pretty
result = newJNull() result = newJNull()
let error = result.getError let error = result.getError
if error != null and error notin errorsToSkip: if error != null and error notin errorsToSkip:
echo "Fetch error, API: ", api, ", error: ", error echo "Fetch error, API: ", url.path, ", error: ", error, ", session: ", session.pretty
if error in {expiredToken, badToken, locked}: if error in {expiredToken, badToken, locked}:
invalidate(session) invalidate(session)
raise rateLimitError() raise rateLimitError()
proc fetchRaw*(url: Uri | SessionAwareUrl; api: Api): Future[string] {.async.} = proc fetchRaw*(req: ApiReq): Future[string] {.async.} =
retry: retry:
var session = await getAndValidateSession(api) session = await getAndValidateSession(req)
let url = req.toUrl(session.kind)
when url is SessionAwareUrl:
let url = case session.kind
of SessionKind.oauth: url.oauthUrl
of SessionKind.cookie: url.cookieUrl
fetchImpl result: fetchImpl result:
if not (result.startsWith('{') or result.startsWith('[')): if not (result.startsWith('{') or result.startsWith('[')):
echo resp.status, ": ", result, " --- url: ", url echo resp.status, ": ", result, " --- url: ", url, ", session: ", session.pretty
result.setLen(0) result.setLen(0)

View file

@ -1,20 +1,28 @@
#SPDX-License-Identifier: AGPL-3.0-only #SPDX-License-Identifier: AGPL-3.0-only
import std/[asyncdispatch, times, json, random, sequtils, strutils, tables, packedsets, os] import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os]
import types import types, consts
import experimental/parser/session import experimental/parser/session
# max requests at a time per session to avoid race conditions const hourInSeconds = 60 * 60
const
maxConcurrentReqs = 2
hourInSeconds = 60 * 60
var var
sessionPool: seq[Session] sessionPool: seq[Session]
enableLogging = false 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, `$`]) = template log(str: varargs[string, `$`]) =
echo "[sessions] ", str.join("") 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 = proc pretty*(session: Session): string =
if session.isNil: if session.isNil:
return "<null>" return "<null>"
@ -42,6 +50,8 @@ proc getSessionPoolHealth*(): JsonNode =
oldest = now.int64 oldest = now.int64
newest = 0'i64 newest = 0'i64
average = 0'i64 average = 0'i64
oauthTotal, cookieTotal = 0
oauthLimited, cookieLimited = 0
for session in sessionPool: for session in sessionPool:
let created = snowflakeToEpoch(session.id) let created = snowflakeToEpoch(session.id)
@ -51,8 +61,15 @@ proc getSessionPoolHealth*(): JsonNode =
oldest = created oldest = created
average += created average += created
case session.kind
of oauth: inc oauthTotal
of cookie: inc cookieTotal
if session.limited: if session.limited:
limited.incl session.id limited.incl session.id
case session.kind
of oauth: inc oauthLimited
of cookie: inc cookieLimited
for api in session.apis.keys: for api in session.apis.keys:
let let
@ -76,6 +93,8 @@ proc getSessionPoolHealth*(): JsonNode =
"sessions": %*{ "sessions": %*{
"total": sessionPool.len, "total": sessionPool.len,
"limited": limited.card, "limited": limited.card,
"oauth": %*{"total": oauthTotal, "limited": oauthLimited},
"cookie": %*{"total": cookieTotal, "limited": cookieLimited},
"oldest": $fromUnix(oldest), "oldest": $fromUnix(oldest),
"newest": $fromUnix(newest), "newest": $fromUnix(newest),
"average": $fromUnix(average) "average": $fromUnix(average)
@ -92,6 +111,7 @@ proc getSessionPoolDebug*(): JsonNode =
for session in sessionPool: for session in sessionPool:
let sessionJson = %*{ let sessionJson = %*{
"kind": $session.kind,
"apis": newJObject(), "apis": newJObject(),
"pending": session.pending, "pending": session.pending,
} }
@ -122,11 +142,12 @@ proc rateLimitError*(): ref RateLimitError =
proc noSessionsError*(): ref NoSessionsError = proc noSessionsError*(): ref NoSessionsError =
newException(NoSessionsError, "no sessions available") newException(NoSessionsError, "no sessions available")
proc isLimited(session: Session; api: Api): bool = proc isLimited(session: Session; req: ApiReq): bool =
if session.isNil: if session.isNil:
return true 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: if (epochTime().int - session.limitedAt) > hourInSeconds:
session.limited = false session.limited = false
log "resetting limit: ", session.pretty log "resetting limit: ", session.pretty
@ -140,8 +161,8 @@ proc isLimited(session: Session; api: Api): bool =
else: else:
return false return false
proc isReady(session: Session; api: Api): bool = proc isReady(session: Session; req: ApiReq): bool =
not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(api)) not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(req))
proc invalidate*(session: var Session) = proc invalidate*(session: var Session) =
if session.isNil: return if session.isNil: return
@ -156,24 +177,29 @@ proc release*(session: Session) =
if session.isNil: return if session.isNil: return
dec session.pending dec session.pending
proc getSession*(api: Api): Future[Session] {.async.} = proc getSession*(req: ApiReq): Future[Session] {.async.} =
for i in 0 ..< sessionPool.len: for i in 0 ..< sessionPool.len:
if result.isReady(api): break if result.isReady(req): break
result = sessionPool.sample() result = sessionPool.sample()
if not result.isNil and result.isReady(api): if not result.isNil and result.isReady(req):
inc result.pending inc result.pending
else: else:
log "no sessions available for API: ", api if result.isNil:
log "no sessions available for API: ", req.cookie.endpoint
else:
log "no sessions available for API: ", req.endpoint(result), ", last tried: ", result.pretty
raise noSessionsError() raise noSessionsError()
proc setLimited*(session: Session; api: Api) = proc setLimited*(session: Session; req: ApiReq) =
let api = req.endpoint(session)
session.limited = true session.limited = true
session.limitedAt = epochTime().int session.limitedAt = epochTime().int
log "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", ", session.pretty 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 # avoid undefined behavior in race conditions
let api = req.endpoint(session)
if api in session.apis: if api in session.apis:
let rateLimit = session.apis[api] let rateLimit = session.apis[api]
if rateLimit.reset >= reset and rateLimit.remaining < remaining: 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) = proc getConfig*(path: string): (Config, parseCfg.Config) =
var cfg = loadConfig(path) var cfg = loadConfig(path)
let masterRss = cfg.get("Config", "enableRSS", true)
let conf = Config( let conf = Config(
# Server # Server
address: cfg.get("Server", "address", "0.0.0.0"), address: cfg.get("Server", "address", "0.0.0.0"),
@ -37,10 +39,20 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
hmacKey: cfg.get("Config", "hmacKey", "secretkey"), hmacKey: cfg.get("Config", "hmacKey", "secretkey"),
base64Media: cfg.get("Config", "base64Media", false), base64Media: cfg.get("Config", "base64Media", false),
minTokens: cfg.get("Config", "tokenCount", 10), 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),
enableRSSUserArticles: masterRss and cfg.get("Config", "enableRSSUserArticles", true),
enableRSSSearch: masterRss and cfg.get("Config", "enableRSSSearch", true),
enableRSSList: masterRss and cfg.get("Config", "enableRSSList", true),
enableDebug: cfg.get("Config", "enableDebug", false), enableDebug: cfg.get("Config", "enableDebug", false),
proxy: cfg.get("Config", "proxy", ""), 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) return (conf, cfg)

View file

@ -1,116 +1,108 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import uri, strutils import strutils
const const
consumerKey* = "3nVuSoBZnx6U4vzUxf5w" consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys" consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F"
gql = parseUri("https://api.x.com") / "graphql" graphUser* = "Gb-d6r0vxPOADdG62OEBpQ/UserByScreenName"
graphUserV2* = "-ZzAG_Bckx16LMbEvHC3lg/UserResultByScreenNameQuery"
graphUserById* = "xvmVfRLmnr1alc5f2dib0Q/UserByRestId"
graphUserTweetsV2* = "LE3eTyeqhBh2g-fX85O2eQ/UserWithProfileTweetsQueryV2"
graphUserTweetsAndRepliesV2* = "AcYHjc_YAx-9_rKWdMsKvA/UserWithProfileTweetsAndRepliesQueryV2"
graphUserTweets* = "SXVCYB8XHSS25nzIljNtZA/UserTweets"
graphUserTweetsAndReplies* = "qUpkZU6eN8MbtQb7rC_pYg/UserTweetsAndReplies"
graphUserMedia* = "VyudDWQnr9vJNw7GasFz2g/UserMedia"
graphUserMediaV2* = "WK111rbR0vM0ZX4lyZCYjw/MediaTimelineV2"
graphUserArticles* = "ZmMjUyrTpwYfTGAdylEyMw/UserArticlesTweets"
graphUserArticlesV2* = "PsGixN38UZz2RheyayNB5Q/UserProfileArticlesTimelineQuery"
graphTweet* = "OZMbEnEa96AN8Pq6HyTWdw/ConversationTimeline"
graphTweetDetail* = "XMOz5h24KAZ86qKffKTLdQ/TweetDetail"
graphTweetResult* = "xYOrBQoTlfKJJPsX76MZEw/TweetResultByIdQuery"
graphTweetEditHistory* = "1izbuOcH_QpuMcyCxOXkAg/TweetEditHistory"
graphSearchTimeline* = "hyPfJYJ_XAtDYoslQc-Rgg/SearchTimeline"
graphUser* = gql / "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery" graphListById* = "niz0TtOxL2zIcbq6_NQiNw/ListByRestId"
graphUserById* = gql / "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery" graphListBySlug* = "RqkWNDQpOntlxNtJa4RIoQ/ListBySlug"
graphUserTweetsV2* = gql / "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2" graphListMembers* = "8rYmkvWQe9jRRZdy_-vkGA/ListMembers"
graphUserTweetsAndRepliesV2* = gql / "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2" graphListTweets* = "0QJtcuMzVywHGAWD6Dtjlw/ListTimeline"
graphUserTweets* = gql / "oRJs8SLCRNRbQzuZG93_oA/UserTweets" graphAboutAccount* = "TzOG2twZEfhr9KmClvVVqA/AboutAccountQuery"
graphUserTweetsAndReplies* = gql / "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
graphUserMedia* = gql / "36oKqyQ7E_9CmtONGjJRsA/UserMedia" graphCommunity* = "-ElI1vg3dYbttVMhBhGdLw/CommunityQuery"
graphUserMediaV2* = gql / "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2" graphCommunityTweets* = "EwftYyqQemkckQ0wzGM6uw/CommunityTweetsTimeline"
graphTweet* = gql / "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline" graphCommunityMedia* = "ESJtwnI_apuGesbJncpc0Q/CommunityMediaTimeline"
graphTweetDetail* = gql / "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail" graphCommunityMembers* = "woAp_YdzAdqnWDrqLTNpAw/membersSliceTimeline_Query"
graphTweetResult* = gql / "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery" graphCommunityModerators* = "0oYT9GRiWUhrz5xoqFE9uw/moderatorsSliceTimeline_Query"
graphSearchTimeline* = gql / "bshMIjqDk8LTXTq4w91WKw/SearchTimeline" graphCommunityHashtags* = "D5EqomOIWeJnSkMhL-FLew/CommunityHashtagsTimeline"
graphListById* = gql / "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId"
graphListBySlug* = gql / "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug" graphTweetResultByRestId* = "GZsN2Pc4knAoit6pXa4HSA/TweetResultByRestId"
graphListMembers* = gql / "fuVHh5-gFn8zDBBxb8wOMA/ListMembers" graphTweetResultsByRestIds* = "Pho4sg8jLcrVlMeclMayrg/TweetResultsByRestIds"
graphListTweets* = gql / "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
graphBroadcast* = "RG6wSogandh6WPIzxW9aag/BroadcastQuery"
graphAudioSpace* = "Bh0L6azTQoMs9rJKeCF4wQ/AudioSpaceById"
restLiveStream* = "1.1/live_video_stream/status/"
graphFollowers* = "JNyQdTISpzCkj_1fqxDvFg/Followers"
graphFollowing* = "qGZZDF3mp91q7X22s3HxpA/Following"
gqlFeatures* = """{ gqlFeatures* = """{
"android_ad_formats_media_component_render_overlay_enabled": false, "rweb_video_screen_enabled": false,
"android_graphql_skip_api_media_color_palette": false, "rweb_cashtags_enabled": true,
"android_professional_link_spotlight_display_enabled": false, "profile_label_improvements_pcf_label_in_post_enabled": true,
"blue_business_profile_image_shape_enabled": false, "responsive_web_profile_redirect_enabled": false,
"commerce_android_shop_module_enabled": false, "rweb_tipjar_consumption_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,
"hidden_profile_likes_enabled": false,
"highlights_tweets_tab_ui_enabled": 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,
"mobile_app_spotlight_module_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_media_download_video_enabled": false,
"responsive_web_text_conversations_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,
"spaces_2022_h2_clipping": true,
"spaces_2022_h2_spaces_communities": true,
"standardized_nudges_misinfo": true,
"subscriptions_verification_info_enabled": true,
"subscriptions_verification_info_reason_enabled": true,
"subscriptions_verification_info_verified_since_enabled": true,
"super_follow_badge_privacy_enabled": false,
"super_follow_exclusive_tweet_notifications_enabled": false,
"super_follow_tweet_api_enabled": false,
"super_follow_user_api_enabled": false,
"tweet_awards_web_tipping_enabled": false,
"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,
"verified_phone_label_enabled": false, "verified_phone_label_enabled": false,
"vibe_api_enabled": false, "creator_subscriptions_tweet_preview_api_enabled": true,
"view_counts_everywhere_api_enabled": true, "responsive_web_graphql_timeline_navigation_enabled": true,
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
"premium_content_api_read_enabled": false, "premium_content_api_read_enabled": false,
"communities_web_enable_tweet_community_results_fetch": true, "communities_web_enable_tweet_community_results_fetch": true,
"responsive_web_jetfuel_frame": true, "c9s_tweet_anatomy_moderator_badge_enabled": true,
"c9s_list_members_action_api_enabled": false,
"c9s_superc9s_indication_enabled": false,
"responsive_web_grok_analyze_button_fetch_trends_enabled": false, "responsive_web_grok_analyze_button_fetch_trends_enabled": false,
"responsive_web_grok_analyze_post_followups_enabled": true,
"rweb_cashtags_composer_attachment_enabled": true,
"responsive_web_jetfuel_frame": true,
"responsive_web_grok_share_attachment_enabled": true,
"responsive_web_grok_annotations_enabled": true,
"articles_preview_enabled": true,
"responsive_web_edit_tweet_api_enabled": true,
"rweb_conversational_replies_downvote_enabled": false,
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
"view_counts_everywhere_api_enabled": true,
"longform_notetweets_consumption_enabled": true,
"responsive_web_twitter_article_tweet_consumption_enabled": true,
"content_disclosure_indicator_enabled": true,
"content_disclosure_ai_generated_indicator_enabled": true,
"responsive_web_grok_show_grok_translated_post": true,
"responsive_web_grok_analysis_button_from_backend": true,
"post_ctas_fetch_enabled": true,
"freedom_of_speech_not_reach_fetch_enabled": true,
"standardized_nudges_misinfo": true,
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
"longform_notetweets_rich_text_read_enabled": true,
"longform_notetweets_inline_media_enabled": false,
"responsive_web_grok_image_annotation_enabled": true, "responsive_web_grok_image_annotation_enabled": true,
"responsive_web_grok_imagine_annotation_enabled": true, "responsive_web_grok_imagine_annotation_enabled": true,
"rweb_tipjar_consumption_enabled": true, "responsive_web_grok_community_note_auto_translation_is_enabled": true,
"profile_label_improvements_pcf_label_in_post_enabled": true, "responsive_web_enhance_cards_enabled": false
"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
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
tweetVariables* = """{ tweetVars* = """{
"postId": "$1", "postId": "$1",
$2 $2
"ranking_mode": "$3",
"includeHasBirdwatchNotes": false, "includeHasBirdwatchNotes": false,
"includePromotedContent": false, "includePromotedContent": false,
"withBirdwatchNotes": false, "withBirdwatchNotes": true,
"withVoice": false, "withVoice": false,
"withV2Timeline": true "withV2Timeline": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
tweetDetailVariables* = """{ tweetDetailVars* = """{
"focalTweetId": "$1", "focalTweetId": "$1",
$2 $2
"referrer": "profile", "referrer": "profile",
@ -123,21 +115,26 @@ const
"withVoice": true "withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
restIdVariables* = """{ tweetEditHistoryVars* = """{
"rest_id": "$1", $2 "tweetId": "$1",
"count": 20 "withQuickPromoteEligibilityTweetFields": true
}""" }""".replace(" ", "").replace("\n", "")
userMediaVariables* = """{ restIdVars* = """{
"rest_id": "$1", $2
"count": $3
}""".replace(" ", "").replace("\n", "")
userMediaVars* = """{
"userId": "$1", $2 "userId": "$1", $2
"count": 20, "count": $3,
"includePromotedContent": false, "includePromotedContent": false,
"withClientEventToken": false, "withClientEventToken": false,
"withBirdwatchNotes": false, "withBirdwatchNotes": false,
"withVoice": true "withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
userTweetsVariables* = """{ userTweetsVars* = """{
"userId": "$1", $2 "userId": "$1", $2
"count": 20, "count": 20,
"includePromotedContent": false, "includePromotedContent": false,
@ -145,7 +142,7 @@ const
"withVoice": true "withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
userTweetsAndRepliesVariables* = """{ userTweetsAndRepliesVars* = """{
"userId": "$1", $2 "userId": "$1", $2
"count": 20, "count": 20,
"includePromotedContent": false, "includePromotedContent": false,
@ -153,5 +150,70 @@ const
"withVoice": true "withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
fieldToggles* = """{"withArticlePlainText":false}""" userArticlesVars* = """{
"userId": "$1", $2
"count": 20,
"includePromotedContent": false,
"withVoice": true
}""".replace(" ", "").replace("\n", "")
articleVars* = """{
"tweetId": "$1",
"includePromotedContent": false,
"withBirdwatchNotes": true,
"withVoice": true,
"withCommunity": true
}""".replace(" ", "").replace("\n", "")
articleBatchVars* = """{
"tweetIds": $1,
"includePromotedContent": false,
"withBirdwatchNotes": true,
"withVoice": true,
"withCommunity": true
}""".replace(" ", "").replace("\n", "")
articleFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withArticleSummaryText":true,"withArticleVoiceOver":true}"""
tweetByRestIdVars* = """{
"tweetId": "$1",
"includePromotedContent": false,
"withBirdwatchNotes": false,
"withVoice": false,
"withCommunity": false
}""".replace(" ", "").replace("\n", "")
userByRestIdVars* = """{
"userId": "$1",
"withSafetyModeUserFields": true
}""".replace(" ", "").replace("\n", "")
communityTweetsVars* = """{
"communityId": "$1", $2
"count": 20,
"displayLocation": "Community",
"rankingMode": "$3",
"withCommunity": true
}""".replace(" ", "").replace("\n", "")
communityMediaVars* = """{
"communityId": "$1", $2
"count": 20,
"withCommunity": true
}""".replace(" ", "").replace("\n", "")
communityMembersVars* = """{
"communityId": "$1",
"cursor": $2
}""".replace(" ", "").replace("\n", "")
communityHashtagsVars* = """{
"communityId": "$1", $2
"count": 20,
"hashtags": ["$3"],
"withCommunity": true
}""".replace(" ", "").replace("\n", "")
userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
userTweetsFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false}"""
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}""" tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""

View file

@ -1,2 +1,2 @@
import parser/[user, graphql] import parser/[user, graphql, article]
export user, graphql export user, graphql, article

View file

@ -0,0 +1,88 @@
# SPDX-License-Identifier: AGPL-3.0-only
import std/[strutils, tables, times, options]
import jsony
import utils, graphql, ../types/article
from ../../types import Article, ArticleParagraph, ArticleEntity, ArticleMedia,
User, TweetStats
proc parseGraphArticle*(json: string): Article =
if json.len == 0 or json[0] != '{':
return
var raw: GraphArticle
try:
raw = json.fromJson(GraphArticle)
except CatchableError:
return
let
tweet = raw.data.tweetResult.result
article = tweet.article.articleResults.result
if article.title.len == 0:
return
let publishedAt = article.metadata.firstPublishedAtSecs
var articleTime: DateTime
if publishedAt > 0:
articleTime = publishedAt.int64.fromUnix.utc
elif tweet.legacy.createdAt.len > 0:
articleTime = parseTwitterDate(tweet.legacy.createdAt)
result = Article(
title: article.title,
coverImage: getImageUrl(article.coverMedia.mediaInfo.originalImgUrl),
time: articleTime,
user: parseUserResult(tweet.core.userResults.result),
)
result.stats = TweetStats(
replies: tweet.legacy.replyCount,
retweets: tweet.legacy.retweetCount,
likes: tweet.legacy.favoriteCount,
)
if tweet.views.count.len > 0:
try: result.stats.views = parseInt(tweet.views.count)
except ValueError: discard
for blk in article.contentState.blocks:
result.paragraphs.add ArticleParagraph(
text: blk.text,
kind: blk.blockKind,
inlineStyles: blk.inlineStyleRanges,
entityRanges: blk.entityRanges,
)
for entry in article.contentState.entityMap:
let key = try: parseInt(entry.key) except ValueError: continue
var entity = ArticleEntity(kind: entry.value.entityKind)
case entity.kind
of "LINK": entity.url = entry.value.data.url
of "MEDIA":
for mi in entry.value.data.mediaItems:
entity.mediaIds.add mi.mediaId
entity.caption = entry.value.data.caption
of "TWEET": entity.tweetId = entry.value.data.tweetId
of "MARKDOWN": entity.markdown = entry.value.data.markdown
else: discard
result.entities[key] = entity
for me in article.mediaEntities:
let typeName = me.mediaInfo.typeName
var media = ArticleMedia(kind: typeName)
if me.mediaInfo.videoInfo.isSome:
let variants = me.mediaInfo.videoInfo.get.variants
case typeName
of "ApiGif":
if variants.len > 0:
media.url = variants[0].url
of "ApiVideo":
var bestBitrate = -1
for v in variants:
if v.bitrate > bestBitrate:
bestBitrate = v.bitrate
media.url = v.url
else: discard
elif typeName == "ApiImage":
media.url = getImageUrl(me.mediaInfo.originalImgUrl)
result.media[me.mediaId] = media

View file

@ -1,6 +1,6 @@
import options, strutils import options, strutils
import jsony import jsony
import user, ../types/[graphuser, graphlistmembers] import user, utils, ../types/[graphuser, graphlistmembers, graphfollowers]
from ../../types import User, VerifiedType, Result, Query, QueryKind from ../../types import User, VerifiedType, Result, Query, QueryKind
proc parseUserResult*(userResult: UserResult): User = proc parseUserResult*(userResult: UserResult): User =
@ -10,27 +10,61 @@ proc parseUserResult*(userResult: UserResult): User =
result.verifiedType = blue result.verifiedType = blue
if result.username.len == 0 and userResult.core.screenName.len > 0: if result.username.len == 0 and userResult.core.screenName.len > 0:
# Modern UserByRestId/UserByScreenName shape: `legacy` is empty and the data
# lives in typed sub-objects (core/relationship_counts/tweet_counts/etc.).
result.id = userResult.restId result.id = userResult.restId
result.username = userResult.core.screenName result.username = userResult.core.screenName
result.fullname = userResult.core.name result.fullname = userResult.core.name
result.userPic = userResult.avatar.imageUrl.replace("_normal", "") result.userPic = userResult.avatar.imageUrl.replace("_normal", "")
if userResult.banner.imageUrl.len > 0:
result.banner = userResult.banner.imageUrl & "/1500x500"
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: if userResult.verification.isSome:
let v = userResult.verification.get let v = userResult.verification.get
if v.verifiedType != VerifiedType.none: if v.verifiedType != VerifiedType.none:
result.verifiedType = v.verifiedType result.verifiedType = v.verifiedType
if userResult.relationshipCounts.isSome:
let rc = userResult.relationshipCounts.get
result.followers = rc.followers
result.following = rc.following
if userResult.tweetCounts.isSome:
let tc = userResult.tweetCounts.get
result.tweets = tc.tweets
result.media = tc.mediaTweets
if userResult.actionCounts.isSome:
result.likes = userResult.actionCounts.get.favoritesCount
if userResult.profileBio.isSome: if userResult.profileBio.isSome:
result.bio = userResult.profileBio.get.description let bio = userResult.profileBio.get
result.bio = bio.description
result.expandUserEntities(bio.entities)
proc parseGraphUser*(json: string): User = proc parseGraphUser*(json: string): User =
if json.len == 0 or json[0] != '{': if json.len == 0 or json[0] != '{':
return return
let raw = json.fromJson(GraphUser) let
let userResult = raw.data.userResult.result 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) return User(suspended: true)
result = parseUserResult(userResult) result = parseUserResult(userResult)
@ -53,3 +87,25 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] =
of TimelineTimelineCursor: of TimelineTimelineCursor:
if entry.content.cursorType == "Bottom": if entry.content.cursorType == "Bottom":
result.bottom = entry.content.value result.bottom = entry.content.value
proc parseGraphFollowers*(json, cursor: string; kind: QueryKind): Result[User] =
result = Result[User](
beginning: cursor.len == 0,
query: Query(kind: kind)
)
if json.len == 0 or json[0] != '{':
return
let raw = json.fromJson(GraphFollowers)
for instruction in raw.data.user.result.timeline.timeline.instructions:
if instruction.kind == "TimelineAddEntries":
for entry in instruction.entries:
case entry.content.entryType
of TimelineTimelineItem:
let userResult = entry.content.itemContent.userResults.result
if userResult.restId.len > 0:
result.content.add parseUserResult(userResult)
of TimelineTimelineCursor:
if entry.content.cursorType == "Bottom":
result.bottom = entry.content.value

View file

@ -54,7 +54,7 @@ proc replacedWith*(runes: seq[Rune]; repls: openArray[ReplaceSlice];
let let
name = $runes[rep.slice.a.succ .. rep.slice.b] name = $runes[rep.slice.a.succ .. rep.slice.b]
symbol = $runes[rep.slice.a] 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: of rkMention:
result.add a($runes[rep.slice], href = rep.url, title = rep.display) result.add a($runes[rep.slice], href = rep.url, title = rep.display)
of rkUrl: 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,12 +9,10 @@ let
unReplace = "$1<a href=\"/$2\">@$2</a>" unReplace = "$1<a href=\"/$2\">@$2</a>"
htRegex = nre.re"""(*U)(^|[^\w-_.?])([#$])([\w_]*+)(?!</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) = proc expandUserEntities*(user: var User; ent: Entities) =
let let orig = user.bio.toRunes
orig = user.bio.toRunes
ent = raw.entities
if ent.url.urls.len > 0: if ent.url.urls.len > 0:
user.website = ent.url.urls[0].expandedUrl user.website = ent.url.urls[0].expandedUrl
@ -58,15 +56,17 @@ proc toUser*(raw: RawUser): User =
media: raw.mediaCount, media: raw.mediaCount,
verifiedType: raw.verifiedType, verifiedType: raw.verifiedType,
protected: raw.protected, protected: raw.protected,
joinDate: parseTwitterDate(raw.createdAt),
banner: getBanner(raw), banner: getBanner(raw),
userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "") userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "")
) )
if raw.createdAt.len > 0:
result.joinDate = parseTwitterDate(raw.createdAt)
if raw.pinnedTweetIdsStr.len > 0: if raw.pinnedTweetIdsStr.len > 0:
result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0]) result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0])
result.expandUserEntities(raw) result.expandUserEntities(raw.entities)
proc parseHook*(s: string; i: var int; v: var User) = proc parseHook*(s: string; i: var int; v: var User) =
var u: RawUser var u: RawUser

View file

@ -0,0 +1,79 @@
import std/options
import graphuser
from ../../types import ArticleStyle, ArticleEntityRange
type
GraphArticle* = object
data*: tuple[tweetResult: tuple[result: TweetResultNode]]
TweetResultNode* = object
article*: tuple[articleResults: tuple[result: ArticleResultNode]]
legacy*: TweetLegacy
core*: tuple[userResults: UserData]
views*: tuple[count: string]
TweetLegacy* = object
createdAt*: string
replyCount*: int
retweetCount*: int
favoriteCount*: int
ArticleResultNode* = object
title*: string
coverMedia*: tuple[mediaInfo: MediaInfoNode]
contentState*: ContentState
metadata*: tuple[firstPublishedAtSecs: int]
mediaEntities*: seq[RawMediaEntity]
ContentState* = object
blocks*: seq[ContentBlock]
entityMap*: seq[EntityMapEntry]
ContentBlock* = object
text*: string
blockKind*: string
inlineStyleRanges*: seq[ArticleStyle]
entityRanges*: seq[ArticleEntityRange]
EntityMapEntry* = object
key*: string
value*: EntityMapValue
EntityMapValue* = object
entityKind*: string
data*: EntityDataNode
EntityDataNode* = object
url*: string
mediaItems*: seq[tuple[mediaId: string]]
tweetId*: string
markdown*: string
caption*: string
RawMediaEntity* = object
mediaId*: string
mediaInfo*: MediaInfoNode
MediaInfoNode* = object
typeName*: string
originalImgUrl*: string
videoInfo*: Option[VideoInfoNode]
VideoInfoNode* = object
variants*: seq[VideoVariant]
VideoVariant* = object
url*: string
bitrate*: int
proc renameHook*(v: var ContentBlock; fieldName: var string) =
if fieldName == "type":
fieldName = "blockKind"
proc renameHook*(v: var EntityMapValue; fieldName: var string) =
if fieldName == "type":
fieldName = "entityKind"
proc renameHook*(v: var MediaInfoNode; fieldName: var string) =
if fieldName == "__typename":
fieldName = "typeName"

View file

@ -0,0 +1,17 @@
# SPDX-License-Identifier: AGPL-3.0-only
import graphlistmembers
type
GraphFollowers* = object
data*: tuple[user: UserWrapper]
UserWrapper = object
result*: UserResultWrapper
UserResultWrapper = object
timeline*: tuple[timeline: graphlistmembers.Timeline]
# Hook to normalize snake_case field from API to camelCase used by shared types
proc renameHook*(v: var Content; fieldName: var string) =
if fieldName == "user_results":
fieldName = "userResults"

View file

@ -7,10 +7,10 @@ type
List = object List = object
membersTimeline*: tuple[timeline: Timeline] membersTimeline*: tuple[timeline: Timeline]
Timeline = object Timeline* = object
instructions*: seq[Instruction] instructions*: seq[Instruction]
Instruction = object Instruction* = object
kind*: string kind*: string
entries*: seq[tuple[content: Content]] entries*: seq[tuple[content: Content]]
@ -18,7 +18,7 @@ type
TimelineTimelineItem TimelineTimelineItem
TimelineTimelineCursor TimelineTimelineCursor
Content = object Content* = object
case entryType*: ContentEntryType case entryType*: ContentEntryType
of TimelineTimelineItem: of TimelineTimelineItem:
itemContent*: tuple[userResults: UserData] itemContent*: tuple[userResults: UserData]

View file

@ -1,9 +1,10 @@
import options, strutils import options, strutils
from ../../types import User, VerifiedType from ../../types import User, VerifiedType
import user as userType # Entities, for modern profile_bio parsing
type type
GraphUser* = object GraphUser* = object
data*: tuple[userResult: UserData] data*: tuple[userResult: Option[UserData], user: Option[UserData]]
UserData* = object UserData* = object
result*: UserResult result*: UserResult
@ -15,6 +16,7 @@ type
UserBio* = object UserBio* = object
description*: string description*: string
entities*: Entities
UserAvatar* = object UserAvatar* = object
imageUrl*: string imageUrl*: string
@ -22,15 +24,41 @@ type
Verification* = object Verification* = object
verifiedType*: VerifiedType verifiedType*: VerifiedType
Location* = object
location*: string
Privacy* = object
protected*: bool
# Modern UserByRestId/UserByScreenName shape moves the counts out of `legacy`
# into these typed sub-objects.
RelationshipCounts* = object
followers*: int
following*: int
TweetCounts* = object
tweets*: int
mediaTweets*: int
ActionCounts* = object
favoritesCount*: int
UserResult* = object UserResult* = object
legacy*: User legacy*: User
restId*: string restId*: string
isBlueVerified*: bool isBlueVerified*: bool
unavailableReason*: Option[string]
core*: UserCore core*: UserCore
avatar*: UserAvatar avatar*: UserAvatar
banner*: UserAvatar
unavailableReason*: Option[string]
reason*: Option[string]
privacy*: Option[Privacy]
profileBio*: Option[UserBio] profileBio*: Option[UserBio]
verification*: Option[Verification] verification*: Option[Verification]
location*: Option[Location]
relationshipCounts*: Option[RelationshipCounts]
tweetCounts*: Option[TweetCounts]
actionCounts*: Option[ActionCounts]
proc enumHook*(s: string; v: var VerifiedType) = proc enumHook*(s: string; v: var VerifiedType) =
v = try: 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 # 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 std/[enumerate, re]
import types, utils, query import types, utils, query
const const
cards = "cards.twitter.com/cards" cards = "cards.twitter.com/cards"
tco = "https://t.co" tco = "https://t.co"
twitter = parseUri("https://twitter.com") twitter = parseUri("https://x.com")
let let
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com" twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
@ -59,25 +59,28 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
result = body result = body
if prefs.replaceYouTube.len > 0 and "youtu" in result: 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: if prefs.replaceTwitter.len > 0:
let twitterHost = strip(prefs.replaceTwitter, chars={'/'})
if tco in result: 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: if "x.com" in result:
result = result.replace(xRegex, prefs.replaceTwitter) result = result.replace(xRegex, twitterHost)
result = result.replacef(xLinkRegex, a( result = result.replacef(xLinkRegex, a(
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1")) twitterHost & "$2", href = https & twitterHost & "$1"))
if "twitter.com" in result: if "twitter.com" in result:
result = result.replace(cards, prefs.replaceTwitter & "/cards") result = result.replace(cards, twitterHost & "/cards")
result = result.replace(twRegex, prefs.replaceTwitter) result = result.replace(twRegex, twitterHost)
result = result.replacef(twLinkRegex, a( 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): if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result):
result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/") let redditHost = strip(prefs.replaceReddit, chars={'/'})
result = result.replace(rdRegex, prefs.replaceReddit) result = result.replace(rdShortRegex, redditHost & "/comments/")
if prefs.replaceReddit in result and "/gallery/" in result: result = result.replace(rdRegex, redditHost)
if redditHost in result and "/gallery/" in result:
result = result.replace("/gallery/", "/comments/") result = result.replace("/gallery/", "/comments/")
if absolute.len > 0 and "href" in result: if absolute.len > 0 and "href" in result:
@ -88,7 +91,17 @@ proc getM3u8Url*(content: string): string =
if re.find(content, m3u8Regex, matches) != -1: if re.find(content, m3u8Regex, matches) != -1:
result = matches[0] result = matches[0]
proc proxifyVideo*(manifest: string; proxy: bool): string = proc proxifyVideo*(manifest: string; proxy: bool; manifestUrl = ""): string =
let (baseUrl, basePath) =
if manifestUrl.len > 0:
let
u = parseUri(manifestUrl)
origin = u.scheme & "://" & u.hostname
idx = manifestUrl.rfind('/')
dirPath = if idx > 8: manifestUrl[0 .. idx] else: ""
(origin, dirPath)
else:
("https://video.twimg.com", "")
var replacements: seq[(string, string)] var replacements: seq[(string, string)]
for line in manifest.splitLines: for line in manifest.splitLines:
let url = let url =
@ -96,9 +109,13 @@ proc proxifyVideo*(manifest: string; proxy: bool): string =
elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line: elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line:
line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))] line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))]
else: line else: line
if url.startsWith('/'): let resolved =
let path = "https://video.twimg.com" & url if url.startsWith('/'): baseUrl & url
replacements.add (url, if proxy: path.getVidUrl else: path) elif basePath.len > 0 and url.len > 0 and not url.startsWith('#') and
not url.startsWith("http") and ('.' in url): basePath & url
else: ""
if resolved.len > 0:
replacements.add (url, if proxy: resolved.getVidUrl else: resolved)
return manifest.multiReplace(replacements) return manifest.multiReplace(replacements)
proc getUserPic*(userPic: string; style=""): string = proc getUserPic*(userPic: string; style=""): string =
@ -123,25 +140,30 @@ proc pageDesc*(user: User): string =
"The latest tweets from " & user.fullname "The latest tweets from " & user.fullname
proc getJoinDate*(user: User): string = proc getJoinDate*(user: User): string =
if user.joinDate.year == 0: return ""
user.joinDate.format("'Joined' MMMM YYYY") user.joinDate.format("'Joined' MMMM YYYY")
proc getJoinDateFull*(user: User): string = proc getJoinDateFull*(user: User): string =
if user.joinDate.year == 0: return ""
user.joinDate.format("h:mm tt - d MMM YYYY") user.joinDate.format("h:mm tt - d MMM YYYY")
proc getTime*(tweet: Tweet): string = proc getTime*(tweet: Tweet): string =
if tweet.time.year == 0: return ""
tweet.time.format("MMM d', 'YYYY' · 'h:mm tt' UTC'") tweet.time.format("MMM d', 'YYYY' · 'h:mm tt' UTC'")
proc getRfc822Time*(tweet: Tweet): string = proc getRfc822Time*(tweet: Tweet): string =
if tweet.time.year == 0: return ""
tweet.time.format("ddd', 'dd MMM yyyy HH:mm:ss 'GMT'") tweet.time.format("ddd', 'dd MMM yyyy HH:mm:ss 'GMT'")
proc getShortTime*(tweet: Tweet): string = proc getShortTime*(time: DateTime): string =
if time.year == 0: return ""
let now = now() let now = now()
let since = now - tweet.time let since = now - time
if now.year != tweet.time.year: if now.year != time.year:
result = tweet.time.format("d MMM yyyy") result = time.format("d MMM yyyy")
elif since.inDays >= 1: elif since.inDays >= 1:
result = tweet.time.format("MMM d") result = time.format("MMM d")
elif since.inHours >= 1: elif since.inHours >= 1:
result = $since.inHours & "h" result = $since.inHours & "h"
elif since.inMinutes >= 1: elif since.inMinutes >= 1:
@ -151,13 +173,33 @@ proc getShortTime*(tweet: Tweet): string =
else: else:
result = "now" result = "now"
proc getShortTime*(tweet: Tweet): string =
getShortTime(tweet.time)
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 = proc getLink*(tweet: Tweet; focus=true): string =
if tweet.id == 0: return if tweet.id == 0: return
var username = tweet.user.username var username = tweet.user.username
if username.len == 0: return getLink(tweet.id, username, focus)
username = "i"
result = &"/{username}/status/{tweet.id}"
if focus: result &= "#m"
proc getTwitterLink*(path: string; params: Table[string, string]): string = proc getTwitterLink*(path: string; params: Table[string, string]): string =
var var
@ -185,7 +227,7 @@ proc getTwitterLink*(path: string; params: Table[string, string]): string =
proc getLocation*(u: User | Tweet): (string, string) = proc getLocation*(u: User | Tweet): (string, string) =
if "://" in u.location: return (u.location, "") if "://" in u.location: return (u.location, "")
let loc = u.location.split(":") 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) (loc[0], url)
proc getSuspended*(username: string): string = proc getSuspended*(username: string): string =

View file

@ -27,7 +27,7 @@ proc release*(pool: HttpPool; client: AsyncHttpClient; badClient=false) =
proc acquire*(pool: HttpPool; heads: HttpHeaders): AsyncHttpClient = proc acquire*(pool: HttpPool; heads: HttpHeaders): AsyncHttpClient =
if pool.conns.len == 0: if pool.conns.len == 0:
result = newAsyncHttpClient(headers=heads, proxy=proxy) result = newAsyncHttpClient(userAgent="", headers=heads, proxy=proxy)
else: else:
result = pool.conns.pop() result = pool.conns.pop()
result.headers = heads result.headers = heads

View file

@ -2,15 +2,15 @@
import asyncdispatch, strformat, logging import asyncdispatch, strformat, logging
from net import Port from net import Port
from htmlgen import a from htmlgen import a
from os import getEnv from os import getEnv, normalizedPath
import jester 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 views/[general, about]
import routes/[ import routes/[
preferences, timeline, status, media, search, rss, list, debug, preferences, timeline, status, media, search, rss, list, community, debug,
unsupported, embed, resolver, router_utils] unsupported, embed, resolver, broadcast, space, article, router_utils]
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
const issuesUrl = "https://github.com/zedeus/nitter/issues" const issuesUrl = "https://github.com/zedeus/nitter/issues"
@ -34,39 +34,62 @@ stdout.flushFile
updateDefaultPrefs(fullCfg) updateDefaultPrefs(fullCfg)
setCacheTimes(cfg) setCacheTimes(cfg)
setHmacKey(cfg.hmacKey) setHmacKey(cfg.hmacKey)
if cfg.hmacKey.len == 0 or cfg.hmacKey == "secretkey":
stderr.write "WARNING: insecure default 'hmacKey' in nitter.conf; " &
"set a unique random value to stop media URL signatures being forgeable.\n"
stderr.flushFile
setProxyEncoding(cfg.base64Media) setProxyEncoding(cfg.base64Media)
setMaxHttpConns(cfg.httpMaxConns) setMaxHttpConns(cfg.httpMaxConns)
setHttpProxy(cfg.proxy, cfg.proxyAuth) setHttpProxy(cfg.proxy, cfg.proxyAuth)
setApiProxy(cfg.apiProxy)
setDisableTid(cfg.disableTid)
setMaxConcurrentReqs(cfg.maxConcurrentReqs)
setMaxRetries(cfg.maxRetries)
setRetryDelayMs(cfg.retryDelayMs)
initAboutPage(cfg.staticDir) initAboutPage(cfg.staticDir)
waitFor initRedisPool(cfg) waitFor initRedisPool(cfg)
stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n" stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
stdout.flushFile stdout.flushFile
createArticleRouter(cfg)
createUnsupportedRouter(cfg) createUnsupportedRouter(cfg)
createResolverRouter(cfg) createResolverRouter(cfg)
createPrefRouter(cfg) createPrefRouter(cfg)
createTimelineRouter(cfg) createTimelineRouter(cfg)
createListRouter(cfg) createListRouter(cfg)
createCommunityRouter(cfg)
createStatusRouter(cfg) createStatusRouter(cfg)
createSearchRouter(cfg) createSearchRouter(cfg)
createMediaRouter(cfg) createMediaRouter(cfg)
createEmbedRouter(cfg) createEmbedRouter(cfg)
createRssRouter(cfg) createRssRouter(cfg)
createBroadcastRouter(cfg)
createSpaceRouter(cfg)
createDebugRouter(cfg) createDebugRouter(cfg)
settings: settings:
port = Port(cfg.port) port = Port(cfg.port)
staticDir = cfg.staticDir staticDir = normalizedPath(cfg.staticDir)
bindAddr = cfg.address bindAddr = cfg.address
reusePort = true reusePort = true
maxBody = 64 * 1024
routes: routes:
before:
# Reject malformed paths
if request.path.len == 0 or request.path[0] != '/':
halt Http400
# skip all file URLs (except Twitter widget compatibility)
cond "." notin request.path or request.path == "/embed/Tweet.html"
applyUrlPrefs()
get "/": get "/":
resp renderMain(renderSearch(), request, cfg, themePrefs()) resp renderMain(renderSearch(), request, cfg, requestPrefs())
get "/about": get "/about":
resp renderMain(renderAbout(), request, cfg, themePrefs()) resp renderMain(renderAbout(), request, cfg, requestPrefs())
get "/explore": get "/explore":
redirect("/about") redirect("/about")
@ -77,7 +100,7 @@ routes:
get "/i/redirect": get "/i/redirect":
let url = decodeUrl(@"url") let url = decodeUrl(@"url")
if url.len == 0: resp Http404 if url.len == 0: resp Http404
redirect(replaceUrls(url, cookiePrefs())) redirect(replaceUrls(url, requestPrefs()))
error Http404: error Http404:
resp Http404, showError("Page not found", cfg) resp Http404, showError("Page not found", cfg)
@ -102,14 +125,18 @@ routes:
resp Http429, showError( resp Http429, showError(
&"Instance has no auth tokens, or is fully rate limited.<br>Use {link} or try again later.", cfg) &"Instance has no auth tokens, or is fully rate limited.<br>Use {link} or try again later.", cfg)
extend articleRoute, ""
extend rss, "" extend rss, ""
extend status, "" extend status, ""
extend search, "" extend search, ""
extend timeline, "" extend timeline, ""
extend media, "" extend media, ""
extend list, "" extend list, ""
extend community, ""
extend preferences, "" extend preferences, ""
extend resolver, "" extend resolver, ""
extend embed, "" extend embed, ""
extend broadcastRoute, ""
extend spaceRoute, ""
extend debug, "" extend debug, ""
extend unsupported, "" extend unsupported, ""

View file

@ -1,10 +1,20 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import strutils, options, times, math, tables import strutils, options, times, math, tables, uri
import packedjson, packedjson/deserialiser import packedjson, packedjson/deserialiser
import types, parserutils, utils import types, parserutils, utils
import experimental/parser/unifiedcard import experimental/parser/unifiedcard
proc parseGraphTweet(js: JsonNode): Tweet proc parseGraphTweet*(js: JsonNode): Tweet
proc parseVerifiedType(s: string; current: VerifiedType): VerifiedType =
try: parseEnum[VerifiedType](s)
except ValueError: current
proc parseCommunityNote(js: JsonNode): string =
let subtitle = js{"subtitle"}
result = subtitle{"text"}.getStr
with entities, subtitle{"entities"}:
result = expandBirdwatchEntities(result, entities)
proc parseUser(js: JsonNode; id=""): User = proc parseUser(js: JsonNode; id=""): User =
if js.isNull: return if js.isNull: return
@ -21,7 +31,7 @@ proc parseUser(js: JsonNode; id=""): User =
tweets: js{"statuses_count"}.getInt, tweets: js{"statuses_count"}.getInt,
likes: js{"favourites_count"}.getInt, likes: js{"favourites_count"}.getInt,
media: js{"media_count"}.getInt, media: js{"media_count"}.getInt,
protected: js{"protected"}.getBool, protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool),
joinDate: js{"created_at"}.getTime joinDate: js{"created_at"}.getTime
) )
@ -29,17 +39,17 @@ proc parseUser(js: JsonNode; id=""): User =
result.verifiedType = blue result.verifiedType = blue
with verifiedType, js{"verified_type"}: with verifiedType, js{"verified_type"}:
result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr) result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType)
result.expandUserEntities(js) result.expandUserEntities(js)
proc parseGraphUser(js: JsonNode): User = proc parseGraphUser(js: JsonNode): User =
var user = js{"user_result", "result"} var user = js{"user_result", "result"}
if user.isNull: if user.isNull:
user = ? js{"user_results", "result"} user = js{"user_results", "result"}
if user.isNull: if user.isNull:
if js{"core"}.notNull and js{"legacy"}.notNull: if js{"core"}.notNull:
user = js user = js
else: else:
return return
@ -51,15 +61,164 @@ proc parseGraphUser(js: JsonNode): User =
# fallback to support UserMedia/recent GraphQL updates # fallback to support UserMedia/recent GraphQL updates
if result.username.len == 0: if result.username.len == 0:
result.id = user{"rest_id"}.getStr
result.username = user{"core", "screen_name"}.getStr result.username = user{"core", "screen_name"}.getStr
result.fullname = user{"core", "name"}.getStr result.fullname = user{"core", "name"}.getStr
result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "") result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "")
if user{"is_blue_verified"}.getBool(false): if user{"is_blue_verified"}.getBool(
user{"verification", "is_blue_verified"}.getBool(false)):
result.verifiedType = blue result.verifiedType = blue
with verifiedType, user{"verification", "verified_type"}: with verifiedType, user{"verification", "verified_type"}:
result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr) result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType)
proc parseAboutAccount*(js: JsonNode): AccountInfo =
if js.isNull: return
let user = ? js{"data", "user_result_by_screen_name", "result"}
if user{"unavailable_reason"}.getStr == "Suspended":
result.suspended = true
return
result = AccountInfo(
username: user{"core", "screen_name"}.getStr,
fullname: user{"core", "name"}.getStr,
joinDate: user{"core", "created_at"}.getTime,
userPic: user{"avatar", "image_url"}.getImageStr.replace("_normal", ""),
affiliateLabel: user{"identity_profile_labels_highlighted_label", "label", "description"}.getStr,
)
if user{"is_blue_verified"}.getBool(false):
result.verifiedType = blue
with verifiedType, user{"verification", "verified_type"}:
result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType)
with about, user{"about_profile"}:
result.basedIn = about{"account_based_in"}.getStr
result.source = about{"source"}.getStr
result.affiliateUsername = about{"affiliate_username"}.getStr
try:
result.usernameChanges = about{"username_changes", "count"}.getStr("0").parseInt
except ValueError:
discard
with lastChange, about{"username_changes", "last_changed_at_msec"}:
result.lastUsernameChange = lastChange.getTimeFromMsStr
with info, user{"verification_info"}:
result.isIdentityVerified = info{"is_identity_verified"}.getBool
with reason, info{"reason"}:
result.overrideVerifiedYear = reason{"override_verified_year"}.getInt
with since, reason{"verified_since_msec"}:
result.verifiedSince = since.getTimeFromMsStr
proc parseBroadcastInfo*(js: JsonNode): Broadcast =
let bc = ? js{"data", "broadcast"}
result = Broadcast(
id: bc{"broadcast_id"}.getStr,
title: bc{"status"}.getStr,
state: bc{"state"}.getStr.toUpperAscii,
thumb: bc{"image_url"}.getStr,
mediaKey: bc{"media_key"}.getStr,
totalWatched: bc{"total_watched"}.getInt,
startTime: bc{"start_time"}.getTimeFromMs,
endTime: bc{"end_time"}.getTimeFromMs,
replayStart: bc{"edited_replay", "start_time"}.getInt,
availableForReplay: bc{"available_for_replay"}.getBool,
user: parseGraphUser(bc)
)
proc parseSpaceParticipant(js: JsonNode): SpaceParticipant =
result = SpaceParticipant(
userId: js{"user_results", "rest_id"}.getStr,
username: js{"twitter_screen_name"}.getStr,
displayName: js{"display_name"}.getStr,
avatarUrl: js{"avatar_url"}.getStr,
isVerified: js{"is_verified"}.getBool or
js{"user_results", "result", "is_blue_verified"}.getBool
)
proc parseAudioSpace*(js: JsonNode): AudioSpace =
let space = ? js{"data", "audioSpace"}
let meta = space{"metadata"}
result = AudioSpace(
id: meta{"rest_id"}.getStr,
title: meta{"title"}.getStr,
state: meta{"state"}.getStr.toUpperAscii,
mediaKey: meta{"media_key"}.getStr,
totalLiveListeners: meta{"total_live_listeners"}.getInt,
totalReplayWatched: meta{"total_replay_watched"}.getInt,
availableForReplay: meta{"is_space_available_for_replay"}.getBool
)
let startedAt = meta{"started_at"}.getInt(0)
if startedAt > 0:
result.startTime = fromUnix(startedAt div 1000).utc()
let endedAtStr = meta{"ended_at"}.getStr
if endedAtStr.len > 0:
try:
let endedAt = parseBiggestInt(endedAtStr)
if endedAt > 0:
result.endTime = fromUnix(endedAt div 1000).utc()
except ValueError:
discard
result.creator = parseGraphUser(meta{"creator_results", "result"})
for admin in space{"participants", "admins"}:
result.admins.add parseSpaceParticipant(admin)
for speaker in space{"participants", "speakers"}:
result.speakers.add parseSpaceParticipant(speaker)
proc parseGraphCommunity*(js: JsonNode): Community =
if js.isNull: return
let c = ? js{"data", "communityResults", "result"}
result = Community(
id: c{"rest_id"}.getStr(c{"id_str"}.getStr),
name: c{"name"}.getStr,
description: c{"description"}.getStr,
memberCount: c{"member_count"}.getInt,
joinPolicy: c{"join_policy"}.getStr,
category: c{"primary_community_topic", "topic_name"}.getStr,
banner: c{"custom_banner_media", "media_info", "original_img_url"}.getImageStr,
creator: parseGraphUser(c{"creator_results", "result"}),
)
let createdMs = c{"created_at"}.getInt(0)
if createdMs > 0:
result.createdAt = fromUnix(createdMs div 1000).utc()
for rule in c{"rules"}:
result.rules.add CommunityRule(
name: rule{"name"}.getStr,
description: rule{"description"}.getStr
)
for item in c{"trending_hashtags_slice", "items"}:
let tag = item{"hashtag"}.getStr
if tag.len > 0:
result.hashtags.add tag
proc parseListObject(js: JsonNode; owner: User): List =
List(
id: js{"id_str"}.getStr,
name: js{"name"}.getStr,
username: owner.username,
userId: owner.id,
description: js{"description"}.getStr,
members: js{"member_count"}.getInt,
banner: select(
js{"custom_banner_media", "media_info", "original_img_url"},
js{"default_banner_media", "media_info", "original_img_url"}
).getImageStr
)
proc parseGraphList*(js: JsonNode): List = proc parseGraphList*(js: JsonNode): List =
if js.isNull: return if js.isNull: return
@ -70,15 +229,17 @@ proc parseGraphList*(js: JsonNode): List =
if list.isNull: if list.isNull:
return return
result = List( result = parseListObject(list, parseGraphUser(list))
id: list{"id_str"}.getStr,
name: list{"name"}.getStr, proc parseGraphSearchList(js: JsonNode): ListSearchResult =
username: list{"user_results", "result", "legacy", "screen_name"}.getStr, let owner = parseGraphUser(js)
userId: list{"user_results", "result", "rest_id"}.getStr, result = ListSearchResult(
description: list{"description"}.getStr, list: parseListObject(js, owner),
members: list{"member_count"}.getInt, owner: owner,
banner: list{"custom_banner_media", "media_info", "original_img_url"}.getImageStr followersContext: js{"followers_context"}.getStr
) )
for url in js{"facepile_urls"}:
result.facepiles.add url.getStr
proc parsePoll(js: JsonNode): Poll = proc parsePoll(js: JsonNode): Poll =
let vals = js{"binding_values"} let vals = js{"binding_values"}
@ -134,60 +295,85 @@ proc parseVideo(js: JsonNode): Video =
result.variants = parseVideoVariants(js{"video_info", "variants"}) result.variants = parseVideoVariants(js{"video_info", "variants"})
proc addMedia(media: var MediaEntities; photo: Photo) =
media.add Media(kind: photoMedia, photo: photo)
proc addMedia(media: var MediaEntities; video: Video) =
media.add Media(kind: videoMedia, video: video)
proc addMedia(media: var MediaEntities; gif: Gif) =
media.add Media(kind: gifMedia, gif: gif)
proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) = proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
with jsMedia, js{"extended_entities", "media"}: with jsMedia, js{"extended_entities", "media"}:
for m in jsMedia: for m in jsMedia:
case m.getTypeName: case m.getTypeName:
of "photo": of "photo":
result.photos.add m{"media_url_https"}.getImageStr result.media.addMedia(Photo(
url: m{"media_url_https"}.getImageStr,
altText: m{"ext_alt_text"}.getStr
))
of "video": of "video":
result.video = some(parseVideo(m)) result.media.addMedia(parseVideo(m))
with user, m{"additional_media_info", "source_user"}: with user, m{"additional_media_info", "source_user"}:
if user{"id"}.getInt > 0: if user{"id"}.getInt > 0:
result.attribution = some(parseUser(user)) result.attribution = some(parseUser(user))
else: else:
result.attribution = some(parseGraphUser(user)) result.attribution = some(parseGraphUser(user))
# Set attribution link from expanded_url (strip /video/N suffix)
let expanded = m{"expanded_url"}.getStr
if expanded.len > 0:
result.attributionLink = expanded.parseUri.path.replace("/video/1", "")
of "animated_gif": of "animated_gif":
result.gif = some Gif( result.media.addMedia(Gif(
url: m{"video_info", "variants"}[0]{"url"}.getImageStr, url: m{"video_info", "variants"}[0]{"url"}.getImageStr,
thumb: m{"media_url_https"}.getImageStr thumb: m{"media_url_https"}.getImageStr,
) altText: m{"ext_alt_text"}.getStr
))
else: discard else: discard
with url, m{"url"}:
if result.text.endsWith(url.getStr):
result.text.removeSuffix(url.getStr)
result.text = result.text.strip()
proc parseMediaEntities(js: JsonNode; result: var Tweet) = proc parseMediaEntities(js: JsonNode; result: var Tweet) =
with mediaEntities, js{"media_entities"}: with mediaEntities, js{"media_entities"}:
var parsedMedia: MediaEntities
for mediaEntity in mediaEntities: for mediaEntity in mediaEntities:
with mediaInfo, mediaEntity{"media_results", "result", "media_info"}: with mediaInfo, mediaEntity{"media_results", "result", "media_info"}:
case mediaInfo.getTypeName case mediaInfo.getTypeName
of "ApiImage": of "ApiImage":
result.photos.add mediaInfo{"original_img_url"}.getImageStr parsedMedia.addMedia(Photo(
url: mediaInfo{"original_img_url"}.getImageStr,
altText: mediaInfo{"alt_text"}.getStr
))
of "ApiVideo": of "ApiVideo":
let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"} let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"}
result.video = some Video( parsedMedia.addMedia(Video(
available: status.getStr == "Available", available: status.getStr == "Available",
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr, thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
title: mediaInfo{"alt_text"}.getStr,
durationMs: mediaInfo{"duration_millis"}.getInt, durationMs: mediaInfo{"duration_millis"}.getInt,
variants: parseVideoVariants(mediaInfo{"variants"}) variants: parseVideoVariants(mediaInfo{"variants"})
) ))
# Parse source user for video attribution
with sourceUser, mediaEntity{"source_user_results", "result"}:
if result.attribution.isNone:
let expanded = mediaEntity{"expanded_url"}.getStr
if expanded.len > 0:
result.attributionLink = expanded.parseUri.path.replace("/video/1", "")
result.attribution = some(User(
id: sourceUser{"rest_id"}.getStr,
fullname: sourceUser{"core", "name"}.getStr,
userPic: sourceUser{"avatar", "image_url"}.getImageStr.replace("_normal", "")
))
of "ApiGif": of "ApiGif":
result.gif = some Gif( parsedMedia.addMedia(Gif(
url: mediaInfo{"variants"}[0]{"url"}.getImageStr, url: mediaInfo{"variants"}[0]{"url"}.getImageStr,
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
) altText: mediaInfo{"alt_text"}.getStr
))
else: discard else: discard
# Remove media URLs from text if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len:
with mediaList, js{"legacy", "entities", "media"}: result.media = parsedMedia
for url in mediaList:
let expandedUrl = url{"expanded_url"}.getStr
if result.text.endsWith(expandedUrl):
result.text.removeSuffix(expandedUrl)
result.text = result.text.strip()
proc parsePromoVideo(js: JsonNode): Video = proc parsePromoVideo(js: JsonNode): Video =
result = Video( result = Video(
@ -210,14 +396,23 @@ proc parsePromoVideo(js: JsonNode): Video =
result.variants.add variant result.variants.add variant
proc parseBroadcast(js: JsonNode): Card = proc parseBroadcast(js: JsonNode): Card =
let image = js{"broadcast_thumbnail_large"}.getImageVal let
image = js{"broadcast_thumbnail_large"}.getImageVal
broadcastUrl = js{"broadcast_url"}.getStrVal
broadcastId = broadcastUrl.rsplit('/', maxsplit=1)[^1]
streamUrl = "/i/broadcasts/" & broadcastId & "/stream"
result = Card( result = Card(
kind: broadcast, kind: broadcast,
url: js{"broadcast_url"}.getStrVal, url: "/i/broadcasts/" & broadcastId,
title: js{"broadcaster_display_name"}.getStrVal, title: js{"broadcaster_display_name"}.getStrVal,
text: js{"broadcast_title"}.getStrVal, text: js{"broadcast_title"}.getStrVal,
image: image, image: image,
video: some Video(thumb: image) video: some Video(
thumb: image,
available: true,
playbackType: m3u8,
variants: @[VideoVariant(contentType: m3u8, url: streamUrl)]
)
) )
proc parseCard(js: JsonNode; urls: JsonNode): Card = proc parseCard(js: JsonNode; urls: JsonNode): Card =
@ -256,7 +451,13 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
result.url = vals{"player_url"}.getStrVal result.url = vals{"player_url"}.getStrVal
if "youtube.com" in result.url: if "youtube.com" in result.url:
result.url = result.url.replace("/embed/", "/watch?v=") result.url = result.url.replace("/embed/", "/watch?v=")
of audiospace, unknown: of audiospace:
let spaceId = vals{"id"}.getStrVal
if spaceId.len > 0:
result.url = "/i/spaces/" & spaceId
result.title = "Twitter Space"
result.text = "Click to view Space"
of unknown:
result.title = "This card type is not supported." result.title = "This card type is not supported."
else: discard else: discard
@ -267,7 +468,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
for u in ? urls: for u in ? urls:
if u{"url"}.getStr == result.url: if u{"url"}.getStr == result.url:
result.url = u{"expanded_url"}.getStr result.url = u.getExpandedUrl(result.url)
break break
if kind in {videoDirectMessage, imageDirectMessage}: if kind in {videoDirectMessage, imageDirectMessage}:
@ -277,8 +478,9 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
result.url.len == 0 or result.url.startsWith("card://"): result.url.len == 0 or result.url.startsWith("card://"):
result.url = getPicUrl(result.image) result.url = getPicUrl(result.image)
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
if js.isNull: return replyId: int64 = 0; hasArticle = false): Tweet =
if js.isNull: return Tweet()
let time = let time =
if js{"created_at"}.notNull: js{"created_at"}.getTime if js{"created_at"}.notNull: js{"created_at"}.getTime
@ -301,6 +503,9 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
) )
) )
if result.replyId == 0:
result.replyId = replyId
# fix for pinned threads # fix for pinned threads
if result.hasThread and result.threadId == 0: if result.hasThread and result.threadId == 0:
result.threadId = js{"self_thread", "id_str"}.getId result.threadId = js{"self_thread", "id_str"}.getId
@ -318,13 +523,13 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
# graphql # graphql
with rt, js{"retweeted_status_result", "result"}: with rt, js{"retweeted_status_result", "result"}:
# needed due to weird edgecase where the actual tweet data isn't included # needed due to weird edgecase where the actual tweet data isn't included
if "legacy" in rt: if "legacy" in rt or "rest_id" in rt:
result.retweet = some parseGraphTweet(rt) result.retweet = some parseGraphTweet(rt)
return return
with reposts, js{"repostedStatusResults"}: with reposts, js{"repostedStatusResults"}:
with rt, reposts{"result"}: with rt, reposts{"result"}:
if "legacy" in rt: if "legacy" in rt or "rest_id" in rt:
result.retweet = some parseGraphTweet(rt) result.retweet = some parseGraphTweet(rt)
return return
@ -332,15 +537,17 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
let name = jsCard{"name"}.getStr let name = jsCard{"name"}.getStr
if "poll" in name: if "poll" in name:
if "image" in name: if "image" in name:
result.photos.add jsCard{"binding_values", "image_large"}.getImageVal result.media.addMedia(Photo(
url: jsCard{"binding_values", "image_large"}.getImageVal
))
result.poll = some parsePoll(jsCard) result.poll = some parsePoll(jsCard)
elif name == "amplify": elif name == "amplify":
result.video = some(parsePromoVideo(jsCard{"binding_values"})) result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
else: elif name.len > 0 and jsCard{"binding_values"}.notNull:
result.card = some parseCard(jsCard, js{"entities", "urls"}) result.card = some parseCard(jsCard, js{"entities", "urls"})
result.expandTweetEntities(js) result.expandTweetEntities(js, hasArticle)
parseLegacyMediaEntities(js, result) parseLegacyMediaEntities(js, result)
with jsWithheld, js{"withheld_in_countries"}: with jsWithheld, js{"withheld_in_countries"}:
@ -357,7 +564,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
result.text.removeSuffix(" Learn more.") result.text.removeSuffix(" Learn more.")
result.available = false result.available = false
proc parseGraphTweet(js: JsonNode): Tweet = proc parseGraphTweet*(js: JsonNode): Tweet =
if js.kind == JNull: if js.kind == JNull:
return Tweet() return Tweet()
@ -375,7 +582,7 @@ proc parseGraphTweet(js: JsonNode): Tweet =
else: else:
discard discard
if not js.hasKey("legacy"): if "legacy" notin js and "rest_id" notin js:
return Tweet() return Tweet()
var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"}) var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"})
@ -394,12 +601,80 @@ proc parseGraphTweet(js: JsonNode): Tweet =
"binding_values": %bindingObj "binding_values": %bindingObj
} }
result = parseTweet(js{"legacy"}, jsCard) var replyId: int64 = 0
result.id = js{"rest_id"}.getId with restId, js{"reply_to_results", "rest_id"}:
replyId = restId.getId
let hasArticle = js{"article", "article_results", "result", "title"}.getStr.len > 0
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"}))
elif name.len > 0 and jsCard{"binding_values"}.notNull:
result.card = some parseCard(jsCard, js{"url_entities"})
parseMediaEntities(js, result)
if result.attribution.isNone:
parseLegacyMediaEntities(js{"legacy"}, result)
result.expandTweetEntitiesV2(js, hasArticle)
# Strip video source URL from text (for videos from other tweets)
with mediaEntities, js{"media_entities"}:
for m in mediaEntities:
if "source_status_id_str" in m:
let mediaUrl = m{"url"}.getStr
if mediaUrl.len > 0:
let idx = result.text.rfind(mediaUrl)
if idx >= 0:
result.text = result.text[0 ..< idx].strip()
break
else:
result = parseTweet(js{"legacy"}, jsCard, replyId, hasArticle)
result.id = js{"rest_id"}.getId
with artNode, js{"article", "article_results", "result"}:
let artTitle = artNode{"title"}.getStr
if artTitle.len > 0:
result.articlePreview = some ArticlePreview(
title: artTitle,
previewText: artNode{"preview_text"}.getStr,
coverImage: select(
artNode{"cover_media_results", "result", "media_info", "original_img_url"},
artNode{"cover_media", "media_info", "original_img_url"}
).getImageStr,
tweetId: result.id
)
result.user = parseGraphUser(js{"core"}) result.user = parseGraphUser(js{"core"})
if result.replyId == 0: if result.reply.len == 0:
result.replyId = js{"reply_to_results", "rest_id"}.getId with replyTo, js{"reply_to_user_results", "result", "core", "screen_name"}:
result.reply = @[replyTo.getStr]
with count, js{"views", "count"}: with count, js{"views", "count"}:
result.stats.views = count.getStr("0").parseInt result.stats.views = count.getStr("0").parseInt
@ -409,21 +684,58 @@ proc parseGraphTweet(js: JsonNode): Tweet =
parseMediaEntities(js, result) parseMediaEntities(js, result)
if result.quote.isSome: # Hide card if it's redundant with attribution (same video shown via embed)
result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"})) if result.attribution.isSome and result.card.isSome:
let cardUri = get(result.card).url.parseUri
if cardUri.isTwitterUrl:
let cardPath = cardUri.path.replace("/video/1", "")
if cardPath.len > 0 and cardPath == result.attributionLink:
get(result.card).kind = hidden
with quoted, js{"quotedPostResults", "result"}: # Handle retweets - check both legacy and top-level paths
with reposts, js{"legacy", "repostedStatusResults"}:
with rt, reposts{"result"}:
if "legacy" in rt or "rest_id" in rt:
result.retweet = some parseGraphTweet(rt)
with quoted, js{"quoted_status_result", "result"}:
result.quote = some(parseGraphTweet(quoted)) 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 getConvSection(js: JsonNode): string =
let details = select(
js{"item", "client_event_info", "details"},
js{"item", "clientEventInfo", "details"}
)
select(
details{"conversation_details", "conversation_section"},
details{"conversationDetails", "conversationSection"}
).getStr
proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
var checkedSection = false
for t in ? js{"content", "items"}: for t in ? js{"content", "items"}:
let entryId = t.getEntryId let entryId = t.getEntryId
if "cursor-showmore" in entryId: if "tweet-" in entryId and "promoted" notin entryId:
let cursor = t{"item", "content", "value"} if not checkedSection:
result.thread.cursor = cursor.getStr checkedSection = true
result.thread.hasMore = true if getConvSection(t) == "RelatedTweet":
elif "tweet" in entryId and "promoted" notin entryId: result.thread.related = true
with tweet, t.getTweetResult("item"):
let tweet = t.getTweetResult("item")
if tweet.notNull:
result.thread.content.add parseGraphTweet(tweet) result.thread.content.add parseGraphTweet(tweet)
let tweetDisplayType = select( let tweetDisplayType = select(
@ -432,11 +744,31 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
) )
if tweetDisplayType.getStr == "SelfThread": if tweetDisplayType.getStr == "SelfThread":
result.self = true 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 = proc parseGraphTweetResult*(js: JsonNode): Tweet =
with tweet, js{"data", "tweet_result", "result"}: with tweet, js{"data", "tweet_result", "result"}:
result = parseGraphTweet(tweet) result = parseGraphTweet(tweet)
proc parseTweetByRestId*(js: JsonNode): Tweet =
with tweet, js{"data", "tweetResult", "result"}:
result = parseGraphTweet(tweet)
proc parseGraphTweetResults*(js: JsonNode): seq[Tweet] =
let results = js{"data", "tweetResult"}
if results.kind != JArray: return
for item in results:
let tweet = item{"result"}
if tweet.isNull: continue
let t = parseGraphTweet(tweet)
if t != nil:
result.add t
proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
result = Conversation(replies: Result[Chain](beginning: true)) result = Conversation(replies: Result[Chain](beginning: true))
@ -452,7 +784,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
if i.getTypeName == "TimelineAddEntries": if i.getTypeName == "TimelineAddEntries":
for e in i{"entries"}: for e in i{"entries"}:
let entryId = e.getEntryId let entryId = e.getEntryId
if entryId.startsWith("tweet"): if entryId.startsWith("tweet-"):
let tweetResult = getTweetResult(e) let tweetResult = getTweetResult(e)
if tweetResult.notNull: if tweetResult.notNull:
let tweet = parseGraphTweet(tweetResult) let tweet = parseGraphTweet(tweetResult)
@ -460,11 +792,14 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
if not tweet.available: if not tweet.available:
tweet.id = entryId.getId tweet.id = entryId.getId
if $tweet.id == tweetId: if entryId.endsWith(tweetId):
result.tweet = tweet result.tweet = tweet
else: else:
result.before.content.add tweet result.before.content.add tweet
elif entryId.startsWith("conversationthread"): elif not entryId.endsWith(tweetId):
result.before.content.add Tweet(id: entryId.getId)
elif entryId.startsWith("conversationthread") or
entryId.startsWith("tweetdetailrelatedtweets"):
let (thread, self) = parseGraphThread(e) let (thread, self) = parseGraphThread(e)
if self: if self:
result.after = thread result.after = thread
@ -485,25 +820,60 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
result.before.content.add tweet result.before.content.add tweet
elif entryId.startsWith("cursor-bottom"): elif entryId.startsWith("cursor-bottom"):
var cursorValue = select( var cursorValue = select(
e{"content", "value"},
e{"content", "content", "value"}, e{"content", "content", "value"},
e{"content", "itemContent", "value"} e{"content", "itemContent", "value"}
) )
result.replies.bottom = cursorValue.getStr 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)
iterator extractTweetsFromModuleItems(items: JsonNode): Tweet =
for item in items:
with tweetResult, item.getTweetResult("item"):
let tweet = parseGraphTweet(tweetResult)
if not tweet.available:
tweet.id = item.getEntryId.getId
yield tweet
iterator extractListsFromItems(items: JsonNode): ListSearchResult =
for item in items:
with listJs, item{"item", "itemContent", "list"}:
let r = parseGraphSearchList(listJs)
if r.list.id.len > 0:
yield r
proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] = proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] =
with tweetResult, getTweetResult(e): with tweetResult, getTweetResult(e):
var tweet = parseGraphTweet(tweetResult) let tweet = parseGraphTweet(tweetResult)
if not tweet.available: if not tweet.available:
tweet.id = e.getEntryId.getId tweet.id = e.getEntryId.getId
result.add tweet result.add tweet
return return
for item in e{"content", "items"}: for tweet in extractTweetsFromModuleItems(e{"content", "items"}):
with tweetResult, item.getTweetResult("item"): result.add tweet
var tweet = parseGraphTweet(tweetResult)
if not tweet.available:
tweet.id = item.getEntryId.getId
result.add tweet
proc parseGraphTimeline*(js: JsonNode; after=""): Profile = proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
result = Profile(tweets: Timeline(beginning: after.len == 0)) result = Profile(tweets: Timeline(beginning: after.len == 0))
@ -518,12 +888,8 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
for i in instructions: for i in instructions:
if i{"moduleItems"}.notNull: if i{"moduleItems"}.notNull:
for item in i{"moduleItems"}: for tweet in extractTweetsFromModuleItems(i{"moduleItems"}):
with tweetResult, item.getTweetResult("item"): result.tweets.content.add tweet
let tweet = parseGraphTweet(tweetResult)
if not tweet.available:
tweet.id = item.getEntryId.getId
result.tweets.content.add tweet
continue continue
if i{"entries"}.notNull: if i{"entries"}.notNull:
@ -558,18 +924,13 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
for i in instructions: for i in instructions:
if i{"moduleItems"}.notNull: if i{"moduleItems"}.notNull:
for item in i{"moduleItems"}: for t in extractTweetsFromModuleItems(i{"moduleItems"}):
with tweetResult, item.getTweetResult("item"): let photo = extractGalleryPhoto(t)
let t = parseGraphTweet(tweetResult) if photo.url.len > 0:
if not t.available: result.add photo
t.id = item.getEntryId.getId
let photo = extractGalleryPhoto(t) if result.len == 16:
if photo.url.len > 0: return
result.add photo
if result.len == 16:
return
continue continue
if i.getTypeName != "TimelineAddEntries": if i.getTypeName != "TimelineAddEntries":
@ -586,7 +947,7 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
if result.len == 16: if result.len == 16:
return return
proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] = proc parseGraphSearch*[T: User | Tweets | ListSearchResult](js: JsonNode; after=""): Result[T] =
result = Result[T](beginning: after.len == 0) result = Result[T](beginning: after.len == 0)
let instructions = select( let instructions = select(
@ -602,19 +963,73 @@ proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
for e in instruction{"entries"}: for e in instruction{"entries"}:
let entryId = e.getEntryId let entryId = e.getEntryId
when T is Tweets: when T is Tweets:
if entryId.startsWith("tweet"): if entryId.startsWith("tweet") or entryId.startsWith("search-grid"):
with tweetRes, getTweetResult(e): for tweet in extractTweetsFromEntry(e):
let tweet = parseGraphTweet(tweetRes)
if not tweet.available:
tweet.id = entryId.getId
result.content.add tweet result.content.add tweet
elif T is User: elif T is User:
if entryId.startsWith("user"): if entryId.startsWith("user"):
with userRes, e{"content", "itemContent"}: with userRes, e{"content", "itemContent"}:
result.content.add parseGraphUser(userRes) result.content.add parseGraphUser(userRes)
elif T is ListSearchResult:
if entryId.startsWith("list-search"):
for list in extractListsFromItems(e{"content", "items"}):
result.content.add list
if entryId.startsWith("cursor-bottom"): if entryId.startsWith("cursor-bottom"):
result.bottom = e{"content", "value"}.getStr result.bottom = e{"content", "value"}.getStr
elif typ == "TimelineAddToModule":
when T is Tweets:
for tweet in extractTweetsFromModuleItems(instruction{"moduleItems"}):
result.content.add tweet
elif T is ListSearchResult:
for list in extractListsFromItems(instruction{"moduleItems"}):
result.content.add list
elif typ == "TimelineReplaceEntry": elif typ == "TimelineReplaceEntry":
if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"): if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"):
result.bottom = instruction{"entry", "content", "value"}.getStr result.bottom = instruction{"entry", "content", "value"}.getStr
proc parseGraphCommunityTimeline*(js: JsonNode; after=""): Timeline =
result = Timeline(beginning: after.len == 0)
let communityResult = js{"data", "communityResults", "result"}
let instructions = ? select(
communityResult{"ranked_community_timeline", "timeline", "instructions"},
communityResult{"community_media_timeline", "timeline", "instructions"},
communityResult{"community_filtered_timeline", "timeline", "instructions"}
)
if instructions.len == 0:
return
for i in instructions:
if i{"entries"}.notNull:
for e in i{"entries"}:
let entryId = e.getEntryId
if entryId.startsWith("tweet") or entryId.startsWith("profile-grid") or
entryId.startsWith("communities-grid"):
for tweet in extractTweetsFromEntry(e):
result.content.add tweet
elif entryId.startsWith("cursor-bottom"):
result.bottom = e{"content", "value"}.getStr
if after.len == 0 and i.getTypeName == "TimelinePinEntry":
var tweets = extractTweetsFromEntry(i{"entry"})
for tweet in tweets.mitems:
tweet.pinned = true
if tweets.len > 0:
result.content.insert(tweets, 0)
proc parseGraphCommunityMembers*(js: JsonNode; after=""): Result[User] =
result = Result[User](beginning: after.len == 0)
let r = js{"data", "communityResults", "result"}
let slice = if not r{"members_slice"}.isNull: r{"members_slice"}
else: r{"moderators_slice"}
for item in slice{"items_results"}:
let user = parseGraphUser(item{"result"})
if user.username.len > 0:
result.content.add user
let cursor = slice{"slice_info", "next_cursor"}.getStr
if cursor.len > 0:
result.bottom = cursor

View file

@ -17,7 +17,7 @@ let
unReplace = "$1<a href=\"/$2\">@$2</a>" unReplace = "$1<a href=\"/$2\">@$2</a>"
htRegex = re"(^|[^\w-_./?])([#$]|)([\w_]+)" 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 type
ReplaceSliceKind = enum ReplaceSliceKind = enum
@ -72,7 +72,6 @@ template getTypeName*(js: JsonNode): string =
template getEntryId*(e: JsonNode): string = template getEntryId*(e: JsonNode): string =
e{"entryId"}.getStr(e{"entry_id"}.getStr) e{"entryId"}.getStr(e{"entry_id"}.getStr)
template parseTime(time: string; f: static string; flen: int): DateTime = template parseTime(time: string; f: static string; flen: int): DateTime =
if time.len != flen: return if time.len != flen: return
parse(time, f, utc()) parse(time, f, utc())
@ -89,11 +88,19 @@ proc getTimeFromMs*(js: JsonNode): DateTime =
let seconds = ms div 1000 let seconds = ms div 1000
return fromUnix(seconds).utc() return fromUnix(seconds).utc()
proc getTimeFromMsStr*(js: JsonNode): DateTime =
var ms: int64
try: ms = parseBiggestInt(js.getStr("0"))
except ValueError: return
if ms == 0: return
let seconds = ms div 1000
return fromUnix(seconds).utc()
proc getId*(id: string): int64 {.inline.} = proc getId*(id: string): int64 {.inline.} =
let start = id.rfind("-") let start = id.rfind("-")
if start < 0: try:
return parseBiggestInt(id) parseBiggestInt(if start < 0: id else: id[start + 1 ..< id.len])
return parseBiggestInt(id[start + 1 ..< id.len]) except ValueError: 0'i64
proc getId*(js: JsonNode): int64 {.inline.} = proc getId*(js: JsonNode): int64 {.inline.} =
case js.kind case js.kind
@ -112,6 +119,9 @@ proc getImageStr*(js: JsonNode): string =
template getImageVal*(js: JsonNode): string = template getImageVal*(js: JsonNode): string =
js{"image_value", "url"}.getImageStr 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 = proc getCardUrl*(js: JsonNode; kind: CardKind): string =
result = js{"website_url"}.getStrVal result = js{"website_url"}.getStrVal
if kind == promoVideoConvo: if kind == promoVideoConvo:
@ -175,12 +185,16 @@ proc extractSlice(js: JsonNode): Slice[int] =
result = js["indices"][0].getInt ..< js["indices"][1].getInt result = js["indices"][0].getInt ..< js["indices"][1].getInt
proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode; proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
textLen: int; hideTwitter = false) = textLen: int; hideTwitter = false;
hideArticle = false) =
let let
url = js["expanded_url"].getStr url = js.getExpandedUrl
slice = js.extractSlice slice = js.extractSlice
if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl: if hideArticle and url.isTwitterUrl and "/article/" in url:
if slice.a < textLen:
result.add ReplaceSlice(kind: rkRemove, slice: slice)
elif hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
if slice.a < textLen: if slice.a < textLen:
result.add ReplaceSlice(kind: rkRemove, slice: slice) result.add ReplaceSlice(kind: rkRemove, slice: slice)
else: else:
@ -192,28 +206,41 @@ proc extractHashtags(result: var seq[ReplaceSlice]; js: JsonNode) =
proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice]; proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice];
textSlice: Slice[int]): string = textSlice: Slice[int]): string =
let
runeLen = runes.len
safeStart = max(0, textSlice.a)
safeEnd = min(runeLen, textSlice.b)
var validRepls: seq[ReplaceSlice]
for rep in repls:
if rep.slice.a >= 0 and rep.slice.b >= 0 and rep.slice.b < runeLen and rep.slice.a <= rep.slice.b:
validRepls.add rep
template extractLowerBound(i: int; idx): int = template extractLowerBound(i: int; idx): int =
if i > 0: repls[idx].slice.b.succ else: textSlice.a if i > 0: min(validRepls[idx].slice.b.succ, runeLen) else: safeStart
result = newStringOfCap(runes.len) result = newStringOfCap(runes.len)
for i, rep in repls: for i, rep in validRepls:
result.add $runes[extractLowerBound(i, i - 1) ..< rep.slice.a] let lower = extractLowerBound(i, i - 1)
if lower < rep.slice.a:
result.add $runes[lower ..< rep.slice.a]
case rep.kind case rep.kind
of rkHashtag: of rkHashtag:
let if rep.slice.a.succ <= rep.slice.b:
name = $runes[rep.slice.a.succ .. rep.slice.b] let
symbol = $runes[rep.slice.a] name = $runes[rep.slice.a.succ .. rep.slice.b]
result.add a(symbol & name, href = "/search?q=%23" & name) symbol = $runes[rep.slice.a]
result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name)
of rkMention: of rkMention:
result.add a($runes[rep.slice], href = rep.url, title = rep.display) result.add a($runes[rep.slice], href = rep.url, title = escape(rep.display))
of rkUrl: of rkUrl:
result.add a(rep.display, href = rep.url) result.add a(escape(rep.display), href = rep.url)
of rkRemove: of rkRemove:
discard discard
let rest = extractLowerBound(repls.len, ^1) ..< textSlice.b let rest = extractLowerBound(validRepls.len, ^1) ..< safeEnd
if rest.a <= rest.b: if rest.a >= 0 and rest.a <= rest.b and rest.b <= runeLen:
result.add $runes[rest] result.add $runes[rest]
proc deduplicate(s: var seq[ReplaceSlice]) = proc deduplicate(s: var seq[ReplaceSlice]) =
@ -238,7 +265,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
ent = ? js{"entities"} ent = ? js{"entities"}
with urls, ent{"url", "urls"}: with urls, ent{"url", "urls"}:
user.website = urls[0]{"expanded_url"}.getStr user.website = urls[0].getExpandedUrl
var replacements = newSeq[ReplaceSlice]() var replacements = newSeq[ReplaceSlice]()
@ -254,7 +281,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
.replacef(htRegex, htReplace) .replacef(htRegex, htReplace)
proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int]; proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int];
replyTo=""; hasRedundantLink=false) = replyTo=""; hasRedundantLink=false; hasArticle=false) =
let hasCard = tweet.card.isSome let hasCard = tweet.card.isSome
var replacements = newSeq[ReplaceSlice]() var replacements = newSeq[ReplaceSlice]()
@ -265,10 +292,11 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
if urlStr.len == 0 or urlStr notin text: if urlStr.len == 0 or urlStr notin text:
continue continue
replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink) replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink,
hideArticle = hasArticle)
if hasCard and u{"url"}.getStr == get(tweet.card).url: 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"}: with media, entities{"media"}:
for m in media: for m in media:
@ -302,7 +330,7 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false) tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false)
proc expandTweetEntities*(tweet: Tweet; js: JsonNode) = proc expandTweetEntities*(tweet: Tweet; js: JsonNode; hasArticle=false) =
let let
entities = ? js{"entities"} entities = ? js{"entities"}
textRange = js{"display_text_range"} textRange = js{"display_text_range"}
@ -316,23 +344,99 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
replyTo = reply.getStr replyTo = reply.getStr
tweet.reply.add replyTo tweet.reply.add replyTo
tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote or hasJobCard) tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo,
hasQuote or hasJobCard, hasArticle)
proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int];
hasRedundantLink=false; hasArticle=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,
hideArticle = hasArticle)
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; hasArticle=false) =
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
hasAttribution = tweet.attribution.isSome
tweet.expandTextEntitiesV2(js, tweet.text, textSlice,
hasQuote or hasJobCard or hasAttribution,
hasArticle)
proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) = proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
let let
entities = ? js{"entity_set"} entities = ? js{"entity_set"}
text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose)) text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose))
textSlice = 0..text.runeLen textSlice = 0..text.runeLen
hasAttribution = tweet.attribution.isSome
tweet.expandTextEntities(entities, text, textSlice) tweet.expandTextEntities(entities, text, textSlice, hasRedundantLink=hasAttribution)
tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose)) 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 = proc extractGalleryPhoto*(t: Tweet): GalleryPhoto =
let url = let url =
if t.photos.len > 0: t.photos[0] if t.media.len > 0: t.media[0].getThumb
elif t.video.isSome: get(t.video).thumb
elif t.gif.isSome: get(t.gif).thumb
elif t.card.isSome: get(t.card).image elif t.card.isSome: get(t.card).image
else: "" else: ""

View file

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

View file

@ -60,6 +60,9 @@ genPrefs:
stickyProfile(checkbox, true): stickyProfile(checkbox, true):
"Make profile sidebar stick to top" "Make profile sidebar stick to top"
stickyNav(checkbox, true):
"Keep navbar fixed to top"
bidiSupport(checkbox, false): bidiSupport(checkbox, false):
"Support bidirectional text (makes clicking on tweets harder)" "Support bidirectional text (makes clicking on tweets harder)"
@ -75,6 +78,12 @@ genPrefs:
hideReplies(checkbox, false): hideReplies(checkbox, false):
"Hide tweet replies" "Hide tweet replies"
hideRelated(checkbox, true):
"Hide related tweets under replies"
hideCommunityNotes(checkbox, false):
"Hide community notes"
squareAvatars(checkbox, false): squareAvatars(checkbox, false):
"Square profile pictures" "Square profile pictures"
@ -94,6 +103,17 @@ genPrefs:
autoplayGifs(checkbox, true): autoplayGifs(checkbox, true):
"Autoplay gifs" "Autoplay gifs"
compactGallery(checkbox, false):
"Compact media gallery (no profile info or text)"
gallerySize(select, "Medium"):
"Gallery column size"
options: @["Small", "Medium", "Large"]
mediaView(select, "Timeline"):
"Default media view"
options: @["Timeline", "Grid", "Gallery"]
"Link replacements (blank to disable)": "Link replacements (blank to disable)":
replaceTwitter(input, ""): replaceTwitter(input, ""):
"Twitter -> Nitter" "Twitter -> Nitter"
@ -127,7 +147,7 @@ macro genDefaultPrefs*(): untyped =
result.add quote do: result.add quote do:
defaultPrefs.`ident` = cfg.get("Preferences", `name`, `default`) defaultPrefs.`ident` = cfg.get("Preferences", `name`, `default`)
macro genCookiePrefs*(cookies): untyped = macro genParsePrefs*(prefs): untyped =
result = nnkStmtList.newTree() result = nnkStmtList.newTree()
for pref in allPrefs(): for pref in allPrefs():
let let
@ -137,37 +157,17 @@ macro genCookiePrefs*(cookies): untyped =
options = pref.options options = pref.options
result.add quote do: result.add quote do:
if `name` in `cookies`: if `name` in `prefs`:
when `kind` == input or `name` == "theme": when `kind` == input or `name` == "theme":
result.`ident` = `cookies`[`name`] result.`ident` = `prefs`[`name`]
elif `kind` == checkbox: elif `kind` == checkbox:
result.`ident` = `cookies`[`name`] == "on" result.`ident` = `prefs`[`name`] == "on" or
`prefs`[`name`] == "true" or
`prefs`[`name`] == "1"
else: else:
let value = `cookies`[`name`] let value = `prefs`[`name`]
if value in `options`: result.`ident` = value 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 = macro genUpdatePrefs*(): untyped =
result = nnkStmtList.newTree() result = nnkStmtList.newTree()
let req = ident("request") let req = ident("request")
@ -202,6 +202,36 @@ macro genResetPrefs*(): untyped =
result.add quote do: result.add quote do:
savePref(`name`, "", `req`, expire=true) 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 = macro genPrefsType*(): untyped =
let name = nnkPostfix.newTree(ident("*"), ident("Prefs")) let name = nnkPostfix.newTree(ident("*"), ident("Prefs"))
result = quote do: result = quote do:

View file

@ -1,15 +1,14 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, sequtils, tables, uri import strutils, strformat, sequtils, tables, uri
import types import types, utils
const const
validFilters* = @[ validFilters* = @[
"media", "images", "twimg", "videos", "media", "images", "twimg", "videos",
"native_video", "consumer_video", "pro_video", "native_video", "consumer_video", "spaces",
"links", "news", "quote", "mentions", "links", "news", "quote", "mentions",
"replies", "retweets", "nativeretweets", "replies", "retweets", "nativeretweets"
"verified", "safe"
] ]
emptyQuery* = "include:nativeretweets" emptyQuery* = "include:nativeretweets"
@ -21,32 +20,43 @@ template `@`(param: string): untyped =
proc initQuery*(pms: Table[string, string]; name=""): Query = proc initQuery*(pms: Table[string, string]; name=""): Query =
result = Query( result = Query(
kind: parseEnum[QueryKind](@"f", tweets), kind: parseEnum[QueryKind](@"f", tweets),
view: @"view",
text: @"q", text: @"q",
filters: validFilters.filterIt("f-" & it in pms), filters: validFilters.filterIt("f-" & it in pms),
excludes: validFilters.filterIt("e-" & it in pms), excludes: validFilters.filterIt("e-" & it in pms),
since: @"since", since: @"since",
until: @"until", until: @"until",
near: @"near" minLikes: validateNumber(@"min_faves")
) )
# articles is an internal tab kind, not a valid search filter
if result.kind == QueryKind.articles:
result.kind = tweets
if name.len > 0: if name.len > 0:
result.fromUser = name.split(",") result.fromUser = name.split(",")
proc getMediaQuery*(name: string): Query = proc getMediaQuery*(name: string): Query =
Query( Query(
kind: media, kind: QueryKind.media,
filters: @["twimg", "native_video"], filters: @["twimg", "native_video"],
fromUser: @[name], fromUser: @[name],
sep: "OR" sep: "OR"
) )
proc getArticlesQuery*(name: string): Query =
Query(
kind: QueryKind.articles,
fromUser: @[name]
)
proc getReplyQuery*(name: string): Query = proc getReplyQuery*(name: string): Query =
Query( Query(
kind: replies, kind: replies,
fromUser: @[name] fromUser: @[name]
) )
proc genQueryParam*(query: Query): string = proc genQueryParam*(query: Query; maxId=""): string =
var var
filters: seq[string] filters: seq[string]
param: string param: string
@ -55,15 +65,20 @@ proc genQueryParam*(query: Query): string =
return query.text return query.text
for i, user in query.fromUser: for i, user in query.fromUser:
param &= &"from:{user} " if i == 0:
if i < query.fromUser.high: param = "("
param &= "OR "
if query.fromUser.len > 0 and query.kind in {posts, media}: param &= &"from:{user}"
param &= "filter:self_threads OR -filter:replies " if i < query.fromUser.high:
param &= " OR "
else:
param &= ")"
if query.fromUser.len > 0 and query.kind in {posts, QueryKind.media}:
param &= " (filter:self_threads OR -filter:replies)"
if "nativeretweets" notin query.excludes: if "nativeretweets" notin query.excludes:
param &= "include:nativeretweets " param &= " include:nativeretweets"
for f in query.filters: for f in query.filters:
filters.add "filter:" & f filters.add "filter:" & f
@ -73,38 +88,51 @@ proc genQueryParam*(query: Query): string =
for i in query.includes: for i in query.includes:
filters.add "include:" & i 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: if query.since.len > 0:
result &= " since:" & query.since result &= " since:" & query.since
if query.until.len > 0: if query.until.len > 0 and maxId.len == 0:
result &= " until:" & query.until result &= " until:" & query.until
if query.near.len > 0: if query.minLikes.len > 0:
result &= &" near:\"{query.near}\" within:15mi" result &= " min_faves:" & query.minLikes
if query.text.len > 0: if query.text.len > 0:
if result.len > 0: if result.len > 0:
result &= " " & query.text result &= " " & query.text
else: else:
result = query.text result = query.text
if result.len > 0 and maxId.len > 0:
result &= " max_id:" & maxId
proc genQueryUrl*(query: Query): string = proc genQueryUrl*(query: Query): string =
if query.kind notin {tweets, users}: return var params: seq[string]
var params = @[&"f={query.kind}"] if query.view.len > 0:
if query.text.len > 0: params.add "view=" & encodeUrl(query.view)
params.add "q=" & encodeUrl(query.text)
for f in query.filters:
params.add &"f-{f}=on"
for e in query.excludes:
params.add &"e-{e}=on"
for i in query.includes.filterIt(it != "nativeretweets"):
params.add &"i-{i}=on"
if query.since.len > 0: # media doubles as the profile media tab, where f isn't part of the URL scheme
params.add "since=" & query.since if query.kind in {tweets, users, lists, top} or
if query.until.len > 0: (query.kind == QueryKind.media and query.fromUser.len == 0):
params.add "until=" & query.until params.add &"f={query.kind}"
if query.near.len > 0: if query.text.len > 0:
params.add "near=" & query.near 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: if params.len > 0:
result &= params.join("&") result &= params.join("&")

View file

@ -144,9 +144,10 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
else: else:
let user = await getGraphUserById(userId) let user = await getGraphUserById(userId)
result = user.username result = user.username
await setEx(key, baseCacheTime, result) if result.len > 0:
if result.len > 0 and user.id.len > 0: await setEx(key, baseCacheTime, result)
await all(cacheUserId(result, user.id), cache(user)) if user.id.len > 0:
await all(cacheUserId(result, user.id), cache(user))
# proc getCachedTweet*(id: int64): Future[Tweet] {.async.} = # proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
# if id == 0: return # if id == 0: return
@ -158,6 +159,48 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
# if not result.isNil: # if not result.isNil:
# await cache(result) # await cache(result)
proc cache*(data: Broadcast) {.async.} =
if data.id.len == 0: return
await setEx("bc:" & data.id, baseCacheTime, compress(toFlatty(data)))
proc getCachedBroadcast*(id: string): Future[Broadcast] {.async.} =
if id.len == 0: return
let cached = await get("bc:" & id)
if cached != redisNil:
cached.deserialize(Broadcast)
else:
result = await getBroadcastInfo(id)
await cache(result)
result.m3u8Url = await fetchBroadcastStream(result.mediaKey)
proc cache*(data: AudioSpace) {.async.} =
if data.id.len == 0: return
let ttl = if data.state == "RUNNING": baseCacheTime div 6 else: baseCacheTime
await setEx("sp:" & data.id, ttl, compress(toFlatty(data)))
proc getCachedAudioSpace*(id: string): Future[AudioSpace] {.async.} =
if id.len == 0: return
let cached = await get("sp:" & id)
if cached != redisNil:
cached.deserialize(AudioSpace)
else:
result = await getAudioSpace(id)
await cache(result)
result.m3u8Url = await fetchBroadcastStream(result.mediaKey)
proc cache*(data: AccountInfo; name: string) {.async.} =
await setEx("ai:" & toLower(name), baseCacheTime * 24, compress(toFlatty(data)))
proc getCachedAccountInfo*(username: string; fetch=true): Future[AccountInfo] {.async.} =
if username.len == 0: return
let name = toLower(username)
let cached = await get("ai:" & name)
if cached != redisNil:
cached.deserialize(AccountInfo)
elif fetch:
result = await getAboutAccount(username)
await cache(result, name)
proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} = proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} =
if id.len == 0: return if id.len == 0: return
let rail = await get("pr2:" & toLower(id)) let rail = await get("pr2:" & toLower(id))
@ -167,6 +210,29 @@ proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} =
result = await getPhotoRail(id) result = await getPhotoRail(id)
await cache(result, id) await cache(result, id)
proc cache*(data: Community) {.async.} =
if data.id.len == 0: return
await setEx("cm:" & data.id, listCacheTime, compress(toFlatty(data)))
proc getCachedCommunity*(id: string): Future[Community] {.async.} =
if id.len == 0: return
let cached = await get("cm:" & id)
if cached != redisNil:
cached.deserialize(Community)
else:
result = await getGraphCommunity(id)
await cache(result)
proc getCachedCommunityModerators*(id: string): Future[seq[User]] {.async.} =
if id.len == 0: return
let cached = await get("cmm:" & id)
if cached != redisNil:
cached.deserialize(seq[User])
else:
let mods = await getGraphCommunityModerators(id)
result = mods.content
await setEx("cmm:" & id, listCacheTime, compress(toFlatty(result)))
proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} = proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
let list = if id.len == 0: redisNil let list = if id.len == 0: redisNil
else: await get("l:" & id) else: await get("l:" & id)

48
src/routes/article.nim Normal file
View file

@ -0,0 +1,48 @@
# SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, tables, strutils
import jester, karax/vdom
import ".."/[types, api]
import ../views/[article, general]
import router_utils
export api, article, vdom, general, router_utils
proc createArticleRouter*(cfg: Config) =
router articleRoute:
get "/i/article/@id":
cond @"id".allCharsInSet(Digits)
let article = await getGraphArticle(@"id")
if article == nil:
resp Http404, showError("Article not found", cfg)
var tweetIds: seq[string]
for e in article.entities.values:
if e.kind == "TWEET":
tweetIds.add e.tweetId
var tweets = initTable[int64, Tweet]()
if tweetIds.len > 0:
try:
for t in await getGraphTweetResults(tweetIds):
tweets[t.id] = t
except CatchableError:
discard
let
prefs = requestPrefs()
path = getPath()
html = renderArticle(article, tweets, path, prefs, @"id")
twitterUrl = "https://x.com/" & article.user.username & "/article/" & @"id"
resp renderMain(html, request, cfg, prefs, titleText=article.title,
twitterLink=twitterUrl)
get "/@name/article/@id/?":
cond '.' notin @"name"
cond @"id".allCharsInSet(Digits)
redirect("/i/article/" & @"id")
get "/@name/status/@id/article":
cond '.' notin @"name"
cond @"id".allCharsInSet(Digits)
redirect("/i/article/" & @"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

89
src/routes/community.nim Normal file
View file

@ -0,0 +1,89 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strformat
import jester
import router_utils
import ".."/[types, redis_cache, api]
import ../views/[general, timeline, community]
export community
template respCommunity*(cmty: Community; title: string; nav, vnode: typed) =
if cmty.id.len == 0 or cmty.name.len == 0:
resp Http404, showError(&"""Community "{@"id"}" not found""", cfg)
let html = renderCommunity(vnode, nav, cmty)
resp renderMain(html, request, cfg, prefs, titleText=title, banner=cmty.banner)
proc createCommunityRouter*(cfg: Config) =
router community:
get "/i/communities/@id/?":
cond '.' notin @"id"
let
prefs = requestPrefs()
cmty = await getCachedCommunity(@"id")
tl = await getGraphCommunityTweets(cmty.id, "Relevance", getCursor())
respCommunity(cmty, cmty.name,
renderCommunityTabs(QueryKind.posts, cmty),
renderTimelineTweets(tl, prefs, request.path))
get "/i/communities/@id/latest":
cond '.' notin @"id"
let
prefs = requestPrefs()
cmty = await getCachedCommunity(@"id")
tl = await getGraphCommunityTweets(cmty.id, "Recency", getCursor())
respCommunity(cmty, cmty.name & " - Latest",
renderCommunityTabs(QueryKind.replies, cmty),
renderTimelineTweets(tl, prefs, request.path))
get "/i/communities/@id/media":
cond '.' notin @"id"
let
prefs = requestPrefs()
cmty = await getCachedCommunity(@"id")
tl = await getGraphCommunityMedia(cmty.id, getCursor())
respCommunity(cmty, cmty.name & " - Media",
renderCommunityTabs(QueryKind.media, cmty),
renderTimelineTweets(tl, prefs, request.path))
get "/i/communities/@id/about":
cond '.' notin @"id"
let
prefs = requestPrefs()
cmty = await getCachedCommunity(@"id")
mods = await getCachedCommunityModerators(cmty.id)
respCommunity(cmty, cmty.name & " - About",
renderCommunityTabs(QueryKind.userList, cmty),
renderCommunityAbout(cmty, mods))
get "/i/communities/@id/members":
cond '.' notin @"id"
let
prefs = requestPrefs()
cmty = await getCachedCommunity(@"id")
members = await getGraphCommunityMembers(cmty.id, getCursor())
respCommunity(cmty, cmty.name & " - Members",
renderMemberTabs(cmty, false),
renderTimelineUsers(members, prefs, request.path))
get "/i/communities/@id/moderators":
cond '.' notin @"id"
let
prefs = requestPrefs()
cmty = await getCachedCommunity(@"id")
mods = await getCachedCommunityModerators(cmty.id)
respCommunity(cmty, cmty.name & " - Moderators",
renderMemberTabs(cmty, true),
renderTimelineUsers(Result[User](content: mods), prefs, request.path))
get "/i/communities/@id/hashtag/@tag":
cond '.' notin @"id"
let
prefs = requestPrefs()
cmty = await getCachedCommunity(@"id")
tl = await getGraphCommunityHashtags(cmty.id, @"tag", getCursor())
respCommunity(cmty, cmty.name & " - #" & @"tag",
renderHashtagHeader(cmty, @"tag"),
renderTimelineTweets(tl, prefs, request.path))

View file

@ -1,29 +1,79 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, strutils, strformat, options import asyncdispatch, strutils, strformat, json
import jester, karax/vdom import jester, karax/vdom
import ".."/[types, api] import ".."/[types, api, formatters]
import ../views/[embed, tweet, general] import ../views/[embed, tweet, general]
include "../views/oembed.nimf"
import router_utils import router_utils
export api, embed, vdom, tweet, general, router_utils export api, embed, vdom, tweet, general, router_utils
proc parseTweetPath(path: string): tuple[username, id: string] =
let parts = path.split('/')
if parts.len >= 3 and parts[1] in ["status", "statuses"]:
let tweetId = parts[2].split('?')[0].split('#')[0]
if tweetId.len > 0 and tweetId.allCharsInSet(Digits):
return (parts[0], tweetId)
return ("", "")
proc parseTweetUrl*(url: string; cfg: Config): tuple[username, id: string] =
var path = url
if path.startsWith("https://"):
path = path[8..^1]
elif path.startsWith("http://"):
path = path[7..^1]
const twitterPrefixes = ["twitter.com/", "x.com/", "mobile.twitter.com/",
"www.twitter.com/", "www.x.com/"]
for prefix in twitterPrefixes:
if path.startsWith(prefix):
return parseTweetPath(path[prefix.len..^1])
let nitterPrefix = cfg.hostname & "/"
if path.startsWith(nitterPrefix):
return parseTweetPath(path[nitterPrefix.len..^1])
# Fall back: strip any hostname and try to parse as a tweet path.
# Handles requests where the URL's host differs from cfg.hostname
# (e.g. localhost in dev/CI, or a reverse proxy with a different domain).
let slashPos = path.find('/')
if slashPos > 0:
let afterHost = path[slashPos + 1..^1]
let parsed = parseTweetPath(afterHost)
if parsed.username.len > 0:
return parsed
return ("", "")
proc createEmbedRouter*(cfg: Config) = proc createEmbedRouter*(cfg: Config) =
router embed: router embed:
get "/i/videos/tweet/@id": get "/i/videos/tweet/@id":
let tweet = await getGraphTweetResult(@"id") let
if tweet == nil or tweet.video.isNone: id = @"id"
resp Http404 tweet = await getTweetByRestId(id)
prefs = requestPrefs()
if tweet == nil:
resp renderErrorEmbed("Tweet not found", prefs, cfg, request, tweetId=id)
if not tweet.hasVideos:
resp renderErrorEmbed("No video in tweet", prefs, cfg, request,
tweetId=id, username=tweet.user.username)
resp renderVideoEmbed(tweet, cfg, request) resp renderVideoEmbed(tweet, cfg, request)
get "/@user/status/@id/embed": get "/@user/status/@id/embed":
let let
tweet = await getGraphTweetResult(@"id") id = @"id"
prefs = cookiePrefs() user = @"user"
tweet = await getTweetByRestId(id)
prefs = requestPrefs()
path = getPath() path = getPath()
if tweet == nil: if tweet == nil:
resp Http404 resp renderErrorEmbed("Tweet not found", prefs, cfg, request,
tweetId=id, username=user)
resp renderTweetEmbed(tweet, path, prefs, cfg, request) resp renderTweetEmbed(tweet, path, prefs, cfg, request)
@ -34,3 +84,57 @@ proc createEmbedRouter*(cfg: Config) =
redirect(&"/i/status/{id}/embed") redirect(&"/i/status/{id}/embed")
else: else:
resp Http404 resp Http404
get "/api/oembed":
responseHeaders().get.add(("Access-Control-Allow-Origin", "*"))
let
url = @"url"
format = @"format"
if format.len > 0 and format != "json":
resp Http501, "Only JSON format is supported"
if url.len == 0:
resp Http400, "Missing url parameter"
let (username, tweetId) = parseTweetUrl(url, cfg)
if username.len == 0 or tweetId.len == 0:
resp Http400, "Invalid tweet URL"
let tweet = await getTweetByRestId(tweetId)
if tweet == nil:
resp Http404
let
maxwidthParam = @"maxwidth"
maxwidth = if maxwidthParam.len > 0:
try: clamp(parseInt(maxwidthParam), 220, 550)
except ValueError: 550
else: 550
embedUrl = getUrlPrefix(cfg) & "/" & tweet.user.username & "/status/" & tweetId & "/embed"
authorUrl = getUrlPrefix(cfg) & "/" & tweet.user.username
title = stripHtml(tweet.text)
var response = %*{
"version": "1.0",
"type": "rich",
"provider_name": cfg.title,
"provider_url": getUrlPrefix(cfg),
"title": title,
"author_name": tweet.user.fullname,
"author_url": authorUrl,
"url": embedUrl,
"width": maxwidth,
"height": newJNull(),
"cache_age": "3153600000",
"html": renderOembedIframe(embedUrl, maxwidth)
}
if tweet.media.len > 0:
let thumbUrl = getUrlPrefix(cfg) & getPicUrl(tweet.media[0].getThumb)
response["thumbnail_url"] = %thumbUrl
response["thumbnail_width"] = %maxwidth
response["thumbnail_height"] = %maxwidth
respJson response

View file

@ -13,7 +13,7 @@ template respList*(list, timeline, title, vnode: typed) =
let let
html = renderList(vnode, timeline.query, list) 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) 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/?": get "/i/lists/@id/?":
cond '.' notin @"id" cond '.' notin @"id"
let let
prefs = cookiePrefs() prefs = requestPrefs()
list = await getCachedList(id=(@"id")) list = await getCachedList(id=(@"id"))
timeline = await getGraphListTweets(list.id, getCursor()) timeline = await getGraphListTweets(list.id, getCursor())
vnode = renderTimelineTweets(timeline, prefs, request.path) vnode = renderTimelineTweets(timeline, prefs, request.path)
@ -45,7 +45,7 @@ proc createListRouter*(cfg: Config) =
get "/i/lists/@id/members": get "/i/lists/@id/members":
cond '.' notin @"id" cond '.' notin @"id"
let let
prefs = cookiePrefs() prefs = requestPrefs()
list = await getCachedList(id=(@"id")) list = await getCachedList(id=(@"id"))
members = await getGraphListMembers(list, getCursor()) members = await getGraphListMembers(list, getCursor())
respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path)) respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path))

View file

@ -1,6 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import uri, strutils, httpclient, os, hashes, base64, re import uri, strutils, httpclient, os, hashes, base64, re
import asynchttpserver, asyncstreams, asyncfile, asyncnet import asynchttpserver, asyncstreams, asyncfile, asyncnet
import asyncdispatch
import jester import jester
@ -15,7 +16,9 @@ const
maxAge* = "max-age=604800" maxAge* = "max-age=604800"
proc safeFetch*(url: string): Future[string] {.async.} = proc safeFetch*(url: string): Future[string] {.async.} =
let client = newAsyncHttpClient() # maxRedirects=0: the caller already validated the host, so never follow a
# redirect off the allowlisted host (would re-open the #1411 SSRF).
let client = newAsyncHttpClient(maxRedirects = 0)
try: result = await client.getContent(url) try: result = await client.getContent(url)
except: discard except: discard
finally: client.close() finally: client.close()
@ -30,47 +33,79 @@ template respond*(req: asynchttpserver.Request; headers) =
proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} = proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
result = Http200 result = Http200
let let request = req.getNativeReq()
request = req.getNativeReq() var fetchUrl = url
client = newAsyncHttpClient()
try: for attempt in 0 .. 2:
let res = await client.get(url) let client = newAsyncHttpClient(maxRedirects = 0)
if res.status != "200 OK": var shouldRetry = false
if res.status != "404 Not Found": try:
echo "[media] Proxying failed, status: $1, url: $2" % [res.status, url] let resFut = client.get(fetchUrl)
return Http404 let completed = await withTimeout(resFut, 5000)
if not completed:
let hashed = $hash(url) if attempt < 2:
if request.headers.getOrDefault("If-None-Match") == hashed: echo "[media] Retry $1/2, timeout after 5s, url: $2" % [$(attempt + 1), fetchUrl]
return Http304 shouldRetry = true
else:
let contentLength = echo "[media] Proxying timeout after 5s, url: $1" % [fetchUrl]
if res.headers.hasKey("content-length"): return Http504
res.headers["content-length", 0]
else: else:
"" let res = resFut.read()
if res.status != "200 OK":
if res.status == "404 Not Found":
return Http404
if res.status.startsWith("30") and res.headers.hasKey("location"):
let location = res.headers["location", 0]
if isTwitterUrl(location):
fetchUrl = location
shouldRetry = true
continue
else:
return Http403
if attempt < 2:
echo "[media] Retry $1/2, status: $2, url: $3" % [$(attempt + 1), res.status, fetchUrl]
shouldRetry = true
else:
echo "[media] Proxying failed, status: $1, url: $2" % [res.status, fetchUrl]
return Http404
else:
let hashed = $hash(url)
if request.headers.getOrDefault("If-None-Match") == hashed:
return Http304
let headers = newHttpHeaders({ let contentLength =
"Content-Type": res.headers["content-type", 0], if res.headers.hasKey("content-length"):
"Content-Length": contentLength, res.headers["content-length", 0]
"Cache-Control": maxAge, else:
"ETag": hashed ""
})
respond(request, headers) let headers = newHttpHeaders({
"content-type": res.headers["content-type", 0],
"content-length": contentLength,
"cache-control": maxAge,
"etag": hashed
})
var (hasValue, data) = (true, "") respond(request, headers)
while hasValue:
(hasValue, data) = await res.bodyStream.read() var (hasValue, data) = (true, "")
if hasValue: while hasValue:
await request.client.send(data) (hasValue, data) = await res.bodyStream.read()
data.setLen 0 if hasValue:
except HttpRequestError, ProtocolError, OSError: await request.client.send(data)
echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url] data.setLen 0
result = Http404 return Http200
finally: except CatchableError:
client.close() if attempt < 2:
echo "[media] Retry $1/2, error: $2, url: $3" % [$(attempt + 1), getCurrentExceptionMsg(), fetchUrl]
shouldRetry = true
else:
echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), fetchUrl]
result = Http404
finally:
client.close()
if not shouldRetry:
break
template check*(code): untyped = template check*(code): untyped =
if code != Http200: if code != Http200:
@ -86,6 +121,12 @@ proc decoded*(req: jester.Request; index: int): string =
if based: decode(encoded) if based: decode(encoded)
else: decodeUrl(encoded) else: decodeUrl(encoded)
proc normalizeImgUrl*(url: var string) =
if not url.startsWith("http"):
if "twimg.com" notin url:
url.insert(twimg)
url.insert(https)
proc createMediaRouter*(cfg: Config) = proc createMediaRouter*(cfg: Config) =
router media: router media:
get "/pic/?": get "/pic/?":
@ -93,10 +134,8 @@ proc createMediaRouter*(cfg: Config) =
get re"^\/pic\/orig\/(enc)?\/?(.+)": get re"^\/pic\/orig\/(enc)?\/?(.+)":
var url = decoded(request, 1) var url = decoded(request, 1)
if "twimg.com" notin url: cond "/amplify_video/" notin url
url.insert(twimg) normalizeImgUrl(url)
if not url.startsWith(https):
url.insert(https)
url.add("?name=orig") url.add("?name=orig")
let uri = parseUri(url) let uri = parseUri(url)
@ -107,10 +146,8 @@ proc createMediaRouter*(cfg: Config) =
get re"^\/pic\/(enc)?\/?(.+)": get re"^\/pic\/(enc)?\/?(.+)":
var url = decoded(request, 1) var url = decoded(request, 1)
if "twimg.com" notin url: cond "/amplify_video/" notin url
url.insert(twimg) normalizeImgUrl(url)
if not url.startsWith(https):
url.insert(https)
let uri = parseUri(url) let uri = parseUri(url)
cond isTwitterUrl(uri) == true cond isTwitterUrl(uri) == true
@ -120,12 +157,12 @@ proc createMediaRouter*(cfg: Config) =
get re"^\/video\/(enc)?\/?(.+)\/(.+)$": get re"^\/video\/(enc)?\/?(.+)\/(.+)$":
let url = decoded(request, 2) let url = decoded(request, 2)
cond "http" in url cond isTwitterUrl(url)
if getHmac(url) != request.matches[1]: if getHmac(url) != request.matches[1]:
resp Http403, showError("Failed to verify signature", cfg) resp Http403, showError("Failed to verify signature", cfg)
if ".mp4" in url or ".ts" in url or ".m4s" in url: if ".mp4" in url or ".ts" in url or ".m4s" in url or ".aac" in url:
let code = await proxyMedia(request, url) let code = await proxyMedia(request, url)
check code check code
@ -139,6 +176,6 @@ proc createMediaRouter*(cfg: Config) =
if ".m3u8" in url: if ".m3u8" in url:
let vid = await safeFetch(url) let vid = await safeFetch(url)
content = proxifyVideo(vid, cookiePref(proxyVideos)) content = proxifyVideo(vid, requestPrefs().proxyVideos, url)
resp content, m3u8Mime resp content, m3u8Mime

View file

@ -19,8 +19,10 @@ proc createPrefRouter*(cfg: Config) =
router preferences: router preferences:
get "/settings": get "/settings":
let let
prefs = cookiePrefs() prefs = requestPrefs()
html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir)) prefsCode = encodePrefs(prefs)
prefsUrl = getUrlPrefix(cfg) & "/?prefs=" & prefsCode
html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir), prefsUrl)
resp renderMain(html, request, cfg, prefs, "Preferences") resp renderMain(html, request, cfg, prefs, "Preferences")
get "/settings/@i?": get "/settings/@i?":
@ -38,3 +40,6 @@ proc createPrefRouter*(cfg: Config) =
savePref("hlsPlayback", "on", request) savePref("hlsPlayback", "on", request)
redirect(refPath()) redirect(refPath())
post "/enablemp4":
savePref("mp4Playback", "on", request)
redirect(refPath())

View file

@ -18,8 +18,8 @@ proc createResolverRouter*(cfg: Config) =
router resolver: router resolver:
get "/cards/@card/@id": get "/cards/@card/@id":
let url = "https://cards.twitter.com/cards/$1/$2" % [@"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": get "/t.co/@url":
let url = "https://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

@ -4,26 +4,19 @@ from jester import Request, cookies
import ../views/general import ../views/general
import ".."/[utils, prefs, types] import ".."/[utils, prefs, types]
export utils, prefs, types, uri export utils, prefs, types, uri, json
template savePref*(pref, value: string; req: Request; expire=false) = template savePref*(pref, value: string; req: Request; expire=false) =
if not expire or pref in cookies(req): if not expire or pref in cookies(req):
let sameSite = if cfg.useHttps: None else: Lax
setCookie(pref, value, daysForward(when expire: -10 else: 360), setCookie(pref, value, daysForward(when expire: -10 else: 360),
httpOnly=true, secure=cfg.useHttps, sameSite=None) httpOnly=true, secure=cfg.useHttps, sameSite=sameSite, path="/")
template cookiePrefs*(): untyped {.dirty.} = template requestPrefs*(): untyped {.dirty.} =
getPrefs(cookies(request)) getPrefs(cookies(request), params(request))
template cookiePref*(pref): untyped {.dirty.} =
getPref(cookies(request), pref)
template themePrefs*(): Prefs =
var res = defaultPrefs
res.theme = cookiePref(theme)
res
template showError*(error: string; cfg: Config): string = 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.} = template getPath*(): untyped {.dirty.} =
$(parseUri(request.path) ? filterParams(request.params)) $(parseUri(request.path) ? filterParams(request.params))
@ -43,5 +36,28 @@ template getCursor*(req: Request): string =
proc getNames*(name: string): seq[string] = proc getNames*(name: string): seq[string] =
name.strip(chars={'/'}).split(",").filterIt(it.len > 0) 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) = template respJson*(node: JsonNode) =
resp $node, "application/json" resp $node, "application/json"

View file

@ -15,7 +15,7 @@ proc redisKey*(page, name, cursor: string): string =
if cursor.len > 0: if cursor.len > 0:
result &= ":" & cursor 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 var profile: Profile
let let
name = req.params.getOrDefault("name") 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") return Rss(feed: profile.user.username, cursor: "suspended")
if profile.user.fullname.len > 0: 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) return Rss(feed: rss, cursor: profile.tweets.bottom)
template respRss*(rss, page) = template respRss*(rss, page) =
@ -60,12 +60,15 @@ template respRss*(rss, page) =
proc createRssRouter*(cfg: Config) = proc createRssRouter*(cfg: Config) =
router rss: router rss:
get "/search/rss": get "/search/rss":
cond cfg.enableRss if not cfg.enableRSSSearch:
resp Http403, showError("RSS feed is disabled", cfg)
if @"q".len > 200: if @"q".len > 200:
resp Http400, showError("Search input too long.", cfg) resp Http400, showError("Search input too long.", cfg)
let query = initQuery(params(request)) let
if query.kind != tweets: prefs = requestPrefs()
query = initQuery(params(request))
if query.kind notin {QueryKind.tweets, QueryKind.top, QueryKind.media}:
resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg) resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg)
let let
@ -78,15 +81,17 @@ proc createRssRouter*(cfg: Config) =
let tweets = await getGraphTweetSearch(query, cursor) let tweets = await getGraphTweetSearch(query, cursor)
rss.cursor = tweets.bottom 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) await cacheRss(key, rss)
respRss(rss, "Search") respRss(rss, "Search")
get "/@name/rss": get "/@name/rss":
cond cfg.enableRss
cond '.' notin @"name" cond '.' notin @"name"
if not cfg.enableRSSUserTweets:
resp Http403, showError("RSS feed is disabled", cfg)
let let
prefs = requestPrefs()
name = @"name" name = @"name"
key = redisKey("twitter", name, getCursor()) key = redisKey("twitter", name, getCursor())
@ -94,24 +99,23 @@ proc createRssRouter*(cfg: Config) =
if rss.cursor.len > 0: if rss.cursor.len > 0:
respRss(rss, "User") respRss(rss, "User")
rss = await timelineRss(request, cfg, Query(fromUser: @[name])) rss = await timelineRss(request, cfg, Query(fromUser: @[name]), prefs)
await cacheRss(key, rss) await cacheRss(key, rss)
respRss(rss, "User") respRss(rss, "User")
get "/@name/@tab/rss": get "/@name/@tab/rss":
cond cfg.enableRss
cond '.' notin @"name" cond '.' notin @"name"
cond @"tab" in ["with_replies", "media", "search"] cond @"tab" in ["with_replies", "media", "search", "articles"]
# articles can't be approximated by search, so multi-user is unsupported
cond not (@"tab" == "articles" and ',' in @"name")
if not cfg.tabRssEnabled(@"tab"):
resp Http403, showError("RSS feed is disabled", cfg)
let let
prefs = requestPrefs()
name = @"name" name = @"name"
tab = @"tab" tab = @"tab"
query = query = request.getQuery(tab, name, prefs)
case tab
of "with_replies": getReplyQuery(name)
of "media": getMediaQuery(name)
of "search": initQuery(params(request), name=name)
else: Query(fromUser: @[name])
let searchKey = if tab != "search": "" let searchKey = if tab != "search": ""
else: ":" & $hash(genQueryUrl(query)) else: ":" & $hash(genQueryUrl(query))
@ -122,14 +126,15 @@ proc createRssRouter*(cfg: Config) =
if rss.cursor.len > 0: if rss.cursor.len > 0:
respRss(rss, "User") respRss(rss, "User")
rss = await timelineRss(request, cfg, query) rss = await timelineRss(request, cfg, query, prefs)
await cacheRss(key, rss) await cacheRss(key, rss)
respRss(rss, "User") respRss(rss, "User")
get "/@name/lists/@slug/rss": get "/@name/lists/@slug/rss":
cond cfg.enableRss
cond @"name" != "i" cond @"name" != "i"
if not cfg.enableRSSList:
resp Http403, showError("RSS feed is disabled", cfg)
let let
slug = decodeUrl(@"slug") slug = decodeUrl(@"slug")
list = await getCachedList(@"name", slug) list = await getCachedList(@"name", slug)
@ -145,8 +150,10 @@ proc createRssRouter*(cfg: Config) =
redirect(url) redirect(url)
get "/i/lists/@id/rss": get "/i/lists/@id/rss":
cond cfg.enableRss if not cfg.enableRSSList:
resp Http403, showError("RSS feed is disabled", cfg)
let let
prefs = requestPrefs()
id = @"id" id = @"id"
cursor = getCursor() cursor = getCursor()
key = redisKey("lists", id, cursor) key = redisKey("lists", id, cursor)
@ -159,7 +166,7 @@ proc createRssRouter*(cfg: Config) =
list = await getCachedList(id=id) list = await getCachedList(id=id)
timeline = await getGraphListTweets(list.id, cursor) timeline = await getGraphListTweets(list.id, cursor)
rss.cursor = timeline.bottom rss.cursor = timeline.bottom
rss.feed = renderListRss(timeline.content, list, cfg) rss.feed = renderListRss(timeline.content, list, cfg, prefs)
await cacheRss(key, rss) await cacheRss(key, rss)
respRss(rss, "List") respRss(rss, "List")

View file

@ -19,10 +19,22 @@ proc createSearchRouter*(cfg: Config) =
resp Http400, showError("Search input too long.", cfg) resp Http400, showError("Search input too long.", cfg)
let let
prefs = cookiePrefs() prefs = requestPrefs()
query = initQuery(params(request))
title = "Search" & (if q.len > 0: " (" & q & ")" else: "") title = "Search" & (if q.len > 0: " (" & q & ")" else: "")
var query = initQuery(params(request))
# x.com URL compat: f=user and f=list map to our kind names
# (f=live already falls back to tweets/Latest; f=media matches natively)
if @"f" == "user":
query.kind = users
elif @"f" == "list":
query.kind = lists
# media searches support view modes, defaulting like /user/media
if query.kind == QueryKind.media and
query.view notin ["timeline", "grid", "gallery"]:
query.view = prefs.mediaView.toLowerAscii
case query.kind case query.kind
of users: of users:
if "," in q: if "," in q:
@ -33,19 +45,24 @@ proc createSearchRouter*(cfg: Config) =
except InternalError: except InternalError:
users = Result[User](beginning: true, query: query) users = Result[User](beginning: true, query: query)
resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title)
of tweets: of tweets, top, QueryKind.media:
let let
tweets = await getGraphTweetSearch(query, getCursor()) 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()), resp renderMain(renderTweetSearch(tweets, prefs, getPath()),
request, cfg, prefs, title, rss=rss) request, cfg, prefs, title, rss=rss)
of lists:
let listResults = await getGraphListSearch(query, getCursor())
resp renderMain(renderListSearch(listResults, prefs, getPath()),
request, cfg, prefs, title)
else: else:
resp Http404, showError("Invalid search", cfg) resp Http404, showError("Invalid search", cfg)
get "/hashtag/@hash": get "/hashtag/@hash":
redirect("/search?q=" & encodeUrl("#" & @"hash")) redirect("/search?f=tweets&q=" & encodeUrl("#" & @"hash"))
get "/opensearch": get "/opensearch":
let url = getUrlPrefix(cfg) & "/search?q=" let
resp Http200, {"Content-Type": "application/opensearchdescription+xml"}, url = getUrlPrefix(cfg) & "/search?f=tweets&q="
generateOpenSearchXML(cfg.title, cfg.hostname, url) headers = {"Content-Type": "application/opensearchdescription+xml"}
resp Http200, headers, generateOpenSearchXML(cfg.title, cfg.hostname, url)

36
src/routes/space.nim Normal file
View file

@ -0,0 +1,36 @@
# SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, strutils
import jester
import router_utils
import ".."/[types, formatters, redis_cache]
import ../views/[general, space]
import media
export space
proc createSpaceRouter*(cfg: Config) =
router spaceRoute:
get "/i/spaces/@id":
cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
let sp = await getCachedAudioSpace(@"id")
if sp.id.len == 0:
resp Http404, showError("Space not found", cfg)
let prefs = requestPrefs()
resp renderMain(renderSpace(sp, prefs, request.path), request, cfg, prefs,
sp.title, ogTitle=sp.title)
get "/i/spaces/@id/stream":
cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
let sp = await getCachedAudioSpace(@"id")
if sp.m3u8Url.len == 0:
resp Http404
let manifest = await safeFetch(sp.m3u8Url)
if manifest.len == 0:
resp Http502
resp proxifyVideo(manifest, requestPrefs().proxyVideos, sp.m3u8Url), m3u8Mime

View file

@ -21,18 +21,18 @@ proc createStatusRouter*(cfg: Config) =
if id.len > 19 or id.any(c => not c.isDigit): if id.len > 19 or id.any(c => not c.isDigit):
resp Http404, showError("Invalid tweet ID", cfg) resp Http404, showError("Invalid tweet ID", cfg)
let prefs = cookiePrefs() let
prefs = requestPrefs()
sort = parseEnum[RankingMode](@"sort".toLowerAscii.capitalizeAscii, Relevance)
# used for the infinite scroll feature # used for the infinite scroll feature
if @"scroll".len > 0: if @"scroll".len > 0:
let replies = await getReplies(id, getCursor()) let replies = await getReplies(id, getCursor(), sort)
if replies.content.len == 0: if replies.content.len == 0:
resp Http404, "" resp Http204
resp $renderReplies(replies, prefs, getPath()) resp $renderReplies(replies, prefs, getPath(), sort=sort)
let conv = await getTweet(id, getCursor()) let conv = await getTweet(id, getCursor(), sort)
if conv == nil:
echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id == 0: if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
var error = "Tweet not found" var error = "Tweet not found"
@ -46,15 +46,19 @@ proc createStatusRouter*(cfg: Config) =
desc = conv.tweet.text desc = conv.tweet.text
var var
images = conv.tweet.photos images = conv.tweet.getPhotos.mapIt(it.url)
video = "" video = ""
if conv.tweet.video.isSome(): let
images = @[get(conv.tweet.video).thumb] firstMediaKind = if conv.tweet.media.len > 0: conv.tweet.media[0].kind
else: photoMedia
if firstMediaKind == videoMedia:
images = @[conv.tweet.media[0].getThumb]
video = getVideoEmbed(cfg, conv.tweet.id) video = getVideoEmbed(cfg, conv.tweet.id)
elif conv.tweet.gif.isSome(): elif firstMediaKind == gifMedia:
images = @[get(conv.tweet.gif).thumb] images = @[conv.tweet.media[0].getThumb]
video = getPicUrl(get(conv.tweet.gif).url) video = getPicUrl(conv.tweet.media[0].gif.url)
elif conv.tweet.card.isSome(): elif conv.tweet.card.isSome():
let card = conv.tweet.card.get() let card = conv.tweet.card.get()
if card.image.len > 0: if card.image.len > 0:
@ -62,9 +66,33 @@ proc createStatusRouter*(cfg: Config) =
elif card.video.isSome(): elif card.video.isSome():
images = @[card.video.get().thumb] images = @[card.video.get().thumb]
let html = renderConversation(conv, prefs, getPath() & "#m") let
tweetUrl = getUrlPrefix(cfg) & "/" & conv.tweet.user.username & "/status/" & $conv.tweet.id
oembedUrl = getUrlPrefix(cfg) & "/api/oembed?url=" & encodeUrl(tweetUrl)
let html = renderConversation(conv, prefs, getPath() & "#m", sort)
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle, resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
images=images, video=video) images=images, video=video, oembed=oembedUrl)
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?": get "/@name/@s/@id/@m/?@i?":
cond @"s" in ["status", "statuses"] cond @"s" in ["status", "statuses"]
@ -76,6 +104,6 @@ proc createStatusRouter*(cfg: Config) =
get "/i/web/status/@id": get "/i/web/status/@id":
redirect("/i/status/" & @"id") redirect("/i/status/" & @"id")
get "/@name/thread/@id/?": get "/@name/thread/@id/?":
redirect("/$1/status/$2" % [@"name", @"id"]) redirect("/$1/status/$2" % [@"name", @"id"])

View file

@ -4,20 +4,39 @@ import jester, karax/vdom
import router_utils import router_utils
import ".."/[types, redis_cache, formatters, query, api] import ".."/[types, redis_cache, formatters, query, api]
import ../views/[general, profile, timeline, status, search] import ../views/[general, profile, timeline, status, search, about_account]
export vdom export vdom
export uri, sequtils export uri, sequtils
export router_utils export router_utils
export redis_cache, formatters, query, api export redis_cache, formatters, query, api
export profile, timeline, status export profile, timeline, status, about_account
proc getQuery*(request: Request; tab, name: string): Query = proc tabRssEnabled*(cfg: Config; tab: string): bool =
case tab case tab
of "with_replies": getReplyQuery(name) of "": cfg.enableRSSUserTweets
of "media": getMediaQuery(name) of "with_replies": cfg.enableRSSUserReplies
of "search": initQuery(params(request), name=name) of "media": cfg.enableRSSUserMedia
else: Query(fromUser: @[name]) of "articles": cfg.enableRSSUserArticles
of "search": cfg.enableRSSSearch
else: false
proc getQuery*(request: Request; tab, name: string; prefs: Prefs): Query =
let view = request.params.getOrDefault("view")
case tab
of "with_replies":
result = getReplyQuery(name)
of "articles":
result = getArticlesQuery(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] = template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
if cond: if cond:
@ -45,20 +64,23 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile]
let let
rail = rail =
skipIf(skipRail or query.kind == media, @[]): skipIf(skipRail or query.kind == QueryKind.media, @[]):
getCachedPhotoRail(userId) getCachedPhotoRail(userId)
user = getCachedUser(name) user = getCachedUser(name)
info = getCachedAccountInfo(name, fetch=false)
result = result =
case query.kind case query.kind
of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after) of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after)
of replies: await getGraphUserTweets(userId, TimelineKind.replies, after) of replies: await getGraphUserTweets(userId, TimelineKind.replies, after)
of media: await getGraphUserTweets(userId, TimelineKind.media, after) of media: await getGraphUserTweets(userId, TimelineKind.media, after)
of QueryKind.articles: await getGraphUserTweets(userId, TimelineKind.articles, after)
else: Profile(tweets: await getGraphTweetSearch(query, after)) else: Profile(tweets: await getGraphTweetSearch(query, after))
result.user = await user result.user = await user
result.photoRail = await rail result.photoRail = await rail
result.accountInfo = await info
result.tweets.query = query result.tweets.query = query
@ -105,16 +127,72 @@ proc createTimelineRouter*(cfg: Config) =
get "/intent/user": get "/intent/user":
respUserId() 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/@kind/?":
cond @"kind" in ["followers", "following"]
cond '.' notin @"name"
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'})
let
prefs = requestPrefs()
name = @"name"
userId = await getUserId(name)
if userId.len == 0:
resp Http404, showError("User \"" & name & "\" not found", cfg)
if userId == "suspended":
resp showError(getSuspended(name), cfg)
let
cursor = getCursor()
isFollowers = @"kind" == "followers"
user = await getCachedUser(name)
results = if isFollowers: await getGraphFollowers(userId, cursor)
else: await getGraphFollowing(userId, cursor)
if user.protected:
resp renderMain(renderProtected(user.username), request, cfg, prefs,
"Protected account @" & name)
let
tab = if isFollowers: "Followers" else: "Following"
title = (if isFollowers: "People following @" else: "People followed by @") & name
html = renderUserList(user, results, prefs, request.path, tab)
resp renderMain(html, request, cfg, prefs, title,
images = @[user.getUserPic("_400x400")])
get "/@name/?@tab?/?": get "/@name/?@tab?/?":
cond '.' notin @"name" cond '.' notin @"name"
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
cond @"tab" in ["with_replies", "media", "search", ""] cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','})
cond @"tab" in ["with_replies", "media", "search", "articles", ""]
# articles can't be approximated by search, so multi-user is unsupported
cond not (@"tab" == "articles" and ',' in @"name")
let let
prefs = cookiePrefs() prefs = requestPrefs()
after = getCursor() after = getCursor()
names = getNames(@"name") names = getNames(@"name")
var query = request.getQuery(@"tab", @"name") var query = request.getQuery(@"tab", @"name", prefs)
if names.len != 1: if names.len != 1:
query.fromUser = names query.fromUser = names
@ -122,7 +200,8 @@ proc createTimelineRouter*(cfg: Config) =
if @"scroll".len > 0: if @"scroll".len > 0:
if query.fromUser.len != 1: if query.fromUser.len != 1:
var timeline = await getGraphTweetSearch(query, after) var timeline = await getGraphTweetSearch(query, after)
if timeline.content.len == 0: resp Http404 if timeline.content.len == 0:
resp Http204
timeline.beginning = true timeline.beginning = true
resp $renderTweetSearch(timeline, prefs, getPath()) resp $renderTweetSearch(timeline, prefs, getPath())
else: else:
@ -132,7 +211,9 @@ proc createTimelineRouter*(cfg: Config) =
resp $renderTimelineTweets(profile.tweets, prefs, getPath()) resp $renderTimelineTweets(profile.tweets, prefs, getPath())
let rss = let rss =
if @"tab".len == 0: if not cfg.tabRssEnabled(@"tab"):
""
elif @"tab".len == 0:
"/$1/rss" % @"name" "/$1/rss" % @"name"
elif @"tab" == "search": elif @"tab" == "search":
"/$1/search/rss?$2" % [@"name", genQueryUrl(query)] "/$1/search/rss?$2" % [@"name", genQueryUrl(query)]

View file

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

278
src/sass/_article.scss Normal file
View file

@ -0,0 +1,278 @@
.article-page {
max-width: 700px;
margin: 0 auto 20px;
background-color: var(--bg_panel);
> .top-ref {
padding-top: 20px;
}
.article-cover {
width: 100%;
display: block;
}
.article-body {
padding: 20px;
> :last-child {
margin-bottom: 0;
}
.article-title {
display: block;
font-size: 2rem;
line-height: 1.3;
margin: 0 0 10px;
color: var(--fg_color);
}
.article-author {
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border_grey);
font-size: 14px;
.article-author-row {
display: flex;
align-items: center;
gap: 8px;
}
.article-avatar {
display: flex;
}
.avatar {
width: 40px;
height: 40px;
}
.article-author-name {
display: flex;
align-items: center;
margin-bottom: 2px;
.fullname {
font-size: 15px;
}
.verified-icon {
margin-left: 2px;
}
}
.article-author-meta {
display: flex;
align-items: center;
}
.fullname {
font-weight: 700;
color: var(--fg_color);
max-width: unset;
text-overflow: unset;
overflow: visible;
white-space: normal;
}
.username,
.article-date-sep,
.article-date {
color: var(--fg_dark);
}
.username {
margin-left: 0;
}
.article-date-sep {
margin: 0 4px;
}
.article-date {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.tweet-stats {
margin-top: 6px;
.tweet-stat {
padding-top: 0;
}
}
}
> h1 {
display: block;
font-size: 1.8rem;
margin: 25px 0 15px;
}
> h2 {
font-size: 1.4rem;
font-weight: bold;
margin: 20px 0 12px;
}
> h3 {
font-size: 1.2rem;
font-weight: bold;
margin: 18px 0 10px;
}
> p {
font-size: 16px;
line-height: 1.7;
margin: 16px 0;
word-wrap: break-word;
}
.blockquote-attribution {
display: block;
margin-top: 0.5em;
}
> blockquote {
border-left: 3px solid var(--accent);
padding-left: 16px;
margin: 16px 0;
color: var(--fg_faded);
font-size: 16px;
line-height: 1.7;
}
> pre {
background-color: var(--bg_elements);
padding: 12px 16px;
border-radius: 6px;
overflow-x: auto;
margin: 16px 0;
code {
font-family: monospace;
font-size: 14px;
color: var(--fg_color);
}
}
code {
background-color: var(--bg_elements);
padding: 2px 5px;
border-radius: 3px;
font-family: monospace;
font-size: 0.9em;
}
> ul,
> ol {
margin: 16px 0;
padding-left: 2em;
li {
font-size: 16px;
line-height: 1.7;
margin: 6px 0;
}
}
.article-media {
text-align: center;
margin: 20px 0;
img,
video {
max-width: 100%;
border-radius: 12px;
}
.article-media-caption {
color: var(--fg_faded);
font-size: 0.875rem;
margin-top: 6px;
}
}
> a,
> p a,
> h1 a,
> h2 a,
> h3 a,
> blockquote a,
> ul a,
> ol a {
color: var(--accent);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.article-divider {
border: none;
border-top: 1px solid var(--border_grey);
margin: 30px 0;
}
.timeline-item {
margin: 20px 0;
border: 1px solid var(--border_grey);
border-radius: 12px;
overflow: hidden;
}
}
}
.conversation .article-page {
max-width: 100%;
margin-bottom: 0;
}
.article-card {
.card-image-container {
position: relative;
}
.card-image img {
height: auto;
}
.article-card-badge {
position: absolute;
bottom: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.75);
color: #fff;
font-size: 13px;
font-weight: 700;
padding: 2px 8px;
border-radius: 4px;
}
}
.quote .article-card {
margin: 0;
.card-container {
border: none;
border-radius: 0;
border-top: solid 1px var(--dark_grey);
}
}
@media (max-width: 700px) {
.article-page {
.article-body {
padding: 12px 15px 25px;
.article-title {
font-size: 1.6rem;
}
}
}
}

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

149
src/sass/_space.scss Normal file
View file

@ -0,0 +1,149 @@
.space-page {
max-width: 800px;
width: 100%;
margin: 20px auto 0;
}
.space-panel {
background-color: var(--bg_panel);
border: 1px solid var(--border_grey);
border-radius: 8px;
overflow: hidden;
}
.space-player {
position: relative;
background: linear-gradient(135deg, #7b2a8c 0%, #9b3ab1 100%);
min-height: 140px;
display: flex;
align-items: center;
justify-content: center;
audio {
width: 100%;
padding: 15px;
box-sizing: border-box;
&:not([controls]) {
display: none;
}
}
.video-overlay {
background-color: transparent;
}
}
.space-live {
background: #e0245e;
color: white;
padding: 3px 8px;
border-radius: 4px;
font-weight: bold;
font-size: 12px;
text-transform: uppercase;
position: absolute;
top: 8px;
right: 8px;
}
.space-info {
padding: 16px;
}
.space-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 16px;
}
.space-title {
font-size: 18px;
font-weight: bold;
margin: 0;
line-height: 1.3;
flex: 1;
}
.space-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
font-size: 14px;
color: var(--fg_faded);
}
.listener-count {
color: var(--fg_color);
}
.space-state {
color: var(--fg_dark);
}
.space-participants {
border-top: 1px solid var(--border_grey);
padding-top: 12px;
}
.space-participant {
margin-bottom: 10px;
a {
display: flex;
align-items: center;
gap: 10px;
color: var(--fg_color);
padding: 6px 0;
}
img {
width: 40px;
height: 40px;
border-radius: 50%;
flex-shrink: 0;
}
}
.participant-info {
min-width: 0;
}
.participant-name {
display: flex;
align-items: center;
gap: 4px;
strong {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.verified-icon {
margin-bottom: 0;
position: relative;
top: -2px;
}
}
.host-badge {
background: var(--accent);
color: white;
padding: 2px 7px;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
line-height: 1;
position: relative;
top: 1px;
}
.participant-username {
color: var(--fg_dark);
font-size: 13px;
}

View file

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

View file

@ -66,18 +66,7 @@
} }
#search-panel-toggle:checked ~ .search-panel { #search-panel-toggle:checked ~ .search-panel {
@if $rows == 6 { max-height: 380px !important;
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;
}
} }
} }
} }

View file

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

View file

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

View file

@ -1,185 +1,216 @@
@import '_variables'; @import "_variables";
@import '_mixins'; @import "_mixins";
button { button {
@include input-colors; @include input-colors;
background-color: var(--bg_elements); background-color: var(--bg_elements);
color: var(--fg_color); color: var(--fg_color);
border: 1px solid var(--accent_border); border: 1px solid var(--accent_border);
padding: 3px 6px; padding: 3px 6px;
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
float: right; float: right;
} }
input[type="text"], input[type="text"],
input[type="date"], input[type="date"],
input[type="number"],
select { select {
@include input-colors; @include input-colors;
background-color: var(--bg_elements); background-color: var(--bg_elements);
padding: 1px 4px; padding: 1px 4px;
color: var(--fg_color); color: var(--fg_color);
border: 1px solid var(--accent_border); border: 1px solid var(--accent_border);
border-radius: 0; border-radius: 0;
font-size: 14px; font-size: 14px;
} }
input[type="text"] { input[type="number"] {
height: 16px; -moz-appearance: textfield;
}
input[type="text"],
input[type="number"] {
height: 16px;
} }
select { select {
height: 20px; height: 20px;
padding: 0 2px; padding: 0 2px;
line-height: 1; line-height: 1;
} }
input[type="date"]::-webkit-inner-spin-button { 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 { input[type="date"]::-webkit-clear-button {
margin-left: 17px; margin-left: 17px;
filter: grayscale(100%); filter: grayscale(100%);
filter: hue-rotate(120deg); filter: hue-rotate(120deg);
} }
input::-webkit-calendar-picker-indicator { input::-webkit-calendar-picker-indicator {
opacity: 0; opacity: 0;
} }
input::-webkit-datetime-edit-day-field:focus, input::-webkit-datetime-edit-day-field:focus,
input::-webkit-datetime-edit-month-field:focus, input::-webkit-datetime-edit-month-field:focus,
input::-webkit-datetime-edit-year-field:focus { input::-webkit-datetime-edit-year-field:focus {
background-color: var(--accent); background-color: var(--accent);
color: var(--fg_color); color: var(--fg_color);
outline: none; outline: none;
} }
.date-range { .date-range {
.date-input { .date-input {
display: inline-block; display: inline-block;
position: relative; position: relative;
} }
.icon-container { .icon-container {
pointer-events: none; pointer-events: none;
position: absolute; position: absolute;
top: 2px; top: 2px;
right: 5px; right: 5px;
} }
.search-title { .search-title {
margin: 0 2px; margin: 0 2px;
} }
} }
.icon-button button { .icon-button button {
color: var(--accent); color: var(--accent);
text-decoration: none; text-decoration: none;
background: none; background: none;
border: none; border: none;
float: none; float: none;
padding: unset; padding: unset;
padding-left: 4px; padding-left: 4px;
&:hover { &:hover {
color: var(--accent_light); color: var(--accent_light);
} }
} }
.checkbox { .checkbox {
position: absolute; position: absolute;
top: 1px; top: 1px;
right: 0; right: 0;
height: 17px; height: 17px;
width: 17px; width: 17px;
background-color: var(--bg_elements); background-color: var(--bg_elements);
border: 1px solid var(--accent_border); border: 1px solid var(--accent_border);
&:after { &:after {
content: ""; content: "";
position: absolute; position: absolute;
display: none; display: none;
} }
} }
.checkbox-container { .checkbox-container {
display: block; display: block;
position: relative; position: relative;
margin-bottom: 5px; margin-bottom: 5px;
cursor: pointer;
user-select: none;
padding-right: 22px;
input {
position: absolute;
opacity: 0;
cursor: pointer; cursor: pointer;
user-select: none; height: 0;
padding-right: 22px; width: 0;
input { &:checked ~ .checkbox:after {
position: absolute; display: block;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
&:checked ~ .checkbox:after {
display: block;
}
} }
}
&:hover input ~ .checkbox { &:hover input ~ .checkbox {
border-color: var(--accent); border-color: var(--accent);
} }
&:active input ~ .checkbox { &:active input ~ .checkbox {
border-color: var(--accent_light); border-color: var(--accent_light);
} }
.checkbox:after { .checkbox:after {
left: 2px; left: 2px;
bottom: 0; bottom: 0;
font-size: 13px; font-size: 13px;
font-family: $font_4; font-family: $font_1;
content: '\e803'; content: "\e811";
} }
} }
.pref-group { .pref-group {
display: inline; display: inline;
} }
.preferences { .preferences {
button { button {
margin: 6px 0 3px 0; margin: 6px 0 3px 0;
} }
label { label {
padding-right: 150px; padding-right: 150px;
} }
select { select {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
display: block; display: block;
-moz-appearance: none; -moz-appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
} min-width: 100px;
}
input[type="text"] { input[type="text"],
position: absolute; input[type="number"] {
right: 0; position: absolute;
max-width: 140px; right: 0;
} max-width: 140px;
}
.pref-group { .pref-group {
display: block; display: block;
} }
.pref-input { .pref-input {
position: relative; position: relative;
margin-bottom: 6px; margin-bottom: 6px;
} }
.pref-reset { .pref-reset {
float: left; 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 { nav {
display: flex; display: flex;
align-items: center; align-items: center;
position: fixed; background-color: var(--bg_overlays);
background-color: var(--bg_overlays); box-shadow: 0 0 4px $shadow;
box-shadow: 0 0 4px $shadow; padding: 0;
padding: 0; width: 100%;
width: 100%; height: 50px;
height: 50px; z-index: 1000;
z-index: 1000; font-size: 16px;
font-size: 16px;
a, .icon-button button { a,
color: var(--fg_nav); .icon-button button {
} color: var(--fg_nav);
}
body.fixed-nav & {
position: fixed;
}
} }
.inner-nav { .inner-nav {
margin: auto; margin: auto;
box-sizing: border-box; box-sizing: border-box;
padding: 0 10px; padding: 0 10px;
display: flex; display: flex;
align-items: center; align-items: center;
flex-basis: 920px; flex-basis: 920px;
height: 50px; height: 50px;
} }
.site-name { .site-name {
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
line-height: 1; line-height: 1;
&:hover { &:hover {
color: var(--accent_light); color: var(--accent_light);
text-decoration: unset; text-decoration: unset;
} }
} }
.site-logo { .site-logo {
display: block; display: block;
width: 35px; width: 35px;
height: 35px; height: 35px;
} }
.nav-item { .nav-item {
display: flex; display: flex;
flex: 1; flex: 1;
line-height: 50px; line-height: 50px;
height: 50px; height: 50px;
overflow: hidden; overflow: hidden;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
&.right { &.right {
text-align: right; text-align: right;
justify-content: flex-end; justify-content: flex-end;
} }
&.right a { &.right a:hover {
padding-left: 4px; color: var(--accent_light);
text-decoration: unset;
&:hover { }
color: var(--accent_light);
text-decoration: unset;
}
}
} }
.lp { .lp {
height: 14px; height: 14px;
display: inline-block; display: inline-block;
position: relative; position: relative;
top: 2px; top: 2px;
fill: var(--fg_nav); fill: var(--fg_nav);
&:hover { &:hover {
fill: var(--accent_light); fill: var(--accent_light);
} }
} }
.icon-info:before { .icon-info {
margin: 0 -3px; margin: 0 -3px;
} }
.icon-cog { .icon-cog {
font-size: 15px; font-size: 15px;
padding-left: 0 !important;
} }

View file

@ -1,83 +1,118 @@
@import '_variables'; @import "_variables";
@import '_mixins'; @import "_mixins";
@import 'card'; @import "card";
@import 'photo-rail'; @import "about-account";
@import "photo-rail";
@import "community";
.profile-tabs { .profile-tabs {
@include panel(auto, 900px); @include panel(auto, 900px);
.timeline-container { .timeline-container {
float: right; float: right;
width: 68% !important; width: 68% !important;
max-width: unset; max-width: unset;
} }
} }
.profile-banner { .profile-banner {
margin-bottom: 4px; margin-bottom: 4px;
background-color: var(--bg_panel); background-color: var(--bg_panel);
a { a {
display: block; display: block;
position: relative; position: relative;
padding: 33.34% 0 0 0; padding: 33.34% 0 0 0;
} }
img { img {
max-width: 100%; max-width: 100%;
position: absolute; position: absolute;
top: 0; top: 0;
} }
} }
.profile-tab { .profile-tab {
padding: 0 4px 0 0; padding: 0 4px 0 0;
box-sizing: border-box; box-sizing: border-box;
display: inline-block; display: inline-block;
font-size: 14px; font-size: 14px;
text-align: left; text-align: left;
vertical-align: top; vertical-align: top;
max-width: 32%; max-width: 32%;
top: 0;
body.fixed-nav & {
top: 50px; top: 50px;
}
} }
.profile-result { .profile-result {
min-height: 54px; min-height: 54px;
.username { .username {
margin: 0 !important; margin: 0 !important;
} }
.tweet-header { .tweet-header {
margin-bottom: unset; margin-bottom: unset;
} }
} }
@media(max-width: 700px) { .profile-tabs.media-only {
.profile-tabs { max-width: none;
width: 100vw; width: 100%;
max-width: 600px;
.timeline-container { .timeline-container {
width: 100% !important; float: none;
width: 100% !important;
max-width: none;
padding: 0 10px;
box-sizing: border-box;
}
.tab-item wide { .timeline-container > .tab {
flex-grow: 1.4; max-width: 900px;
} margin-left: auto;
} margin-right: auto;
}
}
@media (max-width: 700px) {
.profile-tabs {
width: 100vw;
max-width: 600px;
.timeline-container {
width: 100% !important;
.tab-item wide {
flex-grow: 1.4;
}
} }
}
.profile-tab { .profile-tabs.media-only {
width: 100%; width: 100%;
max-width: unset; max-width: none;
position: initial !important;
padding: 0; .timeline-container {
width: 100vw !important;
padding: 0;
} }
}
.profile-tab {
width: 100%;
max-width: unset;
position: initial !important;
padding: 0;
}
} }
@media (min-height: 900px) { @media (min-height: 900px) {
.profile-tab.sticky { .profile-tab.sticky {
position: sticky; position: sticky;
} }
} }

View file

@ -0,0 +1,203 @@
.community-header {
padding: 12px 15px;
border-bottom: 1px solid var(--border_grey);
background-color: var(--bg_panel);
.community-name {
font-size: 22px;
margin-bottom: 6px;
a {
color: inherit;
}
}
.community-category {
display: inline-block;
background-color: var(--bg_elements);
border: 1px solid var(--border_grey);
border-radius: 16px;
padding: 2px 12px;
font-size: 13px;
color: var(--fg_faded);
margin-bottom: 8px;
}
.community-description {
color: var(--fg_faded);
margin-bottom: 8px;
line-height: 1.4;
}
.community-member-count {
font-weight: bold;
color: inherit;
}
.community-stats {
color: var(--grey);
font-size: 14px;
}
}
.community-about {
padding: 16px 15px 15px;
background-color: var(--bg_panel);
h2 {
font-size: 18px;
margin: 0 0 12px;
}
.community-info {
border-bottom: 1px solid var(--border_grey);
padding-bottom: 12px;
}
.community-info-item {
display: flex;
gap: 10px;
padding: 8px 0;
align-items: center;
.verified-icon {
margin-left: 2px;
}
> .icon-container {
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--grey);
flex-shrink: 0;
width: 26px;
height: 26px;
}
strong {
color: var(--fg_color);
}
a {
color: var(--accent);
}
}
.community-rules {
border-bottom: 1px solid var(--border_grey);
padding: 16px 0 12px;
}
.community-rules-intro {
color: var(--fg_faded);
font-size: 14px;
margin: 0 0 12px;
}
.community-rule {
display: flex;
gap: 10px;
padding: 10px 0;
align-items: flex-start;
.community-rule-number {
display: flex;
align-items: center;
justify-content: center;
min-width: 26px;
height: 26px;
border-radius: 50%;
background-color: var(--accent);
color: var(--fg_color);
font-weight: bold;
font-size: 13px;
flex-shrink: 0;
}
.community-rule-content p {
margin: 4px 0 0;
color: var(--fg_faded);
font-size: 14px;
}
}
.community-moderators {
padding-top: 16px;
h2 {
display: flex;
align-items: center;
justify-content: space-between;
}
.community-mods-link {
font-size: 14px;
font-weight: normal;
color: var(--accent);
}
}
.community-moderator {
display: flex;
gap: 10px;
padding: 8px 0;
align-items: center;
.community-mod-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
.community-mod-info {
display: flex;
flex-direction: column;
}
.community-mod-name {
display: flex;
align-items: center;
font-weight: bold;
color: var(--fg_color);
.verified-icon {
margin-left: 2px;
}
}
.community-mod-username {
color: var(--fg_faded);
font-size: 14px;
}
}
}
.community-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 10px 15px;
border-bottom: 1px solid var(--border_grey);
.community-tag {
display: inline-block;
background-color: var(--bg_elements);
border: 1px solid var(--border_grey);
border-radius: 16px;
padding: 4px 12px;
font-size: 13px;
color: var(--accent);
}
}
.community-hashtag-header {
padding: 12px 15px;
border-bottom: 1px solid var(--border_grey);
.community-hashtag-title {
font-size: 20px;
color: var(--accent);
margin: 0;
}
}

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,194 @@
@import '_variables'; @import "_variables";
@import '_mixins'; @import "_mixins";
.search-title { .search-title {
font-weight: bold; font-weight: bold;
display: inline-block; display: inline-block;
margin-top: 4px; margin-top: 4px;
} }
.search-field { .search-field {
display: flex;
flex-wrap: wrap;
button {
margin: 0 2px 0 0;
padding: 0px 1px 1px 4px;
height: 23px;
display: flex; display: flex;
flex-wrap: wrap; align-items: center;
}
button { .pref-input {
margin: 0 2px 0 0; margin: 0 4px 0 0;
height: 23px; flex-grow: 1;
display: flex; height: 23px;
align-items: center; }
}
.pref-input { input[type="text"],
margin: 0 4px 0 0; input[type="number"] {
flex-grow: 1; height: calc(100% - 4px);
height: 23px; width: calc(100% - 8px);
} }
input[type="text"] { > label {
height: calc(100% - 4px); display: inline;
width: calc(100% - 8px); 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 { @include input-colors;
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 create-toggle(search-panel, 380px);
}
@include create-toggle(search-panel, 200px);
} }
.search-panel { .search-panel {
width: 100%; width: 100%;
max-height: 0; max-height: 0;
overflow: hidden; overflow: hidden;
transition: max-height 0.4s; transition: max-height 0.4s;
flex-grow: 1; flex-grow: 1;
font-weight: initial; font-weight: initial;
text-align: left; text-align: left;
> div { .checkbox-container {
line-height: 1.7em; display: inline;
} padding-right: unset;
margin-bottom: 5px;
margin-left: 23px;
}
.checkbox-container { .checkbox {
display: inline; right: unset;
padding-right: unset; left: -22px;
margin-bottom: unset; line-height: 1.6em;
margin-left: 23px; }
}
.checkbox { .checkbox-container .checkbox:after {
right: unset; top: -4px;
left: -22px; }
}
.checkbox-container .checkbox:after {
top: -4px;
}
} }
.search-row { .search-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
line-height: unset; line-height: unset;
> div { > div {
flex-grow: 1; flex-grow: 1;
flex-shrink: 1; flex-shrink: 1;
} }
input {
height: 21px;
}
.pref-input {
display: block;
padding-bottom: 5px;
input { input {
height: 21px; height: 21px;
} margin-top: 1px;
.pref-input {
display: block;
padding-bottom: 5px;
input {
height: 21px;
margin-top: 1px;
}
} }
}
} }
.search-toggles { .search-toggles {
flex-grow: 1; flex-grow: 1;
display: grid; display: grid;
grid-template-columns: repeat(6, auto); grid-template-columns: repeat(5, auto);
grid-column-gap: 10px; grid-column-gap: 10px;
}
.list-result {
display: flex;
align-items: flex-start;
.list-result-banner {
flex-shrink: 0;
width: 56px;
height: 56px;
margin-right: 10px;
border-radius: 8px;
overflow: hidden;
background-color: var(--darker_grey);
// stay above the tweet-link overlay's hover background
z-index: 1;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.list-result-body {
min-width: 0;
pointer-events: none;
z-index: 1;
a {
pointer-events: all;
}
}
.list-result-title {
align-items: baseline;
}
.list-members {
flex-shrink: 0;
margin-left: 0.3em;
color: var(--fg_faded);
}
.list-result-context {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-top: 2px;
color: var(--fg_faded);
a {
color: var(--fg_dark);
}
a.fullname {
color: var(--fg_color);
}
.list-facepile {
width: 20px;
height: 20px;
border-radius: 50%;
margin-right: 4px;
}
}
.list-result-description {
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: all;
}
} }
.profile-tabs { .profile-tabs {
@include search-resize(820px, 5); @include search-resize(820px, 5);
@include search-resize(725px, 4); @include search-resize(715px, 4);
@include search-resize(600px, 6); @include search-resize(700px, 5);
@include search-resize(560px, 5); @include search-resize(485px, 4);
@include search-resize(480px, 4); @include search-resize(410px, 3);
@include search-resize(410px, 3);
} }
@include search-resize(560px, 5); @include search-resize(700px, 5);
@include search-resize(480px, 4); @include search-resize(485px, 4);
@include search-resize(410px, 3); @include search-resize(410px, 3);

View file

@ -1,162 +1,505 @@
@import '_variables'; @import "_variables";
.timeline-container { .timeline-container {
@include panel(100%, 600px); @include panel(100%, 600px);
} }
.timeline { .timeline-container.media-only {
background-color: var(--bg_panel); max-width: none;
width: 100%;
padding: 0 10px;
box-sizing: border-box;
> div:not(:first-child) { > .tab,
border-top: 1px solid var(--border_grey); > .timeline-header {
} max-width: 900px;
margin-left: auto;
margin-right: auto;
}
}
@media (max-width: 700px) {
.timeline-container.media-only {
padding: 0;
}
}
.timeline > div:not(:first-child) {
border-top: 1px solid var(--border_grey);
} }
.timeline-header { .timeline-header {
width: 100%; width: 100%;
background-color: var(--bg_panel); background-color: var(--bg_panel);
text-align: center; text-align: center;
padding: 8px; padding: 8px;
display: block; display: block;
font-weight: bold; font-weight: bold;
margin-bottom: 5px; margin-bottom: 4px;
box-sizing: border-box; box-sizing: border-box;
button { button {
float: unset; float: unset;
} }
} }
.timeline-banner img { .timeline-banner img {
width: 100%; width: 100%;
} }
.timeline-description { .timeline-description {
font-weight: normal; font-weight: normal;
} }
.tab { .tab {
align-items: center; align-items: center;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
list-style: none; list-style: none;
margin: 0 0 5px 0; margin: 0 0 4px 0;
background-color: var(--bg_panel); background-color: var(--bg_panel);
padding: 0; padding: 0;
} }
.tab-item { .tab-item {
flex: 1 1 0; flex: 1 1 0;
text-align: center; text-align: center;
margin-top: 0; margin-top: 0;
a { a {
border-bottom: .1rem solid transparent; border-bottom: 0.1rem solid transparent;
color: var(--tab); color: var(--tab);
display: block; display: block;
padding: 8px 0; padding: 8px 0;
text-decoration: none; text-decoration: none;
font-weight: bold; font-weight: bold;
&:hover { &:hover {
text-decoration: none; text-decoration: none;
}
&.active {
border-bottom-color: var(--tab_selected);
color: var(--tab_selected);
}
} }
&.active a { &.active {
border-bottom-color: var(--tab_selected); border-bottom-color: var(--tab_selected);
color: var(--tab_selected); color: var(--tab_selected);
} }
}
&.wide { &.active a {
flex-grow: 1.2; border-bottom-color: var(--tab_selected);
flex-basis: 50px; color: var(--tab_selected);
} }
&.wide {
flex-grow: 1.2;
flex-basis: 50px;
}
} }
.timeline-footer { .timeline-footer {
background-color: var(--bg_panel); background-color: var(--bg_panel);
padding: 6px 0; padding: 6px 0;
} }
.timeline-protected { .timeline-protected {
text-align: center; text-align: center;
p { p {
margin: 8px 0; margin: 8px 0;
} }
h2 { h2 {
color: var(--accent);
font-size: 20px;
font-weight: 600;
}
}
.timeline-none {
color: var(--accent); color: var(--accent);
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
text-align: center; }
}
.timeline-none {
color: var(--accent);
font-size: 20px;
font-weight: 600;
text-align: center;
} }
.timeline-end { .timeline-end {
background-color: var(--bg_panel); background-color: var(--bg_panel);
color: var(--accent); color: var(--accent);
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
text-align: center; text-align: center;
} }
.show-more { .show-more {
background-color: var(--bg_panel); background-color: var(--bg_panel);
text-align: center; text-align: center;
padding: .75em 0; padding: 0.75em 0;
display: block !important; display: block !important;
a { a {
background-color: var(--darkest_grey); background-color: var(--darkest_grey);
display: inline-block; display: inline-block;
height: 2em; height: 2em;
padding: 0 2em; padding: 0 2em;
line-height: 2em; line-height: 2em;
&:hover { &:hover {
background-color: var(--darker_grey); background-color: var(--darker_grey);
}
} }
}
} }
.top-ref { .top-ref {
background-color: var(--bg_color); background-color: var(--bg_color);
border-top: none !important; border-top: none !important;
.icon-down { .icon-down {
font-size: 20px; font-size: 20px;
display: flex; display: flex;
justify-content: center; justify-content: center;
text-decoration: none; text-decoration: none;
&:hover { &:hover {
color: var(--accent_light); color: var(--accent_light);
}
&::before {
transform: rotate(180deg) translateY(-1px);
}
} }
&::before {
transform: rotate(180deg) translateY(-1px);
}
}
} }
.timeline-item { .timeline-item {
overflow-wrap: break-word; overflow-wrap: break-word;
border-left-width: 0; border-left-width: 0;
min-width: 0; min-width: 0;
padding: .75em; padding: 0.75em;
display: flex; display: flex;
position: relative; 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,284 @@
@import '_variables'; @import "_variables";
@import '_mixins'; @import "_mixins";
@import 'thread'; @import "thread";
@import 'media'; @import "media";
@import 'video'; @import "video";
@import 'embed'; @import "embed";
@import 'card'; @import "card";
@import 'poll'; @import "poll";
@import 'quote'; @import "quote";
.tweet-body { .tweet-body {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
margin-left: 58px; margin-left: 58px;
pointer-events: none; pointer-events: none;
z-index: 1; z-index: 1;
} }
.tweet-content { .tweet-content {
font-family: $font_3; line-height: 1.3em;
line-height: 1.3em; pointer-events: all;
pointer-events: all; display: inline;
display: inline;
} }
.tweet-bidi { .tweet-bidi {
display: block !important; display: block !important;
} }
.tweet-header { .tweet-header {
padding: 0; padding: 0;
vertical-align: bottom; vertical-align: bottom;
flex-basis: 100%; flex-basis: 100%;
margin-bottom: .2em; margin-bottom: 0.2em;
a { a {
display: inline-block; display: inline-block;
word-break: break-all; word-break: break-all;
max-width: 100%; max-width: 100%;
pointer-events: all; pointer-events: all;
} }
} }
.tweet-name-row { .tweet-name-row {
padding: 0; padding: 0;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
.verified-icon {
margin-left: 2px;
}
} }
.fullname-and-username { .fullname-and-username {
display: flex; display: flex;
min-width: 0; min-width: 0;
} }
.fullname { .fullname {
@include ellipsis; @include ellipsis;
flex-shrink: 2; flex-shrink: 2;
max-width: 80%; max-width: 80%;
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: var(--fg_color); color: var(--fg_color);
} }
.username { .username {
@include ellipsis; @include ellipsis;
min-width: 1.6em; min-width: 1.6em;
margin-left: .4em; margin-left: 0.4em;
word-wrap: normal; word-wrap: normal;
} }
.tweet-date { .tweet-date {
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
margin-left: 4px; margin-left: 4px;
} }
.tweet-date a, .username, .show-more a { .tweet-date a,
color: var(--fg_dark); .username,
.show-more a {
color: var(--fg_dark);
} }
.tweet-published { .tweet-published {
margin: 0; margin-top: 6px;
margin-top: 5px; margin-bottom: 0px;
color: var(--grey); color: var(--grey);
pointer-events: all;
} }
.tweet-avatar { .tweet-avatar {
display: contents !important; display: contents !important;
img { img {
float: left; float: left;
margin-top: 3px; margin-top: 3px;
margin-left: -58px; margin-left: -58px;
width: 48px; width: 48px;
height: 48px; height: 48px;
} }
} }
.avatar { .avatar {
&.round { &.round {
border-radius: 50%; border-radius: 50%;
-webkit-user-select: none; user-select: none;
} -webkit-user-select: none;
}
&.mini {
position: unset;
margin-right: 5px;
margin-top: -1px;
width: 20px;
height: 20px;
}
}
.tweet-embed { &.mini {
display: flex; position: unset;
flex-direction: column; margin-right: 5px;
justify-content: center; margin-top: -1px;
height: 100%; width: 20px;
background-color: var(--bg_panel); height: 20px;
}
.tweet-content {
font-size: 18px;
}
.tweet-body {
display: flex;
flex-direction: column;
max-height: calc(100vh - 0.75em * 2);
}
.card-image img {
height: auto;
}
.avatar {
position: absolute;
}
} }
.attribution { .attribution {
display: flex; display: flex;
pointer-events: all; pointer-events: all;
margin: 5px 0; margin: 5px 0;
strong { strong {
color: var(--fg_color); color: var(--fg_color);
} }
} }
.media-tag-block { .media-tag-block {
padding-top: 5px; padding-top: 5px;
pointer-events: all; pointer-events: all;
color: var(--fg_faded);
.icon-container {
padding-right: 2px;
}
.media-tag,
.icon-container {
color: var(--fg_faded); color: var(--fg_faded);
}
.icon-container {
padding-right: 2px;
}
.media-tag, .icon-container {
color: var(--fg_faded);
}
} }
.timeline-container .media-tag-block { .timeline-container .media-tag-block {
font-size: 13px; font-size: 13px;
} }
.tweet-geo { .tweet-geo {
color: var(--fg_faded); color: var(--fg_faded);
} }
.replying-to { .replying-to {
color: var(--fg_faded); color: var(--fg_faded);
margin: -2px 0 4px; margin: -2px 0 4px;
a { a {
pointer-events: all; pointer-events: all;
} }
} }
.retweet-header, .pinned, .tweet-stats { .retweet-header,
align-content: center; .pinned,
color: var(--grey); .tweet-stats {
display: flex; align-content: center;
flex-shrink: 0; color: var(--grey);
flex-wrap: wrap; display: flex;
font-size: 14px; flex-shrink: 0;
font-weight: 600; flex-wrap: wrap;
line-height: 22px; font-size: 14px;
font-weight: 600;
line-height: 22px;
span { span {
@include ellipsis; @include ellipsis;
} }
} }
.retweet-header { .retweet-header {
margin-top: -5px !important; margin-top: -5px !important;
} }
.tweet-stats { .tweet-stats {
margin-bottom: -3px; margin-bottom: -3px;
-webkit-user-select: none; user-select: none;
-webkit-user-select: none;
} }
.tweet-stat { .tweet-stat {
padding-top: 5px; padding-top: 5px;
min-width: 1em; min-width: 1em;
margin-right: 0.8em; margin-right: 0.8em;
} }
.show-thread { .show-thread {
display: block; display: block;
pointer-events: all; pointer-events: all;
padding-top: 2px; padding-top: 2px;
} }
.unavailable-box { .unavailable-box {
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 12px; padding: 12px;
border: solid 1px var(--dark_grey); border: solid 1px var(--dark_grey);
box-sizing: border-box; box-sizing: border-box;
border-radius: 10px; border-radius: 10px;
background-color: var(--bg_color); background-color: var(--bg_color);
z-index: 2; z-index: 2;
} }
.tweet-link { .tweet-link {
height: 100%; height: 100%;
width: 100%; width: 100%;
left: 0; left: 0;
top: 0; top: 0;
position: absolute; position: absolute;
-webkit-user-select: none; user-select: none;
-webkit-user-select: none;
&:hover { &:hover {
background-color: var(--bg_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-circled {
margin-right: -3px;
}
} }

View file

@ -1,119 +1,119 @@
@import '_variables'; @import "_variables";
@import '_mixins'; @import "_mixins";
.card { .card {
margin: 5px 0; margin: 5px 0;
pointer-events: all; pointer-events: all;
max-height: unset; max-height: unset;
} }
.card-container { .card-container {
border-radius: 10px; border: solid 1px var(--dark_grey);
border-width: 1px; border-radius: 10px;
border-style: solid; background-color: var(--bg_elements);
border-color: var(--dark_grey); overflow: hidden;
background-color: var(--bg_elements); color: inherit;
overflow: hidden; display: flex;
color: inherit; flex-direction: row;
display: flex; text-decoration: none !important;
flex-direction: row;
text-decoration: none !important;
&:hover { &:hover {
border-color: var(--grey); border-color: var(--grey);
} }
.attachments { .attachments {
margin: 0; margin: 0;
border-radius: 0; border-radius: 0;
} }
} }
.card-content { .card-content {
padding: 0.5em; padding: 0.5em;
} }
.card-title { .card-title {
@include ellipsis; @include ellipsis;
white-space: unset; white-space: unset;
font-weight: bold; font-weight: bold;
font-size: 1.1em; font-size: 1.1em;
} }
.card-description { .card-description {
margin: 0.3em 0; margin: 0.3em 0;
white-space: pre-wrap; white-space: pre-wrap;
} }
.card-destination { .card-destination {
@include ellipsis; @include ellipsis;
color: var(--grey); color: var(--grey);
display: block; display: block;
} }
.card-content-container { .card-content-container {
color: unset; color: unset;
overflow: auto; overflow: auto;
&:hover {
text-decoration: none; &:hover {
} text-decoration: none;
}
} }
.card-image-container { .card-image-container {
width: 98px; width: 98px;
flex-shrink: 0; flex-shrink: 0;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
&:before {
content: ""; &:before {
display: block; content: "";
padding-top: 100%; display: block;
} padding-top: 100%;
}
} }
.card-image { .card-image {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
bottom: 0; bottom: 0;
right: 0; right: 0;
background-color: var(--bg_overlays); background-color: var(--bg_overlays);
img { img {
width: 100%; width: 100%;
height: 100%; height: 100%;
max-height: 400px; max-height: 400px;
display: block; display: block;
object-fit: cover; object-fit: cover;
} }
} }
.card-overlay { .card-overlay {
@include play-button; @include play-button;
opacity: 0.8; opacity: 0.8;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
.large { .large {
.card-container { .card-container {
display: block; display: block;
} }
.card-image-container { .card-image-container {
width: unset; width: unset;
&:before { &:before {
display: none; display: none;
}
} }
}
.card-image { .card-image {
position: unset; position: unset;
border-style: solid; border-style: solid;
border-color: var(--dark_grey); border-color: var(--dark_grey);
border-width: 0; border-width: 0;
border-bottom-width: 1px; border-bottom-width: 1px;
} }
} }

View file

@ -1,17 +1,159 @@
@import '_variables'; @import "_variables";
@import '_mixins'; @import "_mixins";
.embed-video { // Embed page: transparent background, no scrollbars
.gallery-video { html:has(body > .embed-wrapper),
width: 100%; html:has(body > .embed-video) {
height: 100%; background: transparent;
position: absolute; overflow: hidden;
background-color: black;
top: 0%;
left: 0%;
}
.video-container { body {
max-height: unset; background: transparent;
} overflow: hidden;
}
}
// Tweet embed wrapper
.embed-wrapper {
box-sizing: border-box;
border: 1px solid var(--border_grey);
border-radius: 12px;
overflow: hidden;
.embed-footer {
display: block;
padding: 12px 16px;
border-top: 1px solid var(--border_grey);
background: var(--bg_panel);
color: var(--accent);
font-size: 14px;
font-weight: 500;
text-align: center;
text-decoration: none;
transition: background-color 0.15s;
&:hover {
background: var(--bg_hover);
}
}
}
// Tweet embed content
.tweet-embed {
position: relative;
background-color: var(--bg_panel);
transition: background-color 0.15s;
&:hover {
background-color: var(--bg_hover);
}
.timeline-item {
pointer-events: none;
background-color: transparent;
}
.tweet-link:hover {
background-color: transparent;
}
.tweet-content {
font-size: 18px;
}
.avatar:not(.mini) {
position: absolute;
}
// Cap media height in embeds
.still-image img,
.quote-media-container img,
.quote-media-container video {
max-height: 600px;
}
&.error-embed {
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
padding: 20px;
.error-panel {
margin: 0;
}
}
}
// Video-only embed
.embed-video {
position: relative;
min-height: 300px;
background-color: black;
border: 1px solid var(--border_grey);
.attachments {
margin: 0;
border-radius: 0;
max-height: 560px;
background-color: unset;
}
.card {
margin: 0;
}
.gallery-video {
width: 100%;
}
.gallery-video>.attachment {
max-height: 560px;
width: 100%;
}
video {
width: 100%;
height: auto;
max-height: 560px;
object-fit: contain;
}
.video-download {
display: none;
}
.video-overlay-link {
position: absolute;
top: 12px;
right: 12px;
padding: 6px 12px;
background: rgba(30, 30, 30, 0.75);
backdrop-filter: blur(4px);
color: #fff;
font-size: 13px;
font-weight: 700;
text-decoration: none;
border-radius: 9999px;
border: 1px solid transparent;
transition:
background 0.15s,
opacity 0.15s;
z-index: 10;
&:hover {
background: rgba(60, 60, 60, 0.9);
}
}
// Hide button while playing, show on hover or when paused
&.video-playing .video-overlay-link {
opacity: 0;
pointer-events: none;
}
&.video-playing:hover .video-overlay-link {
opacity: 1;
pointer-events: auto;
}
} }

View file

@ -1,119 +1,168 @@
@import '_variables'; @import "_variables";
.gallery-row { .gallery-row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: nowrap; flex-wrap: nowrap;
align-items: center; overflow: hidden;
overflow: hidden; flex-grow: 1;
flex-grow: 1; max-height: 379.5px;
max-height: 379.5px; max-width: 533px;
max-width: 533px; pointer-events: all;
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 { .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 { .attachments {
margin-top: .35em; margin-top: 0.35em;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
width: 100%; width: 100%;
max-height: 600px; max-height: 600px;
border-radius: 7px; border-radius: 7px;
overflow: hidden; overflow: hidden;
flex-flow: column; flex-flow: column;
background-color: var(--bg_color); background-color: var(--bg_color);
align-items: center; align-items: center;
pointer-events: all; pointer-events: all;
.image-attachment {
width: 100%;
}
} }
.attachment { .attachment {
position: relative; position: relative;
line-height: 0; line-height: 0;
overflow: hidden; overflow: hidden;
margin: 0 .25em 0 0; margin: 0 0.25em 0 0;
flex-grow: 1; flex-grow: 1;
box-sizing: border-box; box-sizing: border-box;
min-width: 2em; min-width: 2em;
&:last-child { &:last-child {
margin: 0; margin: 0;
max-height: 530px;
}
}
.gallery-gif video {
max-height: 530px; 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 { .media-gif {
display: table; display: table;
background-color: unset; background-color: unset;
width: unset; width: unset;
max-height: unset;
}
.media-gif video,
.media-gif img {
width: 100%;
height: 100%;
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 { .media-body {
flex: 1; flex: 1;
padding: 0; padding: 0;
white-space: pre-wrap; white-space: pre-wrap;
} }

View file

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

View file

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

View file

@ -1,138 +1,196 @@
@import '_variables'; @import "_variables";
@import '_mixins'; @import "_mixins";
.conversation { .conversation,
@include panel(100%, 600px); .edit-history {
@include panel(100%, 600px);
.show-more { .show-more {
margin-bottom: 10px; margin-bottom: 10px;
} }
} }
.main-thread { .main-thread,
margin-bottom: 20px; .latest-edit {
background-color: var(--bg_panel); margin-bottom: 20px;
}
.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;
}
} }
.reply { .reply {
background-color: var(--bg_panel); margin-bottom: 10px;
margin-bottom: 10px; }
.reply-sort {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px 14px;
margin-bottom: 10px;
padding: 8px 12px;
background-color: var(--bg_panel);
font-size: 14px;
}
.reply-sort-label {
color: var(--fg_faded);
margin-right: 2px;
}
.reply-sort-option {
color: var(--tab);
font-weight: bold;
text-decoration: none;
border-bottom: 0.1rem solid transparent;
&:hover {
color: var(--fg_color);
text-decoration: none;
}
&.active {
color: var(--tab_selected);
border-bottom-color: var(--tab_selected);
}
}
.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 { .thread-line {
.timeline-item::before, .timeline-item::before,
&.timeline-item::before { &.timeline-item::before {
background: var(--accent_dark); background: var(--accent_dark);
content: ''; content: "";
position: relative; position: relative;
min-width: 3px; min-width: 3px;
width: 3px; width: 3px;
left: 26px; left: 26px;
border-radius: 2px; border-radius: 2px;
margin-left: -3px; margin-left: -3px;
margin-bottom: 37px; margin-bottom: 37px;
top: 56px; top: 56px;
z-index: 1; z-index: 1;
pointer-events: none; pointer-events: none;
} }
.with-header:not(:first-child)::after { .with-header:not(:first-child)::after {
background: var(--accent_dark); background: var(--accent_dark);
content: ''; content: "";
position: relative; position: relative;
float: left; float: left;
min-width: 3px; min-width: 3px;
width: 3px; width: 3px;
right: calc(100% - 26px); right: calc(100% - 26px);
border-radius: 2px; border-radius: 2px;
margin-left: -3px; margin-left: -3px;
margin-bottom: 37px; margin-bottom: 37px;
bottom: 10px; bottom: 10px;
height: 30px; height: 30px;
z-index: 1; z-index: 1;
pointer-events: none; pointer-events: none;
} }
.unavailable::before { .unavailable::before {
top: 48px; top: 48px;
margin-bottom: 28px; margin-bottom: 28px;
} }
.more-replies::before { .more-replies::before {
content: '...'; content: "...";
background: unset; background: unset;
color: var(--more_replies_dots); color: var(--more_replies_dots);
font-weight: bold; font-weight: bold;
font-size: 20px; font-size: 20px;
line-height: 0.25em; line-height: 0.25em;
left: 1.2em; left: 1.2em;
width: 5px; width: 5px;
top: 2px; top: 2px;
margin-bottom: 0; margin-bottom: 0;
margin-left: -2.5px; margin-left: -2.5px;
} }
.earlier-replies { .earlier-replies {
padding-bottom: 0; padding-bottom: 0;
margin-bottom: -5px; margin-bottom: -5px;
} }
} }
.timeline-item.thread-last::before { .timeline-item.thread-last::before {
background: unset; background: unset;
min-width: unset; min-width: unset;
width: 0; width: 0;
margin: 0; margin: 0;
} }
.more-replies { .more-replies {
padding-top: 0.3em !important; padding-top: 0.3em !important;
} }
.more-replies-text { .more-replies-text {
@include ellipsis; @include ellipsis;
display: block; display: block;
margin-left: 58px; margin-left: 58px;
padding: 7px 0; padding: 7px 0;
} }
.timeline-item.thread.more-replies-thread { .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 { &::before {
top: 40px; display: inline-block;
margin-bottom: 31px; position: relative;
top: -1px;
line-height: 0.4em;
} }
.more-replies { .more-replies-text {
display: flex; display: inline;
padding-top: unset !important;
margin-top: 8px;
&::before {
display: inline-block;
position: relative;
top: -1px;
line-height: 0.4em;
}
.more-replies-text {
display: inline;
}
} }
}
}
.related-header {
padding: 8px 12px;
margin-top: 10px;
background-color: var(--bg_panel);
color: var(--fg_faded);
font-size: 14px;
border-bottom: 1px solid var(--border_grey);
} }

View file

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

64
src/tid.nim Normal file
View file

@ -0,0 +1,64 @@
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:
let client = newAsyncHttpClient()
defer: client.close()
let resp = await client.get(pairsUrl)
if resp.status == $Http200:
cachedPairs = parseTidPairs(await resp.body)
lastCached = int(epochTime())
if cachedPairs.len == 0:
raise newException(ValueError, "Failed to fetch x-client-transaction-id pairs")
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 # SPDX-License-Identifier: AGPL-3.0-only
import times, sequtils, options, tables, uri import times, sequtils, options, tables
import prefs_impl import prefs_impl
genPrefsType() genPrefsType()
@ -11,21 +11,16 @@ type
BadClientError* = object of CatchableError BadClientError* = object of CatchableError
TimelineKind* {.pure.} = enum TimelineKind* {.pure.} = enum
tweets, replies, media tweets, replies, media, articles
Api* {.pure.} = enum ApiUrl* = object
tweetDetail endpoint*: string
tweetResult params*: seq[(string, string)]
search skipTid*: bool
list
listBySlug ApiReq* = object
listMembers oauth*: ApiUrl
listTweets cookie*: ApiUrl
userRestId
userScreenName
userTweets
userTweetsAndReplies
userMedia
RateLimit* = object RateLimit* = object
limit*: int limit*: int
@ -42,7 +37,7 @@ type
pending*: int pending*: int
limited*: bool limited*: bool
limitedAt*: int limitedAt*: int
apis*: Table[Api, RateLimit] apis*: Table[string, RateLimit]
case kind*: SessionKind case kind*: SessionKind
of oauth: of oauth:
oauthToken*: string oauthToken*: string
@ -51,10 +46,6 @@ type
authToken*: string authToken*: string
ct0*: string ct0*: string
SessionAwareUrl* = object
oauthUrl*: Uri
cookieUrl*: Uri
Error* = enum Error* = enum
null = 0 null = 0
noUserMatches = 17 noUserMatches = 17
@ -70,6 +61,7 @@ type
rateLimited = 88 rateLimited = 88
expiredToken = 89 expiredToken = 89
listIdOrSlug = 112 listIdOrSlug = 112
timelineUnavailable = 131
tweetNotFound = 144 tweetNotFound = 144
tweetNotAuthorized = 179 tweetNotAuthorized = 179
forbidden = 200 forbidden = 200
@ -106,6 +98,59 @@ type
suspended*: bool suspended*: bool
joinDate*: DateTime joinDate*: DateTime
AccountInfo* = object
username*: string
fullname*: string
userPic*: string
joinDate*: DateTime
verifiedType*: VerifiedType
suspended*: bool
basedIn*: string
source*: string
usernameChanges*: int
lastUsernameChange*: DateTime
affiliateUsername*: string
affiliateLabel*: string
isIdentityVerified*: bool
verifiedSince*: DateTime
overrideVerifiedYear*: int
Broadcast* = object
id*: string
title*: string
state*: string
thumb*: string
mediaKey*: string
m3u8Url*: string
totalWatched*: int
startTime*: DateTime
endTime*: DateTime
replayStart*: int
availableForReplay*: bool
user*: User
SpaceParticipant* = object
userId*: string
username*: string
displayName*: string
avatarUrl*: string
isVerified*: bool
AudioSpace* = object
id*: string
title*: string
state*: string
mediaKey*: string
m3u8Url*: string
totalLiveListeners*: int
totalReplayWatched*: int
startTime*: DateTime
endTime*: DateTime
availableForReplay*: bool
creator*: User
admins*: seq[SpaceParticipant]
speakers*: seq[SpaceParticipant]
VideoType* = enum VideoType* = enum
m3u8 = "application/x-mpegURL" m3u8 = "application/x-mpegURL"
mp4 = "video/mp4" mp4 = "video/mp4"
@ -129,10 +174,15 @@ type
variants*: seq[VideoVariant] variants*: seq[VideoVariant]
QueryKind* = enum QueryKind* = enum
posts, replies, media, users, tweets, userList posts, replies, media, users, tweets, userList, followers, following, lists, top,
articles
RankingMode* = enum
Relevance, Recency, Likes
Query* = object Query* = object
kind*: QueryKind kind*: QueryKind
view*: string
text*: string text*: string
filters*: seq[string] filters*: seq[string]
includes*: seq[string] includes*: seq[string]
@ -140,12 +190,33 @@ type
fromUser*: seq[string] fromUser*: seq[string]
since*: string since*: string
until*: string until*: string
near*: string minLikes*: string
sep*: string sep*: string
Gif* = object Gif* = object
url*: string url*: string
thumb*: 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 GalleryPhoto* = object
url*: string url*: string
@ -154,6 +225,44 @@ type
PhotoRail* = seq[GalleryPhoto] PhotoRail* = seq[GalleryPhoto]
Article* = ref object
title*: string
coverImage*: string
user*: User
time*: DateTime
stats*: TweetStats
paragraphs*: seq[ArticleParagraph]
entities*: Table[int, ArticleEntity]
media*: Table[string, ArticleMedia]
ArticleParagraph* = object
text*: string
kind*: string
inlineStyles*: seq[ArticleStyle]
entityRanges*: seq[ArticleEntityRange]
ArticleStyle* = object
offset*: int
length*: int
style*: string
ArticleEntityRange* = object
offset*: int
length*: int
key*: int
ArticleEntity* = object
kind*: string
url*: string
mediaIds*: seq[string]
tweetId*: string
markdown*: string
caption*: string
ArticleMedia* = object
kind*: string
url*: string
Poll* = object Poll* = object
options*: seq[string] options*: seq[string]
values*: seq[int] values*: seq[int]
@ -203,6 +312,12 @@ type
likes*: int likes*: int
views*: int views*: int
ArticlePreview* = object
title*: string
previewText*: string
coverImage*: string
tweetId*: int64
Tweet* = ref object Tweet* = ref object
id*: int64 id*: int64
threadId*: int64 threadId*: int64
@ -221,13 +336,17 @@ type
stats*: TweetStats stats*: TweetStats
retweet*: Option[Tweet] retweet*: Option[Tweet]
attribution*: Option[User] attribution*: Option[User]
attributionLink*: string
mediaTags*: seq[User] mediaTags*: seq[User]
quote*: Option[Tweet] quote*: Option[Tweet]
card*: Option[Card] card*: Option[Card]
poll*: Option[Poll] poll*: Option[Poll]
gif*: Option[Gif] media*: MediaEntities
video*: Option[Video] history*: seq[int64]
photos*: seq[string] note*: string
isAd*: bool
isAI*: bool
articlePreview*: Option[ArticlePreview]
Tweets* = seq[Tweet] Tweets* = seq[Tweet]
@ -241,6 +360,7 @@ type
content*: Tweets content*: Tweets
hasMore*: bool hasMore*: bool
cursor*: string cursor*: string
related*: bool
Conversation* = ref object Conversation* = ref object
tweet*: Tweet tweet*: Tweet
@ -248,6 +368,10 @@ type
after*: Chain after*: Chain
replies*: Result[Chain] replies*: Result[Chain]
EditHistory* = object
latest*: Tweet
history*: Tweets
Timeline* = Result[Tweets] Timeline* = Result[Tweets]
Profile* = object Profile* = object
@ -255,6 +379,7 @@ type
photoRail*: PhotoRail photoRail*: PhotoRail
pinned*: Option[Tweet] pinned*: Option[Tweet]
tweets*: Timeline tweets*: Timeline
accountInfo*: AccountInfo
List* = object List* = object
id*: string id*: string
@ -265,6 +390,29 @@ type
members*: int members*: int
banner*: string banner*: string
ListSearchResult* = object
list*: List
owner*: User
followersContext*: string
facepiles*: seq[string]
CommunityRule* = object
name*: string
description*: string
Community* = object
id*: string
name*: string
description*: string
memberCount*: int
banner*: string
creator*: User
category*: string
joinPolicy*: string
createdAt*: DateTime
rules*: seq[CommunityRule]
hashtags*: seq[string]
GlobalObjects* = ref object GlobalObjects* = ref object
tweets*: Table[string, Tweet] tweets*: Table[string, Tweet]
users*: Table[string, User] users*: Table[string, User]
@ -281,10 +429,20 @@ type
hmacKey*: string hmacKey*: string
base64Media*: bool base64Media*: bool
minTokens*: int minTokens*: int
enableRss*: bool enableRSSUserTweets*: bool
enableRSSUserReplies*: bool
enableRSSUserMedia*: bool
enableRSSUserArticles*: bool
enableRSSSearch*: bool
enableRSSList*: bool
enableDebug*: bool enableDebug*: bool
proxy*: string proxy*: string
proxyAuth*: string proxyAuth*: string
apiProxy*: string
disableTid*: bool
maxConcurrentReqs*: int
maxRetries*: int
retryDelayMs*: int
rssCacheTime*: int rssCacheTime*: int
listCacheTime*: int listCacheTime*: int
@ -303,3 +461,24 @@ proc contains*(thread: Chain; tweet: Tweet): bool =
proc add*(timeline: var seq[Tweets]; tweet: Tweet) = proc add*(timeline: var seq[Tweets]; tweet: Tweet) =
timeline.add @[tweet] timeline.add @[tweet]
proc getPhotos*(tweet: Tweet): seq[Photo] =
tweet.media.filterIt(it.kind == photoMedia).mapIt(it.photo)
proc getVideos*(tweet: Tweet): seq[Video] =
tweet.media.filterIt(it.kind == videoMedia).mapIt(it.video)
proc hasPhotos*(tweet: Tweet): bool =
tweet.media.anyIt(it.kind == photoMedia)
proc hasVideos*(tweet: Tweet): bool =
tweet.media.anyIt(it.kind == videoMedia)
proc hasGifs*(tweet: Tweet): bool =
tweet.media.anyIt(it.kind == gifMedia)
proc getThumb*(media: Media): string =
case media.kind
of photoMedia: media.photo.url
of videoMedia: media.video.thumb
of gifMedia: media.gif.thumb

View file

@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, uri, tables, base64 import sequtils, strutils, strformat, uri, tables, base64
import nimcrypto import nimcrypto
var var
@ -9,7 +9,7 @@ var
const const
https* = "https://" https* = "https://"
twimg* = "pbs.twimg.com/" twimg* = "pbs.twimg.com/"
nitterParams = ["name", "tab", "id", "list", "referer", "scroll"] nitterParams* = ["name", "tab", "id", "list", "referer", "scroll", "prefs"]
twitterDomains = @[ twitterDomains = @[
"twitter.com", "twitter.com",
"pic.twitter.com", "pic.twitter.com",
@ -17,7 +17,9 @@ const
"abs.twimg.com", "abs.twimg.com",
"pbs.twimg.com", "pbs.twimg.com",
"video.twimg.com", "video.twimg.com",
"x.com" "x.com",
"pscp.tv",
"video.pscp.tv"
] ]
proc setHmacKey*(key: string) = proc setHmacKey*(key: string) =
@ -38,12 +40,14 @@ proc getVidUrl*(link: string): string =
&"/video/{sig}/{encodeUrl(link)}" &"/video/{sig}/{encodeUrl(link)}"
proc getPicUrl*(link: string): string = proc getPicUrl*(link: string): string =
if link.len == 0: return
if base64Media: if base64Media:
&"/pic/enc/{encode(link, safe=true)}" &"/pic/enc/{encode(link, safe=true)}"
else: else:
&"/pic/{encodeUrl(link)}" &"/pic/{encodeUrl(link)}"
proc getOrigPicUrl*(link: string): string = proc getOrigPicUrl*(link: string): string =
if link.len == 0: return
if base64Media: if base64Media:
&"/pic/orig/enc/{encode(link, safe=true)}" &"/pic/orig/enc/{encode(link, safe=true)}"
else: else:
@ -55,7 +59,13 @@ proc filterParams*(params: Table): seq[(string, string)] =
result.add p result.add p
proc isTwitterUrl*(uri: Uri): bool = proc isTwitterUrl*(uri: Uri): bool =
uri.hostname in twitterDomains uri.scheme in ["http", "https"] and
(uri.hostname in twitterDomains or uri.hostname.endsWith(".video.pscp.tv"))
proc isTwitterUrl*(url: string): bool = proc isTwitterUrl*(url: string): bool =
isTwitterUrl(parseUri(url)) isTwitterUrl(parseUri(url))
proc validateNumber*(value: string): string =
if value.anyIt(not it.isDigit):
return ""
return value

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

248
src/views/article.nim Normal file
View file

@ -0,0 +1,248 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, tables, unicode, bitops, uri
import karax/[karaxdsl, vdom]
import renderutils, tweet, timeline
import ".."/[types, utils, formatters]
proc renderAtomicParagraph(paragraph: ArticleParagraph; article: Article;
tweets: Table[int64, Tweet]; path: string;
prefs: Prefs): VNode =
if paragraph.entityRanges.len == 0:
return text ""
let er = paragraph.entityRanges[0]
if er.key notin article.entities:
return text ""
let entity = article.entities[er.key]
case entity.kind
of "MEDIA":
buildHtml(tdiv(class="article-media")):
for id in entity.mediaIds:
let media = article.media.getOrDefault(id)
if media.url.len == 0:
continue
case media.kind
of "ApiGif":
video(src=getVidUrl(media.url), controls="", autoplay="", loop="",
muted="")
of "ApiVideo":
video(src=getVidUrl(media.url), controls="")
else:
a(href=getOrigPicUrl(media.url), target="_blank"):
img(src=getSmallPic(media.url), alt=entity.caption, loading="lazy")
if entity.caption.len > 0:
p(class="article-media-caption"): text entity.caption
of "TWEET":
let tweet = tweets.getOrDefault(
try: parseBiggestInt(entity.tweetId)
except ValueError: 0, nil)
if tweet != nil:
renderTweet(tweet, prefs, path)
else:
text ""
of "MARKDOWN":
var content = entity.markdown
if content.startsWith("```"):
let firstNl = content.find('\n')
if firstNl >= 0: content = content[firstNl + 1 .. ^1]
if content.endsWith("```"): content = content[0 .. ^4]
content = content.strip
buildHtml(pre()):
code(): text content
of "DIVIDER":
buildHtml(hr(class="article-divider"))
else:
text ""
proc wrapStyle(node: VNode; style: int): VNode =
result = node
if style.testBit(4): result = buildHtml(code()): result
if style.testBit(0): result = buildHtml(strong()): result
if style.testBit(1): result = buildHtml(em()): result
if style.testBit(2): result = buildHtml(del()): result
if style.testBit(3): result = buildHtml(underlined()): result
proc addContent(target: VNode; content: string; style = 0) =
var first = true
for line in content.split('\n'):
if not first:
target.add VNode(kind: VNodeKind.br)
first = false
var pos = 0
while pos < line.len:
let atPos = line.find('@', pos)
if atPos == -1:
target.add wrapStyle(text line[pos .. ^1], style)
break
if atPos > 0 and line[atPos - 1] in Letters + Digits + {'_'}:
target.add wrapStyle(text line[pos .. atPos], style)
pos = atPos + 1
continue
var j = atPos + 1
while j < line.len and j - atPos - 1 < 15 and
line[j] in Letters + Digits + {'_'}:
inc j
if j == atPos + 1:
target.add wrapStyle(text line[pos .. atPos], style)
pos = atPos + 1
continue
if atPos > pos:
target.add wrapStyle(text line[pos ..< atPos], style)
let username = line[atPos + 1 ..< j]
let link = a.newVNode()
link.setAttr("href", "/" & username)
link.add wrapStyle(text ("@" & username), style)
target.add link
pos = j
proc applyInlineStyles(target: VNode; runes: seq[Rune]; start, length: int;
styles: seq[ArticleStyle]) =
if styles.len == 0:
target.addContent($runes[start ..< start + length])
return
var
lastStyle = 0
lastStart = start
let endPos = start + length
for i in start ..< endPos:
var style = 0
for sr in styles:
let
sStart = sr.offset
sEnd = sStart + sr.length
if sStart <= i and sEnd > i:
case sr.style
of "Bold": style.setBit(0)
of "Italic": style.setBit(1)
of "Strikethrough": style.setBit(2)
of "Underline": style.setBit(3)
of "Code": style.setBit(4)
else: discard
if style != lastStyle:
if i > lastStart:
addContent(target, $runes[lastStart ..< i], lastStyle)
lastStyle = style
lastStart = i
if lastStart < endPos:
addContent(target, $runes[lastStart ..< endPos], lastStyle)
proc renderTextParagraph(paragraph: ArticleParagraph; article: Article): VNode =
let text = paragraph.text
result = case paragraph.kind
of "header-one": h1.newVNode()
of "header-two": h2.newVNode()
of "header-three": h3.newVNode()
of "ordered-list-item", "unordered-list-item": li.newVNode()
of "blockquote": VNode(kind: VNodeKind.blockquote)
of "code-block":
let pre = pre.newVNode()
let code = code.newVNode()
code.add text text
pre.add code
return pre
else: p.newVNode()
let
runes = text.toRunes
textLen = runes.len
var last = 0
for er in paragraph.entityRanges:
if er.offset > last:
applyInlineStyles(result, runes, last, er.offset - last,
paragraph.inlineStyles)
last = er.offset + er.length
var target = result
if er.key in article.entities:
let entity = article.entities[er.key]
if entity.kind == "LINK":
let parsed = parseUri(entity.url)
if parsed.scheme in ["http", "https"]:
target = a.newVNode()
if parsed.isTwitterUrl:
target.setAttr("href", parsed.path)
else:
target.setAttr("href", entity.url)
applyInlineStyles(target, runes, er.offset, er.length,
paragraph.inlineStyles)
if target != result:
result.add target
if last < textLen:
applyInlineStyles(result, runes, last, textLen - last,
paragraph.inlineStyles)
if paragraph.kind == "blockquote" and result.len > 0:
let lastChild = result[result.len - 1]
if lastChild.kind == VNodeKind.strong and lastChild.len > 0 and
lastChild[0].kind == VNodeKind.text:
lastChild.setAttr("class", "blockquote-attribution")
proc renderArticle*(article: Article; tweets: Table[int64, Tweet];
path: string; prefs: Prefs; tweetId=""): VNode =
let author = article.user
let main = buildHtml(article(class="article-body")):
h1(class="article-title"): text article.title
tdiv(class="article-author"):
tdiv(class="article-author-row"):
a(class="article-avatar", href=("/" & author.username)):
genImg(author.getUserPic("_bigger"), class=prefs.getAvatarClass)
tdiv(class="article-author-info"):
tdiv(class="article-author-name"):
linkUser(author, class="fullname")
verifiedIcon(author)
tdiv(class="article-author-meta"):
linkUser(author, class="username")
span(class="article-date-sep"): text " · "
a(class="article-date",
href=("/" & author.username & "/status/" & tweetId)):
text article.time.getShortTime
if not prefs.hideTweetStats:
renderStats(article.stats)
var listKind = ""
var list: VNode = nil
for paragraph in article.paragraphs:
let isListItem = paragraph.kind in [
"ordered-list-item", "unordered-list-item"]
if not isListItem and list != nil:
main.add list
list = nil
listKind = ""
if paragraph.kind == "atomic":
main.add renderAtomicParagraph(paragraph, article, tweets, path, prefs)
elif isListItem:
if paragraph.kind != listKind:
if list != nil:
main.add list
list = if paragraph.kind == "ordered-list-item": ol.newVNode()
else: ul.newVNode()
listKind = paragraph.kind
list.add renderTextParagraph(paragraph, article)
else:
main.add renderTextParagraph(paragraph, article)
if list != nil:
main.add list
buildHtml(tdiv(class="article-page")):
if article.coverImage.len > 0:
a(href=getOrigPicUrl(article.coverImage), target="_blank"):
img(class="article-cover", src=getSmallPic(article.coverImage), alt="")
main
renderToTop()

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

128
src/views/community.nim Normal file
View file

@ -0,0 +1,128 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, times
import karax/[karaxdsl, vdom]
import renderutils
import ".."/[types, utils, formatters]
proc renderCommunityTabs*(kind: QueryKind; community: Community): VNode =
let
path = &"/i/communities/{community.id}"
q = Query(kind: kind)
buildHtml(tdiv):
ul(class="tab"):
li(class=q.getTabClass(posts)):
a(href=path): text "Top"
li(class=q.getTabClass(replies)):
a(href=(path & "/latest")): text "Latest"
li(class=q.getTabClass(media)):
a(href=(path & "/media")): text "Media"
li(class=q.getTabClass(userList)):
a(href=(path & "/about")): text "About"
if community.hashtags.len > 0:
tdiv(class="community-tags"):
for tag in community.hashtags:
let bare = tag.strip(chars={'#'})
a(class="community-tag",
href=(&"/i/communities/{community.id}/hashtag/{bare}")):
text tag
proc renderMemberTabs*(community: Community; isModerators: bool): VNode =
let path = &"/i/communities/{community.id}"
buildHtml(ul(class="tab")):
li(class=(if not isModerators: "tab-item active" else: "tab-item")):
a(href=(path & "/members")): text "All"
li(class=(if isModerators: "tab-item active" else: "tab-item")):
a(href=(path & "/moderators")): text "Moderators"
proc renderHashtagHeader*(community: Community; tag: string): VNode =
buildHtml(tdiv(class="community-hashtag-header")):
h2(class="community-hashtag-title"): text "#" & tag
proc renderCommunityAbout*(community: Community; moderators: seq[User]): VNode =
buildHtml(tdiv(class="community-about")):
tdiv(class="community-info"):
h2: text "Community Info"
tdiv(class="community-info-item"):
icon "group"
if community.joinPolicy == "Open":
text "Anyone can join this Community."
else:
text "Membership is by approval only."
tdiv(class="community-info-item"):
icon "info"
text "All Communities are publicly visible."
tdiv(class="community-info-item"):
icon "calendar"
let
date = community.createdAt.format("MMMM d, yyyy")
creator = community.creator.username
span:
text &"Created {date} by "
a(href=(&"/{creator}")): text &"@{creator}"
if community.creator.verifiedType != none:
verifiedIcon(community.creator)
if community.rules.len > 0:
tdiv(class="community-rules"):
h2: text "Rules"
p(class="community-rules-intro"):
text "These are set and enforced by Community admins and are in addition to "
a(href="https://help.x.com/rules-and-policies/x-rules"): text "X's rules"
text "."
for i, rule in community.rules:
tdiv(class="community-rule"):
span(class="community-rule-number"): text $(i + 1)
tdiv(class="community-rule-content"):
strong: text rule.name
if rule.description.len > 0:
p: text rule.description
if moderators.len > 0:
tdiv(class="community-moderators"):
h2:
text "Moderators"
a(class="community-mods-link",
href=(&"/i/communities/{community.id}/moderators")):
text "See all"
for user in moderators:
tdiv(class="community-moderator"):
a(href=(&"/{user.username}")):
genImg(user.getUserPic("_bigger"), class="community-mod-avatar")
tdiv(class="community-mod-info"):
a(href=(&"/{user.username}"), class="community-mod-name"):
text user.fullname
if user.verifiedType != none:
verifiedIcon(user)
a(href=(&"/{user.username}"), class="community-mod-username"):
text &"@{user.username}"
proc renderCommunity*(body, nav: VNode; community: Community): VNode =
buildHtml(tdiv(class="timeline-container")):
if community.banner.len > 0:
tdiv(class="timeline-banner"):
a(href=getPicUrl(community.banner), target="_blank"):
genImg(community.banner)
tdiv(class="community-header"):
h1(class="community-name"):
a(href=(&"/i/communities/{community.id}")): text community.name
if community.category.len > 0:
span(class="community-category"): text community.category
if community.description.len > 0:
tdiv(class="community-description"):
text community.description
tdiv(class="community-stats"):
a(class="community-member-count",
href=(&"/i/communities/{community.id}/members")):
text insertSep($community.memberCount, ',')
text " Members"
nav
body

View file

@ -1,22 +1,76 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import options
import karax/[karaxdsl, vdom] import karax/[karaxdsl, vdom]
from jester import Request from jester import Request
import ".."/[types, formatters] import ".."/[types, formatters, prefs]
import general, tweet import general, tweet
const doctype = "<!DOCTYPE html>\n" const
doctype = "<!DOCTYPE html>\n"
embedResizeJs = staticRead("../../public/js/embedResize.js")
proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
let thumb = get(tweet.video).thumb let
let vidUrl = getVideoEmbed(cfg, tweet.id) video = tweet.getVideos()[0]
let prefs = Prefs(hlsPlayback: true, mp4Playback: true) thumb = video.thumb
vidUrl = getVideoEmbed(cfg, tweet.id)
prefs = Prefs(hlsPlayback: true, mp4Playback: true, proxyVideos: defaultPrefs.proxyVideos)
tweetUrl = getLink(tweet)
let node = buildHtml(html(lang="en")): let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb])) renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb]))
base(target="_blank")
body: body:
tdiv(class="embed-video"): tdiv(class="embed-video"):
renderVideo(get(tweet.video), prefs, "") renderVideo(video, prefs, "")
a(class="video-overlay-link", href=tweetUrl):
text "Watch on " & cfg.hostname
script:
verbatim embedResizeJs
result = doctype & $node
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req)
base(target="_blank")
body:
tdiv(class="embed-wrapper"):
tdiv(class="tweet-embed"):
a(class="tweet-link", href=getLink(tweet))
renderTweet(tweet, prefs, path, mainTweet=true)
a(class="embed-footer", href=getLink(tweet)):
text "Read more on " & cfg.hostname
script:
verbatim embedResizeJs
result = doctype & $node
proc renderErrorEmbed*(error: string; prefs: Prefs; cfg: Config; req: Request;
tweetId = ""; username = ""): string =
let link = if tweetId.len > 0:
if username.len > 0: "/" & username & "/status/" & tweetId
else: "/i/status/" & tweetId
else: "/"
let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req)
base(target="_blank")
body:
tdiv(class="embed-wrapper"):
tdiv(class="tweet-embed error-embed"):
a(class="tweet-link", href=link)
tdiv(class="error-panel"):
span: text error
a(class="embed-footer", href=link):
text "Read more on " & cfg.hostname
script:
verbatim embedResizeJs
result = doctype & $node result = doctype & $node

View file

@ -29,19 +29,17 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
tdiv(class="nav-item right"): tdiv(class="nav-item right"):
icon "search", title="Search", href="/search" 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 "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 a(href="https://liberapay.com/zedeus"): verbatim lp
icon "info", title="About", href="/about" icon "info", title="About", href="/about"
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path)) icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
video=""; images: seq[string] = @[]; banner=""; ogTitle=""; video=""; images: seq[string] = @[]; banner=""; ogTitle="";
rss=""; canonical=""): VNode = rss=""; alternate=""; oembed=""): VNode =
var theme = prefs.theme.toTheme let theme = prefs.theme.toTheme
if "theme" in req.params:
theme = req.params["theme"].toTheme
let ogType = let ogType =
if video.len > 0: "video" if video.len > 0: "video"
@ -52,8 +50,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
buildHtml(head): buildHtml(head):
link(rel="stylesheet", type="text/css", href="/css/style.css?v=19") link(rel="stylesheet", type="text/css", href="/css/style.css?v=106")
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7")
if theme.len > 0: if theme.len > 0:
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css")) link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
@ -66,15 +64,19 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title, link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
href=opensearchUrl) href=opensearchUrl)
if canonical.len > 0: if alternate.len > 0:
link(rel="canonical", href=canonical) 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") link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
if oembed.len > 0:
let oembedTitle = if titleText.len > 0: titleText else: "oEmbed"
link(rel="alternate", type="application/json+oembed", href=oembed, title=oembedTitle)
if prefs.hlsPlayback: if prefs.hlsPlayback:
script(src="/js/hls.min.js", `defer`="") script(src="/js/hls.min.js", `defer`="")
script(src="/js/hlsPlayback.js", `defer`="") script(src="/js/hlsPlayback.js?v=1", `defer`="")
if prefs.infiniteScroll: if prefs.infiniteScroll:
script(src="/js/infiniteScroll.js", `defer`="") script(src="/js/infiniteScroll.js", `defer`="")
@ -86,6 +88,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
text cfg.title text cfg.title
meta(name="viewport", content="width=device-width, initial-scale=1.0") meta(name="viewport", content="width=device-width, initial-scale=1.0")
meta(name="referrer", content="same-origin")
meta(name="theme-color", content="#1F1F1F") meta(name="theme-color", content="#1F1F1F")
meta(property="og:type", content=ogType) meta(property="og:type", content=ogType)
meta(property="og:title", content=(if ogTitle.len > 0: ogTitle else: titleText)) meta(property="og:title", content=(if ogTitle.len > 0: ogTitle else: titleText))
@ -98,6 +101,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
link(rel="preload", type="image/png", href=bannerUrl, `as`="image") link(rel="preload", type="image/png", href=bannerUrl, `as`="image")
for url in images: for url in images:
if url.len == 0: continue
let preloadUrl = if "400x400" in url: getPicUrl(url) let preloadUrl = if "400x400" in url: getPicUrl(url)
else: getSmallPic(url) else: getSmallPic(url)
link(rel="preload", type="image/png", href=preloadUrl, `as`="image") link(rel="preload", type="image/png", href=preloadUrl, `as`="image")
@ -119,20 +123,24 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
# this is last so images are also preloaded # this is last so images are also preloaded
# if this is done earlier, Chrome only preloads one image for some reason # if this is done earlier, Chrome only preloads one image for some reason
link(rel="preload", type="font/woff2", `as`="font", link(rel="preload", type="font/woff2", `as`="font",
href="/fonts/fontello.woff2?61663884", crossorigin="anonymous") href="/fonts/fontello.woff2?59696369", crossorigin="anonymous")
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
titleText=""; desc=""; ogTitle=""; rss=""; video=""; titleText=""; desc=""; ogTitle=""; rss=""; video="";
images: seq[string] = @[]; banner=""): string = images: seq[string] = @[]; banner="";
twitterLink=""; oembed=""): string =
let canonical = getTwitterLink(req.path, req.params) let twitterLink =
if twitterLink.len > 0: twitterLink
else: getTwitterLink(req.path, req.params)
let node = buildHtml(html(lang="en")): let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
rss, canonical) rss, twitterLink, oembed)
body: let bodyClass = if prefs.stickyNav: "fixed-nav" else: ""
renderNavbar(cfg, req, rss, canonical) body(class=bodyClass):
renderNavbar(cfg, req, rss, twitterLink)
tdiv(class="container"): tdiv(class="container"):
body body

7
src/views/oembed.nimf Normal file
View file

@ -0,0 +1,7 @@
#? stdtmpl(subsChar = '$', metaChar = '#')
## SPDX-License-Identifier: AGPL-3.0-only
#proc renderOembedIframe*(embedUrl: string; maxwidth = 550): string =
# result = ""
<iframe src="$embedUrl" style="width:100%;max-width:${maxwidth}px;height:250px;border:none" scrolling="no" loading="lazy" onload="let c=new MessageChannel;c.port1.onmessage=e=>this.style.height=e.data+'px';this.contentWindow.postMessage('','*',[c.port2])"></iframe>
# result = result.strip()
#end proc

View file

@ -32,7 +32,8 @@ macro renderPrefs*(): untyped =
result[2].add stmt 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")): buildHtml(tdiv(class="overlay-panel")):
fieldset(class="preferences"): fieldset(class="preferences"):
form(`method`="post", action="/saveprefs", autocomplete="off"): form(`method`="post", action="/saveprefs", autocomplete="off"):
@ -40,6 +41,14 @@ proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode
renderPrefs() 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"): h4(class="note"):
text "Preferences are stored client-side using cookies without any personal information." text "Preferences are stored client-side using cookies without any personal information."

View file

@ -2,7 +2,7 @@
import strutils, strformat import strutils, strformat
import karax/[karaxdsl, vdom, vstyles] import karax/[karaxdsl, vdom, vstyles]
import renderutils, search import renderutils, search, timeline
import ".."/[types, utils, formatters] import ".."/[types, utils, formatters]
proc renderStat(num: int; class: string; text=""): VNode = proc renderStat(num: int; class: string; text=""): VNode =
@ -12,7 +12,14 @@ proc renderStat(num: int; class: string; text=""): VNode =
span(class="profile-stat-num"): span(class="profile-stat-num"):
text insertSep($num, ',') text insertSep($num, ',')
proc renderUserCard*(user: User; prefs: Prefs): VNode = proc renderStatLink(num: int; class, href: string): VNode =
buildHtml(li(class=class)):
a(href=href):
span(class="profile-stat-header"): text capitalizeAscii(class)
span(class="profile-stat-num"):
text insertSep($num, ',')
proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
buildHtml(tdiv(class="profile-card")): buildHtml(tdiv(class="profile-card")):
tdiv(class="profile-card-info"): tdiv(class="profile-card-info"):
let let
@ -26,6 +33,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
tdiv(class="profile-card-tabs-name"): tdiv(class="profile-card-tabs-name"):
linkUser(user, class="profile-card-fullname") linkUser(user, class="profile-card-fullname")
verifiedIcon(user)
linkUser(user, class="profile-card-username") linkUser(user, class="profile-card-username")
tdiv(class="profile-card-extra"): tdiv(class="profile-card-extra"):
@ -45,6 +53,11 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
else: else:
span: text place span: text place
if info.basedIn.len > 0:
tdiv(class="profile-location"):
span: icon "location"
span: text "Based in " & info.basedIn
if user.website.len > 0: if user.website.len > 0:
tdiv(class="profile-website"): tdiv(class="profile-website"):
span: span:
@ -53,14 +66,14 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
a(href=url): text url.shortLink a(href=url): text url.shortLink
tdiv(class="profile-joindate"): tdiv(class="profile-joindate"):
span(title=getJoinDateFull(user)): a(href=(&"/{user.username}/about"), title=getJoinDateFull(user)):
icon "calendar", getJoinDate(user) icon "calendar", getJoinDate(user)
tdiv(class="profile-card-extra-links"): tdiv(class="profile-card-extra-links"):
ul(class="profile-statlist"): ul(class="profile-statlist"):
renderStat(user.tweets, "posts", text="Tweets") renderStat(user.tweets, "posts", text="Tweets")
renderStat(user.following, "following") renderStatLink(user.following, "following", &"/{user.username}/following")
renderStat(user.followers, "followers") renderStatLink(user.followers, "followers", &"/{user.username}/followers")
renderStat(user.likes, "likes") renderStat(user.likes, "likes")
proc renderPhotoRail(profile: Profile): VNode = proc renderPhotoRail(profile: Profile): VNode =
@ -93,7 +106,7 @@ proc renderBanner(banner: string): VNode =
else: else:
a(href=getPicUrl(banner), target="_blank"): genImg(banner) a(href=getPicUrl(banner), target="_blank"): genImg(banner)
proc renderProtected(username: string): VNode = proc renderProtected*(username: string): VNode =
buildHtml(tdiv(class="timeline-container")): buildHtml(tdiv(class="timeline-container")):
tdiv(class="timeline-header timeline-protected"): tdiv(class="timeline-header timeline-protected"):
h2: text "This account's tweets are protected." h2: text "This account's tweets are protected."
@ -101,19 +114,52 @@ proc renderProtected(username: string): VNode =
proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode = proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
profile.tweets.query.fromUser = @[profile.user.username] profile.tweets.query.fromUser = @[profile.user.username]
let
isGalleryView = profile.tweets.query.kind == QueryKind.media and
profile.tweets.query.view == "gallery"
viewClass = if isGalleryView: " media-only" else: ""
buildHtml(tdiv(class="profile-tabs")): buildHtml(tdiv(class=("profile-tabs" & viewClass))):
if not prefs.hideBanner: if not isGalleryView and not prefs.hideBanner:
tdiv(class="profile-banner"): tdiv(class="profile-banner"):
renderBanner(profile.user.banner) renderBanner(profile.user.banner)
let sticky = if prefs.stickyProfile: " sticky" else: "" if not isGalleryView:
tdiv(class=("profile-tab" & sticky)): let sticky = if prefs.stickyProfile: " sticky" else: ""
renderUserCard(profile.user, prefs) tdiv(class=("profile-tab" & sticky)):
if profile.photoRail.len > 0: renderUserCard(profile.user, prefs, profile.accountInfo)
renderPhotoRail(profile) if profile.photoRail.len > 0:
renderPhotoRail(profile)
if profile.user.protected: if profile.user.protected:
renderProtected(profile.user.username) renderProtected(profile.user.username)
else: else:
renderTweetSearch(profile.tweets, prefs, path, profile.pinned) renderTweetSearch(profile.tweets, prefs, path, profile.pinned)
proc renderFollowTabs(user: User; activeTab: string): VNode =
buildHtml(ul(class="tab")):
for tab in ["Following", "Followers"]:
li(class=(if activeTab == tab: "tab-item active" else: "tab-item")):
a(href=(&"/{user.username}/{tab.toLowerAscii()}")): text tab
proc renderUserList*(user: User; results: Result[User]; prefs: Prefs;
path, activeTab: string): VNode =
# Check if we've loaded all results (for page 1)
var displayResults = results
if results.beginning:
let expectedCount = if activeTab == "Followers": user.followers else: user.following
if results.content.len >= expectedCount:
displayResults.bottom = ""
buildHtml(tdiv(class="profile-tabs")):
if not prefs.hideBanner:
tdiv(class="profile-banner"):
renderBanner(user.banner)
let sticky = if prefs.stickyProfile: " sticky" else: ""
tdiv(class=("profile-tab" & sticky)):
renderUserCard(user, prefs, AccountInfo())
tdiv(class="timeline-container"):
renderFollowTabs(user, activeTab)
renderTimelineUsers(displayResults, prefs, path)

View file

@ -4,14 +4,23 @@ import karax/[karaxdsl, vdom, vstyles]
import ".."/[types, utils] import ".."/[types, utils]
const smallWebp* = "?name=small&format=webp" const smallWebp* = "?name=small&format=webp"
const mediumWebp* = "?name=medium&format=webp"
proc getSmallPic*(url: string): string = proc getSmallPic*(url: string): string =
if url.len == 0: return
result = url result = url
if "?" notin url and not url.endsWith("placeholder.png"): if "?" notin url and not url.endsWith("placeholder.png"):
result &= smallWebp result &= smallWebp
result = getPicUrl(result) result = getPicUrl(result)
proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode = proc getMediumPic*(url: string): string =
if url.len == 0: return
result = url
if "?" notin url and not url.endsWith("placeholder.png"):
result &= mediumWebp
result = getPicUrl(result)
proc icon*(icon: string; label=""; title=""; class=""; href=""): VNode =
var c = "icon-" & icon var c = "icon-" & icon
if class.len > 0: c = &"{c} {class}" if class.len > 0: c = &"{c} {class}"
buildHtml(tdiv(class="icon-container")): buildHtml(tdiv(class="icon-container")):
@ -20,13 +29,15 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
else: else:
span(class=c, title=title) span(class=c, title=title)
if text.len > 0: if label.len > 0:
text " " & text text " " & label
template verifiedIcon*(user: User): untyped {.dirty.} = template verifiedIcon*(user: User): untyped {.dirty.} =
if user.verifiedType != VerifiedType.none: if user.verifiedType != VerifiedType.none:
let lower = ($user.verifiedType).toLowerAscii() 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: else:
text "" text ""
@ -40,7 +51,6 @@ proc linkUser*(user: User, class=""): VNode =
buildHtml(a(href=href, class=class, title=nameText)): buildHtml(a(href=href, class=class, title=nameText)):
text nameText text nameText
if isName: if isName:
verifiedIcon(user)
if user.protected: if user.protected:
text " " text " "
icon "lock", title="Protected account" icon "lock", title="Protected account"
@ -64,20 +74,20 @@ proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNod
text text text text
proc genCheckbox*(pref, label: string; state: bool): VNode = 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 text label
input(name=pref, `type`="checkbox", checked=state) input(name=pref, `type`="checkbox", checked=state)
span(class="checkbox") span(class="checkbox")
proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode = proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode =
let p = placeholder 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: if label.len > 0:
label(`for`=pref): text label label(`for`=pref): text label
input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0)) 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 = 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 label(`for`=pref): text label
select(name=pref): select(name=pref):
for opt in options: for opt in options:
@ -89,9 +99,16 @@ proc genDate*(pref, state: string): VNode =
input(name=pref, `type`="date", value=state) input(name=pref, `type`="date", value=state)
icon "calendar" 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(): 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 = proc getTabClass*(query: Query; tab: QueryKind): string =
if query.kind == tab: "tab-item active" if query.kind == tab: "tab-item active"

View file

@ -1,31 +1,140 @@
#? stdtmpl(subsChar = '$', metaChar = '#') #? stdtmpl(subsChar = '$', metaChar = '#')
## SPDX-License-Identifier: AGPL-3.0-only ## SPDX-License-Identifier: AGPL-3.0-only
#import strutils, xmltree, strformat, options, unicode #import strutils, sequtils, xmltree, strformat, options, unicode
#import ../types, ../utils, ../formatters, ../prefs #import ../types, ../utils, ../formatters, ../prefs
## Snowflake ID cutoff for RSS GUID format transition
## Corresponds to approximately December 14, 2025 UTC
#const guidCutoff = 2000000000000000000'i64
# #
#proc getTitle(tweet: Tweet; retweet: string): string = #proc getTitle(tweet: Tweet; retweet: string): string =
#if tweet.pinned: result = "Pinned: " #var prefix = ""
#elif retweet.len > 0: result = &"RT by @{retweet}: " #if tweet.pinned: prefix = "Pinned: "
#elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: " #elif retweet.len > 0: prefix = &"RT by @{retweet}: "
#elif tweet.reply.len > 0: prefix = &"R to @{tweet.reply[0]}: "
#end if #end if
#var text = stripHtml(tweet.text) #var text = strutils.splitWhitespace(stripHtml(tweet.text)).join(" ")
##if unicode.runeLen(text) > 32: ##if unicode.runeLen(text) > 32:
## text = unicode.runeSubStr(text, 0, 32) & "..." ## text = unicode.runeSubStr(text, 0, 32) & "..."
##end if ##end if
#result &= xmltree.escape(text) #text = xmltree.escape(text)
#if result.len > 0: return ## article tweets' text is just the article link; the title says more
#if tweet.articlePreview.isSome and tweet.articlePreview.get().title.len > 0:
# result = prefix & xmltree.escape(tweet.articlePreview.get().title)
# return
#end if #end if
#if tweet.photos.len > 0: #if text.len > 0:
# result &= "Image" # result = prefix & text
#elif tweet.video.isSome: # return
# result &= "Video" #end if
#elif tweet.gif.isSome: #if tweet.media.len > 0:
# result &= "Gif" # 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
#if result.len == 0 and tweet.card.isSome:
# let card = tweet.card.get()
# if card.kind notin {hidden, unknown} and card.title.len > 0:
# result = prefix & xmltree.escape(card.title)
# end if
#end if #end if
#end proc #end proc
# #
#proc getDescription(desc: string; cfg: Config): string = #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 renderRssCard(card: Card; prefs: Prefs; urlPrefix: string): string =
#let cardLink = if card.url.startsWith("/"): urlPrefix & card.url else: replaceUrls(card.url, prefs)
#let title = xmltree.escape(card.title)
<hr/>
<b>Link</b><br>
#if cardLink.len > 0:
<a href="${xmltree.escape(cardLink)}">
#end if
#if card.image.len > 0:
<img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" />
#end if
#if title.len > 0:
# if card.image.len > 0:
<br>
# end if
<b>${title}</b>
#end if
#if cardLink.len > 0:
</a>
#end if
#if card.text.len > 0:
<p>${xmltree.escape(card.text)}</p>
#end if
#if cardLink.len > 0:
# let destText = if card.dest.len > 0: xmltree.escape(card.dest) else: xmltree.escape(cardLink)
<small><a href="${xmltree.escape(cardLink)}">${destText}</a></small>
#elif card.dest.len > 0:
<small>${xmltree.escape(card.dest)}</small>
#end if
#end proc
#
#proc renderRssArticle(article: ArticlePreview; urlPrefix: string): string =
#let link = urlPrefix & "/i/article/" & $article.tweetId
<hr/>
<b>Article</b><br>
<a href="${link}">
#if article.coverImage.len > 0:
<img src="${urlPrefix}${getPicUrl(article.coverImage)}" style="max-width:250px;" />
#end if
#if article.title.len > 0:
# if article.coverImage.len > 0:
<br>
# end if
<b>${xmltree.escape(article.title)}</b>
#end if
</a>
#if article.previewText.len > 0:
<p>${xmltree.escape(article.previewText)}</p>
#end if
#end proc
#
#proc renderRssPoll(poll: Poll): string =
<hr/>
<b>Poll</b>
<p>
#for i in 0 ..< poll.options.len:
# let perc = if poll.votes > 0: poll.values[i] / poll.votes * 100 else: 0.0
# let pct = (&"{perc:.0f}").strip(chars={'.'})
# let line = pct & "% — " & xmltree.escape(poll.options[i])
${line}<br>
#end for
#let votesStr = insertSep($poll.votes, ',')
<i>${votesStr} votes • ${xmltree.escape(poll.status)}</i>
</p>
#end proc #end proc
# #
#proc getTweetsWithPinned(profile: Profile): seq[Tweets] = #proc getTweetsWithPinned(profile: Profile): seq[Tweets] =
@ -46,35 +155,46 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
#end if #end if
#end proc #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 tweet = tweet.retweet.get(tweet)
#let urlPrefix = getUrlPrefix(cfg) #let urlPrefix = getUrlPrefix(cfg)
#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix) #let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix)
#if text.len > 0:
<p>${text.replace("\n", "<br>\n")}</p> <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 #end if
#if tweet.photos.len > 0: #if tweet.media.len > 0:
# for photo in tweet.photos: # for media in tweet.media:
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" /> ${renderRssMedia(media, tweet, urlPrefix)}
# end for # end for
#elif tweet.video.isSome: #elif tweet.card.isSome and tweet.card.get().kind notin {hidden, unknown}:
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" /> ${renderRssCard(tweet.card.get(), prefs, urlPrefix)}
#elif tweet.gif.isSome: #end if
# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}" #if tweet.articlePreview.isSome:
# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}" ${renderRssArticle(tweet.articlePreview.get(), urlPrefix)}
<video poster="${thumb}" autoplay muted loop style="max-width:250px;"> #end if
<source src="${url}" type="video/mp4"></video> #if tweet.poll.isSome:
#elif tweet.card.isSome: ${renderRssPoll(tweet.poll.get())}
# let card = tweet.card.get() #end if
# if card.image.len > 0: #if tweet.note.len > 0 and not prefs.hideCommunityNotes:
<img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" /> <p><b>Community note:</b> ${replaceUrls(tweet.note, prefs, absolute=urlPrefix)}</p>
# end if #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 if
#end proc #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) #let urlPrefix = getUrlPrefix(cfg)
#var links: seq[string] #var links: seq[string]
#for thread in tweets: #for thread in tweets:
@ -88,19 +208,24 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
# if link in links: continue # if link in links: continue
# end if # end if
# links.add link # links.add link
# let useGlobalGuid = tweet.id >= guidCutoff
<item> <item>
<title>${getTitle(tweet, retweet)}</title> <title>${getTitle(tweet, retweet)}</title>
<dc:creator>@${tweet.user.username}</dc:creator> <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> <pubDate>${getRfc822Time(tweet)}</pubDate>
#if useGlobalGuid:
<guid isPermaLink="false">${tweet.id}</guid>
#else:
<guid>${urlPrefix & link}</guid> <guid>${urlPrefix & link}</guid>
#end if
<link>${urlPrefix & link}</link> <link>${urlPrefix & link}</link>
</item> </item>
# end for # end for
#end for #end for
#end proc #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) #let urlPrefix = getUrlPrefix(cfg)
#result = "" #result = ""
#let handle = (if multi: "" else: "@") & profile.user.username #let handle = (if multi: "" else: "@") & profile.user.username
@ -126,13 +251,13 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
</image> </image>
#let tweetsList = getTweetsWithPinned(profile) #let tweetsList = getTweetsWithPinned(profile)
#if tweetsList.len > 0: #if tweetsList.len > 0:
${renderRssTweets(tweetsList, cfg, userId=profile.user.id)} ${renderRssTweets(tweetsList, cfg, prefs, userId=profile.user.id)}
#end if #end if
</channel> </channel>
</rss> </rss>
#end proc #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}" #let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}"
#result = "" #result = ""
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
@ -144,12 +269,12 @@ ${renderRssTweets(tweetsList, cfg, userId=profile.user.id)}
<description>${getDescription(&"{list.name} by @{list.username}", cfg)}</description> <description>${getDescription(&"{list.name} by @{list.username}", cfg)}</description>
<language>en-us</language> <language>en-us</language>
<ttl>40</ttl> <ttl>40</ttl>
${renderRssTweets(tweets, cfg)} ${renderRssTweets(tweets, cfg, prefs)}
</channel> </channel>
</rss> </rss>
#end proc #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 link = &"{getUrlPrefix(cfg)}/search"
#let escName = xmltree.escape(name) #let escName = xmltree.escape(name)
#result = "" #result = ""
@ -162,7 +287,7 @@ ${renderRssTweets(tweets, cfg)}
<description>${getDescription(&"Search \"{escName}\"", cfg)}</description> <description>${getDescription(&"Search \"{escName}\"", cfg)}</description>
<language>en-us</language> <language>en-us</language>
<ttl>40</ttl> <ttl>40</ttl>
${renderRssTweets(tweets, cfg)} ${renderRssTweets(tweets, cfg, prefs)}
</channel> </channel>
</rss> </rss>
#end proc #end proc

View file

@ -10,14 +10,12 @@ const toggles = {
"media": "Media", "media": "Media",
"videos": "Videos", "videos": "Videos",
"news": "News", "news": "News",
"verified": "Verified",
"native_video": "Native videos", "native_video": "Native videos",
"replies": "Replies", "replies": "Replies",
"links": "Links", "links": "Links",
"images": "Images", "images": "Images",
"safe": "Safe",
"quote": "Quotes", "quote": "Quotes",
"pro_video": "Pro videos" "spaces": "Spaces"
}.toOrderedTable }.toOrderedTable
proc renderSearch*(): VNode = proc renderSearch*(): VNode =
@ -38,29 +36,62 @@ proc renderProfileTabs*(query: Query; username: string): VNode =
a(href=(link & "/with_replies")): text "Tweets & Replies" a(href=(link & "/with_replies")): text "Tweets & Replies"
li(class=query.getTabClass(media)): li(class=query.getTabClass(media)):
a(href=(link & "/media")): text "Media" a(href=(link & "/media")): text "Media"
if query.fromUser.len == 1:
li(class=query.getTabClass(QueryKind.articles)):
a(href=(link & "/articles")): text "Articles"
li(class=query.getTabClass(tweets)): li(class=query.getTabClass(tweets)):
a(href=(link & "/search")): text "Search" a(href=(link & "/search")): text "Search"
proc mediaViewUrl(query: Query; view: string): string =
var q = query
q.view = view
"?" & genQueryUrl(q)
proc renderMediaViewTabs*(query: Query): VNode =
let currentView = if query.view.len > 0: query.view else: "timeline"
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=query.mediaViewUrl("timeline")): text "Timeline"
li(class=cls("grid")):
a(href=query.mediaViewUrl("grid")): text "Grid"
li(class=cls("gallery")):
a(href=query.mediaViewUrl("gallery")): text "Gallery"
proc renderSearchTabs*(query: Query): VNode = proc renderSearchTabs*(query: Query): VNode =
var q = query var q = query
# the media view mode only applies to the Media tab
q.view = ""
buildHtml(ul(class="tab")): buildHtml(ul(class="tab")):
li(class=query.getTabClass(top)):
q.kind = top
a(href=("?" & genQueryUrl(q))): text "Top"
li(class=query.getTabClass(tweets)): li(class=query.getTabClass(tweets)):
q.kind = tweets q.kind = tweets
a(href=("?" & genQueryUrl(q))): text "Tweets" a(href=("?" & genQueryUrl(q))): text "Latest"
li(class=query.getTabClass(media)):
q.kind = media
q.view = query.view
a(href=("?" & genQueryUrl(q))): text "Media"
li(class=query.getTabClass(users)): li(class=query.getTabClass(users)):
q.kind = users q.kind = users
q.view = ""
a(href=("?" & genQueryUrl(q))): text "Users" a(href=("?" & genQueryUrl(q))): text "Users"
li(class=query.getTabClass(lists)):
q.kind = lists
a(href=("?" & genQueryUrl(q))): text "Lists"
proc isPanelOpen(q: Query): bool = proc isPanelOpen(q: Query): bool =
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or 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 = proc renderSearchPanel*(query: Query): VNode =
let user = query.fromUser.join(",") let user = query.fromUser.join(",")
let action = if user.len > 0: &"/{user}/search" else: "/search" let action = if user.len > 0: &"/{user}/search" else: "/search"
buildHtml(form(`method`="get", action=action, buildHtml(form(`method`="get", action=action,
class="search-field", autocomplete="off")): class="search-field", autocomplete="off")):
hiddenField("f", "tweets") hiddenField("f", $query.kind)
genInput("q", "", query.text, "Enter search...", class="pref-inline") genInput("q", "", query.text, "Enter search...", class="pref-inline")
button(`type`="submit"): icon "search" button(`type`="submit"): icon "search"
@ -85,36 +116,58 @@ proc renderSearchPanel*(query: Query): VNode =
span(class="search-title"): text "-" span(class="search-title"): text "-"
genDate("until", query.until) genDate("until", query.until)
tdiv: tdiv:
span(class="search-title"): text "Near" span(class="search-title"): text "Minimum likes"
genInput("near", "", query.near, "Location...", autofocus=false) genNumberInput("min_faves", "", query.minLikes, "Number...", autofocus=false)
proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
pinned=none(Tweet)): VNode = pinned=none(Tweet)): VNode =
let query = results.query let query = results.query
buildHtml(tdiv(class="timeline-container")): let containerClass =
if query.fromUser.len == 0 and query.kind == QueryKind.media and
query.view == "gallery": "timeline-container media-only"
else: "timeline-container"
buildHtml(tdiv(class=containerClass)):
if query.fromUser.len > 1: if query.fromUser.len > 1:
tdiv(class="timeline-header"): tdiv(class="timeline-header"):
text query.fromUser.join(" | ") text query.fromUser.join(" | ")
if query.fromUser.len > 0: if query.fromUser.len > 0:
renderProfileTabs(query, query.fromUser.join(",")) if query.kind != QueryKind.media or query.view != "gallery":
renderProfileTabs(query, query.fromUser.join(","))
if query.kind == QueryKind.media and query.fromUser.len == 1:
renderMediaViewTabs(query)
if query.fromUser.len == 0 or query.kind == tweets: if query.fromUser.len == 0 or query.kind == QueryKind.tweets:
tdiv(class="timeline-header"): tdiv(class="timeline-header"):
renderSearchPanel(query) renderSearchPanel(query)
if query.fromUser.len == 0: if query.fromUser.len == 0:
renderSearchTabs(query) renderSearchTabs(query)
if query.kind == QueryKind.media:
renderMediaViewTabs(query)
renderTimelineTweets(results, prefs, path, pinned) renderTimelineTweets(results, prefs, path, pinned)
proc renderSearchForm(kind, placeholder, value: string): VNode =
buildHtml(form(`method`="get", action="/search",
class="search-field", autocomplete="off")):
hiddenField("f", kind)
genInput("q", "", value, placeholder, class="pref-inline")
button(`type`="submit"): icon "search"
proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode = proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline-container")): buildHtml(tdiv(class="timeline-container")):
tdiv(class="timeline-header"): tdiv(class="timeline-header"):
form(`method`="get", action="/search", class="search-field", autocomplete="off"): renderSearchForm("users", "Enter username...", results.query.text)
hiddenField("f", "users")
genInput("q", "", results.query.text, "Enter username...", class="pref-inline")
button(`type`="submit"): icon "search"
renderSearchTabs(results.query) renderSearchTabs(results.query)
renderTimelineUsers(results, prefs) renderTimelineUsers(results, prefs)
proc renderListSearch*(results: Result[ListSearchResult]; prefs: Prefs;
path: string): VNode =
buildHtml(tdiv(class="timeline-container")):
tdiv(class="timeline-header"):
renderSearchForm("lists", "Enter search...", results.query.text)
renderSearchTabs(results.query)
renderTimelineLists(results, prefs, path)

86
src/views/space.nim Normal file
View file

@ -0,0 +1,86 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, times
import karax/[karaxdsl, vdom]
import renderutils
import ".."/[types, utils, formatters]
proc renderParticipant(p: SpaceParticipant; role: string): VNode =
buildHtml(tdiv(class="space-participant")):
a(href=("/" & p.username)):
genImg(p.avatarUrl.replace("_normal", "_bigger"))
tdiv(class="participant-info"):
tdiv(class="participant-name"):
strong: text p.displayName
if p.isVerified:
tdiv(class="verified-icon blue"):
icon "circle", class="verified-icon-circle", title="Verified account"
icon "ok", class="verified-icon-check", title="Verified account"
if role.len > 0:
span(class="host-badge"): text role
span(class="participant-username"): text "@" & p.username
proc renderSpace*(sp: AudioSpace; prefs: Prefs; path: string): VNode =
let
isLive = sp.state == "RUNNING"
source = if prefs.proxyVideos and sp.m3u8Url.startsWith("http"):
getVidUrl(sp.m3u8Url) else: sp.m3u8Url
stateText =
if isLive: "LIVE"
elif sp.endTime.year > 1: "Ended " & sp.endTime.format("MMM d, YYYY")
elif sp.state.len > 0: sp.state
else: "Ended"
durationMs =
if sp.startTime.year > 1 and sp.endTime.year > 1:
int((sp.endTime - sp.startTime).inMilliseconds)
else: 0
duration = if durationMs > 0: getDuration(durationMs) else: ""
totalListeners =
if sp.totalReplayWatched > 0: sp.totalReplayWatched
else: sp.totalLiveListeners
buildHtml(tdiv(class="space-page")):
tdiv(class="space-panel"):
tdiv(class="space-player"):
if sp.m3u8Url.len > 0 and prefs.hlsPlayback:
audio(data-url=source, data-autoload="false")
verbatim "<div class=\"video-overlay\" onclick=\"playAudio(this)\">"
tdiv(class="overlay-circle"): span(class="overlay-triangle")
if isLive:
tdiv(class="space-live"): text "LIVE"
elif duration.len > 0:
tdiv(class="overlay-duration"): text duration
verbatim "</div>"
elif sp.m3u8Url.len > 0:
tdiv(class="video-overlay"):
buttonReferer "/enablehls", "Enable hls playback", path
if isLive:
tdiv(class="space-live"): text "LIVE"
elif duration.len > 0:
tdiv(class="overlay-duration"): text duration
elif sp.availableForReplay:
tdiv(class="video-overlay"):
p: text "Audio stream unavailable"
else:
tdiv(class="video-overlay"):
p: text "Replay is not available"
tdiv(class="space-info"):
tdiv(class="space-header"):
h2(class="space-title"): text sp.title
tdiv(class="space-meta"):
if totalListeners > 0:
span(class="listener-count"): text insertSep($totalListeners, ',') & " listeners"
if isLive:
span(class="space-live"): text stateText
else:
span(class="space-state"): text stateText
if sp.admins.len > 0 or sp.speakers.len > 0:
tdiv(class="space-participants"):
for admin in sp.admins:
let role = if admin.username == sp.creator.username: "Host"
else: "Co-host"
renderParticipant(admin, role)
for speaker in sp.speakers:
renderParticipant(speaker, "")

View file

@ -1,4 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import sequtils
import karax/[karaxdsl, vdom] import karax/[karaxdsl, vdom]
import ".."/[types, formatters] import ".."/[types, formatters]
@ -28,16 +29,46 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode =
if thread.hasMore: if thread.hasMore:
renderMoreReplies(thread) renderMoreReplies(thread)
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode = proc renderReplySort(sort: RankingMode): VNode =
buildHtml(tdiv(class="reply-sort")):
span(class="reply-sort-label"): text "Sort replies:"
for mode in RankingMode:
let
cls = if mode == sort: "reply-sort-option active"
else: "reply-sort-option"
label = case mode
of Relevance: "Relevant"
of Recency: "Recent"
of Likes: "Liked"
a(class=cls, href=("?sort=" & $mode & "#r")):
text label
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string;
tweet: Tweet = nil; sort = Relevance): VNode =
buildHtml(tdiv(class="replies", id="r")): buildHtml(tdiv(class="replies", id="r")):
var hasReplies = false
var replyCount = 0
for thread in replies.content: for thread in replies.content:
if thread.content.len == 0: continue if thread.content.len == 0 or thread.related: continue
hasReplies = true
replyCount += thread.content.len
renderReplyThread(thread, prefs, path) renderReplyThread(thread, prefs, path)
if replies.bottom.len > 0: if hasReplies and replies.bottom.len > 0:
renderMore(Query(), replies.bottom, focus="#r") if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies:
let extra = if sort == Relevance: "" else: "sort=" & $sort & "&"
renderMore(Query(), replies.bottom, focus="#r", extra=extra)
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode = proc renderRelated(replies: Result[Chain]; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="related-tweets")):
tdiv(class="related-header"):
text "Related tweets"
for thread in replies.content:
if thread.content.len == 0 or not thread.related: continue
renderReplyThread(thread, prefs, path)
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string;
sort = Relevance): VNode =
let hasAfter = conv.after.content.len > 0 let hasAfter = conv.after.content.len > 0
let threadId = conv.tweet.threadId let threadId = conv.tweet.threadId
buildHtml(tdiv(class="conversation")): buildHtml(tdiv(class="conversation")):
@ -70,6 +101,25 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
if not conv.replies.beginning: if not conv.replies.beginning:
renderNewer(Query(), getLink(conv.tweet), focus="#r") renderNewer(Query(), getLink(conv.tweet), focus="#r")
if conv.replies.content.len > 0 or conv.replies.bottom.len > 0: if conv.replies.content.len > 0 or conv.replies.bottom.len > 0:
renderReplies(conv.replies, prefs, path) renderReplySort(sort)
renderReplies(conv.replies, prefs, path, conv.tweet, sort)
if not prefs.hideRelated:
if conv.replies.content.anyIt(it.related and it.content.len > 0):
renderRelated(conv.replies, prefs, path)
renderToTop(focus="#m") 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 ".."/[types, query, formatters]
import tweet, renderutils import tweet, renderutils
proc timelineViewClass(query: Query): string =
if query.kind != QueryKind.media:
return "timeline"
case query.view
of "grid": "timeline media-grid-view"
of "gallery": "timeline media-gallery-view"
else: "timeline"
proc getQuery(query: Query): string = proc getQuery(query: Query): string =
if query.kind != posts: if query.kind != posts:
result = genQueryUrl(query) result = genQueryUrl(query)
if result.len > 0: if result.len > 0:
result &= "&" result &= "&"
proc getSearchMaxId(results: Timeline; path: string): string =
if results.query.kind != tweets or results.content.len == 0 or
results.query.until.len == 0:
return
let lastThread = results.content[^1]
if lastThread.len == 0 or lastThread[^1].id == 0:
return
# 2000000 is the minimum decrement to guarantee no result overlap
var maxId = lastThread[^1].id - 2_000_000'i64
if maxId <= 0:
maxId = lastThread[^1].id - 1
if maxId > 0:
return "maxid:" & $maxId
proc renderToTop*(focus="#"): VNode = proc renderToTop*(focus="#"): VNode =
buildHtml(tdiv(class="top-ref")): buildHtml(tdiv(class="top-ref")):
icon "down", href=focus icon "down", href=focus
@ -24,9 +50,9 @@ proc renderNewer*(query: Query; path: string; focus=""): VNode =
a(href=(p & url)): a(href=(p & url)):
text "Load newest" text "Load newest"
proc renderMore*(query: Query; cursor: string; focus=""): VNode = proc renderMore*(query: Query; cursor: string; focus=""; extra=""): VNode =
buildHtml(tdiv(class="show-more")): buildHtml(tdiv(class="show-more")):
a(href=(&"?{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")): a(href=(&"?{extra}{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")):
text "Load more" text "Load more"
proc renderNoMore(): VNode = proc renderNoMore(): VNode =
@ -39,7 +65,7 @@ proc renderNoneFound(): VNode =
h2(class="timeline-none"): h2(class="timeline-none"):
text "No items found" text "No items found"
proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode = proc renderThread(thread: Tweets; prefs: Prefs; path: string; bigThumb=false): VNode =
buildHtml(tdiv(class="thread-line")): buildHtml(tdiv(class="thread-line")):
let sortedThread = thread.sortedByIt(it.id) let sortedThread = thread.sortedByIt(it.id)
for i, tweet in sortedThread: for i, tweet in sortedThread:
@ -53,10 +79,10 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
let show = i == thread.high and sortedThread[0].id != tweet.threadId let show = i == thread.high and sortedThread[0].id != tweet.threadId
let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: "" let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: ""
renderTweet(tweet, prefs, path, class=(header & "thread"), renderTweet(tweet, prefs, path, class=(header & "thread"),
index=i, last=(i == thread.high), showThread=show) index=i, last=(i == thread.high), bigThumb=bigThumb)
proc renderUser(user: User; prefs: Prefs): VNode = proc renderUser(user: User; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline-item")): buildHtml(tdiv(class="timeline-item", data-username=user.username)):
a(class="tweet-link", href=("/" & user.username)) a(class="tweet-link", href=("/" & user.username))
tdiv(class="tweet-body profile-result"): tdiv(class="tweet-body profile-result"):
tdiv(class="tweet-header"): tdiv(class="tweet-header"):
@ -66,6 +92,7 @@ proc renderUser(user: User; prefs: Prefs): VNode =
tdiv(class="tweet-name-row"): tdiv(class="tweet-name-row"):
tdiv(class="fullname-and-username"): tdiv(class="fullname-and-username"):
linkUser(user, class="fullname") linkUser(user, class="fullname")
verifiedIcon(user)
linkUser(user, class="username") linkUser(user, class="username")
tdiv(class="tweet-content media-body", dir="auto"): tdiv(class="tweet-content media-body", dir="auto"):
@ -87,15 +114,106 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode =
else: else:
renderNoMore() renderNoMore()
proc mentionUsername(word: string): string =
# "@user" -> "user" for well-formed mentions, "" otherwise
if word.len > 1 and word[0] == '@' and
word[1 .. ^1].allCharsInSet({'A'..'Z', 'a'..'z', '0'..'9', '_'}):
word[1 .. ^1]
else: ""
proc mentionedUser(s: string): string =
# last @mention in strings like "65 followers including @user"
let words = s.split(' ')
for i in countdown(words.high, 0):
result = mentionUsername(words[i])
if result.len > 0: return
proc renderMentionedText(s: string): VNode =
# linkify @mentions in plain API strings like "65 followers including @user"
let words = s.split(' ')
buildHtml(span):
for i in 0 ..< words.len:
if i > 0: text " "
let username = mentionUsername(words[i])
if username.len > 0:
a(href=("/" & username)): text words[i]
else:
text words[i]
proc renderListCard(r: ListSearchResult): VNode =
let listUrl = "/i/lists/" & r.list.id
buildHtml(tdiv(class="timeline-item list-result")):
a(class="tweet-link", href=listUrl)
a(class="list-result-banner", href=listUrl):
if r.list.banner.len > 0:
genImg(r.list.banner)
tdiv(class="list-result-body"):
tdiv(class="list-result-title fullname-and-username"):
a(class="list-name fullname", href=listUrl): text r.list.name
span(class="list-members"):
text &"· {insertSep($r.list.members, ',')} members"
tdiv(class="list-result-context"):
if r.followersContext.len > 0:
# the first facepile belongs to the "including @user" account
let mentioned = mentionedUser(r.followersContext)
for i in 0 ..< r.facepiles.len:
if i == 0 and mentioned.len > 0:
a(class="facepile-link", href=("/" & mentioned)):
genImg(r.facepiles[i], class="list-facepile")
else:
genImg(r.facepiles[i], class="list-facepile")
renderMentionedText(r.followersContext)
else:
if r.owner.username.len > 0:
a(class="facepile-link", href=("/" & r.owner.username)):
genImg(r.owner.getUserPic("_mini"), class="list-facepile")
else:
genImg(r.owner.getUserPic("_mini"), class="list-facepile")
linkUser(r.owner, class="fullname")
linkUser(r.owner, class="username")
if r.list.description.len > 0:
tdiv(class="list-result-description"):
text r.list.description
proc renderTimelineLists*(results: Result[ListSearchResult]; prefs: Prefs;
path=""): VNode =
buildHtml(tdiv(class="timeline")):
if not results.beginning:
renderNewer(results.query, path)
if results.content.len > 0:
for list in results.content:
renderListCard(list)
if results.bottom.len > 0:
renderMore(results.query, results.bottom)
renderToTop()
elif results.beginning:
renderNoneFound()
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; proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
pinned=none(Tweet)): VNode = pinned=none(Tweet)): VNode =
buildHtml(tdiv(class="timeline")): buildHtml(tdiv(class=results.query.timelineViewClass)):
if not results.beginning: if not results.beginning:
renderNewer(results.query, parseUri(path).path) renderNewer(results.query, parseUri(path).path)
if not prefs.hidePins and pinned.isSome: if not prefs.hidePins and pinned.isSome:
let tweet = get pinned let tweet = get pinned
renderTweet(tweet, prefs, path, showThread=tweet.hasThread) renderTweet(tweet, prefs, path)
if results.content.len == 0: if results.content.len == 0:
if not results.beginning: if not results.beginning:
@ -103,26 +221,24 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
else: else:
renderNoneFound() renderNoneFound()
else: else:
var retweets: seq[int64] let filtered = filterThreads(results.content, prefs)
for thread in results.content: if results.query.view == "gallery":
if thread.len == 1: let bigThumb = prefs.gallerySize == "Large"
let let galClass = if prefs.compactGallery: "gallery-masonry compact" else: "gallery-masonry"
tweet = thread[0] tdiv(class=galClass, `data-col-size`=prefs.gallerySize.toLowerAscii):
retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0 for thread in filtered:
if thread.len == 1: renderTweet(thread[0], prefs, path, bigThumb=bigThumb)
else: renderThread(thread, prefs, path, bigThumb)
else:
for thread in filtered:
if thread.len == 1:
renderTweet(thread[0], prefs, path)
else: renderThread(thread, prefs, path)
if retweetId in retweets or tweet.id in retweets or var cursor = getSearchMaxId(results, path)
tweet.pinned and prefs.hidePins: if cursor.len > 0:
continue renderMore(results.query, cursor)
elif results.bottom.len > 0:
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:
renderMore(results.query, results.bottom) renderMore(results.query, results.bottom)
renderToTop() renderToTop()

Some files were not shown because too many files have changed in this diff Show more