diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index c9f0392..20a15a0 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -7,57 +7,105 @@ on: branches: - master +concurrency: + group: docker-publish-${{ github.ref }} + cancel-in-progress: true + +env: + IMAGE: zedeus/nitter + jobs: tests: uses: ./.github/workflows/run-tests.yml secrets: inherit - build-docker-amd64: + + build: 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: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 + - name: Prepare platform name + run: echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV" + env: + platform: ${{ matrix.platform }} + + - uses: actions/checkout@v6 + - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 with: version: latest + - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - 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: context: . file: ./Dockerfile - platforms: linux/amd64 - push: true - tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }} - build-docker-arm64: - needs: [tests] - runs-on: buildjet-2vcpu-ubuntu-2204-arm - steps: - - uses: actions/checkout@v3 + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + provenance: false + sbom: false + + - name: Export digest + run: | + 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: - 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 - id: buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 with: version: latest + - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - name: Build and push ARM64 Docker image - uses: docker/build-push-action@v3 - with: - context: . - file: ./Dockerfile.arm64 - platforms: linux/arm64 - push: true - tags: zedeus/nitter:latest-arm64,zedeus/nitter:${{ github.sha }}-arm64 + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + run: | + docker buildx imagetools create \ + -t ${{ env.IMAGE }}:latest \ + -t ${{ env.IMAGE }}:latest-arm64 \ + -t ${{ env.IMAGE }}:${{ github.sha }} \ + $(printf '${{ env.IMAGE }}@sha256:%s ' *) + + - name: Inspect image + run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ github.sha }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f4639a4..33dcba5 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -20,24 +20,24 @@ defaults: jobs: build-test: name: Build and test - runs-on: buildjet-2vcpu-ubuntu-2204 + runs-on: ubuntu-24.04 strategy: matrix: nim: ["2.0.x", "2.2.x", "devel"] steps: - name: Checkout Code - uses: actions/checkout@v4 - with: - fetch-depth: 0 + uses: actions/checkout@v6 - name: Cache Nimble Dependencies id: cache-nimble - uses: buildjet/cache@v4 + uses: actions/cache@v5 with: - path: ~/.nimble - key: ${{ matrix.nim }}-nimble-v2-${{ hashFiles('*.nimble') }} + path: | + ~/.nimble/pkgcache + ~/.nimble/packages_official.json + key: ${{ matrix.nim }}-nimble-v6-${{ hashFiles('*.nimble') }} restore-keys: | - ${{ matrix.nim }}-nimble-v2- + ${{ matrix.nim }}-nimble-v6- - name: Setup Nim uses: jiro4989/setup-nim-action@v2 @@ -47,62 +47,106 @@ jobs: repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Build Project - run: nimble build -d:release -Y + run: nimble build -Y + + - name: Upload 2.2.x build artifact + if: matrix.nim == '2.2.x' + uses: actions/upload-artifact@v6 + with: + name: nitter-linux-nim-2.2.x-${{ github.sha }} + path: | + ./nitter + if-no-files-found: error integration-test: needs: [build-test] name: Integration test - runs-on: buildjet-2vcpu-ubuntu-2204 + runs-on: ubuntu-24.04 + timeout-minutes: 30 + + services: + redis: + image: redis:7 + ports: + - 6379:6379 + steps: + - name: Install runtime deps + run: | + sudo apt-get install -y --no-install-recommends libsass-dev libpcre3 + - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 + + - name: Cache pipx (poetry) + uses: actions/cache@v5 with: - fetch-depth: 0 + path: | + ~/.local/pipx + ~/.local/bin + key: pipx-poetry-${{ runner.os }} + + - name: Install poetry + env: + PIPX_HOME: ~/.local/pipx + PIPX_BIN_DIR: ~/.local/bin + run: command -v poetry >/dev/null 2>&1 || pipx install poetry + + - name: Setup Python (3.14) with Poetry cache + uses: actions/setup-python@v6 + with: + python-version: "3.14" + cache: poetry + cache-dependency-path: tests/poetry.lock + + - name: Install Python deps + working-directory: tests + run: poetry sync - name: Cache Nimble Dependencies - id: cache-nimble - uses: buildjet/cache@v4 + uses: actions/cache@v5 with: - path: ~/.nimble - key: devel-nimble-v2-${{ hashFiles('*.nimble') }} + path: | + ~/.nimble/pkgcache + ~/.nimble/packages_official.json + key: 2.2.x-nimble-v6-${{ hashFiles('*.nimble') }} restore-keys: | - devel-nimble-v2- - - - name: Setup Python (3.10) with pip cache - uses: buildjet/setup-python@v4 - with: - python-version: "3.10" - cache: pip + 2.2.x-nimble-v6- - name: Setup Nim uses: jiro4989/setup-nim-action@v2 with: - nim-version: devel + nim-version: 2.2.x use-nightlies: true repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Build Project - run: nimble build -d:release -Y + - name: Install Nimble dependencies + run: nimble install -y --depsOnly - - name: Install SeleniumBase and Chromedriver - run: | - pip install seleniumbase - seleniumbase install chromedriver + - name: Download 2.2.x build artifact + uses: actions/download-artifact@v4 + with: + name: nitter-linux-nim-2.2.x-${{ github.sha }} + path: . - - name: Start Redis Service - uses: supercharge/redis-github-action@1.5.0 + - name: Make nitter binary executable + run: chmod +x ./nitter - name: Prepare Nitter Environment run: | - sudo apt-get update && sudo apt-get install -y libsass-dev cp nitter.example.conf nitter.conf sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf - nimble md - nimble scss + sed -i 's/maxRetries = 1/maxRetries = 3/g' nitter.conf + 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 }}' > ./sessions.jsonl - name: Run Tests run: | ./nitter & - pytest -n1 tests + cd tests + poetry run pytest -n2 --rs . diff --git a/.gitignore b/.gitignore index dbd2f6b..2e52163 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,8 @@ nitter.conf guest_accounts.json* sessions.json* dump.rdb +*.bak +/tools/*.json* +nimbledeps/ +nimble.paths +nimble.develop diff --git a/Dockerfile b/Dockerfile index ab442ba..251b63a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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" 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 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.example.conf ./nitter.conf COPY --from=nim /src/nitter/public ./public diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 deleted file mode 100644 index 46352c7..0000000 --- a/Dockerfile.arm64 +++ /dev/null @@ -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 diff --git a/README.md b/README.md index 05c2be4..86ebd47 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,26 @@ # 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] -> Running a Nitter instance now requires real accounts, since Twitter removed the previous methods. \ -> 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). +> 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. A free and open source alternative Twitter front-end focused on privacy and performance. \ Inspired by the [Invidious](https://github.com/iv-org/invidious) project. +## Donations + +**Liberapay**: https://liberapay.com/zedeus
+**Patreon**: https://patreon.com/nitter
+**Ko-fi**: https://ko-fi.com/zedeus
+**BTC**: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55
+**ETH**: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460
+**XMR**: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
+**SOL**: FF5bheiD5AqPEdc3eyjymJ8AoMRF1hS78Ht6FiSZZF1t
+**$Nitter**: 4fSxCKc91ELQYVdv3tmHW8R15KoALPwEngyoQe1Xpump
+**ZEC**: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt + +## Features + - No JavaScript or ads - All requests go through the backend, client never talks to Twitter - 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) - AGPLv3 licensed, no proprietary instances permitted -
-Donations -Liberapay: https://liberapay.com/zedeus
-Patreon: https://patreon.com/nitter
-BTC: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55
-ETH: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460
-XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
-SOL: ANsyGNXFo6osuFwr1YnUqif2RdoYRhc27WdyQNmmETSW
-ZEC: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt -
- ## Roadmap - Embeds @@ -104,9 +101,9 @@ along with the scss and md files. # su nitter $ git clone https://github.com/zedeus/nitter $ cd nitter -$ nimble build -d:danger --mm:refc -$ nimble scss -$ nimble md +$ nimble -l build -d:danger --mm:refc +$ nimble -l scss +$ nimble -l md $ cp nitter.example.conf nitter.conf ``` @@ -123,12 +120,23 @@ performance reasons. 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 before you can run the container. See below for how to also run Redis using 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: ```bash @@ -136,8 +144,6 @@ docker build -t 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: ```bash @@ -151,8 +157,11 @@ Change `redisHost` from `localhost` to `nitter-redis` in `nitter.conf`, then run docker-compose up -d ``` -Note the Docker commands expect a `nitter.conf` file in the directory you run -them. +Note the Docker commands mount `nitter.conf` (and `sessions.jsonl` for +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 @@ -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). You can email me at zedeus@pm.me if you wish to contact me personally. + +For legal inquiries, contact legal@poast.org diff --git a/docker-compose.yml b/compose.yml similarity index 98% rename from docker-compose.yml rename to compose.yml index 3d75751..72d5a96 100644 --- a/docker-compose.yml +++ b/compose.yml @@ -1,5 +1,3 @@ -version: "3" - services: nitter: diff --git a/config.nims b/config.nims index 4a7af27..3ee4842 100644 --- a/config.nims +++ b/config.nims @@ -11,3 +11,7 @@ warning("HoleEnumConv", off) hint("XDeclaredButNotUsed", off) hint("XCannotRaiseY", off) hint("User", off) +# begin Nimble config (version 2) +when withDir(thisDir(), system.fileExists("nimble.paths")): + include "nimble.paths" +# end Nimble config diff --git a/nitter.example.conf b/nitter.example.conf index bddb9a4..4e040f8 100644 --- a/nitter.example.conf +++ b/nitter.example.conf @@ -1,31 +1,42 @@ [Server] -hostname = "nitter.net" # for generating links, change this to your own domain/ip +hostname = "nitter.net" # for generating links, change this to your own domain/ip title = "nitter" address = "0.0.0.0" port = 8080 -https = false # disable to enable cookies when not using https +https = false # disable to enable cookies when not using https httpMaxConnections = 100 staticDir = "./public" [Cache] -listMinutes = 240 # how long to cache list info (not the tweets, so keep it high) -rssMinutes = 10 # how long to cache rss queries -redisHost = "localhost" # Change to "nitter-redis" if using docker-compose +listMinutes = 240 # how long to cache list info (not the tweets, so keep it high) +rssMinutes = 10 # how long to cache rss queries +redisHost = "localhost" # Change to "nitter-redis" if using docker-compose redisPort = 6379 redisPassword = "" -redisConnections = 20 # minimum open connections in pool +redisConnections = 20 # minimum open connections in pool redisMaxConnections = 30 # new connections are opened when none are available, but if the pool size # goes above this, they're closed when released. don't worry about this unless # you receive tons of requests per second [Config] -hmacKey = "secretkey" # random key for cryptographic signing of video urls -base64Media = false # use base64 encoding for proxied media urls -enableRSS = true # set this to false to disable RSS feeds -enableDebug = false # enable request logs and debug endpoints (/.sessions) -proxy = "" # http/https url, SOCKS proxies are not supported +hmacKey = "secretkey" # 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 +enableRSS = true # master switch, set to false to disable all RSS feeds +enableRSSUserTweets = true # /@user/rss +enableRSSUserReplies = true # /@user/with_replies/rss +enableRSSUserMedia = true # /@user/media/rss +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 = "" +apiProxy = "" # nitter-proxy host, e.g. localhost:7000 +disableTid = false # enable this if cookie-based auth is failing +maxConcurrentReqs = 2 # max requests at a time per session to avoid race conditions +maxRetries = 1 # max number of retries on rate limit errors +retryDelayMs = 150 # delay in ms between retries # Change default preferences here, see src/prefs_impl.nim for a complete list [Preferences] diff --git a/nitter.nimble b/nitter.nimble index 7ff8196..b36f498 100644 --- a/nitter.nimble +++ b/nitter.nimble @@ -11,24 +11,23 @@ bin = @["nitter"] # Dependencies requires "nim >= 2.0.0" -requires "jester#baca3f" -requires "karax#5cf360c" -requires "sass#7dfdd03" -requires "nimcrypto#a079df9" -requires "markdown#158efe3" +requires "jester == 0.6.0" +requires "karax == 1.5.0" +requires "sass == 0.2.0" +requires "nimcrypto == 0.7.3" +requires "markdown == 0.8.8" requires "packedjson#9e6fbb6" -requires "supersnappy#6c94198" -requires "redpool#8b7c1db" -requires "https://github.com/zedeus/redis#d0a0e6f" -requires "zippy#ca5989a" -requires "flatty#e668085" -requires "jsony#1de1f08" -requires "oauth#b8c163b" +requires "supersnappy == 2.1.4" +requires "redpool == 0.2.2" +requires "zippy == 0.10.19" +requires "flatty == 0.4.0" +requires "jsony == 1.1.6" +requires "oauth == 0.11" # Tasks task scss, "Generate css": - exec "nimble c --hint[Processing]:off -d:danger -r tools/gencss" + exec "nim r --hint[Processing]:off tools/gencss" task md, "Render md": - exec "nimble c --hint[Processing]:off -d:danger -r tools/rendermd" + exec "nim r --hint[Processing]:off tools/rendermd" diff --git a/public/css/fontello.css b/public/css/fontello.css index 2453575..8f9abad 100644 --- a/public/css/fontello.css +++ b/public/css/fontello.css @@ -1,53 +1,153 @@ @font-face { - font-family: 'fontello'; - src: url('/fonts/fontello.eot?61663884'); - src: url('/fonts/fontello.eot?61663884#iefix') format('embedded-opentype'), - url('/fonts/fontello.woff2?61663884') format('woff2'), - url('/fonts/fontello.woff?61663884') format('woff'), - url('/fonts/fontello.ttf?61663884') format('truetype'), - url('/fonts/fontello.svg?61663884#fontello') format('svg'); + font-family: "fontello"; + src: url("/fonts/fontello.eot?59696369"); + src: + url("/fonts/fontello.eot?59696369#iefix") format("embedded-opentype"), + url("/fonts/fontello.woff2?59696369") format("woff2"), + url("/fonts/fontello.woff?59696369") format("woff"), + url("/fonts/fontello.ttf?59696369") format("truetype"), + url("/fonts/fontello.svg?59696369#fontello") format("svg"); font-weight: normal; font-style: normal; } -[class^="icon-"]:before, [class*=" icon-"]:before { + +[class^="icon-"]:before, +[class*=" icon-"]:before { font-family: "fontello"; font-style: normal; font-weight: normal; speak: never; - + display: inline-block; text-decoration: inherit; width: 1em; + margin-right: 0.2em; text-align: center; /* For safety - reset parent styles, that can break glyph codes*/ font-variant: normal; text-transform: none; - + /* fix buttons height, for twitter bootstrap */ line-height: 1em; - + /* Font smoothing. That was taken from TWBS */ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } -.icon-views:before { content: '\e800'; } /* '' */ -.icon-heart:before { content: '\e801'; } /* '' */ -.icon-quote:before { content: '\e802'; } /* '' */ -.icon-comment:before { content: '\e803'; } /* '' */ -.icon-ok:before { content: '\e804'; } /* '' */ -.icon-play:before { content: '\e805'; } /* '' */ -.icon-link:before { content: '\e806'; } /* '' */ -.icon-calendar:before { content: '\e807'; } /* '' */ -.icon-location:before { content: '\e808'; } /* '' */ -.icon-picture:before { content: '\e809'; } /* '' */ -.icon-lock:before { content: '\e80a'; } /* '' */ -.icon-down:before { content: '\e80b'; } /* '' */ -.icon-retweet:before { content: '\e80c'; } /* '' */ -.icon-search:before { content: '\e80d'; } /* '' */ -.icon-pin:before { content: '\e80e'; } /* '' */ -.icon-cog:before { content: '\e80f'; } /* '' */ -.icon-rss:before { content: '\e810'; } /* '' */ -.icon-info:before { content: '\f128'; } /* '' */ -.icon-bird:before { content: '\f309'; } /* '' */ +.icon-group:before { + content: "\e0c3"; +} + +/* '' */ +.icon-views:before { + content: "\e800"; +} + +/* '' */ +.icon-heart:before { + content: "\e801"; +} + +/* '' */ +.icon-quote:before { + content: "\e802"; +} + +/* '' */ +.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"; +} + +/* '' */ diff --git a/public/fonts/fontello.eot b/public/fonts/fontello.eot index 2b2982a..a5b8b1c 100644 Binary files a/public/fonts/fontello.eot and b/public/fonts/fontello.eot differ diff --git a/public/fonts/fontello.svg b/public/fonts/fontello.svg index 2a64343..5dc65a8 100644 --- a/public/fonts/fontello.svg +++ b/public/fonts/fontello.svg @@ -1,11 +1,13 @@ -Copyright (C) 2025 by original authors @ fontello.com +Copyright (C) 2026 by original authors @ fontello.com + + @@ -14,8 +16,6 @@ - - @@ -40,6 +40,14 @@ + + + + + + + + diff --git a/public/fonts/fontello.ttf b/public/fonts/fontello.ttf index ef775f8..a2b972e 100644 Binary files a/public/fonts/fontello.ttf and b/public/fonts/fontello.ttf differ diff --git a/public/fonts/fontello.woff b/public/fonts/fontello.woff index 63c3c23..65508ca 100644 Binary files a/public/fonts/fontello.woff and b/public/fonts/fontello.woff differ diff --git a/public/fonts/fontello.woff2 b/public/fonts/fontello.woff2 index b7541f0..a8d96da 100644 Binary files a/public/fonts/fontello.woff2 and b/public/fonts/fontello.woff2 differ diff --git a/public/js/embedResize.js b/public/js/embedResize.js new file mode 100644 index 0000000..3fb05a0 --- /dev/null +++ b/public/js/embedResize.js @@ -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); +})(); diff --git a/public/js/hlsPlayback.js b/public/js/hlsPlayback.js index 5cd46a6..5970011 100644 --- a/public/js/hlsPlayback.js +++ b/public/js/hlsPlayback.js @@ -1,25 +1,30 @@ // @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0 // SPDX-License-Identifier: AGPL-3.0-only -function playVideo(overlay) { - const video = overlay.parentElement.querySelector('video'); - const url = video.getAttribute("data-url"); - video.setAttribute("controls", ""); +function playMedia(overlay, tagName) { + const media = overlay.parentElement.querySelector(tagName); + const url = media.getAttribute("data-url"); + const startTime = parseFloat(media.getAttribute("data-start") || "0"); + media.setAttribute("controls", ""); overlay.style.display = "none"; if (Hls.isSupported()) { var hls = new Hls({autoStartLoad: false}); hls.loadSource(url); - hls.attachMedia(video); + hls.attachMedia(media); hls.on(Hls.Events.MANIFEST_PARSED, function () { hls.loadLevel = hls.levels.length - 1; - hls.startLoad(); - video.play(); + hls.startLoad(startTime); + media.play(); }); - } else if (video.canPlayType('application/vnd.apple.mpegurl')) { - video.src = url; - video.addEventListener('canplay', function() { - video.play(); + } else if (media.canPlayType('application/vnd.apple.mpegurl')) { + media.src = url; + media.addEventListener('canplay', function() { + if (startTime > 0) media.currentTime = startTime; + media.play(); }); } } + +function playVideo(overlay) { playMedia(overlay, 'video'); } +function playAudio(overlay) { playMedia(overlay, 'audio'); } // @license-end diff --git a/public/js/infiniteScroll.js b/public/js/infiniteScroll.js index be27e0c..f79912f 100644 --- a/public/js/infiniteScroll.js +++ b/public/js/infiniteScroll.js @@ -1,77 +1,225 @@ // @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0 // SPDX-License-Identifier: AGPL-3.0-only + function insertBeforeLast(node, elem) { - node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]); + node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]); } function getLoadMore(doc) { - return doc.querySelector(".show-more:not(.timeline-item)"); + return doc.querySelector(".show-more:not(.timeline-item)"); } -function isDuplicate(item, itemClass) { - const tweet = item.querySelector(".tweet-link"); - if (tweet == null) return false; - const href = tweet.getAttribute("href"); - return document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != null; +function getHrefs(selector) { + return new Set([...document.querySelectorAll(selector)].map(el => el.getAttribute("href"))); } -window.onload = function () { - const url = window.location.pathname; - const isTweet = url.indexOf("/status/") !== -1; - const containerClass = isTweet ? ".replies" : ".timeline"; - const itemClass = containerClass + " > div:not(.top-ref)"; +function getTweetId(item) { + const m = item.querySelector(".tweet-link")?.getAttribute("href")?.match(/\/status\/(\d+)/); + return m ? m[1] : ""; +} - var html = document.querySelector("html"); - var container = document.querySelector(containerClass); - var loading = false; +function isDuplicate(item, hrefs) { + return hrefs.has(item.querySelector(".tweet-link")?.getAttribute("href")); +} - function handleScroll(failed) { - if (loading) return; +const GAP = 10; - if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) { - loading = true; - var loadMore = getLoadMore(document); - if (loadMore == null) return; +class Masonry { + constructor(container) { + this.container = container; + const colSizes = { + small: w => Math.max(130, w * 0.11), + medium: w => Math.max(190, Math.min(350, w * 0.22)), + large: w => Math.max(350, Math.min(480, w * 0.22)), + }; + const size = container.dataset.colSize || "medium"; + this._targetWidth = colSizes[size] || colSizes.medium; + this.colHeights = []; + this.colCounts = []; + this.colCount = 0; + this._lastWidth = 0; + this._colWidthCache = 0; + this._items = []; + this._revealTimer = null; + this.container.classList.add("masonry-active"); - loadMore.children[0].text = "Loading..."; + let resizeTimer; + window.addEventListener("resize", () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => this._rebuild(), 50); + }); - var url = new URL(loadMore.children[0].href); - url.searchParams.append("scroll", "true"); + // Re-sync positions whenever images finish loading and items grow taller. + // Must be set up before _rebuild() so initial items get observed on first pass. + let syncTimer; + this._observer = window.ResizeObserver ? new ResizeObserver(() => { + clearTimeout(syncTimer); + syncTimer = setTimeout(() => this.syncHeights(), 100); + }) : null; - fetch(url.toString()).then(function (response) { - if (response.status === 404) throw "error"; + this._rebuild(); + } - return response.text(); - }).then(function (html) { - var parser = new DOMParser(); - var doc = parser.parseFromString(html, "text/html"); - loadMore.remove(); + // Reveal all items and gallery siblings (show-more, top-ref). Idempotent. + _revealAll() { + clearTimeout(this._revealTimer); + for (const item of this._items) item.classList.add("masonry-visible"); + for (const el of this.container.parentElement.querySelectorAll(":scope > .show-more, :scope > .top-ref, :scope > .timeline-footer")) + el.classList.add("masonry-visible"); + } - for (var item of doc.querySelectorAll(itemClass)) { - if (item.className == "timeline-item show-more") continue; - if (isDuplicate(item, itemClass)) continue; - if (isTweet) container.appendChild(item); - else insertBeforeLast(container, item); - } + // Height-primary, count-as-tiebreaker: handles both tall tweets and unloaded images. + _pickCol() { + return this.colHeights.reduce((min, h, i) => { + const m = this.colHeights[min]; + return (h < m || (h === m && this.colCounts[i] < this.colCounts[min])) ? i : min; + }, 0); + } - loading = false; - const newLoadMore = getLoadMore(doc); - if (newLoadMore == null) return; - if (isTweet) container.appendChild(newLoadMore); - else insertBeforeLast(container, newLoadMore); - }).catch(function (err) { - console.warn("Something went wrong.", err); - if (failed > 3) { - loadMore.children[0].text = "Error"; - return; - } + // Position items using current column state. Updates colHeights, colCounts, container height. + _position(items, heights, colWidth) { + for (let i = 0; i < items.length; i++) { + const col = this._pickCol(); + items[i].style.left = `${col * (colWidth + GAP)}px`; + items[i].style.top = `${this.colHeights[col]}px`; + this.colHeights[col] += heights[i] + GAP; + this.colCounts[col]++; + } + this.container.style.height = `${Math.max(0, ...this.colHeights)}px`; + } - loading = false; - handleScroll((failed || 0) + 1); - }); - } + // Full reset and re-place all items. + _place(items, heights, n, colWidth) { + this.colHeights = new Array(n).fill(0); + this.colCounts = new Array(n).fill(0); + this.colCount = n; + this._position(items, heights, colWidth); + } + + _rebuild() { + const w = this.container.clientWidth; + const n = Math.max(1, Math.floor(w / this._targetWidth(w))); + if (n === this.colCount && w === this._lastWidth) return; + + const isFirst = this.colCount === 0; + + if (isFirst) { + this._items = [...this.container.querySelectorAll(".timeline-item")]; } - window.addEventListener("scroll", () => handleScroll()); -}; + // Sort newest-first by tweet ID (snowflake IDs exceed Number precision, compare as strings). + this._items.sort((a, b) => { + const idA = getTweetId(a), idB = getTweetId(b); + if (idA.length !== idB.length) return idB.length - idA.length; + return idB < idA ? -1 : idB > idA ? 1 : 0; + }); + + // Pre-set widths BEFORE reading heights so measurements reflect the new column width. + const colWidth = this._colWidthCache = Math.floor((w - GAP * (n - 1)) / n); + for (const item of this._items) item.style.width = `${colWidth}px`; + + this._place(this._items, this._items.map(item => item.offsetHeight), n, colWidth); + this._lastWidth = w; + + if (isFirst) { + if (this._observer) this._items.forEach(item => this._observer.observe(item)); + // Reveal immediately if all images are cached, else wait for syncHeights. + const hasUnloaded = this._items.some(item => + [...item.querySelectorAll("img")].some(img => !img.complete)); + if (hasUnloaded) { + this._revealTimer = setTimeout(() => this._revealAll(), 1000); + } else { + this._revealAll(); + } + } + } + + // Re-read actual heights and re-place all items. Fixes drift after images load. + syncHeights() { + this._place(this._items, this._items.map(item => item.offsetHeight), this.colCount, this._colWidthCache); + this._revealAll(); + } + + // Batch-add items in three phases to avoid O(N) reflows: + // 1. writes: set widths, append all — no reads, no reflows + // 2. one read: batch offsetHeight + // 3. writes: assign columns, set left/top + addAll(newItems) { + if (!newItems.length) return; + const colWidth = this._colWidthCache; + + for (const item of newItems) { + item.style.width = `${colWidth}px`; + this.container.appendChild(item); + } + + this._position(newItems, newItems.map(item => item.offsetHeight), colWidth); + this._items.push(...newItems); + + if (this._observer) newItems.forEach(item => this._observer.observe(item)); + } +} + +document.addEventListener("DOMContentLoaded", function () { + const isTweet = location.pathname.includes("/status/"); + const containerClass = isTweet ? ".replies" : ".timeline"; + const itemClass = containerClass + " > div:not(.top-ref)"; + const html = document.documentElement; + const container = document.querySelector(containerClass); + const masonryEl = container?.querySelector(".gallery-masonry"); + const masonry = masonryEl ? new Masonry(masonryEl) : null; + let loading = false; + + function handleScroll(failed) { + if (loading || html.scrollTop + html.clientHeight < html.scrollHeight - 3000) return; + + const loadMore = getLoadMore(document); + if (!loadMore) return; + loading = true; + loadMore.children[0].text = "Loading..."; + + const url = new URL(loadMore.children[0].href); + url.searchParams.append("scroll", "true"); + + fetch(url) + .then(r => { + if (r.status > 299) throw new Error("error"); + return r.text(); + }) + .then(responseText => { + const doc = new DOMParser().parseFromString(responseText, "text/html"); + loadMore.remove(); + + if (masonry) { + masonry.syncHeights(); + const newMasonry = doc.querySelector(".gallery-masonry"); + if (newMasonry) { + const knownHrefs = getHrefs(".gallery-masonry .tweet-link"); + masonry.addAll([...newMasonry.querySelectorAll(".timeline-item")].filter(item => !isDuplicate(item, knownHrefs))); + } + } else { + const knownHrefs = getHrefs(`${itemClass} .tweet-link`); + for (const item of doc.querySelectorAll(itemClass)) { + if (item.className === "timeline-item show-more" || isDuplicate(item, knownHrefs)) continue; + isTweet ? container.appendChild(item) : insertBeforeLast(container, item); + } + } + + loading = false; + const newLoadMore = getLoadMore(doc); + if (newLoadMore) { + isTweet ? container.appendChild(newLoadMore) : insertBeforeLast(container, newLoadMore); + if (masonry) newLoadMore.classList.add("masonry-visible"); + } + }) + .catch(err => { + console.warn("Something went wrong.", err); + if (failed > 3) { loadMore.children[0].text = "Error"; return; } + loading = false; + handleScroll((failed || 0) + 1); + }); + } + + window.addEventListener("scroll", () => handleScroll()); +}); // @license-end diff --git a/public/js/widgets.js b/public/js/widgets.js new file mode 100644 index 0000000..7bb283a --- /dev/null +++ b/public/js/widgets.js @@ -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(); + } +})(); diff --git a/src/api.nim b/src/api.nim index ef3a0f9..555a699 100644 --- a/src/api.nim +++ b/src/api.nim @@ -1,8 +1,8 @@ # SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, httpclient, uri, strutils, sequtils, sugar, tables +import asyncdispatch, httpclient, strutils, sequtils, sugar import packedjson -import types, query, formatters, consts, apiutils, parser -import experimental/parser as newParser +import types, query, formatters, consts, apiutils, parser, utils +import experimental/parser # Helper to generate params object for GraphQL requests proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] = @@ -11,88 +11,188 @@ proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] = if fieldToggles.len > 0: result.add ("fieldToggles", fieldToggles) -proc mediaUrl(id: string; cursor: string): SessionAwareUrl = - let - cookieVariables = userMediaVariables % [id, cursor] - oauthVariables = restIdVariables % [id, cursor] - result = SessionAwareUrl( - cookieUrl: graphUserMedia ? genParams(cookieVariables), - oauthUrl: graphUserMediaV2 ? genParams(oauthVariables) +proc apiUrl(endpoint, variables: string; fieldToggles = ""; skipTid = false): ApiUrl = + return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles), skipTid: skipTid) + +proc apiReq(endpoint, variables: string; fieldToggles = ""; skipTid = false): ApiReq = + let url = apiUrl(endpoint, variables, fieldToggles, skipTid) + return ApiReq(cookie: url, oauth: url) + +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 = - let - 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 userTweetsUrl(id: string; cursor: string): ApiReq = + return apiReq(graphUserTweetsV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles) -proc userTweetsAndRepliesUrl(id: string; cursor: string): SessionAwareUrl = - let - cookieVariables = userTweetsAndRepliesVariables % [id, cursor] - oauthVariables = restIdVariables % [id, cursor] - result = SessionAwareUrl( - cookieUrl: graphUserTweetsAndReplies ? genParams(cookieVariables, fieldToggles), - oauthUrl: graphUserTweetsAndRepliesV2 ? genParams(oauthVariables) +proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq = + result = ApiReq( + cookie: apiUrl(graphUserTweetsAndReplies, userTweetsAndRepliesVars % [id, cursor], userTweetsFieldToggles), + oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles, skipTid=true) ) -proc tweetDetailUrl(id: string; cursor: string): SessionAwareUrl = - let - cookieVariables = tweetDetailVariables % [id, cursor] - oauthVariables = tweetVariables % [id, cursor] - result = SessionAwareUrl( - cookieUrl: graphTweetDetail ? genParams(cookieVariables, tweetDetailFieldToggles), - oauthUrl: graphTweet ? genParams(oauthVariables) +proc userArticlesUrl(id: string; cursor: string): ApiReq = + result = ApiReq( + cookie: apiUrl(graphUserArticles, userArticlesVars % [id, cursor], userTweetsFieldToggles), + oauth: apiUrl(graphUserArticlesV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles) + ) + +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.} = if username.len == 0: return - let - url = graphUser ? genParams("""{"screen_name": "$1"}""" % username) - js = await fetchRaw(url, Api.userScreenName) + let js = await fetchRaw(userUrl(username)) result = parseGraphUser(js) proc getGraphUserById*(id: string): Future[User] {.async.} = if id.len == 0 or id.any(c => not c.isDigit): return let - url = graphUserById ? genParams("""{"rest_id": "$1"}""" % id) - js = await fetchRaw(url, Api.userRestId) + url = apiReq(graphUserById, userByRestIdVars % id) + js = await fetchRaw(url) result = parseGraphUser(js) +proc getAboutAccount*(username: string): Future[AccountInfo] {.async.} = + if username.len == 0: return + let + url = apiReq(graphAboutAccount, $(%*{"screenName": 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.} = if id.len == 0: return let - cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - js = case kind - of TimelineKind.tweets: - await fetch(userTweetsUrl(id, cursor), Api.userTweets) - of TimelineKind.replies: - await fetch(userTweetsAndRepliesUrl(id, cursor), Api.userTweetsAndReplies) - of TimelineKind.media: - await fetch(mediaUrl(id, cursor), Api.userMedia) + cursor = cursorParam(after) + url = case kind + of TimelineKind.tweets: userTweetsUrl(id, cursor) + of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor) + of TimelineKind.media: mediaUrl(id, cursor) + of TimelineKind.articles: userArticlesUrl(id, cursor) + js = await fetch(url) 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.} = if id.len == 0: return let - cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - url = graphListTweets ? genParams(restIdVariables % [id, cursor]) - result = parseGraphTimeline(await fetch(url, Api.listTweets), after).tweets + cursor = cursorParam(after) + url = apiReq(graphListTweets, restIdVars % [id, cursor, "20"]) + js = await fetch(url) + result = parseGraphTimeline(js, after).tweets proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = let variables = %*{"screenName": name, "listSlug": list} - url = graphListBySlug ? genParams($variables) - result = parseGraphList(await fetch(url, Api.listBySlug)) + url = apiReq(graphListBySlug, $variables) + js = await fetch(url) + result = parseGraphList(js) proc getGraphList*(id: string): Future[List] {.async.} = - let - url = graphListById ? genParams("""{"listId": "$1"}""" % id) - result = parseGraphList(await fetch(url, Api.list)) + let + url = apiReq(graphListById, $(%*{"listId": id})) + js = await fetch(url) + result = parseGraphList(js) proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} = if list.id.len == 0: return @@ -106,81 +206,161 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} } if after.len > 0: variables["cursor"] = % after - let url = graphListMembers ? genParams($variables) - result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after) + let + url = apiReq(graphListMembers, $variables) + js = await fetchRaw(url) + result = parseGraphListMembers(js, after) + +proc 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.} = if id.len == 0: return let - variables = """{"rest_id": "$1"}""" % id - params = {"variables": variables, "features": gqlFeatures} - js = await fetch(graphTweetResult ? params, Api.tweetResult) + url = apiReq(graphTweetResult, $(%*{"rest_id": id})) + js = await fetch(url) result = parseGraphTweetResult(js) -proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} = +proc getTweetByRestId*(id: string): Future[Tweet] {.async.} = if id.len == 0: return let - cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - js = await fetch(tweetDetailUrl(id, cursor), Api.tweetDetail) + url = apiReq(graphTweetResultByRestId, tweetByRestIdVars % id, articleFieldToggles) + 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) -proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} = - result = (await getGraphTweet(id, after)).replies +proc getReplies*(id, after: string; mode = Relevance): Future[Result[Chain]] {.async.} = + result = (await getGraphTweet(id, after, mode)).replies result.beginning = after.len == 0 -proc getTweet*(id: string; after=""): Future[Conversation] {.async.} = - result = await getGraphTweet(id) +proc getTweet*(id: string; after=""; mode = Relevance): Future[Conversation] {.async.} = + result = await getGraphTweet(id, mode=mode) 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.} = - let q = genQueryParam(query) + # workaround for #1372 + let maxId = + if not after.startsWith("maxid:"): "" + else: validateNumber(after[6..^1]) + + let q = genQueryParam(query, maxId) if q.len == 0 or q == emptyQuery: return Timeline(query: query, beginning: true) + 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 variables = %*{ "rawQuery": q, - "query_source": "typedQuery", "count": 20, - "product": "Latest", - "withDownvotePerspective": false, - "withReactionsMetadata": false, - "withReactionsPerspective": false + "querySource": "typed_query", + "product": product, + "withGrokTranslatedBio":true, + "withQuickPromoteEligibilityTweetFields":false } - if after.len > 0: + + if after.len > 0 and maxId.len == 0: variables["cursor"] = % after - let url = graphSearchTimeline ? genParams($variables) - result = parseGraphSearch[Tweets](await fetch(url, Api.search), after) + let + url = apiReq(graphSearchTimeline, $variables) + js = await fetch(url) + result = parseGraphSearch[Tweets](js, after) result.query = query -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.. 0: variables["cursor"] = % after - result.beginning = false - let url = graphSearchTimeline ? genParams($variables) - result = parseGraphSearch[User](await fetch(url, Api.search), after) + let + url = apiReq(graphSearchTimeline, $variables) + js = await fetch(url) + result = parseGraphSearch[T](js, after) 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.} = if id.len == 0: return - let js = await fetch(mediaUrl(id, ""), Api.userMedia) + let js = await fetch(mediaUrl(id, "")) 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.} = let client = newAsyncHttpClient(maxRedirects=0) try: diff --git a/src/apiutils.nim b/src/apiutils.nim index defffd1..b2aad58 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -1,16 +1,62 @@ # SPDX-License-Identifier: AGPL-3.0-only import httpclient, asyncdispatch, options, strutils, uri, times, math, tables -import jsony, packedjson, zippy, oauth1 -import types, auth, consts, parserutils, http_pool +import jsony, packedjson, zippy, oauth/oauth1 +import types, auth, consts, parserutils, http_pool, tid import experimental/types/common const rlRemaining = "x-rate-limit-remaining" rlReset = "x-rate-limit-reset" rlLimit = "x-rate-limit-limit" - errorsToSkip = {doesntExist, tweetNotFound, timeout, unauthorized, badRequest} + 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() == " from Cloudflare HTML for log diagnostics + let start = body.find("") + if start < 0: return "unknown" + let contentStart = start + 7 + let stop = body.find("", 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 = let @@ -32,31 +78,41 @@ proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = proc getCookieHeader(authToken, ct0: string): string = "auth_token=" & authToken & "; ct0=" & ct0 -proc genHeaders*(session: Session, url: string): HttpHeaders = +proc genHeaders*(session: Session, url: Uri, skipTid: bool): Future[HttpHeaders] {.async.} = result = newHttpHeaders({ - "connection": "keep-alive", - "content-type": "application/json", - "x-twitter-active-user": "yes", - "x-twitter-client-language": "en", - "authority": "api.x.com", + "accept": "*/*", "accept-encoding": "gzip", "accept-language": "en-US,en;q=0.9", - "accept": "*/*", - "DNT": "1", - "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" - }) + "content-type": "application/json", + "origin": "https://x.com", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36", + "x-twitter-active-user": "yes", + "x-twitter-client-language": "en", + "priority": "u=1, i" + }, titleCase=true) case session.kind of SessionKind.oauth: - result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret) + result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret) of SessionKind.cookie: - result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF" result["x-twitter-auth-type"] = "OAuth2Session" result["x-csrf-token"] = session.ct0 result["cookie"] = getCookieHeader(session.authToken, session.ct0) + result["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.} = - result = await getSession(api) +proc getAndValidateSession*(req: ApiReq): Future[Session] {.async.} = + result = await getSession(req) case result.kind of SessionKind.oauth: if result.oauthToken.len == 0: @@ -73,9 +129,18 @@ template fetchImpl(result, fetchBody) {.dirty.} = try: 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 = - resp = await c.get($url) + # TODO: this is a temporary simple implementation + if apiProxy.len > 0 and "/1.1/" notin url.path: + resp = await c.get(($url).replace("https://", apiProxy)) + else: + resp = await c.get($url) result = await resp.body getContent() @@ -84,38 +149,45 @@ template fetchImpl(result, fetchBody) {.dirty.} = badClient = true 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 remaining = parseInt(resp.headers[rlRemaining]) reset = parseInt(resp.headers[rlReset]) limit = parseInt(resp.headers[rlLimit]) - session.setRateLimit(api, remaining, reset, limit) + session.setRateLimit(req, remaining, reset, limit) if result.len > 0: if resp.headers.getOrDefault("content-encoding") == "gzip": 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"): let errors = result.fromJson(Errors) 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}: invalidate(session) raise rateLimitError() elif errors in {rateLimited}: # rate limit hit, resets after 24 hours - setLimited(session, api) + setLimited(session, req) raise rateLimitError() elif result.startsWith("429 Too Many Requests"): - echo "[sessions] 429 error, API: ", api, ", session: ", session.pretty - session.apis[api].remaining = 0 - # rate limit hit, resets after the 15 minute window + echo "[sessions] 429 error, API: ", url.path, ", session: ", session.pretty raise rateLimitError() fetchBody if resp.status == $Http400: - echo "ERROR 400, ", api, ": ", result + echo "ERROR 400, ", url.path, ": ", result, ", session: ", session.pretty raise newException(InternalError, $url) except InternalError as e: raise e @@ -130,48 +202,57 @@ template fetchImpl(result, fetchBody) {.dirty.} = finally: release(session) -template retry(bod) = - try: - bod - except RateLimitError: - echo "[sessions] Rate limited, retrying ", api, " request..." - bod +template retry(bod) {.dirty.} = + var session: Session + var retrySuccess = false + for i in 0 ..< maxRetries: + try: + 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: - var - body: string - session = await getAndValidateSession(api) + var body: string + session = await getAndValidateSession(req) - when url is SessionAwareUrl: - let url = case session.kind - of SessionKind.oauth: url.oauthUrl - of SessionKind.cookie: url.cookieUrl + let url = req.toUrl(session.kind) fetchImpl body: if body.startsWith('{') or body.startsWith('['): result = parseJson(body) else: - echo resp.status, ": ", body, " --- url: ", url + echo resp.status, ": ", body, " --- url: ", url, ", session: ", session.pretty result = newJNull() let error = result.getError if error != null and error notin errorsToSkip: - echo "Fetch error, API: ", api, ", error: ", error + echo "Fetch error, API: ", url.path, ", error: ", error, ", session: ", session.pretty if error in {expiredToken, badToken, locked}: invalidate(session) raise rateLimitError() -proc fetchRaw*(url: Uri | SessionAwareUrl; api: Api): Future[string] {.async.} = +proc fetchRaw*(req: ApiReq): Future[string] {.async.} = retry: - var session = await getAndValidateSession(api) - - when url is SessionAwareUrl: - let url = case session.kind - of SessionKind.oauth: url.oauthUrl - of SessionKind.cookie: url.cookieUrl + session = await getAndValidateSession(req) + let url = req.toUrl(session.kind) fetchImpl result: 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) diff --git a/src/auth.nim b/src/auth.nim index 734b43e..259c360 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -1,20 +1,28 @@ #SPDX-License-Identifier: AGPL-3.0-only -import std/[asyncdispatch, times, json, random, sequtils, strutils, tables, packedsets, os] -import types +import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os] +import types, consts import experimental/parser/session -# max requests at a time per session to avoid race conditions -const - maxConcurrentReqs = 2 - hourInSeconds = 60 * 60 +const hourInSeconds = 60 * 60 var sessionPool: seq[Session] enableLogging = false + # max requests at a time per session to avoid race conditions + maxConcurrentReqs = 2 + +proc setMaxConcurrentReqs*(reqs: int) = + if reqs > 0: + maxConcurrentReqs = reqs template log(str: varargs[string, `$`]) = echo "[sessions] ", str.join("") +proc endpoint*(req: ApiReq; session: Session): string = + case session.kind + of oauth: req.oauth.endpoint + of cookie: req.cookie.endpoint + proc pretty*(session: Session): string = if session.isNil: return "" @@ -42,6 +50,8 @@ proc getSessionPoolHealth*(): JsonNode = oldest = now.int64 newest = 0'i64 average = 0'i64 + oauthTotal, cookieTotal = 0 + oauthLimited, cookieLimited = 0 for session in sessionPool: let created = snowflakeToEpoch(session.id) @@ -51,8 +61,15 @@ proc getSessionPoolHealth*(): JsonNode = oldest = created average += created + case session.kind + of oauth: inc oauthTotal + of cookie: inc cookieTotal + if session.limited: limited.incl session.id + case session.kind + of oauth: inc oauthLimited + of cookie: inc cookieLimited for api in session.apis.keys: let @@ -76,6 +93,8 @@ proc getSessionPoolHealth*(): JsonNode = "sessions": %*{ "total": sessionPool.len, "limited": limited.card, + "oauth": %*{"total": oauthTotal, "limited": oauthLimited}, + "cookie": %*{"total": cookieTotal, "limited": cookieLimited}, "oldest": $fromUnix(oldest), "newest": $fromUnix(newest), "average": $fromUnix(average) @@ -92,6 +111,7 @@ proc getSessionPoolDebug*(): JsonNode = for session in sessionPool: let sessionJson = %*{ + "kind": $session.kind, "apis": newJObject(), "pending": session.pending, } @@ -122,11 +142,12 @@ proc rateLimitError*(): ref RateLimitError = proc noSessionsError*(): ref NoSessionsError = newException(NoSessionsError, "no sessions available") -proc isLimited(session: Session; api: Api): bool = +proc isLimited(session: Session; req: ApiReq): bool = if session.isNil: return true - if session.limited and api != Api.userTweets: + let api = req.endpoint(session) + if session.limited and api != graphUserTweetsV2: if (epochTime().int - session.limitedAt) > hourInSeconds: session.limited = false log "resetting limit: ", session.pretty @@ -140,8 +161,8 @@ proc isLimited(session: Session; api: Api): bool = else: return false -proc isReady(session: Session; api: Api): bool = - not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(api)) +proc isReady(session: Session; req: ApiReq): bool = + not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(req)) proc invalidate*(session: var Session) = if session.isNil: return @@ -156,24 +177,29 @@ proc release*(session: Session) = if session.isNil: return dec session.pending -proc getSession*(api: Api): Future[Session] {.async.} = +proc getSession*(req: ApiReq): Future[Session] {.async.} = for i in 0 ..< sessionPool.len: - if result.isReady(api): break + if result.isReady(req): break result = sessionPool.sample() - if not result.isNil and result.isReady(api): + if not result.isNil and result.isReady(req): inc result.pending else: - log "no sessions available for API: ", api + 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() -proc setLimited*(session: Session; api: Api) = +proc setLimited*(session: Session; req: ApiReq) = + let api = req.endpoint(session) session.limited = true session.limitedAt = epochTime().int log "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", ", session.pretty -proc setRateLimit*(session: Session; api: Api; remaining, reset, limit: int) = +proc setRateLimit*(session: Session; req: ApiReq; remaining, reset, limit: int) = # avoid undefined behavior in race conditions + let api = req.endpoint(session) if api in session.apis: let rateLimit = session.apis[api] if rateLimit.reset >= reset and rateLimit.remaining < remaining: diff --git a/src/config.nim b/src/config.nim index 1b05ffe..b46a979 100644 --- a/src/config.nim +++ b/src/config.nim @@ -13,6 +13,8 @@ proc get*[T](config: parseCfg.Config; section, key: string; default: T): T = proc getConfig*(path: string): (Config, parseCfg.Config) = var cfg = loadConfig(path) + let masterRss = cfg.get("Config", "enableRSS", true) + let conf = Config( # Server address: cfg.get("Server", "address", "0.0.0.0"), @@ -37,10 +39,20 @@ proc getConfig*(path: string): (Config, parseCfg.Config) = hmacKey: cfg.get("Config", "hmacKey", "secretkey"), base64Media: cfg.get("Config", "base64Media", false), minTokens: cfg.get("Config", "tokenCount", 10), - enableRss: cfg.get("Config", "enableRSS", true), + enableRSSUserTweets: masterRss and cfg.get("Config", "enableRSSUserTweets", true), + enableRSSUserReplies: masterRss and cfg.get("Config", "enableRSSUserReplies", true), + enableRSSUserMedia: masterRss and cfg.get("Config", "enableRSSUserMedia", true), + 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), 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) diff --git a/src/consts.nim b/src/consts.nim index 792a519..c88ae68 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -1,116 +1,108 @@ # SPDX-License-Identifier: AGPL-3.0-only -import uri, strutils +import strutils const consumerKey* = "3nVuSoBZnx6U4vzUxf5w" consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys" + bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" + bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F" - gql = parseUri("https://api.x.com") / "graphql" + graphUser* = "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" - graphUserById* = gql / "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery" - graphUserTweetsV2* = gql / "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2" - graphUserTweetsAndRepliesV2* = gql / "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2" - graphUserTweets* = gql / "oRJs8SLCRNRbQzuZG93_oA/UserTweets" - graphUserTweetsAndReplies* = gql / "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies" - graphUserMedia* = gql / "36oKqyQ7E_9CmtONGjJRsA/UserMedia" - graphUserMediaV2* = gql / "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2" - graphTweet* = gql / "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline" - graphTweetDetail* = gql / "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail" - graphTweetResult* = gql / "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery" - graphSearchTimeline* = gql / "bshMIjqDk8LTXTq4w91WKw/SearchTimeline" - graphListById* = gql / "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId" - graphListBySlug* = gql / "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug" - graphListMembers* = gql / "fuVHh5-gFn8zDBBxb8wOMA/ListMembers" - graphListTweets* = gql / "VQf8_XQynI3WzH6xopOMMQ/ListTimeline" + graphListById* = "niz0TtOxL2zIcbq6_NQiNw/ListByRestId" + graphListBySlug* = "RqkWNDQpOntlxNtJa4RIoQ/ListBySlug" + graphListMembers* = "8rYmkvWQe9jRRZdy_-vkGA/ListMembers" + graphListTweets* = "0QJtcuMzVywHGAWD6Dtjlw/ListTimeline" + graphAboutAccount* = "TzOG2twZEfhr9KmClvVVqA/AboutAccountQuery" + + graphCommunity* = "-ElI1vg3dYbttVMhBhGdLw/CommunityQuery" + graphCommunityTweets* = "EwftYyqQemkckQ0wzGM6uw/CommunityTweetsTimeline" + graphCommunityMedia* = "ESJtwnI_apuGesbJncpc0Q/CommunityMediaTimeline" + graphCommunityMembers* = "woAp_YdzAdqnWDrqLTNpAw/membersSliceTimeline_Query" + graphCommunityModerators* = "0oYT9GRiWUhrz5xoqFE9uw/moderatorsSliceTimeline_Query" + graphCommunityHashtags* = "D5EqomOIWeJnSkMhL-FLew/CommunityHashtagsTimeline" + + graphTweetResultByRestId* = "GZsN2Pc4knAoit6pXa4HSA/TweetResultByRestId" + graphTweetResultsByRestIds* = "Pho4sg8jLcrVlMeclMayrg/TweetResultsByRestIds" + + graphBroadcast* = "RG6wSogandh6WPIzxW9aag/BroadcastQuery" + graphAudioSpace* = "Bh0L6azTQoMs9rJKeCF4wQ/AudioSpaceById" + restLiveStream* = "1.1/live_video_stream/status/" + + graphFollowers* = "JNyQdTISpzCkj_1fqxDvFg/Followers" + graphFollowing* = "qGZZDF3mp91q7X22s3HxpA/Following" gqlFeatures* = """{ - "android_ad_formats_media_component_render_overlay_enabled": false, - "android_graphql_skip_api_media_color_palette": false, - "android_professional_link_spotlight_display_enabled": false, - "blue_business_profile_image_shape_enabled": false, - "commerce_android_shop_module_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, + "rweb_video_screen_enabled": false, + "rweb_cashtags_enabled": true, + "profile_label_improvements_pcf_label_in_post_enabled": true, + "responsive_web_profile_redirect_enabled": false, + "rweb_tipjar_consumption_enabled": false, "verified_phone_label_enabled": false, - "vibe_api_enabled": false, - "view_counts_everywhere_api_enabled": true, + "creator_subscriptions_tweet_preview_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, "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_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_imagine_annotation_enabled": true, - "rweb_tipjar_consumption_enabled": true, - "profile_label_improvements_pcf_label_in_post_enabled": true, - "creator_subscriptions_quote_tweet_preview_enabled": false, - "c9s_tweet_anatomy_moderator_badge_enabled": true, - "responsive_web_grok_analyze_post_followups_enabled": true, - "rweb_video_timestamps_enabled": false, - "responsive_web_grok_share_attachment_enabled": true, - "articles_preview_enabled": true, - "immersive_video_status_linkable_timestamps": false, - "articles_api_enabled": false, - "responsive_web_grok_analysis_button_from_backend": true, - "rweb_video_screen_enabled": false, - "payments_enabled": false, - "responsive_web_profile_redirect_enabled": false, - "responsive_web_grok_show_grok_translated_post": false, - "responsive_web_grok_community_note_auto_translation_is_enabled": false, - "profile_label_improvements_pcf_label_in_profile_enabled": false, - "grok_android_analyze_trend_fetch_enabled": false, - "grok_translations_community_note_auto_translation_is_enabled": false, - "grok_translations_post_auto_translation_is_enabled": false, - "grok_translations_community_note_translation_is_enabled": false, - "grok_translations_timeline_user_bio_auto_translation_is_enabled": false + "responsive_web_grok_community_note_auto_translation_is_enabled": true, + "responsive_web_enhance_cards_enabled": false }""".replace(" ", "").replace("\n", "") - tweetVariables* = """{ + tweetVars* = """{ "postId": "$1", $2 + "ranking_mode": "$3", "includeHasBirdwatchNotes": false, "includePromotedContent": false, - "withBirdwatchNotes": false, + "withBirdwatchNotes": true, "withVoice": false, "withV2Timeline": true }""".replace(" ", "").replace("\n", "") - tweetDetailVariables* = """{ + tweetDetailVars* = """{ "focalTweetId": "$1", $2 "referrer": "profile", @@ -123,21 +115,26 @@ const "withVoice": true }""".replace(" ", "").replace("\n", "") - restIdVariables* = """{ - "rest_id": "$1", $2 - "count": 20 -}""" + tweetEditHistoryVars* = """{ + "tweetId": "$1", + "withQuickPromoteEligibilityTweetFields": true +}""".replace(" ", "").replace("\n", "") - userMediaVariables* = """{ + restIdVars* = """{ + "rest_id": "$1", $2 + "count": $3 +}""".replace(" ", "").replace("\n", "") + + userMediaVars* = """{ "userId": "$1", $2 - "count": 20, + "count": $3, "includePromotedContent": false, "withClientEventToken": false, "withBirdwatchNotes": false, "withVoice": true }""".replace(" ", "").replace("\n", "") - userTweetsVariables* = """{ + userTweetsVars* = """{ "userId": "$1", $2 "count": 20, "includePromotedContent": false, @@ -145,7 +142,7 @@ const "withVoice": true }""".replace(" ", "").replace("\n", "") - userTweetsAndRepliesVariables* = """{ + userTweetsAndRepliesVars* = """{ "userId": "$1", $2 "count": 20, "includePromotedContent": false, @@ -153,5 +150,70 @@ const "withVoice": true }""".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}""" diff --git a/src/experimental/parser.nim b/src/experimental/parser.nim index 40986f5..e22a51f 100644 --- a/src/experimental/parser.nim +++ b/src/experimental/parser.nim @@ -1,2 +1,2 @@ -import parser/[user, graphql] -export user, graphql +import parser/[user, graphql, article] +export user, graphql, article diff --git a/src/experimental/parser/article.nim b/src/experimental/parser/article.nim new file mode 100644 index 0000000..ae5de7c --- /dev/null +++ b/src/experimental/parser/article.nim @@ -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 diff --git a/src/experimental/parser/graphql.nim b/src/experimental/parser/graphql.nim index 045a5d6..85be202 100644 --- a/src/experimental/parser/graphql.nim +++ b/src/experimental/parser/graphql.nim @@ -1,6 +1,6 @@ import options, strutils import jsony -import user, ../types/[graphuser, graphlistmembers] +import user, utils, ../types/[graphuser, graphlistmembers, graphfollowers] from ../../types import User, VerifiedType, Result, Query, QueryKind proc parseUserResult*(userResult: UserResult): User = @@ -10,27 +10,61 @@ proc parseUserResult*(userResult: UserResult): User = result.verifiedType = blue 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.username = userResult.core.screenName result.fullname = userResult.core.name 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: let v = userResult.verification.get if v.verifiedType != VerifiedType.none: 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: - result.bio = userResult.profileBio.get.description + let bio = userResult.profileBio.get + result.bio = bio.description + result.expandUserEntities(bio.entities) proc parseGraphUser*(json: string): User = if json.len == 0 or json[0] != '{': return - let raw = json.fromJson(GraphUser) - let userResult = raw.data.userResult.result + let + raw = json.fromJson(GraphUser) + userResult = + if raw.data.userResult.isSome: raw.data.userResult.get.result + elif raw.data.user.isSome: raw.data.user.get.result + else: UserResult() - if userResult.unavailableReason.get("") == "Suspended": + if userResult.unavailableReason.get("") == "Suspended" or + userResult.reason.get("") == "Suspended": return User(suspended: true) result = parseUserResult(userResult) @@ -53,3 +87,25 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] = of TimelineTimelineCursor: if entry.content.cursorType == "Bottom": 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 diff --git a/src/experimental/parser/slices.nim b/src/experimental/parser/slices.nim index 45e6e1d..db2c98d 100644 --- a/src/experimental/parser/slices.nim +++ b/src/experimental/parser/slices.nim @@ -54,7 +54,7 @@ proc replacedWith*(runes: seq[Rune]; repls: openArray[ReplaceSlice]; let name = $runes[rep.slice.a.succ .. rep.slice.b] symbol = $runes[rep.slice.a] - result.add a(symbol & name, href = "/search?q=%23" & name) + result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name) of rkMention: result.add a($runes[rep.slice], href = rep.url, title = rep.display) of rkUrl: diff --git a/src/experimental/parser/tid.nim b/src/experimental/parser/tid.nim new file mode 100644 index 0000000..28fccea --- /dev/null +++ b/src/experimental/parser/tid.nim @@ -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) diff --git a/src/experimental/parser/user.nim b/src/experimental/parser/user.nim index 498757a..866973c 100644 --- a/src/experimental/parser/user.nim +++ b/src/experimental/parser/user.nim @@ -9,12 +9,10 @@ let unReplace = "$1@$2" htRegex = nre.re"""(*U)(^|[^\w-_.?])([##$])([\w_]*+)(?!|">|#)""" - htReplace = "$1$2$3" + htReplace = "$1$2$3" -proc expandUserEntities(user: var User; raw: RawUser) = - let - orig = user.bio.toRunes - ent = raw.entities +proc expandUserEntities*(user: var User; ent: Entities) = + let orig = user.bio.toRunes if ent.url.urls.len > 0: user.website = ent.url.urls[0].expandedUrl @@ -58,15 +56,17 @@ proc toUser*(raw: RawUser): User = media: raw.mediaCount, verifiedType: raw.verifiedType, protected: raw.protected, - joinDate: parseTwitterDate(raw.createdAt), banner: getBanner(raw), userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "") ) + if raw.createdAt.len > 0: + result.joinDate = parseTwitterDate(raw.createdAt) + if raw.pinnedTweetIdsStr.len > 0: result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0]) - result.expandUserEntities(raw) + result.expandUserEntities(raw.entities) proc parseHook*(s: string; i: var int; v: var User) = var u: RawUser diff --git a/src/experimental/types/article.nim b/src/experimental/types/article.nim new file mode 100644 index 0000000..946f8c3 --- /dev/null +++ b/src/experimental/types/article.nim @@ -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" diff --git a/src/experimental/types/graphfollowers.nim b/src/experimental/types/graphfollowers.nim new file mode 100644 index 0000000..ba9210b --- /dev/null +++ b/src/experimental/types/graphfollowers.nim @@ -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" diff --git a/src/experimental/types/graphlistmembers.nim b/src/experimental/types/graphlistmembers.nim index 4cb3757..9e520d8 100644 --- a/src/experimental/types/graphlistmembers.nim +++ b/src/experimental/types/graphlistmembers.nim @@ -7,10 +7,10 @@ type List = object membersTimeline*: tuple[timeline: Timeline] - Timeline = object + Timeline* = object instructions*: seq[Instruction] - Instruction = object + Instruction* = object kind*: string entries*: seq[tuple[content: Content]] @@ -18,7 +18,7 @@ type TimelineTimelineItem TimelineTimelineCursor - Content = object + Content* = object case entryType*: ContentEntryType of TimelineTimelineItem: itemContent*: tuple[userResults: UserData] diff --git a/src/experimental/types/graphuser.nim b/src/experimental/types/graphuser.nim index d732b4e..ec41f89 100644 --- a/src/experimental/types/graphuser.nim +++ b/src/experimental/types/graphuser.nim @@ -1,9 +1,10 @@ import options, strutils from ../../types import User, VerifiedType +import user as userType # Entities, for modern profile_bio parsing type GraphUser* = object - data*: tuple[userResult: UserData] + data*: tuple[userResult: Option[UserData], user: Option[UserData]] UserData* = object result*: UserResult @@ -15,6 +16,7 @@ type UserBio* = object description*: string + entities*: Entities UserAvatar* = object imageUrl*: string @@ -22,15 +24,41 @@ type Verification* = object 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 legacy*: User restId*: string isBlueVerified*: bool - unavailableReason*: Option[string] core*: UserCore avatar*: UserAvatar + banner*: UserAvatar + unavailableReason*: Option[string] + reason*: Option[string] + privacy*: Option[Privacy] profileBio*: Option[UserBio] verification*: Option[Verification] + location*: Option[Location] + relationshipCounts*: Option[RelationshipCounts] + tweetCounts*: Option[TweetCounts] + actionCounts*: Option[ActionCounts] proc enumHook*(s: string; v: var VerifiedType) = v = try: diff --git a/src/experimental/types/tid.nim b/src/experimental/types/tid.nim new file mode 100644 index 0000000..ad036d9 --- /dev/null +++ b/src/experimental/types/tid.nim @@ -0,0 +1,4 @@ +type + TidPair* = object + animationKey*: string + verification*: string diff --git a/src/formatters.nim b/src/formatters.nim index cafaa4f..958e518 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -1,12 +1,12 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen +import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen, math import std/[enumerate, re] import types, utils, query const cards = "cards.twitter.com/cards" tco = "https://t.co" - twitter = parseUri("https://twitter.com") + twitter = parseUri("https://x.com") let twRegex = re"(?<=(? 0 and "youtu" in result: - result = result.replace(ytRegex, prefs.replaceYouTube) + let youtubeHost = strip(prefs.replaceYouTube, chars={'/'}) + result = result.replace(ytRegex, youtubeHost) if prefs.replaceTwitter.len > 0: + let twitterHost = strip(prefs.replaceTwitter, chars={'/'}) if tco in result: - result = result.replace(tco, https & prefs.replaceTwitter & "/t.co") + result = result.replace(tco, https & twitterHost & "/t.co") if "x.com" in result: - result = result.replace(xRegex, prefs.replaceTwitter) + result = result.replace(xRegex, twitterHost) result = result.replacef(xLinkRegex, a( - prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1")) + twitterHost & "$2", href = https & twitterHost & "$1")) if "twitter.com" in result: - result = result.replace(cards, prefs.replaceTwitter & "/cards") - result = result.replace(twRegex, prefs.replaceTwitter) + result = result.replace(cards, twitterHost & "/cards") + result = result.replace(twRegex, twitterHost) result = result.replacef(twLinkRegex, a( - prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1")) + twitterHost & "$2", href = https & twitterHost & "$1")) if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result): - result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/") - result = result.replace(rdRegex, prefs.replaceReddit) - if prefs.replaceReddit in result and "/gallery/" in result: + let redditHost = strip(prefs.replaceReddit, chars={'/'}) + result = result.replace(rdShortRegex, redditHost & "/comments/") + result = result.replace(rdRegex, redditHost) + if redditHost in result and "/gallery/" in result: result = result.replace("/gallery/", "/comments/") if absolute.len > 0 and "href" in result: @@ -88,7 +91,17 @@ proc getM3u8Url*(content: string): string = if re.find(content, m3u8Regex, matches) != -1: result = matches[0] -proc proxifyVideo*(manifest: string; proxy: bool): string = +proc proxifyVideo*(manifest: string; proxy: bool; manifestUrl = ""): string = + let (baseUrl, basePath) = + if manifestUrl.len > 0: + let + u = parseUri(manifestUrl) + origin = u.scheme & "://" & u.hostname + idx = manifestUrl.rfind('/') + dirPath = if idx > 8: manifestUrl[0 .. idx] else: "" + (origin, dirPath) + else: + ("https://video.twimg.com", "") var replacements: seq[(string, string)] for line in manifest.splitLines: let url = @@ -96,9 +109,13 @@ proc proxifyVideo*(manifest: string; proxy: bool): string = elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line: line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))] else: line - if url.startsWith('/'): - let path = "https://video.twimg.com" & url - replacements.add (url, if proxy: path.getVidUrl else: path) + let resolved = + if url.startsWith('/'): baseUrl & url + elif basePath.len > 0 and url.len > 0 and not url.startsWith('#') and + not url.startsWith("http") and ('.' in url): basePath & url + else: "" + if resolved.len > 0: + replacements.add (url, if proxy: resolved.getVidUrl else: resolved) return manifest.multiReplace(replacements) proc getUserPic*(userPic: string; style=""): string = @@ -123,25 +140,30 @@ proc pageDesc*(user: User): string = "The latest tweets from " & user.fullname proc getJoinDate*(user: User): string = + if user.joinDate.year == 0: return "" user.joinDate.format("'Joined' MMMM YYYY") proc getJoinDateFull*(user: User): string = + if user.joinDate.year == 0: return "" user.joinDate.format("h:mm tt - d MMM YYYY") proc getTime*(tweet: Tweet): string = + if tweet.time.year == 0: return "" tweet.time.format("MMM d', 'YYYY' · 'h:mm tt' UTC'") proc getRfc822Time*(tweet: Tweet): string = + if tweet.time.year == 0: return "" 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 since = now - tweet.time + let since = now - time - if now.year != tweet.time.year: - result = tweet.time.format("d MMM yyyy") + if now.year != time.year: + result = time.format("d MMM yyyy") elif since.inDays >= 1: - result = tweet.time.format("MMM d") + result = time.format("MMM d") elif since.inHours >= 1: result = $since.inHours & "h" elif since.inMinutes >= 1: @@ -151,13 +173,33 @@ proc getShortTime*(tweet: Tweet): string = else: 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 = if tweet.id == 0: return var username = tweet.user.username - if username.len == 0: - username = "i" - result = &"/{username}/status/{tweet.id}" - if focus: result &= "#m" + return getLink(tweet.id, username, focus) proc getTwitterLink*(path: string; params: Table[string, string]): string = var @@ -185,7 +227,7 @@ proc getTwitterLink*(path: string; params: Table[string, string]): string = proc getLocation*(u: User | Tweet): (string, string) = if "://" in u.location: return (u.location, "") let loc = u.location.split(":") - let url = if loc.len > 1: "/search?q=place:" & loc[1] else: "" + let url = if loc.len > 1: "/search?f=tweets&q=place:" & loc[1] else: "" (loc[0], url) proc getSuspended*(username: string): string = diff --git a/src/http_pool.nim b/src/http_pool.nim index 664e9a6..2553dd9 100644 --- a/src/http_pool.nim +++ b/src/http_pool.nim @@ -27,7 +27,7 @@ proc release*(pool: HttpPool; client: AsyncHttpClient; badClient=false) = proc acquire*(pool: HttpPool; heads: HttpHeaders): AsyncHttpClient = if pool.conns.len == 0: - result = newAsyncHttpClient(headers=heads, proxy=proxy) + result = newAsyncHttpClient(userAgent="", headers=heads, proxy=proxy) else: result = pool.conns.pop() result.headers = heads diff --git a/src/nitter.nim b/src/nitter.nim index f81dc1c..a76dda6 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -2,15 +2,15 @@ import asyncdispatch, strformat, logging from net import Port from htmlgen import a -from os import getEnv +from os import getEnv, normalizedPath import jester -import types, config, prefs, formatters, redis_cache, http_pool, auth +import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils import views/[general, about] import routes/[ - preferences, timeline, status, media, search, rss, list, debug, - unsupported, embed, resolver, router_utils] + preferences, timeline, status, media, search, rss, list, community, debug, + unsupported, embed, resolver, broadcast, space, article, router_utils] const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const issuesUrl = "https://github.com/zedeus/nitter/issues" @@ -34,39 +34,62 @@ stdout.flushFile updateDefaultPrefs(fullCfg) setCacheTimes(cfg) 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) setMaxHttpConns(cfg.httpMaxConns) setHttpProxy(cfg.proxy, cfg.proxyAuth) +setApiProxy(cfg.apiProxy) +setDisableTid(cfg.disableTid) +setMaxConcurrentReqs(cfg.maxConcurrentReqs) +setMaxRetries(cfg.maxRetries) +setRetryDelayMs(cfg.retryDelayMs) initAboutPage(cfg.staticDir) waitFor initRedisPool(cfg) stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n" stdout.flushFile +createArticleRouter(cfg) createUnsupportedRouter(cfg) createResolverRouter(cfg) createPrefRouter(cfg) createTimelineRouter(cfg) createListRouter(cfg) +createCommunityRouter(cfg) createStatusRouter(cfg) createSearchRouter(cfg) createMediaRouter(cfg) createEmbedRouter(cfg) createRssRouter(cfg) +createBroadcastRouter(cfg) +createSpaceRouter(cfg) createDebugRouter(cfg) settings: port = Port(cfg.port) - staticDir = cfg.staticDir + staticDir = normalizedPath(cfg.staticDir) bindAddr = cfg.address reusePort = true + maxBody = 64 * 1024 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 "/": - resp renderMain(renderSearch(), request, cfg, themePrefs()) + resp renderMain(renderSearch(), request, cfg, requestPrefs()) get "/about": - resp renderMain(renderAbout(), request, cfg, themePrefs()) + resp renderMain(renderAbout(), request, cfg, requestPrefs()) get "/explore": redirect("/about") @@ -77,7 +100,7 @@ routes: get "/i/redirect": let url = decodeUrl(@"url") if url.len == 0: resp Http404 - redirect(replaceUrls(url, cookiePrefs())) + redirect(replaceUrls(url, requestPrefs())) error Http404: resp Http404, showError("Page not found", cfg) @@ -102,14 +125,18 @@ routes: resp Http429, showError( &"Instance has no auth tokens, or is fully rate limited.
Use {link} or try again later.", cfg) + extend articleRoute, "" extend rss, "" extend status, "" extend search, "" extend timeline, "" extend media, "" extend list, "" + extend community, "" extend preferences, "" extend resolver, "" extend embed, "" + extend broadcastRoute, "" + extend spaceRoute, "" extend debug, "" extend unsupported, "" diff --git a/src/parser.nim b/src/parser.nim index 5bf2b0b..eebca2d 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -1,10 +1,20 @@ # 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 types, parserutils, utils 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 = if js.isNull: return @@ -21,7 +31,7 @@ proc parseUser(js: JsonNode; id=""): User = tweets: js{"statuses_count"}.getInt, likes: js{"favourites_count"}.getInt, media: js{"media_count"}.getInt, - protected: js{"protected"}.getBool, + protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool), joinDate: js{"created_at"}.getTime ) @@ -29,17 +39,17 @@ proc parseUser(js: JsonNode; id=""): User = result.verifiedType = blue with verifiedType, js{"verified_type"}: - result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr) + result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType) result.expandUserEntities(js) proc parseGraphUser(js: JsonNode): User = var user = js{"user_result", "result"} if user.isNull: - user = ? js{"user_results", "result"} + user = js{"user_results", "result"} if user.isNull: - if js{"core"}.notNull and js{"legacy"}.notNull: + if js{"core"}.notNull: user = js else: return @@ -51,15 +61,164 @@ proc parseGraphUser(js: JsonNode): User = # fallback to support UserMedia/recent GraphQL updates if result.username.len == 0: + result.id = user{"rest_id"}.getStr result.username = user{"core", "screen_name"}.getStr result.fullname = user{"core", "name"}.getStr result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "") - if user{"is_blue_verified"}.getBool(false): + if user{"is_blue_verified"}.getBool( + user{"verification", "is_blue_verified"}.getBool(false)): result.verifiedType = blue with verifiedType, user{"verification", "verified_type"}: - result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr) + result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType) + +proc parseAboutAccount*(js: JsonNode): AccountInfo = + if js.isNull: return + + let user = ? js{"data", "user_result_by_screen_name", "result"} + + if user{"unavailable_reason"}.getStr == "Suspended": + result.suspended = true + return + + result = AccountInfo( + username: user{"core", "screen_name"}.getStr, + fullname: user{"core", "name"}.getStr, + joinDate: user{"core", "created_at"}.getTime, + userPic: user{"avatar", "image_url"}.getImageStr.replace("_normal", ""), + affiliateLabel: user{"identity_profile_labels_highlighted_label", "label", "description"}.getStr, + ) + + if user{"is_blue_verified"}.getBool(false): + result.verifiedType = blue + with verifiedType, user{"verification", "verified_type"}: + result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType) + + with about, user{"about_profile"}: + result.basedIn = about{"account_based_in"}.getStr + result.source = about{"source"}.getStr + result.affiliateUsername = about{"affiliate_username"}.getStr + + try: + result.usernameChanges = about{"username_changes", "count"}.getStr("0").parseInt + except ValueError: + discard + + with lastChange, about{"username_changes", "last_changed_at_msec"}: + result.lastUsernameChange = lastChange.getTimeFromMsStr + + with info, user{"verification_info"}: + result.isIdentityVerified = info{"is_identity_verified"}.getBool + with reason, info{"reason"}: + result.overrideVerifiedYear = reason{"override_verified_year"}.getInt + with since, reason{"verified_since_msec"}: + result.verifiedSince = since.getTimeFromMsStr + +proc parseBroadcastInfo*(js: JsonNode): Broadcast = + let bc = ? js{"data", "broadcast"} + result = Broadcast( + id: bc{"broadcast_id"}.getStr, + title: bc{"status"}.getStr, + state: bc{"state"}.getStr.toUpperAscii, + thumb: bc{"image_url"}.getStr, + mediaKey: bc{"media_key"}.getStr, + totalWatched: bc{"total_watched"}.getInt, + startTime: bc{"start_time"}.getTimeFromMs, + endTime: bc{"end_time"}.getTimeFromMs, + replayStart: bc{"edited_replay", "start_time"}.getInt, + availableForReplay: bc{"available_for_replay"}.getBool, + user: parseGraphUser(bc) + ) + +proc 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 = if js.isNull: return @@ -70,15 +229,17 @@ proc parseGraphList*(js: JsonNode): List = if list.isNull: return - result = List( - id: list{"id_str"}.getStr, - name: list{"name"}.getStr, - username: list{"user_results", "result", "legacy", "screen_name"}.getStr, - userId: list{"user_results", "result", "rest_id"}.getStr, - description: list{"description"}.getStr, - members: list{"member_count"}.getInt, - banner: list{"custom_banner_media", "media_info", "original_img_url"}.getImageStr + result = parseListObject(list, parseGraphUser(list)) + +proc parseGraphSearchList(js: JsonNode): ListSearchResult = + let owner = parseGraphUser(js) + result = ListSearchResult( + list: parseListObject(js, owner), + owner: owner, + followersContext: js{"followers_context"}.getStr ) + for url in js{"facepile_urls"}: + result.facepiles.add url.getStr proc parsePoll(js: JsonNode): Poll = let vals = js{"binding_values"} @@ -134,60 +295,85 @@ proc parseVideo(js: JsonNode): Video = result.variants = parseVideoVariants(js{"video_info", "variants"}) +proc addMedia(media: var MediaEntities; photo: Photo) = + media.add Media(kind: photoMedia, photo: photo) + +proc addMedia(media: var MediaEntities; video: Video) = + media.add Media(kind: videoMedia, video: video) + +proc addMedia(media: var MediaEntities; gif: Gif) = + media.add Media(kind: gifMedia, gif: gif) + proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) = with jsMedia, js{"extended_entities", "media"}: for m in jsMedia: case m.getTypeName: of "photo": - result.photos.add m{"media_url_https"}.getImageStr + result.media.addMedia(Photo( + url: m{"media_url_https"}.getImageStr, + altText: m{"ext_alt_text"}.getStr + )) of "video": - result.video = some(parseVideo(m)) + result.media.addMedia(parseVideo(m)) with user, m{"additional_media_info", "source_user"}: if user{"id"}.getInt > 0: result.attribution = some(parseUser(user)) else: result.attribution = some(parseGraphUser(user)) + # 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": - result.gif = some Gif( + result.media.addMedia(Gif( url: m{"video_info", "variants"}[0]{"url"}.getImageStr, - thumb: m{"media_url_https"}.getImageStr - ) + thumb: m{"media_url_https"}.getImageStr, + altText: m{"ext_alt_text"}.getStr + )) else: discard - with url, m{"url"}: - if result.text.endsWith(url.getStr): - result.text.removeSuffix(url.getStr) - result.text = result.text.strip() - proc parseMediaEntities(js: JsonNode; result: var Tweet) = with mediaEntities, js{"media_entities"}: + var parsedMedia: MediaEntities for mediaEntity in mediaEntities: with mediaInfo, mediaEntity{"media_results", "result", "media_info"}: case mediaInfo.getTypeName of "ApiImage": - result.photos.add mediaInfo{"original_img_url"}.getImageStr + parsedMedia.addMedia(Photo( + url: mediaInfo{"original_img_url"}.getImageStr, + altText: mediaInfo{"alt_text"}.getStr + )) of "ApiVideo": let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"} - result.video = some Video( + parsedMedia.addMedia(Video( available: status.getStr == "Available", thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr, + title: mediaInfo{"alt_text"}.getStr, durationMs: mediaInfo{"duration_millis"}.getInt, variants: parseVideoVariants(mediaInfo{"variants"}) - ) + )) + + # 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": - result.gif = some Gif( + parsedMedia.addMedia(Gif( url: mediaInfo{"variants"}[0]{"url"}.getImageStr, - thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr - ) + thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr, + altText: mediaInfo{"alt_text"}.getStr + )) else: discard - # Remove media URLs from text - with mediaList, js{"legacy", "entities", "media"}: - for url in mediaList: - let expandedUrl = url{"expanded_url"}.getStr - if result.text.endsWith(expandedUrl): - result.text.removeSuffix(expandedUrl) - result.text = result.text.strip() + if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len: + result.media = parsedMedia proc parsePromoVideo(js: JsonNode): Video = result = Video( @@ -210,14 +396,23 @@ proc parsePromoVideo(js: JsonNode): Video = result.variants.add variant proc parseBroadcast(js: JsonNode): Card = - let image = js{"broadcast_thumbnail_large"}.getImageVal + let + image = js{"broadcast_thumbnail_large"}.getImageVal + broadcastUrl = js{"broadcast_url"}.getStrVal + broadcastId = broadcastUrl.rsplit('/', maxsplit=1)[^1] + streamUrl = "/i/broadcasts/" & broadcastId & "/stream" result = Card( kind: broadcast, - url: js{"broadcast_url"}.getStrVal, + url: "/i/broadcasts/" & broadcastId, title: js{"broadcaster_display_name"}.getStrVal, text: js{"broadcast_title"}.getStrVal, image: image, - video: some Video(thumb: image) + video: some Video( + thumb: image, + available: true, + playbackType: m3u8, + variants: @[VideoVariant(contentType: m3u8, url: streamUrl)] + ) ) proc parseCard(js: JsonNode; urls: JsonNode): Card = @@ -256,7 +451,13 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card = result.url = vals{"player_url"}.getStrVal if "youtube.com" in result.url: 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." else: discard @@ -267,7 +468,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card = for u in ? urls: if u{"url"}.getStr == result.url: - result.url = u{"expanded_url"}.getStr + result.url = u.getExpandedUrl(result.url) break if kind in {videoDirectMessage, imageDirectMessage}: @@ -277,8 +478,9 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card = result.url.len == 0 or result.url.startsWith("card://"): result.url = getPicUrl(result.image) -proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = - if js.isNull: return +proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull(); + replyId: int64 = 0; hasArticle = false): Tweet = + if js.isNull: return Tweet() let time = 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 if result.hasThread and result.threadId == 0: result.threadId = js{"self_thread", "id_str"}.getId @@ -318,13 +523,13 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = # graphql with rt, js{"retweeted_status_result", "result"}: # 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) return with reposts, js{"repostedStatusResults"}: with rt, reposts{"result"}: - if "legacy" in rt: + if "legacy" in rt or "rest_id" in rt: result.retweet = some parseGraphTweet(rt) return @@ -332,15 +537,17 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = let name = jsCard{"name"}.getStr if "poll" in name: if "image" in name: - result.photos.add jsCard{"binding_values", "image_large"}.getImageVal + result.media.addMedia(Photo( + url: jsCard{"binding_values", "image_large"}.getImageVal + )) result.poll = some parsePoll(jsCard) elif name == "amplify": - result.video = some(parsePromoVideo(jsCard{"binding_values"})) - else: + result.media.addMedia(parsePromoVideo(jsCard{"binding_values"})) + elif name.len > 0 and jsCard{"binding_values"}.notNull: result.card = some parseCard(jsCard, js{"entities", "urls"}) - result.expandTweetEntities(js) + result.expandTweetEntities(js, hasArticle) parseLegacyMediaEntities(js, result) with jsWithheld, js{"withheld_in_countries"}: @@ -357,7 +564,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = result.text.removeSuffix(" Learn more.") result.available = false -proc parseGraphTweet(js: JsonNode): Tweet = +proc parseGraphTweet*(js: JsonNode): Tweet = if js.kind == JNull: return Tweet() @@ -375,7 +582,7 @@ proc parseGraphTweet(js: JsonNode): Tweet = else: discard - if not js.hasKey("legacy"): + if "legacy" notin js and "rest_id" notin js: return Tweet() var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"}) @@ -394,12 +601,80 @@ proc parseGraphTweet(js: JsonNode): Tweet = "binding_values": %bindingObj } - result = parseTweet(js{"legacy"}, jsCard) - result.id = js{"rest_id"}.getId + var replyId: int64 = 0 + 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"}) - if result.replyId == 0: - result.replyId = js{"reply_to_results", "rest_id"}.getId + if result.reply.len == 0: + with replyTo, js{"reply_to_user_results", "result", "core", "screen_name"}: + result.reply = @[replyTo.getStr] with count, js{"views", "count"}: result.stats.views = count.getStr("0").parseInt @@ -409,21 +684,58 @@ proc parseGraphTweet(js: JsonNode): Tweet = parseMediaEntities(js, result) - if result.quote.isSome: - result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"})) + # Hide card if it's redundant with attribution (same video shown via embed) + 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)) + 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] = + var checkedSection = false for t in ? js{"content", "items"}: let entryId = t.getEntryId - if "cursor-showmore" in entryId: - let cursor = t{"item", "content", "value"} - result.thread.cursor = cursor.getStr - result.thread.hasMore = true - elif "tweet" in entryId and "promoted" notin entryId: - with tweet, t.getTweetResult("item"): + if "tweet-" in entryId and "promoted" notin entryId: + if not checkedSection: + checkedSection = true + if getConvSection(t) == "RelatedTweet": + result.thread.related = true + + let tweet = t.getTweetResult("item") + if tweet.notNull: result.thread.content.add parseGraphTweet(tweet) let tweetDisplayType = select( @@ -432,11 +744,31 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = ) if tweetDisplayType.getStr == "SelfThread": result.self = true + else: + result.thread.content.add Tweet(id: entryId.getId) + elif "cursor-showmore" in entryId: + let cursor = t{"item", "content", "value"} + result.thread.cursor = cursor.getStr + result.thread.hasMore = true proc parseGraphTweetResult*(js: JsonNode): Tweet = with tweet, js{"data", "tweet_result", "result"}: 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 = result = Conversation(replies: Result[Chain](beginning: true)) @@ -452,7 +784,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = if i.getTypeName == "TimelineAddEntries": for e in i{"entries"}: let entryId = e.getEntryId - if entryId.startsWith("tweet"): + if entryId.startsWith("tweet-"): let tweetResult = getTweetResult(e) if tweetResult.notNull: let tweet = parseGraphTweet(tweetResult) @@ -460,11 +792,14 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = if not tweet.available: tweet.id = entryId.getId - if $tweet.id == tweetId: + if entryId.endsWith(tweetId): result.tweet = tweet else: result.before.content.add tweet - elif 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) if self: result.after = thread @@ -491,20 +826,54 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = ) 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] = with tweetResult, getTweetResult(e): - var tweet = parseGraphTweet(tweetResult) + let tweet = parseGraphTweet(tweetResult) if not tweet.available: tweet.id = e.getEntryId.getId result.add tweet return - for item in e{"content", "items"}: - with tweetResult, item.getTweetResult("item"): - var tweet = parseGraphTweet(tweetResult) - if not tweet.available: - tweet.id = item.getEntryId.getId - result.add tweet + for tweet in extractTweetsFromModuleItems(e{"content", "items"}): + result.add tweet proc parseGraphTimeline*(js: JsonNode; after=""): Profile = result = Profile(tweets: Timeline(beginning: after.len == 0)) @@ -519,12 +888,8 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile = for i in instructions: if i{"moduleItems"}.notNull: - for item in i{"moduleItems"}: - with tweetResult, item.getTweetResult("item"): - let tweet = parseGraphTweet(tweetResult) - if not tweet.available: - tweet.id = item.getEntryId.getId - result.tweets.content.add tweet + for tweet in extractTweetsFromModuleItems(i{"moduleItems"}): + result.tweets.content.add tweet continue if i{"entries"}.notNull: @@ -559,18 +924,13 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = for i in instructions: if i{"moduleItems"}.notNull: - for item in i{"moduleItems"}: - with tweetResult, item.getTweetResult("item"): - let t = parseGraphTweet(tweetResult) - if not t.available: - t.id = item.getEntryId.getId + for t in extractTweetsFromModuleItems(i{"moduleItems"}): + let photo = extractGalleryPhoto(t) + if photo.url.len > 0: + result.add photo - let photo = extractGalleryPhoto(t) - if photo.url.len > 0: - result.add photo - - if result.len == 16: - return + if result.len == 16: + return continue if i.getTypeName != "TimelineAddEntries": @@ -587,7 +947,7 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = if result.len == 16: 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) let instructions = select( @@ -603,19 +963,73 @@ proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] = for e in instruction{"entries"}: let entryId = e.getEntryId when T is Tweets: - if entryId.startsWith("tweet"): - with tweetRes, getTweetResult(e): - let tweet = parseGraphTweet(tweetRes) - if not tweet.available: - tweet.id = entryId.getId + if entryId.startsWith("tweet") or entryId.startsWith("search-grid"): + for tweet in extractTweetsFromEntry(e): result.content.add tweet elif T is User: if entryId.startsWith("user"): with userRes, e{"content", "itemContent"}: 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"): 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": if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"): 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 + diff --git a/src/parserutils.nim b/src/parserutils.nim index 72c50e1..4b8e0b4 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -17,7 +17,7 @@ let unReplace = "$1@$2" htRegex = re"(^|[^\w-_./?])([#$]|#)([\w_]+)" - htReplace = "$1$2$3" + htReplace = "$1$2$3" type ReplaceSliceKind = enum @@ -72,7 +72,6 @@ template getTypeName*(js: JsonNode): string = template getEntryId*(e: JsonNode): string = e{"entryId"}.getStr(e{"entry_id"}.getStr) - template parseTime(time: string; f: static string; flen: int): DateTime = if time.len != flen: return parse(time, f, utc()) @@ -89,11 +88,19 @@ proc getTimeFromMs*(js: JsonNode): DateTime = let seconds = ms div 1000 return fromUnix(seconds).utc() +proc getTimeFromMsStr*(js: JsonNode): DateTime = + var ms: int64 + try: ms = parseBiggestInt(js.getStr("0")) + except ValueError: return + if ms == 0: return + let seconds = ms div 1000 + return fromUnix(seconds).utc() + proc getId*(id: string): int64 {.inline.} = let start = id.rfind("-") - if start < 0: - return parseBiggestInt(id) - return parseBiggestInt(id[start + 1 ..< id.len]) + try: + parseBiggestInt(if start < 0: id else: id[start + 1 ..< id.len]) + except ValueError: 0'i64 proc getId*(js: JsonNode): int64 {.inline.} = case js.kind @@ -112,6 +119,9 @@ proc getImageStr*(js: JsonNode): string = template getImageVal*(js: JsonNode): string = js{"image_value", "url"}.getImageStr +template getExpandedUrl*(js: JsonNode; fallback=""): string = + js{"expanded_url"}.getStr(js{"url"}.getStr(fallback)) + proc getCardUrl*(js: JsonNode; kind: CardKind): string = result = js{"website_url"}.getStrVal if kind == promoVideoConvo: @@ -175,12 +185,16 @@ proc extractSlice(js: JsonNode): Slice[int] = result = js["indices"][0].getInt ..< js["indices"][1].getInt proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode; - textLen: int; hideTwitter = false) = + textLen: int; hideTwitter = false; + hideArticle = false) = let - url = js["expanded_url"].getStr + url = js.getExpandedUrl 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: result.add ReplaceSlice(kind: rkRemove, slice: slice) else: @@ -192,28 +206,41 @@ proc extractHashtags(result: var seq[ReplaceSlice]; js: JsonNode) = proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice]; 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 = - 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) - for i, rep in repls: - result.add $runes[extractLowerBound(i, i - 1) ..< rep.slice.a] + for i, rep in validRepls: + let lower = extractLowerBound(i, i - 1) + if lower < rep.slice.a: + result.add $runes[lower ..< rep.slice.a] case rep.kind of rkHashtag: - let - name = $runes[rep.slice.a.succ .. rep.slice.b] - symbol = $runes[rep.slice.a] - result.add a(symbol & name, href = "/search?q=%23" & name) + if rep.slice.a.succ <= rep.slice.b: + let + name = $runes[rep.slice.a.succ .. rep.slice.b] + symbol = $runes[rep.slice.a] + result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name) of rkMention: - result.add a($runes[rep.slice], href = rep.url, title = rep.display) + result.add a($runes[rep.slice], href = rep.url, title = escape(rep.display)) of rkUrl: - result.add a(rep.display, href = rep.url) + result.add a(escape(rep.display), href = rep.url) of rkRemove: discard - let rest = extractLowerBound(repls.len, ^1) ..< textSlice.b - if rest.a <= rest.b: + let rest = extractLowerBound(validRepls.len, ^1) ..< safeEnd + if rest.a >= 0 and rest.a <= rest.b and rest.b <= runeLen: result.add $runes[rest] proc deduplicate(s: var seq[ReplaceSlice]) = @@ -238,7 +265,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) = ent = ? js{"entities"} with urls, ent{"url", "urls"}: - user.website = urls[0]{"expanded_url"}.getStr + user.website = urls[0].getExpandedUrl var replacements = newSeq[ReplaceSlice]() @@ -254,7 +281,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) = .replacef(htRegex, htReplace) proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int]; - replyTo=""; hasRedundantLink=false) = + replyTo=""; hasRedundantLink=false; hasArticle=false) = let hasCard = tweet.card.isSome 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: 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: - get(tweet.card).url = u{"expanded_url"}.getStr + get(tweet.card).url = u.getExpandedUrl with media, entities{"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) -proc expandTweetEntities*(tweet: Tweet; js: JsonNode) = +proc expandTweetEntities*(tweet: Tweet; js: JsonNode; hasArticle=false) = let entities = ? js{"entities"} textRange = js{"display_text_range"} @@ -316,23 +344,99 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) = replyTo = reply.getStr 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) = let entities = ? js{"entity_set"} text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose)) 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)) +proc expandBirdwatchEntities*(text: string; entities: JsonNode): string = + let runes = text.toRunes + var replacements: seq[ReplaceSlice] + + for entity in entities: + let + fromIdx = entity{"from_index"}.getInt + toIdx = entity{"to_index"}.getInt + url = entity{"ref", "url"}.getStr + if url.len > 0: + replacements.add ReplaceSlice( + kind: rkUrl, + slice: fromIdx ..< toIdx, + url: url, + display: $runes[fromIdx ..< min(toIdx, runes.len)] + ) + + replacements.sort(cmp) + result = runes.replacedWith(replacements, 0 ..< runes.len) + proc extractGalleryPhoto*(t: Tweet): GalleryPhoto = let url = - if t.photos.len > 0: t.photos[0] - elif t.video.isSome: get(t.video).thumb - elif t.gif.isSome: get(t.gif).thumb + if t.media.len > 0: t.media[0].getThumb elif t.card.isSome: get(t.card).image else: "" diff --git a/src/prefs.nim b/src/prefs.nim index fa40a6d..1a75f75 100644 --- a/src/prefs.nim +++ b/src/prefs.nim @@ -1,22 +1,22 @@ # SPDX-License-Identifier: AGPL-3.0-only -import tables +import tables, strutils import types, prefs_impl from config import get from parsecfg import nil -export genUpdatePrefs, genResetPrefs +export genUpdatePrefs, genResetPrefs, genApplyPrefs var defaultPrefs*: Prefs proc updateDefaultPrefs*(cfg: parsecfg.Config) = genDefaultPrefs() -proc getPrefs*(cookies: Table[string, string]): Prefs = +proc getPrefs*(cookies, params: Table[string, string]): Prefs = result = defaultPrefs - genCookiePrefs(cookies) + genParsePrefs(cookies) + genParsePrefs(params) -template getPref*(cookies: Table[string, string], pref): untyped = - bind genCookiePref - var res = defaultPrefs.`pref` - genCookiePref(cookies, pref, res) - res +proc encodePrefs*(prefs: Prefs): string = + var encPairs: seq[string] + genEncodePrefs(prefs) + encPairs.join(",") diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index 8e2ac8f..8519bd5 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -60,6 +60,9 @@ genPrefs: stickyProfile(checkbox, true): "Make profile sidebar stick to top" + stickyNav(checkbox, true): + "Keep navbar fixed to top" + bidiSupport(checkbox, false): "Support bidirectional text (makes clicking on tweets harder)" @@ -75,6 +78,12 @@ genPrefs: hideReplies(checkbox, false): "Hide tweet replies" + hideRelated(checkbox, true): + "Hide related tweets under replies" + + hideCommunityNotes(checkbox, false): + "Hide community notes" + squareAvatars(checkbox, false): "Square profile pictures" @@ -94,6 +103,17 @@ genPrefs: autoplayGifs(checkbox, true): "Autoplay gifs" + compactGallery(checkbox, false): + "Compact media gallery (no profile info or text)" + + gallerySize(select, "Medium"): + "Gallery column size" + options: @["Small", "Medium", "Large"] + + mediaView(select, "Timeline"): + "Default media view" + options: @["Timeline", "Grid", "Gallery"] + "Link replacements (blank to disable)": replaceTwitter(input, ""): "Twitter -> Nitter" @@ -127,7 +147,7 @@ macro genDefaultPrefs*(): untyped = result.add quote do: defaultPrefs.`ident` = cfg.get("Preferences", `name`, `default`) -macro genCookiePrefs*(cookies): untyped = +macro genParsePrefs*(prefs): untyped = result = nnkStmtList.newTree() for pref in allPrefs(): let @@ -137,37 +157,17 @@ macro genCookiePrefs*(cookies): untyped = options = pref.options result.add quote do: - if `name` in `cookies`: + if `name` in `prefs`: when `kind` == input or `name` == "theme": - result.`ident` = `cookies`[`name`] + result.`ident` = `prefs`[`name`] elif `kind` == checkbox: - result.`ident` = `cookies`[`name`] == "on" + result.`ident` = `prefs`[`name`] == "on" or + `prefs`[`name`] == "true" or + `prefs`[`name`] == "1" else: - let value = `cookies`[`name`] + let value = `prefs`[`name`] if value in `options`: result.`ident` = value -macro genCookiePref*(cookies, prefName, res): untyped = - result = nnkStmtList.newTree() - for pref in allPrefs(): - let ident = ident(pref.name) - if ident != prefName: - continue - - let - name = pref.name - kind = newLit(pref.kind) - options = pref.options - - result.add quote do: - if `name` in `cookies`: - when `kind` == input or `name` == "theme": - `res` = `cookies`[`name`] - elif `kind` == checkbox: - `res` = `cookies`[`name`] == "on" - else: - let value = `cookies`[`name`] - if value in `options`: `res` = value - macro genUpdatePrefs*(): untyped = result = nnkStmtList.newTree() let req = ident("request") @@ -202,6 +202,36 @@ macro genResetPrefs*(): untyped = result.add quote do: savePref(`name`, "", `req`, expire=true) +macro genEncodePrefs*(prefs): untyped = + result = nnkStmtList.newTree() + for pref in allPrefs(): + let + name = newLit(pref.name) + ident = ident(pref.name) + kind = newLit(pref.kind) + defaultIdent = nnkDotExpr.newTree(ident("defaultPrefs"), ident(pref.name)) + + result.add quote do: + when `kind` == checkbox: + if `prefs`.`ident` != `defaultIdent`: + if `prefs`.`ident`: + encPairs.add `name` & "=on" + else: + encPairs.add `name` & "=" + else: + if `prefs`.`ident` != `defaultIdent`: + encPairs.add `name` & "=" & `prefs`.`ident` + +macro genApplyPrefs*(params, req): untyped = + result = nnkStmtList.newTree() + for pref in allPrefs(): + let name = newLit(pref.name) + result.add quote do: + if `name` in `params`: + savePref(`name`, `params`[`name`], `req`) + else: + savePref(`name`, "", `req`, expire=true) + macro genPrefsType*(): untyped = let name = nnkPostfix.newTree(ident("*"), ident("Prefs")) result = quote do: diff --git a/src/query.nim b/src/query.nim index 06e1da2..ecb428d 100644 --- a/src/query.nim +++ b/src/query.nim @@ -1,15 +1,14 @@ # SPDX-License-Identifier: AGPL-3.0-only import strutils, strformat, sequtils, tables, uri -import types +import types, utils const validFilters* = @[ "media", "images", "twimg", "videos", - "native_video", "consumer_video", "pro_video", + "native_video", "consumer_video", "spaces", "links", "news", "quote", "mentions", - "replies", "retweets", "nativeretweets", - "verified", "safe" + "replies", "retweets", "nativeretweets" ] emptyQuery* = "include:nativeretweets" @@ -21,32 +20,43 @@ template `@`(param: string): untyped = proc initQuery*(pms: Table[string, string]; name=""): Query = result = Query( kind: parseEnum[QueryKind](@"f", tweets), + view: @"view", text: @"q", filters: validFilters.filterIt("f-" & it in pms), excludes: validFilters.filterIt("e-" & it in pms), since: @"since", until: @"until", - near: @"near" + minLikes: validateNumber(@"min_faves") ) + # articles is an internal tab kind, not a valid search filter + if result.kind == QueryKind.articles: + result.kind = tweets + if name.len > 0: result.fromUser = name.split(",") proc getMediaQuery*(name: string): Query = Query( - kind: media, + kind: QueryKind.media, filters: @["twimg", "native_video"], fromUser: @[name], sep: "OR" ) +proc getArticlesQuery*(name: string): Query = + Query( + kind: QueryKind.articles, + fromUser: @[name] + ) + proc getReplyQuery*(name: string): Query = Query( kind: replies, fromUser: @[name] ) -proc genQueryParam*(query: Query): string = +proc genQueryParam*(query: Query; maxId=""): string = var filters: seq[string] param: string @@ -55,15 +65,20 @@ proc genQueryParam*(query: Query): string = return query.text for i, user in query.fromUser: - param &= &"from:{user} " - if i < query.fromUser.high: - param &= "OR " + if i == 0: + param = "(" - if query.fromUser.len > 0 and query.kind in {posts, media}: - param &= "filter:self_threads OR -filter:replies " + param &= &"from:{user}" + 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: - param &= "include:nativeretweets " + param &= " include:nativeretweets" for f in query.filters: filters.add "filter:" & f @@ -73,38 +88,51 @@ proc genQueryParam*(query: Query): string = for i in query.includes: filters.add "include:" & i - result = strip(param & filters.join(&" {query.sep} ")) + if filters.len > 0: + result = strip(param & " (" & filters.join(&" {query.sep} ") & ")") + else: + result = strip(param) + if query.since.len > 0: result &= " since:" & query.since - if query.until.len > 0: + if query.until.len > 0 and maxId.len == 0: result &= " until:" & query.until - if query.near.len > 0: - result &= &" near:\"{query.near}\" within:15mi" + if query.minLikes.len > 0: + result &= " min_faves:" & query.minLikes if query.text.len > 0: if result.len > 0: result &= " " & query.text else: result = query.text + if result.len > 0 and maxId.len > 0: + result &= " max_id:" & maxId + proc genQueryUrl*(query: Query): string = - if query.kind notin {tweets, users}: return + var params: seq[string] - var params = @[&"f={query.kind}"] - if query.text.len > 0: - params.add "q=" & encodeUrl(query.text) - for f in query.filters: - params.add &"f-{f}=on" - for e in query.excludes: - params.add &"e-{e}=on" - for i in query.includes.filterIt(it != "nativeretweets"): - params.add &"i-{i}=on" + if query.view.len > 0: + params.add "view=" & encodeUrl(query.view) - if query.since.len > 0: - params.add "since=" & query.since - if query.until.len > 0: - params.add "until=" & query.until - if query.near.len > 0: - params.add "near=" & query.near + # media doubles as the profile media tab, where f isn't part of the URL scheme + if query.kind in {tweets, users, lists, top} or + (query.kind == QueryKind.media and query.fromUser.len == 0): + params.add &"f={query.kind}" + if query.text.len > 0: + params.add "q=" & encodeUrl(query.text) + for f in query.filters: + params.add &"f-{f}=on" + for e in query.excludes: + params.add &"e-{e}=on" + for i in query.includes.filterIt(it != "nativeretweets"): + params.add &"i-{i}=on" + + if query.since.len > 0: + params.add "since=" & query.since + if query.until.len > 0: + params.add "until=" & query.until + if query.minLikes.len > 0: + params.add "min_faves=" & query.minLikes if params.len > 0: result &= params.join("&") diff --git a/src/redis_cache.nim b/src/redis_cache.nim index 559d299..b9ddbcc 100644 --- a/src/redis_cache.nim +++ b/src/redis_cache.nim @@ -144,9 +144,10 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} = else: let user = await getGraphUserById(userId) result = user.username - await setEx(key, baseCacheTime, result) - if result.len > 0 and user.id.len > 0: - await all(cacheUserId(result, user.id), cache(user)) + if result.len > 0: + await setEx(key, baseCacheTime, result) + if user.id.len > 0: + await all(cacheUserId(result, user.id), cache(user)) # proc getCachedTweet*(id: int64): Future[Tweet] {.async.} = # if id == 0: return @@ -158,6 +159,48 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} = # if not result.isNil: # await cache(result) +proc cache*(data: Broadcast) {.async.} = + if data.id.len == 0: return + await setEx("bc:" & data.id, baseCacheTime, compress(toFlatty(data))) + +proc getCachedBroadcast*(id: string): Future[Broadcast] {.async.} = + if id.len == 0: return + let cached = await get("bc:" & id) + if cached != redisNil: + cached.deserialize(Broadcast) + else: + result = await getBroadcastInfo(id) + await cache(result) + result.m3u8Url = await fetchBroadcastStream(result.mediaKey) + +proc cache*(data: 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.} = if id.len == 0: return let rail = await get("pr2:" & toLower(id)) @@ -167,6 +210,29 @@ proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} = result = await getPhotoRail(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.} = let list = if id.len == 0: redisNil else: await get("l:" & id) diff --git a/src/routes/article.nim b/src/routes/article.nim new file mode 100644 index 0000000..0a7d1dc --- /dev/null +++ b/src/routes/article.nim @@ -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") diff --git a/src/routes/broadcast.nim b/src/routes/broadcast.nim new file mode 100644 index 0000000..d3bb95a --- /dev/null +++ b/src/routes/broadcast.nim @@ -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 diff --git a/src/routes/community.nim b/src/routes/community.nim new file mode 100644 index 0000000..b850b6b --- /dev/null +++ b/src/routes/community.nim @@ -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)) diff --git a/src/routes/embed.nim b/src/routes/embed.nim index 994364b..24bba2d 100644 --- a/src/routes/embed.nim +++ b/src/routes/embed.nim @@ -1,29 +1,79 @@ # SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, strutils, strformat, options +import asyncdispatch, strutils, strformat, json import jester, karax/vdom -import ".."/[types, api] +import ".."/[types, api, formatters] import ../views/[embed, tweet, general] +include "../views/oembed.nimf" import 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) = router embed: get "/i/videos/tweet/@id": - let tweet = await getGraphTweetResult(@"id") - if tweet == nil or tweet.video.isNone: - resp Http404 + let + id = @"id" + 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) get "/@user/status/@id/embed": let - tweet = await getGraphTweetResult(@"id") - prefs = cookiePrefs() + id = @"id" + user = @"user" + tweet = await getTweetByRestId(id) + prefs = requestPrefs() path = getPath() if tweet == nil: - resp Http404 + resp renderErrorEmbed("Tweet not found", prefs, cfg, request, + tweetId=id, username=user) resp renderTweetEmbed(tweet, path, prefs, cfg, request) @@ -34,3 +84,57 @@ proc createEmbedRouter*(cfg: Config) = redirect(&"/i/status/{id}/embed") else: 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 diff --git a/src/routes/list.nim b/src/routes/list.nim index ac3e97e..b4ab091 100644 --- a/src/routes/list.nim +++ b/src/routes/list.nim @@ -13,7 +13,7 @@ template respList*(list, timeline, title, vnode: typed) = let html = renderList(vnode, timeline.query, list) - rss = &"""/i/lists/{@"id"}/rss""" + rss = if cfg.enableRSSList: &"""/i/lists/{@"id"}/rss""" else: "" resp renderMain(html, request, cfg, prefs, titleText=title, rss=rss, banner=list.banner) @@ -36,7 +36,7 @@ proc createListRouter*(cfg: Config) = get "/i/lists/@id/?": cond '.' notin @"id" let - prefs = cookiePrefs() + prefs = requestPrefs() list = await getCachedList(id=(@"id")) timeline = await getGraphListTweets(list.id, getCursor()) vnode = renderTimelineTweets(timeline, prefs, request.path) @@ -45,7 +45,7 @@ proc createListRouter*(cfg: Config) = get "/i/lists/@id/members": cond '.' notin @"id" let - prefs = cookiePrefs() + prefs = requestPrefs() list = await getCachedList(id=(@"id")) members = await getGraphListMembers(list, getCursor()) respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path)) diff --git a/src/routes/media.nim b/src/routes/media.nim index de51061..3442916 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -1,6 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only import uri, strutils, httpclient, os, hashes, base64, re import asynchttpserver, asyncstreams, asyncfile, asyncnet +import asyncdispatch import jester @@ -15,7 +16,9 @@ const maxAge* = "max-age=604800" 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) except: discard finally: client.close() @@ -30,47 +33,79 @@ template respond*(req: asynchttpserver.Request; headers) = proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} = result = Http200 - let - request = req.getNativeReq() - client = newAsyncHttpClient() + let request = req.getNativeReq() + var fetchUrl = url - try: - let res = await client.get(url) - if res.status != "200 OK": - if res.status != "404 Not Found": - echo "[media] Proxying failed, status: $1, url: $2" % [res.status, url] - return Http404 - - let hashed = $hash(url) - if request.headers.getOrDefault("If-None-Match") == hashed: - return Http304 - - let contentLength = - if res.headers.hasKey("content-length"): - res.headers["content-length", 0] + for attempt in 0 .. 2: + let client = newAsyncHttpClient(maxRedirects = 0) + var shouldRetry = false + try: + let resFut = client.get(fetchUrl) + let completed = await withTimeout(resFut, 5000) + if not completed: + if attempt < 2: + echo "[media] Retry $1/2, timeout after 5s, url: $2" % [$(attempt + 1), fetchUrl] + shouldRetry = true + else: + echo "[media] Proxying timeout after 5s, url: $1" % [fetchUrl] + return Http504 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({ - "Content-Type": res.headers["content-type", 0], - "Content-Length": contentLength, - "Cache-Control": maxAge, - "ETag": hashed - }) + let contentLength = + if res.headers.hasKey("content-length"): + res.headers["content-length", 0] + else: + "" - 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, "") - while hasValue: - (hasValue, data) = await res.bodyStream.read() - if hasValue: - await request.client.send(data) - data.setLen 0 - except HttpRequestError, ProtocolError, OSError: - echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url] - result = Http404 - finally: - client.close() + respond(request, headers) + + var (hasValue, data) = (true, "") + while hasValue: + (hasValue, data) = await res.bodyStream.read() + if hasValue: + await request.client.send(data) + data.setLen 0 + return Http200 + except CatchableError: + 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 = if code != Http200: @@ -86,6 +121,12 @@ proc decoded*(req: jester.Request; index: int): string = if based: decode(encoded) else: decodeUrl(encoded) +proc normalizeImgUrl*(url: var string) = + if not url.startsWith("http"): + if "twimg.com" notin url: + url.insert(twimg) + url.insert(https) + proc createMediaRouter*(cfg: Config) = router media: get "/pic/?": @@ -93,10 +134,8 @@ proc createMediaRouter*(cfg: Config) = get re"^\/pic\/orig\/(enc)?\/?(.+)": var url = decoded(request, 1) - if "twimg.com" notin url: - url.insert(twimg) - if not url.startsWith(https): - url.insert(https) + cond "/amplify_video/" notin url + normalizeImgUrl(url) url.add("?name=orig") let uri = parseUri(url) @@ -107,10 +146,8 @@ proc createMediaRouter*(cfg: Config) = get re"^\/pic\/(enc)?\/?(.+)": var url = decoded(request, 1) - if "twimg.com" notin url: - url.insert(twimg) - if not url.startsWith(https): - url.insert(https) + cond "/amplify_video/" notin url + normalizeImgUrl(url) let uri = parseUri(url) cond isTwitterUrl(uri) == true @@ -120,12 +157,12 @@ proc createMediaRouter*(cfg: Config) = get re"^\/video\/(enc)?\/?(.+)\/(.+)$": let url = decoded(request, 2) - cond "http" in url + cond isTwitterUrl(url) if getHmac(url) != request.matches[1]: 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) check code @@ -139,6 +176,6 @@ proc createMediaRouter*(cfg: Config) = if ".m3u8" in url: let vid = await safeFetch(url) - content = proxifyVideo(vid, cookiePref(proxyVideos)) + content = proxifyVideo(vid, requestPrefs().proxyVideos, url) resp content, m3u8Mime diff --git a/src/routes/preferences.nim b/src/routes/preferences.nim index b8af03d..7f04de2 100644 --- a/src/routes/preferences.nim +++ b/src/routes/preferences.nim @@ -19,8 +19,10 @@ proc createPrefRouter*(cfg: Config) = router preferences: get "/settings": let - prefs = cookiePrefs() - html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir)) + prefs = requestPrefs() + prefsCode = encodePrefs(prefs) + prefsUrl = getUrlPrefix(cfg) & "/?prefs=" & prefsCode + html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir), prefsUrl) resp renderMain(html, request, cfg, prefs, "Preferences") get "/settings/@i?": @@ -38,3 +40,6 @@ proc createPrefRouter*(cfg: Config) = savePref("hlsPlayback", "on", request) redirect(refPath()) + post "/enablemp4": + savePref("mp4Playback", "on", request) + redirect(refPath()) diff --git a/src/routes/resolver.nim b/src/routes/resolver.nim index 1baf873..5f074a5 100644 --- a/src/routes/resolver.nim +++ b/src/routes/resolver.nim @@ -18,8 +18,8 @@ proc createResolverRouter*(cfg: Config) = router resolver: get "/cards/@card/@id": let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"] - respResolved(await resolve(url, cookiePrefs()), "card") + respResolved(await resolve(url, requestPrefs()), "card") get "/t.co/@url": let url = "https://t.co/" & @"url" - respResolved(await resolve(url, cookiePrefs()), "t.co") + respResolved(await resolve(url, requestPrefs()), "t.co") diff --git a/src/routes/router_utils.nim b/src/routes/router_utils.nim index a071a0d..612a96b 100644 --- a/src/routes/router_utils.nim +++ b/src/routes/router_utils.nim @@ -4,26 +4,19 @@ from jester import Request, cookies import ../views/general import ".."/[utils, prefs, types] -export utils, prefs, types, uri +export utils, prefs, types, uri, json template savePref*(pref, value: string; req: Request; expire=false) = 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), - httpOnly=true, secure=cfg.useHttps, sameSite=None) + httpOnly=true, secure=cfg.useHttps, sameSite=sameSite, path="/") -template cookiePrefs*(): untyped {.dirty.} = - getPrefs(cookies(request)) - -template cookiePref*(pref): untyped {.dirty.} = - getPref(cookies(request), pref) - -template themePrefs*(): Prefs = - var res = defaultPrefs - res.theme = cookiePref(theme) - res +template requestPrefs*(): untyped {.dirty.} = + getPrefs(cookies(request), params(request)) template showError*(error: string; cfg: Config): string = - renderMain(renderError(error), request, cfg, themePrefs(), "Error") + renderMain(renderError(error), request, cfg, requestPrefs(), "Error") template getPath*(): untyped {.dirty.} = $(parseUri(request.path) ? filterParams(request.params)) @@ -43,5 +36,28 @@ template getCursor*(req: Request): string = proc getNames*(name: string): seq[string] = name.strip(chars={'/'}).split(",").filterIt(it.len > 0) +template applyUrlPrefs*() {.dirty.} = + if @"prefs".len > 0: + var prefParams = initTable[string, string]() + for pair in @"prefs".split(','): + let kv = pair.split('=', maxsplit=1) + if kv.len == 2: + prefParams[kv[0]] = kv[1] + elif kv.len == 1 and kv[0].len > 0: + prefParams[kv[0]] = "" + genApplyPrefs(prefParams, request) + + # Rebuild URL without prefs param + var params: seq[(string, string)] + for k, v in request.params: + if k != "prefs": + params.add (k, v) + + if params.len > 0: + let cleanUrl = request.getNativeReq.url ? params + redirect($cleanUrl) + else: + redirect(request.path) + template respJson*(node: JsonNode) = resp $node, "application/json" diff --git a/src/routes/rss.nim b/src/routes/rss.nim index b0e781d..038096c 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -15,7 +15,7 @@ proc redisKey*(page, name, cursor: string): string = if cursor.len > 0: result &= ":" & cursor -proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.} = +proc timelineRss*(req: Request; cfg: Config; query: Query; prefs: Prefs): Future[Rss] {.async.} = var profile: Profile let name = req.params.getOrDefault("name") @@ -39,7 +39,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async. return Rss(feed: profile.user.username, cursor: "suspended") if profile.user.fullname.len > 0: - let rss = renderTimelineRss(profile, cfg, multi=(names.len > 1)) + let rss = renderTimelineRss(profile, cfg, prefs, multi=(names.len > 1)) return Rss(feed: rss, cursor: profile.tweets.bottom) template respRss*(rss, page) = @@ -60,12 +60,15 @@ template respRss*(rss, page) = proc createRssRouter*(cfg: Config) = router rss: get "/search/rss": - cond cfg.enableRss + if not cfg.enableRSSSearch: + resp Http403, showError("RSS feed is disabled", cfg) if @"q".len > 200: resp Http400, showError("Search input too long.", cfg) - let query = initQuery(params(request)) - if query.kind != tweets: + let + 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) let @@ -78,15 +81,17 @@ proc createRssRouter*(cfg: Config) = let tweets = await getGraphTweetSearch(query, cursor) rss.cursor = tweets.bottom - rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg) + rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg, prefs) await cacheRss(key, rss) respRss(rss, "Search") get "/@name/rss": - cond cfg.enableRss cond '.' notin @"name" + if not cfg.enableRSSUserTweets: + resp Http403, showError("RSS feed is disabled", cfg) let + prefs = requestPrefs() name = @"name" key = redisKey("twitter", name, getCursor()) @@ -94,24 +99,23 @@ proc createRssRouter*(cfg: Config) = if rss.cursor.len > 0: respRss(rss, "User") - rss = await timelineRss(request, cfg, Query(fromUser: @[name])) + rss = await timelineRss(request, cfg, Query(fromUser: @[name]), prefs) await cacheRss(key, rss) respRss(rss, "User") get "/@name/@tab/rss": - cond cfg.enableRss cond '.' notin @"name" - cond @"tab" in ["with_replies", "media", "search"] + 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 + prefs = requestPrefs() name = @"name" tab = @"tab" - query = - case tab - of "with_replies": getReplyQuery(name) - of "media": getMediaQuery(name) - of "search": initQuery(params(request), name=name) - else: Query(fromUser: @[name]) + query = request.getQuery(tab, name, prefs) let searchKey = if tab != "search": "" else: ":" & $hash(genQueryUrl(query)) @@ -122,14 +126,15 @@ proc createRssRouter*(cfg: Config) = if rss.cursor.len > 0: respRss(rss, "User") - rss = await timelineRss(request, cfg, query) + rss = await timelineRss(request, cfg, query, prefs) await cacheRss(key, rss) respRss(rss, "User") get "/@name/lists/@slug/rss": - cond cfg.enableRss cond @"name" != "i" + if not cfg.enableRSSList: + resp Http403, showError("RSS feed is disabled", cfg) let slug = decodeUrl(@"slug") list = await getCachedList(@"name", slug) @@ -145,8 +150,10 @@ proc createRssRouter*(cfg: Config) = redirect(url) get "/i/lists/@id/rss": - cond cfg.enableRss + if not cfg.enableRSSList: + resp Http403, showError("RSS feed is disabled", cfg) let + prefs = requestPrefs() id = @"id" cursor = getCursor() key = redisKey("lists", id, cursor) @@ -159,7 +166,7 @@ proc createRssRouter*(cfg: Config) = list = await getCachedList(id=id) timeline = await getGraphListTweets(list.id, cursor) rss.cursor = timeline.bottom - rss.feed = renderListRss(timeline.content, list, cfg) + rss.feed = renderListRss(timeline.content, list, cfg, prefs) await cacheRss(key, rss) respRss(rss, "List") diff --git a/src/routes/search.nim b/src/routes/search.nim index e9f991d..7c7fd14 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -19,10 +19,22 @@ proc createSearchRouter*(cfg: Config) = resp Http400, showError("Search input too long.", cfg) let - prefs = cookiePrefs() - query = initQuery(params(request)) + prefs = requestPrefs() 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 of users: if "," in q: @@ -33,19 +45,24 @@ proc createSearchRouter*(cfg: Config) = except InternalError: users = Result[User](beginning: true, query: query) resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) - of tweets: + of tweets, top, QueryKind.media: let tweets = await getGraphTweetSearch(query, getCursor()) - rss = "/search/rss?" & genQueryUrl(query) + rss = if cfg.enableRSSSearch: "/search/rss?" & genQueryUrl(query) else: "" resp renderMain(renderTweetSearch(tweets, prefs, getPath()), request, cfg, prefs, title, rss=rss) + of lists: + let listResults = await getGraphListSearch(query, getCursor()) + resp renderMain(renderListSearch(listResults, prefs, getPath()), + request, cfg, prefs, title) else: resp Http404, showError("Invalid search", cfg) get "/hashtag/@hash": - redirect("/search?q=" & encodeUrl("#" & @"hash")) + redirect("/search?f=tweets&q=" & encodeUrl("#" & @"hash")) get "/opensearch": - let url = getUrlPrefix(cfg) & "/search?q=" - resp Http200, {"Content-Type": "application/opensearchdescription+xml"}, - generateOpenSearchXML(cfg.title, cfg.hostname, url) + let + url = getUrlPrefix(cfg) & "/search?f=tweets&q=" + headers = {"Content-Type": "application/opensearchdescription+xml"} + resp Http200, headers, generateOpenSearchXML(cfg.title, cfg.hostname, url) diff --git a/src/routes/space.nim b/src/routes/space.nim new file mode 100644 index 0000000..bd956ea --- /dev/null +++ b/src/routes/space.nim @@ -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 diff --git a/src/routes/status.nim b/src/routes/status.nim index 0168dac..32a4447 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -21,16 +21,18 @@ proc createStatusRouter*(cfg: Config) = if id.len > 19 or id.any(c => not c.isDigit): resp Http404, showError("Invalid tweet ID", cfg) - let prefs = cookiePrefs() + let + prefs = requestPrefs() + sort = parseEnum[RankingMode](@"sort".toLowerAscii.capitalizeAscii, Relevance) # used for the infinite scroll feature if @"scroll".len > 0: - let replies = await getReplies(id, getCursor()) + let replies = await getReplies(id, getCursor(), sort) if replies.content.len == 0: - resp Http404, "" - resp $renderReplies(replies, prefs, getPath()) + resp Http204 + resp $renderReplies(replies, prefs, getPath(), sort=sort) - let conv = await getTweet(id, getCursor()) + let conv = await getTweet(id, getCursor(), sort) if conv == nil or conv.tweet == nil or conv.tweet.id == 0: var error = "Tweet not found" @@ -44,15 +46,19 @@ proc createStatusRouter*(cfg: Config) = desc = conv.tweet.text var - images = conv.tweet.photos + images = conv.tweet.getPhotos.mapIt(it.url) video = "" - if conv.tweet.video.isSome(): - images = @[get(conv.tweet.video).thumb] + let + firstMediaKind = if conv.tweet.media.len > 0: conv.tweet.media[0].kind + else: photoMedia + + if firstMediaKind == videoMedia: + images = @[conv.tweet.media[0].getThumb] video = getVideoEmbed(cfg, conv.tweet.id) - elif conv.tweet.gif.isSome(): - images = @[get(conv.tweet.gif).thumb] - video = getPicUrl(get(conv.tweet.gif).url) + elif firstMediaKind == gifMedia: + images = @[conv.tweet.media[0].getThumb] + video = getPicUrl(conv.tweet.media[0].gif.url) elif conv.tweet.card.isSome(): let card = conv.tweet.card.get() if card.image.len > 0: @@ -60,13 +66,37 @@ proc createStatusRouter*(cfg: Config) = elif card.video.isSome(): 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, - 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?": cond @"s" in ["status", "statuses"] - cond @"m" in ["video", "photo", "history"] + cond @"m" in ["video", "photo"] redirect("/$1/status/$2" % [@"name", @"id"]) get "/@name/statuses/@id/?": diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 7a10e91..c7949a2 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -4,20 +4,39 @@ import jester, karax/vdom import router_utils import ".."/[types, redis_cache, formatters, query, api] -import ../views/[general, profile, timeline, status, search] +import ../views/[general, profile, timeline, status, search, about_account] export vdom export uri, sequtils export router_utils export redis_cache, formatters, query, api -export profile, timeline, status +export profile, timeline, status, about_account -proc getQuery*(request: Request; tab, name: string): Query = +proc tabRssEnabled*(cfg: Config; tab: string): bool = case tab - of "with_replies": getReplyQuery(name) - of "media": getMediaQuery(name) - of "search": initQuery(params(request), name=name) - else: Query(fromUser: @[name]) + of "": cfg.enableRSSUserTweets + of "with_replies": cfg.enableRSSUserReplies + of "media": cfg.enableRSSUserMedia + 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] = if cond: @@ -45,20 +64,23 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile] let rail = - skipIf(skipRail or query.kind == media, @[]): + skipIf(skipRail or query.kind == QueryKind.media, @[]): getCachedPhotoRail(userId) user = getCachedUser(name) + info = getCachedAccountInfo(name, fetch=false) result = case query.kind of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after) of replies: await getGraphUserTweets(userId, TimelineKind.replies, 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)) result.user = await user result.photoRail = await rail + result.accountInfo = await info result.tweets.query = query @@ -105,16 +127,72 @@ proc createTimelineRouter*(cfg: Config) = get "/intent/user": respUserId() + get "/intent/follow/?": + let username = request.params.getOrDefault("screen_name") + if username.len == 0: + resp Http400, showError("Missing screen_name parameter", cfg) + redirect("/" & username) + + get "/@name/about/?": + cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'}) + let + prefs = requestPrefs() + name = @"name" + info = await getCachedAccountInfo(name) + if info.suspended: + resp showError(getSuspended(name), cfg) + if info.username.len == 0: + resp Http404, showError("User \"" & name & "\" not found", cfg) + let aboutHtml = renderAboutAccount(info) + resp renderMain(aboutHtml, request, cfg, prefs, + "About @" & info.username) + + get "/@name/@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?/?": cond '.' notin @"name" 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 - prefs = cookiePrefs() + prefs = requestPrefs() after = getCursor() names = getNames(@"name") - var query = request.getQuery(@"tab", @"name") + var query = request.getQuery(@"tab", @"name", prefs) if names.len != 1: query.fromUser = names @@ -122,7 +200,8 @@ proc createTimelineRouter*(cfg: Config) = if @"scroll".len > 0: if query.fromUser.len != 1: var timeline = await getGraphTweetSearch(query, after) - if timeline.content.len == 0: resp Http404 + if timeline.content.len == 0: + resp Http204 timeline.beginning = true resp $renderTweetSearch(timeline, prefs, getPath()) else: @@ -132,7 +211,9 @@ proc createTimelineRouter*(cfg: Config) = resp $renderTimelineTweets(profile.tweets, prefs, getPath()) let rss = - if @"tab".len == 0: + if not cfg.tabRssEnabled(@"tab"): + "" + elif @"tab".len == 0: "/$1/rss" % @"name" elif @"tab" == "search": "/$1/search/rss?$2" % [@"name", genQueryUrl(query)] diff --git a/src/routes/unsupported.nim b/src/routes/unsupported.nim index 0c085d4..345dee7 100644 --- a/src/routes/unsupported.nim +++ b/src/routes/unsupported.nim @@ -10,14 +10,14 @@ export feature proc createUnsupportedRouter*(cfg: Config) = router unsupported: template feature {.dirty.} = - resp renderMain(renderFeature(), request, cfg, themePrefs()) + resp renderMain(renderFeature(), request, cfg, requestPrefs()) get "/about/feature": feature() get "/login/?@i?": feature() get "/@name/lists/?": feature() get "/intent/?@i?": - cond @"i" notin ["user"] + cond @"i" notin ["user", "follow"] feature() get "/i/@i?/?@j?": diff --git a/src/sass/_article.scss b/src/sass/_article.scss new file mode 100644 index 0000000..9488658 --- /dev/null +++ b/src/sass/_article.scss @@ -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; + } + } + } +} diff --git a/src/sass/_broadcast.scss b/src/sass/_broadcast.scss new file mode 100644 index 0000000..dd93606 --- /dev/null +++ b/src/sass/_broadcast.scss @@ -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; +} diff --git a/src/sass/_space.scss b/src/sass/_space.scss new file mode 100644 index 0000000..5fe2e7e --- /dev/null +++ b/src/sass/_space.scss @@ -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; +} diff --git a/src/sass/general.scss b/src/sass/general.scss index 9feb3d3..e6247d6 100644 --- a/src/sass/general.scss +++ b/src/sass/general.scss @@ -1,39 +1,40 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; .panel-container { - margin: auto; - font-size: 130%; + margin: auto; + font-size: 130%; } .error-panel { - @include center-panel(var(--error_red)); - text-align: center; + @include center-panel(var(--error_red)); + text-align: center; } .search-bar > form { - @include center-panel(var(--darkest_grey)); + @include center-panel(var(--darkest_grey)); - button { - background: var(--bg_elements); - color: var(--fg_color); - border: 0; - border-radius: 3px; - cursor: pointer; - font-weight: bold; - width: 30px; - height: 30px; - } + button { + background: var(--bg_elements); + color: var(--fg_color); + border: 0; + border-radius: 3px; + cursor: pointer; + font-weight: bold; + width: 30px; + height: 30px; + padding: 0px 5px 1px 8px; + } - input { - font-size: 16px; - width: 100%; - background: var(--bg_elements); - color: var(--fg_color); - border: 0; - border-radius: 4px; - padding: 4px; - margin-right: 8px; - height: unset; - } + input { + font-size: 16px; + width: 100%; + background: var(--bg_elements); + color: var(--fg_color); + border: 0; + border-radius: 4px; + padding: 4px; + margin-right: 8px; + height: unset; + } } diff --git a/src/sass/include/_mixins.css b/src/sass/include/_mixins.css index 94e11ee..5fde51a 100644 --- a/src/sass/include/_mixins.css +++ b/src/sass/include/_mixins.css @@ -66,18 +66,7 @@ } #search-panel-toggle:checked ~ .search-panel { - @if $rows == 6 { - max-height: 200px !important; - } - @if $rows == 5 { - max-height: 300px !important; - } - @if $rows == 4 { - max-height: 300px !important; - } - @if $rows == 3 { - max-height: 365px !important; - } + max-height: 380px !important; } } } diff --git a/src/sass/include/_variables.scss b/src/sass/include/_variables.scss index 0c95ff6..127cccb 100644 --- a/src/sass/include/_variables.scss +++ b/src/sass/include/_variables.scss @@ -1,46 +1,43 @@ // colors -$bg_color: #0F0F0F; -$fg_color: #F8F8F2; -$fg_faded: #F8F8F2CF; -$fg_dark: #FF6C60; -$fg_nav: #FF6C60; +$bg_color: #0f0f0f; +$fg_color: #f8f8f2; +$fg_faded: #f8f8f2cf; +$fg_dark: #ff6c60; +$fg_nav: #ff6c60; $bg_panel: #161616; $bg_elements: #121212; -$bg_overlays: #1F1F1F; -$bg_hover: #1A1A1A; +$bg_overlays: #1f1f1f; +$bg_hover: #1a1a1a; $grey: #888889; $dark_grey: #404040; $darker_grey: #282828; $darkest_grey: #222222; -$border_grey: #3E3E35; +$border_grey: #3e3e35; -$accent: #FF6C60; -$accent_light: #FFACA0; -$accent_dark: #8A3731; -$accent_border: #FF6C6091; +$accent: #ff6c60; +$accent_light: #ffaca0; +$accent_dark: #8a3731; +$accent_border: #ff6c6091; -$play_button: #D8574D; -$play_button_hover: #FF6C60; +$play_button: #d8574d; +$play_button_hover: #ff6c60; -$more_replies_dots: #AD433B; -$error_red: #420A05; +$more_replies_dots: #ad433b; +$error_red: #420a05; -$verified_blue: #1DA1F2; -$verified_business: #FAC82B; -$verified_government: #C1B6A4; +$verified_blue: #1da1f2; +$verified_business: #fac82b; +$verified_government: #c1b6a4; $icon_text: $fg_color; $tab: $fg_color; $tab_selected: $accent; -$shadow: rgba(0,0,0,.6); -$shadow_dark: rgba(0,0,0,.2); +$shadow: rgba(0, 0, 0, 0.6); +$shadow_dark: rgba(0, 0, 0, 0.2); //fonts -$font_0: Helvetica Neue; -$font_1: Helvetica; -$font_2: Arial; -$font_3: sans-serif; -$font_4: fontello; +$font_0: sans-serif; +$font_1: fontello; diff --git a/src/sass/index.scss b/src/sass/index.scss index 6cab48e..404f7d5 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -1,180 +1,220 @@ -@import '_variables'; +@import "_variables"; -@import 'tweet/_base'; -@import 'profile/_base'; -@import 'general'; -@import 'navbar'; -@import 'inputs'; -@import 'timeline'; -@import 'search'; +@import "tweet/_base"; +@import "profile/_base"; +@import "general"; +@import "navbar"; +@import "inputs"; +@import "timeline"; +@import "search"; +@import "broadcast"; +@import "space"; +@import "_article"; body { - // colors - --bg_color: #{$bg_color}; - --fg_color: #{$fg_color}; - --fg_faded: #{$fg_faded}; - --fg_dark: #{$fg_dark}; - --fg_nav: #{$fg_nav}; + // colors + --bg_color: #{$bg_color}; + --fg_color: #{$fg_color}; + --fg_faded: #{$fg_faded}; + --fg_dark: #{$fg_dark}; + --fg_nav: #{$fg_nav}; - --bg_panel: #{$bg_panel}; - --bg_elements: #{$bg_elements}; - --bg_overlays: #{$bg_overlays}; - --bg_hover: #{$bg_hover}; + --bg_panel: #{$bg_panel}; + --bg_elements: #{$bg_elements}; + --bg_overlays: #{$bg_overlays}; + --bg_hover: #{$bg_hover}; - --grey: #{$grey}; - --dark_grey: #{$dark_grey}; - --darker_grey: #{$darker_grey}; - --darkest_grey: #{$darkest_grey}; - --border_grey: #{$border_grey}; + --grey: #{$grey}; + --dark_grey: #{$dark_grey}; + --darker_grey: #{$darker_grey}; + --darkest_grey: #{$darkest_grey}; + --border_grey: #{$border_grey}; - --accent: #{$accent}; - --accent_light: #{$accent_light}; - --accent_dark: #{$accent_dark}; - --accent_border: #{$accent_border}; + --accent: #{$accent}; + --accent_light: #{$accent_light}; + --accent_dark: #{$accent_dark}; + --accent_border: #{$accent_border}; - --play_button: #{$play_button}; - --play_button_hover: #{$play_button_hover}; + --play_button: #{$play_button}; + --play_button_hover: #{$play_button_hover}; - --more_replies_dots: #{$more_replies_dots}; - --error_red: #{$error_red}; + --more_replies_dots: #{$more_replies_dots}; + --error_red: #{$error_red}; - --verified_blue: #{$verified_blue}; - --verified_business: #{$verified_business}; - --verified_government: #{$verified_government}; - --icon_text: #{$icon_text}; + --verified_blue: #{$verified_blue}; + --verified_business: #{$verified_business}; + --verified_government: #{$verified_government}; + --icon_text: #{$icon_text}; - --tab: #{$fg_color}; - --tab_selected: #{$accent}; + --tab: #{$fg_color}; + --tab_selected: #{$accent}; - --profile_stat: #{$fg_color}; + --profile_stat: #{$fg_color}; - background-color: var(--bg_color); - color: var(--fg_color); - font-family: $font_0, $font_1, $font_2, $font_3; - font-size: 14px; - line-height: 1.3; - margin: 0; + background-color: var(--bg_color); + color: var(--fg_color); + font-family: $font_0, $font_1; + font-size: 15px; + line-height: 1.3; + margin: 0; } * { - outline: unset; - margin: 0; - text-decoration: none; + outline: unset; + margin: 0; + text-decoration: none; +} + +img { + dynamic-range-limit: standard; } h1 { - display: inline; + display: inline; } -h2, h3 { - font-weight: normal; +h2, +h3 { + font-weight: normal; } p { - margin: 14px 0; + margin: 14px 0; } a { - color: var(--accent); + color: var(--accent); - &:hover { - text-decoration: underline; - } + &:hover { + text-decoration: underline; + } } fieldset { - border: 0; - padding: 0; - margin-top: -0.6em; + border: 0; + padding: 0; + margin-top: -0.6em; } legend { - width: 100%; - padding: .6em 0 .3em 0; - border: 0; - font-size: 16px; - font-weight: 600; - border-bottom: 1px solid var(--border_grey); - margin-bottom: 8px; + width: 100%; + padding: 0.6em 0 0.3em 0; + border: 0; + font-size: 16px; + font-weight: 600; + border-bottom: 1px solid var(--border_grey); + margin-bottom: 8px; } -.preferences .note { +.preferences { + .note { border-top: 1px solid var(--border_grey); border-bottom: 1px solid var(--border_grey); padding: 6px 0 8px 0; margin-bottom: 8px; margin-top: 16px; + } + + .bookmark-note { + margin: 0; + margin-bottom: 10px; + } } ul { - padding-left: 1.3em; + padding-left: 1.3em; } .container { - display: flex; - flex-wrap: wrap; - box-sizing: border-box; - padding-top: 50px; - margin: auto; - min-height: 100vh; + display: flex; + flex-wrap: wrap; + box-sizing: border-box; + margin: auto; + min-height: 100vh; +} + +body.fixed-nav .container { + padding-top: 50px; } .icon-container { - display: inline; + display: inline; } .overlay-panel { - max-width: 600px; - width: 100%; - margin: 0 auto; - margin-top: 10px; - background-color: var(--bg_overlays); - padding: 10px 15px; - align-self: start; + max-width: 600px; + width: 100%; + margin: 0 auto; + margin-top: 10px; + background-color: var(--bg_overlays); + padding: 10px 15px; + align-self: start; - ul { - margin-bottom: 14px; - } + ul { + margin-bottom: 14px; + } - p { - word-break: break-word; - } + p { + word-break: break-word; + } } .verified-icon { - color: var(--icon_text); - border-radius: 50%; - flex-shrink: 0; - margin: 2px 0 3px 3px; - padding-top: 3px; - height: 11px; - width: 14px; - font-size: 8px; - display: inline-block; - text-align: center; - vertical-align: middle; + display: inline-block; + position: relative; + width: 14px; + height: 14px; + margin-bottom: 2px; - &.blue { - background-color: var(--verified_blue); + .verified-icon-circle { + position: absolute; + font-size: 15px; + } + + .verified-icon-check { + position: absolute; + font-size: 9px; + margin: 5px 3px; + } + + &.blue { + .verified-icon-circle { + color: var(--verified_blue); } - &.business { - color: var(--bg_panel); - background-color: var(--verified_business); + .verified-icon-check { + color: var(--icon_text); + } + } + + &.business { + .verified-icon-circle { + color: var(--verified_business); } - &.government { - color: var(--bg_panel); - background-color: var(--verified_government); + .verified-icon-check { + color: var(--bg_panel); } + } + + &.government { + .verified-icon-circle { + color: var(--verified_government); + } + + .verified-icon-check { + color: var(--bg_panel); + } + } } -@media(max-width: 600px) { - .preferences-container { - max-width: 95vw; - } +@media (max-width: 600px) { + .preferences-container { + max-width: 95vw; + } - .nav-item, .nav-item .icon-container { - font-size: 16px; - } + .nav-item, + .nav-item .icon-container { + font-size: 16px; + } } diff --git a/src/sass/inputs.scss b/src/sass/inputs.scss index 17c2a22..2b6016f 100644 --- a/src/sass/inputs.scss +++ b/src/sass/inputs.scss @@ -1,185 +1,216 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; button { - @include input-colors; - background-color: var(--bg_elements); - color: var(--fg_color); - border: 1px solid var(--accent_border); - padding: 3px 6px; - font-size: 14px; - cursor: pointer; - float: right; + @include input-colors; + background-color: var(--bg_elements); + color: var(--fg_color); + border: 1px solid var(--accent_border); + padding: 3px 6px; + font-size: 14px; + cursor: pointer; + float: right; } input[type="text"], input[type="date"], +input[type="number"], select { - @include input-colors; - background-color: var(--bg_elements); - padding: 1px 4px; - color: var(--fg_color); - border: 1px solid var(--accent_border); - border-radius: 0; - font-size: 14px; + @include input-colors; + background-color: var(--bg_elements); + padding: 1px 4px; + color: var(--fg_color); + border: 1px solid var(--accent_border); + border-radius: 0; + font-size: 14px; } -input[type="text"] { - height: 16px; +input[type="number"] { + -moz-appearance: textfield; +} + +input[type="text"], +input[type="number"] { + height: 16px; } select { - height: 20px; - padding: 0 2px; - line-height: 1; + height: 20px; + padding: 0 2px; + line-height: 1; } input[type="date"]::-webkit-inner-spin-button { - display: none; + display: none; +} + +input[type="number"] { + -moz-appearance: textfield; +} + +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + display: none; + -webkit-appearance: none; + margin: 0; } input[type="date"]::-webkit-clear-button { - margin-left: 17px; - filter: grayscale(100%); - filter: hue-rotate(120deg); + margin-left: 17px; + filter: grayscale(100%); + filter: hue-rotate(120deg); } input::-webkit-calendar-picker-indicator { - opacity: 0; + opacity: 0; } input::-webkit-datetime-edit-day-field:focus, input::-webkit-datetime-edit-month-field:focus, input::-webkit-datetime-edit-year-field:focus { - background-color: var(--accent); - color: var(--fg_color); - outline: none; + background-color: var(--accent); + color: var(--fg_color); + outline: none; } .date-range { - .date-input { - display: inline-block; - position: relative; - } + .date-input { + display: inline-block; + position: relative; + } - .icon-container { - pointer-events: none; - position: absolute; - top: 2px; - right: 5px; - } + .icon-container { + pointer-events: none; + position: absolute; + top: 2px; + right: 5px; + } - .search-title { - margin: 0 2px; - } + .search-title { + margin: 0 2px; + } } .icon-button button { - color: var(--accent); - text-decoration: none; - background: none; - border: none; - float: none; - padding: unset; - padding-left: 4px; + color: var(--accent); + text-decoration: none; + background: none; + border: none; + float: none; + padding: unset; + padding-left: 4px; - &:hover { - color: var(--accent_light); - } + &:hover { + color: var(--accent_light); + } } .checkbox { - position: absolute; - top: 1px; - right: 0; - height: 17px; - width: 17px; - background-color: var(--bg_elements); - border: 1px solid var(--accent_border); + position: absolute; + top: 1px; + right: 0; + height: 17px; + width: 17px; + background-color: var(--bg_elements); + border: 1px solid var(--accent_border); - &:after { - content: ""; - position: absolute; - display: none; - } + &:after { + content: ""; + position: absolute; + display: none; + } } .checkbox-container { - display: block; - position: relative; - margin-bottom: 5px; + display: block; + position: relative; + margin-bottom: 5px; + cursor: pointer; + user-select: none; + padding-right: 22px; + + input { + position: absolute; + opacity: 0; cursor: pointer; - user-select: none; - padding-right: 22px; + height: 0; + width: 0; - input { - position: absolute; - opacity: 0; - cursor: pointer; - height: 0; - width: 0; - - &:checked ~ .checkbox:after { - display: block; - } + &:checked ~ .checkbox:after { + display: block; } + } - &:hover input ~ .checkbox { - border-color: var(--accent); - } + &:hover input ~ .checkbox { + border-color: var(--accent); + } - &:active input ~ .checkbox { - border-color: var(--accent_light); - } + &:active input ~ .checkbox { + border-color: var(--accent_light); + } - .checkbox:after { - left: 2px; - bottom: 0; - font-size: 13px; - font-family: $font_4; - content: '\e803'; - } + .checkbox:after { + left: 2px; + bottom: 0; + font-size: 13px; + font-family: $font_1; + content: "\e811"; + } } .pref-group { - display: inline; + display: inline; } .preferences { - button { - margin: 6px 0 3px 0; - } + button { + margin: 6px 0 3px 0; + } - label { - padding-right: 150px; - } + label { + padding-right: 150px; + } - select { - position: absolute; - top: 0; - right: 0; - display: block; - -moz-appearance: none; - -webkit-appearance: none; - appearance: none; - } + select { + position: absolute; + top: 0; + right: 0; + display: block; + -moz-appearance: none; + -webkit-appearance: none; + appearance: none; + min-width: 100px; + } - input[type="text"] { - position: absolute; - right: 0; - max-width: 140px; - } + input[type="text"], + input[type="number"] { + position: absolute; + right: 0; + max-width: 140px; + } - .pref-group { - display: block; - } + .pref-group { + display: block; + } - .pref-input { - position: relative; - margin-bottom: 6px; - } + .pref-input { + position: relative; + margin-bottom: 6px; + } - .pref-reset { - float: left; - } + .pref-reset { + float: left; + } + + .prefs-code { + background-color: var(--bg_elements); + border: 1px solid var(--accent_border); + color: var(--fg_color); + font-size: 13px; + padding: 6px 8px; + margin: 4px 0; + word-break: break-all; + white-space: pre-wrap; + user-select: all; + } } diff --git a/src/sass/navbar.scss b/src/sass/navbar.scss index 47a8765..c999022 100644 --- a/src/sass/navbar.scss +++ b/src/sass/navbar.scss @@ -1,89 +1,90 @@ -@import '_variables'; +@import "_variables"; nav { - display: flex; - align-items: center; - position: fixed; - background-color: var(--bg_overlays); - box-shadow: 0 0 4px $shadow; - padding: 0; - width: 100%; - height: 50px; - z-index: 1000; - font-size: 16px; + display: flex; + align-items: center; + background-color: var(--bg_overlays); + box-shadow: 0 0 4px $shadow; + padding: 0; + width: 100%; + height: 50px; + z-index: 1000; + font-size: 16px; - a, .icon-button button { - color: var(--fg_nav); - } + a, + .icon-button button { + color: var(--fg_nav); + } + + body.fixed-nav & { + position: fixed; + } } .inner-nav { - margin: auto; - box-sizing: border-box; - padding: 0 10px; - display: flex; - align-items: center; - flex-basis: 920px; - height: 50px; + margin: auto; + box-sizing: border-box; + padding: 0 10px; + display: flex; + align-items: center; + flex-basis: 920px; + height: 50px; } .site-name { - font-size: 15px; - font-weight: 600; - line-height: 1; + font-size: 15px; + font-weight: 600; + line-height: 1; - &:hover { - color: var(--accent_light); - text-decoration: unset; - } + &:hover { + color: var(--accent_light); + text-decoration: unset; + } } .site-logo { - display: block; - width: 35px; - height: 35px; + display: block; + width: 35px; + height: 35px; } .nav-item { - display: flex; - flex: 1; - line-height: 50px; - height: 50px; - overflow: hidden; - flex-wrap: wrap; - align-items: center; + display: flex; + flex: 1; + line-height: 50px; + height: 50px; + overflow: hidden; + flex-wrap: wrap; + align-items: center; - &.right { - text-align: right; - justify-content: flex-end; - } + &.right { + text-align: right; + justify-content: flex-end; + } - &.right a { - padding-left: 4px; - - &:hover { - color: var(--accent_light); - text-decoration: unset; - } - } + &.right a:hover { + color: var(--accent_light); + text-decoration: unset; + } } .lp { - height: 14px; - display: inline-block; - position: relative; - top: 2px; - fill: var(--fg_nav); + height: 14px; + display: inline-block; + position: relative; + top: 2px; + fill: var(--fg_nav); - &:hover { - fill: var(--accent_light); - } + &:hover { + fill: var(--accent_light); + } } -.icon-info:before { - margin: 0 -3px; +.icon-info { + margin: 0 -3px; } .icon-cog { - font-size: 15px; + font-size: 15px; + padding-left: 0 !important; } diff --git a/src/sass/profile/_base.scss b/src/sass/profile/_base.scss index b7f33e6..81b3d78 100644 --- a/src/sass/profile/_base.scss +++ b/src/sass/profile/_base.scss @@ -1,83 +1,118 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; -@import 'card'; -@import 'photo-rail'; +@import "card"; +@import "about-account"; +@import "photo-rail"; +@import "community"; .profile-tabs { - @include panel(auto, 900px); + @include panel(auto, 900px); - .timeline-container { - float: right; - width: 68% !important; - max-width: unset; - } + .timeline-container { + float: right; + width: 68% !important; + max-width: unset; + } } .profile-banner { - margin-bottom: 4px; - background-color: var(--bg_panel); + margin-bottom: 4px; + background-color: var(--bg_panel); - a { - display: block; - position: relative; - padding: 33.34% 0 0 0; - } + a { + display: block; + position: relative; + padding: 33.34% 0 0 0; + } - img { - max-width: 100%; - position: absolute; - top: 0; - } + img { + max-width: 100%; + position: absolute; + top: 0; + } } .profile-tab { - padding: 0 4px 0 0; - box-sizing: border-box; - display: inline-block; - font-size: 14px; - text-align: left; - vertical-align: top; - max-width: 32%; + padding: 0 4px 0 0; + box-sizing: border-box; + display: inline-block; + font-size: 14px; + text-align: left; + vertical-align: top; + max-width: 32%; + top: 0; + + body.fixed-nav & { top: 50px; + } } .profile-result { - min-height: 54px; + min-height: 54px; - .username { - margin: 0 !important; - } + .username { + margin: 0 !important; + } - .tweet-header { - margin-bottom: unset; - } + .tweet-header { + margin-bottom: unset; + } } -@media(max-width: 700px) { - .profile-tabs { - width: 100vw; - max-width: 600px; +.profile-tabs.media-only { + max-width: none; + width: 100%; - .timeline-container { - width: 100% !important; + .timeline-container { + float: none; + width: 100% !important; + max-width: none; + padding: 0 10px; + box-sizing: border-box; + } - .tab-item wide { - flex-grow: 1.4; - } - } + .timeline-container > .tab { + max-width: 900px; + margin-left: auto; + margin-right: auto; + } +} + +@media (max-width: 700px) { + .profile-tabs { + width: 100vw; + max-width: 600px; + + .timeline-container { + width: 100% !important; + + .tab-item wide { + flex-grow: 1.4; + } } + } - .profile-tab { - width: 100%; - max-width: unset; - position: initial !important; - padding: 0; + .profile-tabs.media-only { + width: 100%; + max-width: none; + + .timeline-container { + width: 100vw !important; + padding: 0; } + } + + .profile-tab { + width: 100%; + max-width: unset; + position: initial !important; + padding: 0; + } } @media (min-height: 900px) { - .profile-tab.sticky { - position: sticky; - } + .profile-tab.sticky { + position: sticky; + } } diff --git a/src/sass/profile/_community.scss b/src/sass/profile/_community.scss new file mode 100644 index 0000000..92d13c9 --- /dev/null +++ b/src/sass/profile/_community.scss @@ -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; + } +} diff --git a/src/sass/profile/about-account.scss b/src/sass/profile/about-account.scss new file mode 100644 index 0000000..aa12f49 --- /dev/null +++ b/src/sass/profile/about-account.scss @@ -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; + } +} diff --git a/src/sass/search.scss b/src/sass/search.scss index f70f7ea..fa2a2d8 100644 --- a/src/sass/search.scss +++ b/src/sass/search.scss @@ -1,122 +1,194 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; .search-title { - font-weight: bold; - display: inline-block; - margin-top: 4px; + font-weight: bold; + display: inline-block; + margin-top: 4px; } .search-field { + display: flex; + flex-wrap: wrap; + + button { + margin: 0 2px 0 0; + padding: 0px 1px 1px 4px; + height: 23px; display: flex; - flex-wrap: wrap; + align-items: center; + } - button { - margin: 0 2px 0 0; - height: 23px; - display: flex; - align-items: center; - } + .pref-input { + margin: 0 4px 0 0; + flex-grow: 1; + height: 23px; + } - .pref-input { - margin: 0 4px 0 0; - flex-grow: 1; - height: 23px; - } + input[type="text"], + input[type="number"] { + height: calc(100% - 4px); + width: calc(100% - 8px); + } - input[type="text"] { - height: calc(100% - 4px); - width: calc(100% - 8px); - } + > label { + display: inline; + background-color: var(--bg_elements); + color: var(--fg_color); + border: 1px solid var(--accent_border); + padding: 1px 1px 2px 4px; + font-size: 14px; + cursor: pointer; + margin-bottom: 2px; - > label { - display: inline; - background-color: var(--bg_elements); - color: var(--fg_color); - border: 1px solid var(--accent_border); - padding: 1px 6px 2px 6px; - font-size: 14px; - cursor: pointer; - margin-bottom: 2px; + @include input-colors; + } - @include input-colors; - } - - @include create-toggle(search-panel, 200px); + @include create-toggle(search-panel, 380px); } .search-panel { - width: 100%; - max-height: 0; - overflow: hidden; - transition: max-height 0.4s; + width: 100%; + max-height: 0; + overflow: hidden; + transition: max-height 0.4s; - flex-grow: 1; - font-weight: initial; - text-align: left; + flex-grow: 1; + font-weight: initial; + text-align: left; - > div { - line-height: 1.7em; - } + .checkbox-container { + display: inline; + padding-right: unset; + margin-bottom: 5px; + margin-left: 23px; + } - .checkbox-container { - display: inline; - padding-right: unset; - margin-bottom: unset; - margin-left: 23px; - } + .checkbox { + right: unset; + left: -22px; + line-height: 1.6em; + } - .checkbox { - right: unset; - left: -22px; - } - - .checkbox-container .checkbox:after { - top: -4px; - } + .checkbox-container .checkbox:after { + top: -4px; + } } .search-row { - display: flex; - flex-wrap: wrap; - line-height: unset; + display: flex; + flex-wrap: wrap; + line-height: unset; - > div { - flex-grow: 1; - flex-shrink: 1; - } + > div { + flex-grow: 1; + flex-shrink: 1; + } + + input { + height: 21px; + } + + .pref-input { + display: block; + padding-bottom: 5px; input { - height: 21px; - } - - .pref-input { - display: block; - padding-bottom: 5px; - - input { - height: 21px; - margin-top: 1px; - } + height: 21px; + margin-top: 1px; } + } } .search-toggles { - flex-grow: 1; - display: grid; - grid-template-columns: repeat(6, auto); - grid-column-gap: 10px; + flex-grow: 1; + display: grid; + grid-template-columns: repeat(5, auto); + grid-column-gap: 10px; +} + +.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 { - @include search-resize(820px, 5); - @include search-resize(725px, 4); - @include search-resize(600px, 6); - @include search-resize(560px, 5); - @include search-resize(480px, 4); - @include search-resize(410px, 3); + @include search-resize(820px, 5); + @include search-resize(715px, 4); + @include search-resize(700px, 5); + @include search-resize(485px, 4); + @include search-resize(410px, 3); } -@include search-resize(560px, 5); -@include search-resize(480px, 4); +@include search-resize(700px, 5); +@include search-resize(485px, 4); @include search-resize(410px, 3); diff --git a/src/sass/timeline.scss b/src/sass/timeline.scss index c8ce309..b7d4a9f 100644 --- a/src/sass/timeline.scss +++ b/src/sass/timeline.scss @@ -1,162 +1,505 @@ -@import '_variables'; +@import "_variables"; .timeline-container { - @include panel(100%, 600px); + @include panel(100%, 600px); } -.timeline { - background-color: var(--bg_panel); +.timeline-container.media-only { + max-width: none; + width: 100%; + padding: 0 10px; + box-sizing: border-box; - > div:not(:first-child) { - border-top: 1px solid var(--border_grey); - } + > .tab, + > .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 { - width: 100%; - background-color: var(--bg_panel); - text-align: center; - padding: 8px; - display: block; - font-weight: bold; - margin-bottom: 5px; - box-sizing: border-box; + width: 100%; + background-color: var(--bg_panel); + text-align: center; + padding: 8px; + display: block; + font-weight: bold; + margin-bottom: 4px; + box-sizing: border-box; - button { - float: unset; - } + button { + float: unset; + } } .timeline-banner img { - width: 100%; + width: 100%; } .timeline-description { - font-weight: normal; + font-weight: normal; } .tab { - align-items: center; - display: flex; - flex-wrap: wrap; - list-style: none; - margin: 0 0 5px 0; - background-color: var(--bg_panel); - padding: 0; + align-items: center; + display: flex; + flex-wrap: wrap; + list-style: none; + margin: 0 0 4px 0; + background-color: var(--bg_panel); + padding: 0; } .tab-item { - flex: 1 1 0; - text-align: center; - margin-top: 0; + flex: 1 1 0; + text-align: center; + margin-top: 0; - a { - border-bottom: .1rem solid transparent; - color: var(--tab); - display: block; - padding: 8px 0; - text-decoration: none; - font-weight: bold; + a { + border-bottom: 0.1rem solid transparent; + color: var(--tab); + display: block; + padding: 8px 0; + text-decoration: none; + font-weight: bold; - &:hover { - text-decoration: none; - } - - &.active { - border-bottom-color: var(--tab_selected); - color: var(--tab_selected); - } + &:hover { + text-decoration: none; } - &.active a { - border-bottom-color: var(--tab_selected); - color: var(--tab_selected); + &.active { + border-bottom-color: var(--tab_selected); + color: var(--tab_selected); } + } - &.wide { - flex-grow: 1.2; - flex-basis: 50px; - } + &.active a { + border-bottom-color: var(--tab_selected); + color: var(--tab_selected); + } + + &.wide { + flex-grow: 1.2; + flex-basis: 50px; + } } .timeline-footer { - background-color: var(--bg_panel); - padding: 6px 0; + background-color: var(--bg_panel); + padding: 6px 0; } .timeline-protected { - text-align: center; + text-align: center; - p { - margin: 8px 0; - } + p { + margin: 8px 0; + } - h2 { - color: var(--accent); - font-size: 20px; - font-weight: 600; - } -} - -.timeline-none { + h2 { color: var(--accent); font-size: 20px; font-weight: 600; - text-align: center; + } +} + +.timeline-none { + color: var(--accent); + font-size: 20px; + font-weight: 600; + text-align: center; } .timeline-end { - background-color: var(--bg_panel); - color: var(--accent); - font-size: 16px; - font-weight: 600; - text-align: center; + background-color: var(--bg_panel); + color: var(--accent); + font-size: 16px; + font-weight: 600; + text-align: center; } .show-more { - background-color: var(--bg_panel); - text-align: center; - padding: .75em 0; - display: block !important; + background-color: var(--bg_panel); + text-align: center; + padding: 0.75em 0; + display: block !important; - a { - background-color: var(--darkest_grey); - display: inline-block; - height: 2em; - padding: 0 2em; - line-height: 2em; + a { + background-color: var(--darkest_grey); + display: inline-block; + height: 2em; + padding: 0 2em; + line-height: 2em; - &:hover { - background-color: var(--darker_grey); - } + &:hover { + background-color: var(--darker_grey); } + } } .top-ref { - background-color: var(--bg_color); - border-top: none !important; + background-color: var(--bg_color); + border-top: none !important; - .icon-down { - font-size: 20px; - display: flex; - justify-content: center; - text-decoration: none; + .icon-down { + font-size: 20px; + display: flex; + justify-content: center; + text-decoration: none; - &:hover { - color: var(--accent_light); - } - - &::before { - transform: rotate(180deg) translateY(-1px); - } + &:hover { + color: var(--accent_light); } + + &::before { + transform: rotate(180deg) translateY(-1px); + } + } } .timeline-item { - overflow-wrap: break-word; - border-left-width: 0; - min-width: 0; - padding: .75em; - display: flex; - position: relative; + overflow-wrap: break-word; + border-left-width: 0; + min-width: 0; + padding: 0.75em; + display: flex; + position: relative; + background-color: var(--bg_panel); +} + +.timeline.media-grid-view, +.timeline.media-gallery-view { + > div:not(:first-child) { + border-top: none; + } + + .timeline-item::before { + display: none; + } +} + +.timeline.media-grid-view, +.timeline.media-gallery-view .gallery-masonry.compact { + .tweet-header, + .replying-to, + .retweet-header, + .pinned, + .tweet-stats, + .attribution, + .poll, + .quote, + .community-note, + .media-tag-block, + .tweet-content, + .card-content { + display: none; + } + + .card { + margin: unset; + + .card-container { + border: unset; + border-radius: unset; + + .card-image-container { + width: 100%; + min-height: 100%; + } + + .card-content-container { + display: none; + } + } + } +} + +.timeline.media-grid-view { + display: grid; + gap: 4px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + + > div:not(:first-child) { + margin-top: 0; + } + + .timeline-item { + padding: 0; + } + + .tweet-link { + z-index: 1000; + + &:hover { + background-color: unset; + } + } + + > .show-more, + > .top-ref, + > .timeline-footer, + > .timeline-header { + grid-column: 1 / -1; + } + + .tweet-body { + height: 100%; + margin-left: 0; + padding: 0; + position: relative; + aspect-ratio: 1/1; + } + + .gallery-row + .gallery-row { + margin-top: 0.25em !important; + } + + .attachments { + background-color: var(--darkest_grey); + border-radius: 0; + margin: 0; + max-height: none; + } + + .attachments, + .gallery-row, + .still-image { + height: 100%; + width: 100%; + } + + .still-image img, + .attachment > video, + .attachment > img { + object-fit: cover; + height: 100%; + width: 100%; + } + + .attachment { + display: flex; + align-items: center; + } + + .gallery-video { + height: 100%; + } + + .media-gif { + display: flex; + } + + .timeline-item:hover { + opacity: 0.85; + } + + .alt-text { + display: none; + } +} + +.timeline.media-gallery-view { + .gallery-masonry { + margin: 10px 0; + column-gap: 10px; + column-width: unquote("clamp(190px, 22vw, 350px)"); + + &[data-col-size="small"] { + column-width: unquote("max(130px, 11vw)"); + } + + &[data-col-size="large"] { + column-width: unquote("clamp(350px, 22vw, 480px)"); + } + + &.masonry-active { + column-width: unset; + column-gap: unset; + position: relative; + + .timeline-item { + animation: none; + position: absolute; + box-sizing: border-box; + margin-bottom: 0; + } + } + + &.compact { + .tweet-body { + padding: 0; + + > .attachments { + margin: 0; + } + } + + .card-image-container img { + max-height: unset; + } + } + } + + @keyframes masonry-init { + to { + opacity: 1; + pointer-events: auto; + } + } + + // Start hidden. CSS animation reveals after a delay as a no-JS fallback. + // With JS, masonry-active cancels the animation and masonry-visible reveals. + .gallery-masonry .timeline-item, + > .show-more, + > .top-ref, + > .timeline-footer { + opacity: 0; + pointer-events: none; + animation: masonry-init 0.2s 0.3s forwards; + } + + .gallery-masonry.masonry-active .timeline-item.masonry-visible, + > .show-more.masonry-visible, + > .top-ref.masonry-visible, + > .timeline-footer.masonry-visible { + opacity: 1; + pointer-events: auto; + transition: opacity 0.15s ease; + animation: none; + } + + .timeline-item { + margin-bottom: 10px; + break-inside: avoid; + flex-direction: column; + padding: 0; + } + + > .show-more, + > .top-ref, + > .timeline-footer, + > .timeline-header { + margin-left: auto; + margin-right: auto; + max-width: 900px; + } + + > .show-more { + padding: 0; + margin-top: 8px; + background-color: unset; + } + + .tweet-content { + margin: 3px 0; + } + + .tweet-body { + display: flex; + flex-direction: column; + height: 100%; + margin-left: 0; + padding: 10px; + + > .attachments { + align-self: stretch; + border-radius: 0; + margin: -10px -10px 10px; + max-height: none; + order: -1; + width: auto; + background-color: var(--bg_elements); + + .gallery-row { + max-height: none; + max-width: none; + align-items: center; + } + + .still-image img, + .attachment > video, + .attachment > img { + max-height: none; + width: 100%; + } + + .attachment:last-child { + max-height: none; + } + + .card-container { + border: unset; + border-radius: unset; + } + } + + .tweet-stat { + padding-top: unset; + } + + .quote { + margin-bottom: 5px; + margin-top: 5px; + } + + .replying-to { + margin: 0; + } + } + + .tweet-header { + align-items: flex-start; + display: flex; + gap: 0.75em; + margin-bottom: 0; + + .tweet-avatar { + img { + float: none; + height: 42px; + margin: 0; + width: 42px; + } + } + + .tweet-name-row { + flex: 1; + } + + .fullname-and-username { + flex-wrap: wrap; + } + + .fullname { + max-width: calc(100% - 18px); + } + + .verified-icon { + margin-left: 4px; + margin-top: 1px; + } + + .username { + display: block; + flex-basis: 100%; + margin-left: 0; + } + } +} + +@media (max-width: 520px) { + .timeline.media-gallery-view { + padding: 8px 0; + } } diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 69f51c0..2f6693e 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -1,240 +1,284 @@ -@import '_variables'; -@import '_mixins'; -@import 'thread'; -@import 'media'; -@import 'video'; -@import 'embed'; -@import 'card'; -@import 'poll'; -@import 'quote'; +@import "_variables"; +@import "_mixins"; +@import "thread"; +@import "media"; +@import "video"; +@import "embed"; +@import "card"; +@import "poll"; +@import "quote"; .tweet-body { - flex: 1; - min-width: 0; - margin-left: 58px; - pointer-events: none; - z-index: 1; + flex: 1; + min-width: 0; + margin-left: 58px; + pointer-events: none; + z-index: 1; } .tweet-content { - font-family: $font_3; - line-height: 1.3em; - pointer-events: all; - display: inline; + line-height: 1.3em; + pointer-events: all; + display: inline; } .tweet-bidi { - display: block !important; + display: block !important; } .tweet-header { - padding: 0; - vertical-align: bottom; - flex-basis: 100%; - margin-bottom: .2em; + padding: 0; + vertical-align: bottom; + flex-basis: 100%; + margin-bottom: 0.2em; - a { - display: inline-block; - word-break: break-all; - max-width: 100%; - pointer-events: all; - } + a { + display: inline-block; + word-break: break-all; + max-width: 100%; + pointer-events: all; + } } .tweet-name-row { - padding: 0; - display: flex; - justify-content: space-between; + padding: 0; + display: flex; + justify-content: space-between; + + .verified-icon { + margin-left: 2px; + } } .fullname-and-username { - display: flex; - min-width: 0; + display: flex; + min-width: 0; } .fullname { - @include ellipsis; - flex-shrink: 2; - max-width: 80%; - font-size: 14px; - font-weight: 700; - color: var(--fg_color); + @include ellipsis; + flex-shrink: 2; + max-width: 80%; + font-size: 14px; + font-weight: 700; + color: var(--fg_color); } .username { - @include ellipsis; - min-width: 1.6em; - margin-left: .4em; - word-wrap: normal; + @include ellipsis; + min-width: 1.6em; + margin-left: 0.4em; + word-wrap: normal; } .tweet-date { - display: flex; - flex-shrink: 0; - margin-left: 4px; + display: flex; + flex-shrink: 0; + margin-left: 4px; } -.tweet-date a, .username, .show-more a { - color: var(--fg_dark); +.tweet-date a, +.username, +.show-more a { + color: var(--fg_dark); } .tweet-published { - margin: 0; - margin-top: 5px; - color: var(--grey); - pointer-events: all; + margin-top: 6px; + margin-bottom: 0px; + color: var(--grey); } .tweet-avatar { - display: contents !important; + display: contents !important; - img { - float: left; - margin-top: 3px; - margin-left: -58px; - width: 48px; - height: 48px; - } + img { + float: left; + margin-top: 3px; + margin-left: -58px; + width: 48px; + height: 48px; + } } .avatar { - &.round { - border-radius: 50%; - -webkit-user-select: none; - } - - &.mini { - position: unset; - margin-right: 5px; - margin-top: -1px; - width: 20px; - height: 20px; - } -} + &.round { + border-radius: 50%; + user-select: none; + -webkit-user-select: none; + } -.tweet-embed { - display: flex; - flex-direction: column; - justify-content: center; - height: 100%; - background-color: var(--bg_panel); - - .tweet-content { - font-size: 18px; - } - - .tweet-body { - display: flex; - flex-direction: column; - max-height: calc(100vh - 0.75em * 2); - } - - .card-image img { - height: auto; - } - - .avatar { - position: absolute; - } + &.mini { + position: unset; + margin-right: 5px; + margin-top: -1px; + width: 20px; + height: 20px; + } } .attribution { - display: flex; - pointer-events: all; - margin: 5px 0; + display: flex; + pointer-events: all; + margin: 5px 0; - strong { - color: var(--fg_color); - } + strong { + color: var(--fg_color); + } } .media-tag-block { - padding-top: 5px; - pointer-events: all; + padding-top: 5px; + pointer-events: all; + color: var(--fg_faded); + + .icon-container { + padding-right: 2px; + } + + .media-tag, + .icon-container { color: var(--fg_faded); - - .icon-container { - padding-right: 2px; - } - - .media-tag, .icon-container { - color: var(--fg_faded); - } + } } .timeline-container .media-tag-block { - font-size: 13px; + font-size: 13px; } .tweet-geo { - color: var(--fg_faded); + color: var(--fg_faded); } .replying-to { - color: var(--fg_faded); - margin: -2px 0 4px; + color: var(--fg_faded); + margin: -2px 0 4px; - a { - pointer-events: all; - } + a { + pointer-events: all; + } } -.retweet-header, .pinned, .tweet-stats { - align-content: center; - color: var(--grey); - display: flex; - flex-shrink: 0; - flex-wrap: wrap; - font-size: 14px; - font-weight: 600; - line-height: 22px; +.retweet-header, +.pinned, +.tweet-stats { + align-content: center; + color: var(--grey); + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + font-size: 14px; + font-weight: 600; + line-height: 22px; - span { - @include ellipsis; - } + span { + @include ellipsis; + } } .retweet-header { - margin-top: -5px !important; + margin-top: -5px !important; } .tweet-stats { - margin-bottom: -3px; - -webkit-user-select: none; + margin-bottom: -3px; + user-select: none; + -webkit-user-select: none; } .tweet-stat { - padding-top: 5px; - min-width: 1em; - margin-right: 0.8em; + padding-top: 5px; + min-width: 1em; + margin-right: 0.8em; } .show-thread { - display: block; - pointer-events: all; - padding-top: 2px; + display: block; + pointer-events: all; + padding-top: 2px; } .unavailable-box { - width: 100%; - height: 100%; - padding: 12px; - border: solid 1px var(--dark_grey); - box-sizing: border-box; - border-radius: 10px; - background-color: var(--bg_color); - z-index: 2; + width: 100%; + height: 100%; + padding: 12px; + border: solid 1px var(--dark_grey); + box-sizing: border-box; + border-radius: 10px; + background-color: var(--bg_color); + z-index: 2; } .tweet-link { - height: 100%; - width: 100%; - left: 0; - top: 0; - position: absolute; - -webkit-user-select: none; + height: 100%; + width: 100%; + left: 0; + top: 0; + position: absolute; + user-select: none; + -webkit-user-select: none; - &:hover { - background-color: var(--bg_hover); - } + &:hover { + background-color: var(--bg_hover); + } +} + +.latest-post-version { + border-bottom: 1px solid var(--dark_grey); + border-top: 1px solid var(--dark_grey); + padding: 01ch 0px; + margin: 1ch 0px; + color: var(--grey); + + a { + pointer-events: all; + } +} + +.community-note { + background-color: var(--bg_elements); + margin-top: 10px; + border: solid 1px var(--dark_grey); + border-radius: 10px; + overflow: hidden; + pointer-events: all; + + &:hover { + background-color: var(--bg_panel); + border-color: var(--grey); + } +} + +.community-note-header { + background-color: var(--bg_hover); + font-weight: 700; + padding: 8px 10px; + padding-top: 6px; + display: flex; + align-items: center; + gap: 2px; + + .icon-container { + flex-shrink: 0; + color: var(--accent); + } +} + +.community-note-text { + white-space: pre-line; + padding: 10px 10px; + padding-top: 6px; +} + +.disclosures { + display: flex; + flex-direction: column; + color: var(--grey); + font-size: 14px; + margin-top: 4px; + margin-bottom: -2px; + + .icon-attention-circled { + margin-right: -3px; + } } diff --git a/src/sass/tweet/card.scss b/src/sass/tweet/card.scss index 5575191..7441d11 100644 --- a/src/sass/tweet/card.scss +++ b/src/sass/tweet/card.scss @@ -1,119 +1,119 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; .card { - margin: 5px 0; - pointer-events: all; - max-height: unset; + margin: 5px 0; + pointer-events: all; + max-height: unset; } .card-container { - border-radius: 10px; - border-width: 1px; - border-style: solid; - border-color: var(--dark_grey); - background-color: var(--bg_elements); - overflow: hidden; - color: inherit; - display: flex; - flex-direction: row; - text-decoration: none !important; + border: solid 1px var(--dark_grey); + border-radius: 10px; + background-color: var(--bg_elements); + overflow: hidden; + color: inherit; + display: flex; + flex-direction: row; + text-decoration: none !important; - &:hover { - border-color: var(--grey); - } + &:hover { + border-color: var(--grey); + } - .attachments { - margin: 0; - border-radius: 0; - } + .attachments { + margin: 0; + border-radius: 0; + } } .card-content { - padding: 0.5em; + padding: 0.5em; } .card-title { - @include ellipsis; - white-space: unset; - font-weight: bold; - font-size: 1.1em; + @include ellipsis; + white-space: unset; + font-weight: bold; + font-size: 1.1em; } .card-description { - margin: 0.3em 0; - white-space: pre-wrap; + margin: 0.3em 0; + white-space: pre-wrap; } .card-destination { - @include ellipsis; - color: var(--grey); - display: block; + @include ellipsis; + color: var(--grey); + display: block; } .card-content-container { - color: unset; - overflow: auto; - &:hover { - text-decoration: none; - } + color: unset; + overflow: auto; + + &:hover { + text-decoration: none; + } } .card-image-container { - width: 98px; - flex-shrink: 0; - position: relative; - overflow: hidden; - &:before { - content: ""; - display: block; - padding-top: 100%; - } + width: 98px; + flex-shrink: 0; + position: relative; + overflow: hidden; + + &:before { + content: ""; + display: block; + padding-top: 100%; + } } .card-image { - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - background-color: var(--bg_overlays); + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background-color: var(--bg_overlays); - img { - width: 100%; - height: 100%; - max-height: 400px; - display: block; - object-fit: cover; - } + img { + width: 100%; + height: 100%; + max-height: 400px; + display: block; + object-fit: cover; + } } .card-overlay { - @include play-button; - opacity: 0.8; - display: flex; - justify-content: center; - align-items: center; + @include play-button; + opacity: 0.8; + display: flex; + justify-content: center; + align-items: center; } .large { - .card-container { - display: block; - } + .card-container { + display: block; + } - .card-image-container { - width: unset; + .card-image-container { + width: unset; - &:before { - display: none; - } + &:before { + display: none; } + } - .card-image { - position: unset; - border-style: solid; - border-color: var(--dark_grey); - border-width: 0; - border-bottom-width: 1px; - } + .card-image { + position: unset; + border-style: solid; + border-color: var(--dark_grey); + border-width: 0; + border-bottom-width: 1px; + } } diff --git a/src/sass/tweet/embed.scss b/src/sass/tweet/embed.scss index 227fc5e..9ff8403 100644 --- a/src/sass/tweet/embed.scss +++ b/src/sass/tweet/embed.scss @@ -1,17 +1,159 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; -.embed-video { - .gallery-video { - width: 100%; - height: 100%; - position: absolute; - background-color: black; - top: 0%; - left: 0%; - } +// Embed page: transparent background, no scrollbars +html:has(body > .embed-wrapper), +html:has(body > .embed-video) { + background: transparent; + overflow: hidden; - .video-container { - max-height: unset; - } + body { + 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; + } } diff --git a/src/sass/tweet/media.scss b/src/sass/tweet/media.scss index 91c9dab..3001a86 100644 --- a/src/sass/tweet/media.scss +++ b/src/sass/tweet/media.scss @@ -1,119 +1,168 @@ -@import '_variables'; +@import "_variables"; .gallery-row { - display: flex; - flex-direction: row; - flex-wrap: nowrap; - align-items: center; - overflow: hidden; - flex-grow: 1; - max-height: 379.5px; - max-width: 533px; - pointer-events: all; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + overflow: hidden; + flex-grow: 1; + max-height: 379.5px; + max-width: 533px; + pointer-events: all; + + &.mixed-row { + .attachment { + min-width: 0; + min-height: 0; + flex: 1 1 0; + max-height: 379.5px; + display: flex; + align-items: center; + justify-content: center; + background-color: #101010; + } + + .still-image, + .still-image img, + .attachment > video, + .attachment > img { + width: 100%; + height: 100%; + max-width: none; + max-height: none; + } .still-image { - width: 100%; - display: flex; + display: flex; + align-self: stretch; } + + .still-image img { + flex-basis: auto; + flex-grow: 0; + object-fit: cover; + } + + .attachment > video, + .attachment > img { + object-fit: cover; + } + + .attachment > video { + object-fit: contain; + } + } } .attachments { - margin-top: .35em; - display: flex; - flex-direction: row; - width: 100%; - max-height: 600px; - border-radius: 7px; - overflow: hidden; - flex-flow: column; - background-color: var(--bg_color); - align-items: center; - pointer-events: all; - - .image-attachment { - width: 100%; - } + margin-top: 0.35em; + display: flex; + flex-direction: row; + width: 100%; + max-height: 600px; + border-radius: 7px; + overflow: hidden; + flex-flow: column; + background-color: var(--bg_color); + align-items: center; + pointer-events: all; } .attachment { - position: relative; - line-height: 0; - overflow: hidden; - margin: 0 .25em 0 0; - flex-grow: 1; - box-sizing: border-box; - min-width: 2em; + position: relative; + line-height: 0; + overflow: hidden; + margin: 0 0.25em 0 0; + flex-grow: 1; + box-sizing: border-box; + min-width: 2em; - &:last-child { - margin: 0; - max-height: 530px; - } -} - -.gallery-gif video { + &:last-child { + margin: 0; max-height: 530px; - background-color: #101010; -} - -.still-image { - max-height: 379.5px; - max-width: 533px; - justify-content: center; - - img { - object-fit: cover; - max-width: 100%; - max-height: 379.5px; - flex-basis: 300px; - flex-grow: 1; - } -} - -.image { - display: inline-block; -} - -// .single-image { -// display: inline-block; -// width: 100%; -// max-height: 600px; - -// .attachments { -// width: unset; -// max-height: unset; -// display: inherit; -// } -// } - -.overlay-circle { - border-radius: 50%; - background-color: var(--dark_grey); - width: 40px; - height: 40px; - align-items: center; - display: flex; - border-width: 5px; - border-color: var(--play_button); - border-style: solid; -} - -.overlay-triangle { - width: 0; - height: 0; - border-style: solid; - border-width: 12px 0 12px 17px; - border-color: transparent transparent transparent var(--play_button); - margin-left: 14px; + } } .media-gif { - display: table; - background-color: unset; - width: unset; + display: table; + background-color: unset; + width: unset; + max-height: unset; +} + +.media-gif video, +.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 { - flex: 1; - padding: 0; - white-space: pre-wrap; + flex: 1; + padding: 0; + white-space: pre-wrap; } diff --git a/src/sass/tweet/poll.scss b/src/sass/tweet/poll.scss index 57590c8..6d54e00 100644 --- a/src/sass/tweet/poll.scss +++ b/src/sass/tweet/poll.scss @@ -1,42 +1,42 @@ -@import '_variables'; +@import "_variables"; .poll-meter { - overflow: hidden; - position: relative; - margin: 6px 0; - height: 26px; - background: var(--bg_color); - border-radius: 5px; - display: flex; - align-items: center; + overflow: hidden; + position: relative; + margin: 6px 0; + height: 26px; + background: var(--bg_color); + border-radius: 5px; + display: flex; + align-items: center; } .poll-choice-bar { - height: 100%; - position: absolute; - background: var(--dark_grey); + height: 100%; + position: absolute; + background: var(--dark_grey); } .poll-choice-value { - position: relative; - font-weight: bold; - margin-left: 5px; - margin-right: 6px; - min-width: 30px; - text-align: right; - pointer-events: all; + position: relative; + font-weight: bold; + margin-left: 5px; + margin-right: 6px; + min-width: 30px; + text-align: right; + pointer-events: all; } .poll-choice-option { - position: relative; - pointer-events: all; + position: relative; + pointer-events: all; } .poll-info { - color: var(--grey); - pointer-events: all; + color: var(--grey); + pointer-events: all; } .leader .poll-choice-bar { - background: var(--accent_dark); + background: var(--accent_dark); } diff --git a/src/sass/tweet/quote.scss b/src/sass/tweet/quote.scss index b4bc60e..f722455 100644 --- a/src/sass/tweet/quote.scss +++ b/src/sass/tweet/quote.scss @@ -1,94 +1,121 @@ -@import '_variables'; +@import "_variables"; .quote { - margin-top: 10px; - border: solid 1px var(--dark_grey); - border-radius: 10px; - background-color: var(--bg_elements); + margin-top: 10px; + border: solid 1px var(--dark_grey); + border-radius: 10px; + background-color: var(--bg_elements); + overflow: hidden; + pointer-events: all; + position: relative; + width: 100%; + + &:hover { + border-color: var(--grey); + } + + &.unavailable:hover { + border-color: var(--dark_grey); + } + + .tweet-name-row { + padding: 8px 10px 6px 10px; + } + + .quote-text { overflow: hidden; - pointer-events: all; - position: relative; - width: 100%; + white-space: pre-wrap; + word-wrap: break-word; + padding: 10px; + padding-top: 0; + } + + .show-thread { + padding: 0px 10px 6px 10px; + margin-top: -6px; + } + + .quote-latest { + padding: 0px 10px 6px 10px; + color: var(--grey); + } + + .replying-to { + padding: 0px 10px; + padding-bottom: 4px; + margin: unset; + } + + .community-note { + background-color: var(--bg_panel); + border: unset; + border-top: solid 1px var(--dark_grey); + border-radius: unset; + margin-top: 0; &:hover { - border-color: var(--grey); + border-top-color: var(--grey); } - &.unavailable:hover { - border-color: var(--dark_grey); - } - - .tweet-name-row { - padding: 6px 8px; - margin-top: 1px; - } - - .quote-text { - overflow: hidden; - white-space: pre-wrap; - word-wrap: break-word; - padding: 0px 8px 8px 8px; - } - - .show-thread { - padding: 0px 8px 6px 8px; - margin-top: -6px; - } - - .replying-to { - padding: 0px 8px; - margin: unset; + .community-note-header { + background-color: var(--bg_panel); + padding-bottom: 0; } + } } .unavailable-quote { - padding: 12px; + padding: 12px; + display: block; } .quote-link { - width: 100%; - height: 100%; - left: 0; - top: 0; - position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + position: absolute; } .quote-media-container { - max-height: 300px; + max-height: 300px; + display: flex; + + .card { + margin: unset; + } + + .attachments { + border-radius: 0; + } + + .media-gif { + width: 100%; display: flex; + justify-content: center; + } - .card { - margin: unset; + .media-gif > .attachment { + display: flex; + justify-content: center; + background-color: var(--bg_color); + + video, + img { + height: unset; + width: unset; + max-height: 100%; + max-width: 100%; } + } - .attachments { - border-radius: 0; - } + .gallery-row .attachment, + .gallery-row .attachment > video, + .gallery-row .attachment > img { + max-height: 300px; + } - .media-gif { - width: 100%; - display: flex; - justify-content: center; - } - - .gallery-gif .attachment { - display: flex; - justify-content: center; - background-color: var(--bg_color); - - video { - height: unset; - width: unset; - max-height: 100%; - max-width: 100%; - } - } - - .gallery-video, .gallery-gif { - max-height: 300px; - } - - .still-image img { - max-height: 250px - } + .still-image img { + max-height: 250px; + } } diff --git a/src/sass/tweet/thread.scss b/src/sass/tweet/thread.scss index 19fb3e0..134e375 100644 --- a/src/sass/tweet/thread.scss +++ b/src/sass/tweet/thread.scss @@ -1,138 +1,196 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; -.conversation { - @include panel(100%, 600px); +.conversation, +.edit-history { + @include panel(100%, 600px); - .show-more { - margin-bottom: 10px; - } + .show-more { + margin-bottom: 10px; + } } -.main-thread { - margin-bottom: 20px; - background-color: var(--bg_panel); -} - -.main-tweet, .replies { - padding-top: 50px; - margin-top: -50px; -} - -.main-tweet .tweet-content { - font-size: 18px; -} - -@media(max-width: 600px) { - .main-tweet .tweet-content { - font-size: 16px; - } +.main-thread, +.latest-edit { + margin-bottom: 20px; } .reply { - background-color: var(--bg_panel); - margin-bottom: 10px; + margin-bottom: 10px; +} + +.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 { - .timeline-item::before, - &.timeline-item::before { - background: var(--accent_dark); - content: ''; - position: relative; - min-width: 3px; - width: 3px; - left: 26px; - border-radius: 2px; - margin-left: -3px; - margin-bottom: 37px; - top: 56px; - z-index: 1; - pointer-events: none; - } + .timeline-item::before, + &.timeline-item::before { + background: var(--accent_dark); + content: ""; + position: relative; + min-width: 3px; + width: 3px; + left: 26px; + border-radius: 2px; + margin-left: -3px; + margin-bottom: 37px; + top: 56px; + z-index: 1; + pointer-events: none; + } - .with-header:not(:first-child)::after { - background: var(--accent_dark); - content: ''; - position: relative; - float: left; - min-width: 3px; - width: 3px; - right: calc(100% - 26px); - border-radius: 2px; - margin-left: -3px; - margin-bottom: 37px; - bottom: 10px; - height: 30px; - z-index: 1; - pointer-events: none; - } + .with-header:not(:first-child)::after { + background: var(--accent_dark); + content: ""; + position: relative; + float: left; + min-width: 3px; + width: 3px; + right: calc(100% - 26px); + border-radius: 2px; + margin-left: -3px; + margin-bottom: 37px; + bottom: 10px; + height: 30px; + z-index: 1; + pointer-events: none; + } - .unavailable::before { - top: 48px; - margin-bottom: 28px; - } + .unavailable::before { + top: 48px; + margin-bottom: 28px; + } - .more-replies::before { - content: '...'; - background: unset; - color: var(--more_replies_dots); - font-weight: bold; - font-size: 20px; - line-height: 0.25em; - left: 1.2em; - width: 5px; - top: 2px; - margin-bottom: 0; - margin-left: -2.5px; - } + .more-replies::before { + content: "..."; + background: unset; + color: var(--more_replies_dots); + font-weight: bold; + font-size: 20px; + line-height: 0.25em; + left: 1.2em; + width: 5px; + top: 2px; + margin-bottom: 0; + margin-left: -2.5px; + } - .earlier-replies { - padding-bottom: 0; - margin-bottom: -5px; - } + .earlier-replies { + padding-bottom: 0; + margin-bottom: -5px; + } } .timeline-item.thread-last::before { - background: unset; - min-width: unset; - width: 0; - margin: 0; + background: unset; + min-width: unset; + width: 0; + margin: 0; } .more-replies { - padding-top: 0.3em !important; + padding-top: 0.3em !important; } .more-replies-text { - @include ellipsis; - display: block; - margin-left: 58px; - padding: 7px 0; + @include ellipsis; + display: block; + margin-left: 58px; + padding: 7px 0; } .timeline-item.thread.more-replies-thread { - padding: 0 0.75em; + padding: 0 0.75em; + + &::before { + top: 40px; + margin-bottom: 31px; + } + + .more-replies { + display: flex; + padding-top: unset !important; + margin-top: 8px; &::before { - top: 40px; - margin-bottom: 31px; + display: inline-block; + position: relative; + top: -1px; + line-height: 0.4em; } - .more-replies { - display: flex; - padding-top: unset !important; - margin-top: 8px; - - &::before { - display: inline-block; - position: relative; - top: -1px; - line-height: 0.4em; - } - - .more-replies-text { - display: inline; - } + .more-replies-text { + display: inline; } + } +} + +.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); } diff --git a/src/sass/tweet/video.scss b/src/sass/tweet/video.scss index 98a1c29..28fc125 100644 --- a/src/sass/tweet/video.scss +++ b/src/sass/tweet/video.scss @@ -1,68 +1,111 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; video { - max-height: 100%; - width: 100%; + height: 100%; + width: 100%; } .gallery-video { - display: flex; - overflow: hidden; -} - -.gallery-video.card-container { + display: flex; + overflow: hidden; + + &.card-container { flex-direction: column; -} + width: 100%; + } -.video-container { + > .attachment { min-height: 80px; min-width: 200px; max-height: 530px; margin: 0; - display: flex; - align-items: center; - justify-content: center; img { - max-height: 100%; - max-width: 100%; + max-height: 100%; + max-width: 100%; } + } +} + +.video-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 { - @include play-button; - background-color: $shadow; + @include play-button; + background-color: $shadow; - p { - position: relative; - z-index: 0; - text-align: center; - top: calc(50% - 20px); - font-size: 20px; - line-height: 1.3; - margin: 0 20px; - } + p { + position: relative; + z-index: 0; + text-align: center; + top: calc(50% - 20px); + font-size: 20px; + line-height: 1.3; + margin: 0 20px; + } - div { - position: relative; - z-index: 0; - top: calc(50% - 20px); - margin: 0 auto; - width: 40px; - height: 40px; - } + .overlay-circle { + position: relative; + z-index: 0; + top: calc(50% - 20px); + margin: 0 auto; + width: 40px; + height: 40px; + } - form { - width: 100%; - height: 100%; - align-items: center; - justify-content: center; - display: flex; - } + .overlay-duration { + position: absolute; + bottom: 8px; + left: 8px; + background-color: #0000007a; + line-height: 1em; + padding: 4px 6px 4px 6px; + border-radius: 5px; + font-weight: bold; + } - button { - padding: 5px 8px; - font-size: 16px; - } + form { + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + display: flex; + } + + button { + padding: 5px 8px; + font-size: 16px; + } } diff --git a/src/tid.nim b/src/tid.nim new file mode 100644 index 0000000..ba1f8ec --- /dev/null +++ b/src/tid.nim @@ -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) diff --git a/src/types.nim b/src/types.nim index f16fe6f..0a748ba 100644 --- a/src/types.nim +++ b/src/types.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import times, sequtils, options, tables, uri +import times, sequtils, options, tables import prefs_impl genPrefsType() @@ -11,21 +11,16 @@ type BadClientError* = object of CatchableError TimelineKind* {.pure.} = enum - tweets, replies, media + tweets, replies, media, articles - Api* {.pure.} = enum - tweetDetail - tweetResult - search - list - listBySlug - listMembers - listTweets - userRestId - userScreenName - userTweets - userTweetsAndReplies - userMedia + ApiUrl* = object + endpoint*: string + params*: seq[(string, string)] + skipTid*: bool + + ApiReq* = object + oauth*: ApiUrl + cookie*: ApiUrl RateLimit* = object limit*: int @@ -42,7 +37,7 @@ type pending*: int limited*: bool limitedAt*: int - apis*: Table[Api, RateLimit] + apis*: Table[string, RateLimit] case kind*: SessionKind of oauth: oauthToken*: string @@ -51,10 +46,6 @@ type authToken*: string ct0*: string - SessionAwareUrl* = object - oauthUrl*: Uri - cookieUrl*: Uri - Error* = enum null = 0 noUserMatches = 17 @@ -70,6 +61,7 @@ type rateLimited = 88 expiredToken = 89 listIdOrSlug = 112 + timelineUnavailable = 131 tweetNotFound = 144 tweetNotAuthorized = 179 forbidden = 200 @@ -106,6 +98,59 @@ type suspended*: bool joinDate*: DateTime + AccountInfo* = object + username*: string + fullname*: string + userPic*: string + joinDate*: DateTime + verifiedType*: VerifiedType + suspended*: bool + basedIn*: string + source*: string + usernameChanges*: int + lastUsernameChange*: DateTime + affiliateUsername*: string + affiliateLabel*: string + isIdentityVerified*: bool + verifiedSince*: DateTime + overrideVerifiedYear*: int + + Broadcast* = object + id*: string + title*: string + state*: string + thumb*: string + mediaKey*: string + m3u8Url*: string + totalWatched*: int + startTime*: DateTime + endTime*: DateTime + replayStart*: int + availableForReplay*: bool + user*: User + + 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 m3u8 = "application/x-mpegURL" mp4 = "video/mp4" @@ -129,10 +174,15 @@ type variants*: seq[VideoVariant] 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 kind*: QueryKind + view*: string text*: string filters*: seq[string] includes*: seq[string] @@ -140,12 +190,33 @@ type fromUser*: seq[string] since*: string until*: string - near*: string + minLikes*: string sep*: string Gif* = object url*: string thumb*: string + altText*: string + + Photo* = object + url*: string + altText*: string + + MediaKind* = enum + photoMedia + videoMedia + gifMedia + + Media* = object + case kind*: MediaKind + of photoMedia: + photo*: Photo + of videoMedia: + video*: Video + of gifMedia: + gif*: Gif + + MediaEntities* = seq[Media] GalleryPhoto* = object url*: string @@ -154,6 +225,44 @@ type 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 options*: seq[string] values*: seq[int] @@ -203,6 +312,12 @@ type likes*: int views*: int + ArticlePreview* = object + title*: string + previewText*: string + coverImage*: string + tweetId*: int64 + Tweet* = ref object id*: int64 threadId*: int64 @@ -221,13 +336,17 @@ type stats*: TweetStats retweet*: Option[Tweet] attribution*: Option[User] + attributionLink*: string mediaTags*: seq[User] quote*: Option[Tweet] card*: Option[Card] poll*: Option[Poll] - gif*: Option[Gif] - video*: Option[Video] - photos*: seq[string] + media*: MediaEntities + history*: seq[int64] + note*: string + isAd*: bool + isAI*: bool + articlePreview*: Option[ArticlePreview] Tweets* = seq[Tweet] @@ -241,6 +360,7 @@ type content*: Tweets hasMore*: bool cursor*: string + related*: bool Conversation* = ref object tweet*: Tweet @@ -248,6 +368,10 @@ type after*: Chain replies*: Result[Chain] + EditHistory* = object + latest*: Tweet + history*: Tweets + Timeline* = Result[Tweets] Profile* = object @@ -255,6 +379,7 @@ type photoRail*: PhotoRail pinned*: Option[Tweet] tweets*: Timeline + accountInfo*: AccountInfo List* = object id*: string @@ -265,6 +390,29 @@ type members*: int 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 tweets*: Table[string, Tweet] users*: Table[string, User] @@ -281,10 +429,20 @@ type hmacKey*: string base64Media*: bool minTokens*: int - enableRss*: bool + enableRSSUserTweets*: bool + enableRSSUserReplies*: bool + enableRSSUserMedia*: bool + enableRSSUserArticles*: bool + enableRSSSearch*: bool + enableRSSList*: bool enableDebug*: bool proxy*: string proxyAuth*: string + apiProxy*: string + disableTid*: bool + maxConcurrentReqs*: int + maxRetries*: int + retryDelayMs*: int rssCacheTime*: int listCacheTime*: int @@ -303,3 +461,24 @@ proc contains*(thread: Chain; tweet: Tweet): bool = proc add*(timeline: var seq[Tweets]; tweet: Tweet) = timeline.add @[tweet] + +proc getPhotos*(tweet: Tweet): seq[Photo] = + tweet.media.filterIt(it.kind == photoMedia).mapIt(it.photo) + +proc getVideos*(tweet: Tweet): seq[Video] = + tweet.media.filterIt(it.kind == videoMedia).mapIt(it.video) + +proc hasPhotos*(tweet: Tweet): bool = + tweet.media.anyIt(it.kind == photoMedia) + +proc hasVideos*(tweet: Tweet): bool = + tweet.media.anyIt(it.kind == videoMedia) + +proc hasGifs*(tweet: Tweet): bool = + tweet.media.anyIt(it.kind == gifMedia) + +proc getThumb*(media: Media): string = + case media.kind + of photoMedia: media.photo.url + of videoMedia: media.video.thumb + of gifMedia: media.gif.thumb diff --git a/src/utils.nim b/src/utils.nim index c96a6dd..95b46de 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, strformat, uri, tables, base64 +import sequtils, strutils, strformat, uri, tables, base64 import nimcrypto var @@ -9,7 +9,7 @@ var const https* = "https://" twimg* = "pbs.twimg.com/" - nitterParams = ["name", "tab", "id", "list", "referer", "scroll"] + nitterParams* = ["name", "tab", "id", "list", "referer", "scroll", "prefs"] twitterDomains = @[ "twitter.com", "pic.twitter.com", @@ -17,7 +17,9 @@ const "abs.twimg.com", "pbs.twimg.com", "video.twimg.com", - "x.com" + "x.com", + "pscp.tv", + "video.pscp.tv" ] proc setHmacKey*(key: string) = @@ -38,12 +40,14 @@ proc getVidUrl*(link: string): string = &"/video/{sig}/{encodeUrl(link)}" proc getPicUrl*(link: string): string = + if link.len == 0: return if base64Media: &"/pic/enc/{encode(link, safe=true)}" else: &"/pic/{encodeUrl(link)}" proc getOrigPicUrl*(link: string): string = + if link.len == 0: return if base64Media: &"/pic/orig/enc/{encode(link, safe=true)}" else: @@ -55,7 +59,13 @@ proc filterParams*(params: Table): seq[(string, string)] = result.add p 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 = isTwitterUrl(parseUri(url)) + +proc validateNumber*(value: string): string = + if value.anyIt(not it.isDigit): + return "" + return value diff --git a/src/views/about_account.nim b/src/views/about_account.nim new file mode 100644 index 0000000..aedd444 --- /dev/null +++ b/src/views/about_account.nim @@ -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 diff --git a/src/views/article.nim b/src/views/article.nim new file mode 100644 index 0000000..eedd4cf --- /dev/null +++ b/src/views/article.nim @@ -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() diff --git a/src/views/broadcast.nim b/src/views/broadcast.nim new file mode 100644 index 0000000..bfcb9ba --- /dev/null +++ b/src/views/broadcast.nim @@ -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 "
" + 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 "
" + 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 diff --git a/src/views/community.nim b/src/views/community.nim new file mode 100644 index 0000000..52f9041 --- /dev/null +++ b/src/views/community.nim @@ -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 diff --git a/src/views/embed.nim b/src/views/embed.nim index ba49f45..5136a35 100644 --- a/src/views/embed.nim +++ b/src/views/embed.nim @@ -1,22 +1,76 @@ # SPDX-License-Identifier: AGPL-3.0-only -import options import karax/[karaxdsl, vdom] from jester import Request -import ".."/[types, formatters] +import ".."/[types, formatters, prefs] import general, tweet -const doctype = "\n" +const + doctype = "\n" + embedResizeJs = staticRead("../../public/js/embedResize.js") proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = - let thumb = get(tweet.video).thumb - let vidUrl = getVideoEmbed(cfg, tweet.id) - let prefs = Prefs(hlsPlayback: true, mp4Playback: true) + let + video = tweet.getVideos()[0] + thumb = video.thumb + vidUrl = getVideoEmbed(cfg, tweet.id) + prefs = Prefs(hlsPlayback: true, mp4Playback: true, proxyVideos: defaultPrefs.proxyVideos) + tweetUrl = getLink(tweet) + let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb])) + base(target="_blank") body: 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 diff --git a/src/views/general.nim b/src/views/general.nim index 0091c74..d979898 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -29,19 +29,17 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode = tdiv(class="nav-item right"): icon "search", title="Search", href="/search" - if cfg.enableRss and rss.len > 0: + if rss.len > 0: icon "rss", title="RSS Feed", href=rss - icon "bird", title="Open in Twitter", href=canonical + icon "bird", title="Open in X", href=canonical a(href="https://liberapay.com/zedeus"): verbatim lp icon "info", title="About", href="/about" icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path)) proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; video=""; images: seq[string] = @[]; banner=""; ogTitle=""; - rss=""; canonical=""): VNode = - var theme = prefs.theme.toTheme - if "theme" in req.params: - theme = req.params["theme"].toTheme + rss=""; alternate=""; oembed=""): VNode = + let theme = prefs.theme.toTheme let ogType = if video.len > 0: "video" @@ -52,8 +50,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=19") - link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=106") + link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7") if theme.len > 0: 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, href=opensearchUrl) - if canonical.len > 0: - link(rel="canonical", href=canonical) + if alternate.len > 0: + link(rel="alternate", href=alternate, title="View on X") - if cfg.enableRss and rss.len > 0: + if rss.len > 0: link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed") + if 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: script(src="/js/hls.min.js", `defer`="") - script(src="/js/hlsPlayback.js", `defer`="") + script(src="/js/hlsPlayback.js?v=1", `defer`="") if prefs.infiniteScroll: script(src="/js/infiniteScroll.js", `defer`="") @@ -86,6 +88,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; text cfg.title meta(name="viewport", content="width=device-width, initial-scale=1.0") + meta(name="referrer", content="same-origin") meta(name="theme-color", content="#1F1F1F") meta(property="og:type", content=ogType) 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") for url in images: + if url.len == 0: continue let preloadUrl = if "400x400" in url: getPicUrl(url) else: getSmallPic(url) 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 # if this is done earlier, Chrome only preloads one image for some reason 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; 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")): renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, - rss, canonical) + rss, twitterLink, oembed) - body: - renderNavbar(cfg, req, rss, canonical) + let bodyClass = if prefs.stickyNav: "fixed-nav" else: "" + body(class=bodyClass): + renderNavbar(cfg, req, rss, twitterLink) tdiv(class="container"): body diff --git a/src/views/oembed.nimf b/src/views/oembed.nimf new file mode 100644 index 0000000..4f7f947 --- /dev/null +++ b/src/views/oembed.nimf @@ -0,0 +1,7 @@ +#? stdtmpl(subsChar = '$', metaChar = '#') +## SPDX-License-Identifier: AGPL-3.0-only +#proc renderOembedIframe*(embedUrl: string; maxwidth = 550): string = +# result = "" + +# result = result.strip() +#end proc diff --git a/src/views/preferences.nim b/src/views/preferences.nim index 1787704..b051a01 100644 --- a/src/views/preferences.nim +++ b/src/views/preferences.nim @@ -32,7 +32,8 @@ macro renderPrefs*(): untyped = result[2].add stmt -proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode = +proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]; + prefsUrl: string): VNode = buildHtml(tdiv(class="overlay-panel")): fieldset(class="preferences"): form(`method`="post", action="/saveprefs", autocomplete="off"): @@ -40,6 +41,14 @@ proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode renderPrefs() + legend: text "Bookmark" + p(class="bookmark-note"): + text "Save this URL to restore your preferences (?prefs works on all pages)" + pre(class="prefs-code"): + text prefsUrl + p(class="bookmark-note"): + verbatim "You can override preferences with query parameters (e.g. ?hlsPlayback=on). These overrides aren't saved to cookies, and links won't retain the parameters. Intended for configuring RSS feeds and other cookieless environments. Hover over a preference to see its name." + h4(class="note"): text "Preferences are stored client-side using cookies without any personal information." diff --git a/src/views/profile.nim b/src/views/profile.nim index 2b2e410..c9012ed 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -2,7 +2,7 @@ import strutils, strformat import karax/[karaxdsl, vdom, vstyles] -import renderutils, search +import renderutils, search, timeline import ".."/[types, utils, formatters] 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"): 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")): tdiv(class="profile-card-info"): let @@ -26,6 +33,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode = tdiv(class="profile-card-tabs-name"): linkUser(user, class="profile-card-fullname") + verifiedIcon(user) linkUser(user, class="profile-card-username") tdiv(class="profile-card-extra"): @@ -45,6 +53,11 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode = else: span: text place + if info.basedIn.len > 0: + tdiv(class="profile-location"): + span: icon "location" + span: text "Based in " & info.basedIn + if user.website.len > 0: tdiv(class="profile-website"): span: @@ -53,14 +66,14 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode = a(href=url): text url.shortLink tdiv(class="profile-joindate"): - span(title=getJoinDateFull(user)): + a(href=(&"/{user.username}/about"), title=getJoinDateFull(user)): icon "calendar", getJoinDate(user) tdiv(class="profile-card-extra-links"): ul(class="profile-statlist"): renderStat(user.tweets, "posts", text="Tweets") - renderStat(user.following, "following") - renderStat(user.followers, "followers") + renderStatLink(user.following, "following", &"/{user.username}/following") + renderStatLink(user.followers, "followers", &"/{user.username}/followers") renderStat(user.likes, "likes") proc renderPhotoRail(profile: Profile): VNode = @@ -93,7 +106,7 @@ proc renderBanner(banner: string): VNode = else: a(href=getPicUrl(banner), target="_blank"): genImg(banner) -proc renderProtected(username: string): VNode = +proc renderProtected*(username: string): VNode = buildHtml(tdiv(class="timeline-container")): tdiv(class="timeline-header timeline-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 = 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")): - if not prefs.hideBanner: + buildHtml(tdiv(class=("profile-tabs" & viewClass))): + if not isGalleryView and not prefs.hideBanner: tdiv(class="profile-banner"): renderBanner(profile.user.banner) - let sticky = if prefs.stickyProfile: " sticky" else: "" - tdiv(class=("profile-tab" & sticky)): - renderUserCard(profile.user, prefs) - if profile.photoRail.len > 0: - renderPhotoRail(profile) + if not isGalleryView: + let sticky = if prefs.stickyProfile: " sticky" else: "" + tdiv(class=("profile-tab" & sticky)): + renderUserCard(profile.user, prefs, profile.accountInfo) + if profile.photoRail.len > 0: + renderPhotoRail(profile) if profile.user.protected: renderProtected(profile.user.username) else: 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) diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index 41ef8df..af2f05d 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -4,14 +4,23 @@ import karax/[karaxdsl, vdom, vstyles] import ".."/[types, utils] const smallWebp* = "?name=small&format=webp" +const mediumWebp* = "?name=medium&format=webp" proc getSmallPic*(url: string): string = + if url.len == 0: return result = url if "?" notin url and not url.endsWith("placeholder.png"): result &= smallWebp 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 if class.len > 0: c = &"{c} {class}" buildHtml(tdiv(class="icon-container")): @@ -20,13 +29,15 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode = else: span(class=c, title=title) - if text.len > 0: - text " " & text + if label.len > 0: + text " " & label template verifiedIcon*(user: User): untyped {.dirty.} = if user.verifiedType != VerifiedType.none: let lower = ($user.verifiedType).toLowerAscii() - icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account") + buildHtml(tdiv(class=(&"verified-icon {lower}"))): + icon "circle", class="verified-icon-circle", title=(&"Verified {lower} account") + icon "ok", class="verified-icon-check", title=(&"Verified {lower} account") else: text "" @@ -40,7 +51,6 @@ proc linkUser*(user: User, class=""): VNode = buildHtml(a(href=href, class=class, title=nameText)): text nameText if isName: - verifiedIcon(user) if user.protected: text " " icon "lock", title="Protected account" @@ -64,20 +74,20 @@ proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNod text text proc genCheckbox*(pref, label: string; state: bool): VNode = - buildHtml(label(class="pref-group checkbox-container")): + buildHtml(label(class="pref-group checkbox-container", title=pref)): text label input(name=pref, `type`="checkbox", checked=state) span(class="checkbox") proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode = let p = placeholder - buildHtml(tdiv(class=("pref-group pref-input " & class))): + buildHtml(tdiv(class=("pref-group pref-input " & class), title=pref)): if label.len > 0: label(`for`=pref): text label input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0)) proc genSelect*(pref, label, state: string; options: seq[string]): VNode = - buildHtml(tdiv(class="pref-group pref-input")): + buildHtml(tdiv(class="pref-group pref-input", title=pref)): label(`for`=pref): text label select(name=pref): for opt in options: @@ -89,9 +99,16 @@ proc genDate*(pref, state: string): VNode = input(name=pref, `type`="date", value=state) icon "calendar" -proc genImg*(url: string; class=""): VNode = +proc genNumberInput*(pref, label, state, placeholder: string; class=""; autofocus=true; min="0"): VNode = + let p = placeholder + buildHtml(tdiv(class=("pref-group pref-input " & class))): + if label.len > 0: + label(`for`=pref): text label + input(name=pref, `type`="number", placeholder=p, value=state, autofocus=(autofocus and state.len == 0), min=min, step="1") + +proc genImg*(url: string; class=""; alt=""): VNode = buildHtml(): - img(src=getPicUrl(url), class=class, alt="", loading="lazy") + img(src=getPicUrl(url), class=class, alt=alt, loading="lazy") proc getTabClass*(query: Query; tab: QueryKind): string = if query.kind == tab: "tab-item active" diff --git a/src/views/rss.nimf b/src/views/rss.nimf index a6f069c..4738705 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -1,26 +1,49 @@ #? stdtmpl(subsChar = '$', metaChar = '#') ## SPDX-License-Identifier: AGPL-3.0-only -#import strutils, xmltree, strformat, options, unicode +#import strutils, sequtils, xmltree, strformat, options, unicode #import ../types, ../utils, ../formatters, ../prefs +## Snowflake ID cutoff for RSS GUID format transition +## Corresponds to approximately December 14, 2025 UTC +#const guidCutoff = 2000000000000000000'i64 # #proc getTitle(tweet: Tweet; retweet: string): string = -#if tweet.pinned: result = "Pinned: " -#elif retweet.len > 0: result = &"RT by @{retweet}: " -#elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: " +#var prefix = "" +#if tweet.pinned: prefix = "Pinned: " +#elif retweet.len > 0: prefix = &"RT by @{retweet}: " +#elif tweet.reply.len > 0: prefix = &"R to @{tweet.reply[0]}: " #end if -#var text = stripHtml(tweet.text) +#var text = strutils.splitWhitespace(stripHtml(tweet.text)).join(" ") ##if unicode.runeLen(text) > 32: ## text = unicode.runeSubStr(text, 0, 32) & "..." ##end if -#result &= xmltree.escape(text) -#if result.len > 0: return +#text = xmltree.escape(text) +## 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 -#if tweet.photos.len > 0: -# result &= "Image" -#elif tweet.video.isSome: -# result &= "Video" -#elif tweet.gif.isSome: -# result &= "Gif" +#if text.len > 0: +# result = prefix & text +# return +#end if +#if tweet.media.len > 0: +# result = prefix +# let firstKind = tweet.media[0].kind +# if tweet.media.anyIt(it.kind != firstKind): +# result &= "Media" +# else: +# case firstKind +# of photoMedia: result &= "Image" +# of videoMedia: result &= "Video" +# of gifMedia: result &= "Gif" +# end case +# end if +#end if +#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 proc # @@ -28,6 +51,92 @@ 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 + +#of videoMedia: +# let video = media.video + +
Video
+ +
+#of gifMedia: +# let gif = media.gif +# let thumb = &"{urlPrefix}{getPicUrl(gif.thumb)}" +# let url = &"{urlPrefix}{getPicUrl(gif.url)}" + +#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) +
+Link
+#if cardLink.len > 0: + +#end if +#if card.image.len > 0: + +#end if +#if title.len > 0: +# if card.image.len > 0: +
+# end if +${title} +#end if +#if cardLink.len > 0: +
+#end if +#if card.text.len > 0: +

${xmltree.escape(card.text)}

+#end if +#if cardLink.len > 0: +# let destText = if card.dest.len > 0: xmltree.escape(card.dest) else: xmltree.escape(cardLink) +${destText} +#elif card.dest.len > 0: +${xmltree.escape(card.dest)} +#end if +#end proc +# +#proc renderRssArticle(article: ArticlePreview; urlPrefix: string): string = +#let link = urlPrefix & "/i/article/" & $article.tweetId +
+Article
+ +#if article.coverImage.len > 0: + +#end if +#if article.title.len > 0: +# if article.coverImage.len > 0: +
+# end if +${xmltree.escape(article.title)} +#end if +
+#if article.previewText.len > 0: +

${xmltree.escape(article.previewText)}

+#end if +#end proc +# +#proc renderRssPoll(poll: Poll): string = +
+Poll +

+#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}
+#end for +#let votesStr = insertSep($poll.votes, ',') +${votesStr} votes • ${xmltree.escape(poll.status)} +

+#end proc +# #proc getTweetsWithPinned(profile: Profile): seq[Tweets] = #result = profile.tweets.content #if profile.pinned.isSome and result.len > 0: @@ -46,27 +155,28 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)} #end if #end proc # -#proc renderRssTweet(tweet: Tweet; cfg: Config): string = +#proc renderRssTweet(tweet: Tweet; cfg: Config; prefs: Prefs): string = #let tweet = tweet.retweet.get(tweet) #let urlPrefix = getUrlPrefix(cfg) -#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix) +#let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix) +#if text.len > 0:

${text.replace("\n", "
\n")}

-#if tweet.photos.len > 0: -# for photo in tweet.photos: - +#end if +#if tweet.media.len > 0: +# for media in tweet.media: +${renderRssMedia(media, tweet, urlPrefix)} # end for -#elif tweet.video.isSome: - -#elif tweet.gif.isSome: -# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}" -# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}" - -#elif tweet.card.isSome: -# let card = tweet.card.get() -# if card.image.len > 0: - -# end if +#elif tweet.card.isSome and tweet.card.get().kind notin {hidden, unknown}: +${renderRssCard(tweet.card.get(), prefs, urlPrefix)} +#end if +#if tweet.articlePreview.isSome: +${renderRssArticle(tweet.articlePreview.get(), urlPrefix)} +#end if +#if tweet.poll.isSome: +${renderRssPoll(tweet.poll.get())} +#end if +#if tweet.note.len > 0 and not prefs.hideCommunityNotes: +

Community note: ${replaceUrls(tweet.note, prefs, absolute=urlPrefix)}

#end if #if tweet.quote.isSome and get(tweet.quote).available: # let quoteTweet = get(tweet.quote) @@ -75,7 +185,7 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
${quoteTweet.user.fullname} (@${quoteTweet.user.username})

-${renderRssTweet(quoteTweet, cfg)} +${renderRssTweet(quoteTweet, cfg, prefs)}