Compare commits
237 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e98aa346d2 | |||
| bd9d492d36 | |||
| 7eed720894 | |||
| ac5ba9469e | |||
| 63891a04ff | |||
| ac2f93b361 | |||
| 1e33ca045d | |||
| 5a4c8dd12c | |||
| fb9107cff6 | |||
| 60cb10229f | |||
| ef1de42593 | |||
| 9b9c86a15c | |||
| 55d067957c | |||
| a8bc1bbb2d | |||
| f629507537 | |||
| 40d17bf042 | |||
| 6ab2143df0 | |||
| 3562b0708d | |||
| a86be15f85 | |||
| c956f7c373 | |||
| e4e6dd13e6 | |||
| 083d65a8cf | |||
| 1d57f1f432 | |||
| d5ff410c5d | |||
| 5a4faa0367 | |||
| 82099de55b | |||
| 74f5ff8acc | |||
| 4e38317582 | |||
| 8114eefa19 | |||
| 7d431781c3 | |||
| 0c7583432c | |||
| e7e7050c6e | |||
| 741060c78b | |||
| 3429667414 | |||
| fea6f59005 | |||
| b6ccea0c7a | |||
| b726767df4 | |||
| 33bf2c2397 | |||
| 7ce29bd8f1 | |||
| 91ff936cb3 | |||
| 0fefcf9917 | |||
| 35a929c415 | |||
| 4bf3df94f8 | |||
| 2898efab6b | |||
| b0773dd934 | |||
| d187b1cc3f | |||
| 95a9ee8dc5 | |||
| 61b6748d97 | |||
| 2bd664ae7d | |||
| a15d1ce16b | |||
| f257ce53ae | |||
| d45545cd53 | |||
| 90b664ffb7 | |||
| cbce620692 | |||
| 05b6dd2a43 | |||
| dcec1eb458 | |||
| 1c06a67afd | |||
| 40b1ba4e4e | |||
| b85e8c5d7d | |||
| db36f75519 | |||
| 5d28bd18c6 | |||
| 0a6e79e626 | |||
| 33dd9b6668 | |||
| a45227b883 | |||
| a92e79ebc3 | |||
| baeaf685d3 | |||
| 51b54852dc | |||
| 663f5a52e1 | |||
| 17fc2628f9 | |||
| e741385828 | |||
| 693a189462 | |||
| 7734d976f7 | |||
| a62ec9cbb4 | |||
| 4b9aec6fde | |||
| 064ec88080 | |||
| 71e65c84d7 | |||
| 436a873e4b | |||
| 96ec75fc7f | |||
| 7a08a9e132 | |||
| 31d210ca47 | |||
| dae68b4f13 | |||
| 8516ebe2b7 | |||
| b83227aaf5 | |||
| 404b06b5f3 | |||
| 2b922c049a | |||
| 78101df2cc | |||
| 12bbddf204 | |||
| 4979d07f2e | |||
| f038b53fa2 | |||
| 4748311f8d | |||
| d47eb8f0eb | |||
| 1657eeb769 | |||
| 25df682094 | |||
| 53edbbc4e9 | |||
| 5b4a3fe691 | |||
| f8a17fdaa5 | |||
| b0d9c1d51a | |||
| 78d788b27f | |||
| 824a7e346a | |||
| e8de18317e | |||
| 6b655cddd8 | |||
| 886f2d2a45 | |||
| bb6eb81a20 | |||
| 0bb0b7e78c | |||
| a666c4867c | |||
| 778eb35ee3 | |||
| 3f3196d103 | |||
| 68fc7b71c8 | |||
| 55d4469401 | |||
| bf36fc471b | |||
| 5aa0b65fea | |||
| 4fc7b873c4 | |||
| 6fe850b2c6 | |||
| 3768762fca | |||
| f89d2329d2 | |||
| 9e95615021 | |||
| 32b04a772b | |||
| 662ae90e22 | |||
| e40c61a6ae | |||
| 94c83f3811 | |||
| 83b0f8b55a | |||
| 41fa47bfbf | |||
| 661be438ec | |||
| 4f9ba9c7d6 | |||
| cb334a7d68 | |||
| cc28d21a62 | |||
| 92cd6abcf6 | |||
| 9ccfd8ee99 | |||
| 6da152db07 | |||
| 5be37737eb | |||
| 7702576369 | |||
| fb7c1d8710 | |||
| 54ba1e30b5 | |||
| bc38315d12 | |||
| 0664074749 | |||
| b9af77a9bd | |||
| 10b1d9c80f | |||
| a3d341e7a6 | |||
| 4d5091947c | |||
| 6fcd849eff | |||
| afad55749b | |||
| 5265de101d | |||
| 5edaea2359 | |||
| c0f2eea276 | |||
| 7728899948 | |||
| b43bfc5d42 | |||
| 81764ea0f8 | |||
| 5b6dae5228 | |||
| e38276a638 | |||
| 2e13d7b57c | |||
| 1aa9b0dba6 | |||
| 28d3ed7d9f | |||
| 19569bb19f | |||
| c6edec0490 | |||
| cdff5e9b1c | |||
| 52db03b73a | |||
| 583c858cdf | |||
| a9740fec8b | |||
| f8254c2f0f | |||
| d6be08d093 | |||
| 4dac9f0798 | |||
| 06ab1ea2e7 | |||
| c2819dab44 | |||
| eaedd2aee7 | |||
| 5e188647fc | |||
| e0d9dd0f9c | |||
| d17583286a | |||
| 209f453b79 | |||
| e1838e0933 | |||
| 623424f516 | |||
| 7b3fcdc622 | |||
| 1d20bd01cb | |||
| 58e73a14c5 | |||
| b0b335106d | |||
| 006b91c903 | |||
| 33bad37128 | |||
| b930a3d5bf | |||
| bd0be724f0 | |||
| 60a82563da | |||
| b8103cf501 | |||
| b62d73dbd3 | |||
| 4120558649 | |||
| 089275826c | |||
| edad09f4c9 | |||
| 32e3469e3a | |||
| 735b30c2da | |||
| 537af7fd5e | |||
| 7d14789910 | |||
| 7abcb489f4 | |||
| 14f9a092d8 | |||
| fcd74e8048 | |||
| 4250245263 | |||
| b8fe212e94 | |||
| 84dcf49079 | |||
| 82beb5da8c | |||
| 282ce8b0e9 | |||
| 37b58a5a7e | |||
| 898b19b92f | |||
| 986b91ac73 | |||
| 4ccf350dc7 | |||
| 7630f57f17 | |||
| 03794a8d4a | |||
| ae9fa02bf5 | |||
| 88b005c9da | |||
| a3e11e3272 | |||
| 45808361af | |||
| 8df5256c1d | |||
| 6e8744943f | |||
| 5c08e6a774 | |||
| 30bdf3a14e | |||
| 12504bcffe | |||
| c3d9441370 | |||
| 51714b5ad2 | |||
| e8b5cbef7b | |||
| 3d8858f0d8 | |||
| bbd68e6840 | |||
| 3572dd7771 | |||
| d7ca353a55 | |||
| 54e6ce14ac | |||
| 967f5e50f9 | |||
| 624394430c | |||
| 5725780c99 | |||
| 20b5cce5dc | |||
| 39192bf191 | |||
| 59a72831c7 | |||
| 72d8f35cd1 | |||
| 50f821dbd8 | |||
| cc5841df30 | |||
| f881226b22 | |||
| 4c4d5485a0 | |||
| afbdbd293e | |||
| 67203a431d | |||
| b290f6fd29 | |||
| 0bc3c153d9 | |||
| dcf73354ff | |||
| 38985af6ed | |||
| f7e878c126 |
@@ -7,56 +7,105 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: docker-publish-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: zedeus/nitter
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
uses: ./.github/workflows/run-tests.yml
|
uses: ./.github/workflows/run-tests.yml
|
||||||
build-docker-amd64:
|
secrets: inherit
|
||||||
|
|
||||||
|
build:
|
||||||
needs: [tests]
|
needs: [tests]
|
||||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- runner: ubuntu-24.04
|
||||||
|
platform: linux/amd64
|
||||||
|
- runner: ubuntu-24.04-arm
|
||||||
|
platform: linux/arm64
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- name: Prepare platform name
|
||||||
with:
|
run: echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
|
||||||
fetch-depth: 0
|
env:
|
||||||
|
platform: ${{ matrix.platform }}
|
||||||
|
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
uses: docker/setup-buildx-action@v3
|
||||||
uses: docker/setup-buildx-action@v2
|
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Build and push AMD64 Docker image
|
|
||||||
uses: docker/build-push-action@v3
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: ${{ matrix.platform }}
|
||||||
push: true
|
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||||
tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }}
|
provenance: false
|
||||||
build-docker-arm64:
|
sbom: false
|
||||||
needs: [tests]
|
|
||||||
runs-on: buildjet-2vcpu-ubuntu-2204-arm
|
- name: Export digest
|
||||||
steps:
|
run: |
|
||||||
- uses: actions/checkout@v3
|
mkdir -p "${{ runner.temp }}/digests"
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
name: digests-${{ env.PLATFORM_PAIR }}
|
||||||
|
path: ${{ runner.temp }}/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
# Combine the per-arch digests into one multi-arch manifest so that
|
||||||
|
# `docker pull zedeus/nitter:latest` serves the right image on any CPU.
|
||||||
|
merge:
|
||||||
|
needs: [build]
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: ${{ runner.temp }}/digests
|
||||||
|
pattern: digests-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
uses: docker/setup-buildx-action@v3
|
||||||
uses: docker/setup-buildx-action@v2
|
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Build and push ARM64 Docker image
|
|
||||||
uses: docker/build-push-action@v3
|
- name: Create manifest list and push
|
||||||
with:
|
working-directory: ${{ runner.temp }}/digests
|
||||||
context: .
|
run: |
|
||||||
file: ./Dockerfile.arm64
|
docker buildx imagetools create \
|
||||||
platforms: linux/arm64
|
-t ${{ env.IMAGE }}:latest \
|
||||||
push: true
|
-t ${{ env.IMAGE }}:latest-arm64 \
|
||||||
tags: zedeus/nitter:latest-arm64,zedeus/nitter:${{ github.sha }}-arm64
|
-t ${{ env.IMAGE }}:${{ github.sha }} \
|
||||||
|
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: Inspect image
|
||||||
|
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ github.sha }}
|
||||||
|
|||||||
+130
-25
@@ -8,38 +8,143 @@ on:
|
|||||||
- master
|
- master
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
|
# Ensure that multiple runs on the same branch do not overlap.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
build-test:
|
||||||
runs-on: ubuntu-latest
|
name: Build and test
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
nim: ["2.0.x", "2.2.x", "devel"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- name: Checkout Code
|
||||||
with:
|
uses: actions/checkout@v6
|
||||||
fetch-depth: 0
|
|
||||||
- name: Cache nimble
|
- name: Cache Nimble Dependencies
|
||||||
id: cache-nimble
|
id: cache-nimble
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.nimble
|
path: |
|
||||||
key: nimble-${{ hashFiles('*.nimble') }}
|
~/.nimble/pkgcache
|
||||||
restore-keys: "nimble-"
|
~/.nimble/packages_official.json
|
||||||
- uses: actions/setup-python@v4
|
key: ${{ matrix.nim }}-nimble-v6-${{ hashFiles('*.nimble') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ matrix.nim }}-nimble-v6-
|
||||||
|
|
||||||
|
- name: Setup Nim
|
||||||
|
uses: jiro4989/setup-nim-action@v2
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
nim-version: ${{ matrix.nim }}
|
||||||
cache: "pip"
|
use-nightlies: true
|
||||||
- uses: jiro4989/setup-nim-action@v1
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build Project
|
||||||
|
run: nimble build -Y
|
||||||
|
|
||||||
|
- name: Upload 2.2.x build artifact
|
||||||
|
if: matrix.nim == '2.2.x'
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
nim-version: "1.x"
|
name: nitter-linux-nim-2.2.x-${{ github.sha }}
|
||||||
- run: nimble build -d:release -Y
|
path: |
|
||||||
- run: pip install seleniumbase
|
./nitter
|
||||||
- run: seleniumbase install chromedriver
|
if-no-files-found: error
|
||||||
- uses: supercharge/redis-github-action@1.5.0
|
|
||||||
- name: Prepare Nitter
|
integration-test:
|
||||||
|
needs: [build-test]
|
||||||
|
name: Integration test
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Install runtime deps
|
||||||
|
run: |
|
||||||
|
sudo apt-get install -y --no-install-recommends libsass-dev libpcre3
|
||||||
|
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Cache pipx (poetry)
|
||||||
|
uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
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
|
||||||
|
uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.nimble/pkgcache
|
||||||
|
~/.nimble/packages_official.json
|
||||||
|
key: 2.2.x-nimble-v6-${{ hashFiles('*.nimble') }}
|
||||||
|
restore-keys: |
|
||||||
|
2.2.x-nimble-v6-
|
||||||
|
|
||||||
|
- name: Setup Nim
|
||||||
|
uses: jiro4989/setup-nim-action@v2
|
||||||
|
with:
|
||||||
|
nim-version: 2.2.x
|
||||||
|
use-nightlies: true
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Install Nimble dependencies
|
||||||
|
run: nimble install -y --depsOnly
|
||||||
|
|
||||||
|
- name: Download 2.2.x build artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: nitter-linux-nim-2.2.x-${{ github.sha }}
|
||||||
|
path: .
|
||||||
|
|
||||||
|
- name: Make nitter binary executable
|
||||||
|
run: chmod +x ./nitter
|
||||||
|
|
||||||
|
- name: Prepare Nitter Environment
|
||||||
run: |
|
run: |
|
||||||
sudo apt install libsass-dev -y
|
|
||||||
cp nitter.example.conf nitter.conf
|
cp nitter.example.conf nitter.conf
|
||||||
nimble md
|
sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf
|
||||||
nimble scss
|
sed -i 's/maxRetries = 1/maxRetries = 10/g' nitter.conf
|
||||||
- name: Run tests
|
|
||||||
|
nim r tools/rendermd.nim
|
||||||
|
nim r tools/gencss.nim
|
||||||
|
|
||||||
|
echo '${{ secrets.SESSIONS }}' | head -n1
|
||||||
|
echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl
|
||||||
|
|
||||||
|
- name: Run Tests
|
||||||
run: |
|
run: |
|
||||||
./nitter &
|
./nitter &
|
||||||
pytest -n4 tests
|
cd tests
|
||||||
|
poetry run pytest -n3 --reruns=5 --rs .
|
||||||
|
|||||||
@@ -10,4 +10,11 @@ nitter
|
|||||||
/public/css/style.css
|
/public/css/style.css
|
||||||
/public/md/*.html
|
/public/md/*.html
|
||||||
nitter.conf
|
nitter.conf
|
||||||
|
guest_accounts.json*
|
||||||
|
sessions.json*
|
||||||
dump.rdb
|
dump.rdb
|
||||||
|
*.bak
|
||||||
|
/tools/*.json*
|
||||||
|
nimbledeps/
|
||||||
|
nimble.paths
|
||||||
|
nimble.develop
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
FROM nimlang/nim:1.6.10-alpine-regular as nim
|
FROM nimlang/nim:2.2.6-alpine-regular as nim
|
||||||
LABEL maintainer="setenforce@protonmail.com"
|
LABEL maintainer="setenforce@protonmail.com"
|
||||||
|
|
||||||
RUN apk --no-cache add libsass-dev pcre
|
RUN apk --no-cache add libsass-dev pcre
|
||||||
@@ -9,13 +9,13 @@ COPY nitter.nimble .
|
|||||||
RUN nimble install -y --depsOnly
|
RUN nimble install -y --depsOnly
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN nimble build -d:danger -d:lto -d:strip \
|
RUN nimble build -d:danger -d:lto -d:strip --mm:refc \
|
||||||
&& nimble scss \
|
&& nimble scss \
|
||||||
&& nimble md
|
&& nimble md
|
||||||
|
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
WORKDIR /src/
|
WORKDIR /src/
|
||||||
RUN apk --no-cache add pcre ca-certificates
|
RUN apk --no-cache add pcre ca-certificates openssl
|
||||||
COPY --from=nim /src/nitter/nitter ./
|
COPY --from=nim /src/nitter/nitter ./
|
||||||
COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf
|
COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf
|
||||||
COPY --from=nim /src/nitter/public ./public
|
COPY --from=nim /src/nitter/public ./public
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
FROM alpine:3.17 as nim
|
|
||||||
LABEL maintainer="setenforce@protonmail.com"
|
|
||||||
|
|
||||||
RUN apk --no-cache add gcc git libc-dev libsass-dev "nim=1.6.8-r0" nimble pcre
|
|
||||||
|
|
||||||
WORKDIR /src/nitter
|
|
||||||
|
|
||||||
COPY nitter.nimble .
|
|
||||||
RUN nimble install -y --depsOnly
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN nimble build -d:danger -d:lto -d:strip \
|
|
||||||
&& nimble scss \
|
|
||||||
&& nimble md
|
|
||||||
|
|
||||||
FROM alpine:3.17
|
|
||||||
WORKDIR /src/
|
|
||||||
RUN apk --no-cache add ca-certificates pcre openssl1.1-compat
|
|
||||||
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
|
|
||||||
CMD ./nitter
|
|
||||||
@@ -4,27 +4,35 @@
|
|||||||
[](https://github.com/zedeus/nitter/actions/workflows/build-docker.yml)
|
[](https://github.com/zedeus/nitter/actions/workflows/build-docker.yml)
|
||||||
[](#license)
|
[](#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).
|
||||||
|
|
||||||
A free and open source alternative Twitter front-end focused on privacy and
|
A free and open source alternative Twitter front-end focused on privacy and
|
||||||
performance. \
|
performance. \
|
||||||
Inspired by the [Invidious](https://github.com/iv-org/invidious)
|
Inspired by the [Invidious](https://github.com/iv-org/invidious) project.
|
||||||
project.
|
|
||||||
|
|
||||||
- No JavaScript or ads
|
- No JavaScript or ads
|
||||||
- All requests go through the backend, client never talks to Twitter
|
- All requests go through the backend, client never talks to Twitter
|
||||||
- Prevents Twitter from tracking your IP or JavaScript fingerprint
|
- Prevents Twitter from tracking your IP or JavaScript fingerprint
|
||||||
- Uses Twitter's unofficial API (no rate limits or developer account required)
|
- Uses Twitter's unofficial API (no developer account required)
|
||||||
- Lightweight (for [@nim_lang](https://nitter.net/nim_lang), 60KB vs 784KB from twitter.com)
|
- Lightweight (for [@nim_lang](https://nitter.net/nim_lang), 60KB vs 784KB from twitter.com)
|
||||||
- RSS feeds
|
- RSS feeds
|
||||||
- Themes
|
- Themes
|
||||||
- Mobile support (responsive design)
|
- Mobile support (responsive design)
|
||||||
- AGPLv3 licensed, no proprietary instances permitted
|
- AGPLv3 licensed, no proprietary instances permitted
|
||||||
|
|
||||||
Liberapay: https://liberapay.com/zedeus \
|
<details>
|
||||||
Patreon: https://patreon.com/nitter \
|
<summary>Donations</summary>
|
||||||
BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \
|
Liberapay: https://liberapay.com/zedeus<br>
|
||||||
ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \
|
Patreon: https://patreon.com/nitter<br>
|
||||||
LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \
|
BTC: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55<br>
|
||||||
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
|
ETH: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460<br>
|
||||||
|
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL<br>
|
||||||
|
SOL: ANsyGNXFo6osuFwr1YnUqif2RdoYRhc27WdyQNmmETSW<br>
|
||||||
|
ZEC: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt
|
||||||
|
</details>
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
@@ -42,12 +50,13 @@ maintained by the community.
|
|||||||
|
|
||||||
## Why?
|
## Why?
|
||||||
|
|
||||||
It's impossible to use Twitter without JavaScript enabled. For privacy-minded
|
It's impossible to use Twitter without JavaScript enabled, and as of 2024 you
|
||||||
folks, preventing JavaScript analytics and IP-based tracking is important, but
|
need to sign up. For privacy-minded folks, preventing JavaScript analytics and
|
||||||
apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind
|
IP-based tracking is important, but apart from using a VPN and uBlock/uMatrix,
|
||||||
a VPN and using heavy-duty adblockers, you can get accurately tracked with your
|
it's impossible. Despite being behind a VPN and using heavy-duty adblockers,
|
||||||
[browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/),
|
you can get accurately tracked with your [browser's
|
||||||
[no JavaScript required](https://noscriptfingerprint.com/). This all became
|
fingerprint](https://restoreprivacy.com/browser-fingerprinting/), [no
|
||||||
|
JavaScript required](https://noscriptfingerprint.com/). This all became
|
||||||
particularly important after Twitter [removed the
|
particularly important after Twitter [removed the
|
||||||
ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws)
|
ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws)
|
||||||
for users to control whether their data gets sent to advertisers.
|
for users to control whether their data gets sent to advertisers.
|
||||||
@@ -71,19 +80,21 @@ Twitter account.
|
|||||||
|
|
||||||
- libpcre
|
- libpcre
|
||||||
- libsass
|
- libsass
|
||||||
- redis
|
- redis/valkey
|
||||||
|
|
||||||
To compile Nitter you need a Nim installation, see
|
To compile Nitter you need a Nim installation, see
|
||||||
[nim-lang.org](https://nim-lang.org/install.html) for details. It is possible to
|
[nim-lang.org](https://nim-lang.org/install.html) for details. It is possible
|
||||||
install it system-wide or in the user directory you create below.
|
to install it system-wide or in the user directory you create below.
|
||||||
|
|
||||||
To compile the scss files, you need to install `libsass`. On Ubuntu and Debian,
|
To compile the scss files, you need to install `libsass`. On Ubuntu and Debian,
|
||||||
you can use `libsass-dev`.
|
you can use `libsass-dev`.
|
||||||
|
|
||||||
Redis is required for caching and in the future for account info. It should be
|
Redis is required for caching and in the future for account info. As of 2024
|
||||||
available on most distros as `redis` or `redis-server` (Ubuntu/Debian).
|
Redis is no longer open source, so using the fork Valkey is recommended. It
|
||||||
Running it with the default config is fine, Nitter's default config is set to
|
should be available on most distros as `redis` or `redis-server`
|
||||||
use the default Redis port and localhost.
|
(Ubuntu/Debian), or `valkey`/`valkey-server`. Running it with the default
|
||||||
|
config is fine, Nitter's default config is set to use the default port and
|
||||||
|
localhost.
|
||||||
|
|
||||||
Here's how to create a `nitter` user, clone the repo, and build the project
|
Here's how to create a `nitter` user, clone the repo, and build the project
|
||||||
along with the scss and md files.
|
along with the scss and md files.
|
||||||
@@ -93,9 +104,9 @@ along with the scss and md files.
|
|||||||
# su nitter
|
# su nitter
|
||||||
$ git clone https://github.com/zedeus/nitter
|
$ git clone https://github.com/zedeus/nitter
|
||||||
$ cd nitter
|
$ cd nitter
|
||||||
$ nimble build -d:release
|
$ nimble -l build -d:danger --mm:refc
|
||||||
$ nimble scss
|
$ nimble -l scss
|
||||||
$ nimble md
|
$ nimble -l md
|
||||||
$ cp nitter.example.conf nitter.conf
|
$ cp nitter.example.conf nitter.conf
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -112,12 +123,23 @@ performance reasons.
|
|||||||
|
|
||||||
Page for the Docker image: https://hub.docker.com/r/zedeus/nitter
|
Page for the Docker image: https://hub.docker.com/r/zedeus/nitter
|
||||||
|
|
||||||
#### NOTE: For ARM64 support, please use the separate ARM64 docker image: [`zedeus/nitter:latest-arm64`](https://hub.docker.com/r/zedeus/nitter/tags).
|
#### NOTE: The published image is multi-arch — `zedeus/nitter:latest` runs natively on both `amd64` and `arm64`.
|
||||||
|
|
||||||
To run Nitter with Docker, you'll need to install and run Redis separately
|
To run Nitter with Docker, you'll need to install and run Redis separately
|
||||||
before you can run the container. See below for how to also run Redis using
|
before you can run the container. See below for how to also run Redis using
|
||||||
Docker.
|
Docker.
|
||||||
|
|
||||||
|
First create your config file. The Docker commands mount it into the container,
|
||||||
|
so it has to exist on the host beforehand. If you've cloned the repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp nitter.example.conf nitter.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
If you're using the prebuilt image without a local clone, download
|
||||||
|
[`nitter.example.conf`](https://raw.githubusercontent.com/zedeus/nitter/master/nitter.example.conf)
|
||||||
|
and save it as `nitter.conf` instead.
|
||||||
|
|
||||||
To build and run Nitter in Docker:
|
To build and run Nitter in Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -125,8 +147,6 @@ docker build -t nitter:latest .
|
|||||||
docker run -v $(pwd)/nitter.conf:/src/nitter.conf -d --network host nitter:latest
|
docker run -v $(pwd)/nitter.conf:/src/nitter.conf -d --network host nitter:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: For ARM64, use this Dockerfile: [`Dockerfile.arm64`](https://github.com/zedeus/nitter/blob/master/Dockerfile.arm64).
|
|
||||||
|
|
||||||
A prebuilt Docker image is provided as well:
|
A prebuilt Docker image is provided as well:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -140,8 +160,11 @@ Change `redisHost` from `localhost` to `nitter-redis` in `nitter.conf`, then run
|
|||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Note the Docker commands expect a `nitter.conf` file in the directory you run
|
Note the Docker commands mount `nitter.conf` (and `sessions.jsonl` for
|
||||||
them.
|
docker-compose) from the directory you run them in. If a mounted file doesn't
|
||||||
|
exist, Docker silently creates a directory in its place and the container fails
|
||||||
|
with `not a directory: Are you trying to mount a directory onto a file`. Remove
|
||||||
|
that directory and create the file as shown above.
|
||||||
|
|
||||||
### systemd
|
### systemd
|
||||||
|
|
||||||
|
|||||||
+5
-6
@@ -7,12 +7,11 @@
|
|||||||
|
|
||||||
# disable annoying warnings
|
# disable annoying warnings
|
||||||
warning("GcUnsafe2", off)
|
warning("GcUnsafe2", off)
|
||||||
|
warning("HoleEnumConv", off)
|
||||||
hint("XDeclaredButNotUsed", off)
|
hint("XDeclaredButNotUsed", off)
|
||||||
hint("XCannotRaiseY", off)
|
hint("XCannotRaiseY", off)
|
||||||
hint("User", off)
|
hint("User", off)
|
||||||
|
# begin Nimble config (version 2)
|
||||||
const
|
when withDir(thisDir(), system.fileExists("nimble.paths")):
|
||||||
nimVersion = (major: NimMajor, minor: NimMinor, patch: NimPatch)
|
include "nimble.paths"
|
||||||
|
# end Nimble config
|
||||||
when nimVersion >= (1, 6, 0):
|
|
||||||
warning("HoleEnumConv", off)
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ services:
|
|||||||
- "127.0.0.1:8080:8080" # Replace with "8080:8080" if you don't use a reverse proxy
|
- "127.0.0.1:8080:8080" # Replace with "8080:8080" if you don't use a reverse proxy
|
||||||
volumes:
|
volumes:
|
||||||
- ./nitter.conf:/src/nitter.conf:Z,ro
|
- ./nitter.conf:/src/nitter.conf:Z,ro
|
||||||
|
- ./sessions.jsonl:/src/sessions.jsonl:Z,ro # Run get_sessions.py to get the credentials
|
||||||
depends_on:
|
depends_on:
|
||||||
- nitter-redis
|
- nitter-redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+12
-8
@@ -22,16 +22,20 @@ redisMaxConnections = 30
|
|||||||
[Config]
|
[Config]
|
||||||
hmacKey = "secretkey" # random key for cryptographic signing of video urls
|
hmacKey = "secretkey" # random key for cryptographic signing of video urls
|
||||||
base64Media = false # use base64 encoding for proxied media urls
|
base64Media = false # use base64 encoding for proxied media urls
|
||||||
enableRSS = true # set this to false to disable RSS feeds
|
enableRSS = true # master switch, set to false to disable all RSS feeds
|
||||||
enableDebug = false # enable request logs and debug endpoints (/.tokens)
|
enableRSSUserTweets = true # /@user/rss
|
||||||
|
enableRSSUserReplies = true # /@user/with_replies/rss
|
||||||
|
enableRSSUserMedia = true # /@user/media/rss
|
||||||
|
enableRSSSearch = true # /search/rss and /@user/search/rss
|
||||||
|
enableRSSList = true # list RSS feeds
|
||||||
|
enableDebug = false # enable request logs and debug endpoints (/.sessions)
|
||||||
proxy = "" # http/https url, SOCKS proxies are not supported
|
proxy = "" # http/https url, SOCKS proxies are not supported
|
||||||
proxyAuth = ""
|
proxyAuth = ""
|
||||||
tokenCount = 10
|
apiProxy = "" # nitter-proxy host, e.g. localhost:7000
|
||||||
# minimum amount of usable tokens. tokens are used to authorize API requests,
|
disableTid = false # enable this if cookie-based auth is failing
|
||||||
# but they expire after ~1 hour, and have a limit of 500 requests per endpoint.
|
maxConcurrentReqs = 2 # max requests at a time per session to avoid race conditions
|
||||||
# the limits reset every 15 minutes, and the pool is filled up so there's
|
maxRetries = 1 # max number of retries on rate limit errors
|
||||||
# always at least `tokenCount` usable tokens. only increase this if you receive
|
retryDelayMs = 150 # delay in ms between retries
|
||||||
# major bursts all the time and don't have a rate limiting setup via e.g. nginx
|
|
||||||
|
|
||||||
# Change default preferences here, see src/prefs_impl.nim for a complete list
|
# Change default preferences here, see src/prefs_impl.nim for a complete list
|
||||||
[Preferences]
|
[Preferences]
|
||||||
|
|||||||
+14
-15
@@ -10,25 +10,24 @@ bin = @["nitter"]
|
|||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
|
|
||||||
requires "nim >= 1.4.8"
|
requires "nim >= 2.0.0"
|
||||||
requires "jester#baca3f"
|
requires "jester == 0.6.0"
|
||||||
requires "karax#5cf360c"
|
requires "karax == 1.5.0"
|
||||||
requires "sass#7dfdd03"
|
requires "sass == 0.2.0"
|
||||||
requires "nimcrypto#4014ef9"
|
requires "nimcrypto == 0.7.3"
|
||||||
requires "markdown#158efe3"
|
requires "markdown == 0.8.8"
|
||||||
requires "packedjson#9e6fbb6"
|
requires "packedjson#9e6fbb6"
|
||||||
requires "supersnappy#6c94198"
|
requires "supersnappy == 2.1.4"
|
||||||
requires "redpool#8b7c1db"
|
requires "redpool == 0.2.2"
|
||||||
requires "https://github.com/zedeus/redis#d0a0e6f"
|
requires "zippy == 0.10.19"
|
||||||
requires "zippy#ca5989a"
|
requires "flatty == 0.4.0"
|
||||||
requires "flatty#e668085"
|
requires "jsony == 1.1.6"
|
||||||
requires "jsony#ea811be"
|
requires "oauth == 0.11"
|
||||||
|
|
||||||
|
|
||||||
# Tasks
|
# Tasks
|
||||||
|
|
||||||
task scss, "Generate css":
|
task scss, "Generate css":
|
||||||
exec "nimble c --hint[Processing]:off -d:danger -r tools/gencss"
|
exec "nim r --hint[Processing]:off tools/gencss"
|
||||||
|
|
||||||
task md, "Render md":
|
task md, "Render md":
|
||||||
exec "nimble c --hint[Processing]:off -d:danger -r tools/rendermd"
|
exec "nim r --hint[Processing]:off tools/rendermd"
|
||||||
|
|||||||
Vendored
+121
-26
@@ -1,16 +1,18 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'fontello';
|
font-family: "fontello";
|
||||||
src: url('/fonts/fontello.eot?21002321');
|
src: url("/fonts/fontello.eot?49059696");
|
||||||
src: url('/fonts/fontello.eot?21002321#iefix') format('embedded-opentype'),
|
src:
|
||||||
url('/fonts/fontello.woff2?21002321') format('woff2'),
|
url("/fonts/fontello.eot?49059696#iefix") format("embedded-opentype"),
|
||||||
url('/fonts/fontello.woff?21002321') format('woff'),
|
url("/fonts/fontello.woff2?49059696") format("woff2"),
|
||||||
url('/fonts/fontello.ttf?21002321') format('truetype'),
|
url("/fonts/fontello.woff?49059696") format("woff"),
|
||||||
url('/fonts/fontello.svg?21002321#fontello') format('svg');
|
url("/fonts/fontello.ttf?49059696") format("truetype"),
|
||||||
|
url("/fonts/fontello.svg?49059696#fontello") format("svg");
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
[class^="icon-"]:before, [class*=" icon-"]:before {
|
[class^="icon-"]:before,
|
||||||
|
[class*=" icon-"]:before {
|
||||||
font-family: "fontello";
|
font-family: "fontello";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
@@ -19,6 +21,7 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
text-decoration: inherit;
|
text-decoration: inherit;
|
||||||
width: 1em;
|
width: 1em;
|
||||||
|
margin-right: 0.2em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
/* For safety - reset parent styles, that can break glyph codes*/
|
/* For safety - reset parent styles, that can break glyph codes*/
|
||||||
@@ -33,21 +36,113 @@
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-heart:before { content: '\2665'; } /* '♥' */
|
.icon-views:before {
|
||||||
.icon-quote:before { content: '\275e'; } /* '❞' */
|
content: "\e800";
|
||||||
.icon-comment:before { content: '\e802'; } /* '' */
|
}
|
||||||
.icon-ok:before { content: '\e803'; } /* '' */
|
|
||||||
.icon-play:before { content: '\e804'; } /* '' */
|
/* '' */
|
||||||
.icon-link:before { content: '\e805'; } /* '' */
|
.icon-heart:before {
|
||||||
.icon-calendar:before { content: '\e806'; } /* '' */
|
content: "\e801";
|
||||||
.icon-location:before { content: '\e807'; } /* '' */
|
}
|
||||||
.icon-picture:before { content: '\e809'; } /* '' */
|
|
||||||
.icon-lock:before { content: '\e80a'; } /* '' */
|
/* '' */
|
||||||
.icon-down:before { content: '\e80b'; } /* '' */
|
.icon-quote:before {
|
||||||
.icon-retweet:before { content: '\e80d'; } /* '' */
|
content: "\e802";
|
||||||
.icon-search:before { content: '\e80e'; } /* '' */
|
}
|
||||||
.icon-pin:before { content: '\e80f'; } /* '' */
|
|
||||||
.icon-cog:before { content: '\e812'; } /* '' */
|
/* '' */
|
||||||
.icon-rss-feed:before { content: '\e813'; } /* '' */
|
.icon-comment:before {
|
||||||
.icon-info:before { content: '\f128'; } /* '' */
|
content: "\e803";
|
||||||
.icon-bird:before { content: '\f309'; } /* '' */
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-group:before {
|
||||||
|
content: "\e804";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-play:before {
|
||||||
|
content: "\e805";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-link:before {
|
||||||
|
content: "\e806";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-calendar:before {
|
||||||
|
content: "\e807";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-location:before {
|
||||||
|
content: "\e808";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-picture:before {
|
||||||
|
content: "\e809";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-lock:before {
|
||||||
|
content: "\e80a";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-down:before {
|
||||||
|
content: "\e80b";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-retweet:before {
|
||||||
|
content: "\e80c";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-search:before {
|
||||||
|
content: "\e80d";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-pin:before {
|
||||||
|
content: "\e80e";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-cog:before {
|
||||||
|
content: "\e80f";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-rss:before {
|
||||||
|
content: "\e810";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-ok:before {
|
||||||
|
content: "\e811";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-attention:before {
|
||||||
|
content: "\e812";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-circle:before {
|
||||||
|
content: "\f111";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-info:before {
|
||||||
|
content: "\f128";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-bird:before {
|
||||||
|
content: "\f309";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
Font license info
|
Font license info
|
||||||
|
|
||||||
|
|
||||||
|
## Modern Pictograms
|
||||||
|
|
||||||
|
Copyright (c) 2012 by John Caserta. All rights reserved.
|
||||||
|
|
||||||
|
Author: John Caserta
|
||||||
|
License: SIL (http://scripts.sil.org/OFL)
|
||||||
|
Homepage: http://thedesignoffice.org/project/modern-pictograms/
|
||||||
|
|
||||||
|
|
||||||
## Entypo
|
## Entypo
|
||||||
|
|
||||||
Copyright (C) 2012 by Daniel Bruce
|
Copyright (C) 2012 by Daniel Bruce
|
||||||
@@ -37,12 +46,3 @@ Font license info
|
|||||||
Homepage: http://aristeides.com/
|
Homepage: http://aristeides.com/
|
||||||
|
|
||||||
|
|
||||||
## Modern Pictograms
|
|
||||||
|
|
||||||
Copyright (c) 2012 by John Caserta. All rights reserved.
|
|
||||||
|
|
||||||
Author: John Caserta
|
|
||||||
License: SIL (http://scripts.sil.org/OFL)
|
|
||||||
Homepage: http://thedesignoffice.org/project/modern-pictograms/
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
+22
-14
@@ -1,26 +1,28 @@
|
|||||||
<?xml version="1.0" standalone="no"?>
|
<?xml version="1.0" standalone="no"?>
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
<metadata>Copyright (C) 2020 by original authors @ fontello.com</metadata>
|
<metadata>Copyright (C) 2026 by original authors @ fontello.com</metadata>
|
||||||
<defs>
|
<defs>
|
||||||
<font id="fontello" horiz-adv-x="1000" >
|
<font id="fontello" horiz-adv-x="1000" >
|
||||||
<font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
|
<font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
|
||||||
<missing-glyph horiz-adv-x="1000" />
|
<missing-glyph horiz-adv-x="1000" />
|
||||||
<glyph glyph-name="heart" unicode="♥" d="M790 644q70-64 70-156t-70-158l-360-330-360 330q-70 66-70 158t70 156q62 58 151 58t153-58l56-52 58 52q62 58 150 58t152-58z" horiz-adv-x="860" />
|
<glyph glyph-name="views" unicode="" d="M180 516l0-538-180 0 0 538 180 0z m250-138l0-400-180 0 0 400 180 0z m250 344l0-744-180 0 0 744 180 0z" horiz-adv-x="680" />
|
||||||
|
|
||||||
<glyph glyph-name="quote" unicode="❞" d="M18 685l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z m558 0l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z" horiz-adv-x="928" />
|
<glyph glyph-name="heart" unicode="" d="M790 644q70-64 70-156t-70-158l-360-330-360 330q-70 66-70 158t70 156q62 58 151 58t153-58l56-52 58 52q62 58 150 58t152-58z" horiz-adv-x="860" />
|
||||||
|
|
||||||
<glyph glyph-name="comment" unicode="" d="M1000 350q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12-10-1-17 5t-10 16v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 73 40 139t106 114 160 76 194 28q136 0 251-48t182-130 67-179z" horiz-adv-x="1000" />
|
<glyph glyph-name="quote" unicode="" d="M18 685l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z m558 0l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z" horiz-adv-x="928" />
|
||||||
|
|
||||||
<glyph glyph-name="ok" unicode="" d="M0 260l162 162 166-164 508 510 164-164-510-510-162-162-162 164z" horiz-adv-x="1000" />
|
<glyph glyph-name="comment" unicode="" d="M1000 350q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12-10-1-17 5t-10 16v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 73 40 139t106 114 160 76 194 28q136 0 251-48t182-130 67-179z" horiz-adv-x="1000" />
|
||||||
|
|
||||||
<glyph glyph-name="play" unicode="" d="M772 333l-741-412q-13-7-22-2t-9 20v822q0 14 9 20t22-2l741-412q13-7 13-17t-13-17z" horiz-adv-x="785.7" />
|
<glyph glyph-name="group" unicode="" d="M0 106l0 134q0 26 18 32l171 80q-66 39-68 131 0 56 35 103 37 41 90 43 31 0 63-19-49-125 23-237-12-11-25-19l-114-55q-48-23-52-84l0-143-114 0q-25 0-27 34z m193-59l0 168q0 27 22 37l152 70 57 28q-37 23-60 66t-22 94q0 76 46 130t110 54 109-54 45-130q0-105-78-158l61-30 146-70q24-10 24-37l0-168q-2-37-37-41l-541 0q-14 2-24 14t-10 27z m473 330q68 106 22 231 31 19 66 21 49 0 90-43 35-41 35-103 0-82-65-131l168-80q18-10 18-32l0-134q0-32-27-34l-118 0 0 143q0 57-50 84l-110 53q-15 8-29 25z" horiz-adv-x="1000" />
|
||||||
|
|
||||||
<glyph glyph-name="link" unicode="" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" />
|
<glyph glyph-name="play" unicode="" d="M772 333l-741-412q-13-7-22-2t-9 20v822q0 14 9 20t22-2l741-412q13-7 13-17t-13-17z" horiz-adv-x="785.7" />
|
||||||
|
|
||||||
<glyph glyph-name="calendar" unicode="" d="M800 700q42 0 71-29t29-71l0-600q0-40-29-70t-71-30l-700 0q-40 0-70 30t-30 70l0 600q0 42 30 71t70 29l46 0 0-100 160 0 0 100 290 0 0-100 160 0 0 100 44 0z m0-700l0 400-700 0 0-400 700 0z m-540 800l0-170-70 0 0 170 70 0z m450 0l0-170-70 0 0 170 70 0z" horiz-adv-x="900" />
|
<glyph glyph-name="link" unicode="" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" />
|
||||||
|
|
||||||
<glyph glyph-name="location" unicode="" d="M250 750q104 0 177-73t73-177q0-106-62-243t-126-223l-62-84q-10 12-27 35t-60 89-76 130-60 147-27 149q0 104 73 177t177 73z m0-388q56 0 96 40t40 96-40 95-96 39-95-39-39-95 39-96 95-40z" horiz-adv-x="500" />
|
<glyph glyph-name="calendar" unicode="" d="M800 700q42 0 71-29t29-71l0-600q0-40-29-70t-71-30l-700 0q-40 0-70 30t-30 70l0 600q0 42 30 71t70 29l46 0 0-100 160 0 0 100 290 0 0-100 160 0 0 100 44 0z m0-700l0 400-700 0 0-400 700 0z m-540 800l0-170-70 0 0 170 70 0z m450 0l0-170-70 0 0 170 70 0z" horiz-adv-x="900" />
|
||||||
|
|
||||||
|
<glyph glyph-name="location" unicode="" d="M250 750q104 0 177-73t73-177q0-106-62-243t-126-223l-62-84q-10 12-27 35t-60 89-76 130-60 147-27 149q0 104 73 177t177 73z m0-388q56 0 96 40t40 96-40 95-96 39-95-39-39-95 39-96 95-40z" horiz-adv-x="500" />
|
||||||
|
|
||||||
<glyph glyph-name="picture" unicode="" d="M357 529q0-45-31-76t-76-32-76 32-31 76 31 76 76 31 76-31 31-76z m572-215v-250h-786v107l178 179 90-89 285 285z m53 393h-893q-7 0-12-5t-6-13v-678q0-7 6-13t12-5h893q7 0 13 5t5 13v678q0 8-5 13t-13 5z m89-18v-678q0-37-26-63t-63-27h-893q-36 0-63 27t-26 63v678q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" />
|
<glyph glyph-name="picture" unicode="" d="M357 529q0-45-31-76t-76-32-76 32-31 76 31 76 76 31 76-31 31-76z m572-215v-250h-786v107l178 179 90-89 285 285z m53 393h-893q-7 0-12-5t-6-13v-678q0-7 6-13t12-5h893q7 0 13 5t5 13v678q0 8-5 13t-13 5z m89-18v-678q0-37-26-63t-63-27h-893q-36 0-63 27t-26 63v678q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" />
|
||||||
|
|
||||||
@@ -28,15 +30,21 @@
|
|||||||
|
|
||||||
<glyph glyph-name="down" unicode="" d="M939 399l-414-413q-10-11-25-11t-25 11l-414 413q-11 11-11 26t11 25l93 92q10 11 25 11t25-11l296-296 296 296q11 11 25 11t26-11l92-92q11-11 11-25t-11-26z" horiz-adv-x="1000" />
|
<glyph glyph-name="down" unicode="" d="M939 399l-414-413q-10-11-25-11t-25 11l-414 413q-11 11-11 26t11 25l93 92q10 11 25 11t25-11l296-296 296 296q11 11 25 11t26-11l92-92q11-11 11-25t-11-26z" horiz-adv-x="1000" />
|
||||||
|
|
||||||
<glyph glyph-name="retweet" unicode="" d="M714 11q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" />
|
<glyph glyph-name="retweet" unicode="" d="M714 11q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" />
|
||||||
|
|
||||||
<glyph glyph-name="search" unicode="" d="M772 78q30-34 6-62l-46-46q-36-32-68 0l-190 190q-74-42-156-42-128 0-223 95t-95 223 90 219 218 91 224-95 96-223q0-88-46-162z m-678 358q0-88 68-156t156-68 151 63 63 153q0 88-68 155t-156 67-151-63-63-151z" horiz-adv-x="789" />
|
<glyph glyph-name="search" unicode="" d="M772 78q30-34 6-62l-46-46q-36-32-68 0l-190 190q-74-42-156-42-128 0-223 95t-95 223 90 219 218 91 224-95 96-223q0-88-46-162z m-678 358q0-88 68-156t156-68 151 63 63 153q0 88-68 155t-156 67-151-63-63-151z" horiz-adv-x="789" />
|
||||||
|
|
||||||
<glyph glyph-name="pin" unicode="" d="M268 368v250q0 8-5 13t-13 5-13-5-5-13v-250q0-8 5-13t13-5 13 5 5 13z m375-197q0-14-11-25t-25-10h-239l-29-270q-1-7-6-11t-11-5h-1q-15 0-17 15l-43 271h-225q-15 0-25 10t-11 25q0 69 44 124t99 55v286q-29 0-50 21t-22 50 22 50 50 22h357q29 0 50-22t21-50-21-50-50-21v-286q55 0 99-55t44-124z" horiz-adv-x="642.9" />
|
<glyph glyph-name="pin" unicode="" d="M268 368v250q0 8-5 13t-13 5-13-5-5-13v-250q0-8 5-13t13-5 13 5 5 13z m375-197q0-14-11-25t-25-10h-239l-29-270q-1-7-6-11t-11-5h-1q-15 0-17 15l-43 271h-225q-15 0-25 10t-11 25q0 69 44 124t99 55v286q-29 0-50 21t-22 50 22 50 50 22h357q29 0 50-22t21-50-21-50-50-21v-286q55 0 99-55t44-124z" horiz-adv-x="642.9" />
|
||||||
|
|
||||||
<glyph glyph-name="cog" unicode="" d="M911 295l-133-56q-8-22-12-31l55-133-79-79-135 53q-9-4-31-12l-55-134-112 0-56 133q-11 4-33 13l-132-55-78 79 53 134q-1 3-4 9t-6 12-4 11l-131 55 0 112 131 56 14 33-54 132 78 79 133-54q22 9 33 13l55 132 112 0 56-132q14-5 31-13l133 55 80-79-54-135q6-12 12-30l133-56 0-112z m-447-111q69 0 118 48t49 118-49 119-118 50-119-50-49-119 49-118 119-48z" horiz-adv-x="928" />
|
<glyph glyph-name="cog" unicode="" d="M911 295l-133-56q-8-22-12-31l55-133-79-79-135 53q-9-4-31-12l-55-134-112 0-56 133q-11 4-33 13l-132-55-78 79 53 134q-1 3-4 9t-6 12-4 11l-131 55 0 112 131 56 14 33-54 132 78 79 133-54q22 9 33 13l55 132 112 0 56-132q14-5 31-13l133 55 80-79-54-135q6-12 12-30l133-56 0-112z m-447-111q69 0 118 48t49 118-49 119-118 50-119-50-49-119 49-118 119-48z" horiz-adv-x="928" />
|
||||||
|
|
||||||
<glyph glyph-name="rss-feed" unicode="" d="M184 93c0-51-43-91-93-91s-91 40-91 91c0 50 41 91 91 91s93-41 93-91z m261-85l-125 0c0 174-140 323-315 323l0 118c231 0 440-163 440-441z m259 0l-136 0c0 300-262 561-563 561l0 129c370 0 699-281 699-690z" horiz-adv-x="704" />
|
<glyph glyph-name="rss" unicode="" d="M184 93c0-51-43-91-93-91s-91 40-91 91c0 50 41 91 91 91s93-41 93-91z m261-85l-125 0c0 174-140 323-315 323l0 118c231 0 440-163 440-441z m259 0l-136 0c0 300-262 561-563 561l0 129c370 0 699-281 699-690z" horiz-adv-x="704" />
|
||||||
|
|
||||||
|
<glyph glyph-name="ok" unicode="" d="M933 534q0-22-16-38l-404-404-76-76q-16-15-38-15t-38 15l-76 76-202 202q-15 16-15 38t15 38l76 76q16 16 38 16t38-16l164-165 366 367q16 16 38 16t38-16l76-76q16-15 16-38z" horiz-adv-x="1000" />
|
||||||
|
|
||||||
|
<glyph glyph-name="attention-circled" unicode="" d="M429 779q116 0 215-58t156-156 57-215-57-215-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58z m71-696v106q0 8-5 13t-12 5h-107q-8 0-13-5t-6-13v-106q0-8 6-13t13-6h107q7 0 12 6t5 13z m-1 192l10 346q0 7-6 10-5 5-13 5h-123q-8 0-13-5-6-3-6-10l10-346q0-6 5-10t14-4h103q8 0 13 4t6 10z" horiz-adv-x="857.1" />
|
||||||
|
|
||||||
|
<glyph glyph-name="circle" unicode="" d="M857 350q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" />
|
||||||
|
|
||||||
<glyph glyph-name="info" unicode="" d="M393 149v-134q0-9-7-15t-15-7h-134q-9 0-16 7t-7 15v134q0 9 7 16t16 6h134q9 0 15-6t7-16z m176 335q0-30-8-56t-20-43-31-33-32-25-34-19q-23-13-38-37t-15-37q0-10-7-18t-16-9h-134q-8 0-14 11t-6 20v26q0 46 37 87t79 60q33 16 47 32t14 42q0 24-26 41t-60 18q-36 0-60-16-20-14-60-64-7-9-17-9-7 0-14 4l-91 70q-8 6-9 14t3 16q89 148 259 148 45 0 90-17t81-46 59-72 23-88z" horiz-adv-x="571.4" />
|
<glyph glyph-name="info" unicode="" d="M393 149v-134q0-9-7-15t-15-7h-134q-9 0-16 7t-7 15v134q0 9 7 16t16 6h134q9 0 15-6t7-16z m176 335q0-30-8-56t-20-43-31-33-32-25-34-19q-23-13-38-37t-15-37q0-10-7-18t-16-9h-134q-8 0-14 11t-6 20v26q0 46 37 87t79 60q33 16 47 32t14 42q0 24-26 41t-60 18q-36 0-60-16-20-14-60-64-7-9-17-9-7 0-14 4l-91 70q-8 6-9 14t3 16q89 148 259 148 45 0 90-17t81-46 59-72 23-88z" horiz-adv-x="571.4" />
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 7.3 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
-5
File diff suppressed because one or more lines are too long
Vendored
+5
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@
|
|||||||
function playVideo(overlay) {
|
function playVideo(overlay) {
|
||||||
const video = overlay.parentElement.querySelector('video');
|
const video = overlay.parentElement.querySelector('video');
|
||||||
const url = video.getAttribute("data-url");
|
const url = video.getAttribute("data-url");
|
||||||
|
const startTime = parseFloat(video.getAttribute("data-start") || "0");
|
||||||
video.setAttribute("controls", "");
|
video.setAttribute("controls", "");
|
||||||
overlay.style.display = "none";
|
overlay.style.display = "none";
|
||||||
|
|
||||||
@@ -12,12 +13,13 @@ function playVideo(overlay) {
|
|||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, function () {
|
hls.on(Hls.Events.MANIFEST_PARSED, function () {
|
||||||
hls.loadLevel = hls.levels.length - 1;
|
hls.loadLevel = hls.levels.length - 1;
|
||||||
hls.startLoad();
|
hls.startLoad(startTime);
|
||||||
video.play();
|
video.play();
|
||||||
});
|
});
|
||||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
video.src = url;
|
video.src = url;
|
||||||
video.addEventListener('canplay', function() {
|
video.addEventListener('canplay', function() {
|
||||||
|
if (startTime > 0) video.currentTime = startTime;
|
||||||
video.play();
|
video.play();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+197
-38
@@ -1,66 +1,225 @@
|
|||||||
// @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
|
// @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
function insertBeforeLast(node, elem) {
|
function insertBeforeLast(node, elem) {
|
||||||
node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]);
|
node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLoadMore(doc) {
|
function getLoadMore(doc) {
|
||||||
return doc.querySelector('.show-more:not(.timeline-item)');
|
return doc.querySelector(".show-more:not(.timeline-item)");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDuplicate(item, itemClass) {
|
function getHrefs(selector) {
|
||||||
const tweet = item.querySelector(".tweet-link");
|
return new Set([...document.querySelectorAll(selector)].map(el => el.getAttribute("href")));
|
||||||
if (tweet == null) return false;
|
|
||||||
const href = tweet.getAttribute("href");
|
|
||||||
return document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.onload = function() {
|
function getTweetId(item) {
|
||||||
const url = window.location.pathname;
|
const m = item.querySelector(".tweet-link")?.getAttribute("href")?.match(/\/status\/(\d+)/);
|
||||||
const isTweet = url.indexOf("/status/") !== -1;
|
return m ? m[1] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDuplicate(item, hrefs) {
|
||||||
|
return hrefs.has(item.querySelector(".tweet-link")?.getAttribute("href"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const GAP = 10;
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
let resizeTimer;
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(() => this._rebuild(), 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
this._rebuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full reset and re-place all items.
|
||||||
|
_place(items, heights, n, colWidth) {
|
||||||
|
this.colHeights = new Array(n).fill(0);
|
||||||
|
this.colCounts = new Array(n).fill(0);
|
||||||
|
this.colCount = n;
|
||||||
|
this._position(items, heights, colWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
_rebuild() {
|
||||||
|
const w = this.container.clientWidth;
|
||||||
|
const n = Math.max(1, Math.floor(w / this._targetWidth(w)));
|
||||||
|
if (n === this.colCount && w === this._lastWidth) return;
|
||||||
|
|
||||||
|
const isFirst = this.colCount === 0;
|
||||||
|
|
||||||
|
if (isFirst) {
|
||||||
|
this._items = [...this.container.querySelectorAll(".timeline-item")];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort newest-first by tweet ID (snowflake IDs exceed Number precision, compare as strings).
|
||||||
|
this._items.sort((a, b) => {
|
||||||
|
const idA = getTweetId(a), idB = getTweetId(b);
|
||||||
|
if (idA.length !== idB.length) return idB.length - idA.length;
|
||||||
|
return idB < idA ? -1 : idB > idA ? 1 : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-set widths BEFORE reading heights so measurements reflect the new column width.
|
||||||
|
const colWidth = this._colWidthCache = Math.floor((w - GAP * (n - 1)) / n);
|
||||||
|
for (const item of this._items) item.style.width = `${colWidth}px`;
|
||||||
|
|
||||||
|
this._place(this._items, this._items.map(item => item.offsetHeight), n, colWidth);
|
||||||
|
this._lastWidth = w;
|
||||||
|
|
||||||
|
if (isFirst) {
|
||||||
|
if (this._observer) this._items.forEach(item => this._observer.observe(item));
|
||||||
|
// Reveal immediately if all images are cached, else wait for syncHeights.
|
||||||
|
const hasUnloaded = this._items.some(item =>
|
||||||
|
[...item.querySelectorAll("img")].some(img => !img.complete));
|
||||||
|
if (hasUnloaded) {
|
||||||
|
this._revealTimer = setTimeout(() => this._revealAll(), 1000);
|
||||||
|
} else {
|
||||||
|
this._revealAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-read actual heights and re-place all items. Fixes drift after images load.
|
||||||
|
syncHeights() {
|
||||||
|
this._place(this._items, this._items.map(item => item.offsetHeight), this.colCount, this._colWidthCache);
|
||||||
|
this._revealAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch-add items in three phases to avoid O(N) reflows:
|
||||||
|
// 1. writes: set widths, append all — no reads, no reflows
|
||||||
|
// 2. one read: batch offsetHeight
|
||||||
|
// 3. writes: assign columns, set left/top
|
||||||
|
addAll(newItems) {
|
||||||
|
if (!newItems.length) return;
|
||||||
|
const colWidth = this._colWidthCache;
|
||||||
|
|
||||||
|
for (const item of newItems) {
|
||||||
|
item.style.width = `${colWidth}px`;
|
||||||
|
this.container.appendChild(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._position(newItems, newItems.map(item => item.offsetHeight), colWidth);
|
||||||
|
this._items.push(...newItems);
|
||||||
|
|
||||||
|
if (this._observer) newItems.forEach(item => this._observer.observe(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const isTweet = location.pathname.includes("/status/");
|
||||||
const containerClass = isTweet ? ".replies" : ".timeline";
|
const containerClass = isTweet ? ".replies" : ".timeline";
|
||||||
const itemClass = containerClass + ' > div:not(.top-ref)';
|
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;
|
||||||
|
|
||||||
var html = document.querySelector("html");
|
function handleScroll(failed) {
|
||||||
var container = document.querySelector(containerClass);
|
if (loading || html.scrollTop + html.clientHeight < html.scrollHeight - 3000) return;
|
||||||
var loading = false;
|
|
||||||
|
|
||||||
window.addEventListener('scroll', function() {
|
const loadMore = getLoadMore(document);
|
||||||
if (loading) return;
|
if (!loadMore) return;
|
||||||
if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) {
|
|
||||||
loading = true;
|
loading = true;
|
||||||
var loadMore = getLoadMore(document);
|
|
||||||
if (loadMore == null) return;
|
|
||||||
|
|
||||||
loadMore.children[0].text = "Loading...";
|
loadMore.children[0].text = "Loading...";
|
||||||
|
|
||||||
var url = new URL(loadMore.children[0].href);
|
const url = new URL(loadMore.children[0].href);
|
||||||
url.searchParams.append('scroll', 'true');
|
url.searchParams.append("scroll", "true");
|
||||||
|
|
||||||
fetch(url.toString()).then(function (response) {
|
fetch(url)
|
||||||
return response.text();
|
.then(r => {
|
||||||
}).then(function (html) {
|
if (r.status > 299) throw new Error("error");
|
||||||
var parser = new DOMParser();
|
return r.text();
|
||||||
var doc = parser.parseFromString(html, 'text/html');
|
})
|
||||||
|
.then(responseText => {
|
||||||
|
const doc = new DOMParser().parseFromString(responseText, "text/html");
|
||||||
loadMore.remove();
|
loadMore.remove();
|
||||||
|
|
||||||
for (var item of doc.querySelectorAll(itemClass)) {
|
if (masonry) {
|
||||||
if (item.className == "timeline-item show-more") continue;
|
masonry.syncHeights();
|
||||||
if (isDuplicate(item, itemClass)) continue;
|
const newMasonry = doc.querySelector(".gallery-masonry");
|
||||||
if (isTweet) container.appendChild(item);
|
if (newMasonry) {
|
||||||
else insertBeforeLast(container, item);
|
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;
|
loading = false;
|
||||||
const newLoadMore = getLoadMore(doc);
|
const newLoadMore = getLoadMore(doc);
|
||||||
if (newLoadMore == null) return;
|
if (newLoadMore) {
|
||||||
if (isTweet) container.appendChild(newLoadMore);
|
isTweet ? container.appendChild(newLoadMore) : insertBeforeLast(container, newLoadMore);
|
||||||
else insertBeforeLast(container, newLoadMore);
|
if (masonry) newLoadMore.classList.add("masonry-visible");
|
||||||
}).catch(function (err) {
|
}
|
||||||
console.warn('Something went wrong.', err);
|
})
|
||||||
loading = true;
|
.catch(err => {
|
||||||
|
console.warn("Something went wrong.", err);
|
||||||
|
if (failed > 3) { loadMore.children[0].text = "Error"; return; }
|
||||||
|
loading = false;
|
||||||
|
handleScroll((failed || 0) + 1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.addEventListener("scroll", () => handleScroll());
|
||||||
});
|
});
|
||||||
};
|
|
||||||
// @license-end
|
// @license-end
|
||||||
|
|||||||
+23
-21
@@ -4,15 +4,15 @@ Nitter is a free and open source alternative Twitter front-end focused on
|
|||||||
privacy and performance. The source is available on GitHub at
|
privacy and performance. The source is available on GitHub at
|
||||||
<https://github.com/zedeus/nitter>
|
<https://github.com/zedeus/nitter>
|
||||||
|
|
||||||
* No JavaScript or ads
|
- No JavaScript or ads
|
||||||
* All requests go through the backend, client never talks to Twitter
|
- All requests go through the backend, client never talks to Twitter
|
||||||
* Prevents Twitter from tracking your IP or JavaScript fingerprint
|
- Prevents Twitter from tracking your IP or JavaScript fingerprint
|
||||||
* Uses Twitter's unofficial API (no rate limits or developer account required)
|
- Uses Twitter's unofficial API (no developer account required)
|
||||||
* Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com)
|
- Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com)
|
||||||
* RSS feeds
|
- RSS feeds
|
||||||
* Themes
|
- Themes
|
||||||
* Mobile support (responsive design)
|
- Mobile support (responsive design)
|
||||||
* AGPLv3 licensed, no proprietary instances permitted
|
- AGPLv3 licensed, no proprietary instances permitted
|
||||||
|
|
||||||
Nitter's GitHub wiki contains
|
Nitter's GitHub wiki contains
|
||||||
[instances](https://github.com/zedeus/nitter/wiki/Instances) and
|
[instances](https://github.com/zedeus/nitter/wiki/Instances) and
|
||||||
@@ -21,12 +21,13 @@ maintained by the community.
|
|||||||
|
|
||||||
## Why use Nitter?
|
## Why use Nitter?
|
||||||
|
|
||||||
It's impossible to use Twitter without JavaScript enabled. For privacy-minded
|
It's impossible to use Twitter without JavaScript enabled, and as of 2024 you
|
||||||
folks, preventing JavaScript analytics and IP-based tracking is important, but
|
need to sign up. For privacy-minded folks, preventing JavaScript analytics and
|
||||||
apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind
|
IP-based tracking is important, but apart from using a VPN and uBlock/uMatrix,
|
||||||
a VPN and using heavy-duty adblockers, you can get accurately tracked with your
|
it's impossible. Despite being behind a VPN and using heavy-duty adblockers,
|
||||||
[browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/),
|
you can get accurately tracked with your [browser's
|
||||||
[no JavaScript required](https://noscriptfingerprint.com/). This all became
|
fingerprint](https://restoreprivacy.com/browser-fingerprinting/), [no
|
||||||
|
JavaScript required](https://noscriptfingerprint.com/). This all became
|
||||||
particularly important after Twitter [removed the
|
particularly important after Twitter [removed the
|
||||||
ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws)
|
ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws)
|
||||||
for users to control whether their data gets sent to advertisers.
|
for users to control whether their data gets sent to advertisers.
|
||||||
@@ -42,12 +43,13 @@ Twitter account.
|
|||||||
|
|
||||||
## Donating
|
## Donating
|
||||||
|
|
||||||
Liberapay: <https://liberapay.com/zedeus> \
|
Liberapay: https://liberapay.com/zedeus \
|
||||||
Patreon: <https://patreon.com/nitter> \
|
Patreon: https://patreon.com/nitter \
|
||||||
BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \
|
BTC: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55 \
|
||||||
ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \
|
ETH: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460 \
|
||||||
LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \
|
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL \
|
||||||
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
|
SOL: ANsyGNXFo6osuFwr1YnUqif2RdoYRhc27WdyQNmmETSW \
|
||||||
|
ZEC: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt
|
||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
|
|
||||||
|
|||||||
+150
-63
@@ -1,58 +1,123 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import asyncdispatch, httpclient, uri, strutils, sequtils, sugar
|
import asyncdispatch, httpclient, strutils, sequtils, sugar
|
||||||
import packedjson
|
import packedjson
|
||||||
import types, query, formatters, consts, apiutils, parser
|
import types, query, formatters, consts, apiutils, parser, utils
|
||||||
import experimental/parser as newParser
|
import experimental/parser as newParser
|
||||||
|
|
||||||
|
# Helper to generate params object for GraphQL requests
|
||||||
|
proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
|
||||||
|
result.add ("variables", variables)
|
||||||
|
result.add ("features", gqlFeatures)
|
||||||
|
if fieldToggles.len > 0:
|
||||||
|
result.add ("fieldToggles", fieldToggles)
|
||||||
|
|
||||||
|
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 mediaUrl(id, cursor: string; count=20): ApiReq =
|
||||||
|
result = ApiReq(
|
||||||
|
cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor, $count]),
|
||||||
|
oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor, $count])
|
||||||
|
)
|
||||||
|
|
||||||
|
proc userTweetsUrl(id: string; cursor: string): ApiReq =
|
||||||
|
return apiReq(graphUserTweetsV2, restIdVars % [id, cursor, "20"])
|
||||||
|
# result = ApiReq(
|
||||||
|
# cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles),
|
||||||
|
# oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor, "20"])
|
||||||
|
# )
|
||||||
|
|
||||||
|
proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
|
||||||
|
return apiReq(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"], skipTid=true)
|
||||||
|
|
||||||
|
proc tweetDetailUrl(id: string; cursor: string): ApiReq =
|
||||||
|
return apiReq(graphTweet, tweetVars % [id, cursor])
|
||||||
|
# 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":"$1","withGrokTranslatedBio":false}""" % username
|
||||||
|
result = ApiReq(
|
||||||
|
cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles),
|
||||||
|
oauth: apiUrl(graphUserV2, """{"screen_name": "$1"}""" % username)
|
||||||
|
)
|
||||||
|
|
||||||
proc getGraphUser*(username: string): Future[User] {.async.} =
|
proc getGraphUser*(username: string): Future[User] {.async.} =
|
||||||
if username.len == 0: return
|
if username.len == 0: return
|
||||||
let
|
let js = await fetchRaw(userUrl(username))
|
||||||
variables = %*{"screen_name": username}
|
|
||||||
params = {"variables": $variables, "features": gqlFeatures}
|
|
||||||
js = await fetchRaw(graphUser ? params, Api.userScreenName)
|
|
||||||
result = parseGraphUser(js)
|
result = parseGraphUser(js)
|
||||||
|
|
||||||
proc getGraphUserById*(id: string): Future[User] {.async.} =
|
proc getGraphUserById*(id: string): Future[User] {.async.} =
|
||||||
if id.len == 0 or id.any(c => not c.isDigit): return
|
if id.len == 0 or id.any(c => not c.isDigit): return
|
||||||
let
|
let
|
||||||
variables = %*{"userId": id}
|
url = apiReq(graphUserById, """{"rest_id": "$1"}""" % id)
|
||||||
params = {"variables": $variables, "features": gqlFeatures}
|
js = await fetchRaw(url)
|
||||||
js = await fetchRaw(graphUserById ? params, Api.userRestId)
|
|
||||||
result = parseGraphUser(js)
|
result = parseGraphUser(js)
|
||||||
|
|
||||||
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Timeline] {.async.} =
|
proc getAboutAccount*(username: string): Future[AccountInfo] {.async.} =
|
||||||
|
if username.len == 0: return
|
||||||
|
let
|
||||||
|
url = apiReq(graphAboutAccount, """{"screenName":"$1"}""" % username)
|
||||||
|
js = await fetch(url)
|
||||||
|
result = parseAboutAccount(js)
|
||||||
|
|
||||||
|
proc restReq(endpoint: string; params: seq[(string, string)] = @[]): ApiReq =
|
||||||
|
let url = ApiUrl(endpoint: endpoint, params: params)
|
||||||
|
ApiReq(cookie: url, oauth: url)
|
||||||
|
|
||||||
|
proc getBroadcastInfo*(id: string): Future[Broadcast] {.async.} =
|
||||||
|
if id.len == 0: return
|
||||||
|
let
|
||||||
|
req = apiReq(graphBroadcast, """{"id":"$1"}""" % id)
|
||||||
|
js = await fetch(req)
|
||||||
|
result = parseBroadcastInfo(js)
|
||||||
|
|
||||||
|
proc fetchBroadcastStream*(mediaKey: string): Future[string] {.async.} =
|
||||||
|
if mediaKey.len == 0: return
|
||||||
|
let
|
||||||
|
streamReq = restReq(restLiveStream & mediaKey)
|
||||||
|
streamJs = await fetch(streamReq)
|
||||||
|
result = streamJs{"source", "noRedirectPlaybackUrl"}.getStr(
|
||||||
|
streamJs{"source", "location"}.getStr)
|
||||||
|
|
||||||
|
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
let
|
||||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||||
variables = userTweetsVariables % [id, cursor]
|
url = case kind
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
of TimelineKind.tweets: userTweetsUrl(id, cursor)
|
||||||
(url, apiId) = case kind
|
of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
|
||||||
of TimelineKind.tweets: (graphUserTweets, Api.userTweets)
|
of TimelineKind.media: mediaUrl(id, cursor, 100)
|
||||||
of TimelineKind.replies: (graphUserTweetsAndReplies, Api.userTweetsAndReplies)
|
js = await fetch(url)
|
||||||
of TimelineKind.media: (graphUserMedia, Api.userMedia)
|
result = parseGraphTimeline(js, after)
|
||||||
js = await fetch(url ? params, apiId)
|
|
||||||
result = parseGraphTimeline(js, "user", after)
|
|
||||||
|
|
||||||
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
|
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
let
|
||||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||||
variables = listTweetsVariables % [id, cursor]
|
url = apiReq(graphListTweets, restIdVars % [id, cursor, "20"])
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
js = await fetch(url)
|
||||||
js = await fetch(graphListTweets ? params, Api.listTweets)
|
result = parseGraphTimeline(js, after).tweets
|
||||||
result = parseGraphTimeline(js, "list", after)
|
|
||||||
|
|
||||||
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
|
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
|
||||||
let
|
let
|
||||||
variables = %*{"screenName": name, "listSlug": list}
|
variables = %*{"screenName": name, "listSlug": list}
|
||||||
params = {"variables": $variables, "features": gqlFeatures}
|
url = apiReq(graphListBySlug, $variables)
|
||||||
result = parseGraphList(await fetch(graphListBySlug ? params, Api.listBySlug))
|
js = await fetch(url)
|
||||||
|
result = parseGraphList(js)
|
||||||
|
|
||||||
proc getGraphList*(id: string): Future[List] {.async.} =
|
proc getGraphList*(id: string): Future[List] {.async.} =
|
||||||
let
|
let
|
||||||
variables = %*{"listId": id}
|
url = apiReq(graphListById, """{"listId": "$1"}""" % id)
|
||||||
params = {"variables": $variables, "features": gqlFeatures}
|
js = await fetch(url)
|
||||||
result = parseGraphList(await fetch(graphListById ? params, Api.list))
|
result = parseGraphList(js)
|
||||||
|
|
||||||
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
|
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
|
||||||
if list.id.len == 0: return
|
if list.id.len == 0: return
|
||||||
@@ -66,24 +131,23 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
|
|||||||
}
|
}
|
||||||
if after.len > 0:
|
if after.len > 0:
|
||||||
variables["cursor"] = % after
|
variables["cursor"] = % after
|
||||||
let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures}
|
let
|
||||||
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after)
|
url = apiReq(graphListMembers, $variables)
|
||||||
|
js = await fetchRaw(url)
|
||||||
|
result = parseGraphListMembers(js, after)
|
||||||
|
|
||||||
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
|
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
let
|
||||||
variables = tweetResultVariables % id
|
url = apiReq(graphTweetResult, """{"rest_id": "$1"}""" % id)
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
js = await fetch(url)
|
||||||
js = await fetch(graphTweetResult ? params, Api.tweetResult)
|
|
||||||
result = parseGraphTweetResult(js)
|
result = parseGraphTweetResult(js)
|
||||||
|
|
||||||
proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
|
proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
let
|
||||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||||
variables = tweetVariables % [id, cursor]
|
js = await fetch(tweetDetailUrl(id, cursor))
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
|
||||||
js = await fetch(graphTweet ? params, Api.tweetDetail)
|
|
||||||
result = parseGraphConversation(js, id)
|
result = parseGraphConversation(js, id)
|
||||||
|
|
||||||
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
|
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
|
||||||
@@ -95,51 +159,74 @@ proc getTweet*(id: string; after=""): Future[Conversation] {.async.} =
|
|||||||
if after.len > 0:
|
if after.len > 0:
|
||||||
result.replies = await getReplies(id, after)
|
result.replies = await getReplies(id, after)
|
||||||
|
|
||||||
proc getGraphSearch*(query: Query; after=""): Future[Result[Tweet]] {.async.} =
|
proc getGraphEditHistory*(id: string): Future[EditHistory] {.async.} =
|
||||||
let q = genQueryParam(query)
|
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.} =
|
||||||
|
# workaround for #1372
|
||||||
|
let maxId =
|
||||||
|
if not after.startsWith("maxid:"): ""
|
||||||
|
else: validateNumber(after[6..^1])
|
||||||
|
|
||||||
|
let q = genQueryParam(query, maxId)
|
||||||
if q.len == 0 or q == emptyQuery:
|
if q.len == 0 or q == emptyQuery:
|
||||||
return Result[Tweet](query: query, beginning: true)
|
return Timeline(query: query, beginning: true)
|
||||||
|
|
||||||
var
|
var
|
||||||
variables = %*{
|
variables = %*{
|
||||||
"rawQuery": q,
|
"rawQuery": q,
|
||||||
"count": 20,
|
"count": 20,
|
||||||
|
"querySource": "typed_query",
|
||||||
"product": "Latest",
|
"product": "Latest",
|
||||||
"withDownvotePerspective": false,
|
"withGrokTranslatedBio":true,
|
||||||
"withReactionsMetadata": false,
|
"withQuickPromoteEligibilityTweetFields":false
|
||||||
"withReactionsPerspective": false
|
|
||||||
}
|
}
|
||||||
if after.len > 0:
|
|
||||||
|
if after.len > 0 and maxId.len == 0:
|
||||||
variables["cursor"] = % after
|
variables["cursor"] = % after
|
||||||
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
|
let
|
||||||
result = parseGraphSearch(await fetch(url, Api.search), after)
|
url = apiReq(graphSearchTimeline, $variables)
|
||||||
|
js = await fetch(url)
|
||||||
|
result = parseGraphSearch[Tweets](js, after)
|
||||||
result.query = query
|
result.query = query
|
||||||
|
|
||||||
proc getUserSearch*(query: Query; page="1"): 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.
|
||||||
|
if after.len > 0 and result.bottom.len > 0 and maxId.len == 0 and
|
||||||
|
after[0..<64] == result.bottom[0..<64]:
|
||||||
|
result.content.setLen(0)
|
||||||
|
|
||||||
|
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
|
||||||
if query.text.len == 0:
|
if query.text.len == 0:
|
||||||
return Result[User](query: query, beginning: true)
|
return Result[User](query: query, beginning: true)
|
||||||
|
|
||||||
var url = userSearch ? {
|
var
|
||||||
"q": query.text,
|
variables = %*{
|
||||||
"skip_status": "1",
|
"rawQuery": query.text,
|
||||||
"count": "20",
|
"count": 20,
|
||||||
"page": page
|
"querySource": "typed_query",
|
||||||
|
"product": "People",
|
||||||
|
"withGrokTranslatedBio":true,
|
||||||
|
"withQuickPromoteEligibilityTweetFields":false
|
||||||
}
|
}
|
||||||
|
if after.len > 0:
|
||||||
|
variables["cursor"] = % after
|
||||||
|
result.beginning = false
|
||||||
|
|
||||||
result = parseUsers(await fetchRaw(url, Api.userSearch))
|
|
||||||
result.query = query
|
|
||||||
if page.len == 0:
|
|
||||||
result.bottom = "2"
|
|
||||||
elif page.allCharsInSet(Digits):
|
|
||||||
result.bottom = $(parseInt(page) + 1)
|
|
||||||
|
|
||||||
proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} =
|
|
||||||
if name.len == 0: return
|
|
||||||
let
|
let
|
||||||
ps = genParams({"screen_name": name, "trim_user": "true"},
|
url = apiReq(graphSearchTimeline, $variables)
|
||||||
count="18", ext=false)
|
js = await fetch(url)
|
||||||
url = photoRail ? ps
|
result = parseGraphSearch[User](js, after)
|
||||||
result = parsePhotoRail(await fetch(url, Api.timeline))
|
result.query = query
|
||||||
|
|
||||||
|
proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
|
||||||
|
if id.len == 0: return
|
||||||
|
let js = await fetch(mediaUrl(id, "", 30))
|
||||||
|
result = parseGraphPhotoRail(js)
|
||||||
|
|
||||||
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
|
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
|
||||||
let client = newAsyncHttpClient(maxRedirects=0)
|
let client = newAsyncHttpClient(maxRedirects=0)
|
||||||
|
|||||||
+180
-68
@@ -1,67 +1,131 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import httpclient, asyncdispatch, options, strutils, uri
|
import httpclient, asyncdispatch, options, strutils, uri, times, math, tables
|
||||||
import jsony, packedjson, zippy
|
import jsony, packedjson, zippy, oauth/oauth1
|
||||||
import types, tokens, consts, parserutils, http_pool
|
import types, auth, consts, parserutils, http_pool, tid
|
||||||
import experimental/types/common
|
import experimental/types/common
|
||||||
|
|
||||||
const
|
const
|
||||||
rlRemaining = "x-rate-limit-remaining"
|
rlRemaining = "x-rate-limit-remaining"
|
||||||
rlReset = "x-rate-limit-reset"
|
rlReset = "x-rate-limit-reset"
|
||||||
|
rlLimit = "x-rate-limit-limit"
|
||||||
|
errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest}
|
||||||
|
|
||||||
var pool: HttpPool
|
var
|
||||||
|
pool: HttpPool
|
||||||
|
disableTid: bool
|
||||||
|
apiProxy: string
|
||||||
|
maxRetries: int
|
||||||
|
retryDelayMs: int
|
||||||
|
|
||||||
proc genParams*(pars: openArray[(string, string)] = @[]; cursor="";
|
proc setDisableTid*(disable: bool) =
|
||||||
count="20"; ext=true): seq[(string, string)] =
|
disableTid = disable
|
||||||
result = timelineParams
|
|
||||||
for p in pars:
|
|
||||||
result &= p
|
|
||||||
if ext:
|
|
||||||
result &= ("ext", "mediaStats")
|
|
||||||
result &= ("include_ext_alt_text", "1")
|
|
||||||
result &= ("include_ext_media_availability", "1")
|
|
||||||
if count.len > 0:
|
|
||||||
result &= ("count", count)
|
|
||||||
if cursor.len > 0:
|
|
||||||
# The raw cursor often has plus signs, which sometimes get turned into spaces,
|
|
||||||
# so we need to turn them back into a plus
|
|
||||||
if " " in cursor:
|
|
||||||
result &= ("cursor", cursor.replace(" ", "+"))
|
|
||||||
else:
|
|
||||||
result &= ("cursor", cursor)
|
|
||||||
|
|
||||||
proc genHeaders*(token: Token = nil): HttpHeaders =
|
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
|
||||||
|
encodedUrl = url.replace(",", "%2C").replace("+", "%20")
|
||||||
|
params = OAuth1Parameters(
|
||||||
|
consumerKey: consumerKey,
|
||||||
|
signatureMethod: "HMAC-SHA1",
|
||||||
|
timestamp: $int(round(epochTime())),
|
||||||
|
nonce: "0",
|
||||||
|
isIncludeVersionToHeader: true,
|
||||||
|
token: oauthToken
|
||||||
|
)
|
||||||
|
signature = getSignature(HttpGet, encodedUrl, "", params, consumerSecret, oauthTokenSecret)
|
||||||
|
|
||||||
|
params.signature = percentEncode(signature)
|
||||||
|
|
||||||
|
return getOauth1RequestHeader(params)["authorization"]
|
||||||
|
|
||||||
|
proc getCookieHeader(authToken, ct0: string): string =
|
||||||
|
"auth_token=" & authToken & "; ct0=" & ct0
|
||||||
|
|
||||||
|
proc genHeaders*(session: Session, url: Uri, skipTid: bool): Future[HttpHeaders] {.async.} =
|
||||||
result = newHttpHeaders({
|
result = newHttpHeaders({
|
||||||
"connection": "keep-alive",
|
"accept": "*/*",
|
||||||
"authorization": auth,
|
|
||||||
"content-type": "application/json",
|
|
||||||
"x-guest-token": if token == nil: "" else: token.tok,
|
|
||||||
"x-twitter-active-user": "yes",
|
|
||||||
"authority": "api.twitter.com",
|
|
||||||
"accept-encoding": "gzip",
|
"accept-encoding": "gzip",
|
||||||
"accept-language": "en-US,en;q=0.9",
|
"accept-language": "en-US,en;q=0.9",
|
||||||
"accept": "*/*",
|
"connection": "keep-alive",
|
||||||
"DNT": "1"
|
"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"
|
||||||
})
|
})
|
||||||
|
|
||||||
template updateToken() =
|
case session.kind
|
||||||
if resp.headers.hasKey(rlRemaining):
|
of SessionKind.oauth:
|
||||||
let
|
result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret)
|
||||||
remaining = parseInt(resp.headers[rlRemaining])
|
of SessionKind.cookie:
|
||||||
reset = parseInt(resp.headers[rlReset])
|
result["x-twitter-auth-type"] = "OAuth2Session"
|
||||||
token.setRateLimit(api, remaining, reset)
|
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*(req: ApiReq): Future[Session] {.async.} =
|
||||||
|
result = await getSession(req)
|
||||||
|
case result.kind
|
||||||
|
of SessionKind.oauth:
|
||||||
|
if result.oauthToken.len == 0:
|
||||||
|
echo "[sessions] Empty oauth token, session: ", result.pretty
|
||||||
|
raise rateLimitError()
|
||||||
|
of SessionKind.cookie:
|
||||||
|
if result.authToken.len == 0 or result.ct0.len == 0:
|
||||||
|
echo "[sessions] Empty cookie credentials, session: ", result.pretty
|
||||||
|
raise rateLimitError()
|
||||||
|
|
||||||
template fetchImpl(result, fetchBody) {.dirty.} =
|
template fetchImpl(result, fetchBody) {.dirty.} =
|
||||||
once:
|
once:
|
||||||
pool = HttpPool()
|
pool = HttpPool()
|
||||||
|
|
||||||
var token = await getToken(api)
|
|
||||||
if token.tok.len == 0:
|
|
||||||
raise rateLimitError()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
var resp: AsyncResponse
|
var resp: AsyncResponse
|
||||||
pool.use(genHeaders(token)):
|
let skipTid = case session.kind
|
||||||
|
of oauth: req.oauth.skipTid
|
||||||
|
of cookie: req.cookie.skipTid
|
||||||
|
let headers = await genHeaders(session, url, skipTid)
|
||||||
|
|
||||||
|
pool.use(headers):
|
||||||
template getContent =
|
template getContent =
|
||||||
|
# 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)
|
resp = await c.get($url)
|
||||||
result = await resp.body
|
result = await resp.body
|
||||||
|
|
||||||
@@ -71,57 +135,105 @@ template fetchImpl(result, fetchBody) {.dirty.} =
|
|||||||
badClient = true
|
badClient = true
|
||||||
raise newException(BadClientError, "Bad client")
|
raise newException(BadClientError, "Bad client")
|
||||||
|
|
||||||
|
if resp.status == $Http404 and result.len == 0:
|
||||||
|
echo "[sessions] transient 404 (empty body), retrying: ", url.path, ", session: ", session.pretty
|
||||||
|
raise rateLimitError()
|
||||||
|
|
||||||
|
if resp.headers.hasKey(rlRemaining):
|
||||||
|
let
|
||||||
|
remaining = parseInt(resp.headers[rlRemaining])
|
||||||
|
reset = parseInt(resp.headers[rlReset])
|
||||||
|
limit = parseInt(resp.headers[rlLimit])
|
||||||
|
session.setRateLimit(req, remaining, reset, limit)
|
||||||
|
|
||||||
if result.len > 0:
|
if result.len > 0:
|
||||||
if resp.headers.getOrDefault("content-encoding") == "gzip":
|
if resp.headers.getOrDefault("content-encoding") == "gzip":
|
||||||
result = uncompress(result, dfGzip)
|
result = uncompress(result, dfGzip)
|
||||||
else:
|
|
||||||
echo "non-gzip body, url: ", url, ", body: ", result
|
if result.startsWith("{\"errors"):
|
||||||
|
let errors = result.fromJson(Errors)
|
||||||
|
if errors notin errorsToSkip:
|
||||||
|
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, req)
|
||||||
|
raise rateLimitError()
|
||||||
|
elif result.startsWith("429 Too Many Requests"):
|
||||||
|
echo "[sessions] 429 error, API: ", url.path, ", session: ", session.pretty
|
||||||
|
raise rateLimitError()
|
||||||
|
|
||||||
fetchBody
|
fetchBody
|
||||||
|
|
||||||
release(token, used=true)
|
|
||||||
|
|
||||||
if resp.status == $Http400:
|
if resp.status == $Http400:
|
||||||
|
echo "ERROR 400, ", url.path, ": ", result, ", session: ", session.pretty
|
||||||
raise newException(InternalError, $url)
|
raise newException(InternalError, $url)
|
||||||
except InternalError as e:
|
except InternalError as e:
|
||||||
raise e
|
raise e
|
||||||
except BadClientError as e:
|
except BadClientError as e:
|
||||||
release(token, used=true)
|
raise e
|
||||||
|
except OSError as e:
|
||||||
raise e
|
raise e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
echo "error: ", e.name, ", msg: ", e.msg, ", token: ", token[], ", url: ", url
|
let s = session.pretty
|
||||||
if "length" notin e.msg and "descriptor" notin e.msg:
|
echo "error: ", e.name, ", msg: ", e.msg, ", session: ", s, ", url: ", url
|
||||||
release(token, invalid=true)
|
raise rateLimitError()
|
||||||
|
finally:
|
||||||
|
release(session)
|
||||||
|
|
||||||
|
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()
|
raise rateLimitError()
|
||||||
|
|
||||||
proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} =
|
proc fetch*(req: ApiReq): Future[JsonNode] {.async.} =
|
||||||
|
retry:
|
||||||
var body: string
|
var body: string
|
||||||
|
session = await getAndValidateSession(req)
|
||||||
|
|
||||||
|
let url = req.toUrl(session.kind)
|
||||||
|
|
||||||
fetchImpl body:
|
fetchImpl body:
|
||||||
if body.startsWith('{') or body.startsWith('['):
|
if body.startsWith('{') or body.startsWith('['):
|
||||||
result = parseJson(body)
|
result = parseJson(body)
|
||||||
else:
|
else:
|
||||||
echo resp.status, ": ", body, " --- url: ", url
|
echo resp.status, ": ", body, " --- url: ", url, ", session: ", session.pretty
|
||||||
result = newJNull()
|
result = newJNull()
|
||||||
|
|
||||||
updateToken()
|
|
||||||
|
|
||||||
let error = result.getError
|
let error = result.getError
|
||||||
if error in {invalidToken, badToken}:
|
if error != null and error notin errorsToSkip:
|
||||||
echo "fetch error: ", result.getError
|
echo "Fetch error, API: ", url.path, ", error: ", error, ", session: ", session.pretty
|
||||||
release(token, invalid=true)
|
if error in {expiredToken, badToken, locked}:
|
||||||
|
invalidate(session)
|
||||||
raise rateLimitError()
|
raise rateLimitError()
|
||||||
|
|
||||||
proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} =
|
proc fetchRaw*(req: ApiReq): Future[string] {.async.} =
|
||||||
|
retry:
|
||||||
|
session = await getAndValidateSession(req)
|
||||||
|
let url = req.toUrl(session.kind)
|
||||||
|
|
||||||
fetchImpl result:
|
fetchImpl result:
|
||||||
if not (result.startsWith('{') or result.startsWith('[')):
|
if not (result.startsWith('{') or result.startsWith('[')):
|
||||||
echo resp.status, ": ", result, " --- url: ", url
|
echo resp.status, ": ", result, " --- url: ", url, ", session: ", session.pretty
|
||||||
result.setLen(0)
|
result.setLen(0)
|
||||||
|
|
||||||
updateToken()
|
|
||||||
|
|
||||||
if result.startsWith("{\"errors"):
|
|
||||||
let errors = result.fromJson(Errors)
|
|
||||||
if errors in {invalidToken, badToken}:
|
|
||||||
echo "fetch error: ", errors
|
|
||||||
release(token, invalid=true)
|
|
||||||
raise rateLimitError()
|
|
||||||
|
|||||||
+228
@@ -0,0 +1,228 @@
|
|||||||
|
#SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os]
|
||||||
|
import types, consts
|
||||||
|
import experimental/parser/session
|
||||||
|
|
||||||
|
const hourInSeconds = 60 * 60
|
||||||
|
|
||||||
|
var
|
||||||
|
sessionPool: seq[Session]
|
||||||
|
enableLogging = false
|
||||||
|
# max requests at a time per session to avoid race conditions
|
||||||
|
maxConcurrentReqs = 2
|
||||||
|
|
||||||
|
proc setMaxConcurrentReqs*(reqs: int) =
|
||||||
|
if reqs > 0:
|
||||||
|
maxConcurrentReqs = reqs
|
||||||
|
|
||||||
|
template log(str: varargs[string, `$`]) =
|
||||||
|
echo "[sessions] ", str.join("")
|
||||||
|
|
||||||
|
proc endpoint*(req: ApiReq; session: Session): string =
|
||||||
|
case session.kind
|
||||||
|
of oauth: req.oauth.endpoint
|
||||||
|
of cookie: req.cookie.endpoint
|
||||||
|
|
||||||
|
proc pretty*(session: Session): string =
|
||||||
|
if session.isNil:
|
||||||
|
return "<null>"
|
||||||
|
|
||||||
|
if session.id > 0 and session.username.len > 0:
|
||||||
|
result = $session.id & " (" & session.username & ")"
|
||||||
|
elif session.username.len > 0:
|
||||||
|
result = session.username
|
||||||
|
elif session.id > 0:
|
||||||
|
result = $session.id
|
||||||
|
else:
|
||||||
|
result = "<unknown>"
|
||||||
|
result = $session.kind & " " & result
|
||||||
|
|
||||||
|
proc snowflakeToEpoch(flake: int64): int64 =
|
||||||
|
int64(((flake shr 22) + 1288834974657) div 1000)
|
||||||
|
|
||||||
|
proc getSessionPoolHealth*(): JsonNode =
|
||||||
|
let now = epochTime().int
|
||||||
|
|
||||||
|
var
|
||||||
|
totalReqs = 0
|
||||||
|
limited: PackedSet[int64]
|
||||||
|
reqsPerApi: Table[string, int]
|
||||||
|
oldest = now.int64
|
||||||
|
newest = 0'i64
|
||||||
|
average = 0'i64
|
||||||
|
oauthTotal, cookieTotal = 0
|
||||||
|
oauthLimited, cookieLimited = 0
|
||||||
|
|
||||||
|
for session in sessionPool:
|
||||||
|
let created = snowflakeToEpoch(session.id)
|
||||||
|
if created > newest:
|
||||||
|
newest = created
|
||||||
|
if created < oldest:
|
||||||
|
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
|
||||||
|
apiStatus = session.apis[api]
|
||||||
|
reqs = apiStatus.limit - apiStatus.remaining
|
||||||
|
|
||||||
|
# no requests made with this session and endpoint since the limit reset
|
||||||
|
if apiStatus.reset < now:
|
||||||
|
continue
|
||||||
|
|
||||||
|
reqsPerApi.mgetOrPut($api, 0).inc reqs
|
||||||
|
totalReqs.inc reqs
|
||||||
|
|
||||||
|
if sessionPool.len > 0:
|
||||||
|
average = average div sessionPool.len
|
||||||
|
else:
|
||||||
|
oldest = 0
|
||||||
|
average = 0
|
||||||
|
|
||||||
|
return %*{
|
||||||
|
"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)
|
||||||
|
},
|
||||||
|
"requests": %*{
|
||||||
|
"total": totalReqs,
|
||||||
|
"apis": reqsPerApi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proc getSessionPoolDebug*(): JsonNode =
|
||||||
|
let now = epochTime().int
|
||||||
|
var list = newJObject()
|
||||||
|
|
||||||
|
for session in sessionPool:
|
||||||
|
let sessionJson = %*{
|
||||||
|
"kind": $session.kind,
|
||||||
|
"apis": newJObject(),
|
||||||
|
"pending": session.pending,
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.limited:
|
||||||
|
sessionJson["limited"] = %true
|
||||||
|
|
||||||
|
for api in session.apis.keys:
|
||||||
|
let
|
||||||
|
apiStatus = session.apis[api]
|
||||||
|
obj = %*{}
|
||||||
|
|
||||||
|
if apiStatus.reset > now.int:
|
||||||
|
obj["remaining"] = %apiStatus.remaining
|
||||||
|
obj["reset"] = %apiStatus.reset
|
||||||
|
|
||||||
|
if "remaining" notin obj:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sessionJson{"apis", $api} = obj
|
||||||
|
list[$session.id] = sessionJson
|
||||||
|
|
||||||
|
return %list
|
||||||
|
|
||||||
|
proc rateLimitError*(): ref RateLimitError =
|
||||||
|
newException(RateLimitError, "rate limited")
|
||||||
|
|
||||||
|
proc noSessionsError*(): ref NoSessionsError =
|
||||||
|
newException(NoSessionsError, "no sessions available")
|
||||||
|
|
||||||
|
proc isLimited(session: Session; req: ApiReq): bool =
|
||||||
|
if session.isNil:
|
||||||
|
return true
|
||||||
|
|
||||||
|
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
|
||||||
|
return false
|
||||||
|
else:
|
||||||
|
return true
|
||||||
|
|
||||||
|
if api in session.apis:
|
||||||
|
let limit = session.apis[api]
|
||||||
|
return limit.remaining <= 10 and limit.reset > epochTime().int
|
||||||
|
else:
|
||||||
|
return false
|
||||||
|
|
||||||
|
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
|
||||||
|
log "invalidating: ", session.pretty
|
||||||
|
|
||||||
|
# TODO: This isn't sufficient, but it works for now
|
||||||
|
let idx = sessionPool.find(session)
|
||||||
|
if idx > -1: sessionPool.delete(idx)
|
||||||
|
session = nil
|
||||||
|
|
||||||
|
proc release*(session: Session) =
|
||||||
|
if session.isNil: return
|
||||||
|
dec session.pending
|
||||||
|
|
||||||
|
proc getSession*(req: ApiReq): Future[Session] {.async.} =
|
||||||
|
for i in 0 ..< sessionPool.len:
|
||||||
|
if result.isReady(req): break
|
||||||
|
result = sessionPool.sample()
|
||||||
|
|
||||||
|
if not result.isNil and result.isReady(req):
|
||||||
|
inc result.pending
|
||||||
|
else:
|
||||||
|
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; 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; 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:
|
||||||
|
return
|
||||||
|
if rateLimit.reset == reset and rateLimit.remaining >= remaining:
|
||||||
|
session.apis[api].remaining = remaining
|
||||||
|
return
|
||||||
|
|
||||||
|
session.apis[api] = RateLimit(limit: limit, remaining: remaining, reset: reset)
|
||||||
|
|
||||||
|
proc initSessionPool*(cfg: Config; path: string) =
|
||||||
|
enableLogging = cfg.enableDebug
|
||||||
|
|
||||||
|
if path.endsWith(".json"):
|
||||||
|
log "ERROR: .json is not supported, the file must be a valid JSONL file ending in .jsonl"
|
||||||
|
quit 1
|
||||||
|
|
||||||
|
if not fileExists(path):
|
||||||
|
log "ERROR: ", path, " not found. This file is required to authenticate API requests."
|
||||||
|
quit 1
|
||||||
|
|
||||||
|
log "parsing JSONL account sessions file: ", path
|
||||||
|
for line in path.lines:
|
||||||
|
sessionPool.add parseSession(line)
|
||||||
|
|
||||||
|
log "successfully added ", sessionPool.len, " valid account sessions"
|
||||||
+13
-2
@@ -13,6 +13,8 @@ proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
|
|||||||
proc getConfig*(path: string): (Config, parseCfg.Config) =
|
proc getConfig*(path: string): (Config, parseCfg.Config) =
|
||||||
var cfg = loadConfig(path)
|
var cfg = loadConfig(path)
|
||||||
|
|
||||||
|
let masterRss = cfg.get("Config", "enableRSS", true)
|
||||||
|
|
||||||
let conf = Config(
|
let conf = Config(
|
||||||
# Server
|
# Server
|
||||||
address: cfg.get("Server", "address", "0.0.0.0"),
|
address: cfg.get("Server", "address", "0.0.0.0"),
|
||||||
@@ -37,10 +39,19 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
|
|||||||
hmacKey: cfg.get("Config", "hmacKey", "secretkey"),
|
hmacKey: cfg.get("Config", "hmacKey", "secretkey"),
|
||||||
base64Media: cfg.get("Config", "base64Media", false),
|
base64Media: cfg.get("Config", "base64Media", false),
|
||||||
minTokens: cfg.get("Config", "tokenCount", 10),
|
minTokens: cfg.get("Config", "tokenCount", 10),
|
||||||
enableRss: cfg.get("Config", "enableRSS", true),
|
enableRSSUserTweets: masterRss and cfg.get("Config", "enableRSSUserTweets", true),
|
||||||
|
enableRSSUserReplies: masterRss and cfg.get("Config", "enableRSSUserReplies", true),
|
||||||
|
enableRSSUserMedia: masterRss and cfg.get("Config", "enableRSSUserMedia", true),
|
||||||
|
enableRSSSearch: masterRss and cfg.get("Config", "enableRSSSearch", true),
|
||||||
|
enableRSSList: masterRss and cfg.get("Config", "enableRSSList", true),
|
||||||
enableDebug: cfg.get("Config", "enableDebug", false),
|
enableDebug: cfg.get("Config", "enableDebug", false),
|
||||||
proxy: cfg.get("Config", "proxy", ""),
|
proxy: cfg.get("Config", "proxy", ""),
|
||||||
proxyAuth: cfg.get("Config", "proxyAuth", "")
|
proxyAuth: cfg.get("Config", "proxyAuth", ""),
|
||||||
|
apiProxy: cfg.get("Config", "apiProxy", ""),
|
||||||
|
disableTid: cfg.get("Config", "disableTid", false),
|
||||||
|
maxConcurrentReqs: cfg.get("Config", "maxConcurrentReqs", 2),
|
||||||
|
maxRetries: cfg.get("Config", "maxRetries", 1),
|
||||||
|
retryDelayMs: cfg.get("Config", "retryDelayMs", 150)
|
||||||
)
|
)
|
||||||
|
|
||||||
return (conf, cfg)
|
return (conf, cfg)
|
||||||
|
|||||||
+114
-99
@@ -1,121 +1,136 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import uri, sequtils, strutils
|
import strutils
|
||||||
|
|
||||||
const
|
const
|
||||||
auth* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
|
consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
|
||||||
|
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
|
||||||
|
bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
|
||||||
|
bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F"
|
||||||
|
|
||||||
api = parseUri("https://api.twitter.com")
|
graphUser* = "IGgvgiOx4QZndDHuD3x9TQ/UserByScreenName"
|
||||||
activate* = $(api / "1.1/guest/activate.json")
|
graphUserV2* = "-ZzAG_Bckx16LMbEvHC3lg/UserResultByScreenNameQuery"
|
||||||
|
graphUserById* = "-DAaa9jPxPswYeI2fZ9rug/UserResultByIdQuery"
|
||||||
|
graphUserTweetsV2* = "PHTSTXqZYuHIeK4B1HQprQ/UserWithProfileTweetsQueryV2"
|
||||||
|
graphUserTweetsAndRepliesV2* = "AcYHjc_YAx-9_rKWdMsKvA/UserWithProfileTweetsAndRepliesQueryV2"
|
||||||
|
graphUserTweets* = "PNd0vlufvrcIwrAnBYKE9g/UserTweets"
|
||||||
|
graphUserTweetsAndReplies* = "EqtpEwt0CoQXmDfq5DKH0A/UserTweetsAndReplies"
|
||||||
|
graphUserMedia* = "g_rGPF0fLON-M9cyVjXuzA/UserMedia"
|
||||||
|
graphUserMediaV2* = "WK111rbR0vM0ZX4lyZCYjw/MediaTimelineV2"
|
||||||
|
graphTweet* = "OZMbEnEa96AN8Pq6HyTWdw/ConversationTimeline"
|
||||||
|
graphTweetDetail* = "6uCvnic3m5reVuehkvHa3w/TweetDetail"
|
||||||
|
graphTweetResult* = "xYOrBQoTlfKJJPsX76MZEw/TweetResultByIdQuery"
|
||||||
|
graphTweetEditHistory* = "MGElmrYILE8wUfI8GorUYA/TweetEditHistory"
|
||||||
|
graphSearchTimeline* = "-TFXKoMnMTKdEXcCn-eahw/SearchTimeline"
|
||||||
|
|
||||||
photoRail* = api / "1.1/statuses/media_timeline.json"
|
graphListById* = "t9AbdyHaJVfjL9jsODwgpQ/ListByRestId"
|
||||||
userSearch* = api / "1.1/users/search.json"
|
graphListBySlug* = "LDQpQ89B5ipR8izCKrWU0g/ListBySlug"
|
||||||
|
graphListMembers* = "EM7YRaM3gCnzDESmchA7RA/ListMembers"
|
||||||
|
graphListTweets* = "0QJtcuMzVywHGAWD6Dtjlw/ListTimeline"
|
||||||
|
graphAboutAccount* = "zUnx-DLN9dkwOkNhTLySjg/AboutAccountQuery"
|
||||||
|
|
||||||
graphql = api / "graphql"
|
graphBroadcast* = "FJLCzpXCLPM1jUZqmM7oEA/BroadcastQuery"
|
||||||
graphUser* = graphql / "pVrmNaXcxPjisIvKtLDMEA/UserByScreenName"
|
restLiveStream* = "1.1/live_video_stream/status/"
|
||||||
graphUserById* = graphql / "1YAM811Q8Ry4XyPpJclURQ/UserByRestId"
|
|
||||||
graphUserTweets* = graphql / "WzJjibAcDa-oCjCcLOotcg/UserTweets"
|
|
||||||
graphUserTweetsAndReplies* = graphql / "fn9oRltM1N4thkh5CVusPg/UserTweetsAndReplies"
|
|
||||||
graphUserMedia* = graphql / "qQoeS7szGavsi8-ehD2AWg/UserMedia"
|
|
||||||
graphTweet* = graphql / "miKSMGb2R1SewIJv2-ablQ/TweetDetail"
|
|
||||||
graphTweetResult* = graphql / "0kc0a_7TTr3dvweZlMslsQ/TweetResultByRestId"
|
|
||||||
graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline"
|
|
||||||
graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId"
|
|
||||||
graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug"
|
|
||||||
graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers"
|
|
||||||
graphListTweets* = graphql / "jZntL0oVJSdjhmPcdbw_eA/ListLatestTweetsTimeline"
|
|
||||||
|
|
||||||
timelineParams* = {
|
|
||||||
"include_profile_interstitial_type": "0",
|
|
||||||
"include_blocking": "0",
|
|
||||||
"include_blocked_by": "0",
|
|
||||||
"include_followed_by": "0",
|
|
||||||
"include_want_retweets": "0",
|
|
||||||
"include_mute_edge": "0",
|
|
||||||
"include_can_dm": "0",
|
|
||||||
"include_can_media_tag": "1",
|
|
||||||
"include_ext_is_blue_verified": "1",
|
|
||||||
"skip_status": "1",
|
|
||||||
"cards_platform": "Web-12",
|
|
||||||
"include_cards": "1",
|
|
||||||
"include_composer_source": "0",
|
|
||||||
"include_reply_count": "1",
|
|
||||||
"tweet_mode": "extended",
|
|
||||||
"include_entities": "1",
|
|
||||||
"include_user_entities": "1",
|
|
||||||
"include_ext_media_color": "0",
|
|
||||||
"send_error_codes": "1",
|
|
||||||
"simple_quoted_tweet": "1",
|
|
||||||
"include_quote_count": "1"
|
|
||||||
}.toSeq
|
|
||||||
|
|
||||||
gqlFeatures* = """{
|
gqlFeatures* = """{
|
||||||
"blue_business_profile_image_shape_enabled": false,
|
"rweb_video_screen_enabled": false,
|
||||||
"creator_subscriptions_tweet_preview_api_enabled": true,
|
"rweb_cashtags_enabled": true,
|
||||||
"freedom_of_speech_not_reach_fetch_enabled": false,
|
"profile_label_improvements_pcf_label_in_post_enabled": true,
|
||||||
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": false,
|
"responsive_web_profile_redirect_enabled": false,
|
||||||
"highlights_tweets_tab_ui_enabled": false,
|
"rweb_tipjar_consumption_enabled": false,
|
||||||
"interactive_text_enabled": false,
|
|
||||||
"longform_notetweets_consumption_enabled": true,
|
|
||||||
"longform_notetweets_inline_media_enabled": false,
|
|
||||||
"longform_notetweets_richtext_consumption_enabled": true,
|
|
||||||
"longform_notetweets_rich_text_read_enabled": false,
|
|
||||||
"responsive_web_edit_tweet_api_enabled": false,
|
|
||||||
"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": false,
|
|
||||||
"responsive_web_text_conversations_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": false,
|
|
||||||
"tweet_awards_web_tipping_enabled": false,
|
|
||||||
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false,
|
|
||||||
"tweetypie_unmention_optimization_enabled": false,
|
|
||||||
"verified_phone_label_enabled": false,
|
"verified_phone_label_enabled": false,
|
||||||
"vibe_api_enabled": false,
|
"creator_subscriptions_tweet_preview_api_enabled": true,
|
||||||
"view_counts_everywhere_api_enabled": false
|
"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,
|
||||||
|
"c9s_tweet_anatomy_moderator_badge_enabled": true,
|
||||||
|
"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,
|
||||||
|
"responsive_web_grok_community_note_auto_translation_is_enabled": true,
|
||||||
|
"responsive_web_enhance_cards_enabled": false
|
||||||
}""".replace(" ", "").replace("\n", "")
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
tweetVariables* = """{
|
tweetVars* = """{
|
||||||
|
"postId": "$1",
|
||||||
|
$2
|
||||||
|
"includeHasBirdwatchNotes": false,
|
||||||
|
"includePromotedContent": false,
|
||||||
|
"withBirdwatchNotes": true,
|
||||||
|
"withVoice": false,
|
||||||
|
"withV2Timeline": true
|
||||||
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
|
tweetDetailVars* = """{
|
||||||
"focalTweetId": "$1",
|
"focalTweetId": "$1",
|
||||||
$2
|
$2
|
||||||
"withBirdwatchNotes": false,
|
"referrer": "profile",
|
||||||
"includePromotedContent": false,
|
"with_rux_injections": false,
|
||||||
"withDownvotePerspective": false,
|
"rankingMode": "Relevance",
|
||||||
"withReactionsMetadata": false,
|
"includePromotedContent": true,
|
||||||
"withReactionsPerspective": false,
|
"withCommunity": true,
|
||||||
"withVoice": false
|
"withQuickPromoteEligibilityTweetFields": true,
|
||||||
}"""
|
"withBirdwatchNotes": true,
|
||||||
|
"withVoice": true
|
||||||
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
tweetResultVariables* = """{
|
tweetEditHistoryVars* = """{
|
||||||
"tweetId": "$1",
|
"tweetId": "$1",
|
||||||
"includePromotedContent": false,
|
"withQuickPromoteEligibilityTweetFields": true
|
||||||
"withDownvotePerspective": false,
|
}""".replace(" ", "").replace("\n", "")
|
||||||
"withReactionsMetadata": false,
|
|
||||||
"withReactionsPerspective": false,
|
|
||||||
"withVoice": false,
|
|
||||||
"withCommunity": false
|
|
||||||
}"""
|
|
||||||
|
|
||||||
userTweetsVariables* = """{
|
restIdVars* = """{
|
||||||
|
"rest_id": "$1", $2
|
||||||
|
"count": $3
|
||||||
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
|
userMediaVars* = """{
|
||||||
|
"userId": "$1", $2
|
||||||
|
"count": $3,
|
||||||
|
"includePromotedContent": false,
|
||||||
|
"withClientEventToken": false,
|
||||||
|
"withBirdwatchNotes": false,
|
||||||
|
"withVoice": true
|
||||||
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
|
userTweetsVars* = """{
|
||||||
"userId": "$1", $2
|
"userId": "$1", $2
|
||||||
"count": 20,
|
"count": 20,
|
||||||
"includePromotedContent": false,
|
"includePromotedContent": false,
|
||||||
"withDownvotePerspective": false,
|
"withQuickPromoteEligibilityTweetFields": true,
|
||||||
"withReactionsMetadata": false,
|
"withVoice": true
|
||||||
"withReactionsPerspective": false,
|
}""".replace(" ", "").replace("\n", "")
|
||||||
"withVoice": false,
|
|
||||||
"withV2Timeline": true
|
|
||||||
}"""
|
|
||||||
|
|
||||||
listTweetsVariables* = """{
|
userTweetsAndRepliesVars* = """{
|
||||||
"listId": "$1", $2
|
"userId": "$1", $2
|
||||||
"count": 20,
|
"count": 20,
|
||||||
"includePromotedContent": false,
|
"includePromotedContent": false,
|
||||||
"withDownvotePerspective": false,
|
"withCommunity": true,
|
||||||
"withReactionsMetadata": false,
|
"withVoice": true
|
||||||
"withReactionsPerspective": false,
|
}""".replace(" ", "").replace("\n", "")
|
||||||
"withVoice": false
|
|
||||||
}"""
|
userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
|
||||||
|
userTweetsFieldToggles* = """{"withArticlePlainText":false}"""
|
||||||
|
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""
|
||||||
|
|||||||
@@ -1,17 +1,53 @@
|
|||||||
import options
|
import options, strutils
|
||||||
import jsony
|
import jsony
|
||||||
import user, ../types/[graphuser, graphlistmembers]
|
import user, utils, ../types/[graphuser, graphlistmembers]
|
||||||
from ../../types import User, Result, Query, QueryKind
|
from ../../types import User, VerifiedType, Result, Query, QueryKind
|
||||||
|
|
||||||
|
proc parseUserResult*(userResult: UserResult): User =
|
||||||
|
result = userResult.legacy
|
||||||
|
|
||||||
|
if result.verifiedType == none and userResult.isBlueVerified:
|
||||||
|
result.verifiedType = blue
|
||||||
|
|
||||||
|
if result.username.len == 0 and userResult.core.screenName.len > 0:
|
||||||
|
result.id = userResult.restId
|
||||||
|
result.username = userResult.core.screenName
|
||||||
|
result.fullname = userResult.core.name
|
||||||
|
result.userPic = userResult.avatar.imageUrl.replace("_normal", "")
|
||||||
|
|
||||||
|
if userResult.privacy.isSome:
|
||||||
|
result.protected = userResult.privacy.get.protected
|
||||||
|
|
||||||
|
if userResult.location.isSome:
|
||||||
|
result.location = userResult.location.get.location
|
||||||
|
|
||||||
|
if userResult.core.createdAt.len > 0:
|
||||||
|
result.joinDate = parseTwitterDate(userResult.core.createdAt)
|
||||||
|
|
||||||
|
if userResult.verification.isSome:
|
||||||
|
let v = userResult.verification.get
|
||||||
|
if v.verifiedType != VerifiedType.none:
|
||||||
|
result.verifiedType = v.verifiedType
|
||||||
|
|
||||||
|
if userResult.profileBio.isSome and result.bio.len == 0:
|
||||||
|
result.bio = userResult.profileBio.get.description
|
||||||
|
|
||||||
proc parseGraphUser*(json: string): User =
|
proc parseGraphUser*(json: string): User =
|
||||||
let raw = json.fromJson(GraphUser)
|
if json.len == 0 or json[0] != '{':
|
||||||
|
return
|
||||||
|
|
||||||
if raw.data.user.result.reason.get("") == "Suspended":
|
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" or
|
||||||
|
userResult.reason.get("") == "Suspended":
|
||||||
return User(suspended: true)
|
return User(suspended: true)
|
||||||
|
|
||||||
result = toUser raw.data.user.result.legacy
|
result = parseUserResult(userResult)
|
||||||
result.id = raw.data.user.result.restId
|
|
||||||
result.verified = result.verified or raw.data.user.result.isBlueVerified
|
|
||||||
|
|
||||||
proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
||||||
result = Result[User](
|
result = Result[User](
|
||||||
@@ -27,7 +63,7 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
|||||||
of TimelineTimelineItem:
|
of TimelineTimelineItem:
|
||||||
let userResult = entry.content.itemContent.userResults.result
|
let userResult = entry.content.itemContent.userResults.result
|
||||||
if userResult.restId.len > 0:
|
if userResult.restId.len > 0:
|
||||||
result.content.add toUser userResult.legacy
|
result.content.add parseUserResult(userResult)
|
||||||
of TimelineTimelineCursor:
|
of TimelineTimelineCursor:
|
||||||
if entry.content.cursorType == "Bottom":
|
if entry.content.cursorType == "Bottom":
|
||||||
result.bottom = entry.content.value
|
result.bottom = entry.content.value
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import std/strutils
|
||||||
|
import jsony
|
||||||
|
import ../types/session
|
||||||
|
from ../../types import Session, SessionKind
|
||||||
|
|
||||||
|
proc parseSession*(raw: string): Session =
|
||||||
|
let session = raw.fromJson(RawSession)
|
||||||
|
let kind = if session.kind == "": "oauth" else: session.kind
|
||||||
|
|
||||||
|
case kind
|
||||||
|
of "oauth":
|
||||||
|
let id = session.oauthToken[0 ..< session.oauthToken.find('-')]
|
||||||
|
result = Session(
|
||||||
|
kind: SessionKind.oauth,
|
||||||
|
id: parseBiggestInt(id),
|
||||||
|
username: session.username,
|
||||||
|
oauthToken: session.oauthToken,
|
||||||
|
oauthSecret: session.oauthTokenSecret
|
||||||
|
)
|
||||||
|
of "cookie":
|
||||||
|
let id = if session.id.len > 0: parseBiggestInt(session.id) else: 0
|
||||||
|
result = Session(
|
||||||
|
kind: SessionKind.cookie,
|
||||||
|
id: id,
|
||||||
|
username: session.username,
|
||||||
|
authToken: session.authToken,
|
||||||
|
ct0: session.ct0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise newException(ValueError, "Unknown session kind: " & kind)
|
||||||
@@ -54,7 +54,7 @@ proc replacedWith*(runes: seq[Rune]; repls: openArray[ReplaceSlice];
|
|||||||
let
|
let
|
||||||
name = $runes[rep.slice.a.succ .. rep.slice.b]
|
name = $runes[rep.slice.a.succ .. rep.slice.b]
|
||||||
symbol = $runes[rep.slice.a]
|
symbol = $runes[rep.slice.a]
|
||||||
result.add a(symbol & name, href = "/search?q=%23" & name)
|
result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name)
|
||||||
of rkMention:
|
of rkMention:
|
||||||
result.add a($runes[rep.slice], href = rep.url, title = rep.display)
|
result.add a($runes[rep.slice], href = rep.url, title = rep.display)
|
||||||
of rkUrl:
|
of rkUrl:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import std/[options, tables, strutils, strformat, sugar]
|
import std/[options, tables, strutils, strformat, sugar]
|
||||||
import jsony
|
import jsony
|
||||||
import ../types/unifiedcard
|
import user, ../types/unifiedcard
|
||||||
|
import ../../formatters
|
||||||
from ../../types import Card, CardKind, Video
|
from ../../types import Card, CardKind, Video
|
||||||
from ../../utils import twimg, https
|
from ../../utils import twimg, https
|
||||||
|
|
||||||
@@ -27,6 +28,14 @@ proc parseMediaDetails(data: ComponentData; card: UnifiedCard; result: var Card)
|
|||||||
result.text = data.topicDetail.title
|
result.text = data.topicDetail.title
|
||||||
result.dest = "Topic"
|
result.dest = "Topic"
|
||||||
|
|
||||||
|
proc parseJobDetails(data: ComponentData; card: UnifiedCard; result: var Card) =
|
||||||
|
data.destination.parseDestination(card, result)
|
||||||
|
|
||||||
|
result.kind = CardKind.jobDetails
|
||||||
|
result.title = data.title
|
||||||
|
result.text = data.shortDescriptionText
|
||||||
|
result.dest = &"@{data.profileUser.username} · {data.location}"
|
||||||
|
|
||||||
proc parseAppDetails(data: ComponentData; card: UnifiedCard; result: var Card) =
|
proc parseAppDetails(data: ComponentData; card: UnifiedCard; result: var Card) =
|
||||||
let app = card.appStoreData[data.appId][0]
|
let app = card.appStoreData[data.appId][0]
|
||||||
|
|
||||||
@@ -69,6 +78,18 @@ proc parseMedia(component: Component; card: UnifiedCard; result: var Card) =
|
|||||||
of model3d:
|
of model3d:
|
||||||
result.title = "Unsupported 3D model ad"
|
result.title = "Unsupported 3D model ad"
|
||||||
|
|
||||||
|
proc parseGrokShare(data: ComponentData; card: UnifiedCard; result: var Card) =
|
||||||
|
result.kind = summaryLarge
|
||||||
|
|
||||||
|
data.destination.parseDestination(card, result)
|
||||||
|
result.dest = "Answer by Grok"
|
||||||
|
|
||||||
|
for msg in data.conversationPreview:
|
||||||
|
if msg.sender == "USER":
|
||||||
|
result.title = msg.message.shorten(70)
|
||||||
|
elif msg.sender == "AGENT":
|
||||||
|
result.text = msg.message.shorten(500)
|
||||||
|
|
||||||
proc parseUnifiedCard*(json: string): Card =
|
proc parseUnifiedCard*(json: string): Card =
|
||||||
let card = json.fromJson(UnifiedCard)
|
let card = json.fromJson(UnifiedCard)
|
||||||
|
|
||||||
@@ -84,6 +105,10 @@ proc parseUnifiedCard*(json: string): Card =
|
|||||||
component.parseMedia(card, result)
|
component.parseMedia(card, result)
|
||||||
of buttonGroup:
|
of buttonGroup:
|
||||||
discard
|
discard
|
||||||
|
of grokShare:
|
||||||
|
component.data.parseGrokShare(card, result)
|
||||||
|
of ComponentType.jobDetails:
|
||||||
|
component.data.parseJobDetails(card, result)
|
||||||
of ComponentType.hidden:
|
of ComponentType.hidden:
|
||||||
result.kind = CardKind.hidden
|
result.kind = CardKind.hidden
|
||||||
of ComponentType.unknown:
|
of ComponentType.unknown:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ let
|
|||||||
unReplace = "$1<a href=\"/$2\">@$2</a>"
|
unReplace = "$1<a href=\"/$2\">@$2</a>"
|
||||||
|
|
||||||
htRegex = nre.re"""(*U)(^|[^\w-_.?])([##$])([\w_]*+)(?!</a>|">|#)"""
|
htRegex = nre.re"""(*U)(^|[^\w-_.?])([##$])([\w_]*+)(?!</a>|">|#)"""
|
||||||
htReplace = "$1<a href=\"/search?q=%23$3\">$2$3</a>"
|
htReplace = "$1<a href=\"/search?f=tweets&q=%23$3\">$2$3</a>"
|
||||||
|
|
||||||
proc expandUserEntities(user: var User; raw: RawUser) =
|
proc expandUserEntities(user: var User; raw: RawUser) =
|
||||||
let
|
let
|
||||||
@@ -56,32 +56,21 @@ proc toUser*(raw: RawUser): User =
|
|||||||
tweets: raw.statusesCount,
|
tweets: raw.statusesCount,
|
||||||
likes: raw.favouritesCount,
|
likes: raw.favouritesCount,
|
||||||
media: raw.mediaCount,
|
media: raw.mediaCount,
|
||||||
verified: raw.verified,
|
verifiedType: raw.verifiedType,
|
||||||
protected: raw.protected,
|
protected: raw.protected,
|
||||||
joinDate: parseTwitterDate(raw.createdAt),
|
|
||||||
banner: getBanner(raw),
|
banner: getBanner(raw),
|
||||||
userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "")
|
userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if raw.createdAt.len > 0:
|
||||||
|
result.joinDate = parseTwitterDate(raw.createdAt)
|
||||||
|
|
||||||
if raw.pinnedTweetIdsStr.len > 0:
|
if raw.pinnedTweetIdsStr.len > 0:
|
||||||
result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0])
|
result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0])
|
||||||
|
|
||||||
result.expandUserEntities(raw)
|
result.expandUserEntities(raw)
|
||||||
|
|
||||||
proc parseUser*(json: string; username=""): User =
|
proc parseHook*(s: string; i: var int; v: var User) =
|
||||||
handleErrors:
|
var u: RawUser
|
||||||
case error.code
|
parseHook(s, i, u)
|
||||||
of suspended: return User(username: username, suspended: true)
|
v = toUser u
|
||||||
of userNotFound: return
|
|
||||||
else: echo "[error - parseUser]: ", error
|
|
||||||
|
|
||||||
result = toUser json.fromJson(RawUser)
|
|
||||||
|
|
||||||
proc parseUsers*(json: string; after=""): Result[User] =
|
|
||||||
result = Result[User](beginning: after.len == 0)
|
|
||||||
|
|
||||||
# starting with '{' means it's an error
|
|
||||||
if json[0] == '[':
|
|
||||||
let raw = json.fromJson(seq[RawUser])
|
|
||||||
for user in raw:
|
|
||||||
result.content.add user.toUser
|
|
||||||
|
|||||||
@@ -1,15 +1,48 @@
|
|||||||
import options
|
import options, strutils
|
||||||
import user
|
from ../../types import User, VerifiedType
|
||||||
|
|
||||||
type
|
type
|
||||||
GraphUser* = object
|
GraphUser* = object
|
||||||
data*: tuple[user: UserData]
|
data*: tuple[userResult: Option[UserData], user: Option[UserData]]
|
||||||
|
|
||||||
UserData* = object
|
UserData* = object
|
||||||
result*: UserResult
|
result*: UserResult
|
||||||
|
|
||||||
UserResult = object
|
UserCore* = object
|
||||||
legacy*: RawUser
|
name*: string
|
||||||
|
screenName*: string
|
||||||
|
createdAt*: string
|
||||||
|
|
||||||
|
UserBio* = object
|
||||||
|
description*: string
|
||||||
|
|
||||||
|
UserAvatar* = object
|
||||||
|
imageUrl*: string
|
||||||
|
|
||||||
|
Verification* = object
|
||||||
|
verifiedType*: VerifiedType
|
||||||
|
|
||||||
|
Location* = object
|
||||||
|
location*: string
|
||||||
|
|
||||||
|
Privacy* = object
|
||||||
|
protected*: bool
|
||||||
|
|
||||||
|
UserResult* = object
|
||||||
|
legacy*: User
|
||||||
restId*: string
|
restId*: string
|
||||||
isBlueVerified*: bool
|
isBlueVerified*: bool
|
||||||
|
core*: UserCore
|
||||||
|
avatar*: UserAvatar
|
||||||
|
unavailableReason*: Option[string]
|
||||||
reason*: Option[string]
|
reason*: Option[string]
|
||||||
|
privacy*: Option[Privacy]
|
||||||
|
profileBio*: Option[UserBio]
|
||||||
|
verification*: Option[Verification]
|
||||||
|
location*: Option[Location]
|
||||||
|
|
||||||
|
proc enumHook*(s: string; v: var VerifiedType) =
|
||||||
|
v = try:
|
||||||
|
parseEnum[VerifiedType](s)
|
||||||
|
except:
|
||||||
|
VerifiedType.none
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
type
|
||||||
|
RawSession* = object
|
||||||
|
kind*: string
|
||||||
|
id*: string
|
||||||
|
username*: string
|
||||||
|
oauthToken*: string
|
||||||
|
oauthTokenSecret*: string
|
||||||
|
authToken*: string
|
||||||
|
ct0*: string
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type
|
||||||
|
TidPair* = object
|
||||||
|
animationKey*: string
|
||||||
|
verification*: string
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import std/tables
|
|
||||||
import user
|
|
||||||
|
|
||||||
type
|
|
||||||
Search* = object
|
|
||||||
globalObjects*: GlobalObjects
|
|
||||||
timeline*: Timeline
|
|
||||||
|
|
||||||
GlobalObjects = object
|
|
||||||
users*: Table[string, RawUser]
|
|
||||||
|
|
||||||
Timeline = object
|
|
||||||
instructions*: seq[Instructions]
|
|
||||||
|
|
||||||
Instructions = object
|
|
||||||
addEntries*: tuple[entries: seq[Entry]]
|
|
||||||
|
|
||||||
Entry = object
|
|
||||||
entryId*: string
|
|
||||||
content*: tuple[operation: Operation]
|
|
||||||
|
|
||||||
Operation = object
|
|
||||||
cursor*: tuple[value, cursorType: string]
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import options, tables
|
import std/[options, tables, times]
|
||||||
from ../../types import VideoType, VideoVariant
|
import jsony
|
||||||
|
from ../../types import VideoType, VideoVariant, User
|
||||||
|
|
||||||
type
|
type
|
||||||
|
Text* = distinct string
|
||||||
|
|
||||||
UnifiedCard* = object
|
UnifiedCard* = object
|
||||||
componentObjects*: Table[string, Component]
|
componentObjects*: Table[string, Component]
|
||||||
destinationObjects*: Table[string, Destination]
|
destinationObjects*: Table[string, Destination]
|
||||||
@@ -13,11 +16,13 @@ type
|
|||||||
media
|
media
|
||||||
swipeableMedia
|
swipeableMedia
|
||||||
buttonGroup
|
buttonGroup
|
||||||
|
jobDetails
|
||||||
appStoreDetails
|
appStoreDetails
|
||||||
twitterListDetails
|
twitterListDetails
|
||||||
communityDetails
|
communityDetails
|
||||||
mediaWithDetailsHorizontal
|
mediaWithDetailsHorizontal
|
||||||
hidden
|
hidden
|
||||||
|
grokShare
|
||||||
unknown
|
unknown
|
||||||
|
|
||||||
Component* = object
|
Component* = object
|
||||||
@@ -29,12 +34,16 @@ type
|
|||||||
appId*: string
|
appId*: string
|
||||||
mediaId*: string
|
mediaId*: string
|
||||||
destination*: string
|
destination*: string
|
||||||
|
location*: string
|
||||||
title*: Text
|
title*: Text
|
||||||
subtitle*: Text
|
subtitle*: Text
|
||||||
name*: Text
|
name*: Text
|
||||||
memberCount*: int
|
memberCount*: int
|
||||||
mediaList*: seq[MediaItem]
|
mediaList*: seq[MediaItem]
|
||||||
topicDetail*: tuple[title: Text]
|
topicDetail*: tuple[title: Text]
|
||||||
|
profileUser*: User
|
||||||
|
shortDescriptionText*: string
|
||||||
|
conversationPreview*: seq[GrokConversation]
|
||||||
|
|
||||||
MediaItem* = object
|
MediaItem* = object
|
||||||
id*: string
|
id*: string
|
||||||
@@ -69,12 +78,13 @@ type
|
|||||||
title*: Text
|
title*: Text
|
||||||
category*: Text
|
category*: Text
|
||||||
|
|
||||||
Text = object
|
GrokConversation* = object
|
||||||
content: string
|
message*: string
|
||||||
|
sender*: string
|
||||||
|
|
||||||
TypeField = Component | Destination | MediaEntity | AppStoreData
|
TypeField = Component | Destination | MediaEntity | AppStoreData
|
||||||
|
|
||||||
converter fromText*(text: Text): string = text.content
|
converter fromText*(text: Text): string = string(text)
|
||||||
|
|
||||||
proc renameHook*(v: var TypeField; fieldName: var string) =
|
proc renameHook*(v: var TypeField; fieldName: var string) =
|
||||||
if fieldName == "type":
|
if fieldName == "type":
|
||||||
@@ -86,11 +96,13 @@ proc enumHook*(s: string; v: var ComponentType) =
|
|||||||
of "media": media
|
of "media": media
|
||||||
of "swipeable_media": swipeableMedia
|
of "swipeable_media": swipeableMedia
|
||||||
of "button_group": buttonGroup
|
of "button_group": buttonGroup
|
||||||
|
of "job_details": jobDetails
|
||||||
of "app_store_details": appStoreDetails
|
of "app_store_details": appStoreDetails
|
||||||
of "twitter_list_details": twitterListDetails
|
of "twitter_list_details": twitterListDetails
|
||||||
of "community_details": communityDetails
|
of "community_details": communityDetails
|
||||||
of "media_with_details_horizontal": mediaWithDetailsHorizontal
|
of "media_with_details_horizontal": mediaWithDetailsHorizontal
|
||||||
of "commerce_drop_details": hidden
|
of "commerce_drop_details": hidden
|
||||||
|
of "grok_share": grokShare
|
||||||
else: echo "ERROR: Unknown enum value (ComponentType): ", s; unknown
|
else: echo "ERROR: Unknown enum value (ComponentType): ", s; unknown
|
||||||
|
|
||||||
proc enumHook*(s: string; v: var AppType) =
|
proc enumHook*(s: string; v: var AppType) =
|
||||||
@@ -106,3 +118,18 @@ proc enumHook*(s: string; v: var MediaType) =
|
|||||||
of "photo": photo
|
of "photo": photo
|
||||||
of "model3d": model3d
|
of "model3d": model3d
|
||||||
else: echo "ERROR: Unknown enum value (MediaType): ", s; photo
|
else: echo "ERROR: Unknown enum value (MediaType): ", s; photo
|
||||||
|
|
||||||
|
proc parseHook*(s: string; i: var int; v: var DateTime) =
|
||||||
|
var str: string
|
||||||
|
parseHook(s, i, str)
|
||||||
|
v = parse(str, "yyyy-MM-dd hh:mm:ss")
|
||||||
|
|
||||||
|
proc parseHook*(s: string; i: var int; v: var Text) =
|
||||||
|
if s[i] == '"':
|
||||||
|
var str: string
|
||||||
|
parseHook(s, i, str)
|
||||||
|
v = Text(str)
|
||||||
|
else:
|
||||||
|
var t: tuple[content: string]
|
||||||
|
parseHook(s, i, t)
|
||||||
|
v = Text(t.content)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import options
|
import options
|
||||||
import common
|
import common
|
||||||
|
from ../../types import VerifiedType
|
||||||
|
|
||||||
type
|
type
|
||||||
RawUser* = object
|
RawUser* = object
|
||||||
@@ -15,7 +16,7 @@ type
|
|||||||
favouritesCount*: int
|
favouritesCount*: int
|
||||||
statusesCount*: int
|
statusesCount*: int
|
||||||
mediaCount*: int
|
mediaCount*: int
|
||||||
verified*: bool
|
verifiedType*: VerifiedType
|
||||||
protected*: bool
|
protected*: bool
|
||||||
profileLinkColor*: string
|
profileLinkColor*: string
|
||||||
profileBannerUrl*: string
|
profileBannerUrl*: string
|
||||||
|
|||||||
+82
-27
@@ -1,16 +1,18 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen
|
import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen, math
|
||||||
import std/[enumerate, re]
|
import std/[enumerate, re]
|
||||||
import types, utils, query
|
import types, utils, query
|
||||||
|
|
||||||
const
|
const
|
||||||
cards = "cards.twitter.com/cards"
|
cards = "cards.twitter.com/cards"
|
||||||
tco = "https://t.co"
|
tco = "https://t.co"
|
||||||
twitter = parseUri("https://twitter.com")
|
twitter = parseUri("https://x.com")
|
||||||
|
|
||||||
let
|
let
|
||||||
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
|
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
|
||||||
twLinkRegex = re"""<a href="https:\/\/twitter.com([^"]+)">twitter\.com(\S+)</a>"""
|
twLinkRegex = re"""<a href="https:\/\/twitter.com([^"]+)">twitter\.com(\S+)</a>"""
|
||||||
|
xRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?x\.com"
|
||||||
|
xLinkRegex = re"""<a href="https:\/\/x.com([^"]+)">x\.com(\S+)</a>"""
|
||||||
|
|
||||||
ytRegex = re(r"([A-z.]+\.)?youtu(be\.com|\.be)", {reStudy, reIgnoreCase})
|
ytRegex = re(r"([A-z.]+\.)?youtu(be\.com|\.be)", {reStudy, reIgnoreCase})
|
||||||
|
|
||||||
@@ -31,11 +33,14 @@ proc getUrlPrefix*(cfg: Config): string =
|
|||||||
if cfg.useHttps: https & cfg.hostname
|
if cfg.useHttps: https & cfg.hostname
|
||||||
else: "http://" & cfg.hostname
|
else: "http://" & cfg.hostname
|
||||||
|
|
||||||
proc shortLink*(text: string; length=28): string =
|
proc shorten*(text: string; length=28): string =
|
||||||
result = text.replace(wwwRegex, "")
|
result = text
|
||||||
if result.len > length:
|
if result.len > length:
|
||||||
result = result[0 ..< length] & "…"
|
result = result[0 ..< length] & "…"
|
||||||
|
|
||||||
|
proc shortLink*(text: string; length=28): string =
|
||||||
|
result = text.replace(wwwRegex, "").shorten(length)
|
||||||
|
|
||||||
proc stripHtml*(text: string; shorten=false): string =
|
proc stripHtml*(text: string; shorten=false): string =
|
||||||
var html = parseHtml(text)
|
var html = parseHtml(text)
|
||||||
for el in html.findAll("a"):
|
for el in html.findAll("a"):
|
||||||
@@ -54,19 +59,28 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
|
|||||||
result = body
|
result = body
|
||||||
|
|
||||||
if prefs.replaceYouTube.len > 0 and "youtu" in result:
|
if prefs.replaceYouTube.len > 0 and "youtu" in result:
|
||||||
result = result.replace(ytRegex, prefs.replaceYouTube)
|
let youtubeHost = strip(prefs.replaceYouTube, chars={'/'})
|
||||||
|
result = result.replace(ytRegex, youtubeHost)
|
||||||
|
|
||||||
if prefs.replaceTwitter.len > 0 and ("twitter.com" in body or tco in body):
|
if prefs.replaceTwitter.len > 0:
|
||||||
result = result.replace(tco, https & prefs.replaceTwitter & "/t.co")
|
let twitterHost = strip(prefs.replaceTwitter, chars={'/'})
|
||||||
result = result.replace(cards, prefs.replaceTwitter & "/cards")
|
if tco in result:
|
||||||
result = result.replace(twRegex, prefs.replaceTwitter)
|
result = result.replace(tco, https & twitterHost & "/t.co")
|
||||||
|
if "x.com" in result:
|
||||||
|
result = result.replace(xRegex, twitterHost)
|
||||||
|
result = result.replacef(xLinkRegex, a(
|
||||||
|
twitterHost & "$2", href = https & twitterHost & "$1"))
|
||||||
|
if "twitter.com" in result:
|
||||||
|
result = result.replace(cards, twitterHost & "/cards")
|
||||||
|
result = result.replace(twRegex, twitterHost)
|
||||||
result = result.replacef(twLinkRegex, a(
|
result = result.replacef(twLinkRegex, a(
|
||||||
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
|
twitterHost & "$2", href = https & twitterHost & "$1"))
|
||||||
|
|
||||||
if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result):
|
if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result):
|
||||||
result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/")
|
let redditHost = strip(prefs.replaceReddit, chars={'/'})
|
||||||
result = result.replace(rdRegex, prefs.replaceReddit)
|
result = result.replace(rdShortRegex, redditHost & "/comments/")
|
||||||
if prefs.replaceReddit in result and "/gallery/" in result:
|
result = result.replace(rdRegex, redditHost)
|
||||||
|
if redditHost in result and "/gallery/" in result:
|
||||||
result = result.replace("/gallery/", "/comments/")
|
result = result.replace("/gallery/", "/comments/")
|
||||||
|
|
||||||
if absolute.len > 0 and "href" in result:
|
if absolute.len > 0 and "href" in result:
|
||||||
@@ -77,15 +91,31 @@ proc getM3u8Url*(content: string): string =
|
|||||||
if re.find(content, m3u8Regex, matches) != -1:
|
if re.find(content, m3u8Regex, matches) != -1:
|
||||||
result = matches[0]
|
result = matches[0]
|
||||||
|
|
||||||
proc proxifyVideo*(manifest: string; proxy: bool): string =
|
proc proxifyVideo*(manifest: string; proxy: bool; manifestUrl = ""): string =
|
||||||
|
let (baseUrl, basePath) =
|
||||||
|
if manifestUrl.len > 0:
|
||||||
|
let
|
||||||
|
u = parseUri(manifestUrl)
|
||||||
|
origin = u.scheme & "://" & u.hostname
|
||||||
|
idx = manifestUrl.rfind('/')
|
||||||
|
dirPath = if idx > 8: manifestUrl[0 .. idx] else: ""
|
||||||
|
(origin, dirPath)
|
||||||
|
else:
|
||||||
|
("https://video.twimg.com", "")
|
||||||
var replacements: seq[(string, string)]
|
var replacements: seq[(string, string)]
|
||||||
for line in manifest.splitLines:
|
for line in manifest.splitLines:
|
||||||
let url =
|
let url =
|
||||||
if line.startsWith("#EXT-X-MAP:URI"): line[16 .. ^2]
|
if line.startsWith("#EXT-X-MAP:URI"): line[16 .. ^2]
|
||||||
|
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
|
else: line
|
||||||
if url.startsWith('/'):
|
let resolved =
|
||||||
let path = "https://video.twimg.com" & url
|
if url.startsWith('/'): baseUrl & url
|
||||||
replacements.add (url, if proxy: path.getVidUrl else: path)
|
elif basePath.len > 0 and url.len > 0 and not url.startsWith('#') and
|
||||||
|
not url.startsWith("http") and ('.' in url): basePath & url
|
||||||
|
else: ""
|
||||||
|
if resolved.len > 0:
|
||||||
|
replacements.add (url, if proxy: resolved.getVidUrl else: resolved)
|
||||||
return manifest.multiReplace(replacements)
|
return manifest.multiReplace(replacements)
|
||||||
|
|
||||||
proc getUserPic*(userPic: string; style=""): string =
|
proc getUserPic*(userPic: string; style=""): string =
|
||||||
@@ -110,25 +140,30 @@ proc pageDesc*(user: User): string =
|
|||||||
"The latest tweets from " & user.fullname
|
"The latest tweets from " & user.fullname
|
||||||
|
|
||||||
proc getJoinDate*(user: User): string =
|
proc getJoinDate*(user: User): string =
|
||||||
|
if user.joinDate.year == 0: return ""
|
||||||
user.joinDate.format("'Joined' MMMM YYYY")
|
user.joinDate.format("'Joined' MMMM YYYY")
|
||||||
|
|
||||||
proc getJoinDateFull*(user: User): string =
|
proc getJoinDateFull*(user: User): string =
|
||||||
|
if user.joinDate.year == 0: return ""
|
||||||
user.joinDate.format("h:mm tt - d MMM YYYY")
|
user.joinDate.format("h:mm tt - d MMM YYYY")
|
||||||
|
|
||||||
proc getTime*(tweet: Tweet): string =
|
proc getTime*(tweet: Tweet): string =
|
||||||
|
if tweet.time.year == 0: return ""
|
||||||
tweet.time.format("MMM d', 'YYYY' · 'h:mm tt' UTC'")
|
tweet.time.format("MMM d', 'YYYY' · 'h:mm tt' UTC'")
|
||||||
|
|
||||||
proc getRfc822Time*(tweet: Tweet): string =
|
proc getRfc822Time*(tweet: Tweet): string =
|
||||||
|
if tweet.time.year == 0: return ""
|
||||||
tweet.time.format("ddd', 'dd MMM yyyy HH:mm:ss 'GMT'")
|
tweet.time.format("ddd', 'dd MMM yyyy HH:mm:ss 'GMT'")
|
||||||
|
|
||||||
proc getShortTime*(tweet: Tweet): string =
|
proc getShortTime*(time: DateTime): string =
|
||||||
|
if time.year == 0: return ""
|
||||||
let now = now()
|
let now = now()
|
||||||
let since = now - tweet.time
|
let since = now - time
|
||||||
|
|
||||||
if now.year != tweet.time.year:
|
if now.year != time.year:
|
||||||
result = tweet.time.format("d MMM yyyy")
|
result = time.format("d MMM yyyy")
|
||||||
elif since.inDays >= 1:
|
elif since.inDays >= 1:
|
||||||
result = tweet.time.format("MMM d")
|
result = time.format("MMM d")
|
||||||
elif since.inHours >= 1:
|
elif since.inHours >= 1:
|
||||||
result = $since.inHours & "h"
|
result = $since.inHours & "h"
|
||||||
elif since.inMinutes >= 1:
|
elif since.inMinutes >= 1:
|
||||||
@@ -138,13 +173,33 @@ proc getShortTime*(tweet: Tweet): string =
|
|||||||
else:
|
else:
|
||||||
result = "now"
|
result = "now"
|
||||||
|
|
||||||
|
proc getShortTime*(tweet: Tweet): string =
|
||||||
|
getShortTime(tweet.time)
|
||||||
|
|
||||||
|
proc getDuration*(ms: int): string =
|
||||||
|
let
|
||||||
|
sec = int(round(ms / 1000))
|
||||||
|
min = floorDiv(sec, 60)
|
||||||
|
hour = floorDiv(min, 60)
|
||||||
|
if hour > 0:
|
||||||
|
&"{hour}:{min mod 60:02}:{sec mod 60:02}"
|
||||||
|
else:
|
||||||
|
&"{min mod 60}:{sec mod 60:02}"
|
||||||
|
|
||||||
|
proc getDuration*(video: Video): string =
|
||||||
|
getDuration(video.durationMs)
|
||||||
|
|
||||||
|
proc getLink*(id: int64; username="i"; focus=true): string =
|
||||||
|
var username = username
|
||||||
|
if username.len == 0:
|
||||||
|
username = "i"
|
||||||
|
result = &"/{username}/status/{id}"
|
||||||
|
if focus: result &= "#m"
|
||||||
|
|
||||||
proc getLink*(tweet: Tweet; focus=true): string =
|
proc getLink*(tweet: Tweet; focus=true): string =
|
||||||
if tweet.id == 0: return
|
if tweet.id == 0: return
|
||||||
var username = tweet.user.username
|
var username = tweet.user.username
|
||||||
if username.len == 0:
|
return getLink(tweet.id, username, focus)
|
||||||
username = "i"
|
|
||||||
result = &"/{username}/status/{tweet.id}"
|
|
||||||
if focus: result &= "#m"
|
|
||||||
|
|
||||||
proc getTwitterLink*(path: string; params: Table[string, string]): string =
|
proc getTwitterLink*(path: string; params: Table[string, string]): string =
|
||||||
var
|
var
|
||||||
@@ -172,7 +227,7 @@ proc getTwitterLink*(path: string; params: Table[string, string]): string =
|
|||||||
proc getLocation*(u: User | Tweet): (string, string) =
|
proc getLocation*(u: User | Tweet): (string, string) =
|
||||||
if "://" in u.location: return (u.location, "")
|
if "://" in u.location: return (u.location, "")
|
||||||
let loc = u.location.split(":")
|
let loc = u.location.split(":")
|
||||||
let url = if loc.len > 1: "/search?q=place:" & loc[1] else: ""
|
let url = if loc.len > 1: "/search?f=tweets&q=place:" & loc[1] else: ""
|
||||||
(loc[0], url)
|
(loc[0], url)
|
||||||
|
|
||||||
proc getSuspended*(username: string): string =
|
proc getSuspended*(username: string): string =
|
||||||
|
|||||||
+2
-5
@@ -39,11 +39,8 @@ template use*(pool: HttpPool; heads: HttpHeaders; body: untyped): untyped =
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
body
|
body
|
||||||
except ProtocolError:
|
except BadClientError, ProtocolError:
|
||||||
# Twitter closed the connection, retry
|
# Twitter returned 503 or closed the connection, we need a new client
|
||||||
body
|
|
||||||
except BadClientError:
|
|
||||||
# Twitter returned 503, we need a new client
|
|
||||||
pool.release(c, true)
|
pool.release(c, true)
|
||||||
badClient = false
|
badClient = false
|
||||||
c = pool.acquire(heads)
|
c = pool.acquire(heads)
|
||||||
|
|||||||
+37
-12
@@ -2,21 +2,26 @@
|
|||||||
import asyncdispatch, strformat, logging
|
import asyncdispatch, strformat, logging
|
||||||
from net import Port
|
from net import Port
|
||||||
from htmlgen import a
|
from htmlgen import a
|
||||||
from os import getEnv
|
from os import getEnv, normalizedPath
|
||||||
|
|
||||||
import jester
|
import jester
|
||||||
|
|
||||||
import types, config, prefs, formatters, redis_cache, http_pool, tokens
|
import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
|
||||||
import views/[general, about]
|
import views/[general, about]
|
||||||
import routes/[
|
import routes/[
|
||||||
preferences, timeline, status, media, search, rss, list, debug,
|
preferences, timeline, status, media, search, rss, list, debug,
|
||||||
unsupported, embed, resolver, router_utils]
|
unsupported, embed, resolver, broadcast, router_utils]
|
||||||
|
|
||||||
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
||||||
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
||||||
|
|
||||||
let configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf")
|
let
|
||||||
let (cfg, fullCfg) = getConfig(configPath)
|
configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf")
|
||||||
|
(cfg, fullCfg) = getConfig(configPath)
|
||||||
|
|
||||||
|
sessionsPath = getEnv("NITTER_SESSIONS_FILE", "./sessions.jsonl")
|
||||||
|
|
||||||
|
initSessionPool(cfg, sessionsPath)
|
||||||
|
|
||||||
if not cfg.enableDebug:
|
if not cfg.enableDebug:
|
||||||
# Silence Jester's query warning
|
# Silence Jester's query warning
|
||||||
@@ -32,14 +37,17 @@ setHmacKey(cfg.hmacKey)
|
|||||||
setProxyEncoding(cfg.base64Media)
|
setProxyEncoding(cfg.base64Media)
|
||||||
setMaxHttpConns(cfg.httpMaxConns)
|
setMaxHttpConns(cfg.httpMaxConns)
|
||||||
setHttpProxy(cfg.proxy, cfg.proxyAuth)
|
setHttpProxy(cfg.proxy, cfg.proxyAuth)
|
||||||
|
setApiProxy(cfg.apiProxy)
|
||||||
|
setDisableTid(cfg.disableTid)
|
||||||
|
setMaxConcurrentReqs(cfg.maxConcurrentReqs)
|
||||||
|
setMaxRetries(cfg.maxRetries)
|
||||||
|
setRetryDelayMs(cfg.retryDelayMs)
|
||||||
initAboutPage(cfg.staticDir)
|
initAboutPage(cfg.staticDir)
|
||||||
|
|
||||||
waitFor initRedisPool(cfg)
|
waitFor initRedisPool(cfg)
|
||||||
stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
|
stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
|
||||||
stdout.flushFile
|
stdout.flushFile
|
||||||
|
|
||||||
asyncCheck initTokenPool(cfg)
|
|
||||||
|
|
||||||
createUnsupportedRouter(cfg)
|
createUnsupportedRouter(cfg)
|
||||||
createResolverRouter(cfg)
|
createResolverRouter(cfg)
|
||||||
createPrefRouter(cfg)
|
createPrefRouter(cfg)
|
||||||
@@ -50,20 +58,31 @@ createSearchRouter(cfg)
|
|||||||
createMediaRouter(cfg)
|
createMediaRouter(cfg)
|
||||||
createEmbedRouter(cfg)
|
createEmbedRouter(cfg)
|
||||||
createRssRouter(cfg)
|
createRssRouter(cfg)
|
||||||
|
createBroadcastRouter(cfg)
|
||||||
createDebugRouter(cfg)
|
createDebugRouter(cfg)
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
port = Port(cfg.port)
|
port = Port(cfg.port)
|
||||||
staticDir = cfg.staticDir
|
staticDir = normalizedPath(cfg.staticDir)
|
||||||
bindAddr = cfg.address
|
bindAddr = cfg.address
|
||||||
reusePort = true
|
reusePort = true
|
||||||
|
maxBody = 64 * 1024
|
||||||
|
|
||||||
routes:
|
routes:
|
||||||
|
before:
|
||||||
|
# Reject malformed paths
|
||||||
|
if request.path.len == 0 or request.path[0] != '/':
|
||||||
|
halt Http400
|
||||||
|
|
||||||
|
# skip all file URLs
|
||||||
|
cond "." notin request.path
|
||||||
|
applyUrlPrefs()
|
||||||
|
|
||||||
get "/":
|
get "/":
|
||||||
resp renderMain(renderSearch(), request, cfg, themePrefs())
|
resp renderMain(renderSearch(), request, cfg, requestPrefs())
|
||||||
|
|
||||||
get "/about":
|
get "/about":
|
||||||
resp renderMain(renderAbout(), request, cfg, themePrefs())
|
resp renderMain(renderAbout(), request, cfg, requestPrefs())
|
||||||
|
|
||||||
get "/explore":
|
get "/explore":
|
||||||
redirect("/about")
|
redirect("/about")
|
||||||
@@ -74,7 +93,7 @@ routes:
|
|||||||
get "/i/redirect":
|
get "/i/redirect":
|
||||||
let url = decodeUrl(@"url")
|
let url = decodeUrl(@"url")
|
||||||
if url.len == 0: resp Http404
|
if url.len == 0: resp Http404
|
||||||
redirect(replaceUrls(url, cookiePrefs()))
|
redirect(replaceUrls(url, requestPrefs()))
|
||||||
|
|
||||||
error Http404:
|
error Http404:
|
||||||
resp Http404, showError("Page not found", cfg)
|
resp Http404, showError("Page not found", cfg)
|
||||||
@@ -87,13 +106,18 @@ routes:
|
|||||||
|
|
||||||
error BadClientError:
|
error BadClientError:
|
||||||
echo error.exc.name, ": ", error.exc.msg
|
echo error.exc.name, ": ", error.exc.msg
|
||||||
resp Http500, showError("Network error occured, please try again.", cfg)
|
resp Http500, showError("Network error occurred, please try again.", cfg)
|
||||||
|
|
||||||
error RateLimitError:
|
error RateLimitError:
|
||||||
const link = a("another instance", href = instancesUrl)
|
const link = a("another instance", href = instancesUrl)
|
||||||
resp Http429, showError(
|
resp Http429, showError(
|
||||||
&"Instance has been rate limited.<br>Use {link} or try again later.", cfg)
|
&"Instance has been rate limited.<br>Use {link} or try again later.", cfg)
|
||||||
|
|
||||||
|
error NoSessionsError:
|
||||||
|
const link = a("another instance", href = instancesUrl)
|
||||||
|
resp Http429, showError(
|
||||||
|
&"Instance has no auth tokens, or is fully rate limited.<br>Use {link} or try again later.", cfg)
|
||||||
|
|
||||||
extend rss, ""
|
extend rss, ""
|
||||||
extend status, ""
|
extend status, ""
|
||||||
extend search, ""
|
extend search, ""
|
||||||
@@ -103,5 +127,6 @@ routes:
|
|||||||
extend preferences, ""
|
extend preferences, ""
|
||||||
extend resolver, ""
|
extend resolver, ""
|
||||||
extend embed, ""
|
extend embed, ""
|
||||||
|
extend broadcastRoute, ""
|
||||||
extend debug, ""
|
extend debug, ""
|
||||||
extend unsupported, ""
|
extend unsupported, ""
|
||||||
|
|||||||
+547
-229
@@ -1,10 +1,20 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import strutils, options, tables, times, math
|
import strutils, options, times, math, tables, uri
|
||||||
import packedjson, packedjson/deserialiser
|
import packedjson, packedjson/deserialiser
|
||||||
import types, parserutils, utils
|
import types, parserutils, utils
|
||||||
import experimental/parser/unifiedcard
|
import experimental/parser/unifiedcard
|
||||||
|
|
||||||
proc parseGraphTweet(js: JsonNode): Tweet
|
proc parseGraphTweet*(js: JsonNode): Tweet
|
||||||
|
|
||||||
|
proc parseVerifiedType(s: string; current: VerifiedType): VerifiedType =
|
||||||
|
try: parseEnum[VerifiedType](s)
|
||||||
|
except ValueError: current
|
||||||
|
|
||||||
|
proc parseCommunityNote(js: JsonNode): string =
|
||||||
|
let subtitle = js{"subtitle"}
|
||||||
|
result = subtitle{"text"}.getStr
|
||||||
|
with entities, subtitle{"entities"}:
|
||||||
|
result = expandBirdwatchEntities(result, entities)
|
||||||
|
|
||||||
proc parseUser(js: JsonNode; id=""): User =
|
proc parseUser(js: JsonNode; id=""): User =
|
||||||
if js.isNull: return
|
if js.isNull: return
|
||||||
@@ -21,19 +31,105 @@ proc parseUser(js: JsonNode; id=""): User =
|
|||||||
tweets: js{"statuses_count"}.getInt,
|
tweets: js{"statuses_count"}.getInt,
|
||||||
likes: js{"favourites_count"}.getInt,
|
likes: js{"favourites_count"}.getInt,
|
||||||
media: js{"media_count"}.getInt,
|
media: js{"media_count"}.getInt,
|
||||||
verified: js{"verified"}.getBool or js{"ext_is_blue_verified"}.getBool,
|
protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool),
|
||||||
protected: js{"protected"}.getBool,
|
|
||||||
joinDate: js{"created_at"}.getTime
|
joinDate: js{"created_at"}.getTime
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if js{"is_blue_verified"}.getBool(false):
|
||||||
|
result.verifiedType = blue
|
||||||
|
|
||||||
|
with verifiedType, js{"verified_type"}:
|
||||||
|
result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType)
|
||||||
|
|
||||||
result.expandUserEntities(js)
|
result.expandUserEntities(js)
|
||||||
|
|
||||||
proc parseGraphUser(js: JsonNode): User =
|
proc parseGraphUser(js: JsonNode): User =
|
||||||
let user = ? js{"user_results", "result"}
|
var user = js{"user_result", "result"}
|
||||||
result = parseUser(user{"legacy"})
|
if user.isNull:
|
||||||
|
user = js{"user_results", "result"}
|
||||||
|
|
||||||
if "is_blue_verified" in user:
|
if user.isNull:
|
||||||
result.verified = true
|
if js{"core"}.notNull:
|
||||||
|
user = js
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
result = parseUser(user{"legacy"}, user{"rest_id"}.getStr)
|
||||||
|
|
||||||
|
if result.verifiedType == none and user{"is_blue_verified"}.getBool(false):
|
||||||
|
result.verifiedType = blue
|
||||||
|
|
||||||
|
# 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(
|
||||||
|
user{"verification", "is_blue_verified"}.getBool(false)):
|
||||||
|
result.verifiedType = blue
|
||||||
|
|
||||||
|
with verifiedType, user{"verification", "verified_type"}:
|
||||||
|
result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType)
|
||||||
|
|
||||||
|
proc parseAboutAccount*(js: JsonNode): AccountInfo =
|
||||||
|
if js.isNull: return
|
||||||
|
|
||||||
|
let user = ? js{"data", "user_result_by_screen_name", "result"}
|
||||||
|
|
||||||
|
if user{"unavailable_reason"}.getStr == "Suspended":
|
||||||
|
result.suspended = true
|
||||||
|
return
|
||||||
|
|
||||||
|
result = AccountInfo(
|
||||||
|
username: user{"core", "screen_name"}.getStr,
|
||||||
|
fullname: user{"core", "name"}.getStr,
|
||||||
|
joinDate: user{"core", "created_at"}.getTime,
|
||||||
|
userPic: user{"avatar", "image_url"}.getImageStr.replace("_normal", ""),
|
||||||
|
affiliateLabel: user{"identity_profile_labels_highlighted_label", "label", "description"}.getStr,
|
||||||
|
)
|
||||||
|
|
||||||
|
if user{"is_blue_verified"}.getBool(false):
|
||||||
|
result.verifiedType = blue
|
||||||
|
with verifiedType, user{"verification", "verified_type"}:
|
||||||
|
result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType)
|
||||||
|
|
||||||
|
with about, user{"about_profile"}:
|
||||||
|
result.basedIn = about{"account_based_in"}.getStr
|
||||||
|
result.source = about{"source"}.getStr
|
||||||
|
result.affiliateUsername = about{"affiliate_username"}.getStr
|
||||||
|
|
||||||
|
try:
|
||||||
|
result.usernameChanges = about{"username_changes", "count"}.getStr("0").parseInt
|
||||||
|
except ValueError:
|
||||||
|
discard
|
||||||
|
|
||||||
|
with lastChange, about{"username_changes", "last_changed_at_msec"}:
|
||||||
|
result.lastUsernameChange = lastChange.getTimeFromMsStr
|
||||||
|
|
||||||
|
with info, user{"verification_info"}:
|
||||||
|
result.isIdentityVerified = info{"is_identity_verified"}.getBool
|
||||||
|
with reason, info{"reason"}:
|
||||||
|
result.overrideVerifiedYear = reason{"override_verified_year"}.getInt
|
||||||
|
with since, reason{"verified_since_msec"}:
|
||||||
|
result.verifiedSince = since.getTimeFromMsStr
|
||||||
|
|
||||||
|
proc parseBroadcastInfo*(js: JsonNode): Broadcast =
|
||||||
|
let bc = ? js{"data", "broadcast"}
|
||||||
|
result = Broadcast(
|
||||||
|
id: bc{"broadcast_id"}.getStr,
|
||||||
|
title: bc{"status"}.getStr,
|
||||||
|
state: bc{"state"}.getStr.toUpperAscii,
|
||||||
|
thumb: bc{"image_url"}.getStr,
|
||||||
|
mediaKey: bc{"media_key"}.getStr,
|
||||||
|
totalWatched: bc{"total_watched"}.getInt,
|
||||||
|
startTime: bc{"start_time"}.getTimeFromMs,
|
||||||
|
endTime: bc{"end_time"}.getTimeFromMs,
|
||||||
|
replayStart: bc{"edited_replay", "start_time"}.getInt,
|
||||||
|
availableForReplay: bc{"available_for_replay"}.getBool,
|
||||||
|
user: parseGraphUser(bc)
|
||||||
|
)
|
||||||
|
|
||||||
proc parseGraphList*(js: JsonNode): List =
|
proc parseGraphList*(js: JsonNode): List =
|
||||||
if js.isNull: return
|
if js.isNull: return
|
||||||
@@ -72,39 +168,121 @@ proc parsePoll(js: JsonNode): Poll =
|
|||||||
result.leader = result.values.find(max(result.values))
|
result.leader = result.values.find(max(result.values))
|
||||||
result.votes = result.values.sum
|
result.votes = result.values.sum
|
||||||
|
|
||||||
proc parseGif(js: JsonNode): Gif =
|
proc parseVideoVariants(variants: JsonNode): seq[VideoVariant] =
|
||||||
result = Gif(
|
result = @[]
|
||||||
url: js{"video_info", "variants"}[0]{"url"}.getImageStr,
|
for v in variants:
|
||||||
thumb: js{"media_url_https"}.getImageStr
|
let
|
||||||
|
url = v{"url"}.getStr
|
||||||
|
contentType = parseEnum[VideoType](v{"content_type"}.getStr("video/mp4"))
|
||||||
|
bitrate = v{"bit_rate"}.getInt(v{"bitrate"}.getInt(0))
|
||||||
|
|
||||||
|
result.add VideoVariant(
|
||||||
|
contentType: contentType,
|
||||||
|
bitrate: bitrate,
|
||||||
|
url: url,
|
||||||
|
resolution: if contentType == mp4: getMp4Resolution(url) else: 0
|
||||||
)
|
)
|
||||||
|
|
||||||
proc parseVideo(js: JsonNode): Video =
|
proc parseVideo(js: JsonNode): Video =
|
||||||
result = Video(
|
result = Video(
|
||||||
thumb: js{"media_url_https"}.getImageStr,
|
thumb: js{"media_url_https"}.getImageStr,
|
||||||
views: js{"ext", "mediaStats", "r", "ok", "viewCount"}.getStr($js{"mediaStats", "viewCount"}.getInt),
|
available: true,
|
||||||
available: js{"ext_media_availability", "status"}.getStr.toLowerAscii == "available",
|
|
||||||
title: js{"ext_alt_text"}.getStr,
|
title: js{"ext_alt_text"}.getStr,
|
||||||
durationMs: js{"video_info", "duration_millis"}.getInt
|
durationMs: js{"video_info", "duration_millis"}.getInt
|
||||||
# playbackType: mp4
|
# playbackType: mp4
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with status, js{"ext_media_availability", "status"}:
|
||||||
|
if status.getStr.len > 0 and status.getStr.toLowerAscii != "available":
|
||||||
|
result.available = false
|
||||||
|
|
||||||
with title, js{"additional_media_info", "title"}:
|
with title, js{"additional_media_info", "title"}:
|
||||||
result.title = title.getStr
|
result.title = title.getStr
|
||||||
|
|
||||||
with description, js{"additional_media_info", "description"}:
|
with description, js{"additional_media_info", "description"}:
|
||||||
result.description = description.getStr
|
result.description = description.getStr
|
||||||
|
|
||||||
for v in js{"video_info", "variants"}:
|
result.variants = parseVideoVariants(js{"video_info", "variants"})
|
||||||
let
|
|
||||||
contentType = parseEnum[VideoType](v{"content_type"}.getStr("summary"))
|
|
||||||
url = v{"url"}.getStr
|
|
||||||
|
|
||||||
result.variants.add VideoVariant(
|
proc addMedia(media: var MediaEntities; photo: Photo) =
|
||||||
contentType: contentType,
|
media.add Media(kind: photoMedia, photo: photo)
|
||||||
bitrate: v{"bitrate"}.getInt,
|
|
||||||
url: url,
|
proc addMedia(media: var MediaEntities; video: Video) =
|
||||||
resolution: if contentType == mp4: getMp4Resolution(url) else: 0
|
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.media.addMedia(Photo(
|
||||||
|
url: m{"media_url_https"}.getImageStr,
|
||||||
|
altText: m{"ext_alt_text"}.getStr
|
||||||
|
))
|
||||||
|
of "video":
|
||||||
|
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.media.addMedia(Gif(
|
||||||
|
url: m{"video_info", "variants"}[0]{"url"}.getImageStr,
|
||||||
|
thumb: m{"media_url_https"}.getImageStr,
|
||||||
|
altText: m{"ext_alt_text"}.getStr
|
||||||
|
))
|
||||||
|
else: discard
|
||||||
|
|
||||||
|
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":
|
||||||
|
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"}
|
||||||
|
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":
|
||||||
|
parsedMedia.addMedia(Gif(
|
||||||
|
url: mediaInfo{"variants"}[0]{"url"}.getImageStr,
|
||||||
|
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
|
||||||
|
altText: mediaInfo{"alt_text"}.getStr
|
||||||
|
))
|
||||||
|
else: discard
|
||||||
|
|
||||||
|
if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len:
|
||||||
|
result.media = parsedMedia
|
||||||
|
|
||||||
proc parsePromoVideo(js: JsonNode): Video =
|
proc parsePromoVideo(js: JsonNode): Video =
|
||||||
result = Video(
|
result = Video(
|
||||||
@@ -127,14 +305,23 @@ proc parsePromoVideo(js: JsonNode): Video =
|
|||||||
result.variants.add variant
|
result.variants.add variant
|
||||||
|
|
||||||
proc parseBroadcast(js: JsonNode): Card =
|
proc parseBroadcast(js: JsonNode): Card =
|
||||||
let image = js{"broadcast_thumbnail_large"}.getImageVal
|
let
|
||||||
|
image = js{"broadcast_thumbnail_large"}.getImageVal
|
||||||
|
broadcastUrl = js{"broadcast_url"}.getStrVal
|
||||||
|
broadcastId = broadcastUrl.rsplit('/', maxsplit=1)[^1]
|
||||||
|
streamUrl = "/i/broadcasts/" & broadcastId & "/stream"
|
||||||
result = Card(
|
result = Card(
|
||||||
kind: broadcast,
|
kind: broadcast,
|
||||||
url: js{"broadcast_url"}.getStrVal,
|
url: "/i/broadcasts/" & broadcastId,
|
||||||
title: js{"broadcaster_display_name"}.getStrVal,
|
title: js{"broadcaster_display_name"}.getStrVal,
|
||||||
text: js{"broadcast_title"}.getStrVal,
|
text: js{"broadcast_title"}.getStrVal,
|
||||||
image: image,
|
image: image,
|
||||||
video: some Video(thumb: image)
|
video: some Video(
|
||||||
|
thumb: image,
|
||||||
|
available: true,
|
||||||
|
playbackType: m3u8,
|
||||||
|
variants: @[VideoVariant(contentType: m3u8, url: streamUrl)]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
||||||
@@ -184,7 +371,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
|||||||
|
|
||||||
for u in ? urls:
|
for u in ? urls:
|
||||||
if u{"url"}.getStr == result.url:
|
if u{"url"}.getStr == result.url:
|
||||||
result.url = u{"expanded_url"}.getStr
|
result.url = u.getExpandedUrl(result.url)
|
||||||
break
|
break
|
||||||
|
|
||||||
if kind in {videoDirectMessage, imageDirectMessage}:
|
if kind in {videoDirectMessage, imageDirectMessage}:
|
||||||
@@ -194,14 +381,20 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
|||||||
result.url.len == 0 or result.url.startsWith("card://"):
|
result.url.len == 0 or result.url.startsWith("card://"):
|
||||||
result.url = getPicUrl(result.image)
|
result.url = getPicUrl(result.image)
|
||||||
|
|
||||||
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
|
||||||
if js.isNull: return
|
replyId: int64 = 0): Tweet =
|
||||||
|
if js.isNull: return Tweet()
|
||||||
|
|
||||||
|
let time =
|
||||||
|
if js{"created_at"}.notNull: js{"created_at"}.getTime
|
||||||
|
else: js{"created_at_ms"}.getTimeFromMs
|
||||||
|
|
||||||
result = Tweet(
|
result = Tweet(
|
||||||
id: js{"id_str"}.getId,
|
id: js{"id_str"}.getId,
|
||||||
threadId: js{"conversation_id_str"}.getId,
|
threadId: js{"conversation_id_str"}.getId,
|
||||||
replyId: js{"in_reply_to_status_id_str"}.getId,
|
replyId: js{"in_reply_to_status_id_str"}.getId,
|
||||||
text: js{"full_text"}.getStr,
|
text: js{"full_text"}.getStr,
|
||||||
time: js{"created_at"}.getTime,
|
time: time,
|
||||||
hasThread: js{"self_thread"}.notNull,
|
hasThread: js{"self_thread"}.notNull,
|
||||||
available: true,
|
available: true,
|
||||||
user: User(id: js{"user_id_str"}.getStr),
|
user: User(id: js{"user_id_str"}.getStr),
|
||||||
@@ -209,17 +402,20 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||||||
replies: js{"reply_count"}.getInt,
|
replies: js{"reply_count"}.getInt,
|
||||||
retweets: js{"retweet_count"}.getInt,
|
retweets: js{"retweet_count"}.getInt,
|
||||||
likes: js{"favorite_count"}.getInt,
|
likes: js{"favorite_count"}.getInt,
|
||||||
quotes: js{"quote_count"}.getInt
|
views: js{"views_count"}.getInt
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
result.expandTweetEntities(js)
|
if result.replyId == 0:
|
||||||
|
result.replyId = replyId
|
||||||
|
|
||||||
# fix for pinned threads
|
# fix for pinned threads
|
||||||
if result.hasThread and result.threadId == 0:
|
if result.hasThread and result.threadId == 0:
|
||||||
result.threadId = js{"self_thread", "id_str"}.getId
|
result.threadId = js{"self_thread", "id_str"}.getId
|
||||||
|
|
||||||
if js{"is_quote_status"}.getBool:
|
if "retweeted_status" in js:
|
||||||
|
result.retweet = some Tweet()
|
||||||
|
elif js{"is_quote_status"}.getBool:
|
||||||
result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId)
|
result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId)
|
||||||
|
|
||||||
# legacy
|
# legacy
|
||||||
@@ -230,7 +426,13 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||||||
# graphql
|
# graphql
|
||||||
with rt, js{"retweeted_status_result", "result"}:
|
with rt, js{"retweeted_status_result", "result"}:
|
||||||
# needed due to weird edgecase where the actual tweet data isn't included
|
# needed due to weird edgecase where the actual tweet data isn't included
|
||||||
if "legacy" in rt:
|
if "legacy" in rt or "rest_id" in rt:
|
||||||
|
result.retweet = some parseGraphTweet(rt)
|
||||||
|
return
|
||||||
|
|
||||||
|
with reposts, js{"repostedStatusResults"}:
|
||||||
|
with rt, reposts{"result"}:
|
||||||
|
if "legacy" in rt or "rest_id" in rt:
|
||||||
result.retweet = some parseGraphTweet(rt)
|
result.retweet = some parseGraphTweet(rt)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -238,29 +440,18 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||||||
let name = jsCard{"name"}.getStr
|
let name = jsCard{"name"}.getStr
|
||||||
if "poll" in name:
|
if "poll" in name:
|
||||||
if "image" in name:
|
if "image" in name:
|
||||||
result.photos.add jsCard{"binding_values", "image_large"}.getImageVal
|
result.media.addMedia(Photo(
|
||||||
|
url: jsCard{"binding_values", "image_large"}.getImageVal
|
||||||
|
))
|
||||||
|
|
||||||
result.poll = some parsePoll(jsCard)
|
result.poll = some parsePoll(jsCard)
|
||||||
elif name == "amplify":
|
elif name == "amplify":
|
||||||
result.video = some(parsePromoVideo(jsCard{"binding_values"}))
|
result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
|
||||||
else:
|
elif name.len > 0 and jsCard{"binding_values"}.notNull:
|
||||||
result.card = some parseCard(jsCard, js{"entities", "urls"})
|
result.card = some parseCard(jsCard, js{"entities", "urls"})
|
||||||
|
|
||||||
with jsMedia, js{"extended_entities", "media"}:
|
result.expandTweetEntities(js)
|
||||||
for m in jsMedia:
|
parseLegacyMediaEntities(js, result)
|
||||||
case m{"type"}.getStr
|
|
||||||
of "photo":
|
|
||||||
result.photos.add m{"media_url_https"}.getImageStr
|
|
||||||
of "video":
|
|
||||||
result.video = some(parseVideo(m))
|
|
||||||
with user, m{"additional_media_info", "source_user"}:
|
|
||||||
if user{"id"}.getInt > 0:
|
|
||||||
result.attribution = some(parseUser(user))
|
|
||||||
else:
|
|
||||||
result.attribution = some(parseGraphUser(user))
|
|
||||||
of "animated_gif":
|
|
||||||
result.gif = some(parseGif(m))
|
|
||||||
else: discard
|
|
||||||
|
|
||||||
with jsWithheld, js{"withheld_in_countries"}:
|
with jsWithheld, js{"withheld_in_countries"}:
|
||||||
let withheldInCountries: seq[string] =
|
let withheldInCountries: seq[string] =
|
||||||
@@ -276,242 +467,369 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||||||
result.text.removeSuffix(" Learn more.")
|
result.text.removeSuffix(" Learn more.")
|
||||||
result.available = false
|
result.available = false
|
||||||
|
|
||||||
proc finalizeTweet(global: GlobalObjects; id: string): Tweet =
|
proc parseGraphTweet*(js: JsonNode): Tweet =
|
||||||
let intId = if id.len > 0: parseBiggestInt(id) else: 0
|
|
||||||
result = global.tweets.getOrDefault(id, Tweet(id: intId))
|
|
||||||
|
|
||||||
if result.quote.isSome:
|
|
||||||
let quote = get(result.quote).id
|
|
||||||
if $quote in global.tweets:
|
|
||||||
result.quote = some global.tweets[$quote]
|
|
||||||
else:
|
|
||||||
result.quote = some Tweet()
|
|
||||||
|
|
||||||
if result.retweet.isSome:
|
|
||||||
let rt = get(result.retweet).id
|
|
||||||
if $rt in global.tweets:
|
|
||||||
result.retweet = some finalizeTweet(global, $rt)
|
|
||||||
else:
|
|
||||||
result.retweet = some Tweet()
|
|
||||||
|
|
||||||
proc parsePin(js: JsonNode; global: GlobalObjects): Tweet =
|
|
||||||
let pin = js{"pinEntry", "entry", "entryId"}.getStr
|
|
||||||
if pin.len == 0: return
|
|
||||||
|
|
||||||
let id = pin.getId
|
|
||||||
if id notin global.tweets: return
|
|
||||||
|
|
||||||
global.tweets[id].pinned = true
|
|
||||||
return finalizeTweet(global, id)
|
|
||||||
|
|
||||||
proc parseGlobalObjects(js: JsonNode): GlobalObjects =
|
|
||||||
result = GlobalObjects()
|
|
||||||
let
|
|
||||||
tweets = ? js{"globalObjects", "tweets"}
|
|
||||||
users = ? js{"globalObjects", "users"}
|
|
||||||
|
|
||||||
for k, v in users:
|
|
||||||
result.users[k] = parseUser(v, k)
|
|
||||||
|
|
||||||
for k, v in tweets:
|
|
||||||
var tweet = parseTweet(v, v{"card"})
|
|
||||||
if tweet.user.id in result.users:
|
|
||||||
tweet.user = result.users[tweet.user.id]
|
|
||||||
result.tweets[k] = tweet
|
|
||||||
|
|
||||||
proc parseInstructions[T](res: var Result[T]; global: GlobalObjects; js: JsonNode) =
|
|
||||||
if js.kind != JArray or js.len == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
for i in js:
|
|
||||||
when T is Tweet:
|
|
||||||
if res.beginning and i{"pinEntry"}.notNull:
|
|
||||||
with pin, parsePin(i, global):
|
|
||||||
res.content.add pin
|
|
||||||
|
|
||||||
with r, i{"replaceEntry", "entry"}:
|
|
||||||
if "top" in r{"entryId"}.getStr:
|
|
||||||
res.top = r.getCursor
|
|
||||||
elif "bottom" in r{"entryId"}.getStr:
|
|
||||||
res.bottom = r.getCursor
|
|
||||||
|
|
||||||
proc parseTimeline*(js: JsonNode; after=""): Timeline =
|
|
||||||
result = Timeline(beginning: after.len == 0)
|
|
||||||
let global = parseGlobalObjects(? js)
|
|
||||||
|
|
||||||
let instructions = ? js{"timeline", "instructions"}
|
|
||||||
if instructions.len == 0: return
|
|
||||||
|
|
||||||
result.parseInstructions(global, instructions)
|
|
||||||
|
|
||||||
var entries: JsonNode
|
|
||||||
for i in instructions:
|
|
||||||
if "addEntries" in i:
|
|
||||||
entries = i{"addEntries", "entries"}
|
|
||||||
|
|
||||||
for e in ? entries:
|
|
||||||
let entry = e{"entryId"}.getStr
|
|
||||||
if "tweet" in entry or entry.startsWith("sq-I-t") or "tombstone" in entry:
|
|
||||||
let tweet = finalizeTweet(global, e.getEntryId)
|
|
||||||
if not tweet.available: continue
|
|
||||||
result.content.add tweet
|
|
||||||
elif "cursor-top" in entry:
|
|
||||||
result.top = e.getCursor
|
|
||||||
elif "cursor-bottom" in entry:
|
|
||||||
result.bottom = e.getCursor
|
|
||||||
elif entry.startsWith("sq-cursor"):
|
|
||||||
with cursor, e{"content", "operation", "cursor"}:
|
|
||||||
if cursor{"cursorType"}.getStr == "Bottom":
|
|
||||||
result.bottom = cursor{"value"}.getStr
|
|
||||||
else:
|
|
||||||
result.top = cursor{"value"}.getStr
|
|
||||||
|
|
||||||
proc parsePhotoRail*(js: JsonNode): PhotoRail =
|
|
||||||
for tweet in js:
|
|
||||||
let
|
|
||||||
t = parseTweet(tweet, js{"card"})
|
|
||||||
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
|
|
||||||
elif t.card.isSome: get(t.card).image
|
|
||||||
else: ""
|
|
||||||
|
|
||||||
if url.len == 0: continue
|
|
||||||
result.add GalleryPhoto(url: url, tweetId: $t.id)
|
|
||||||
|
|
||||||
proc parseGraphTweet(js: JsonNode): Tweet =
|
|
||||||
if js.kind == JNull:
|
if js.kind == JNull:
|
||||||
return Tweet()
|
return Tweet()
|
||||||
|
|
||||||
case js{"__typename"}.getStr
|
case js.getTypeName:
|
||||||
of "TweetUnavailable":
|
of "TweetUnavailable":
|
||||||
return Tweet()
|
return Tweet()
|
||||||
of "TweetTombstone":
|
of "TweetTombstone":
|
||||||
return Tweet(text: js{"tombstone", "text"}.getTombstone)
|
with text, select(js{"tombstone", "richText"}, js{"tombstone", "text"}):
|
||||||
|
return Tweet(text: text.getTombstone)
|
||||||
|
return Tweet()
|
||||||
of "TweetPreviewDisplay":
|
of "TweetPreviewDisplay":
|
||||||
return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
|
return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
|
||||||
of "TweetWithVisibilityResults":
|
of "TweetWithVisibilityResults":
|
||||||
return parseGraphTweet(js{"tweet"})
|
return parseGraphTweet(js{"tweet"})
|
||||||
|
else:
|
||||||
|
discard
|
||||||
|
|
||||||
var jsCard = copy(js{"card", "legacy"})
|
if "legacy" notin js and "rest_id" notin js:
|
||||||
|
return Tweet()
|
||||||
|
|
||||||
|
var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"})
|
||||||
if jsCard.kind != JNull:
|
if jsCard.kind != JNull:
|
||||||
var values = newJObject()
|
let legacyCard = jsCard{"legacy"}
|
||||||
for val in jsCard["binding_values"]:
|
if legacyCard.kind != JNull:
|
||||||
values[val["key"].getStr] = val["value"]
|
let bindingArray = legacyCard{"binding_values"}
|
||||||
jsCard["binding_values"] = values
|
if bindingArray.kind == JArray:
|
||||||
|
var bindingObj: seq[(string, JsonNode)]
|
||||||
|
for item in bindingArray:
|
||||||
|
bindingObj.add((item{"key"}.getStr, item{"value"}))
|
||||||
|
# Create a new card object with flattened structure
|
||||||
|
jsCard = %*{
|
||||||
|
"name": legacyCard{"name"},
|
||||||
|
"url": legacyCard{"url"},
|
||||||
|
"binding_values": %bindingObj
|
||||||
|
}
|
||||||
|
|
||||||
|
var replyId: int64 = 0
|
||||||
|
with restId, js{"reply_to_results", "rest_id"}:
|
||||||
|
replyId = restId.getId
|
||||||
|
|
||||||
|
if "details" in js:
|
||||||
|
result = Tweet(
|
||||||
|
id: js{"rest_id"}.getId,
|
||||||
|
available: true,
|
||||||
|
text: js{"details", "full_text"}.getStr,
|
||||||
|
time: js{"details", "created_at_ms"}.getTimeFromMs,
|
||||||
|
replyId: js{"reply_to_results", "rest_id"}.getId,
|
||||||
|
isAd: js{"content_disclosure", "advertising_disclosure", "is_paid_promotion"}.getBool,
|
||||||
|
isAI: js{"content_disclosure", "ai_generated_disclosure", "has_ai_generated_media"}.getBool,
|
||||||
|
stats: TweetStats(
|
||||||
|
replies: js{"counts", "reply_count"}.getInt,
|
||||||
|
retweets: js{"counts", "retweet_count"}.getInt,
|
||||||
|
likes: js{"counts", "favorite_count"}.getInt,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if jsCard.kind != JNull:
|
||||||
|
let name = jsCard{"name"}.getStr
|
||||||
|
if "poll" in name:
|
||||||
|
if "image" in name:
|
||||||
|
result.media.addMedia(Photo(
|
||||||
|
url: jsCard{"binding_values", "image_large"}.getImageVal
|
||||||
|
))
|
||||||
|
|
||||||
|
result.poll = some parsePoll(jsCard)
|
||||||
|
elif name == "amplify":
|
||||||
|
result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
result.id = js{"rest_id"}.getId
|
||||||
|
|
||||||
result = parseTweet(js{"legacy"}, jsCard)
|
|
||||||
result.user = parseGraphUser(js{"core"})
|
result.user = parseGraphUser(js{"core"})
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
|
with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
|
||||||
result.expandNoteTweetEntities(noteTweet)
|
result.expandNoteTweetEntities(noteTweet)
|
||||||
|
|
||||||
if result.quote.isSome:
|
parseMediaEntities(js, result)
|
||||||
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
|
||||||
|
|
||||||
|
# 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 parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
|
proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
|
||||||
let thread = js{"content", "items"}
|
for t in ? js{"content", "items"}:
|
||||||
for t in js{"content", "items"}:
|
let entryId = t.getEntryId
|
||||||
let entryId = t{"entryId"}.getStr
|
if "tweet-" in entryId and "promoted" notin entryId:
|
||||||
if "cursor-showmore" in entryId:
|
let tweet = t.getTweetResult("item")
|
||||||
let cursor = t{"item", "itemContent", "value"}
|
if tweet.notNull:
|
||||||
|
result.thread.content.add parseGraphTweet(tweet)
|
||||||
|
|
||||||
|
let tweetDisplayType = select(
|
||||||
|
t{"item", "content", "tweet_display_type"},
|
||||||
|
t{"item", "itemContent", "tweetDisplayType"}
|
||||||
|
)
|
||||||
|
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.cursor = cursor.getStr
|
||||||
result.thread.hasMore = true
|
result.thread.hasMore = true
|
||||||
elif "tweet" in entryId:
|
|
||||||
let tweet = parseGraphTweet(t{"item", "itemContent", "tweet_results", "result"})
|
|
||||||
result.thread.content.add tweet
|
|
||||||
|
|
||||||
if t{"item", "itemContent", "tweetDisplayType"}.getStr == "SelfThread":
|
|
||||||
result.self = true
|
|
||||||
|
|
||||||
proc parseGraphTweetResult*(js: JsonNode): Tweet =
|
proc parseGraphTweetResult*(js: JsonNode): Tweet =
|
||||||
with tweet, js{"data", "tweetResult", "result"}:
|
with tweet, js{"data", "tweet_result", "result"}:
|
||||||
result = parseGraphTweet(tweet)
|
result = parseGraphTweet(tweet)
|
||||||
|
|
||||||
proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
||||||
result = Conversation(replies: Result[Chain](beginning: true))
|
result = Conversation(replies: Result[Chain](beginning: true))
|
||||||
|
|
||||||
let instructions = ? js{"data", "threaded_conversation_with_injections", "instructions"}
|
let instructions = ? select(
|
||||||
|
js{"data", "timelineResponse", "instructions"},
|
||||||
|
js{"data", "timeline_response", "instructions"},
|
||||||
|
js{"data", "threaded_conversation_with_injections_v2", "instructions"}
|
||||||
|
)
|
||||||
if instructions.len == 0:
|
if instructions.len == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
for e in instructions[0]{"entries"}:
|
for i in instructions:
|
||||||
let entryId = e{"entryId"}.getStr
|
if i.getTypeName == "TimelineAddEntries":
|
||||||
# echo entryId
|
for e in i{"entries"}:
|
||||||
if entryId.startsWith("tweet"):
|
let entryId = e.getEntryId
|
||||||
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
|
if entryId.startsWith("tweet-"):
|
||||||
|
let tweetResult = getTweetResult(e)
|
||||||
|
if tweetResult.notNull:
|
||||||
let tweet = parseGraphTweet(tweetResult)
|
let tweet = parseGraphTweet(tweetResult)
|
||||||
|
|
||||||
if not tweet.available:
|
if not tweet.available:
|
||||||
tweet.id = parseBiggestInt(entryId.getId())
|
tweet.id = entryId.getId
|
||||||
|
|
||||||
|
if entryId.endsWith(tweetId):
|
||||||
|
result.tweet = tweet
|
||||||
|
else:
|
||||||
|
result.before.content.add tweet
|
||||||
|
elif not entryId.endsWith(tweetId):
|
||||||
|
result.before.content.add Tweet(id: entryId.getId)
|
||||||
|
elif entryId.startsWith("conversationthread"):
|
||||||
|
let (thread, self) = parseGraphThread(e)
|
||||||
|
if self:
|
||||||
|
result.after = thread
|
||||||
|
elif thread.content.len > 0:
|
||||||
|
result.replies.content.add thread
|
||||||
|
elif entryId.startsWith("tombstone"):
|
||||||
|
let
|
||||||
|
content = select(e{"content", "content"}, e{"content", "itemContent"})
|
||||||
|
tweet = Tweet(
|
||||||
|
id: entryId.getId,
|
||||||
|
available: false,
|
||||||
|
text: content{"tombstoneInfo", "richText"}.getTombstone
|
||||||
|
)
|
||||||
|
|
||||||
if $tweet.id == tweetId:
|
if $tweet.id == tweetId:
|
||||||
result.tweet = tweet
|
result.tweet = tweet
|
||||||
else:
|
else:
|
||||||
result.before.content.add tweet
|
result.before.content.add tweet
|
||||||
elif entryId.startsWith("tombstone"):
|
|
||||||
let id = entryId.getId()
|
|
||||||
let tweet = Tweet(
|
|
||||||
id: parseBiggestInt(id),
|
|
||||||
available: false,
|
|
||||||
text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone
|
|
||||||
)
|
|
||||||
|
|
||||||
if id == tweetId:
|
|
||||||
result.tweet = tweet
|
|
||||||
else:
|
|
||||||
result.before.content.add tweet
|
|
||||||
elif entryId.startsWith("conversationthread"):
|
|
||||||
let (thread, self) = parseGraphThread(e)
|
|
||||||
if self:
|
|
||||||
result.after = thread
|
|
||||||
else:
|
|
||||||
result.replies.content.add thread
|
|
||||||
elif entryId.startsWith("cursor-bottom"):
|
elif entryId.startsWith("cursor-bottom"):
|
||||||
result.replies.bottom = e{"content", "itemContent", "value"}.getStr
|
var cursorValue = select(
|
||||||
|
e{"content", "value"},
|
||||||
proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Timeline =
|
e{"content", "content", "value"},
|
||||||
result = Timeline(beginning: after.len == 0)
|
e{"content", "itemContent", "value"}
|
||||||
|
)
|
||||||
let instructions =
|
result.replies.bottom = cursorValue.getStr
|
||||||
if root == "list": ? js{"data", "list", "tweets_timeline", "timeline", "instructions"}
|
|
||||||
else: ? js{"data", "user", "result", "timeline_v2", "timeline", "instructions"}
|
|
||||||
|
|
||||||
|
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:
|
if instructions.len == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
for i in instructions:
|
for i in instructions:
|
||||||
if i{"type"}.getStr == "TimelineAddEntries":
|
if i.getTypeName == "TimelineAddEntries":
|
||||||
for e in i{"entries"}:
|
for e in i{"entries"}:
|
||||||
let entryId = e{"entryId"}.getStr
|
let entryId = e.getEntryId
|
||||||
if entryId.startsWith("tweet"):
|
if entryId == "latestTweet":
|
||||||
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
|
with item, e{"content", "items"}[0]:
|
||||||
|
let tweetResult = item.getTweetResult("item")
|
||||||
|
if tweetResult.notNull:
|
||||||
|
result.latest = parseGraphTweet(tweetResult)
|
||||||
|
elif entryId == "staleTweets":
|
||||||
|
for item in e{"content", "items"}:
|
||||||
|
let tweetResult = item.getTweetResult("item")
|
||||||
|
if tweetResult.notNull:
|
||||||
|
result.history.add parseGraphTweet(tweetResult)
|
||||||
|
|
||||||
|
proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] =
|
||||||
|
with tweetResult, getTweetResult(e):
|
||||||
|
var tweet = parseGraphTweet(tweetResult)
|
||||||
|
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
|
||||||
|
|
||||||
|
proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
|
||||||
|
result = Profile(tweets: Timeline(beginning: after.len == 0))
|
||||||
|
|
||||||
|
let instructions = ? select(
|
||||||
|
js{"data", "list", "timeline_response", "timeline", "instructions"},
|
||||||
|
js{"data", "user", "result", "timeline", "timeline", "instructions"},
|
||||||
|
js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
|
||||||
|
)
|
||||||
|
if instructions.len == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
for i in instructions:
|
||||||
|
if i{"moduleItems"}.notNull:
|
||||||
|
for item in i{"moduleItems"}:
|
||||||
|
with tweetResult, item.getTweetResult("item"):
|
||||||
let tweet = parseGraphTweet(tweetResult)
|
let tweet = parseGraphTweet(tweetResult)
|
||||||
if not tweet.available:
|
if not tweet.available:
|
||||||
tweet.id = parseBiggestInt(entryId.getId())
|
tweet.id = item.getEntryId.getId
|
||||||
result.content.add tweet
|
result.tweets.content.add tweet
|
||||||
|
continue
|
||||||
|
|
||||||
|
if i{"entries"}.notNull:
|
||||||
|
for e in i{"entries"}:
|
||||||
|
let entryId = e.getEntryId
|
||||||
|
if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"):
|
||||||
|
for tweet in extractTweetsFromEntry(e):
|
||||||
|
result.tweets.content.add tweet
|
||||||
|
elif "-conversation-" in entryId or entryId.startsWith("homeConversation"):
|
||||||
|
let (thread, self) = parseGraphThread(e)
|
||||||
|
result.tweets.content.add thread.content
|
||||||
elif entryId.startsWith("cursor-bottom"):
|
elif entryId.startsWith("cursor-bottom"):
|
||||||
result.bottom = e{"content", "value"}.getStr
|
result.tweets.bottom = e{"content", "value"}.getStr
|
||||||
|
|
||||||
proc parseGraphSearch*(js: JsonNode; after=""): Timeline =
|
if after.len == 0:
|
||||||
result = Timeline(beginning: after.len == 0)
|
if i.getTypeName == "TimelinePinEntry":
|
||||||
|
let tweets = extractTweetsFromEntry(i{"entry"})
|
||||||
|
if tweets.len > 0:
|
||||||
|
var tweet = tweets[0]
|
||||||
|
tweet.pinned = true
|
||||||
|
result.pinned = some tweet
|
||||||
|
|
||||||
let instructions = js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"}
|
proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
|
||||||
|
result = @[]
|
||||||
|
|
||||||
|
let instructions = select(
|
||||||
|
js{"data", "user", "result", "timeline", "timeline", "instructions"},
|
||||||
|
js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
|
||||||
|
)
|
||||||
|
if instructions.len == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
let photo = extractGalleryPhoto(t)
|
||||||
|
if photo.url.len > 0:
|
||||||
|
result.add photo
|
||||||
|
|
||||||
|
if result.len == 16:
|
||||||
|
return
|
||||||
|
continue
|
||||||
|
|
||||||
|
if i.getTypeName != "TimelineAddEntries":
|
||||||
|
continue
|
||||||
|
|
||||||
|
for e in i{"entries"}:
|
||||||
|
let entryId = e.getEntryId
|
||||||
|
if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"):
|
||||||
|
for t in extractTweetsFromEntry(e):
|
||||||
|
let photo = extractGalleryPhoto(t)
|
||||||
|
if photo.url.len > 0:
|
||||||
|
result.add photo
|
||||||
|
|
||||||
|
if result.len == 16:
|
||||||
|
return
|
||||||
|
|
||||||
|
proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
|
||||||
|
result = Result[T](beginning: after.len == 0)
|
||||||
|
|
||||||
|
let instructions = select(
|
||||||
|
js{"data", "search", "timeline_response", "timeline", "instructions"},
|
||||||
|
js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"}
|
||||||
|
)
|
||||||
if instructions.len == 0:
|
if instructions.len == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
for instruction in instructions:
|
for instruction in instructions:
|
||||||
let typ = instruction{"type"}.getStr
|
let typ = getTypeName(instruction)
|
||||||
if typ == "TimelineAddEntries":
|
if typ == "TimelineAddEntries":
|
||||||
for e in instructions[0]{"entries"}:
|
for e in instruction{"entries"}:
|
||||||
let entryId = e{"entryId"}.getStr
|
let entryId = e.getEntryId
|
||||||
|
when T is Tweets:
|
||||||
if entryId.startsWith("tweet"):
|
if entryId.startsWith("tweet"):
|
||||||
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
|
with tweetRes, getTweetResult(e):
|
||||||
let tweet = parseGraphTweet(tweetResult)
|
let tweet = parseGraphTweet(tweetRes)
|
||||||
if not tweet.available:
|
if not tweet.available:
|
||||||
tweet.id = parseBiggestInt(entryId.getId())
|
tweet.id = entryId.getId
|
||||||
result.content.add tweet
|
result.content.add tweet
|
||||||
elif entryId.startsWith("cursor-bottom"):
|
elif T is User:
|
||||||
|
if entryId.startsWith("user"):
|
||||||
|
with userRes, e{"content", "itemContent"}:
|
||||||
|
result.content.add parseGraphUser(userRes)
|
||||||
|
|
||||||
|
if entryId.startsWith("cursor-bottom"):
|
||||||
result.bottom = e{"content", "value"}.getStr
|
result.bottom = e{"content", "value"}.getStr
|
||||||
elif typ == "TimelineReplaceEntry":
|
elif typ == "TimelineReplaceEntry":
|
||||||
if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"):
|
if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"):
|
||||||
|
|||||||
+166
-33
@@ -1,15 +1,23 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import std/[strutils, times, macros, htmlgen, options, algorithm, re]
|
import std/[times, macros, htmlgen, options, algorithm, re]
|
||||||
|
import std/strutils except escape
|
||||||
import std/unicode except strip
|
import std/unicode except strip
|
||||||
|
from xmltree import escape
|
||||||
import packedjson
|
import packedjson
|
||||||
import types, utils, formatters
|
import types, utils, formatters
|
||||||
|
|
||||||
|
const
|
||||||
|
unicodeOpen = "\uFFFA"
|
||||||
|
unicodeClose = "\uFFFB"
|
||||||
|
xmlOpen = escape("<")
|
||||||
|
xmlClose = escape(">")
|
||||||
|
|
||||||
let
|
let
|
||||||
unRegex = re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})"
|
unRegex = re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})"
|
||||||
unReplace = "$1<a href=\"/$2\">@$2</a>"
|
unReplace = "$1<a href=\"/$2\">@$2</a>"
|
||||||
|
|
||||||
htRegex = re"(^|[^\w-_./?])([#$]|#)([\w_]+)"
|
htRegex = re"(^|[^\w-_./?])([#$]|#)([\w_]+)"
|
||||||
htReplace = "$1<a href=\"/search?q=%23$3\">$2$3</a>"
|
htReplace = "$1<a href=\"/search?f=tweets&q=%23$3\">$2$3</a>"
|
||||||
|
|
||||||
type
|
type
|
||||||
ReplaceSliceKind = enum
|
ReplaceSliceKind = enum
|
||||||
@@ -28,6 +36,12 @@ template `?`*(js: JsonNode): untyped =
|
|||||||
if j.isNull: return
|
if j.isNull: return
|
||||||
j
|
j
|
||||||
|
|
||||||
|
template select*(a, b: JsonNode): untyped =
|
||||||
|
if a.notNull: a else: b
|
||||||
|
|
||||||
|
template select*(a, b, c: JsonNode): untyped =
|
||||||
|
if a.notNull: a elif b.notNull: b else: c
|
||||||
|
|
||||||
template with*(ident, value, body): untyped =
|
template with*(ident, value, body): untyped =
|
||||||
if true:
|
if true:
|
||||||
let ident {.inject.} = value
|
let ident {.inject.} = value
|
||||||
@@ -45,6 +59,19 @@ template getError*(js: JsonNode): Error =
|
|||||||
if js.kind != JArray or js.len == 0: null
|
if js.kind != JArray or js.len == 0: null
|
||||||
else: Error(js[0]{"code"}.getInt)
|
else: Error(js[0]{"code"}.getInt)
|
||||||
|
|
||||||
|
proc getTweetResult*(js: JsonNode; root="content"): JsonNode =
|
||||||
|
select(
|
||||||
|
js{root, "content", "tweet_results", "result"},
|
||||||
|
js{root, "itemContent", "tweet_results", "result"},
|
||||||
|
js{root, "content", "tweetResult", "result"}
|
||||||
|
)
|
||||||
|
|
||||||
|
template getTypeName*(js: JsonNode): string =
|
||||||
|
js{"__typename"}.getStr(js{"type"}.getStr)
|
||||||
|
|
||||||
|
template getEntryId*(e: JsonNode): string =
|
||||||
|
e{"entryId"}.getStr(e{"entry_id"}.getStr)
|
||||||
|
|
||||||
template parseTime(time: string; f: static string; flen: int): DateTime =
|
template parseTime(time: string; f: static string; flen: int): DateTime =
|
||||||
if time.len != flen: return
|
if time.len != flen: return
|
||||||
parse(time, f, utc())
|
parse(time, f, utc())
|
||||||
@@ -55,29 +82,32 @@ proc getDateTime*(js: JsonNode): DateTime =
|
|||||||
proc getTime*(js: JsonNode): DateTime =
|
proc getTime*(js: JsonNode): DateTime =
|
||||||
parseTime(js.getStr, "ddd MMM dd hh:mm:ss \'+0000\' yyyy", 30)
|
parseTime(js.getStr, "ddd MMM dd hh:mm:ss \'+0000\' yyyy", 30)
|
||||||
|
|
||||||
proc getId*(id: string): string {.inline.} =
|
proc getTimeFromMs*(js: JsonNode): DateTime =
|
||||||
|
let ms = js.getInt(0)
|
||||||
|
if ms == 0: return
|
||||||
|
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("-")
|
let start = id.rfind("-")
|
||||||
if start < 0: return id
|
if start < 0:
|
||||||
id[start + 1 ..< id.len]
|
return parseBiggestInt(id)
|
||||||
|
return parseBiggestInt(id[start + 1 ..< id.len])
|
||||||
|
|
||||||
proc getId*(js: JsonNode): int64 {.inline.} =
|
proc getId*(js: JsonNode): int64 {.inline.} =
|
||||||
case js.kind
|
case js.kind
|
||||||
of JString: return parseBiggestInt(js.getStr("0"))
|
of JString: return js.getStr("0").getId
|
||||||
of JInt: return js.getBiggestInt()
|
of JInt: return js.getBiggestInt()
|
||||||
else: return 0
|
else: return 0
|
||||||
|
|
||||||
proc getEntryId*(js: JsonNode): string {.inline.} =
|
|
||||||
let entry = js{"entryId"}.getStr
|
|
||||||
if entry.len == 0: return
|
|
||||||
|
|
||||||
if "tweet" in entry or "sq-I-t" in entry:
|
|
||||||
return entry.getId
|
|
||||||
elif "tombstone" in entry:
|
|
||||||
return js{"content", "item", "content", "tombstone", "tweet", "id"}.getStr
|
|
||||||
else:
|
|
||||||
echo "unknown entry: ", entry
|
|
||||||
return
|
|
||||||
|
|
||||||
template getStrVal*(js: JsonNode; default=""): string =
|
template getStrVal*(js: JsonNode; default=""): string =
|
||||||
js{"string_value"}.getStr(default)
|
js{"string_value"}.getStr(default)
|
||||||
|
|
||||||
@@ -89,6 +119,9 @@ proc getImageStr*(js: JsonNode): string =
|
|||||||
template getImageVal*(js: JsonNode): string =
|
template getImageVal*(js: JsonNode): string =
|
||||||
js{"image_value", "url"}.getImageStr
|
js{"image_value", "url"}.getImageStr
|
||||||
|
|
||||||
|
template getExpandedUrl*(js: JsonNode; fallback=""): string =
|
||||||
|
js{"expanded_url"}.getStr(js{"url"}.getStr(fallback))
|
||||||
|
|
||||||
proc getCardUrl*(js: JsonNode; kind: CardKind): string =
|
proc getCardUrl*(js: JsonNode; kind: CardKind): string =
|
||||||
result = js{"website_url"}.getStrVal
|
result = js{"website_url"}.getStrVal
|
||||||
if kind == promoVideoConvo:
|
if kind == promoVideoConvo:
|
||||||
@@ -154,7 +187,7 @@ proc extractSlice(js: JsonNode): Slice[int] =
|
|||||||
proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
|
proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
|
||||||
textLen: int; hideTwitter = false) =
|
textLen: int; hideTwitter = false) =
|
||||||
let
|
let
|
||||||
url = js["expanded_url"].getStr
|
url = js.getExpandedUrl
|
||||||
slice = js.extractSlice
|
slice = js.extractSlice
|
||||||
|
|
||||||
if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
|
if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
|
||||||
@@ -169,19 +202,32 @@ proc extractHashtags(result: var seq[ReplaceSlice]; js: JsonNode) =
|
|||||||
|
|
||||||
proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice];
|
proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice];
|
||||||
textSlice: Slice[int]): string =
|
textSlice: Slice[int]): string =
|
||||||
|
let
|
||||||
|
runeLen = runes.len
|
||||||
|
safeStart = max(0, textSlice.a)
|
||||||
|
safeEnd = min(runeLen, textSlice.b)
|
||||||
|
|
||||||
|
var validRepls: seq[ReplaceSlice]
|
||||||
|
for rep in repls:
|
||||||
|
if rep.slice.a >= 0 and rep.slice.b >= 0 and rep.slice.b < runeLen and rep.slice.a <= rep.slice.b:
|
||||||
|
validRepls.add rep
|
||||||
|
|
||||||
template extractLowerBound(i: int; idx): int =
|
template extractLowerBound(i: int; idx): int =
|
||||||
if i > 0: repls[idx].slice.b.succ else: textSlice.a
|
if i > 0: min(validRepls[idx].slice.b.succ, runeLen) else: safeStart
|
||||||
|
|
||||||
result = newStringOfCap(runes.len)
|
result = newStringOfCap(runes.len)
|
||||||
|
|
||||||
for i, rep in repls:
|
for i, rep in validRepls:
|
||||||
result.add $runes[extractLowerBound(i, i - 1) ..< rep.slice.a]
|
let lower = extractLowerBound(i, i - 1)
|
||||||
|
if lower < rep.slice.a:
|
||||||
|
result.add $runes[lower ..< rep.slice.a]
|
||||||
case rep.kind
|
case rep.kind
|
||||||
of rkHashtag:
|
of rkHashtag:
|
||||||
|
if rep.slice.a.succ <= rep.slice.b:
|
||||||
let
|
let
|
||||||
name = $runes[rep.slice.a.succ .. rep.slice.b]
|
name = $runes[rep.slice.a.succ .. rep.slice.b]
|
||||||
symbol = $runes[rep.slice.a]
|
symbol = $runes[rep.slice.a]
|
||||||
result.add a(symbol & name, href = "/search?q=%23" & name)
|
result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name)
|
||||||
of rkMention:
|
of rkMention:
|
||||||
result.add a($runes[rep.slice], href = rep.url, title = rep.display)
|
result.add a($runes[rep.slice], href = rep.url, title = rep.display)
|
||||||
of rkUrl:
|
of rkUrl:
|
||||||
@@ -189,8 +235,8 @@ proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice];
|
|||||||
of rkRemove:
|
of rkRemove:
|
||||||
discard
|
discard
|
||||||
|
|
||||||
let rest = extractLowerBound(repls.len, ^1) ..< textSlice.b
|
let rest = extractLowerBound(validRepls.len, ^1) ..< safeEnd
|
||||||
if rest.a <= rest.b:
|
if rest.a >= 0 and rest.a <= rest.b and rest.b <= runeLen:
|
||||||
result.add $runes[rest]
|
result.add $runes[rest]
|
||||||
|
|
||||||
proc deduplicate(s: var seq[ReplaceSlice]) =
|
proc deduplicate(s: var seq[ReplaceSlice]) =
|
||||||
@@ -215,7 +261,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
|
|||||||
ent = ? js{"entities"}
|
ent = ? js{"entities"}
|
||||||
|
|
||||||
with urls, ent{"url", "urls"}:
|
with urls, ent{"url", "urls"}:
|
||||||
user.website = urls[0]{"expanded_url"}.getStr
|
user.website = urls[0].getExpandedUrl
|
||||||
|
|
||||||
var replacements = newSeq[ReplaceSlice]()
|
var replacements = newSeq[ReplaceSlice]()
|
||||||
|
|
||||||
@@ -231,7 +277,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
|
|||||||
.replacef(htRegex, htReplace)
|
.replacef(htRegex, htReplace)
|
||||||
|
|
||||||
proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int];
|
proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int];
|
||||||
replyTo=""; hasQuote=false) =
|
replyTo=""; hasRedundantLink=false) =
|
||||||
let hasCard = tweet.card.isSome
|
let hasCard = tweet.card.isSome
|
||||||
|
|
||||||
var replacements = newSeq[ReplaceSlice]()
|
var replacements = newSeq[ReplaceSlice]()
|
||||||
@@ -242,10 +288,10 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
|
|||||||
if urlStr.len == 0 or urlStr notin text:
|
if urlStr.len == 0 or urlStr notin text:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
replacements.extractUrls(u, textSlice.b, hideTwitter = hasQuote)
|
replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink)
|
||||||
|
|
||||||
if hasCard and u{"url"}.getStr == get(tweet.card).url:
|
if hasCard and u{"url"}.getStr == get(tweet.card).url:
|
||||||
get(tweet.card).url = u{"expanded_url"}.getStr
|
get(tweet.card).url = u.getExpandedUrl
|
||||||
|
|
||||||
with media, entities{"media"}:
|
with media, entities{"media"}:
|
||||||
for m in media:
|
for m in media:
|
||||||
@@ -282,9 +328,10 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
|
|||||||
proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
|
proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
|
||||||
let
|
let
|
||||||
entities = ? js{"entities"}
|
entities = ? js{"entities"}
|
||||||
hasQuote = js{"is_quote_status"}.getBool
|
|
||||||
textRange = js{"display_text_range"}
|
textRange = js{"display_text_range"}
|
||||||
textSlice = textRange{0}.getInt .. textRange{1}.getInt
|
textSlice = textRange{0}.getInt .. textRange{1}.getInt
|
||||||
|
hasQuote = js{"is_quote_status"}.getBool
|
||||||
|
hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
|
||||||
|
|
||||||
var replyTo = ""
|
var replyTo = ""
|
||||||
if tweet.replyId != 0:
|
if tweet.replyId != 0:
|
||||||
@@ -292,12 +339,98 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
|
|||||||
replyTo = reply.getStr
|
replyTo = reply.getStr
|
||||||
tweet.reply.add replyTo
|
tweet.reply.add replyTo
|
||||||
|
|
||||||
tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote)
|
tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo,
|
||||||
|
hasQuote or hasJobCard)
|
||||||
|
|
||||||
|
proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int];
|
||||||
|
hasRedundantLink=false) =
|
||||||
|
let hasCard = tweet.card.isSome
|
||||||
|
|
||||||
|
var replacements = newSeq[ReplaceSlice]()
|
||||||
|
|
||||||
|
with urls, js{"url_entities"}:
|
||||||
|
for u in urls:
|
||||||
|
let urlStr = u["url"].getStr
|
||||||
|
if urlStr.len == 0 or urlStr notin text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink)
|
||||||
|
|
||||||
|
if hasCard and u{"url"}.getStr == get(tweet.card).url:
|
||||||
|
get(tweet.card).url = u.getExpandedUrl
|
||||||
|
|
||||||
|
with hashtags, js{"details", "hashtag_entities"}:
|
||||||
|
for hashtag in hashtags:
|
||||||
|
replacements.extractHashtags(hashtag)
|
||||||
|
|
||||||
|
with cashtags, js{"details", "cashtag_entities"}:
|
||||||
|
for cashtag in cashtags:
|
||||||
|
replacements.extractHashtags(cashtag)
|
||||||
|
|
||||||
|
with mentions, js{"mention_entities"}:
|
||||||
|
for mention in mentions:
|
||||||
|
let
|
||||||
|
name = mention{"screen_name"}.getStr
|
||||||
|
slice = mention.extractSlice
|
||||||
|
idx = tweet.reply.find(name)
|
||||||
|
|
||||||
|
if slice.a >= textSlice.a:
|
||||||
|
replacements.add ReplaceSlice(kind: rkMention, slice: slice,
|
||||||
|
url: "/" & name, display: mention["name"].getStr)
|
||||||
|
elif idx == -1 and tweet.replyId != 0:
|
||||||
|
tweet.reply.add name
|
||||||
|
|
||||||
|
replacements.deduplicate
|
||||||
|
replacements.sort(cmp)
|
||||||
|
|
||||||
|
tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false)
|
||||||
|
|
||||||
|
proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode) =
|
||||||
|
let
|
||||||
|
textRange = js{"details", "display_text_range"}
|
||||||
|
textSlice = textRange{0}.getInt .. textRange{1}.getInt
|
||||||
|
hasQuote = "quoted_tweet_results" in js
|
||||||
|
hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
|
||||||
|
hasAttribution = tweet.attribution.isSome
|
||||||
|
|
||||||
|
tweet.expandTextEntitiesV2(js, tweet.text, textSlice,
|
||||||
|
hasQuote or hasJobCard or hasAttribution)
|
||||||
|
|
||||||
proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
|
proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
|
||||||
let
|
let
|
||||||
entities = ? js{"entity_set"}
|
entities = ? js{"entity_set"}
|
||||||
text = js{"text"}.getStr
|
text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose))
|
||||||
textSlice = 0..text.runeLen
|
textSlice = 0..text.runeLen
|
||||||
|
hasAttribution = tweet.attribution.isSome
|
||||||
|
|
||||||
tweet.expandTextEntities(entities, text, textSlice)
|
tweet.expandTextEntities(entities, text, textSlice, hasRedundantLink=hasAttribution)
|
||||||
|
|
||||||
|
tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose))
|
||||||
|
|
||||||
|
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.media.len > 0: t.media[0].getThumb
|
||||||
|
elif t.card.isSome: get(t.card).image
|
||||||
|
else: ""
|
||||||
|
|
||||||
|
result = GalleryPhoto(url: url, tweetId: $t.id)
|
||||||
|
|||||||
+9
-9
@@ -1,22 +1,22 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import tables
|
import tables, strutils
|
||||||
import types, prefs_impl
|
import types, prefs_impl
|
||||||
from config import get
|
from config import get
|
||||||
from parsecfg import nil
|
from parsecfg import nil
|
||||||
|
|
||||||
export genUpdatePrefs, genResetPrefs
|
export genUpdatePrefs, genResetPrefs, genApplyPrefs
|
||||||
|
|
||||||
var defaultPrefs*: Prefs
|
var defaultPrefs*: Prefs
|
||||||
|
|
||||||
proc updateDefaultPrefs*(cfg: parsecfg.Config) =
|
proc updateDefaultPrefs*(cfg: parsecfg.Config) =
|
||||||
genDefaultPrefs()
|
genDefaultPrefs()
|
||||||
|
|
||||||
proc getPrefs*(cookies: Table[string, string]): Prefs =
|
proc getPrefs*(cookies, params: Table[string, string]): Prefs =
|
||||||
result = defaultPrefs
|
result = defaultPrefs
|
||||||
genCookiePrefs(cookies)
|
genParsePrefs(cookies)
|
||||||
|
genParsePrefs(params)
|
||||||
|
|
||||||
template getPref*(cookies: Table[string, string], pref): untyped =
|
proc encodePrefs*(prefs: Prefs): string =
|
||||||
bind genCookiePref
|
var encPairs: seq[string]
|
||||||
var res = defaultPrefs.`pref`
|
genEncodePrefs(prefs)
|
||||||
genCookiePref(cookies, pref, res)
|
encPairs.join(",")
|
||||||
res
|
|
||||||
|
|||||||
+54
-27
@@ -60,6 +60,9 @@ genPrefs:
|
|||||||
stickyProfile(checkbox, true):
|
stickyProfile(checkbox, true):
|
||||||
"Make profile sidebar stick to top"
|
"Make profile sidebar stick to top"
|
||||||
|
|
||||||
|
stickyNav(checkbox, true):
|
||||||
|
"Keep navbar fixed to top"
|
||||||
|
|
||||||
bidiSupport(checkbox, false):
|
bidiSupport(checkbox, false):
|
||||||
"Support bidirectional text (makes clicking on tweets harder)"
|
"Support bidirectional text (makes clicking on tweets harder)"
|
||||||
|
|
||||||
@@ -75,6 +78,9 @@ genPrefs:
|
|||||||
hideReplies(checkbox, false):
|
hideReplies(checkbox, false):
|
||||||
"Hide tweet replies"
|
"Hide tweet replies"
|
||||||
|
|
||||||
|
hideCommunityNotes(checkbox, false):
|
||||||
|
"Hide community notes"
|
||||||
|
|
||||||
squareAvatars(checkbox, false):
|
squareAvatars(checkbox, false):
|
||||||
"Square profile pictures"
|
"Square profile pictures"
|
||||||
|
|
||||||
@@ -94,6 +100,17 @@ genPrefs:
|
|||||||
autoplayGifs(checkbox, true):
|
autoplayGifs(checkbox, true):
|
||||||
"Autoplay gifs"
|
"Autoplay gifs"
|
||||||
|
|
||||||
|
compactGallery(checkbox, false):
|
||||||
|
"Compact media gallery (no profile info or text)"
|
||||||
|
|
||||||
|
gallerySize(select, "Medium"):
|
||||||
|
"Gallery column size"
|
||||||
|
options: @["Small", "Medium", "Large"]
|
||||||
|
|
||||||
|
mediaView(select, "Timeline"):
|
||||||
|
"Default media view"
|
||||||
|
options: @["Timeline", "Grid", "Gallery"]
|
||||||
|
|
||||||
"Link replacements (blank to disable)":
|
"Link replacements (blank to disable)":
|
||||||
replaceTwitter(input, ""):
|
replaceTwitter(input, ""):
|
||||||
"Twitter -> Nitter"
|
"Twitter -> Nitter"
|
||||||
@@ -127,7 +144,7 @@ macro genDefaultPrefs*(): untyped =
|
|||||||
result.add quote do:
|
result.add quote do:
|
||||||
defaultPrefs.`ident` = cfg.get("Preferences", `name`, `default`)
|
defaultPrefs.`ident` = cfg.get("Preferences", `name`, `default`)
|
||||||
|
|
||||||
macro genCookiePrefs*(cookies): untyped =
|
macro genParsePrefs*(prefs): untyped =
|
||||||
result = nnkStmtList.newTree()
|
result = nnkStmtList.newTree()
|
||||||
for pref in allPrefs():
|
for pref in allPrefs():
|
||||||
let
|
let
|
||||||
@@ -137,37 +154,17 @@ macro genCookiePrefs*(cookies): untyped =
|
|||||||
options = pref.options
|
options = pref.options
|
||||||
|
|
||||||
result.add quote do:
|
result.add quote do:
|
||||||
if `name` in `cookies`:
|
if `name` in `prefs`:
|
||||||
when `kind` == input or `name` == "theme":
|
when `kind` == input or `name` == "theme":
|
||||||
result.`ident` = `cookies`[`name`]
|
result.`ident` = `prefs`[`name`]
|
||||||
elif `kind` == checkbox:
|
elif `kind` == checkbox:
|
||||||
result.`ident` = `cookies`[`name`] == "on"
|
result.`ident` = `prefs`[`name`] == "on" or
|
||||||
|
`prefs`[`name`] == "true" or
|
||||||
|
`prefs`[`name`] == "1"
|
||||||
else:
|
else:
|
||||||
let value = `cookies`[`name`]
|
let value = `prefs`[`name`]
|
||||||
if value in `options`: result.`ident` = value
|
if value in `options`: result.`ident` = value
|
||||||
|
|
||||||
macro genCookiePref*(cookies, prefName, res): untyped =
|
|
||||||
result = nnkStmtList.newTree()
|
|
||||||
for pref in allPrefs():
|
|
||||||
let ident = ident(pref.name)
|
|
||||||
if ident != prefName:
|
|
||||||
continue
|
|
||||||
|
|
||||||
let
|
|
||||||
name = pref.name
|
|
||||||
kind = newLit(pref.kind)
|
|
||||||
options = pref.options
|
|
||||||
|
|
||||||
result.add quote do:
|
|
||||||
if `name` in `cookies`:
|
|
||||||
when `kind` == input or `name` == "theme":
|
|
||||||
`res` = `cookies`[`name`]
|
|
||||||
elif `kind` == checkbox:
|
|
||||||
`res` = `cookies`[`name`] == "on"
|
|
||||||
else:
|
|
||||||
let value = `cookies`[`name`]
|
|
||||||
if value in `options`: `res` = value
|
|
||||||
|
|
||||||
macro genUpdatePrefs*(): untyped =
|
macro genUpdatePrefs*(): untyped =
|
||||||
result = nnkStmtList.newTree()
|
result = nnkStmtList.newTree()
|
||||||
let req = ident("request")
|
let req = ident("request")
|
||||||
@@ -202,6 +199,36 @@ macro genResetPrefs*(): untyped =
|
|||||||
result.add quote do:
|
result.add quote do:
|
||||||
savePref(`name`, "", `req`, expire=true)
|
savePref(`name`, "", `req`, expire=true)
|
||||||
|
|
||||||
|
macro genEncodePrefs*(prefs): untyped =
|
||||||
|
result = nnkStmtList.newTree()
|
||||||
|
for pref in allPrefs():
|
||||||
|
let
|
||||||
|
name = newLit(pref.name)
|
||||||
|
ident = ident(pref.name)
|
||||||
|
kind = newLit(pref.kind)
|
||||||
|
defaultIdent = nnkDotExpr.newTree(ident("defaultPrefs"), ident(pref.name))
|
||||||
|
|
||||||
|
result.add quote do:
|
||||||
|
when `kind` == checkbox:
|
||||||
|
if `prefs`.`ident` != `defaultIdent`:
|
||||||
|
if `prefs`.`ident`:
|
||||||
|
encPairs.add `name` & "=on"
|
||||||
|
else:
|
||||||
|
encPairs.add `name` & "="
|
||||||
|
else:
|
||||||
|
if `prefs`.`ident` != `defaultIdent`:
|
||||||
|
encPairs.add `name` & "=" & `prefs`.`ident`
|
||||||
|
|
||||||
|
macro genApplyPrefs*(params, req): untyped =
|
||||||
|
result = nnkStmtList.newTree()
|
||||||
|
for pref in allPrefs():
|
||||||
|
let name = newLit(pref.name)
|
||||||
|
result.add quote do:
|
||||||
|
if `name` in `params`:
|
||||||
|
savePref(`name`, `params`[`name`], `req`)
|
||||||
|
else:
|
||||||
|
savePref(`name`, "", `req`, expire=true)
|
||||||
|
|
||||||
macro genPrefsType*(): untyped =
|
macro genPrefsType*(): untyped =
|
||||||
let name = nnkPostfix.newTree(ident("*"), ident("Prefs"))
|
let name = nnkPostfix.newTree(ident("*"), ident("Prefs"))
|
||||||
result = quote do:
|
result = quote do:
|
||||||
|
|||||||
+32
-16
@@ -1,15 +1,14 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import strutils, strformat, sequtils, tables, uri
|
import strutils, strformat, sequtils, tables, uri
|
||||||
|
|
||||||
import types
|
import types, utils
|
||||||
|
|
||||||
const
|
const
|
||||||
validFilters* = @[
|
validFilters* = @[
|
||||||
"media", "images", "twimg", "videos",
|
"media", "images", "twimg", "videos",
|
||||||
"native_video", "consumer_video", "pro_video",
|
"native_video", "consumer_video", "spaces",
|
||||||
"links", "news", "quote", "mentions",
|
"links", "news", "quote", "mentions",
|
||||||
"replies", "retweets", "nativeretweets",
|
"replies", "retweets", "nativeretweets", "cashtags"
|
||||||
"verified", "safe"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
emptyQuery* = "include:nativeretweets"
|
emptyQuery* = "include:nativeretweets"
|
||||||
@@ -21,12 +20,13 @@ template `@`(param: string): untyped =
|
|||||||
proc initQuery*(pms: Table[string, string]; name=""): Query =
|
proc initQuery*(pms: Table[string, string]; name=""): Query =
|
||||||
result = Query(
|
result = Query(
|
||||||
kind: parseEnum[QueryKind](@"f", tweets),
|
kind: parseEnum[QueryKind](@"f", tweets),
|
||||||
|
view: @"view",
|
||||||
text: @"q",
|
text: @"q",
|
||||||
filters: validFilters.filterIt("f-" & it in pms),
|
filters: validFilters.filterIt("f-" & it in pms),
|
||||||
excludes: validFilters.filterIt("e-" & it in pms),
|
excludes: validFilters.filterIt("e-" & it in pms),
|
||||||
since: @"since",
|
since: @"since",
|
||||||
until: @"until",
|
until: @"until",
|
||||||
near: @"near"
|
minLikes: validateNumber(@"min_faves")
|
||||||
)
|
)
|
||||||
|
|
||||||
if name.len > 0:
|
if name.len > 0:
|
||||||
@@ -46,7 +46,7 @@ proc getReplyQuery*(name: string): Query =
|
|||||||
fromUser: @[name]
|
fromUser: @[name]
|
||||||
)
|
)
|
||||||
|
|
||||||
proc genQueryParam*(query: Query): string =
|
proc genQueryParam*(query: Query; maxId=""): string =
|
||||||
var
|
var
|
||||||
filters: seq[string]
|
filters: seq[string]
|
||||||
param: string
|
param: string
|
||||||
@@ -55,12 +55,17 @@ proc genQueryParam*(query: Query): string =
|
|||||||
return query.text
|
return query.text
|
||||||
|
|
||||||
for i, user in query.fromUser:
|
for i, user in query.fromUser:
|
||||||
|
if i == 0:
|
||||||
|
param = "("
|
||||||
|
|
||||||
param &= &"from:{user}"
|
param &= &"from:{user}"
|
||||||
if i < query.fromUser.high:
|
if i < query.fromUser.high:
|
||||||
param &= " OR "
|
param &= " OR "
|
||||||
|
else:
|
||||||
|
param &= ")"
|
||||||
|
|
||||||
if query.fromUser.len > 0 and query.kind in {posts, media}:
|
if query.fromUser.len > 0 and query.kind in {posts, media}:
|
||||||
param &= "filter:self_threads OR-filter:replies "
|
param &= " (filter:self_threads OR -filter:replies)"
|
||||||
|
|
||||||
if "nativeretweets" notin query.excludes:
|
if "nativeretweets" notin query.excludes:
|
||||||
param &= " include:nativeretweets"
|
param &= " include:nativeretweets"
|
||||||
@@ -73,23 +78,34 @@ proc genQueryParam*(query: Query): string =
|
|||||||
for i in query.includes:
|
for i in query.includes:
|
||||||
filters.add "include:" & i
|
filters.add "include:" & i
|
||||||
|
|
||||||
result = strip(param & filters.join(&" {query.sep} "))
|
if filters.len > 0:
|
||||||
|
result = strip(param & " (" & filters.join(&" {query.sep} ") & ")")
|
||||||
|
else:
|
||||||
|
result = strip(param)
|
||||||
|
|
||||||
if query.since.len > 0:
|
if query.since.len > 0:
|
||||||
result &= " since:" & query.since
|
result &= " since:" & query.since
|
||||||
if query.until.len > 0:
|
if query.until.len > 0 and maxId.len == 0:
|
||||||
result &= " until:" & query.until
|
result &= " until:" & query.until
|
||||||
if query.near.len > 0:
|
if query.minLikes.len > 0:
|
||||||
result &= &" near:\"{query.near}\" within:15mi"
|
result &= " min_faves:" & query.minLikes
|
||||||
if query.text.len > 0:
|
if query.text.len > 0:
|
||||||
if result.len > 0:
|
if result.len > 0:
|
||||||
result &= " " & query.text
|
result &= " " & query.text
|
||||||
else:
|
else:
|
||||||
result = query.text
|
result = query.text
|
||||||
|
|
||||||
proc genQueryUrl*(query: Query): string =
|
if result.len > 0 and maxId.len > 0:
|
||||||
if query.kind notin {tweets, users}: return
|
result &= " max_id:" & maxId
|
||||||
|
|
||||||
var params = @[&"f={query.kind}"]
|
proc genQueryUrl*(query: Query): string =
|
||||||
|
var params: seq[string]
|
||||||
|
|
||||||
|
if query.view.len > 0:
|
||||||
|
params.add "view=" & encodeUrl(query.view)
|
||||||
|
|
||||||
|
if query.kind in {tweets, users}:
|
||||||
|
params.add &"f={query.kind}"
|
||||||
if query.text.len > 0:
|
if query.text.len > 0:
|
||||||
params.add "q=" & encodeUrl(query.text)
|
params.add "q=" & encodeUrl(query.text)
|
||||||
for f in query.filters:
|
for f in query.filters:
|
||||||
@@ -103,8 +119,8 @@ proc genQueryUrl*(query: Query): string =
|
|||||||
params.add "since=" & query.since
|
params.add "since=" & query.since
|
||||||
if query.until.len > 0:
|
if query.until.len > 0:
|
||||||
params.add "until=" & query.until
|
params.add "until=" & query.until
|
||||||
if query.near.len > 0:
|
if query.minLikes.len > 0:
|
||||||
params.add "near=" & query.near
|
params.add "min_faves=" & query.minLikes
|
||||||
|
|
||||||
if params.len > 0:
|
if params.len > 0:
|
||||||
result &= params.join("&")
|
result &= params.join("&")
|
||||||
|
|||||||
+45
-16
@@ -52,6 +52,7 @@ proc initRedisPool*(cfg: Config) {.async.} =
|
|||||||
await migrate("profileDates", "p:*")
|
await migrate("profileDates", "p:*")
|
||||||
await migrate("profileStats", "p:*")
|
await migrate("profileStats", "p:*")
|
||||||
await migrate("userType", "p:*")
|
await migrate("userType", "p:*")
|
||||||
|
await migrate("verifiedType", "p:*")
|
||||||
|
|
||||||
pool.withAcquire(r):
|
pool.withAcquire(r):
|
||||||
# optimize memory usage for user ID buckets
|
# optimize memory usage for user ID buckets
|
||||||
@@ -85,7 +86,7 @@ proc cache*(data: List) {.async.} =
|
|||||||
await setEx(data.listKey, listCacheTime, compress(toFlatty(data)))
|
await setEx(data.listKey, listCacheTime, compress(toFlatty(data)))
|
||||||
|
|
||||||
proc cache*(data: PhotoRail; name: string) {.async.} =
|
proc cache*(data: PhotoRail; name: string) {.async.} =
|
||||||
await setEx("pr:" & toLower(name), baseCacheTime, compress(toFlatty(data)))
|
await setEx("pr2:" & toLower(name), baseCacheTime * 2, compress(toFlatty(data)))
|
||||||
|
|
||||||
proc cache*(data: User) {.async.} =
|
proc cache*(data: User) {.async.} =
|
||||||
if data.username.len == 0: return
|
if data.username.len == 0: return
|
||||||
@@ -143,28 +144,56 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
|
|||||||
else:
|
else:
|
||||||
let user = await getGraphUserById(userId)
|
let user = await getGraphUserById(userId)
|
||||||
result = user.username
|
result = user.username
|
||||||
|
if result.len > 0:
|
||||||
await setEx(key, baseCacheTime, result)
|
await setEx(key, baseCacheTime, result)
|
||||||
if result.len > 0 and user.id.len > 0:
|
if user.id.len > 0:
|
||||||
await all(cacheUserId(result, user.id), cache(user))
|
await all(cacheUserId(result, user.id), cache(user))
|
||||||
|
|
||||||
proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
|
# proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
|
||||||
if id == 0: return
|
# if id == 0: return
|
||||||
let tweet = await get(id.tweetKey)
|
# let tweet = await get(id.tweetKey)
|
||||||
if tweet != redisNil:
|
# if tweet != redisNil:
|
||||||
tweet.deserialize(Tweet)
|
# tweet.deserialize(Tweet)
|
||||||
else:
|
# else:
|
||||||
result = await getGraphTweetResult($id)
|
# result = await getGraphTweetResult($id)
|
||||||
if not result.isNil:
|
# if not result.isNil:
|
||||||
await cache(result)
|
# await cache(result)
|
||||||
|
|
||||||
proc getCachedPhotoRail*(name: string): Future[PhotoRail] {.async.} =
|
proc cache*(data: Broadcast) {.async.} =
|
||||||
if name.len == 0: return
|
if data.id.len == 0: return
|
||||||
let rail = await get("pr:" & toLower(name))
|
await setEx("bc:" & data.id, baseCacheTime, compress(toFlatty(data)))
|
||||||
|
|
||||||
|
proc getCachedBroadcast*(id: string): Future[Broadcast] {.async.} =
|
||||||
|
if id.len == 0: return
|
||||||
|
let cached = await get("bc:" & id)
|
||||||
|
if cached != redisNil:
|
||||||
|
cached.deserialize(Broadcast)
|
||||||
|
else:
|
||||||
|
result = await getBroadcastInfo(id)
|
||||||
|
await cache(result)
|
||||||
|
result.m3u8Url = await fetchBroadcastStream(result.mediaKey)
|
||||||
|
|
||||||
|
proc cache*(data: AccountInfo; name: string) {.async.} =
|
||||||
|
await setEx("ai:" & toLower(name), baseCacheTime * 24, compress(toFlatty(data)))
|
||||||
|
|
||||||
|
proc getCachedAccountInfo*(username: string; fetch=true): Future[AccountInfo] {.async.} =
|
||||||
|
if username.len == 0: return
|
||||||
|
let name = toLower(username)
|
||||||
|
let cached = await get("ai:" & name)
|
||||||
|
if cached != redisNil:
|
||||||
|
cached.deserialize(AccountInfo)
|
||||||
|
elif fetch:
|
||||||
|
result = await getAboutAccount(username)
|
||||||
|
await cache(result, name)
|
||||||
|
|
||||||
|
proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} =
|
||||||
|
if id.len == 0: return
|
||||||
|
let rail = await get("pr2:" & toLower(id))
|
||||||
if rail != redisNil:
|
if rail != redisNil:
|
||||||
rail.deserialize(PhotoRail)
|
rail.deserialize(PhotoRail)
|
||||||
else:
|
else:
|
||||||
result = await getPhotoRail(name)
|
result = await getPhotoRail(id)
|
||||||
await cache(result, name)
|
await cache(result, id)
|
||||||
|
|
||||||
proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
|
proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
|
||||||
let list = if id.len == 0: redisNil
|
let list = if id.len == 0: redisNil
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
import asyncdispatch, strutils
|
||||||
|
import jester
|
||||||
|
|
||||||
|
import router_utils
|
||||||
|
import ".."/[types, formatters, redis_cache]
|
||||||
|
import ../views/[general, broadcast]
|
||||||
|
import media
|
||||||
|
|
||||||
|
export broadcast
|
||||||
|
|
||||||
|
proc createBroadcastRouter*(cfg: Config) =
|
||||||
|
router broadcastRoute:
|
||||||
|
get "/i/broadcasts/@id":
|
||||||
|
cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
|
||||||
|
var bc: Broadcast
|
||||||
|
try:
|
||||||
|
bc = await getCachedBroadcast(@"id")
|
||||||
|
except:
|
||||||
|
discard
|
||||||
|
|
||||||
|
if bc.id.len == 0:
|
||||||
|
resp Http404, showError("Broadcast not found", cfg)
|
||||||
|
|
||||||
|
let prefs = requestPrefs()
|
||||||
|
resp renderMain(renderBroadcast(bc, prefs, request.path), request, cfg, prefs,
|
||||||
|
bc.title, ogTitle=bc.title)
|
||||||
|
|
||||||
|
get "/i/broadcasts/@id/stream":
|
||||||
|
cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
|
||||||
|
var bc: Broadcast
|
||||||
|
try:
|
||||||
|
bc = await getCachedBroadcast(@"id")
|
||||||
|
except:
|
||||||
|
discard
|
||||||
|
|
||||||
|
if bc.m3u8Url.len == 0:
|
||||||
|
resp Http404
|
||||||
|
|
||||||
|
let manifest = await safeFetch(bc.m3u8Url)
|
||||||
|
if manifest.len == 0:
|
||||||
|
resp Http502
|
||||||
|
|
||||||
|
resp proxifyVideo(manifest, requestPrefs().proxyVideos, bc.m3u8Url), m3u8Mime
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import jester
|
import jester
|
||||||
import router_utils
|
import router_utils
|
||||||
import ".."/[tokens, types]
|
import ".."/[auth, types]
|
||||||
|
|
||||||
proc createDebugRouter*(cfg: Config) =
|
proc createDebugRouter*(cfg: Config) =
|
||||||
router debug:
|
router debug:
|
||||||
get "/.tokens":
|
get "/.health":
|
||||||
|
respJson getSessionPoolHealth()
|
||||||
|
|
||||||
|
get "/.sessions":
|
||||||
cond cfg.enableDebug
|
cond cfg.enableDebug
|
||||||
respJson getPoolJson()
|
respJson getSessionPoolDebug()
|
||||||
|
|||||||
@@ -10,22 +10,22 @@ export api, embed, vdom, tweet, general, router_utils
|
|||||||
proc createEmbedRouter*(cfg: Config) =
|
proc createEmbedRouter*(cfg: Config) =
|
||||||
router embed:
|
router embed:
|
||||||
get "/i/videos/tweet/@id":
|
get "/i/videos/tweet/@id":
|
||||||
let convo = await getTweet(@"id")
|
let tweet = await getGraphTweetResult(@"id")
|
||||||
if convo == nil or convo.tweet == nil or convo.tweet.video.isNone:
|
if tweet == nil or not tweet.hasVideos:
|
||||||
resp Http404
|
resp Http404
|
||||||
|
|
||||||
resp renderVideoEmbed(convo.tweet, cfg, request)
|
resp renderVideoEmbed(tweet, cfg, request)
|
||||||
|
|
||||||
get "/@user/status/@id/embed":
|
get "/@user/status/@id/embed":
|
||||||
let
|
let
|
||||||
convo = await getTweet(@"id")
|
tweet = await getGraphTweetResult(@"id")
|
||||||
prefs = cookiePrefs()
|
prefs = requestPrefs()
|
||||||
path = getPath()
|
path = getPath()
|
||||||
|
|
||||||
if convo == nil or convo.tweet == nil:
|
if tweet == nil:
|
||||||
resp Http404
|
resp Http404
|
||||||
|
|
||||||
resp renderTweetEmbed(convo.tweet, path, prefs, cfg, request)
|
resp renderTweetEmbed(tweet, path, prefs, cfg, request)
|
||||||
|
|
||||||
get "/embed/Tweet.html":
|
get "/embed/Tweet.html":
|
||||||
let id = @"id"
|
let id = @"id"
|
||||||
|
|||||||
+3
-3
@@ -13,7 +13,7 @@ template respList*(list, timeline, title, vnode: typed) =
|
|||||||
|
|
||||||
let
|
let
|
||||||
html = renderList(vnode, timeline.query, list)
|
html = renderList(vnode, timeline.query, list)
|
||||||
rss = &"""/i/lists/{@"id"}/rss"""
|
rss = if cfg.enableRSSList: &"""/i/lists/{@"id"}/rss""" else: ""
|
||||||
|
|
||||||
resp renderMain(html, request, cfg, prefs, titleText=title, rss=rss, banner=list.banner)
|
resp renderMain(html, request, cfg, prefs, titleText=title, rss=rss, banner=list.banner)
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ proc createListRouter*(cfg: Config) =
|
|||||||
get "/i/lists/@id/?":
|
get "/i/lists/@id/?":
|
||||||
cond '.' notin @"id"
|
cond '.' notin @"id"
|
||||||
let
|
let
|
||||||
prefs = cookiePrefs()
|
prefs = requestPrefs()
|
||||||
list = await getCachedList(id=(@"id"))
|
list = await getCachedList(id=(@"id"))
|
||||||
timeline = await getGraphListTweets(list.id, getCursor())
|
timeline = await getGraphListTweets(list.id, getCursor())
|
||||||
vnode = renderTimelineTweets(timeline, prefs, request.path)
|
vnode = renderTimelineTweets(timeline, prefs, request.path)
|
||||||
@@ -45,7 +45,7 @@ proc createListRouter*(cfg: Config) =
|
|||||||
get "/i/lists/@id/members":
|
get "/i/lists/@id/members":
|
||||||
cond '.' notin @"id"
|
cond '.' notin @"id"
|
||||||
let
|
let
|
||||||
prefs = cookiePrefs()
|
prefs = requestPrefs()
|
||||||
list = await getCachedList(id=(@"id"))
|
list = await getCachedList(id=(@"id"))
|
||||||
members = await getGraphListMembers(list, getCursor())
|
members = await getGraphListMembers(list, getCursor())
|
||||||
respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path))
|
respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path))
|
||||||
|
|||||||
+19
-14
@@ -37,6 +37,8 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
|
|||||||
try:
|
try:
|
||||||
let res = await client.get(url)
|
let res = await client.get(url)
|
||||||
if res.status != "200 OK":
|
if res.status != "200 OK":
|
||||||
|
if res.status != "404 Not Found":
|
||||||
|
echo "[media] Proxying failed, status: $1, url: $2" % [res.status, url]
|
||||||
return Http404
|
return Http404
|
||||||
|
|
||||||
let hashed = $hash(url)
|
let hashed = $hash(url)
|
||||||
@@ -50,10 +52,10 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
|
|||||||
""
|
""
|
||||||
|
|
||||||
let headers = newHttpHeaders({
|
let headers = newHttpHeaders({
|
||||||
"Content-Type": res.headers["content-type", 0],
|
"content-type": res.headers["content-type", 0],
|
||||||
"Content-Length": contentLength,
|
"content-length": contentLength,
|
||||||
"Cache-Control": maxAge,
|
"cache-control": maxAge,
|
||||||
"ETag": hashed
|
"etag": hashed
|
||||||
})
|
})
|
||||||
|
|
||||||
respond(request, headers)
|
respond(request, headers)
|
||||||
@@ -65,6 +67,7 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
|
|||||||
await request.client.send(data)
|
await request.client.send(data)
|
||||||
data.setLen 0
|
data.setLen 0
|
||||||
except HttpRequestError, ProtocolError, OSError:
|
except HttpRequestError, ProtocolError, OSError:
|
||||||
|
echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url]
|
||||||
result = Http404
|
result = Http404
|
||||||
finally:
|
finally:
|
||||||
client.close()
|
client.close()
|
||||||
@@ -83,6 +86,12 @@ proc decoded*(req: jester.Request; index: int): string =
|
|||||||
if based: decode(encoded)
|
if based: decode(encoded)
|
||||||
else: decodeUrl(encoded)
|
else: decodeUrl(encoded)
|
||||||
|
|
||||||
|
proc normalizeImgUrl*(url: var string) =
|
||||||
|
if not url.startsWith("http"):
|
||||||
|
if "twimg.com" notin url:
|
||||||
|
url.insert(twimg)
|
||||||
|
url.insert(https)
|
||||||
|
|
||||||
proc createMediaRouter*(cfg: Config) =
|
proc createMediaRouter*(cfg: Config) =
|
||||||
router media:
|
router media:
|
||||||
get "/pic/?":
|
get "/pic/?":
|
||||||
@@ -90,10 +99,8 @@ proc createMediaRouter*(cfg: Config) =
|
|||||||
|
|
||||||
get re"^\/pic\/orig\/(enc)?\/?(.+)":
|
get re"^\/pic\/orig\/(enc)?\/?(.+)":
|
||||||
var url = decoded(request, 1)
|
var url = decoded(request, 1)
|
||||||
if "twimg.com" notin url:
|
cond "/amplify_video/" notin url
|
||||||
url.insert(twimg)
|
normalizeImgUrl(url)
|
||||||
if not url.startsWith(https):
|
|
||||||
url.insert(https)
|
|
||||||
url.add("?name=orig")
|
url.add("?name=orig")
|
||||||
|
|
||||||
let uri = parseUri(url)
|
let uri = parseUri(url)
|
||||||
@@ -104,10 +111,8 @@ proc createMediaRouter*(cfg: Config) =
|
|||||||
|
|
||||||
get re"^\/pic\/(enc)?\/?(.+)":
|
get re"^\/pic\/(enc)?\/?(.+)":
|
||||||
var url = decoded(request, 1)
|
var url = decoded(request, 1)
|
||||||
if "twimg.com" notin url:
|
cond "/amplify_video/" notin url
|
||||||
url.insert(twimg)
|
normalizeImgUrl(url)
|
||||||
if not url.startsWith(https):
|
|
||||||
url.insert(https)
|
|
||||||
|
|
||||||
let uri = parseUri(url)
|
let uri = parseUri(url)
|
||||||
cond isTwitterUrl(uri) == true
|
cond isTwitterUrl(uri) == true
|
||||||
@@ -120,7 +125,7 @@ proc createMediaRouter*(cfg: Config) =
|
|||||||
cond "http" in url
|
cond "http" in url
|
||||||
|
|
||||||
if getHmac(url) != request.matches[1]:
|
if getHmac(url) != request.matches[1]:
|
||||||
resp showError("Failed to verify signature", cfg)
|
resp Http403, showError("Failed to verify signature", cfg)
|
||||||
|
|
||||||
if ".mp4" in url or ".ts" in url or ".m4s" in url:
|
if ".mp4" in url or ".ts" in url or ".m4s" in url:
|
||||||
let code = await proxyMedia(request, url)
|
let code = await proxyMedia(request, url)
|
||||||
@@ -136,6 +141,6 @@ proc createMediaRouter*(cfg: Config) =
|
|||||||
|
|
||||||
if ".m3u8" in url:
|
if ".m3u8" in url:
|
||||||
let vid = await safeFetch(url)
|
let vid = await safeFetch(url)
|
||||||
content = proxifyVideo(vid, cookiePref(proxyVideos))
|
content = proxifyVideo(vid, requestPrefs().proxyVideos, url)
|
||||||
|
|
||||||
resp content, m3u8Mime
|
resp content, m3u8Mime
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ proc createPrefRouter*(cfg: Config) =
|
|||||||
router preferences:
|
router preferences:
|
||||||
get "/settings":
|
get "/settings":
|
||||||
let
|
let
|
||||||
prefs = cookiePrefs()
|
prefs = requestPrefs()
|
||||||
html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir))
|
prefsCode = encodePrefs(prefs)
|
||||||
|
prefsUrl = getUrlPrefix(cfg) & "/?prefs=" & prefsCode
|
||||||
|
html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir), prefsUrl)
|
||||||
resp renderMain(html, request, cfg, prefs, "Preferences")
|
resp renderMain(html, request, cfg, prefs, "Preferences")
|
||||||
|
|
||||||
get "/settings/@i?":
|
get "/settings/@i?":
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ proc createResolverRouter*(cfg: Config) =
|
|||||||
router resolver:
|
router resolver:
|
||||||
get "/cards/@card/@id":
|
get "/cards/@card/@id":
|
||||||
let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"]
|
let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"]
|
||||||
respResolved(await resolve(url, cookiePrefs()), "card")
|
respResolved(await resolve(url, requestPrefs()), "card")
|
||||||
|
|
||||||
get "/t.co/@url":
|
get "/t.co/@url":
|
||||||
let url = "https://t.co/" & @"url"
|
let url = "https://t.co/" & @"url"
|
||||||
respResolved(await resolve(url, cookiePrefs()), "t.co")
|
respResolved(await resolve(url, requestPrefs()), "t.co")
|
||||||
|
|||||||
+28
-12
@@ -8,22 +8,15 @@ export utils, prefs, types, uri
|
|||||||
|
|
||||||
template savePref*(pref, value: string; req: Request; expire=false) =
|
template savePref*(pref, value: string; req: Request; expire=false) =
|
||||||
if not expire or pref in cookies(req):
|
if not expire or pref in cookies(req):
|
||||||
|
let sameSite = if cfg.useHttps: None else: Lax
|
||||||
setCookie(pref, value, daysForward(when expire: -10 else: 360),
|
setCookie(pref, value, daysForward(when expire: -10 else: 360),
|
||||||
httpOnly=true, secure=cfg.useHttps, sameSite=None)
|
httpOnly=true, secure=cfg.useHttps, sameSite=sameSite, path="/")
|
||||||
|
|
||||||
template cookiePrefs*(): untyped {.dirty.} =
|
template requestPrefs*(): untyped {.dirty.} =
|
||||||
getPrefs(cookies(request))
|
getPrefs(cookies(request), params(request))
|
||||||
|
|
||||||
template cookiePref*(pref): untyped {.dirty.} =
|
|
||||||
getPref(cookies(request), pref)
|
|
||||||
|
|
||||||
template themePrefs*(): Prefs =
|
|
||||||
var res = defaultPrefs
|
|
||||||
res.theme = cookiePref(theme)
|
|
||||||
res
|
|
||||||
|
|
||||||
template showError*(error: string; cfg: Config): string =
|
template showError*(error: string; cfg: Config): string =
|
||||||
renderMain(renderError(error), request, cfg, themePrefs(), "Error")
|
renderMain(renderError(error), request, cfg, requestPrefs(), "Error")
|
||||||
|
|
||||||
template getPath*(): untyped {.dirty.} =
|
template getPath*(): untyped {.dirty.} =
|
||||||
$(parseUri(request.path) ? filterParams(request.params))
|
$(parseUri(request.path) ? filterParams(request.params))
|
||||||
@@ -43,5 +36,28 @@ template getCursor*(req: Request): string =
|
|||||||
proc getNames*(name: string): seq[string] =
|
proc getNames*(name: string): seq[string] =
|
||||||
name.strip(chars={'/'}).split(",").filterIt(it.len > 0)
|
name.strip(chars={'/'}).split(",").filterIt(it.len > 0)
|
||||||
|
|
||||||
|
template applyUrlPrefs*() {.dirty.} =
|
||||||
|
if @"prefs".len > 0:
|
||||||
|
var prefParams = initTable[string, string]()
|
||||||
|
for pair in @"prefs".split(','):
|
||||||
|
let kv = pair.split('=', maxsplit=1)
|
||||||
|
if kv.len == 2:
|
||||||
|
prefParams[kv[0]] = kv[1]
|
||||||
|
elif kv.len == 1 and kv[0].len > 0:
|
||||||
|
prefParams[kv[0]] = ""
|
||||||
|
genApplyPrefs(prefParams, request)
|
||||||
|
|
||||||
|
# Rebuild URL without prefs param
|
||||||
|
var params: seq[(string, string)]
|
||||||
|
for k, v in request.params:
|
||||||
|
if k != "prefs":
|
||||||
|
params.add (k, v)
|
||||||
|
|
||||||
|
if params.len > 0:
|
||||||
|
let cleanUrl = request.getNativeReq.url ? params
|
||||||
|
redirect($cleanUrl)
|
||||||
|
else:
|
||||||
|
redirect(request.path)
|
||||||
|
|
||||||
template respJson*(node: JsonNode) =
|
template respJson*(node: JsonNode) =
|
||||||
resp $node, "application/json"
|
resp $node, "application/json"
|
||||||
|
|||||||
+31
-18
@@ -15,7 +15,7 @@ proc redisKey*(page, name, cursor: string): string =
|
|||||||
if cursor.len > 0:
|
if cursor.len > 0:
|
||||||
result &= ":" & cursor
|
result &= ":" & cursor
|
||||||
|
|
||||||
proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.} =
|
proc timelineRss*(req: Request; cfg: Config; query: Query; prefs: Prefs): Future[Rss] {.async.} =
|
||||||
var profile: Profile
|
var profile: Profile
|
||||||
let
|
let
|
||||||
name = req.params.getOrDefault("name")
|
name = req.params.getOrDefault("name")
|
||||||
@@ -23,25 +23,23 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.
|
|||||||
names = getNames(name)
|
names = getNames(name)
|
||||||
|
|
||||||
if names.len == 1:
|
if names.len == 1:
|
||||||
profile = await fetchProfile(after, query, skipRail=true, skipPinned=true)
|
profile = await fetchProfile(after, query, skipRail=true)
|
||||||
else:
|
else:
|
||||||
var q = query
|
var q = query
|
||||||
q.fromUser = names
|
q.fromUser = names
|
||||||
profile = Profile(
|
profile.tweets = await getGraphTweetSearch(q, after)
|
||||||
tweets: await getGraphSearch(q, after),
|
|
||||||
# this is kinda dumb
|
# this is kinda dumb
|
||||||
user: User(
|
profile.user = User(
|
||||||
username: name,
|
username: name,
|
||||||
fullname: names.join(" | "),
|
fullname: names.join(" | "),
|
||||||
userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png"
|
userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png"
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
if profile.user.suspended:
|
if profile.user.suspended:
|
||||||
return Rss(feed: profile.user.username, cursor: "suspended")
|
return Rss(feed: profile.user.username, cursor: "suspended")
|
||||||
|
|
||||||
if profile.user.fullname.len > 0:
|
if profile.user.fullname.len > 0:
|
||||||
let rss = renderTimelineRss(profile, cfg, multi=(names.len > 1))
|
let rss = renderTimelineRss(profile, cfg, prefs, multi=(names.len > 1))
|
||||||
return Rss(feed: rss, cursor: profile.tweets.bottom)
|
return Rss(feed: rss, cursor: profile.tweets.bottom)
|
||||||
|
|
||||||
template respRss*(rss, page) =
|
template respRss*(rss, page) =
|
||||||
@@ -62,11 +60,14 @@ template respRss*(rss, page) =
|
|||||||
proc createRssRouter*(cfg: Config) =
|
proc createRssRouter*(cfg: Config) =
|
||||||
router rss:
|
router rss:
|
||||||
get "/search/rss":
|
get "/search/rss":
|
||||||
cond cfg.enableRss
|
if not cfg.enableRSSSearch:
|
||||||
|
resp Http403, showError("RSS feed is disabled", cfg)
|
||||||
if @"q".len > 200:
|
if @"q".len > 200:
|
||||||
resp Http400, showError("Search input too long.", cfg)
|
resp Http400, showError("Search input too long.", cfg)
|
||||||
|
|
||||||
let query = initQuery(params(request))
|
let
|
||||||
|
prefs = requestPrefs()
|
||||||
|
query = initQuery(params(request))
|
||||||
if query.kind != tweets:
|
if query.kind != tweets:
|
||||||
resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg)
|
resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg)
|
||||||
|
|
||||||
@@ -78,17 +79,19 @@ proc createRssRouter*(cfg: Config) =
|
|||||||
if rss.cursor.len > 0:
|
if rss.cursor.len > 0:
|
||||||
respRss(rss, "Search")
|
respRss(rss, "Search")
|
||||||
|
|
||||||
let tweets = await getGraphSearch(query, cursor)
|
let tweets = await getGraphTweetSearch(query, cursor)
|
||||||
rss.cursor = tweets.bottom
|
rss.cursor = tweets.bottom
|
||||||
rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg)
|
rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg, prefs)
|
||||||
|
|
||||||
await cacheRss(key, rss)
|
await cacheRss(key, rss)
|
||||||
respRss(rss, "Search")
|
respRss(rss, "Search")
|
||||||
|
|
||||||
get "/@name/rss":
|
get "/@name/rss":
|
||||||
cond cfg.enableRss
|
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
|
if not cfg.enableRSSUserTweets:
|
||||||
|
resp Http403, showError("RSS feed is disabled", cfg)
|
||||||
let
|
let
|
||||||
|
prefs = requestPrefs()
|
||||||
name = @"name"
|
name = @"name"
|
||||||
key = redisKey("twitter", name, getCursor())
|
key = redisKey("twitter", name, getCursor())
|
||||||
|
|
||||||
@@ -96,16 +99,23 @@ proc createRssRouter*(cfg: Config) =
|
|||||||
if rss.cursor.len > 0:
|
if rss.cursor.len > 0:
|
||||||
respRss(rss, "User")
|
respRss(rss, "User")
|
||||||
|
|
||||||
rss = await timelineRss(request, cfg, Query(fromUser: @[name]))
|
rss = await timelineRss(request, cfg, Query(fromUser: @[name]), prefs)
|
||||||
|
|
||||||
await cacheRss(key, rss)
|
await cacheRss(key, rss)
|
||||||
respRss(rss, "User")
|
respRss(rss, "User")
|
||||||
|
|
||||||
get "/@name/@tab/rss":
|
get "/@name/@tab/rss":
|
||||||
cond cfg.enableRss
|
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
cond @"tab" in ["with_replies", "media", "search"]
|
cond @"tab" in ["with_replies", "media", "search"]
|
||||||
|
let rssEnabled = case @"tab"
|
||||||
|
of "with_replies": cfg.enableRSSUserReplies
|
||||||
|
of "media": cfg.enableRSSUserMedia
|
||||||
|
of "search": cfg.enableRSSSearch
|
||||||
|
else: false
|
||||||
|
if not rssEnabled:
|
||||||
|
resp Http403, showError("RSS feed is disabled", cfg)
|
||||||
let
|
let
|
||||||
|
prefs = requestPrefs()
|
||||||
name = @"name"
|
name = @"name"
|
||||||
tab = @"tab"
|
tab = @"tab"
|
||||||
query =
|
query =
|
||||||
@@ -124,14 +134,15 @@ proc createRssRouter*(cfg: Config) =
|
|||||||
if rss.cursor.len > 0:
|
if rss.cursor.len > 0:
|
||||||
respRss(rss, "User")
|
respRss(rss, "User")
|
||||||
|
|
||||||
rss = await timelineRss(request, cfg, query)
|
rss = await timelineRss(request, cfg, query, prefs)
|
||||||
|
|
||||||
await cacheRss(key, rss)
|
await cacheRss(key, rss)
|
||||||
respRss(rss, "User")
|
respRss(rss, "User")
|
||||||
|
|
||||||
get "/@name/lists/@slug/rss":
|
get "/@name/lists/@slug/rss":
|
||||||
cond cfg.enableRss
|
|
||||||
cond @"name" != "i"
|
cond @"name" != "i"
|
||||||
|
if not cfg.enableRSSList:
|
||||||
|
resp Http403, showError("RSS feed is disabled", cfg)
|
||||||
let
|
let
|
||||||
slug = decodeUrl(@"slug")
|
slug = decodeUrl(@"slug")
|
||||||
list = await getCachedList(@"name", slug)
|
list = await getCachedList(@"name", slug)
|
||||||
@@ -147,8 +158,10 @@ proc createRssRouter*(cfg: Config) =
|
|||||||
redirect(url)
|
redirect(url)
|
||||||
|
|
||||||
get "/i/lists/@id/rss":
|
get "/i/lists/@id/rss":
|
||||||
cond cfg.enableRss
|
if not cfg.enableRSSList:
|
||||||
|
resp Http403, showError("RSS feed is disabled", cfg)
|
||||||
let
|
let
|
||||||
|
prefs = requestPrefs()
|
||||||
id = @"id"
|
id = @"id"
|
||||||
cursor = getCursor()
|
cursor = getCursor()
|
||||||
key = redisKey("lists", id, cursor)
|
key = redisKey("lists", id, cursor)
|
||||||
@@ -161,7 +174,7 @@ proc createRssRouter*(cfg: Config) =
|
|||||||
list = await getCachedList(id=id)
|
list = await getCachedList(id=id)
|
||||||
timeline = await getGraphListTweets(list.id, cursor)
|
timeline = await getGraphListTweets(list.id, cursor)
|
||||||
rss.cursor = timeline.bottom
|
rss.cursor = timeline.bottom
|
||||||
rss.feed = renderListRss(timeline.content, list, cfg)
|
rss.feed = renderListRss(timeline.content, list, cfg, prefs)
|
||||||
|
|
||||||
await cacheRss(key, rss)
|
await cacheRss(key, rss)
|
||||||
respRss(rss, "List")
|
respRss(rss, "List")
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ proc createSearchRouter*(cfg: Config) =
|
|||||||
resp Http400, showError("Search input too long.", cfg)
|
resp Http400, showError("Search input too long.", cfg)
|
||||||
|
|
||||||
let
|
let
|
||||||
prefs = cookiePrefs()
|
prefs = requestPrefs()
|
||||||
query = initQuery(params(request))
|
query = initQuery(params(request))
|
||||||
title = "Search" & (if q.len > 0: " (" & q & ")" else: "")
|
title = "Search" & (if q.len > 0: " (" & q & ")" else: "")
|
||||||
|
|
||||||
@@ -29,23 +29,24 @@ proc createSearchRouter*(cfg: Config) =
|
|||||||
redirect("/" & q)
|
redirect("/" & q)
|
||||||
var users: Result[User]
|
var users: Result[User]
|
||||||
try:
|
try:
|
||||||
users = await getUserSearch(query, getCursor())
|
users = await getGraphUserSearch(query, getCursor())
|
||||||
except InternalError:
|
except InternalError:
|
||||||
users = Result[User](beginning: true, query: query)
|
users = Result[User](beginning: true, query: query)
|
||||||
resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title)
|
resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title)
|
||||||
of tweets:
|
of tweets:
|
||||||
let
|
let
|
||||||
tweets = await getGraphSearch(query, getCursor())
|
tweets = await getGraphTweetSearch(query, getCursor())
|
||||||
rss = "/search/rss?" & genQueryUrl(query)
|
rss = if cfg.enableRSSSearch: "/search/rss?" & genQueryUrl(query) else: ""
|
||||||
resp renderMain(renderTweetSearch(tweets, prefs, getPath()),
|
resp renderMain(renderTweetSearch(tweets, prefs, getPath()),
|
||||||
request, cfg, prefs, title, rss=rss)
|
request, cfg, prefs, title, rss=rss)
|
||||||
else:
|
else:
|
||||||
resp Http404, showError("Invalid search", cfg)
|
resp Http404, showError("Invalid search", cfg)
|
||||||
|
|
||||||
get "/hashtag/@hash":
|
get "/hashtag/@hash":
|
||||||
redirect("/search?q=" & encodeUrl("#" & @"hash"))
|
redirect("/search?f=tweets&q=" & encodeUrl("#" & @"hash"))
|
||||||
|
|
||||||
get "/opensearch":
|
get "/opensearch":
|
||||||
let url = getUrlPrefix(cfg) & "/search?q="
|
let
|
||||||
resp Http200, {"Content-Type": "application/opensearchdescription+xml"},
|
url = getUrlPrefix(cfg) & "/search?f=tweets&q="
|
||||||
generateOpenSearchXML(cfg.title, cfg.hostname, url)
|
headers = {"Content-Type": "application/opensearchdescription+xml"}
|
||||||
|
resp Http200, headers, generateOpenSearchXML(cfg.title, cfg.hostname, url)
|
||||||
|
|||||||
+32
-10
@@ -21,18 +21,16 @@ proc createStatusRouter*(cfg: Config) =
|
|||||||
if id.len > 19 or id.any(c => not c.isDigit):
|
if id.len > 19 or id.any(c => not c.isDigit):
|
||||||
resp Http404, showError("Invalid tweet ID", cfg)
|
resp Http404, showError("Invalid tweet ID", cfg)
|
||||||
|
|
||||||
let prefs = cookiePrefs()
|
let prefs = requestPrefs()
|
||||||
|
|
||||||
# used for the infinite scroll feature
|
# used for the infinite scroll feature
|
||||||
if @"scroll".len > 0:
|
if @"scroll".len > 0:
|
||||||
let replies = await getReplies(id, getCursor())
|
let replies = await getReplies(id, getCursor())
|
||||||
if replies.content.len == 0:
|
if replies.content.len == 0:
|
||||||
resp Http404, ""
|
resp Http204
|
||||||
resp $renderReplies(replies, prefs, getPath())
|
resp $renderReplies(replies, prefs, getPath())
|
||||||
|
|
||||||
let conv = await getTweet(id, getCursor())
|
let conv = await getTweet(id, getCursor())
|
||||||
if conv == nil:
|
|
||||||
echo "nil conv"
|
|
||||||
|
|
||||||
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
|
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
|
||||||
var error = "Tweet not found"
|
var error = "Tweet not found"
|
||||||
@@ -46,15 +44,19 @@ proc createStatusRouter*(cfg: Config) =
|
|||||||
desc = conv.tweet.text
|
desc = conv.tweet.text
|
||||||
|
|
||||||
var
|
var
|
||||||
images = conv.tweet.photos
|
images = conv.tweet.getPhotos.mapIt(it.url)
|
||||||
video = ""
|
video = ""
|
||||||
|
|
||||||
if conv.tweet.video.isSome():
|
let
|
||||||
images = @[get(conv.tweet.video).thumb]
|
firstMediaKind = if conv.tweet.media.len > 0: conv.tweet.media[0].kind
|
||||||
|
else: photoMedia
|
||||||
|
|
||||||
|
if firstMediaKind == videoMedia:
|
||||||
|
images = @[conv.tweet.media[0].getThumb]
|
||||||
video = getVideoEmbed(cfg, conv.tweet.id)
|
video = getVideoEmbed(cfg, conv.tweet.id)
|
||||||
elif conv.tweet.gif.isSome():
|
elif firstMediaKind == gifMedia:
|
||||||
images = @[get(conv.tweet.gif).thumb]
|
images = @[conv.tweet.media[0].getThumb]
|
||||||
video = getPicUrl(get(conv.tweet.gif).url)
|
video = getPicUrl(conv.tweet.media[0].gif.url)
|
||||||
elif conv.tweet.card.isSome():
|
elif conv.tweet.card.isSome():
|
||||||
let card = conv.tweet.card.get()
|
let card = conv.tweet.card.get()
|
||||||
if card.image.len > 0:
|
if card.image.len > 0:
|
||||||
@@ -66,6 +68,26 @@ proc createStatusRouter*(cfg: Config) =
|
|||||||
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
|
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
|
||||||
images=images, video=video)
|
images=images, video=video)
|
||||||
|
|
||||||
|
get "/@name/status/@id/history/?":
|
||||||
|
cond '.' notin @"name"
|
||||||
|
let id = @"id"
|
||||||
|
|
||||||
|
if id.len > 19 or id.any(c => not c.isDigit):
|
||||||
|
resp Http404, showError("Invalid tweet ID", cfg)
|
||||||
|
|
||||||
|
let edits = await getGraphEditHistory(id)
|
||||||
|
if edits.latest == nil or edits.latest.id == 0:
|
||||||
|
resp Http404, showError("Tweet history not found", cfg)
|
||||||
|
|
||||||
|
let
|
||||||
|
prefs = requestPrefs()
|
||||||
|
title = "History for " & pageTitle(edits.latest)
|
||||||
|
ogTitle = "Edit History for " & pageTitle(edits.latest.user)
|
||||||
|
desc = edits.latest.text
|
||||||
|
|
||||||
|
let html = renderEditHistory(edits, prefs, getPath())
|
||||||
|
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle)
|
||||||
|
|
||||||
get "/@name/@s/@id/@m/?@i?":
|
get "/@name/@s/@id/@m/?@i?":
|
||||||
cond @"s" in ["status", "statuses"]
|
cond @"s" in ["status", "statuses"]
|
||||||
cond @"m" in ["video", "photo"]
|
cond @"m" in ["video", "photo"]
|
||||||
|
|||||||
+66
-42
@@ -4,20 +4,28 @@ import jester, karax/vdom
|
|||||||
|
|
||||||
import router_utils
|
import router_utils
|
||||||
import ".."/[types, redis_cache, formatters, query, api]
|
import ".."/[types, redis_cache, formatters, query, api]
|
||||||
import ../views/[general, profile, timeline, status, search]
|
import ../views/[general, profile, timeline, status, search, about_account]
|
||||||
|
|
||||||
export vdom
|
export vdom
|
||||||
export uri, sequtils
|
export uri, sequtils
|
||||||
export router_utils
|
export router_utils
|
||||||
export redis_cache, formatters, query, api
|
export redis_cache, formatters, query, api
|
||||||
export profile, timeline, status
|
export profile, timeline, status, about_account
|
||||||
|
|
||||||
proc getQuery*(request: Request; tab, name: string): Query =
|
proc getQuery*(request: Request; tab, name: string; prefs: Prefs): Query =
|
||||||
|
let view = request.params.getOrDefault("view")
|
||||||
case tab
|
case tab
|
||||||
of "with_replies": getReplyQuery(name)
|
of "with_replies":
|
||||||
of "media": getMediaQuery(name)
|
result = getReplyQuery(name)
|
||||||
of "search": initQuery(params(request), name=name)
|
of "media":
|
||||||
else: Query(fromUser: @[name])
|
result = getMediaQuery(name)
|
||||||
|
result.view =
|
||||||
|
if view in ["timeline", "grid", "gallery"]: view
|
||||||
|
else: prefs.mediaView.toLowerAscii
|
||||||
|
of "search":
|
||||||
|
result = initQuery(params(request), name=name)
|
||||||
|
else:
|
||||||
|
result = Query(fromUser: @[name])
|
||||||
|
|
||||||
template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
|
template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
|
||||||
if cond:
|
if cond:
|
||||||
@@ -27,8 +35,7 @@ template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
|
|||||||
else:
|
else:
|
||||||
body
|
body
|
||||||
|
|
||||||
proc fetchProfile*(after: string; query: Query; skipRail=false;
|
proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile] {.async.} =
|
||||||
skipPinned=false): Future[Profile] {.async.} =
|
|
||||||
let
|
let
|
||||||
name = query.fromUser[0]
|
name = query.fromUser[0]
|
||||||
userId = await getUserId(name)
|
userId = await getUserId(name)
|
||||||
@@ -45,37 +52,23 @@ proc fetchProfile*(after: string; query: Query; skipRail=false;
|
|||||||
after.setLen 0
|
after.setLen 0
|
||||||
|
|
||||||
let
|
let
|
||||||
timeline =
|
|
||||||
case query.kind
|
|
||||||
of posts: getGraphUserTweets(userId, TimelineKind.tweets, after)
|
|
||||||
of replies: getGraphUserTweets(userId, TimelineKind.replies, after)
|
|
||||||
of media: getGraphUserTweets(userId, TimelineKind.media, after)
|
|
||||||
else: getGraphSearch(query, after)
|
|
||||||
|
|
||||||
rail =
|
rail =
|
||||||
skipIf(skipRail or query.kind == media, @[]):
|
skipIf(skipRail or query.kind == media, @[]):
|
||||||
getCachedPhotoRail(name)
|
getCachedPhotoRail(userId)
|
||||||
|
|
||||||
user = await getCachedUser(name)
|
user = getCachedUser(name)
|
||||||
|
info = getCachedAccountInfo(name, fetch=false)
|
||||||
|
|
||||||
var pinned: Option[Tweet]
|
result =
|
||||||
if not skipPinned and user.pinnedTweet > 0 and
|
case query.kind
|
||||||
after.len == 0 and query.kind in {posts, replies}:
|
of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after)
|
||||||
let tweet = await getCachedTweet(user.pinnedTweet)
|
of replies: await getGraphUserTweets(userId, TimelineKind.replies, after)
|
||||||
if not tweet.isNil:
|
of media: await getGraphUserTweets(userId, TimelineKind.media, after)
|
||||||
tweet.pinned = true
|
else: Profile(tweets: await getGraphTweetSearch(query, after))
|
||||||
tweet.user = user
|
|
||||||
pinned = some tweet
|
|
||||||
|
|
||||||
result = Profile(
|
result.user = await user
|
||||||
user: user,
|
result.photoRail = await rail
|
||||||
pinned: pinned,
|
result.accountInfo = await info
|
||||||
tweets: await timeline,
|
|
||||||
photoRail: await rail
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.user.protected or result.user.suspended:
|
|
||||||
return
|
|
||||||
|
|
||||||
result.tweets.query = query
|
result.tweets.query = query
|
||||||
|
|
||||||
@@ -83,11 +76,11 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
|
|||||||
rss, after: string): Future[string] {.async.} =
|
rss, after: string): Future[string] {.async.} =
|
||||||
if query.fromUser.len != 1:
|
if query.fromUser.len != 1:
|
||||||
let
|
let
|
||||||
timeline = await getGraphSearch(query, after)
|
timeline = await getGraphTweetSearch(query, after)
|
||||||
html = renderTweetSearch(timeline, prefs, getPath())
|
html = renderTweetSearch(timeline, prefs, getPath())
|
||||||
return renderMain(html, request, cfg, prefs, "Multi", rss=rss)
|
return renderMain(html, request, cfg, prefs, "Multi", rss=rss)
|
||||||
|
|
||||||
var profile = await fetchProfile(after, query, skipPinned=prefs.hidePins)
|
var profile = await fetchProfile(after, query)
|
||||||
template u: untyped = profile.user
|
template u: untyped = profile.user
|
||||||
|
|
||||||
if u.suspended:
|
if u.suspended:
|
||||||
@@ -122,24 +115,46 @@ proc createTimelineRouter*(cfg: Config) =
|
|||||||
get "/intent/user":
|
get "/intent/user":
|
||||||
respUserId()
|
respUserId()
|
||||||
|
|
||||||
|
get "/intent/follow/?":
|
||||||
|
let username = request.params.getOrDefault("screen_name")
|
||||||
|
if username.len == 0:
|
||||||
|
resp Http400, showError("Missing screen_name parameter", cfg)
|
||||||
|
redirect("/" & username)
|
||||||
|
|
||||||
|
get "/@name/about/?":
|
||||||
|
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'})
|
||||||
|
let
|
||||||
|
prefs = requestPrefs()
|
||||||
|
name = @"name"
|
||||||
|
info = await getCachedAccountInfo(name)
|
||||||
|
if info.suspended:
|
||||||
|
resp showError(getSuspended(name), cfg)
|
||||||
|
if info.username.len == 0:
|
||||||
|
resp Http404, showError("User \"" & name & "\" not found", cfg)
|
||||||
|
let aboutHtml = renderAboutAccount(info)
|
||||||
|
resp renderMain(aboutHtml, request, cfg, prefs,
|
||||||
|
"About @" & info.username)
|
||||||
|
|
||||||
get "/@name/?@tab?/?":
|
get "/@name/?@tab?/?":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
|
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
|
||||||
|
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','})
|
||||||
cond @"tab" in ["with_replies", "media", "search", ""]
|
cond @"tab" in ["with_replies", "media", "search", ""]
|
||||||
let
|
let
|
||||||
prefs = cookiePrefs()
|
prefs = requestPrefs()
|
||||||
after = getCursor()
|
after = getCursor()
|
||||||
names = getNames(@"name")
|
names = getNames(@"name")
|
||||||
|
|
||||||
var query = request.getQuery(@"tab", @"name")
|
var query = request.getQuery(@"tab", @"name", prefs)
|
||||||
if names.len != 1:
|
if names.len != 1:
|
||||||
query.fromUser = names
|
query.fromUser = names
|
||||||
|
|
||||||
# used for the infinite scroll feature
|
# used for the infinite scroll feature
|
||||||
if @"scroll".len > 0:
|
if @"scroll".len > 0:
|
||||||
if query.fromUser.len != 1:
|
if query.fromUser.len != 1:
|
||||||
var timeline = await getGraphSearch(query, after)
|
var timeline = await getGraphTweetSearch(query, after)
|
||||||
if timeline.content.len == 0: resp Http404
|
if timeline.content.len == 0:
|
||||||
|
resp Http204
|
||||||
timeline.beginning = true
|
timeline.beginning = true
|
||||||
resp $renderTweetSearch(timeline, prefs, getPath())
|
resp $renderTweetSearch(timeline, prefs, getPath())
|
||||||
else:
|
else:
|
||||||
@@ -148,8 +163,17 @@ proc createTimelineRouter*(cfg: Config) =
|
|||||||
profile.tweets.beginning = true
|
profile.tweets.beginning = true
|
||||||
resp $renderTimelineTweets(profile.tweets, prefs, getPath())
|
resp $renderTimelineTweets(profile.tweets, prefs, getPath())
|
||||||
|
|
||||||
|
let rssEnabled =
|
||||||
|
if @"tab".len == 0: cfg.enableRSSUserTweets
|
||||||
|
elif @"tab" == "with_replies": cfg.enableRSSUserReplies
|
||||||
|
elif @"tab" == "media": cfg.enableRSSUserMedia
|
||||||
|
elif @"tab" == "search": cfg.enableRSSSearch
|
||||||
|
else: false
|
||||||
|
|
||||||
let rss =
|
let rss =
|
||||||
if @"tab".len == 0:
|
if not rssEnabled:
|
||||||
|
""
|
||||||
|
elif @"tab".len == 0:
|
||||||
"/$1/rss" % @"name"
|
"/$1/rss" % @"name"
|
||||||
elif @"tab" == "search":
|
elif @"tab" == "search":
|
||||||
"/$1/search/rss?$2" % [@"name", genQueryUrl(query)]
|
"/$1/search/rss?$2" % [@"name", genQueryUrl(query)]
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ export feature
|
|||||||
proc createUnsupportedRouter*(cfg: Config) =
|
proc createUnsupportedRouter*(cfg: Config) =
|
||||||
router unsupported:
|
router unsupported:
|
||||||
template feature {.dirty.} =
|
template feature {.dirty.} =
|
||||||
resp renderMain(renderFeature(), request, cfg, themePrefs())
|
resp renderMain(renderFeature(), request, cfg, requestPrefs())
|
||||||
|
|
||||||
get "/about/feature": feature()
|
get "/about/feature": feature()
|
||||||
get "/login/?@i?": feature()
|
get "/login/?@i?": feature()
|
||||||
get "/@name/lists/?": feature()
|
get "/@name/lists/?": feature()
|
||||||
|
|
||||||
get "/intent/?@i?":
|
get "/intent/?@i?":
|
||||||
cond @"i" notin ["user"]
|
cond @"i" notin ["user", "follow"]
|
||||||
feature()
|
feature()
|
||||||
|
|
||||||
get "/i/@i?/?@j?":
|
get "/i/@i?/?@j?":
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
|
|
||||||
.panel-container {
|
.panel-container {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
|
padding: 0px 5px 1px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
|
|||||||
@@ -66,18 +66,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#search-panel-toggle:checked ~ .search-panel {
|
#search-panel-toggle:checked ~ .search-panel {
|
||||||
@if $rows == 6 {
|
max-height: 380px !important;
|
||||||
max-height: 200px !important;
|
|
||||||
}
|
|
||||||
@if $rows == 5 {
|
|
||||||
max-height: 300px !important;
|
|
||||||
}
|
|
||||||
@if $rows == 4 {
|
|
||||||
max-height: 300px !important;
|
|
||||||
}
|
|
||||||
@if $rows == 3 {
|
|
||||||
max-height: 365px !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,43 @@
|
|||||||
// colors
|
// colors
|
||||||
$bg_color: #0F0F0F;
|
$bg_color: #0f0f0f;
|
||||||
$fg_color: #F8F8F2;
|
$fg_color: #f8f8f2;
|
||||||
$fg_faded: #F8F8F2CF;
|
$fg_faded: #f8f8f2cf;
|
||||||
$fg_dark: #FF6C60;
|
$fg_dark: #ff6c60;
|
||||||
$fg_nav: #FF6C60;
|
$fg_nav: #ff6c60;
|
||||||
|
|
||||||
$bg_panel: #161616;
|
$bg_panel: #161616;
|
||||||
$bg_elements: #121212;
|
$bg_elements: #121212;
|
||||||
$bg_overlays: #1F1F1F;
|
$bg_overlays: #1f1f1f;
|
||||||
$bg_hover: #1A1A1A;
|
$bg_hover: #1a1a1a;
|
||||||
|
|
||||||
$grey: #888889;
|
$grey: #888889;
|
||||||
$dark_grey: #404040;
|
$dark_grey: #404040;
|
||||||
$darker_grey: #282828;
|
$darker_grey: #282828;
|
||||||
$darkest_grey: #222222;
|
$darkest_grey: #222222;
|
||||||
$border_grey: #3E3E35;
|
$border_grey: #3e3e35;
|
||||||
|
|
||||||
$accent: #FF6C60;
|
$accent: #ff6c60;
|
||||||
$accent_light: #FFACA0;
|
$accent_light: #ffaca0;
|
||||||
$accent_dark: #8A3731;
|
$accent_dark: #8a3731;
|
||||||
$accent_border: #FF6C6091;
|
$accent_border: #ff6c6091;
|
||||||
|
|
||||||
$play_button: #D8574D;
|
$play_button: #d8574d;
|
||||||
$play_button_hover: #FF6C60;
|
$play_button_hover: #ff6c60;
|
||||||
|
|
||||||
$more_replies_dots: #AD433B;
|
$more_replies_dots: #ad433b;
|
||||||
$error_red: #420A05;
|
$error_red: #420a05;
|
||||||
|
|
||||||
$verified_blue: #1DA1F2;
|
$verified_blue: #1da1f2;
|
||||||
|
$verified_business: #fac82b;
|
||||||
|
$verified_government: #c1b6a4;
|
||||||
$icon_text: $fg_color;
|
$icon_text: $fg_color;
|
||||||
|
|
||||||
$tab: $fg_color;
|
$tab: $fg_color;
|
||||||
$tab_selected: $accent;
|
$tab_selected: $accent;
|
||||||
|
|
||||||
$shadow: rgba(0,0,0,.6);
|
$shadow: rgba(0, 0, 0, 0.6);
|
||||||
$shadow_dark: rgba(0,0,0,.2);
|
$shadow_dark: rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
//fonts
|
//fonts
|
||||||
$font_0: Helvetica Neue;
|
$font_0: sans-serif;
|
||||||
$font_1: Helvetica;
|
$font_1: fontello;
|
||||||
$font_2: Arial;
|
|
||||||
$font_3: sans-serif;
|
|
||||||
$font_4: fontello;
|
|
||||||
|
|||||||
+78
-26
@@ -1,12 +1,13 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
|
|
||||||
@import 'tweet/_base';
|
@import "tweet/_base";
|
||||||
@import 'profile/_base';
|
@import "profile/_base";
|
||||||
@import 'general';
|
@import "general";
|
||||||
@import 'navbar';
|
@import "navbar";
|
||||||
@import 'inputs';
|
@import "inputs";
|
||||||
@import 'timeline';
|
@import "timeline";
|
||||||
@import 'search';
|
@import "search";
|
||||||
|
@import "broadcast";
|
||||||
|
|
||||||
body {
|
body {
|
||||||
// colors
|
// colors
|
||||||
@@ -39,6 +40,8 @@ body {
|
|||||||
--error_red: #{$error_red};
|
--error_red: #{$error_red};
|
||||||
|
|
||||||
--verified_blue: #{$verified_blue};
|
--verified_blue: #{$verified_blue};
|
||||||
|
--verified_business: #{$verified_business};
|
||||||
|
--verified_government: #{$verified_government};
|
||||||
--icon_text: #{$icon_text};
|
--icon_text: #{$icon_text};
|
||||||
|
|
||||||
--tab: #{$fg_color};
|
--tab: #{$fg_color};
|
||||||
@@ -48,8 +51,8 @@ body {
|
|||||||
|
|
||||||
background-color: var(--bg_color);
|
background-color: var(--bg_color);
|
||||||
color: var(--fg_color);
|
color: var(--fg_color);
|
||||||
font-family: $font_0, $font_1, $font_2, $font_3;
|
font-family: $font_0, $font_1;
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -60,11 +63,16 @@ body {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
dynamic-range-limit: standard;
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2, h3 {
|
h2,
|
||||||
|
h3 {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +96,7 @@ fieldset {
|
|||||||
|
|
||||||
legend {
|
legend {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: .6em 0 .3em 0;
|
padding: 0.6em 0 0.3em 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -96,7 +104,8 @@ legend {
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preferences .note {
|
.preferences {
|
||||||
|
.note {
|
||||||
border-top: 1px solid var(--border_grey);
|
border-top: 1px solid var(--border_grey);
|
||||||
border-bottom: 1px solid var(--border_grey);
|
border-bottom: 1px solid var(--border_grey);
|
||||||
padding: 6px 0 8px 0;
|
padding: 6px 0 8px 0;
|
||||||
@@ -104,6 +113,12 @@ legend {
|
|||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bookmark-note {
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
padding-left: 1.3em;
|
padding-left: 1.3em;
|
||||||
}
|
}
|
||||||
@@ -112,11 +127,14 @@ ul {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding-top: 50px;
|
|
||||||
margin: auto;
|
margin: auto;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.fixed-nav .container {
|
||||||
|
padding-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
.icon-container {
|
.icon-container {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
@@ -140,18 +158,51 @@ ul {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.verified-icon {
|
.verified-icon {
|
||||||
color: var(--icon_text);
|
|
||||||
background-color: var(--verified_blue);
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin: 2px 0 3px 3px;
|
|
||||||
padding-top: 2px;
|
|
||||||
height: 12px;
|
|
||||||
width: 14px;
|
|
||||||
font-size: 8px;
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
text-align: center;
|
width: 14px;
|
||||||
vertical-align: middle;
|
height: 14px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verified-icon-check {
|
||||||
|
color: var(--icon_text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.business {
|
||||||
|
.verified-icon-circle {
|
||||||
|
color: var(--verified_business);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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) {
|
@media (max-width: 600px) {
|
||||||
@@ -159,7 +210,8 @@ ul {
|
|||||||
max-width: 95vw;
|
max-width: 95vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item, .nav-item .icon-container {
|
.nav-item,
|
||||||
|
.nav-item .icon-container {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-6
@@ -1,5 +1,5 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@include input-colors;
|
@include input-colors;
|
||||||
@@ -14,6 +14,7 @@ button {
|
|||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="date"],
|
input[type="date"],
|
||||||
|
input[type="number"],
|
||||||
select {
|
select {
|
||||||
@include input-colors;
|
@include input-colors;
|
||||||
background-color: var(--bg_elements);
|
background-color: var(--bg_elements);
|
||||||
@@ -24,7 +25,12 @@ select {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"] {
|
input[type="number"] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"] {
|
||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +44,17 @@ input[type="date"]::-webkit-inner-spin-button {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"]::-webkit-inner-spin-button,
|
||||||
|
input[type="number"]::-webkit-outer-spin-button {
|
||||||
|
display: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
input[type="date"]::-webkit-clear-button {
|
input[type="date"]::-webkit-clear-button {
|
||||||
margin-left: 17px;
|
margin-left: 17px;
|
||||||
filter: grayscale(100%);
|
filter: grayscale(100%);
|
||||||
@@ -136,8 +153,8 @@ input::-webkit-datetime-edit-year-field:focus {
|
|||||||
left: 2px;
|
left: 2px;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-family: $font_4;
|
font-family: $font_1;
|
||||||
content: '\e803';
|
content: "\e811";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,9 +179,11 @@ input::-webkit-datetime-edit-year-field:focus {
|
|||||||
-moz-appearance: none;
|
-moz-appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
min-width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"] {
|
input[type="text"],
|
||||||
|
input[type="number"] {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
max-width: 140px;
|
max-width: 140px;
|
||||||
@@ -182,4 +201,16 @@ input::-webkit-datetime-edit-year-field:focus {
|
|||||||
.pref-reset {
|
.pref-reset {
|
||||||
float: left;
|
float: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prefs-code {
|
||||||
|
background-color: var(--bg_elements);
|
||||||
|
border: 1px solid var(--accent_border);
|
||||||
|
color: var(--fg_color);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
margin: 4px 0;
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-11
@@ -1,9 +1,8 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
position: fixed;
|
|
||||||
background-color: var(--bg_overlays);
|
background-color: var(--bg_overlays);
|
||||||
box-shadow: 0 0 4px $shadow;
|
box-shadow: 0 0 4px $shadow;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -12,9 +11,14 @@ nav {
|
|||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|
||||||
a, .icon-button button {
|
a,
|
||||||
|
.icon-button button {
|
||||||
color: var(--fg_nav);
|
color: var(--fg_nav);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.fixed-nav & {
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.inner-nav {
|
.inner-nav {
|
||||||
@@ -58,20 +62,17 @@ nav {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.right a {
|
&.right a:hover {
|
||||||
padding-left: 4px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--accent_light);
|
color: var(--accent_light);
|
||||||
text-decoration: unset;
|
text-decoration: unset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.lp {
|
.lp {
|
||||||
height: 14px;
|
height: 14px;
|
||||||
margin-top: 2px;
|
display: inline-block;
|
||||||
display: block;
|
position: relative;
|
||||||
|
top: 2px;
|
||||||
fill: var(--fg_nav);
|
fill: var(--fg_nav);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -79,10 +80,11 @@ nav {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-info:before {
|
.icon-info {
|
||||||
margin: 0 -3px;
|
margin: 0 -3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-cog {
|
.icon-cog {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
padding-left: 0 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
|
|
||||||
@import 'card';
|
@import "card";
|
||||||
@import 'photo-rail';
|
@import "about-account";
|
||||||
|
@import "photo-rail";
|
||||||
|
|
||||||
.profile-tabs {
|
.profile-tabs {
|
||||||
@include panel(auto, 900px);
|
@include panel(auto, 900px);
|
||||||
@@ -39,8 +40,12 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
max-width: 32%;
|
max-width: 32%;
|
||||||
|
top: 0;
|
||||||
|
|
||||||
|
body.fixed-nav & {
|
||||||
top: 50px;
|
top: 50px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.profile-result {
|
.profile-result {
|
||||||
min-height: 54px;
|
min-height: 54px;
|
||||||
@@ -54,6 +59,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-tabs.media-only {
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.timeline-container {
|
||||||
|
float: none;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: none;
|
||||||
|
padding: 0 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-container > .tab {
|
||||||
|
max-width: 900px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.profile-tabs {
|
.profile-tabs {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
@@ -68,6 +92,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-tabs.media-only {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
|
||||||
|
.timeline-container {
|
||||||
|
width: 100vw !important;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.profile-tab {
|
.profile-tab {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: unset;
|
max-width: unset;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-tabs-name {
|
.profile-card-tabs-name {
|
||||||
@include breakable;
|
flex-shrink: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-avatar {
|
.profile-card-avatar {
|
||||||
|
|||||||
+17
-17
@@ -1,5 +1,5 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
|
|
||||||
.search-title {
|
.search-title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
@@ -13,7 +13,10 @@
|
|||||||
|
|
||||||
button {
|
button {
|
||||||
margin: 0 2px 0 0;
|
margin: 0 2px 0 0;
|
||||||
|
padding: 0px 1px 1px 4px;
|
||||||
height: 23px;
|
height: 23px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pref-input {
|
.pref-input {
|
||||||
@@ -22,7 +25,8 @@
|
|||||||
height: 23px;
|
height: 23px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"] {
|
input[type="text"],
|
||||||
|
input[type="number"] {
|
||||||
height: calc(100% - 4px);
|
height: calc(100% - 4px);
|
||||||
width: calc(100% - 8px);
|
width: calc(100% - 8px);
|
||||||
}
|
}
|
||||||
@@ -32,7 +36,7 @@
|
|||||||
background-color: var(--bg_elements);
|
background-color: var(--bg_elements);
|
||||||
color: var(--fg_color);
|
color: var(--fg_color);
|
||||||
border: 1px solid var(--accent_border);
|
border: 1px solid var(--accent_border);
|
||||||
padding: 1px 6px 2px 6px;
|
padding: 1px 1px 2px 4px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
@@ -40,7 +44,7 @@
|
|||||||
@include input-colors;
|
@include input-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include create-toggle(search-panel, 200px);
|
@include create-toggle(search-panel, 380px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-panel {
|
.search-panel {
|
||||||
@@ -53,20 +57,17 @@
|
|||||||
font-weight: initial;
|
font-weight: initial;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
> div {
|
|
||||||
line-height: 1.7em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-container {
|
.checkbox-container {
|
||||||
display: inline;
|
display: inline;
|
||||||
padding-right: unset;
|
padding-right: unset;
|
||||||
margin-bottom: unset;
|
margin-bottom: 5px;
|
||||||
margin-left: 23px;
|
margin-left: 23px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
right: unset;
|
right: unset;
|
||||||
left: -22px;
|
left: -22px;
|
||||||
|
line-height: 1.6em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-container .checkbox:after {
|
.checkbox-container .checkbox:after {
|
||||||
@@ -102,19 +103,18 @@
|
|||||||
.search-toggles {
|
.search-toggles {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, auto);
|
grid-template-columns: repeat(5, auto);
|
||||||
grid-column-gap: 10px;
|
grid-column-gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-tabs {
|
.profile-tabs {
|
||||||
@include search-resize(820px, 5);
|
@include search-resize(820px, 5);
|
||||||
@include search-resize(725px, 4);
|
@include search-resize(715px, 4);
|
||||||
@include search-resize(600px, 6);
|
@include search-resize(700px, 5);
|
||||||
@include search-resize(560px, 5);
|
@include search-resize(485px, 4);
|
||||||
@include search-resize(480px, 4);
|
|
||||||
@include search-resize(410px, 3);
|
@include search-resize(410px, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@include search-resize(560px, 5);
|
@include search-resize(700px, 5);
|
||||||
@include search-resize(480px, 4);
|
@include search-resize(485px, 4);
|
||||||
@include search-resize(410px, 3);
|
@include search-resize(410px, 3);
|
||||||
|
|||||||
+334
-11
@@ -1,16 +1,12 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
|
|
||||||
.timeline-container {
|
.timeline-container {
|
||||||
@include panel(100%, 600px);
|
@include panel(100%, 600px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline {
|
.timeline > div:not(:first-child) {
|
||||||
background-color: var(--bg_panel);
|
|
||||||
|
|
||||||
> div:not(:first-child) {
|
|
||||||
border-top: 1px solid var(--border_grey);
|
border-top: 1px solid var(--border_grey);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-header {
|
.timeline-header {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -19,7 +15,7 @@
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 4px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@@ -40,7 +36,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0 0 5px 0;
|
margin: 0 0 4px 0;
|
||||||
background-color: var(--bg_panel);
|
background-color: var(--bg_panel);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -51,7 +47,7 @@
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
border-bottom: .1rem solid transparent;
|
border-bottom: 0.1rem solid transparent;
|
||||||
color: var(--tab);
|
color: var(--tab);
|
||||||
display: block;
|
display: block;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
@@ -116,7 +112,7 @@
|
|||||||
.show-more {
|
.show-more {
|
||||||
background-color: var(--bg_panel);
|
background-color: var(--bg_panel);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: .75em 0;
|
padding: 0.75em 0;
|
||||||
display: block !important;
|
display: block !important;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -156,7 +152,334 @@
|
|||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
border-left-width: 0;
|
border-left-width: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: .75em;
|
padding: 0.75em;
|
||||||
display: flex;
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
background-color: var(--bg_panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline.media-grid-view,
|
||||||
|
.timeline.media-gallery-view {
|
||||||
|
> div:not(:first-child) {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline.media-grid-view,
|
||||||
|
.timeline.media-gallery-view .gallery-masonry.compact {
|
||||||
|
.tweet-header,
|
||||||
|
.replying-to,
|
||||||
|
.retweet-header,
|
||||||
|
.pinned,
|
||||||
|
.tweet-stats,
|
||||||
|
.attribution,
|
||||||
|
.poll,
|
||||||
|
.quote,
|
||||||
|
.community-note,
|
||||||
|
.media-tag-block,
|
||||||
|
.tweet-content,
|
||||||
|
.card-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
margin: unset;
|
||||||
|
|
||||||
|
.card-container {
|
||||||
|
border: unset;
|
||||||
|
border-radius: unset;
|
||||||
|
|
||||||
|
.card-image-container {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline.media-grid-view {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
|
||||||
|
> div:not(:first-child) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tweet-link {
|
||||||
|
z-index: 1000;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .show-more,
|
||||||
|
> .top-ref,
|
||||||
|
> .timeline-footer,
|
||||||
|
> .timeline-header {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tweet-body {
|
||||||
|
height: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-row + .gallery-row {
|
||||||
|
margin-top: 0.25em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments {
|
||||||
|
background-color: var(--darkest_grey);
|
||||||
|
border-radius: 0;
|
||||||
|
margin: 0;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments,
|
||||||
|
.gallery-row,
|
||||||
|
.still-image {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.still-image img,
|
||||||
|
.attachment > video,
|
||||||
|
.attachment > img {
|
||||||
|
object-fit: cover;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-video {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-gif {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alt-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline.media-gallery-view {
|
||||||
|
.gallery-masonry {
|
||||||
|
margin: 10px 0;
|
||||||
|
column-gap: 10px;
|
||||||
|
column-width: unquote("clamp(190px, 22vw, 350px)");
|
||||||
|
|
||||||
|
&[data-col-size="small"] {
|
||||||
|
column-width: unquote("max(130px, 11vw)");
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-col-size="large"] {
|
||||||
|
column-width: unquote("clamp(350px, 22vw, 480px)");
|
||||||
|
}
|
||||||
|
|
||||||
|
&.masonry-active {
|
||||||
|
column-width: unset;
|
||||||
|
column-gap: unset;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
animation: none;
|
||||||
|
position: absolute;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.compact {
|
||||||
|
.tweet-body {
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
> .attachments {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-image-container img {
|
||||||
|
max-height: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes masonry-init {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start hidden. CSS animation reveals after a delay as a no-JS fallback.
|
||||||
|
// With JS, masonry-active cancels the animation and masonry-visible reveals.
|
||||||
|
.gallery-masonry .timeline-item,
|
||||||
|
> .show-more,
|
||||||
|
> .top-ref,
|
||||||
|
> .timeline-footer {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: masonry-init 0.2s 0.3s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-masonry.masonry-active .timeline-item.masonry-visible,
|
||||||
|
> .show-more.masonry-visible,
|
||||||
|
> .top-ref.masonry-visible,
|
||||||
|
> .timeline-footer.masonry-visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
break-inside: avoid;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .show-more,
|
||||||
|
> .top-ref,
|
||||||
|
> .timeline-footer,
|
||||||
|
> .timeline-header {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .show-more {
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
background-color: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tweet-content {
|
||||||
|
margin: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tweet-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
> .attachments {
|
||||||
|
align-self: stretch;
|
||||||
|
border-radius: 0;
|
||||||
|
margin: -10px -10px 10px;
|
||||||
|
max-height: none;
|
||||||
|
order: -1;
|
||||||
|
width: auto;
|
||||||
|
background-color: var(--bg_elements);
|
||||||
|
|
||||||
|
.gallery-row {
|
||||||
|
max-height: none;
|
||||||
|
max-width: none;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.still-image img,
|
||||||
|
.attachment > video,
|
||||||
|
.attachment > img {
|
||||||
|
max-height: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment:last-child {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-container {
|
||||||
|
border: unset;
|
||||||
|
border-radius: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tweet-stat {
|
||||||
|
padding-top: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replying-to {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tweet-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75em;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
.tweet-avatar {
|
||||||
|
img {
|
||||||
|
float: none;
|
||||||
|
height: 42px;
|
||||||
|
margin: 0;
|
||||||
|
width: 42px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tweet-name-row {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullname-and-username {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullname {
|
||||||
|
max-width: calc(100% - 18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verified-icon {
|
||||||
|
margin-left: 4px;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
display: block;
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.timeline.media-gallery-view {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-17
@@ -1,12 +1,12 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
@import 'thread';
|
@import "thread";
|
||||||
@import 'media';
|
@import "media";
|
||||||
@import 'video';
|
@import "video";
|
||||||
@import 'embed';
|
@import "embed";
|
||||||
@import 'card';
|
@import "card";
|
||||||
@import 'poll';
|
@import "poll";
|
||||||
@import 'quote';
|
@import "quote";
|
||||||
|
|
||||||
.tweet-body {
|
.tweet-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -17,7 +17,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tweet-content {
|
.tweet-content {
|
||||||
font-family: $font_3;
|
|
||||||
line-height: 1.3em;
|
line-height: 1.3em;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
display: inline;
|
display: inline;
|
||||||
@@ -31,7 +30,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
vertical-align: bottom;
|
vertical-align: bottom;
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
margin-bottom: .2em;
|
margin-bottom: 0.2em;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -45,6 +44,10 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.verified-icon {
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullname-and-username {
|
.fullname-and-username {
|
||||||
@@ -64,7 +67,7 @@
|
|||||||
.username {
|
.username {
|
||||||
@include ellipsis;
|
@include ellipsis;
|
||||||
min-width: 1.6em;
|
min-width: 1.6em;
|
||||||
margin-left: .4em;
|
margin-left: 0.4em;
|
||||||
word-wrap: normal;
|
word-wrap: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,13 +77,15 @@
|
|||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-date a, .username, .show-more a {
|
.tweet-date a,
|
||||||
|
.username,
|
||||||
|
.show-more a {
|
||||||
color: var(--fg_dark);
|
color: var(--fg_dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-published {
|
.tweet-published {
|
||||||
margin: 0;
|
margin-top: 6px;
|
||||||
margin-top: 5px;
|
margin-bottom: 0px;
|
||||||
color: var(--grey);
|
color: var(--grey);
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
@@ -100,6 +105,7 @@
|
|||||||
.avatar {
|
.avatar {
|
||||||
&.round {
|
&.round {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +163,8 @@
|
|||||||
padding-right: 2px;
|
padding-right: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-tag, .icon-container {
|
.media-tag,
|
||||||
|
.icon-container {
|
||||||
color: var(--fg_faded);
|
color: var(--fg_faded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +186,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.retweet-header, .pinned, .tweet-stats {
|
.retweet-header,
|
||||||
|
.pinned,
|
||||||
|
.tweet-stats {
|
||||||
align-content: center;
|
align-content: center;
|
||||||
color: var(--grey);
|
color: var(--grey);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -200,6 +209,7 @@
|
|||||||
|
|
||||||
.tweet-stats {
|
.tweet-stats {
|
||||||
margin-bottom: -3px;
|
margin-bottom: -3px;
|
||||||
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,9 +242,70 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--bg_hover);
|
background-color: var(--bg_hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.latest-post-version {
|
||||||
|
border-bottom: 1px solid var(--dark_grey);
|
||||||
|
border-top: 1px solid var(--dark_grey);
|
||||||
|
padding: 01ch 0px;
|
||||||
|
margin: 1ch 0px;
|
||||||
|
color: var(--grey);
|
||||||
|
|
||||||
|
a {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.community-note {
|
||||||
|
background-color: var(--bg_elements);
|
||||||
|
margin-top: 10px;
|
||||||
|
border: solid 1px var(--dark_grey);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: all;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg_panel);
|
||||||
|
border-color: var(--grey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.community-note-header {
|
||||||
|
background-color: var(--bg_hover);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 8px 10px;
|
||||||
|
padding-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
.icon-container {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.community-note-text {
|
||||||
|
white-space: pre-line;
|
||||||
|
padding: 10px 10px;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclosures {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color: var(--grey);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
|
||||||
|
.icon-attention {
|
||||||
|
margin-right: -3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
@@ -8,10 +8,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-container {
|
.card-container {
|
||||||
|
border: solid 1px var(--dark_grey);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border-width: 1px;
|
|
||||||
border-style: solid;
|
|
||||||
border-color: var(--dark_grey);
|
|
||||||
background-color: var(--bg_elements);
|
background-color: var(--bg_elements);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -42,6 +40,7 @@
|
|||||||
|
|
||||||
.card-description {
|
.card-description {
|
||||||
margin: 0.3em 0;
|
margin: 0.3em 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-destination {
|
.card-destination {
|
||||||
@@ -53,6 +52,7 @@
|
|||||||
.card-content-container {
|
.card-content-container {
|
||||||
color: unset;
|
color: unset;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
@@ -63,6 +63,7 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&:before {
|
&:before {
|
||||||
content: "";
|
content: "";
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
|
|
||||||
.embed-video {
|
.embed-video {
|
||||||
.gallery-video {
|
.gallery-video {
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
left: 0%;
|
left: 0%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-container {
|
.gallery-video > .attachment {
|
||||||
max-height: unset;
|
max-height: unset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-31
@@ -1,24 +1,61 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
|
|
||||||
.gallery-row {
|
.gallery-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
max-height: 379.5px;
|
max-height: 379.5px;
|
||||||
max-width: 533px;
|
max-width: 533px;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
|
|
||||||
.still-image {
|
&.mixed-row {
|
||||||
width: 100%;
|
.attachment {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 0;
|
||||||
|
max-height: 379.5px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: #101010;
|
||||||
|
}
|
||||||
|
|
||||||
|
.still-image,
|
||||||
|
.still-image img,
|
||||||
|
.attachment > video,
|
||||||
|
.attachment > img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.still-image {
|
||||||
|
display: flex;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.still-image img {
|
||||||
|
flex-basis: auto;
|
||||||
|
flex-grow: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment > video,
|
||||||
|
.attachment > img {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment > video {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachments {
|
.attachments {
|
||||||
margin-top: .35em;
|
margin-top: 0.35em;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -29,17 +66,13 @@
|
|||||||
background-color: var(--bg_color);
|
background-color: var(--bg_color);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
|
|
||||||
.image-attachment {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment {
|
.attachment {
|
||||||
position: relative;
|
position: relative;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0 .25em 0 0;
|
margin: 0 0.25em 0 0;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-width: 2em;
|
min-width: 2em;
|
||||||
@@ -50,7 +83,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-gif video {
|
.media-gif {
|
||||||
|
display: table;
|
||||||
|
background-color: unset;
|
||||||
|
width: unset;
|
||||||
|
max-height: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-gif video {
|
||||||
max-height: 530px;
|
max-height: 530px;
|
||||||
background-color: #101010;
|
background-color: #101010;
|
||||||
}
|
}
|
||||||
@@ -58,7 +98,6 @@
|
|||||||
.still-image {
|
.still-image {
|
||||||
max-height: 379.5px;
|
max-height: 379.5px;
|
||||||
max-width: 533px;
|
max-width: 533px;
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
img {
|
img {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
@@ -69,21 +108,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.image {
|
.alt-text {
|
||||||
display: inline-block;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// .single-image {
|
.alt-text:hover {
|
||||||
// display: inline-block;
|
padding: 7px;
|
||||||
// width: 100%;
|
width: Min(230px, calc(100% - 10px * 2));
|
||||||
// max-height: 600px;
|
max-height: calc(100% - 10px);
|
||||||
|
line-height: 1.2em;
|
||||||
// .attachments {
|
white-space: pre-wrap;
|
||||||
// width: unset;
|
transition-duration: 0.4s;
|
||||||
// max-height: unset;
|
transition-property: max-height;
|
||||||
// display: inherit;
|
}
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
.overlay-circle {
|
.overlay-circle {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -106,12 +158,6 @@
|
|||||||
margin-left: 14px;
|
margin-left: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-gif {
|
|
||||||
display: table;
|
|
||||||
background-color: unset;
|
|
||||||
width: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-body {
|
.media-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
|
|
||||||
.poll-meter {
|
.poll-meter {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
|
|
||||||
.quote {
|
.quote {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
@@ -19,30 +19,54 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tweet-name-row {
|
.tweet-name-row {
|
||||||
padding: 6px 8px;
|
padding: 8px 10px 6px 10px;
|
||||||
margin-top: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.quote-text {
|
.quote-text {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
padding: 0px 8px 8px 8px;
|
padding: 10px;
|
||||||
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.show-thread {
|
.show-thread {
|
||||||
padding: 0px 8px 6px 8px;
|
padding: 0px 10px 6px 10px;
|
||||||
margin-top: -6px;
|
margin-top: -6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quote-latest {
|
||||||
|
padding: 0px 10px 6px 10px;
|
||||||
|
color: var(--grey);
|
||||||
|
}
|
||||||
|
|
||||||
.replying-to {
|
.replying-to {
|
||||||
padding: 0px 8px;
|
padding: 0px 10px;
|
||||||
|
padding-bottom: 4px;
|
||||||
margin: unset;
|
margin: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.community-note {
|
||||||
|
background-color: var(--bg_panel);
|
||||||
|
border: unset;
|
||||||
|
border-top: solid 1px var(--dark_grey);
|
||||||
|
border-radius: unset;
|
||||||
|
margin-top: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-top-color: var(--grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
.community-note-header {
|
||||||
|
background-color: var(--bg_panel);
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.unavailable-quote {
|
.unavailable-quote {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quote-link {
|
.quote-link {
|
||||||
@@ -71,7 +95,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-gif .attachment {
|
.media-gif > .attachment {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background-color: var(--bg_color);
|
background-color: var(--bg_color);
|
||||||
@@ -84,11 +108,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-video, .gallery-gif {
|
.gallery-row .attachment,
|
||||||
|
.gallery-row .attachment > video,
|
||||||
|
.gallery-row .attachment > img {
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.still-image img {
|
.still-image img {
|
||||||
max-height: 250px
|
max-height: 250px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-15
@@ -1,7 +1,8 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
|
|
||||||
.conversation {
|
.conversation,
|
||||||
|
.edit-history {
|
||||||
@include panel(100%, 600px);
|
@include panel(100%, 600px);
|
||||||
|
|
||||||
.show-more {
|
.show-more {
|
||||||
@@ -9,14 +10,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-thread {
|
.main-thread,
|
||||||
|
.latest-edit {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-tweet,
|
||||||
|
.replies,
|
||||||
|
.edit-history > div {
|
||||||
|
body.fixed-nav & {
|
||||||
|
padding-top: 50px;
|
||||||
|
margin-top: -50px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-header {
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
background-color: var(--bg_panel);
|
background-color: var(--bg_panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-tweet, .replies {
|
.tweet-edit {
|
||||||
padding-top: 50px;
|
margin-bottom: 5px;
|
||||||
margin-top: -50px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-tweet .tweet-content {
|
.main-tweet .tweet-content {
|
||||||
@@ -29,16 +50,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.reply {
|
|
||||||
background-color: var(--bg_panel);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thread-line {
|
.thread-line {
|
||||||
.timeline-item::before,
|
.timeline-item::before,
|
||||||
&.timeline-item::before {
|
&.timeline-item::before {
|
||||||
background: var(--accent_dark);
|
background: var(--accent_dark);
|
||||||
content: '';
|
content: "";
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 3px;
|
min-width: 3px;
|
||||||
width: 3px;
|
width: 3px;
|
||||||
@@ -53,7 +69,7 @@
|
|||||||
|
|
||||||
.with-header:not(:first-child)::after {
|
.with-header:not(:first-child)::after {
|
||||||
background: var(--accent_dark);
|
background: var(--accent_dark);
|
||||||
content: '';
|
content: "";
|
||||||
position: relative;
|
position: relative;
|
||||||
float: left;
|
float: left;
|
||||||
min-width: 3px;
|
min-width: 3px;
|
||||||
@@ -74,7 +90,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.more-replies::before {
|
.more-replies::before {
|
||||||
content: '...';
|
content: "...";
|
||||||
background: unset;
|
background: unset;
|
||||||
color: var(--more_replies_dots);
|
color: var(--more_replies_dots);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
@@ -110,3 +126,29 @@
|
|||||||
margin-left: 58px;
|
margin-left: 58px;
|
||||||
padding: 7px 0;
|
padding: 7px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeline-item.thread.more-replies-thread {
|
||||||
|
padding: 0 0.75em;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
top: 40px;
|
||||||
|
margin-bottom: 31px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+21
-10
@@ -1,32 +1,32 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
|
|
||||||
video {
|
video {
|
||||||
max-height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-video {
|
.gallery-video {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
|
||||||
|
|
||||||
.gallery-video.card-container {
|
&.card-container {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-container {
|
> .attachment {
|
||||||
|
min-height: 80px;
|
||||||
|
min-width: 200px;
|
||||||
max-height: 530px;
|
max-height: 530px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
img {
|
img {
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.video-overlay {
|
.video-overlay {
|
||||||
@include play-button;
|
@include play-button;
|
||||||
@@ -42,7 +42,7 @@ video {
|
|||||||
margin: 0 20px;
|
margin: 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
div {
|
.overlay-circle {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
top: calc(50% - 20px);
|
top: calc(50% - 20px);
|
||||||
@@ -51,6 +51,17 @@ video {
|
|||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
import std/[asyncdispatch, base64, httpclient, random, strutils, sequtils, times]
|
||||||
|
import nimcrypto
|
||||||
|
import experimental/parser/tid
|
||||||
|
|
||||||
|
randomize()
|
||||||
|
|
||||||
|
const defaultKeyword = "obfiowerehiring";
|
||||||
|
const pairsUrl =
|
||||||
|
"https://raw.githubusercontent.com/fa0311/x-client-transaction-id-pair-dict/refs/heads/main/pair.json";
|
||||||
|
|
||||||
|
var
|
||||||
|
cachedPairs: seq[TidPair] = @[]
|
||||||
|
lastCached = 0
|
||||||
|
# refresh every hour
|
||||||
|
ttlSec = 60 * 60
|
||||||
|
|
||||||
|
proc getPair(): Future[TidPair] {.async.} =
|
||||||
|
if cachedPairs.len == 0 or int(epochTime()) - lastCached > ttlSec:
|
||||||
|
lastCached = int(epochTime())
|
||||||
|
|
||||||
|
let client = newAsyncHttpClient()
|
||||||
|
defer: client.close()
|
||||||
|
|
||||||
|
let resp = await client.get(pairsUrl)
|
||||||
|
if resp.status == $Http200:
|
||||||
|
cachedPairs = parseTidPairs(await resp.body)
|
||||||
|
|
||||||
|
return sample(cachedPairs)
|
||||||
|
|
||||||
|
proc encodeSha256(text: string): array[32, byte] =
|
||||||
|
let
|
||||||
|
data = cast[ptr byte](addr text[0])
|
||||||
|
dataLen = uint(len(text))
|
||||||
|
digest = sha256.digest(data, dataLen)
|
||||||
|
return digest.data
|
||||||
|
|
||||||
|
proc encodeBase64[T](data: T): string =
|
||||||
|
return encode(data).replace("=", "")
|
||||||
|
|
||||||
|
proc decodeBase64(data: string): seq[byte] =
|
||||||
|
return cast[seq[byte]](decode(data))
|
||||||
|
|
||||||
|
proc genTid*(path: string): Future[string] {.async.} =
|
||||||
|
let
|
||||||
|
pair = await getPair()
|
||||||
|
|
||||||
|
timeNow = int(epochTime() - 1682924400)
|
||||||
|
timeNowBytes = @[
|
||||||
|
byte(timeNow and 0xff),
|
||||||
|
byte((timeNow shr 8) and 0xff),
|
||||||
|
byte((timeNow shr 16) and 0xff),
|
||||||
|
byte((timeNow shr 24) and 0xff)
|
||||||
|
]
|
||||||
|
|
||||||
|
data = "GET!" & path & "!" & $timeNow & defaultKeyword & pair.animationKey
|
||||||
|
hashBytes = encodeSha256(data)
|
||||||
|
keyBytes = decodeBase64(pair.verification)
|
||||||
|
bytesArr = keyBytes & timeNowBytes & hashBytes[0 ..< 16] & @[3'u8]
|
||||||
|
randomNum = byte(rand(256))
|
||||||
|
tid = @[randomNum] & bytesArr.mapIt(it xor randomNum)
|
||||||
|
|
||||||
|
return encodeBase64(tid)
|
||||||
-164
@@ -1,164 +0,0 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
import asyncdispatch, httpclient, times, sequtils, json, random
|
|
||||||
import strutils, tables
|
|
||||||
import types, consts
|
|
||||||
|
|
||||||
const
|
|
||||||
maxConcurrentReqs = 5 # max requests at a time per token, to avoid race conditions
|
|
||||||
maxLastUse = 1.hours # if a token is unused for 60 minutes, it expires
|
|
||||||
maxAge = 2.hours + 55.minutes # tokens expire after 3 hours
|
|
||||||
failDelay = initDuration(minutes=30)
|
|
||||||
|
|
||||||
var
|
|
||||||
tokenPool: seq[Token]
|
|
||||||
lastFailed: Time
|
|
||||||
enableLogging = false
|
|
||||||
|
|
||||||
let headers = newHttpHeaders({"authorization": auth})
|
|
||||||
|
|
||||||
template log(str) =
|
|
||||||
if enableLogging: echo "[tokens] ", str
|
|
||||||
|
|
||||||
proc getPoolJson*(): JsonNode =
|
|
||||||
var
|
|
||||||
list = newJObject()
|
|
||||||
totalReqs = 0
|
|
||||||
totalPending = 0
|
|
||||||
reqsPerApi: Table[string, int]
|
|
||||||
|
|
||||||
for token in tokenPool:
|
|
||||||
totalPending.inc(token.pending)
|
|
||||||
list[token.tok] = %*{
|
|
||||||
"apis": newJObject(),
|
|
||||||
"pending": token.pending,
|
|
||||||
"init": $token.init,
|
|
||||||
"lastUse": $token.lastUse
|
|
||||||
}
|
|
||||||
|
|
||||||
for api in token.apis.keys:
|
|
||||||
list[token.tok]["apis"][$api] = %token.apis[api]
|
|
||||||
|
|
||||||
let
|
|
||||||
maxReqs =
|
|
||||||
case api
|
|
||||||
of Api.timeline: 187
|
|
||||||
of Api.listMembers, Api.listBySlug, Api.list, Api.listTweets,
|
|
||||||
Api.userTweets, Api.userTweetsAndReplies, Api.userMedia,
|
|
||||||
Api.userRestId, Api.userScreenName,
|
|
||||||
Api.tweetDetail, Api.tweetResult, Api.search: 500
|
|
||||||
of Api.userSearch: 900
|
|
||||||
reqs = maxReqs - token.apis[api].remaining
|
|
||||||
|
|
||||||
reqsPerApi[$api] = reqsPerApi.getOrDefault($api, 0) + reqs
|
|
||||||
totalReqs.inc(reqs)
|
|
||||||
|
|
||||||
return %*{
|
|
||||||
"amount": tokenPool.len,
|
|
||||||
"requests": totalReqs,
|
|
||||||
"pending": totalPending,
|
|
||||||
"apis": reqsPerApi,
|
|
||||||
"tokens": list
|
|
||||||
}
|
|
||||||
|
|
||||||
proc rateLimitError*(): ref RateLimitError =
|
|
||||||
newException(RateLimitError, "rate limited")
|
|
||||||
|
|
||||||
proc fetchToken(): Future[Token] {.async.} =
|
|
||||||
if getTime() - lastFailed < failDelay:
|
|
||||||
raise rateLimitError()
|
|
||||||
|
|
||||||
let client = newAsyncHttpClient(headers=headers)
|
|
||||||
|
|
||||||
try:
|
|
||||||
let
|
|
||||||
resp = await client.postContent(activate)
|
|
||||||
tokNode = parseJson(resp)["guest_token"]
|
|
||||||
tok = tokNode.getStr($(tokNode.getInt))
|
|
||||||
time = getTime()
|
|
||||||
|
|
||||||
return Token(tok: tok, init: time, lastUse: time)
|
|
||||||
except Exception as e:
|
|
||||||
echo "[tokens] fetching token failed: ", e.msg
|
|
||||||
if "Try again" notin e.msg:
|
|
||||||
echo "[tokens] fetching tokens paused, resuming in 30 minutes"
|
|
||||||
lastFailed = getTime()
|
|
||||||
finally:
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
proc expired(token: Token): bool =
|
|
||||||
let time = getTime()
|
|
||||||
token.init < time - maxAge or token.lastUse < time - maxLastUse
|
|
||||||
|
|
||||||
proc isLimited(token: Token; api: Api): bool =
|
|
||||||
if token.isNil or token.expired:
|
|
||||||
return true
|
|
||||||
|
|
||||||
if api in token.apis:
|
|
||||||
let limit = token.apis[api]
|
|
||||||
return (limit.remaining <= 10 and limit.reset > epochTime().int)
|
|
||||||
else:
|
|
||||||
return false
|
|
||||||
|
|
||||||
proc isReady(token: Token; api: Api): bool =
|
|
||||||
not (token.isNil or token.pending > maxConcurrentReqs or token.isLimited(api))
|
|
||||||
|
|
||||||
proc release*(token: Token; used=false; invalid=false) =
|
|
||||||
if token.isNil: return
|
|
||||||
if invalid or token.expired:
|
|
||||||
if invalid: log "discarding invalid token"
|
|
||||||
elif token.expired: log "discarding expired token"
|
|
||||||
|
|
||||||
let idx = tokenPool.find(token)
|
|
||||||
if idx > -1: tokenPool.delete(idx)
|
|
||||||
elif used:
|
|
||||||
dec token.pending
|
|
||||||
token.lastUse = getTime()
|
|
||||||
|
|
||||||
proc getToken*(api: Api): Future[Token] {.async.} =
|
|
||||||
for i in 0 ..< tokenPool.len:
|
|
||||||
if result.isReady(api): break
|
|
||||||
release(result)
|
|
||||||
result = tokenPool.sample()
|
|
||||||
|
|
||||||
if not result.isReady(api):
|
|
||||||
release(result)
|
|
||||||
result = await fetchToken()
|
|
||||||
log "added new token to pool"
|
|
||||||
tokenPool.add result
|
|
||||||
|
|
||||||
if not result.isNil:
|
|
||||||
inc result.pending
|
|
||||||
else:
|
|
||||||
raise rateLimitError()
|
|
||||||
|
|
||||||
proc setRateLimit*(token: Token; api: Api; remaining, reset: int) =
|
|
||||||
# avoid undefined behavior in race conditions
|
|
||||||
if api in token.apis:
|
|
||||||
let limit = token.apis[api]
|
|
||||||
if limit.reset >= reset and limit.remaining < remaining:
|
|
||||||
return
|
|
||||||
|
|
||||||
token.apis[api] = RateLimit(remaining: remaining, reset: reset)
|
|
||||||
|
|
||||||
proc poolTokens*(amount: int) {.async.} =
|
|
||||||
var futs: seq[Future[Token]]
|
|
||||||
for i in 0 ..< amount:
|
|
||||||
futs.add fetchToken()
|
|
||||||
|
|
||||||
for token in futs:
|
|
||||||
var newToken: Token
|
|
||||||
|
|
||||||
try: newToken = await token
|
|
||||||
except: discard
|
|
||||||
|
|
||||||
if not newToken.isNil:
|
|
||||||
log "added new token to pool"
|
|
||||||
tokenPool.add newToken
|
|
||||||
|
|
||||||
proc initTokenPool*(cfg: Config) {.async.} =
|
|
||||||
enableLogging = cfg.enableDebug
|
|
||||||
|
|
||||||
while true:
|
|
||||||
if tokenPool.countIt(not it.isLimited(Api.timeline)) < cfg.minTokens:
|
|
||||||
await poolTokens(min(4, cfg.minTokens - tokenPool.len))
|
|
||||||
await sleepAsync(2000)
|
|
||||||
+145
-34
@@ -6,62 +6,77 @@ genPrefsType()
|
|||||||
|
|
||||||
type
|
type
|
||||||
RateLimitError* = object of CatchableError
|
RateLimitError* = object of CatchableError
|
||||||
|
NoSessionsError* = object of CatchableError
|
||||||
InternalError* = object of CatchableError
|
InternalError* = object of CatchableError
|
||||||
BadClientError* = object of CatchableError
|
BadClientError* = object of CatchableError
|
||||||
|
|
||||||
TimelineKind* {.pure.} = enum
|
TimelineKind* {.pure.} = enum
|
||||||
tweets
|
tweets, replies, media
|
||||||
replies
|
|
||||||
media
|
|
||||||
|
|
||||||
Api* {.pure.} = enum
|
ApiUrl* = object
|
||||||
tweetDetail
|
endpoint*: string
|
||||||
tweetResult
|
params*: seq[(string, string)]
|
||||||
timeline
|
skipTid*: bool
|
||||||
search
|
|
||||||
userSearch
|
ApiReq* = object
|
||||||
list
|
oauth*: ApiUrl
|
||||||
listBySlug
|
cookie*: ApiUrl
|
||||||
listMembers
|
|
||||||
listTweets
|
|
||||||
userRestId
|
|
||||||
userScreenName
|
|
||||||
userTweets
|
|
||||||
userTweetsAndReplies
|
|
||||||
userMedia
|
|
||||||
|
|
||||||
RateLimit* = object
|
RateLimit* = object
|
||||||
|
limit*: int
|
||||||
remaining*: int
|
remaining*: int
|
||||||
reset*: int
|
reset*: int
|
||||||
|
|
||||||
Token* = ref object
|
SessionKind* = enum
|
||||||
tok*: string
|
oauth
|
||||||
init*: Time
|
cookie
|
||||||
lastUse*: Time
|
|
||||||
|
Session* = ref object
|
||||||
|
id*: int64
|
||||||
|
username*: string
|
||||||
pending*: int
|
pending*: int
|
||||||
apis*: Table[Api, RateLimit]
|
limited*: bool
|
||||||
|
limitedAt*: int
|
||||||
|
apis*: Table[string, RateLimit]
|
||||||
|
case kind*: SessionKind
|
||||||
|
of oauth:
|
||||||
|
oauthToken*: string
|
||||||
|
oauthSecret*: string
|
||||||
|
of cookie:
|
||||||
|
authToken*: string
|
||||||
|
ct0*: string
|
||||||
|
|
||||||
Error* = enum
|
Error* = enum
|
||||||
null = 0
|
null = 0
|
||||||
noUserMatches = 17
|
noUserMatches = 17
|
||||||
protectedUser = 22
|
protectedUser = 22
|
||||||
missingParams = 25
|
missingParams = 25
|
||||||
|
timeout = 29
|
||||||
couldntAuth = 32
|
couldntAuth = 32
|
||||||
doesntExist = 34
|
doesntExist = 34
|
||||||
|
unauthorized = 37
|
||||||
invalidParam = 47
|
invalidParam = 47
|
||||||
userNotFound = 50
|
userNotFound = 50
|
||||||
suspended = 63
|
suspended = 63
|
||||||
rateLimited = 88
|
rateLimited = 88
|
||||||
invalidToken = 89
|
expiredToken = 89
|
||||||
listIdOrSlug = 112
|
listIdOrSlug = 112
|
||||||
tweetNotFound = 144
|
tweetNotFound = 144
|
||||||
tweetNotAuthorized = 179
|
tweetNotAuthorized = 179
|
||||||
forbidden = 200
|
forbidden = 200
|
||||||
|
badRequest = 214
|
||||||
badToken = 239
|
badToken = 239
|
||||||
|
locked = 326
|
||||||
noCsrf = 353
|
noCsrf = 353
|
||||||
tweetUnavailable = 421
|
tweetUnavailable = 421
|
||||||
tweetCensored = 422
|
tweetCensored = 422
|
||||||
|
|
||||||
|
VerifiedType* = enum
|
||||||
|
none = "None"
|
||||||
|
blue = "Blue"
|
||||||
|
business = "Business"
|
||||||
|
government = "Government"
|
||||||
|
|
||||||
User* = object
|
User* = object
|
||||||
id*: string
|
id*: string
|
||||||
username*: string
|
username*: string
|
||||||
@@ -77,11 +92,42 @@ type
|
|||||||
tweets*: int
|
tweets*: int
|
||||||
likes*: int
|
likes*: int
|
||||||
media*: int
|
media*: int
|
||||||
verified*: bool
|
verifiedType*: VerifiedType
|
||||||
protected*: bool
|
protected*: bool
|
||||||
suspended*: bool
|
suspended*: bool
|
||||||
joinDate*: DateTime
|
joinDate*: DateTime
|
||||||
|
|
||||||
|
AccountInfo* = object
|
||||||
|
username*: string
|
||||||
|
fullname*: string
|
||||||
|
userPic*: string
|
||||||
|
joinDate*: DateTime
|
||||||
|
verifiedType*: VerifiedType
|
||||||
|
suspended*: bool
|
||||||
|
basedIn*: string
|
||||||
|
source*: string
|
||||||
|
usernameChanges*: int
|
||||||
|
lastUsernameChange*: DateTime
|
||||||
|
affiliateUsername*: string
|
||||||
|
affiliateLabel*: string
|
||||||
|
isIdentityVerified*: bool
|
||||||
|
verifiedSince*: DateTime
|
||||||
|
overrideVerifiedYear*: int
|
||||||
|
|
||||||
|
Broadcast* = object
|
||||||
|
id*: string
|
||||||
|
title*: string
|
||||||
|
state*: string
|
||||||
|
thumb*: string
|
||||||
|
mediaKey*: string
|
||||||
|
m3u8Url*: string
|
||||||
|
totalWatched*: int
|
||||||
|
startTime*: DateTime
|
||||||
|
endTime*: DateTime
|
||||||
|
replayStart*: int
|
||||||
|
availableForReplay*: bool
|
||||||
|
user*: User
|
||||||
|
|
||||||
VideoType* = enum
|
VideoType* = enum
|
||||||
m3u8 = "application/x-mpegURL"
|
m3u8 = "application/x-mpegURL"
|
||||||
mp4 = "video/mp4"
|
mp4 = "video/mp4"
|
||||||
@@ -97,7 +143,6 @@ type
|
|||||||
durationMs*: int
|
durationMs*: int
|
||||||
url*: string
|
url*: string
|
||||||
thumb*: string
|
thumb*: string
|
||||||
views*: string
|
|
||||||
available*: bool
|
available*: bool
|
||||||
reason*: string
|
reason*: string
|
||||||
title*: string
|
title*: string
|
||||||
@@ -110,6 +155,7 @@ type
|
|||||||
|
|
||||||
Query* = object
|
Query* = object
|
||||||
kind*: QueryKind
|
kind*: QueryKind
|
||||||
|
view*: string
|
||||||
text*: string
|
text*: string
|
||||||
filters*: seq[string]
|
filters*: seq[string]
|
||||||
includes*: seq[string]
|
includes*: seq[string]
|
||||||
@@ -117,12 +163,33 @@ type
|
|||||||
fromUser*: seq[string]
|
fromUser*: seq[string]
|
||||||
since*: string
|
since*: string
|
||||||
until*: string
|
until*: string
|
||||||
near*: string
|
minLikes*: string
|
||||||
sep*: string
|
sep*: string
|
||||||
|
|
||||||
Gif* = object
|
Gif* = object
|
||||||
url*: string
|
url*: string
|
||||||
thumb*: string
|
thumb*: string
|
||||||
|
altText*: string
|
||||||
|
|
||||||
|
Photo* = object
|
||||||
|
url*: string
|
||||||
|
altText*: string
|
||||||
|
|
||||||
|
MediaKind* = enum
|
||||||
|
photoMedia
|
||||||
|
videoMedia
|
||||||
|
gifMedia
|
||||||
|
|
||||||
|
Media* = object
|
||||||
|
case kind*: MediaKind
|
||||||
|
of photoMedia:
|
||||||
|
photo*: Photo
|
||||||
|
of videoMedia:
|
||||||
|
video*: Video
|
||||||
|
of gifMedia:
|
||||||
|
gif*: Gif
|
||||||
|
|
||||||
|
MediaEntities* = seq[Media]
|
||||||
|
|
||||||
GalleryPhoto* = object
|
GalleryPhoto* = object
|
||||||
url*: string
|
url*: string
|
||||||
@@ -161,6 +228,7 @@ type
|
|||||||
imageDirectMessage = "image_direct_message"
|
imageDirectMessage = "image_direct_message"
|
||||||
audiospace = "audiospace"
|
audiospace = "audiospace"
|
||||||
newsletterPublication = "newsletter_publication"
|
newsletterPublication = "newsletter_publication"
|
||||||
|
jobDetails = "job_details"
|
||||||
hidden
|
hidden
|
||||||
unknown
|
unknown
|
||||||
|
|
||||||
@@ -177,7 +245,7 @@ type
|
|||||||
replies*: int
|
replies*: int
|
||||||
retweets*: int
|
retweets*: int
|
||||||
likes*: int
|
likes*: int
|
||||||
quotes*: int
|
views*: int
|
||||||
|
|
||||||
Tweet* = ref object
|
Tweet* = ref object
|
||||||
id*: int64
|
id*: int64
|
||||||
@@ -197,13 +265,18 @@ type
|
|||||||
stats*: TweetStats
|
stats*: TweetStats
|
||||||
retweet*: Option[Tweet]
|
retweet*: Option[Tweet]
|
||||||
attribution*: Option[User]
|
attribution*: Option[User]
|
||||||
|
attributionLink*: string
|
||||||
mediaTags*: seq[User]
|
mediaTags*: seq[User]
|
||||||
quote*: Option[Tweet]
|
quote*: Option[Tweet]
|
||||||
card*: Option[Card]
|
card*: Option[Card]
|
||||||
poll*: Option[Poll]
|
poll*: Option[Poll]
|
||||||
gif*: Option[Gif]
|
media*: MediaEntities
|
||||||
video*: Option[Video]
|
history*: seq[int64]
|
||||||
photos*: seq[string]
|
note*: string
|
||||||
|
isAd*: bool
|
||||||
|
isAI*: bool
|
||||||
|
|
||||||
|
Tweets* = seq[Tweet]
|
||||||
|
|
||||||
Result*[T] = object
|
Result*[T] = object
|
||||||
content*: seq[T]
|
content*: seq[T]
|
||||||
@@ -212,7 +285,7 @@ type
|
|||||||
query*: Query
|
query*: Query
|
||||||
|
|
||||||
Chain* = object
|
Chain* = object
|
||||||
content*: seq[Tweet]
|
content*: Tweets
|
||||||
hasMore*: bool
|
hasMore*: bool
|
||||||
cursor*: string
|
cursor*: string
|
||||||
|
|
||||||
@@ -222,13 +295,18 @@ type
|
|||||||
after*: Chain
|
after*: Chain
|
||||||
replies*: Result[Chain]
|
replies*: Result[Chain]
|
||||||
|
|
||||||
Timeline* = Result[Tweet]
|
EditHistory* = object
|
||||||
|
latest*: Tweet
|
||||||
|
history*: Tweets
|
||||||
|
|
||||||
|
Timeline* = Result[Tweets]
|
||||||
|
|
||||||
Profile* = object
|
Profile* = object
|
||||||
user*: User
|
user*: User
|
||||||
photoRail*: PhotoRail
|
photoRail*: PhotoRail
|
||||||
pinned*: Option[Tweet]
|
pinned*: Option[Tweet]
|
||||||
tweets*: Timeline
|
tweets*: Timeline
|
||||||
|
accountInfo*: AccountInfo
|
||||||
|
|
||||||
List* = object
|
List* = object
|
||||||
id*: string
|
id*: string
|
||||||
@@ -255,10 +333,19 @@ type
|
|||||||
hmacKey*: string
|
hmacKey*: string
|
||||||
base64Media*: bool
|
base64Media*: bool
|
||||||
minTokens*: int
|
minTokens*: int
|
||||||
enableRss*: bool
|
enableRSSUserTweets*: bool
|
||||||
|
enableRSSUserReplies*: bool
|
||||||
|
enableRSSUserMedia*: bool
|
||||||
|
enableRSSSearch*: bool
|
||||||
|
enableRSSList*: bool
|
||||||
enableDebug*: bool
|
enableDebug*: bool
|
||||||
proxy*: string
|
proxy*: string
|
||||||
proxyAuth*: string
|
proxyAuth*: string
|
||||||
|
apiProxy*: string
|
||||||
|
disableTid*: bool
|
||||||
|
maxConcurrentReqs*: int
|
||||||
|
maxRetries*: int
|
||||||
|
retryDelayMs*: int
|
||||||
|
|
||||||
rssCacheTime*: int
|
rssCacheTime*: int
|
||||||
listCacheTime*: int
|
listCacheTime*: int
|
||||||
@@ -274,3 +361,27 @@ type
|
|||||||
|
|
||||||
proc contains*(thread: Chain; tweet: Tweet): bool =
|
proc contains*(thread: Chain; tweet: Tweet): bool =
|
||||||
thread.content.anyIt(it.id == tweet.id)
|
thread.content.anyIt(it.id == tweet.id)
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
+14
-5
@@ -1,5 +1,5 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import strutils, strformat, uri, tables, base64
|
import sequtils, strutils, strformat, uri, tables, base64
|
||||||
import nimcrypto
|
import nimcrypto
|
||||||
|
|
||||||
var
|
var
|
||||||
@@ -9,14 +9,17 @@ var
|
|||||||
const
|
const
|
||||||
https* = "https://"
|
https* = "https://"
|
||||||
twimg* = "pbs.twimg.com/"
|
twimg* = "pbs.twimg.com/"
|
||||||
nitterParams = ["name", "tab", "id", "list", "referer", "scroll"]
|
nitterParams* = ["name", "tab", "id", "list", "referer", "scroll", "prefs"]
|
||||||
twitterDomains = @[
|
twitterDomains = @[
|
||||||
"twitter.com",
|
"twitter.com",
|
||||||
"pic.twitter.com",
|
"pic.twitter.com",
|
||||||
"twimg.com",
|
"twimg.com",
|
||||||
"abs.twimg.com",
|
"abs.twimg.com",
|
||||||
"pbs.twimg.com",
|
"pbs.twimg.com",
|
||||||
"video.twimg.com"
|
"video.twimg.com",
|
||||||
|
"x.com",
|
||||||
|
"pscp.tv",
|
||||||
|
"video.pscp.tv"
|
||||||
]
|
]
|
||||||
|
|
||||||
proc setHmacKey*(key: string) =
|
proc setHmacKey*(key: string) =
|
||||||
@@ -54,7 +57,13 @@ proc filterParams*(params: Table): seq[(string, string)] =
|
|||||||
result.add p
|
result.add p
|
||||||
|
|
||||||
proc isTwitterUrl*(uri: Uri): bool =
|
proc isTwitterUrl*(uri: Uri): bool =
|
||||||
uri.hostname in twitterDomains
|
uri.hostname in twitterDomains or
|
||||||
|
uri.hostname.endsWith(".video.pscp.tv")
|
||||||
|
|
||||||
proc isTwitterUrl*(url: string): bool =
|
proc isTwitterUrl*(url: string): bool =
|
||||||
parseUri(url).hostname in twitterDomains
|
isTwitterUrl(parseUri(url))
|
||||||
|
|
||||||
|
proc validateNumber*(value: string): string =
|
||||||
|
if value.anyIt(not it.isDigit):
|
||||||
|
return ""
|
||||||
|
return value
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
import strutils, strformat, times
|
||||||
|
import karax/[karaxdsl, vdom]
|
||||||
|
|
||||||
|
import renderutils
|
||||||
|
import ".."/[types, utils, formatters]
|
||||||
|
|
||||||
|
proc renderBroadcast*(bc: Broadcast; prefs: Prefs; path: string): VNode =
|
||||||
|
let
|
||||||
|
isLive = bc.state == "RUNNING"
|
||||||
|
thumb = getPicUrl(bc.thumb)
|
||||||
|
source = if prefs.proxyVideos and bc.m3u8Url.startsWith("http"):
|
||||||
|
getVidUrl(bc.m3u8Url) else: bc.m3u8Url
|
||||||
|
stateText =
|
||||||
|
if isLive: "LIVE"
|
||||||
|
elif bc.endTime.year > 1: "Ended " & bc.endTime.format("MMM d, YYYY")
|
||||||
|
elif bc.state.len > 0: bc.state
|
||||||
|
else: "Ended"
|
||||||
|
durationMs =
|
||||||
|
if bc.startTime.year > 1 and bc.endTime.year > 1:
|
||||||
|
int((bc.endTime - bc.startTime).inMilliseconds) - bc.replayStart * 1000
|
||||||
|
else: 0
|
||||||
|
duration = if durationMs > 0: getDuration(durationMs) else: ""
|
||||||
|
|
||||||
|
buildHtml(tdiv(class="broadcast-page")):
|
||||||
|
tdiv(class="broadcast-panel"):
|
||||||
|
tdiv(class="broadcast-player"):
|
||||||
|
if bc.m3u8Url.len > 0 and prefs.hlsPlayback:
|
||||||
|
video(poster=thumb, data-url=source, data-autoload="false",
|
||||||
|
data-start=($bc.replayStart), muted=prefs.muteVideos)
|
||||||
|
verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
|
||||||
|
tdiv(class="overlay-circle"): span(class="overlay-triangle")
|
||||||
|
if isLive:
|
||||||
|
tdiv(class="broadcast-live"): text "LIVE"
|
||||||
|
elif duration.len > 0:
|
||||||
|
tdiv(class="overlay-duration"): text duration
|
||||||
|
verbatim "</div>"
|
||||||
|
elif bc.m3u8Url.len > 0:
|
||||||
|
img(src=thumb, alt=bc.title)
|
||||||
|
tdiv(class="video-overlay"):
|
||||||
|
buttonReferer "/enablehls", "Enable hls playback", path
|
||||||
|
if isLive:
|
||||||
|
tdiv(class="broadcast-live"): text "LIVE"
|
||||||
|
elif duration.len > 0:
|
||||||
|
tdiv(class="overlay-duration"): text duration
|
||||||
|
elif bc.thumb.len > 0:
|
||||||
|
img(src=thumb, alt=bc.title)
|
||||||
|
tdiv(class="video-overlay"):
|
||||||
|
if bc.availableForReplay:
|
||||||
|
p: text "Stream unavailable"
|
||||||
|
else:
|
||||||
|
p: text "Replay is not available"
|
||||||
|
else:
|
||||||
|
tdiv(class="video-overlay"):
|
||||||
|
p: text "Broadcast not found"
|
||||||
|
|
||||||
|
tdiv(class="broadcast-info"):
|
||||||
|
h2(class="broadcast-title"): text bc.title
|
||||||
|
|
||||||
|
tdiv(class="broadcast-user-row"):
|
||||||
|
a(class="broadcast-user", href=("/" & bc.user.username)):
|
||||||
|
genImg(getUserPic(bc.user.userPic, "_bigger"))
|
||||||
|
tdiv:
|
||||||
|
tdiv:
|
||||||
|
strong: text bc.user.fullname
|
||||||
|
verifiedIcon(bc.user)
|
||||||
|
span(class="broadcast-username"): text "@" & bc.user.username
|
||||||
|
|
||||||
|
tdiv(class="broadcast-meta"):
|
||||||
|
if bc.totalWatched > 0:
|
||||||
|
span: text insertSep($bc.totalWatched, ',') & " views"
|
||||||
|
if isLive:
|
||||||
|
span(class="broadcast-live"): text stateText
|
||||||
|
else:
|
||||||
|
span: text stateText
|
||||||
+7
-4
@@ -9,14 +9,17 @@ import general, tweet
|
|||||||
const doctype = "<!DOCTYPE html>\n"
|
const doctype = "<!DOCTYPE html>\n"
|
||||||
|
|
||||||
proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
|
proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
|
||||||
let thumb = get(tweet.video).thumb
|
let
|
||||||
let vidUrl = getVideoEmbed(cfg, tweet.id)
|
video = tweet.getVideos()[0]
|
||||||
let prefs = Prefs(hlsPlayback: true)
|
thumb = video.thumb
|
||||||
|
vidUrl = getVideoEmbed(cfg, tweet.id)
|
||||||
|
prefs = Prefs(hlsPlayback: true, mp4Playback: true)
|
||||||
|
|
||||||
let node = buildHtml(html(lang="en")):
|
let node = buildHtml(html(lang="en")):
|
||||||
renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb]))
|
renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb]))
|
||||||
|
|
||||||
body:
|
body:
|
||||||
tdiv(class="embed-video"):
|
tdiv(class="embed-video"):
|
||||||
renderVideo(get(tweet.video), prefs, "")
|
renderVideo(video, prefs, "")
|
||||||
|
|
||||||
result = doctype & $node
|
result = doctype & $node
|
||||||
|
|||||||
+18
-18
@@ -29,19 +29,17 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
|
|||||||
|
|
||||||
tdiv(class="nav-item right"):
|
tdiv(class="nav-item right"):
|
||||||
icon "search", title="Search", href="/search"
|
icon "search", title="Search", href="/search"
|
||||||
if cfg.enableRss and rss.len > 0:
|
if rss.len > 0:
|
||||||
icon "rss-feed", title="RSS Feed", href=rss
|
icon "rss", title="RSS Feed", href=rss
|
||||||
icon "bird", title="Open in Twitter", href=canonical
|
icon "bird", title="Open in X", href=canonical
|
||||||
a(href="https://liberapay.com/zedeus"): verbatim lp
|
a(href="https://liberapay.com/zedeus"): verbatim lp
|
||||||
icon "info", title="About", href="/about"
|
icon "info", title="About", href="/about"
|
||||||
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
|
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
|
||||||
|
|
||||||
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||||
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
|
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
|
||||||
rss=""; canonical=""): VNode =
|
rss=""; alternate=""): VNode =
|
||||||
var theme = prefs.theme.toTheme
|
let theme = prefs.theme.toTheme
|
||||||
if "theme" in req.params:
|
|
||||||
theme = req.params["theme"].toTheme
|
|
||||||
|
|
||||||
let ogType =
|
let ogType =
|
||||||
if video.len > 0: "video"
|
if video.len > 0: "video"
|
||||||
@@ -52,8 +50,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||||||
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
||||||
|
|
||||||
buildHtml(head):
|
buildHtml(head):
|
||||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=18")
|
link(rel="stylesheet", type="text/css", href="/css/style.css?v=35")
|
||||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2")
|
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5")
|
||||||
|
|
||||||
if theme.len > 0:
|
if theme.len > 0:
|
||||||
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
|
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
|
||||||
@@ -66,14 +64,14 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||||||
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
|
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
|
||||||
href=opensearchUrl)
|
href=opensearchUrl)
|
||||||
|
|
||||||
if canonical.len > 0:
|
if alternate.len > 0:
|
||||||
link(rel="canonical", href=canonical)
|
link(rel="alternate", href=alternate, title="View on X")
|
||||||
|
|
||||||
if cfg.enableRss and rss.len > 0:
|
if rss.len > 0:
|
||||||
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
|
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
|
||||||
|
|
||||||
if prefs.hlsPlayback:
|
if prefs.hlsPlayback:
|
||||||
script(src="/js/hls.light.min.js", `defer`="")
|
script(src="/js/hls.min.js", `defer`="")
|
||||||
script(src="/js/hlsPlayback.js", `defer`="")
|
script(src="/js/hlsPlayback.js", `defer`="")
|
||||||
|
|
||||||
if prefs.infiniteScroll:
|
if prefs.infiniteScroll:
|
||||||
@@ -86,6 +84,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||||||
text cfg.title
|
text cfg.title
|
||||||
|
|
||||||
meta(name="viewport", content="width=device-width, initial-scale=1.0")
|
meta(name="viewport", content="width=device-width, initial-scale=1.0")
|
||||||
|
meta(name="referrer", content="same-origin")
|
||||||
meta(name="theme-color", content="#1F1F1F")
|
meta(name="theme-color", content="#1F1F1F")
|
||||||
meta(property="og:type", content=ogType)
|
meta(property="og:type", content=ogType)
|
||||||
meta(property="og:title", content=(if ogTitle.len > 0: ogTitle else: titleText))
|
meta(property="og:title", content=(if ogTitle.len > 0: ogTitle else: titleText))
|
||||||
@@ -119,20 +118,21 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||||||
# this is last so images are also preloaded
|
# this is last so images are also preloaded
|
||||||
# if this is done earlier, Chrome only preloads one image for some reason
|
# if this is done earlier, Chrome only preloads one image for some reason
|
||||||
link(rel="preload", type="font/woff2", `as`="font",
|
link(rel="preload", type="font/woff2", `as`="font",
|
||||||
href="/fonts/fontello.woff2?21002321", crossorigin="anonymous")
|
href="/fonts/fontello.woff2?61663884", crossorigin="anonymous")
|
||||||
|
|
||||||
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
|
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
|
||||||
titleText=""; desc=""; ogTitle=""; rss=""; video="";
|
titleText=""; desc=""; ogTitle=""; rss=""; video="";
|
||||||
images: seq[string] = @[]; banner=""): string =
|
images: seq[string] = @[]; banner=""): string =
|
||||||
|
|
||||||
let canonical = getTwitterLink(req.path, req.params)
|
let twitterLink = getTwitterLink(req.path, req.params)
|
||||||
|
|
||||||
let node = buildHtml(html(lang="en")):
|
let node = buildHtml(html(lang="en")):
|
||||||
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
|
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
|
||||||
rss, canonical)
|
rss, twitterLink)
|
||||||
|
|
||||||
body:
|
let bodyClass = if prefs.stickyNav: "fixed-nav" else: ""
|
||||||
renderNavbar(cfg, req, rss, canonical)
|
body(class=bodyClass):
|
||||||
|
renderNavbar(cfg, req, rss, twitterLink)
|
||||||
|
|
||||||
tdiv(class="container"):
|
tdiv(class="container"):
|
||||||
body
|
body
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ macro renderPrefs*(): untyped =
|
|||||||
|
|
||||||
result[2].add stmt
|
result[2].add stmt
|
||||||
|
|
||||||
proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode =
|
proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string];
|
||||||
|
prefsUrl: string): VNode =
|
||||||
buildHtml(tdiv(class="overlay-panel")):
|
buildHtml(tdiv(class="overlay-panel")):
|
||||||
fieldset(class="preferences"):
|
fieldset(class="preferences"):
|
||||||
form(`method`="post", action="/saveprefs", autocomplete="off"):
|
form(`method`="post", action="/saveprefs", autocomplete="off"):
|
||||||
@@ -40,6 +41,14 @@ proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode
|
|||||||
|
|
||||||
renderPrefs()
|
renderPrefs()
|
||||||
|
|
||||||
|
legend: text "Bookmark"
|
||||||
|
p(class="bookmark-note"):
|
||||||
|
text "Save this URL to restore your preferences (?prefs works on all pages)"
|
||||||
|
pre(class="prefs-code"):
|
||||||
|
text prefsUrl
|
||||||
|
p(class="bookmark-note"):
|
||||||
|
verbatim "You can override preferences with query parameters (e.g. <code>?hlsPlayback=on</code>). These overrides aren't saved to cookies, and links won't retain the parameters. Intended for configuring RSS feeds and other cookieless environments. Hover over a preference to see its name."
|
||||||
|
|
||||||
h4(class="note"):
|
h4(class="note"):
|
||||||
text "Preferences are stored client-side using cookies without any personal information."
|
text "Preferences are stored client-side using cookies without any personal information."
|
||||||
|
|
||||||
|
|||||||
+16
-5
@@ -12,7 +12,7 @@ proc renderStat(num: int; class: string; text=""): VNode =
|
|||||||
span(class="profile-stat-num"):
|
span(class="profile-stat-num"):
|
||||||
text insertSep($num, ',')
|
text insertSep($num, ',')
|
||||||
|
|
||||||
proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
|
||||||
buildHtml(tdiv(class="profile-card")):
|
buildHtml(tdiv(class="profile-card")):
|
||||||
tdiv(class="profile-card-info"):
|
tdiv(class="profile-card-info"):
|
||||||
let
|
let
|
||||||
@@ -26,6 +26,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
|||||||
|
|
||||||
tdiv(class="profile-card-tabs-name"):
|
tdiv(class="profile-card-tabs-name"):
|
||||||
linkUser(user, class="profile-card-fullname")
|
linkUser(user, class="profile-card-fullname")
|
||||||
|
verifiedIcon(user)
|
||||||
linkUser(user, class="profile-card-username")
|
linkUser(user, class="profile-card-username")
|
||||||
|
|
||||||
tdiv(class="profile-card-extra"):
|
tdiv(class="profile-card-extra"):
|
||||||
@@ -45,6 +46,11 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
|||||||
else:
|
else:
|
||||||
span: text place
|
span: text place
|
||||||
|
|
||||||
|
if info.basedIn.len > 0:
|
||||||
|
tdiv(class="profile-location"):
|
||||||
|
span: icon "location"
|
||||||
|
span: text "Based in " & info.basedIn
|
||||||
|
|
||||||
if user.website.len > 0:
|
if user.website.len > 0:
|
||||||
tdiv(class="profile-website"):
|
tdiv(class="profile-website"):
|
||||||
span:
|
span:
|
||||||
@@ -53,7 +59,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
|||||||
a(href=url): text url.shortLink
|
a(href=url): text url.shortLink
|
||||||
|
|
||||||
tdiv(class="profile-joindate"):
|
tdiv(class="profile-joindate"):
|
||||||
span(title=getJoinDateFull(user)):
|
a(href=(&"/{user.username}/about"), title=getJoinDateFull(user)):
|
||||||
icon "calendar", getJoinDate(user)
|
icon "calendar", getJoinDate(user)
|
||||||
|
|
||||||
tdiv(class="profile-card-extra-links"):
|
tdiv(class="profile-card-extra-links"):
|
||||||
@@ -101,15 +107,20 @@ proc renderProtected(username: string): VNode =
|
|||||||
|
|
||||||
proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
|
proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
|
||||||
profile.tweets.query.fromUser = @[profile.user.username]
|
profile.tweets.query.fromUser = @[profile.user.username]
|
||||||
|
let
|
||||||
|
isGalleryView = profile.tweets.query.kind == media and
|
||||||
|
profile.tweets.query.view == "gallery"
|
||||||
|
viewClass = if isGalleryView: " media-only" else: ""
|
||||||
|
|
||||||
buildHtml(tdiv(class="profile-tabs")):
|
buildHtml(tdiv(class=("profile-tabs" & viewClass))):
|
||||||
if not prefs.hideBanner:
|
if not isGalleryView and not prefs.hideBanner:
|
||||||
tdiv(class="profile-banner"):
|
tdiv(class="profile-banner"):
|
||||||
renderBanner(profile.user.banner)
|
renderBanner(profile.user.banner)
|
||||||
|
|
||||||
|
if not isGalleryView:
|
||||||
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
||||||
tdiv(class=("profile-tab" & sticky)):
|
tdiv(class=("profile-tab" & sticky)):
|
||||||
renderUserCard(profile.user, prefs)
|
renderUserCard(profile.user, prefs, profile.accountInfo)
|
||||||
if profile.photoRail.len > 0:
|
if profile.photoRail.len > 0:
|
||||||
renderPhotoRail(profile)
|
renderPhotoRail(profile)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import karax/[karaxdsl, vdom, vstyles]
|
|||||||
import ".."/[types, utils]
|
import ".."/[types, utils]
|
||||||
|
|
||||||
const smallWebp* = "?name=small&format=webp"
|
const smallWebp* = "?name=small&format=webp"
|
||||||
|
const mediumWebp* = "?name=medium&format=webp"
|
||||||
|
|
||||||
proc getSmallPic*(url: string): string =
|
proc getSmallPic*(url: string): string =
|
||||||
result = url
|
result = url
|
||||||
@@ -11,6 +12,12 @@ proc getSmallPic*(url: string): string =
|
|||||||
result &= smallWebp
|
result &= smallWebp
|
||||||
result = getPicUrl(result)
|
result = getPicUrl(result)
|
||||||
|
|
||||||
|
proc getMediumPic*(url: string): string =
|
||||||
|
result = url
|
||||||
|
if "?" notin url and not url.endsWith("placeholder.png"):
|
||||||
|
result &= mediumWebp
|
||||||
|
result = getPicUrl(result)
|
||||||
|
|
||||||
proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
|
proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
|
||||||
var c = "icon-" & icon
|
var c = "icon-" & icon
|
||||||
if class.len > 0: c = &"{c} {class}"
|
if class.len > 0: c = &"{c} {class}"
|
||||||
@@ -23,6 +30,15 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
|
|||||||
if text.len > 0:
|
if text.len > 0:
|
||||||
text " " & text
|
text " " & text
|
||||||
|
|
||||||
|
template verifiedIcon*(user: User): untyped {.dirty.} =
|
||||||
|
if user.verifiedType != VerifiedType.none:
|
||||||
|
let lower = ($user.verifiedType).toLowerAscii()
|
||||||
|
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 ""
|
||||||
|
|
||||||
proc linkUser*(user: User, class=""): VNode =
|
proc linkUser*(user: User, class=""): VNode =
|
||||||
let
|
let
|
||||||
isName = "username" notin class
|
isName = "username" notin class
|
||||||
@@ -32,9 +48,8 @@ proc linkUser*(user: User, class=""): VNode =
|
|||||||
|
|
||||||
buildHtml(a(href=href, class=class, title=nameText)):
|
buildHtml(a(href=href, class=class, title=nameText)):
|
||||||
text nameText
|
text nameText
|
||||||
if isName and user.verified:
|
if isName:
|
||||||
icon "ok", class="verified-icon", title="Verified account"
|
if user.protected:
|
||||||
if isName and user.protected:
|
|
||||||
text " "
|
text " "
|
||||||
icon "lock", title="Protected account"
|
icon "lock", title="Protected account"
|
||||||
|
|
||||||
@@ -57,20 +72,20 @@ proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNod
|
|||||||
text text
|
text text
|
||||||
|
|
||||||
proc genCheckbox*(pref, label: string; state: bool): VNode =
|
proc genCheckbox*(pref, label: string; state: bool): VNode =
|
||||||
buildHtml(label(class="pref-group checkbox-container")):
|
buildHtml(label(class="pref-group checkbox-container", title=pref)):
|
||||||
text label
|
text label
|
||||||
input(name=pref, `type`="checkbox", checked=state)
|
input(name=pref, `type`="checkbox", checked=state)
|
||||||
span(class="checkbox")
|
span(class="checkbox")
|
||||||
|
|
||||||
proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode =
|
proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode =
|
||||||
let p = placeholder
|
let p = placeholder
|
||||||
buildHtml(tdiv(class=("pref-group pref-input " & class))):
|
buildHtml(tdiv(class=("pref-group pref-input " & class), title=pref)):
|
||||||
if label.len > 0:
|
if label.len > 0:
|
||||||
label(`for`=pref): text label
|
label(`for`=pref): text label
|
||||||
input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0))
|
input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0))
|
||||||
|
|
||||||
proc genSelect*(pref, label, state: string; options: seq[string]): VNode =
|
proc genSelect*(pref, label, state: string; options: seq[string]): VNode =
|
||||||
buildHtml(tdiv(class="pref-group pref-input")):
|
buildHtml(tdiv(class="pref-group pref-input", title=pref)):
|
||||||
label(`for`=pref): text label
|
label(`for`=pref): text label
|
||||||
select(name=pref):
|
select(name=pref):
|
||||||
for opt in options:
|
for opt in options:
|
||||||
@@ -82,9 +97,16 @@ proc genDate*(pref, state: string): VNode =
|
|||||||
input(name=pref, `type`="date", value=state)
|
input(name=pref, `type`="date", value=state)
|
||||||
icon "calendar"
|
icon "calendar"
|
||||||
|
|
||||||
proc genImg*(url: string; class=""): VNode =
|
proc genNumberInput*(pref, label, state, placeholder: string; class=""; autofocus=true; min="0"): VNode =
|
||||||
|
let p = placeholder
|
||||||
|
buildHtml(tdiv(class=("pref-group pref-input " & class))):
|
||||||
|
if label.len > 0:
|
||||||
|
label(`for`=pref): text label
|
||||||
|
input(name=pref, `type`="number", placeholder=p, value=state, autofocus=(autofocus and state.len == 0), min=min, step="1")
|
||||||
|
|
||||||
|
proc genImg*(url: string; class=""; alt=""): VNode =
|
||||||
buildHtml():
|
buildHtml():
|
||||||
img(src=getPicUrl(url), class=class, alt="")
|
img(src=getPicUrl(url), class=class, alt=alt, loading="lazy")
|
||||||
|
|
||||||
proc getTabClass*(query: Query; tab: QueryKind): string =
|
proc getTabClass*(query: Query; tab: QueryKind): string =
|
||||||
if query.kind == tab: "tab-item active"
|
if query.kind == tab: "tab-item active"
|
||||||
|
|||||||
+110
-43
@@ -1,83 +1,149 @@
|
|||||||
#? stdtmpl(subsChar = '$', metaChar = '#')
|
#? stdtmpl(subsChar = '$', metaChar = '#')
|
||||||
## SPDX-License-Identifier: AGPL-3.0-only
|
## SPDX-License-Identifier: AGPL-3.0-only
|
||||||
#import strutils, xmltree, strformat, options, unicode
|
#import strutils, sequtils, xmltree, strformat, options, unicode
|
||||||
#import ../types, ../utils, ../formatters, ../prefs
|
#import ../types, ../utils, ../formatters, ../prefs
|
||||||
|
## Snowflake ID cutoff for RSS GUID format transition
|
||||||
|
## Corresponds to approximately December 14, 2025 UTC
|
||||||
|
#const guidCutoff = 2000000000000000000'i64
|
||||||
#
|
#
|
||||||
#proc getTitle(tweet: Tweet; retweet: string): string =
|
#proc getTitle(tweet: Tweet; retweet: string): string =
|
||||||
#if tweet.pinned: result = "Pinned: "
|
#var prefix = ""
|
||||||
#elif retweet.len > 0: result = &"RT by @{retweet}: "
|
#if tweet.pinned: prefix = "Pinned: "
|
||||||
#elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: "
|
#elif retweet.len > 0: prefix = &"RT by @{retweet}: "
|
||||||
|
#elif tweet.reply.len > 0: prefix = &"R to @{tweet.reply[0]}: "
|
||||||
#end if
|
#end if
|
||||||
#var text = stripHtml(tweet.text)
|
#var text = stripHtml(tweet.text)
|
||||||
##if unicode.runeLen(text) > 32:
|
##if unicode.runeLen(text) > 32:
|
||||||
## text = unicode.runeSubStr(text, 0, 32) & "..."
|
## text = unicode.runeSubStr(text, 0, 32) & "..."
|
||||||
##end if
|
##end if
|
||||||
#result &= xmltree.escape(text)
|
#text = xmltree.escape(text)
|
||||||
#if result.len > 0: return
|
#if text.len > 0:
|
||||||
|
# result = prefix & text
|
||||||
|
# return
|
||||||
|
#end if
|
||||||
|
#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 tweet.photos.len > 0:
|
|
||||||
# result &= "Image"
|
|
||||||
#elif tweet.video.isSome:
|
|
||||||
# result &= "Video"
|
|
||||||
#elif tweet.gif.isSome:
|
|
||||||
# result &= "Gif"
|
|
||||||
#end if
|
#end if
|
||||||
#end proc
|
#end proc
|
||||||
#
|
#
|
||||||
#proc getDescription(desc: string; cfg: Config): string =
|
#proc getDescription(desc: string; cfg: Config): string =
|
||||||
Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
|
||||||
#end proc
|
#end proc
|
||||||
#
|
#
|
||||||
#proc renderRssTweet(tweet: Tweet; cfg: Config): string =
|
#proc renderRssMedia(media: Media; tweet: Tweet; urlPrefix: string): string =
|
||||||
#let tweet = tweet.retweet.get(tweet)
|
#case media.kind
|
||||||
#let urlPrefix = getUrlPrefix(cfg)
|
#of photoMedia:
|
||||||
#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix)
|
# let photo = media.photo
|
||||||
<p>${text.replace("\n", "<br>\n")}</p>
|
<img src="${urlPrefix}${getPicUrl(photo.url)}" style="max-width:250px;" />
|
||||||
#if tweet.quote.isSome and get(tweet.quote).available:
|
#of videoMedia:
|
||||||
# let quoteLink = getLink(get(tweet.quote))
|
# let video = media.video
|
||||||
<p><a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p>
|
<a href="${urlPrefix}${tweet.getLink}">
|
||||||
#end if
|
<br>Video<br>
|
||||||
#if tweet.photos.len > 0:
|
<img src="${urlPrefix}${getPicUrl(video.thumb)}" style="max-width:250px;" />
|
||||||
# for photo in tweet.photos:
|
</a>
|
||||||
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" />
|
#of gifMedia:
|
||||||
# end for
|
# let gif = media.gif
|
||||||
#elif tweet.video.isSome:
|
# let thumb = &"{urlPrefix}{getPicUrl(gif.thumb)}"
|
||||||
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
|
# let url = &"{urlPrefix}{getPicUrl(gif.url)}"
|
||||||
#elif tweet.gif.isSome:
|
|
||||||
# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
|
|
||||||
# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
|
|
||||||
<video poster="${thumb}" autoplay muted loop style="max-width:250px;">
|
<video poster="${thumb}" autoplay muted loop style="max-width:250px;">
|
||||||
<source src="${url}" type="video/mp4"></video>
|
<source src="${url}" type="video/mp4"></video>
|
||||||
|
#end case
|
||||||
|
#end proc
|
||||||
|
#
|
||||||
|
#proc getTweetsWithPinned(profile: Profile): seq[Tweets] =
|
||||||
|
#result = profile.tweets.content
|
||||||
|
#if profile.pinned.isSome and result.len > 0:
|
||||||
|
# let pinnedTweet = profile.pinned.get
|
||||||
|
# var inserted = false
|
||||||
|
# for threadIdx in 0 ..< result.len:
|
||||||
|
# if not inserted:
|
||||||
|
# for tweetIdx in 0 ..< result[threadIdx].len:
|
||||||
|
# if result[threadIdx][tweetIdx].id < pinnedTweet.id:
|
||||||
|
# result[threadIdx].insert(pinnedTweet, tweetIdx)
|
||||||
|
# inserted = true
|
||||||
|
# end if
|
||||||
|
# end for
|
||||||
|
# end if
|
||||||
|
# end for
|
||||||
|
#end if
|
||||||
|
#end proc
|
||||||
|
#
|
||||||
|
#proc renderRssTweet(tweet: Tweet; cfg: Config; prefs: Prefs): string =
|
||||||
|
#let tweet = tweet.retweet.get(tweet)
|
||||||
|
#let urlPrefix = getUrlPrefix(cfg)
|
||||||
|
#let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix)
|
||||||
|
<p>${text.replace("\n", "<br>\n")}</p>
|
||||||
|
#if tweet.media.len > 0:
|
||||||
|
# for media in tweet.media:
|
||||||
|
${renderRssMedia(media, tweet, urlPrefix)}
|
||||||
|
# end for
|
||||||
#elif tweet.card.isSome:
|
#elif tweet.card.isSome:
|
||||||
# let card = tweet.card.get()
|
# let card = tweet.card.get()
|
||||||
# if card.image.len > 0:
|
# if card.image.len > 0:
|
||||||
<img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" />
|
<img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" />
|
||||||
# end if
|
# end if
|
||||||
#end if
|
#end if
|
||||||
|
#if tweet.note.len > 0 and not prefs.hideCommunityNotes:
|
||||||
|
<p><b>Community note:</b> ${replaceUrls(tweet.note, prefs, absolute=urlPrefix)}</p>
|
||||||
|
#end if
|
||||||
|
#if tweet.quote.isSome and get(tweet.quote).available:
|
||||||
|
# let quoteTweet = get(tweet.quote)
|
||||||
|
# let quoteLink = urlPrefix & getLink(quoteTweet)
|
||||||
|
<hr/>
|
||||||
|
<blockquote>
|
||||||
|
<b>${quoteTweet.user.fullname} (@${quoteTweet.user.username})</b>
|
||||||
|
<p>
|
||||||
|
${renderRssTweet(quoteTweet, cfg, prefs)}
|
||||||
|
</p>
|
||||||
|
<footer>
|
||||||
|
— <cite><a href="${quoteLink}">${quoteLink}</a>
|
||||||
|
</footer>
|
||||||
|
</blockquote>
|
||||||
|
#end if
|
||||||
#end proc
|
#end proc
|
||||||
#
|
#
|
||||||
#proc renderRssTweets(tweets: seq[Tweet]; cfg: Config): string =
|
#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; prefs: Prefs; userId=""): string =
|
||||||
#let urlPrefix = getUrlPrefix(cfg)
|
#let urlPrefix = getUrlPrefix(cfg)
|
||||||
#var links: seq[string]
|
#var links: seq[string]
|
||||||
#for t in tweets:
|
#for thread in tweets:
|
||||||
# let retweet = if t.retweet.isSome: t.user.username else: ""
|
# for tweet in thread:
|
||||||
# let tweet = if retweet.len > 0: t.retweet.get else: t
|
# if userId.len > 0 and tweet.user.id != userId: continue
|
||||||
|
# end if
|
||||||
|
#
|
||||||
|
# let retweet = if tweet.retweet.isSome: tweet.user.username else: ""
|
||||||
|
# let tweet = if retweet.len > 0: tweet.retweet.get else: tweet
|
||||||
# let link = getLink(tweet)
|
# let link = getLink(tweet)
|
||||||
# if link in links: continue
|
# if link in links: continue
|
||||||
# end if
|
# end if
|
||||||
# links.add link
|
# links.add link
|
||||||
|
# let useGlobalGuid = tweet.id >= guidCutoff
|
||||||
<item>
|
<item>
|
||||||
<title>${getTitle(tweet, retweet)}</title>
|
<title>${getTitle(tweet, retweet)}</title>
|
||||||
<dc:creator>@${tweet.user.username}</dc:creator>
|
<dc:creator>@${tweet.user.username}</dc:creator>
|
||||||
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
|
<description><![CDATA[${renderRssTweet(tweet, cfg, prefs).strip(chars={'\n'})}]]></description>
|
||||||
<pubDate>${getRfc822Time(tweet)}</pubDate>
|
<pubDate>${getRfc822Time(tweet)}</pubDate>
|
||||||
|
#if useGlobalGuid:
|
||||||
|
<guid isPermaLink="false">${tweet.id}</guid>
|
||||||
|
#else:
|
||||||
<guid>${urlPrefix & link}</guid>
|
<guid>${urlPrefix & link}</guid>
|
||||||
|
#end if
|
||||||
<link>${urlPrefix & link}</link>
|
<link>${urlPrefix & link}</link>
|
||||||
</item>
|
</item>
|
||||||
# end for
|
# end for
|
||||||
|
#end for
|
||||||
#end proc
|
#end proc
|
||||||
#
|
#
|
||||||
#proc renderTimelineRss*(profile: Profile; cfg: Config; multi=false): string =
|
#proc renderTimelineRss*(profile: Profile; cfg: Config; prefs: Prefs; multi=false): string =
|
||||||
#let urlPrefix = getUrlPrefix(cfg)
|
#let urlPrefix = getUrlPrefix(cfg)
|
||||||
#result = ""
|
#result = ""
|
||||||
#let handle = (if multi: "" else: "@") & profile.user.username
|
#let handle = (if multi: "" else: "@") & profile.user.username
|
||||||
@@ -101,14 +167,15 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
|||||||
<width>128</width>
|
<width>128</width>
|
||||||
<height>128</height>
|
<height>128</height>
|
||||||
</image>
|
</image>
|
||||||
#if profile.tweets.content.len > 0:
|
#let tweetsList = getTweetsWithPinned(profile)
|
||||||
${renderRssTweets(profile.tweets.content, cfg)}
|
#if tweetsList.len > 0:
|
||||||
|
${renderRssTweets(tweetsList, cfg, prefs, userId=profile.user.id)}
|
||||||
#end if
|
#end if
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
#end proc
|
#end proc
|
||||||
#
|
#
|
||||||
#proc renderListRss*(tweets: seq[Tweet]; list: List; cfg: Config): string =
|
#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config; prefs: Prefs): string =
|
||||||
#let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}"
|
#let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}"
|
||||||
#result = ""
|
#result = ""
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
@@ -120,12 +187,12 @@ ${renderRssTweets(profile.tweets.content, cfg)}
|
|||||||
<description>${getDescription(&"{list.name} by @{list.username}", cfg)}</description>
|
<description>${getDescription(&"{list.name} by @{list.username}", cfg)}</description>
|
||||||
<language>en-us</language>
|
<language>en-us</language>
|
||||||
<ttl>40</ttl>
|
<ttl>40</ttl>
|
||||||
${renderRssTweets(tweets, cfg)}
|
${renderRssTweets(tweets, cfg, prefs)}
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
#end proc
|
#end proc
|
||||||
#
|
#
|
||||||
#proc renderSearchRss*(tweets: seq[Tweet]; name, param: string; cfg: Config): string =
|
#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config; prefs: Prefs): string =
|
||||||
#let link = &"{getUrlPrefix(cfg)}/search"
|
#let link = &"{getUrlPrefix(cfg)}/search"
|
||||||
#let escName = xmltree.escape(name)
|
#let escName = xmltree.escape(name)
|
||||||
#result = ""
|
#result = ""
|
||||||
@@ -138,7 +205,7 @@ ${renderRssTweets(tweets, cfg)}
|
|||||||
<description>${getDescription(&"Search \"{escName}\"", cfg)}</description>
|
<description>${getDescription(&"Search \"{escName}\"", cfg)}</description>
|
||||||
<language>en-us</language>
|
<language>en-us</language>
|
||||||
<ttl>40</ttl>
|
<ttl>40</ttl>
|
||||||
${renderRssTweets(tweets, cfg)}
|
${renderRssTweets(tweets, cfg, prefs)}
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
#end proc
|
#end proc
|
||||||
|
|||||||
+24
-9
@@ -10,23 +10,22 @@ const toggles = {
|
|||||||
"media": "Media",
|
"media": "Media",
|
||||||
"videos": "Videos",
|
"videos": "Videos",
|
||||||
"news": "News",
|
"news": "News",
|
||||||
"verified": "Verified",
|
|
||||||
"native_video": "Native videos",
|
"native_video": "Native videos",
|
||||||
"replies": "Replies",
|
"replies": "Replies",
|
||||||
"links": "Links",
|
"links": "Links",
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
"safe": "Safe",
|
|
||||||
"quote": "Quotes",
|
"quote": "Quotes",
|
||||||
"pro_video": "Pro videos"
|
"spaces": "Spaces",
|
||||||
|
"cashtags": "Cashtags"
|
||||||
}.toOrderedTable
|
}.toOrderedTable
|
||||||
|
|
||||||
proc renderSearch*(): VNode =
|
proc renderSearch*(): VNode =
|
||||||
buildHtml(tdiv(class="panel-container")):
|
buildHtml(tdiv(class="panel-container")):
|
||||||
tdiv(class="search-bar"):
|
tdiv(class="search-bar"):
|
||||||
form(`method`="get", action="/search", autocomplete="off"):
|
form(`method`="get", action="/search", autocomplete="off"):
|
||||||
hiddenField("f", "users")
|
hiddenField("f", "tweets")
|
||||||
input(`type`="text", name="q", autofocus="",
|
input(`type`="text", name="q", autofocus="",
|
||||||
placeholder="Enter username...", dir="auto")
|
placeholder="Search...", dir="auto")
|
||||||
button(`type`="submit"): icon "search"
|
button(`type`="submit"): icon "search"
|
||||||
|
|
||||||
proc renderProfileTabs*(query: Query; username: string): VNode =
|
proc renderProfileTabs*(query: Query; username: string): VNode =
|
||||||
@@ -41,6 +40,19 @@ proc renderProfileTabs*(query: Query; username: string): VNode =
|
|||||||
li(class=query.getTabClass(tweets)):
|
li(class=query.getTabClass(tweets)):
|
||||||
a(href=(link & "/search")): text "Search"
|
a(href=(link & "/search")): text "Search"
|
||||||
|
|
||||||
|
proc renderMediaViewTabs*(query: Query; username: string): VNode =
|
||||||
|
let currentView = if query.view.len > 0: query.view else: "timeline"
|
||||||
|
let base = "/" & username & "/media?view="
|
||||||
|
func cls(view: string): string =
|
||||||
|
if currentView == view: "tab-item active" else: "tab-item"
|
||||||
|
buildHtml(ul(class="tab media-view-tabs")):
|
||||||
|
li(class=cls("timeline")):
|
||||||
|
a(href=(base & "timeline")): text "Timeline"
|
||||||
|
li(class=cls("grid")):
|
||||||
|
a(href=(base & "grid")): text "Grid"
|
||||||
|
li(class=cls("gallery")):
|
||||||
|
a(href=(base & "gallery")): text "Gallery"
|
||||||
|
|
||||||
proc renderSearchTabs*(query: Query): VNode =
|
proc renderSearchTabs*(query: Query): VNode =
|
||||||
var q = query
|
var q = query
|
||||||
buildHtml(ul(class="tab")):
|
buildHtml(ul(class="tab")):
|
||||||
@@ -53,7 +65,7 @@ proc renderSearchTabs*(query: Query): VNode =
|
|||||||
|
|
||||||
proc isPanelOpen(q: Query): bool =
|
proc isPanelOpen(q: Query): bool =
|
||||||
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or
|
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or
|
||||||
@[q.near, q.until, q.since].anyIt(it.len > 0))
|
@[q.minLikes, q.until, q.since].anyIt(it.len > 0))
|
||||||
|
|
||||||
proc renderSearchPanel*(query: Query): VNode =
|
proc renderSearchPanel*(query: Query): VNode =
|
||||||
let user = query.fromUser.join(",")
|
let user = query.fromUser.join(",")
|
||||||
@@ -85,10 +97,10 @@ proc renderSearchPanel*(query: Query): VNode =
|
|||||||
span(class="search-title"): text "-"
|
span(class="search-title"): text "-"
|
||||||
genDate("until", query.until)
|
genDate("until", query.until)
|
||||||
tdiv:
|
tdiv:
|
||||||
span(class="search-title"): text "Near"
|
span(class="search-title"): text "Minimum likes"
|
||||||
genInput("near", "", query.near, "Location...", autofocus=false)
|
genNumberInput("min_faves", "", query.minLikes, "Number...", autofocus=false)
|
||||||
|
|
||||||
proc renderTweetSearch*(results: Result[Tweet]; prefs: Prefs; path: string;
|
proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
|
||||||
pinned=none(Tweet)): VNode =
|
pinned=none(Tweet)): VNode =
|
||||||
let query = results.query
|
let query = results.query
|
||||||
buildHtml(tdiv(class="timeline-container")):
|
buildHtml(tdiv(class="timeline-container")):
|
||||||
@@ -97,7 +109,10 @@ proc renderTweetSearch*(results: Result[Tweet]; prefs: Prefs; path: string;
|
|||||||
text query.fromUser.join(" | ")
|
text query.fromUser.join(" | ")
|
||||||
|
|
||||||
if query.fromUser.len > 0:
|
if query.fromUser.len > 0:
|
||||||
|
if query.kind != media or query.view != "gallery":
|
||||||
renderProfileTabs(query, query.fromUser.join(","))
|
renderProfileTabs(query, query.fromUser.join(","))
|
||||||
|
if query.kind == media and query.fromUser.len == 1:
|
||||||
|
renderMediaViewTabs(query, query.fromUser[0])
|
||||||
|
|
||||||
if query.fromUser.len == 0 or query.kind == tweets:
|
if query.fromUser.len == 0 or query.kind == tweets:
|
||||||
tdiv(class="timeline-header"):
|
tdiv(class="timeline-header"):
|
||||||
|
|||||||
+22
-3
@@ -28,13 +28,18 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode =
|
|||||||
if thread.hasMore:
|
if thread.hasMore:
|
||||||
renderMoreReplies(thread)
|
renderMoreReplies(thread)
|
||||||
|
|
||||||
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode =
|
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: Tweet = nil): VNode =
|
||||||
buildHtml(tdiv(class="replies", id="r")):
|
buildHtml(tdiv(class="replies", id="r")):
|
||||||
|
var hasReplies = false
|
||||||
|
var replyCount = 0
|
||||||
for thread in replies.content:
|
for thread in replies.content:
|
||||||
if thread.content.len == 0: continue
|
if thread.content.len == 0: continue
|
||||||
|
hasReplies = true
|
||||||
|
replyCount += thread.content.len
|
||||||
renderReplyThread(thread, prefs, path)
|
renderReplyThread(thread, prefs, path)
|
||||||
|
|
||||||
if replies.bottom.len > 0:
|
if hasReplies and replies.bottom.len > 0:
|
||||||
|
if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies:
|
||||||
renderMore(Query(), replies.bottom, focus="#r")
|
renderMore(Query(), replies.bottom, focus="#r")
|
||||||
|
|
||||||
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode =
|
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode =
|
||||||
@@ -70,6 +75,20 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
|
|||||||
if not conv.replies.beginning:
|
if not conv.replies.beginning:
|
||||||
renderNewer(Query(), getLink(conv.tweet), focus="#r")
|
renderNewer(Query(), getLink(conv.tweet), focus="#r")
|
||||||
if conv.replies.content.len > 0 or conv.replies.bottom.len > 0:
|
if conv.replies.content.len > 0 or conv.replies.bottom.len > 0:
|
||||||
renderReplies(conv.replies, prefs, path)
|
renderReplies(conv.replies, prefs, path, conv.tweet)
|
||||||
|
|
||||||
renderToTop(focus="#m")
|
renderToTop(focus="#m")
|
||||||
|
|
||||||
|
proc renderEditHistory*(edits: EditHistory; prefs: Prefs; path: string): VNode =
|
||||||
|
buildHtml(tdiv(class="edit-history")):
|
||||||
|
tdiv(class="latest-edit"):
|
||||||
|
tdiv(class="edit-history-header"):
|
||||||
|
text "Latest post"
|
||||||
|
renderTweet(edits.latest, prefs, path)
|
||||||
|
|
||||||
|
tdiv(class="previous-edits"):
|
||||||
|
tdiv(class="edit-history-header"):
|
||||||
|
text "Version history"
|
||||||
|
for tweet in edits.history:
|
||||||
|
tdiv(class="tweet-edit"):
|
||||||
|
renderTweet(tweet, prefs, path)
|
||||||
|
|||||||
+69
-34
@@ -1,16 +1,42 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import strutils, strformat, sequtils, algorithm, uri, options
|
import strutils, strformat, algorithm, uri, options
|
||||||
import karax/[karaxdsl, vdom]
|
import karax/[karaxdsl, vdom]
|
||||||
|
|
||||||
import ".."/[types, query, formatters]
|
import ".."/[types, query, formatters]
|
||||||
import tweet, renderutils
|
import tweet, renderutils
|
||||||
|
|
||||||
|
proc timelineViewClass(query: Query): string =
|
||||||
|
if query.kind != media:
|
||||||
|
return "timeline"
|
||||||
|
|
||||||
|
case query.view
|
||||||
|
of "grid": "timeline media-grid-view"
|
||||||
|
of "gallery": "timeline media-gallery-view"
|
||||||
|
else: "timeline"
|
||||||
|
|
||||||
proc getQuery(query: Query): string =
|
proc getQuery(query: Query): string =
|
||||||
if query.kind != posts:
|
if query.kind != posts:
|
||||||
result = genQueryUrl(query)
|
result = genQueryUrl(query)
|
||||||
if result.len > 0:
|
if result.len > 0:
|
||||||
result &= "&"
|
result &= "&"
|
||||||
|
|
||||||
|
proc getSearchMaxId(results: Timeline; path: string): string =
|
||||||
|
if results.query.kind != tweets or results.content.len == 0 or
|
||||||
|
results.query.until.len == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
let lastThread = results.content[^1]
|
||||||
|
if lastThread.len == 0 or lastThread[^1].id == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2000000 is the minimum decrement to guarantee no result overlap
|
||||||
|
var maxId = lastThread[^1].id - 2_000_000'i64
|
||||||
|
if maxId <= 0:
|
||||||
|
maxId = lastThread[^1].id - 1
|
||||||
|
|
||||||
|
if maxId > 0:
|
||||||
|
return "maxid:" & $maxId
|
||||||
|
|
||||||
proc renderToTop*(focus="#"): VNode =
|
proc renderToTop*(focus="#"): VNode =
|
||||||
buildHtml(tdiv(class="top-ref")):
|
buildHtml(tdiv(class="top-ref")):
|
||||||
icon "down", href=focus
|
icon "down", href=focus
|
||||||
@@ -39,26 +65,24 @@ proc renderNoneFound(): VNode =
|
|||||||
h2(class="timeline-none"):
|
h2(class="timeline-none"):
|
||||||
text "No items found"
|
text "No items found"
|
||||||
|
|
||||||
proc renderThread(thread: seq[Tweet]; prefs: Prefs; path: string): VNode =
|
proc renderThread(thread: Tweets; prefs: Prefs; path: string; bigThumb=false): VNode =
|
||||||
buildHtml(tdiv(class="thread-line")):
|
buildHtml(tdiv(class="thread-line")):
|
||||||
let sortedThread = thread.sortedByIt(it.id)
|
let sortedThread = thread.sortedByIt(it.id)
|
||||||
for i, tweet in sortedThread:
|
for i, tweet in sortedThread:
|
||||||
|
# thread has a gap, display "more replies" link
|
||||||
|
if i > 0 and tweet.replyId != sortedThread[i - 1].id:
|
||||||
|
tdiv(class="timeline-item thread more-replies-thread"):
|
||||||
|
tdiv(class="more-replies"):
|
||||||
|
a(class="more-replies-text", href=getLink(tweet)):
|
||||||
|
text "more replies"
|
||||||
|
|
||||||
let show = i == thread.high and sortedThread[0].id != tweet.threadId
|
let show = i == thread.high and sortedThread[0].id != tweet.threadId
|
||||||
let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: ""
|
let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: ""
|
||||||
renderTweet(tweet, prefs, path, class=(header & "thread"),
|
renderTweet(tweet, prefs, path, class=(header & "thread"),
|
||||||
index=i, last=(i == thread.high), showThread=show)
|
index=i, last=(i == thread.high), bigThumb=bigThumb)
|
||||||
|
|
||||||
proc threadFilter(tweets: openArray[Tweet]; threads: openArray[int64]; it: Tweet): seq[Tweet] =
|
|
||||||
result = @[it]
|
|
||||||
if it.retweet.isSome or it.replyId in threads: return
|
|
||||||
for t in tweets:
|
|
||||||
if t.id == result[0].replyId:
|
|
||||||
result.insert t
|
|
||||||
elif t.replyId == result[0].id:
|
|
||||||
result.add t
|
|
||||||
|
|
||||||
proc renderUser(user: User; prefs: Prefs): VNode =
|
proc renderUser(user: User; prefs: Prefs): VNode =
|
||||||
buildHtml(tdiv(class="timeline-item")):
|
buildHtml(tdiv(class="timeline-item", data-username=user.username)):
|
||||||
a(class="tweet-link", href=("/" & user.username))
|
a(class="tweet-link", href=("/" & user.username))
|
||||||
tdiv(class="tweet-body profile-result"):
|
tdiv(class="tweet-body profile-result"):
|
||||||
tdiv(class="tweet-header"):
|
tdiv(class="tweet-header"):
|
||||||
@@ -68,6 +92,7 @@ proc renderUser(user: User; prefs: Prefs): VNode =
|
|||||||
tdiv(class="tweet-name-row"):
|
tdiv(class="tweet-name-row"):
|
||||||
tdiv(class="fullname-and-username"):
|
tdiv(class="fullname-and-username"):
|
||||||
linkUser(user, class="fullname")
|
linkUser(user, class="fullname")
|
||||||
|
verifiedIcon(user)
|
||||||
linkUser(user, class="username")
|
linkUser(user, class="username")
|
||||||
|
|
||||||
tdiv(class="tweet-content media-body", dir="auto"):
|
tdiv(class="tweet-content media-body", dir="auto"):
|
||||||
@@ -89,15 +114,28 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode =
|
|||||||
else:
|
else:
|
||||||
renderNoMore()
|
renderNoMore()
|
||||||
|
|
||||||
proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string;
|
proc filterThreads(threads: seq[Tweets]; prefs: Prefs): seq[Tweets] =
|
||||||
|
var retweets: seq[int64]
|
||||||
|
for thread in threads:
|
||||||
|
if thread.len == 1:
|
||||||
|
let tweet = thread[0]
|
||||||
|
let retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
|
||||||
|
if retweetId in retweets or tweet.id in retweets or
|
||||||
|
tweet.pinned and prefs.hidePins:
|
||||||
|
continue
|
||||||
|
if retweetId != 0 and tweet.retweet.isSome:
|
||||||
|
retweets &= retweetId
|
||||||
|
result.add(thread)
|
||||||
|
|
||||||
|
proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
|
||||||
pinned=none(Tweet)): VNode =
|
pinned=none(Tweet)): VNode =
|
||||||
buildHtml(tdiv(class="timeline")):
|
buildHtml(tdiv(class=results.query.timelineViewClass)):
|
||||||
if not results.beginning:
|
if not results.beginning:
|
||||||
renderNewer(results.query, parseUri(path).path)
|
renderNewer(results.query, parseUri(path).path)
|
||||||
|
|
||||||
if not prefs.hidePins and pinned.isSome:
|
if not prefs.hidePins and pinned.isSome:
|
||||||
let tweet = get pinned
|
let tweet = get pinned
|
||||||
renderTweet(tweet, prefs, path, showThread=tweet.hasThread)
|
renderTweet(tweet, prefs, path)
|
||||||
|
|
||||||
if results.content.len == 0:
|
if results.content.len == 0:
|
||||||
if not results.beginning:
|
if not results.beginning:
|
||||||
@@ -105,26 +143,23 @@ proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string;
|
|||||||
else:
|
else:
|
||||||
renderNoneFound()
|
renderNoneFound()
|
||||||
else:
|
else:
|
||||||
var
|
let filtered = filterThreads(results.content, prefs)
|
||||||
threads: seq[int64]
|
|
||||||
retweets: seq[int64]
|
|
||||||
|
|
||||||
for tweet in results.content:
|
if results.query.view == "gallery":
|
||||||
let rt = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
|
let bigThumb = prefs.gallerySize == "Large"
|
||||||
|
let galClass = if prefs.compactGallery: "gallery-masonry compact" else: "gallery-masonry"
|
||||||
if tweet.id in threads or rt in retweets or tweet.id in retweets or
|
tdiv(class=galClass, `data-col-size`=prefs.gallerySize.toLowerAscii):
|
||||||
tweet.pinned and prefs.hidePins: continue
|
for thread in filtered:
|
||||||
|
if thread.len == 1: renderTweet(thread[0], prefs, path, bigThumb=bigThumb)
|
||||||
let thread = results.content.threadFilter(threads, tweet)
|
else: renderThread(thread, prefs, path, bigThumb)
|
||||||
if thread.len < 2:
|
|
||||||
var hasThread = tweet.hasThread
|
|
||||||
if rt != 0:
|
|
||||||
retweets &= rt
|
|
||||||
hasThread = get(tweet.retweet).hasThread
|
|
||||||
renderTweet(tweet, prefs, path, showThread=hasThread)
|
|
||||||
else:
|
else:
|
||||||
renderThread(thread, prefs, path)
|
for thread in filtered:
|
||||||
threads &= thread.mapIt(it.id)
|
if thread.len == 1: renderTweet(thread[0], prefs, path)
|
||||||
|
else: renderThread(thread, prefs, path)
|
||||||
|
|
||||||
|
var cursor = getSearchMaxId(results, path)
|
||||||
|
if cursor.len > 0:
|
||||||
|
renderMore(results.query, cursor)
|
||||||
|
elif results.bottom.len > 0:
|
||||||
renderMore(results.query, results.bottom)
|
renderMore(results.query, results.bottom)
|
||||||
renderToTop()
|
renderToTop()
|
||||||
|
|||||||
+165
-87
@@ -10,19 +10,16 @@ import general
|
|||||||
const doctype = "<!DOCTYPE html>\n"
|
const doctype = "<!DOCTYPE html>\n"
|
||||||
|
|
||||||
proc renderMiniAvatar(user: User; prefs: Prefs): VNode =
|
proc renderMiniAvatar(user: User; prefs: Prefs): VNode =
|
||||||
let url = getPicUrl(user.getUserPic("_mini"))
|
genImg(user.getUserPic("_mini"), class=(prefs.getAvatarClass & " mini"))
|
||||||
buildHtml():
|
|
||||||
img(class=(prefs.getAvatarClass & " mini"), src=url)
|
|
||||||
|
|
||||||
proc renderHeader(tweet: Tweet; retweet: string; prefs: Prefs): VNode =
|
proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode =
|
||||||
buildHtml(tdiv):
|
buildHtml(tdiv):
|
||||||
if retweet.len > 0:
|
if pinned:
|
||||||
tdiv(class="retweet-header"):
|
|
||||||
span: icon "retweet", retweet & " retweeted"
|
|
||||||
|
|
||||||
if tweet.pinned:
|
|
||||||
tdiv(class="pinned"):
|
tdiv(class="pinned"):
|
||||||
span: icon "pin", "Pinned Tweet"
|
span: icon "pin", "Pinned Tweet"
|
||||||
|
elif retweet.len > 0:
|
||||||
|
tdiv(class="retweet-header"):
|
||||||
|
span: icon "retweet", retweet & " retweeted"
|
||||||
|
|
||||||
tdiv(class="tweet-header"):
|
tdiv(class="tweet-header"):
|
||||||
a(class="tweet-avatar", href=("/" & tweet.user.username)):
|
a(class="tweet-avatar", href=("/" & tweet.user.username)):
|
||||||
@@ -34,28 +31,28 @@ proc renderHeader(tweet: Tweet; retweet: string; prefs: Prefs): VNode =
|
|||||||
tdiv(class="tweet-name-row"):
|
tdiv(class="tweet-name-row"):
|
||||||
tdiv(class="fullname-and-username"):
|
tdiv(class="fullname-and-username"):
|
||||||
linkUser(tweet.user, class="fullname")
|
linkUser(tweet.user, class="fullname")
|
||||||
|
verifiedIcon(tweet.user)
|
||||||
linkUser(tweet.user, class="username")
|
linkUser(tweet.user, class="username")
|
||||||
|
|
||||||
span(class="tweet-date"):
|
span(class="tweet-date"):
|
||||||
a(href=getLink(tweet), title=tweet.getTime):
|
a(href=getLink(tweet), title=tweet.getTime):
|
||||||
text tweet.getShortTime
|
text tweet.getShortTime
|
||||||
|
|
||||||
proc renderAlbum(tweet: Tweet): VNode =
|
proc renderAltText(altText: string): VNode =
|
||||||
let
|
buildHtml(p(class="alt-text")):
|
||||||
groups = if tweet.photos.len < 3: @[tweet.photos]
|
text "ALT " & altText
|
||||||
else: tweet.photos.distribute(2)
|
|
||||||
|
|
||||||
buildHtml(tdiv(class="attachments")):
|
proc renderPhotoAttachment(photo: Photo; bigThumb=false): VNode =
|
||||||
for i, photos in groups:
|
buildHtml(tdiv(class="attachment")):
|
||||||
let margin = if i > 0: ".25em" else: ""
|
|
||||||
tdiv(class="gallery-row", style={marginTop: margin}):
|
|
||||||
for photo in photos:
|
|
||||||
tdiv(class="attachment image"):
|
|
||||||
let
|
let
|
||||||
named = "name=" in photo
|
named = "name=" in photo.url
|
||||||
small = if named: photo else: photo & smallWebp
|
thumb = if named: photo.url
|
||||||
a(href=getOrigPicUrl(photo), class="still-image", target="_blank"):
|
elif bigThumb: photo.url & mediumWebp
|
||||||
genImg(small)
|
else: photo.url & smallWebp
|
||||||
|
a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"):
|
||||||
|
genImg(thumb, alt=photo.altText)
|
||||||
|
if photo.altText.len > 0:
|
||||||
|
renderAltText(photo.altText)
|
||||||
|
|
||||||
proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
|
proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
|
||||||
case playbackType
|
case playbackType
|
||||||
@@ -65,7 +62,7 @@ proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
|
|||||||
proc hasMp4Url(video: Video): bool =
|
proc hasMp4Url(video: Video): bool =
|
||||||
video.variants.anyIt(it.contentType == mp4)
|
video.variants.anyIt(it.contentType == mp4)
|
||||||
|
|
||||||
proc renderVideoDisabled(playbackType: VideoType; path: string): VNode =
|
proc renderVideoDisabled(playbackType: VideoType; path=""): VNode =
|
||||||
buildHtml(tdiv(class="video-overlay")):
|
buildHtml(tdiv(class="video-overlay")):
|
||||||
case playbackType
|
case playbackType
|
||||||
of mp4:
|
of mp4:
|
||||||
@@ -81,29 +78,25 @@ proc renderVideoUnavailable(video: Video): VNode =
|
|||||||
else:
|
else:
|
||||||
p: text "This media is unavailable"
|
p: text "This media is unavailable"
|
||||||
|
|
||||||
proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode =
|
proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""; bigThumb=false): VNode =
|
||||||
let
|
let
|
||||||
container = if video.description.len == 0 and video.title.len == 0: ""
|
playbackType = if not prefs.proxyVideos and videoData.hasMp4Url: mp4
|
||||||
else: " card-container"
|
else: videoData.playbackType
|
||||||
playbackType = if not prefs.proxyVideos and video.hasMp4Url: mp4
|
thumb = if bigThumb: getMediumPic(videoData.thumb) else: getSmallPic(videoData.thumb)
|
||||||
else: video.playbackType
|
|
||||||
|
|
||||||
buildHtml(tdiv(class="attachments card")):
|
buildHtml(tdiv(class="attachment")):
|
||||||
tdiv(class="gallery-video" & container):
|
if not videoData.available:
|
||||||
tdiv(class="attachment video-container"):
|
img(src=thumb, loading="lazy")
|
||||||
let thumb = getSmallPic(video.thumb)
|
renderVideoUnavailable(videoData)
|
||||||
if not video.available:
|
|
||||||
img(src=thumb)
|
|
||||||
renderVideoUnavailable(video)
|
|
||||||
elif not prefs.isPlaybackEnabled(playbackType):
|
elif not prefs.isPlaybackEnabled(playbackType):
|
||||||
img(src=thumb)
|
img(src=thumb, loading="lazy")
|
||||||
renderVideoDisabled(playbackType, path)
|
renderVideoDisabled(playbackType, path)
|
||||||
else:
|
else:
|
||||||
let
|
let
|
||||||
vars = video.variants.filterIt(it.contentType == playbackType)
|
vars = videoData.variants.filterIt(it.contentType == playbackType)
|
||||||
vidUrl = vars.sortedByIt(it.resolution)[^1].url
|
vidUrl = vars.sortedByIt(it.resolution)[^1].url
|
||||||
source = if prefs.proxyVideos: getVidUrl(vidUrl)
|
source = if prefs.proxyVideos and vidUrl.startsWith("http"):
|
||||||
else: vidUrl
|
getVidUrl(vidUrl) else: vidUrl
|
||||||
case playbackType
|
case playbackType
|
||||||
of mp4:
|
of mp4:
|
||||||
video(poster=thumb, controls="", muted=prefs.muteVideos):
|
video(poster=thumb, controls="", muted=prefs.muteVideos):
|
||||||
@@ -112,20 +105,71 @@ proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode =
|
|||||||
video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos)
|
video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos)
|
||||||
verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
|
verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
|
||||||
tdiv(class="overlay-circle"): span(class="overlay-triangle")
|
tdiv(class="overlay-circle"): span(class="overlay-triangle")
|
||||||
|
if videoData.durationMs > 0:
|
||||||
|
tdiv(class="overlay-duration"): text getDuration(videoData)
|
||||||
verbatim "</div>"
|
verbatim "</div>"
|
||||||
if container.len > 0:
|
|
||||||
|
proc renderVideo*(video: Video; prefs: Prefs; path: string; bigThumb=false): VNode =
|
||||||
|
let hasCardContent = video.description.len > 0 or video.title.len > 0
|
||||||
|
|
||||||
|
buildHtml(tdiv(class="attachments card")):
|
||||||
|
tdiv(class=("gallery-video" & (if hasCardContent: " card-container" else: ""))):
|
||||||
|
renderVideoAttachment(video, prefs, path, bigThumb)
|
||||||
|
if hasCardContent:
|
||||||
tdiv(class="card-content"):
|
tdiv(class="card-content"):
|
||||||
h2(class="card-title"): text video.title
|
h2(class="card-title"): text video.title
|
||||||
if video.description.len > 0:
|
if video.description.len > 0:
|
||||||
p(class="card-description"): text video.description
|
p(class="card-description"): text video.description
|
||||||
|
|
||||||
|
proc renderGifAttachment(gif: Gif; prefs: Prefs): VNode =
|
||||||
|
let thumb = getSmallPic(gif.thumb)
|
||||||
|
|
||||||
|
buildHtml(tdiv(class="attachment")):
|
||||||
|
if not prefs.mp4Playback:
|
||||||
|
img(src=thumb, loading="lazy")
|
||||||
|
renderVideoDisabled(mp4)
|
||||||
|
elif prefs.autoplayGifs:
|
||||||
|
video(class="gif", poster=thumb, autoplay="", muted="", loop=""):
|
||||||
|
source(src=getPicUrl(gif.url), `type`="video/mp4")
|
||||||
|
else:
|
||||||
|
video(class="gif", poster=thumb, controls="", muted="", loop=""):
|
||||||
|
source(src=getPicUrl(gif.url), `type`="video/mp4")
|
||||||
|
if gif.altText.len > 0:
|
||||||
|
renderAltText(gif.altText)
|
||||||
|
|
||||||
proc renderGif(gif: Gif; prefs: Prefs): VNode =
|
proc renderGif(gif: Gif; prefs: Prefs): VNode =
|
||||||
buildHtml(tdiv(class="attachments media-gif")):
|
buildHtml(tdiv(class="attachments media-gif")):
|
||||||
tdiv(class="gallery-gif", style={maxHeight: "unset"}):
|
renderGifAttachment(gif, prefs)
|
||||||
tdiv(class="attachment"):
|
|
||||||
video(class="gif", poster=getSmallPic(gif.thumb), autoplay=prefs.autoplayGifs,
|
proc renderMedia(media: seq[Media]; prefs: Prefs; path: string; bigThumb=false): VNode =
|
||||||
controls="", muted="", loop=""):
|
if media.len == 0:
|
||||||
source(src=getPicUrl(gif.url), `type`="video/mp4")
|
return nil
|
||||||
|
|
||||||
|
if media.len == 1:
|
||||||
|
let item = media[0]
|
||||||
|
if item.kind == videoMedia:
|
||||||
|
return renderVideo(item.video, prefs, path, bigThumb)
|
||||||
|
if item.kind == gifMedia:
|
||||||
|
return renderGif(item.gif, prefs)
|
||||||
|
|
||||||
|
let
|
||||||
|
groups = if media.len < 3: @[media]
|
||||||
|
else: media.distribute(2)
|
||||||
|
|
||||||
|
buildHtml(tdiv(class="attachments")):
|
||||||
|
for i, mediaGroup in groups:
|
||||||
|
let margin = if i > 0: ".25em" else: ""
|
||||||
|
let rowClass = "gallery-row" &
|
||||||
|
(if mediaGroup.allIt(it.kind == photoMedia): "" else: " mixed-row")
|
||||||
|
tdiv(class=rowClass, style={marginTop: margin}):
|
||||||
|
for mediaItem in mediaGroup:
|
||||||
|
case mediaItem.kind
|
||||||
|
of photoMedia:
|
||||||
|
renderPhotoAttachment(mediaItem.photo, bigThumb)
|
||||||
|
of videoMedia:
|
||||||
|
renderVideoAttachment(mediaItem.video, prefs, path, bigThumb)
|
||||||
|
of gifMedia:
|
||||||
|
renderGifAttachment(mediaItem.gif, prefs)
|
||||||
|
|
||||||
proc renderPoll(poll: Poll): VNode =
|
proc renderPoll(poll: Poll): VNode =
|
||||||
buildHtml(tdiv(class="poll")):
|
buildHtml(tdiv(class="poll")):
|
||||||
@@ -145,7 +189,7 @@ proc renderPoll(poll: Poll): VNode =
|
|||||||
proc renderCardImage(card: Card): VNode =
|
proc renderCardImage(card: Card): VNode =
|
||||||
buildHtml(tdiv(class="card-image-container")):
|
buildHtml(tdiv(class="card-image-container")):
|
||||||
tdiv(class="card-image"):
|
tdiv(class="card-image"):
|
||||||
img(src=getPicUrl(card.image), alt="")
|
genImg(card.image)
|
||||||
if card.kind == player:
|
if card.kind == player:
|
||||||
tdiv(class="card-overlay"):
|
tdiv(class="card-overlay"):
|
||||||
tdiv(class="overlay-circle"):
|
tdiv(class="overlay-circle"):
|
||||||
@@ -181,14 +225,12 @@ func formatStat(stat: int): string =
|
|||||||
if stat > 0: insertSep($stat, ',')
|
if stat > 0: insertSep($stat, ',')
|
||||||
else: ""
|
else: ""
|
||||||
|
|
||||||
proc renderStats(stats: TweetStats; views: string): VNode =
|
proc renderStats(stats: TweetStats): VNode =
|
||||||
buildHtml(tdiv(class="tweet-stats")):
|
buildHtml(tdiv(class="tweet-stats")):
|
||||||
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
|
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
|
||||||
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
|
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
|
||||||
span(class="tweet-stat"): icon "quote", formatStat(stats.quotes)
|
|
||||||
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
|
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
|
||||||
if views.len > 0:
|
span(class="tweet-stat"): icon "views", formatStat(stats.views)
|
||||||
span(class="tweet-stat"): icon "play", insertSep(views, ',')
|
|
||||||
|
|
||||||
proc renderReply(tweet: Tweet): VNode =
|
proc renderReply(tweet: Tweet): VNode =
|
||||||
buildHtml(tdiv(class="replying-to")):
|
buildHtml(tdiv(class="replying-to")):
|
||||||
@@ -197,12 +239,12 @@ proc renderReply(tweet: Tweet): VNode =
|
|||||||
if i > 0: text " "
|
if i > 0: text " "
|
||||||
a(href=("/" & u)): text "@" & u
|
a(href=("/" & u)): text "@" & u
|
||||||
|
|
||||||
proc renderAttribution(user: User; prefs: Prefs): VNode =
|
proc renderAttribution(user: User; prefs: Prefs; link = ""): VNode =
|
||||||
buildHtml(a(class="attribution", href=("/" & user.username))):
|
let href = if link.len > 0: link else: "/" & user.username
|
||||||
|
buildHtml(a(class="attribution", href=href)):
|
||||||
renderMiniAvatar(user, prefs)
|
renderMiniAvatar(user, prefs)
|
||||||
strong: text user.fullname
|
strong: text user.fullname
|
||||||
if user.verified:
|
verifiedIcon(user)
|
||||||
icon "ok", class="verified-icon", title="Verified account"
|
|
||||||
|
|
||||||
proc renderMediaTags(tags: seq[User]): VNode =
|
proc renderMediaTags(tags: seq[User]): VNode =
|
||||||
buildHtml(tdiv(class="media-tag-block")):
|
buildHtml(tdiv(class="media-tag-block")):
|
||||||
@@ -213,19 +255,28 @@ proc renderMediaTags(tags: seq[User]): VNode =
|
|||||||
if i < tags.high:
|
if i < tags.high:
|
||||||
text ", "
|
text ", "
|
||||||
|
|
||||||
|
proc renderLatestPost(username: string; id: int64): VNode =
|
||||||
|
buildHtml(tdiv(class="latest-post-version")):
|
||||||
|
text "There's a new version of this post. "
|
||||||
|
a(href=getLink(id, username)):
|
||||||
|
text "See the latest post"
|
||||||
|
|
||||||
|
proc renderCommunityNote(note: string; prefs: Prefs): VNode =
|
||||||
|
buildHtml(tdiv(class="community-note")):
|
||||||
|
tdiv(class="community-note-header"):
|
||||||
|
icon "group"
|
||||||
|
span: text "Community note"
|
||||||
|
tdiv(class="community-note-text", dir="auto"):
|
||||||
|
verbatim replaceUrls(note, prefs)
|
||||||
|
|
||||||
proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode =
|
proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode =
|
||||||
buildHtml(tdiv(class="quote-media-container")):
|
buildHtml(tdiv(class="quote-media-container")):
|
||||||
if quote.photos.len > 0:
|
renderMedia(quote.media, prefs, path)
|
||||||
renderAlbum(quote)
|
|
||||||
elif quote.video.isSome:
|
|
||||||
renderVideo(quote.video.get(), prefs, path)
|
|
||||||
elif quote.gif.isSome:
|
|
||||||
renderGif(quote.gif.get(), prefs)
|
|
||||||
|
|
||||||
proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
|
proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
|
||||||
if not quote.available:
|
if not quote.available:
|
||||||
return buildHtml(tdiv(class="quote unavailable")):
|
return buildHtml(tdiv(class="quote unavailable")):
|
||||||
tdiv(class="unavailable-quote"):
|
a(class="unavailable-quote", href=getLink(quote, focus=false)):
|
||||||
if quote.tombstone.len > 0:
|
if quote.tombstone.len > 0:
|
||||||
text quote.tombstone
|
text quote.tombstone
|
||||||
elif quote.text.len > 0:
|
elif quote.text.len > 0:
|
||||||
@@ -240,6 +291,7 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
|
|||||||
tdiv(class="fullname-and-username"):
|
tdiv(class="fullname-and-username"):
|
||||||
renderMiniAvatar(quote.user, prefs)
|
renderMiniAvatar(quote.user, prefs)
|
||||||
linkUser(quote.user, class="fullname")
|
linkUser(quote.user, class="fullname")
|
||||||
|
verifiedIcon(quote.user)
|
||||||
linkUser(quote.user, class="username")
|
linkUser(quote.user, class="username")
|
||||||
|
|
||||||
span(class="tweet-date"):
|
span(class="tweet-date"):
|
||||||
@@ -253,12 +305,28 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
|
|||||||
tdiv(class="quote-text", dir="auto"):
|
tdiv(class="quote-text", dir="auto"):
|
||||||
verbatim replaceUrls(quote.text, prefs)
|
verbatim replaceUrls(quote.text, prefs)
|
||||||
|
|
||||||
|
if quote.media.len > 0:
|
||||||
|
renderQuoteMedia(quote, prefs, path)
|
||||||
|
|
||||||
|
if quote.note.len > 0 and not prefs.hideCommunityNotes:
|
||||||
|
renderCommunityNote(quote.note, prefs)
|
||||||
|
|
||||||
if quote.hasThread:
|
if quote.hasThread:
|
||||||
a(class="show-thread", href=getLink(quote)):
|
a(class="show-thread", href=getLink(quote)):
|
||||||
text "Show this thread"
|
text "Show this thread"
|
||||||
|
|
||||||
if quote.photos.len > 0 or quote.video.isSome or quote.gif.isSome:
|
if quote.history.len > 0 and quote.id != max(quote.history):
|
||||||
renderQuoteMedia(quote, prefs, path)
|
tdiv(class="quote-latest"):
|
||||||
|
text "There's a new version of this post"
|
||||||
|
|
||||||
|
proc renderDisclosures*(tweet: Tweet): VNode =
|
||||||
|
buildHtml(tdiv(class="disclosures")):
|
||||||
|
if tweet.isAI:
|
||||||
|
span(data-disclosure="ai"):
|
||||||
|
icon "attention", "Made with AI"
|
||||||
|
if tweet.isAd:
|
||||||
|
span(data-disclosure="ad"):
|
||||||
|
icon "attention", "Paid partnership (ad)"
|
||||||
|
|
||||||
proc renderLocation*(tweet: Tweet): string =
|
proc renderLocation*(tweet: Tweet): string =
|
||||||
let (place, url) = tweet.getLocation()
|
let (place, url) = tweet.getLocation()
|
||||||
@@ -272,14 +340,14 @@ proc renderLocation*(tweet: Tweet): string =
|
|||||||
return $node
|
return $node
|
||||||
|
|
||||||
proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||||
last=false; showThread=false; mainTweet=false; afterTweet=false): VNode =
|
last=false; mainTweet=false; afterTweet=false; bigThumb=false): VNode =
|
||||||
var divClass = class
|
var divClass = class
|
||||||
if index == -1 or last:
|
if index == -1 or last:
|
||||||
divClass = "thread-last " & class
|
divClass = "thread-last " & class
|
||||||
|
|
||||||
if not tweet.available:
|
if not tweet.available:
|
||||||
return buildHtml(tdiv(class=divClass & "unavailable timeline-item")):
|
return buildHtml(tdiv(class=divClass & "unavailable timeline-item", data-username=tweet.user.username)):
|
||||||
tdiv(class="unavailable-box"):
|
a(class="unavailable-box", href=getLink(tweet)):
|
||||||
if tweet.tombstone.len > 0:
|
if tweet.tombstone.len > 0:
|
||||||
text tweet.tombstone
|
text tweet.tombstone
|
||||||
elif tweet.text.len > 0:
|
elif tweet.text.len > 0:
|
||||||
@@ -290,23 +358,25 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||||||
if tweet.quote.isSome:
|
if tweet.quote.isSome:
|
||||||
renderQuote(tweet.quote.get(), prefs, path)
|
renderQuote(tweet.quote.get(), prefs, path)
|
||||||
|
|
||||||
let fullTweet = tweet
|
let
|
||||||
|
fullTweet = tweet
|
||||||
|
pinned = tweet.pinned
|
||||||
|
|
||||||
var retweet: string
|
var retweet: string
|
||||||
var tweet = fullTweet
|
var tweet = fullTweet
|
||||||
if tweet.retweet.isSome:
|
if tweet.retweet.isSome:
|
||||||
tweet = tweet.retweet.get
|
tweet = tweet.retweet.get
|
||||||
retweet = fullTweet.user.fullname
|
retweet = fullTweet.user.fullname
|
||||||
|
|
||||||
buildHtml(tdiv(class=("timeline-item " & divClass))):
|
buildHtml(tdiv(class=("timeline-item " & divClass), data-username=tweet.user.username)):
|
||||||
if not mainTweet:
|
if not mainTweet:
|
||||||
a(class="tweet-link", href=getLink(tweet))
|
a(class="tweet-link", href=getLink(tweet))
|
||||||
|
|
||||||
tdiv(class="tweet-body"):
|
tdiv(class="tweet-body"):
|
||||||
var views = ""
|
renderHeader(tweet, retweet, pinned, prefs)
|
||||||
renderHeader(tweet, retweet, prefs)
|
|
||||||
|
|
||||||
if not afterTweet and index == 0 and tweet.reply.len > 0 and
|
if not afterTweet and index == 0 and tweet.reply.len > 0 and
|
||||||
(tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username):
|
(tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username or pinned):
|
||||||
renderReply(tweet)
|
renderReply(tweet)
|
||||||
|
|
||||||
var tweetClass = "tweet-content media-body"
|
var tweetClass = "tweet-content media-body"
|
||||||
@@ -317,19 +387,13 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||||||
verbatim replaceUrls(tweet.text, prefs) & renderLocation(tweet)
|
verbatim replaceUrls(tweet.text, prefs) & renderLocation(tweet)
|
||||||
|
|
||||||
if tweet.attribution.isSome:
|
if tweet.attribution.isSome:
|
||||||
renderAttribution(tweet.attribution.get(), prefs)
|
renderAttribution(tweet.attribution.get(), prefs, tweet.attributionLink)
|
||||||
|
|
||||||
if tweet.card.isSome and tweet.card.get().kind != hidden:
|
if tweet.card.isSome and tweet.card.get().kind != hidden:
|
||||||
renderCard(tweet.card.get(), prefs, path)
|
renderCard(tweet.card.get(), prefs, path)
|
||||||
|
|
||||||
if tweet.photos.len > 0:
|
if tweet.media.len > 0:
|
||||||
renderAlbum(tweet)
|
renderMedia(tweet.media, prefs, path, bigThumb)
|
||||||
elif tweet.video.isSome:
|
|
||||||
renderVideo(tweet.video.get(), prefs, path)
|
|
||||||
views = tweet.video.get().views
|
|
||||||
elif tweet.gif.isSome:
|
|
||||||
renderGif(tweet.gif.get(), prefs)
|
|
||||||
views = "GIF"
|
|
||||||
|
|
||||||
if tweet.poll.isSome:
|
if tweet.poll.isSome:
|
||||||
renderPoll(tweet.poll.get())
|
renderPoll(tweet.poll.get())
|
||||||
@@ -337,18 +401,32 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||||||
if tweet.quote.isSome:
|
if tweet.quote.isSome:
|
||||||
renderQuote(tweet.quote.get(), prefs, path)
|
renderQuote(tweet.quote.get(), prefs, path)
|
||||||
|
|
||||||
|
if tweet.note.len > 0 and not prefs.hideCommunityNotes:
|
||||||
|
renderCommunityNote(tweet.note, prefs)
|
||||||
|
|
||||||
|
if tweet.isAI or tweet.isAd:
|
||||||
|
renderDisclosures(tweet)
|
||||||
|
|
||||||
|
let
|
||||||
|
hasEdits = tweet.history.len > 1
|
||||||
|
isLatest = hasEdits and tweet.id == max(tweet.history)
|
||||||
|
|
||||||
if mainTweet:
|
if mainTweet:
|
||||||
p(class="tweet-published"): text &"{getTime(tweet)}"
|
p(class="tweet-published"):
|
||||||
|
if hasEdits and isLatest:
|
||||||
|
a(href=(getLink(tweet, focus=false) & "/history")):
|
||||||
|
text &"Last edited {getTime(tweet)}"
|
||||||
|
else:
|
||||||
|
text &"{getTime(tweet)}"
|
||||||
|
|
||||||
|
if hasEdits and not isLatest:
|
||||||
|
renderLatestPost(tweet.user.username, max(tweet.history))
|
||||||
|
|
||||||
if tweet.mediaTags.len > 0:
|
if tweet.mediaTags.len > 0:
|
||||||
renderMediaTags(tweet.mediaTags)
|
renderMediaTags(tweet.mediaTags)
|
||||||
|
|
||||||
if not prefs.hideTweetStats:
|
if not prefs.hideTweetStats:
|
||||||
renderStats(tweet.stats, views)
|
renderStats(tweet.stats)
|
||||||
|
|
||||||
if showThread:
|
|
||||||
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
|
|
||||||
text "Show this thread"
|
|
||||||
|
|
||||||
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
|
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
|
||||||
let node = buildHtml(html(lang="en")):
|
let node = buildHtml(html(lang="en")):
|
||||||
|
|||||||
+8
-1
@@ -54,6 +54,13 @@ class Timeline(object):
|
|||||||
none = '.timeline-none'
|
none = '.timeline-none'
|
||||||
protected = '.timeline-protected'
|
protected = '.timeline-protected'
|
||||||
photo_rail = '.photo-rail-grid'
|
photo_rail = '.photo-rail-grid'
|
||||||
|
media_view_tabs = '.media-view-tabs'
|
||||||
|
media_view_timeline = '.media-view-tabs a[href$="media?view=timeline"]'
|
||||||
|
media_view_grid = '.media-view-tabs a[href$="media?view=grid"]'
|
||||||
|
media_view_gallery = '.media-view-tabs a[href$="media?view=gallery"]'
|
||||||
|
media_view_active = '.media-view-tabs .tab-item.active a'
|
||||||
|
grid_view = '.timeline.media-grid-view'
|
||||||
|
gallery_view = '.timeline.media-gallery-view'
|
||||||
|
|
||||||
|
|
||||||
class Conversation(object):
|
class Conversation(object):
|
||||||
@@ -79,7 +86,7 @@ class Media(object):
|
|||||||
row = '.gallery-row'
|
row = '.gallery-row'
|
||||||
image = '.still-image'
|
image = '.still-image'
|
||||||
video = '.gallery-video'
|
video = '.gallery-video'
|
||||||
gif = '.gallery-gif'
|
gif = '.media-gif'
|
||||||
|
|
||||||
|
|
||||||
class BaseTestCase(BaseCase):
|
class BaseTestCase(BaseCase):
|
||||||
|
|||||||
Generated
+1716
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user