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:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
group: docker-publish-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
IMAGE: zedeus/nitter
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
uses: ./.github/workflows/run-tests.yml
|
||||
build-docker-amd64:
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
needs: [tests]
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-24.04
|
||||
platform: linux/amd64
|
||||
- runner: ubuntu-24.04-arm
|
||||
platform: linux/arm64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Prepare platform name
|
||||
run: echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
|
||||
env:
|
||||
platform: ${{ matrix.platform }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push AMD64 Docker image
|
||||
uses: docker/build-push-action@v3
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }}
|
||||
build-docker-arm64:
|
||||
needs: [tests]
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p "${{ runner.temp }}/digests"
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Combine the per-arch digests into one multi-arch manifest so that
|
||||
# `docker pull zedeus/nitter:latest` serves the right image on any CPU.
|
||||
merge:
|
||||
needs: [build]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push ARM64 Docker image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.arm64
|
||||
platforms: linux/arm64
|
||||
push: true
|
||||
tags: zedeus/nitter:latest-arm64,zedeus/nitter:${{ github.sha }}-arm64
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.IMAGE }}:latest \
|
||||
-t ${{ env.IMAGE }}:latest-arm64 \
|
||||
-t ${{ env.IMAGE }}:${{ github.sha }} \
|
||||
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ github.sha }}
|
||||
|
||||
+130
-25
@@ -8,38 +8,143 @@ on:
|
||||
- master
|
||||
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:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
build-test:
|
||||
name: Build and test
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
nim: ["2.0.x", "2.2.x", "devel"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Cache nimble
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Cache Nimble Dependencies
|
||||
id: cache-nimble
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.nimble
|
||||
key: nimble-${{ hashFiles('*.nimble') }}
|
||||
restore-keys: "nimble-"
|
||||
- uses: actions/setup-python@v4
|
||||
path: |
|
||||
~/.nimble/pkgcache
|
||||
~/.nimble/packages_official.json
|
||||
key: ${{ matrix.nim }}-nimble-v6-${{ hashFiles('*.nimble') }}
|
||||
restore-keys: |
|
||||
${{ matrix.nim }}-nimble-v6-
|
||||
|
||||
- name: Setup Nim
|
||||
uses: jiro4989/setup-nim-action@v2
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: "pip"
|
||||
- uses: jiro4989/setup-nim-action@v1
|
||||
nim-version: ${{ matrix.nim }}
|
||||
use-nightlies: true
|
||||
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:
|
||||
nim-version: "1.x"
|
||||
- run: nimble build -d:release -Y
|
||||
- run: pip install seleniumbase
|
||||
- run: seleniumbase install chromedriver
|
||||
- uses: supercharge/redis-github-action@1.5.0
|
||||
- name: Prepare Nitter
|
||||
name: nitter-linux-nim-2.2.x-${{ github.sha }}
|
||||
path: |
|
||||
./nitter
|
||||
if-no-files-found: error
|
||||
|
||||
integration-test:
|
||||
needs: [build-test]
|
||||
name: Integration test
|
||||
runs-on: 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: |
|
||||
sudo apt install libsass-dev -y
|
||||
cp nitter.example.conf nitter.conf
|
||||
nimble md
|
||||
nimble scss
|
||||
- name: Run tests
|
||||
sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf
|
||||
sed -i 's/maxRetries = 1/maxRetries = 10/g' nitter.conf
|
||||
|
||||
nim r tools/rendermd.nim
|
||||
nim r tools/gencss.nim
|
||||
|
||||
echo '${{ secrets.SESSIONS }}' | head -n1
|
||||
echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
./nitter &
|
||||
pytest -n4 tests
|
||||
cd tests
|
||||
poetry run pytest -n3 --reruns=5 --rs .
|
||||
|
||||
@@ -10,4 +10,11 @@ nitter
|
||||
/public/css/style.css
|
||||
/public/md/*.html
|
||||
nitter.conf
|
||||
guest_accounts.json*
|
||||
sessions.json*
|
||||
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"
|
||||
|
||||
RUN apk --no-cache add libsass-dev pcre
|
||||
@@ -9,13 +9,13 @@ COPY nitter.nimble .
|
||||
RUN nimble install -y --depsOnly
|
||||
|
||||
COPY . .
|
||||
RUN nimble build -d:danger -d:lto -d:strip \
|
||||
RUN nimble build -d:danger -d:lto -d:strip --mm:refc \
|
||||
&& nimble scss \
|
||||
&& nimble md
|
||||
|
||||
FROM alpine:latest
|
||||
WORKDIR /src/
|
||||
RUN apk --no-cache add pcre ca-certificates
|
||||
RUN apk --no-cache add pcre ca-certificates openssl
|
||||
COPY --from=nim /src/nitter/nitter ./
|
||||
COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf
|
||||
COPY --from=nim /src/nitter/public ./public
|
||||
|
||||
@@ -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)
|
||||
[](#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
|
||||
performance. \
|
||||
Inspired by the [Invidious](https://github.com/iv-org/invidious)
|
||||
project.
|
||||
Inspired by the [Invidious](https://github.com/iv-org/invidious) project.
|
||||
|
||||
- No JavaScript or ads
|
||||
- All requests go through the backend, client never talks to Twitter
|
||||
- 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)
|
||||
- RSS feeds
|
||||
- Themes
|
||||
- Mobile support (responsive design)
|
||||
- AGPLv3 licensed, no proprietary instances permitted
|
||||
|
||||
Liberapay: https://liberapay.com/zedeus \
|
||||
Patreon: https://patreon.com/nitter \
|
||||
BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \
|
||||
ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \
|
||||
LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \
|
||||
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
|
||||
<details>
|
||||
<summary>Donations</summary>
|
||||
Liberapay: https://liberapay.com/zedeus<br>
|
||||
Patreon: https://patreon.com/nitter<br>
|
||||
BTC: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55<br>
|
||||
ETH: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460<br>
|
||||
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL<br>
|
||||
SOL: ANsyGNXFo6osuFwr1YnUqif2RdoYRhc27WdyQNmmETSW<br>
|
||||
ZEC: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt
|
||||
</details>
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -42,12 +50,13 @@ maintained by the community.
|
||||
|
||||
## Why?
|
||||
|
||||
It's impossible to use Twitter without JavaScript enabled. For privacy-minded
|
||||
folks, preventing JavaScript analytics and IP-based tracking is important, but
|
||||
apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind
|
||||
a VPN and using heavy-duty adblockers, you can get accurately tracked with your
|
||||
[browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/),
|
||||
[no JavaScript required](https://noscriptfingerprint.com/). This all became
|
||||
It's impossible to use Twitter without JavaScript enabled, and as of 2024 you
|
||||
need to sign up. For privacy-minded folks, preventing JavaScript analytics and
|
||||
IP-based tracking is important, but apart from using a VPN and uBlock/uMatrix,
|
||||
it's impossible. Despite being behind a VPN and using heavy-duty adblockers,
|
||||
you can get accurately tracked with your [browser's
|
||||
fingerprint](https://restoreprivacy.com/browser-fingerprinting/), [no
|
||||
JavaScript required](https://noscriptfingerprint.com/). This all became
|
||||
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)
|
||||
for users to control whether their data gets sent to advertisers.
|
||||
@@ -71,19 +80,21 @@ Twitter account.
|
||||
|
||||
- libpcre
|
||||
- libsass
|
||||
- redis
|
||||
- redis/valkey
|
||||
|
||||
To compile Nitter you need a Nim installation, see
|
||||
[nim-lang.org](https://nim-lang.org/install.html) for details. It is possible to
|
||||
install it system-wide or in the user directory you create below.
|
||||
[nim-lang.org](https://nim-lang.org/install.html) for details. It is possible
|
||||
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,
|
||||
you can use `libsass-dev`.
|
||||
|
||||
Redis is required for caching and in the future for account info. It should be
|
||||
available on most distros as `redis` or `redis-server` (Ubuntu/Debian).
|
||||
Running it with the default config is fine, Nitter's default config is set to
|
||||
use the default Redis port and localhost.
|
||||
Redis is required for caching and in the future for account info. As of 2024
|
||||
Redis is no longer open source, so using the fork Valkey is recommended. It
|
||||
should be available on most distros as `redis` or `redis-server`
|
||||
(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
|
||||
along with the scss and md files.
|
||||
@@ -93,9 +104,9 @@ along with the scss and md files.
|
||||
# su nitter
|
||||
$ git clone https://github.com/zedeus/nitter
|
||||
$ cd nitter
|
||||
$ nimble build -d:release
|
||||
$ nimble scss
|
||||
$ nimble md
|
||||
$ nimble -l build -d:danger --mm:refc
|
||||
$ nimble -l scss
|
||||
$ nimble -l md
|
||||
$ cp nitter.example.conf nitter.conf
|
||||
```
|
||||
|
||||
@@ -112,12 +123,23 @@ performance reasons.
|
||||
|
||||
Page for the Docker image: https://hub.docker.com/r/zedeus/nitter
|
||||
|
||||
#### NOTE: For ARM64 support, please use the separate ARM64 docker image: [`zedeus/nitter:latest-arm64`](https://hub.docker.com/r/zedeus/nitter/tags).
|
||||
#### NOTE: The published image is multi-arch — `zedeus/nitter:latest` runs natively on both `amd64` and `arm64`.
|
||||
|
||||
To run Nitter with Docker, you'll need to install and run Redis separately
|
||||
before you can run the container. See below for how to also run Redis using
|
||||
Docker.
|
||||
|
||||
First create your config file. The Docker commands mount it into the container,
|
||||
so it has to exist on the host beforehand. If you've cloned the repo:
|
||||
|
||||
```bash
|
||||
cp nitter.example.conf nitter.conf
|
||||
```
|
||||
|
||||
If you're using the prebuilt image without a local clone, download
|
||||
[`nitter.example.conf`](https://raw.githubusercontent.com/zedeus/nitter/master/nitter.example.conf)
|
||||
and save it as `nitter.conf` instead.
|
||||
|
||||
To build and run Nitter in Docker:
|
||||
|
||||
```bash
|
||||
@@ -125,8 +147,6 @@ docker build -t nitter:latest .
|
||||
docker run -v $(pwd)/nitter.conf:/src/nitter.conf -d --network host nitter:latest
|
||||
```
|
||||
|
||||
Note: For ARM64, use this Dockerfile: [`Dockerfile.arm64`](https://github.com/zedeus/nitter/blob/master/Dockerfile.arm64).
|
||||
|
||||
A prebuilt Docker image is provided as well:
|
||||
|
||||
```bash
|
||||
@@ -140,8 +160,11 @@ Change `redisHost` from `localhost` to `nitter-redis` in `nitter.conf`, then run
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Note the Docker commands expect a `nitter.conf` file in the directory you run
|
||||
them.
|
||||
Note the Docker commands mount `nitter.conf` (and `sessions.jsonl` for
|
||||
docker-compose) from the directory you run them in. If a mounted file doesn't
|
||||
exist, Docker silently creates a directory in its place and the container fails
|
||||
with `not a directory: Are you trying to mount a directory onto a file`. Remove
|
||||
that directory and create the file as shown above.
|
||||
|
||||
### systemd
|
||||
|
||||
|
||||
+5
-6
@@ -7,12 +7,11 @@
|
||||
|
||||
# disable annoying warnings
|
||||
warning("GcUnsafe2", off)
|
||||
warning("HoleEnumConv", off)
|
||||
hint("XDeclaredButNotUsed", off)
|
||||
hint("XCannotRaiseY", off)
|
||||
hint("User", off)
|
||||
|
||||
const
|
||||
nimVersion = (major: NimMajor, minor: NimMinor, patch: NimPatch)
|
||||
|
||||
when nimVersion >= (1, 6, 0):
|
||||
warning("HoleEnumConv", off)
|
||||
# begin Nimble config (version 2)
|
||||
when withDir(thisDir(), system.fileExists("nimble.paths")):
|
||||
include "nimble.paths"
|
||||
# end Nimble config
|
||||
|
||||
@@ -9,6 +9,7 @@ services:
|
||||
- "127.0.0.1:8080:8080" # Replace with "8080:8080" if you don't use a reverse proxy
|
||||
volumes:
|
||||
- ./nitter.conf:/src/nitter.conf:Z,ro
|
||||
- ./sessions.jsonl:/src/sessions.jsonl:Z,ro # Run get_sessions.py to get the credentials
|
||||
depends_on:
|
||||
- nitter-redis
|
||||
restart: unless-stopped
|
||||
|
||||
+21
-17
@@ -1,37 +1,41 @@
|
||||
[Server]
|
||||
hostname = "nitter.net" # for generating links, change this to your own domain/ip
|
||||
hostname = "nitter.net" # for generating links, change this to your own domain/ip
|
||||
title = "nitter"
|
||||
address = "0.0.0.0"
|
||||
port = 8080
|
||||
https = false # disable to enable cookies when not using https
|
||||
https = false # disable to enable cookies when not using https
|
||||
httpMaxConnections = 100
|
||||
staticDir = "./public"
|
||||
|
||||
[Cache]
|
||||
listMinutes = 240 # how long to cache list info (not the tweets, so keep it high)
|
||||
rssMinutes = 10 # how long to cache rss queries
|
||||
redisHost = "localhost" # Change to "nitter-redis" if using docker-compose
|
||||
listMinutes = 240 # how long to cache list info (not the tweets, so keep it high)
|
||||
rssMinutes = 10 # how long to cache rss queries
|
||||
redisHost = "localhost" # Change to "nitter-redis" if using docker-compose
|
||||
redisPort = 6379
|
||||
redisPassword = ""
|
||||
redisConnections = 20 # minimum open connections in pool
|
||||
redisConnections = 20 # minimum open connections in pool
|
||||
redisMaxConnections = 30
|
||||
# new connections are opened when none are available, but if the pool size
|
||||
# goes above this, they're closed when released. don't worry about this unless
|
||||
# you receive tons of requests per second
|
||||
|
||||
[Config]
|
||||
hmacKey = "secretkey" # random key for cryptographic signing of video urls
|
||||
base64Media = false # use base64 encoding for proxied media urls
|
||||
enableRSS = true # set this to false to disable RSS feeds
|
||||
enableDebug = false # enable request logs and debug endpoints (/.tokens)
|
||||
proxy = "" # http/https url, SOCKS proxies are not supported
|
||||
hmacKey = "secretkey" # random key for cryptographic signing of video urls
|
||||
base64Media = false # use base64 encoding for proxied media urls
|
||||
enableRSS = true # master switch, set to false to disable all RSS feeds
|
||||
enableRSSUserTweets = true # /@user/rss
|
||||
enableRSSUserReplies = true # /@user/with_replies/rss
|
||||
enableRSSUserMedia = true # /@user/media/rss
|
||||
enableRSSSearch = true # /search/rss and /@user/search/rss
|
||||
enableRSSList = true # list RSS feeds
|
||||
enableDebug = false # enable request logs and debug endpoints (/.sessions)
|
||||
proxy = "" # http/https url, SOCKS proxies are not supported
|
||||
proxyAuth = ""
|
||||
tokenCount = 10
|
||||
# minimum amount of usable tokens. tokens are used to authorize API requests,
|
||||
# but they expire after ~1 hour, and have a limit of 500 requests per endpoint.
|
||||
# the limits reset every 15 minutes, and the pool is filled up so there's
|
||||
# always at least `tokenCount` usable tokens. only increase this if you receive
|
||||
# major bursts all the time and don't have a rate limiting setup via e.g. nginx
|
||||
apiProxy = "" # nitter-proxy host, e.g. localhost:7000
|
||||
disableTid = false # enable this if cookie-based auth is failing
|
||||
maxConcurrentReqs = 2 # max requests at a time per session to avoid race conditions
|
||||
maxRetries = 1 # max number of retries on rate limit errors
|
||||
retryDelayMs = 150 # delay in ms between retries
|
||||
|
||||
# Change default preferences here, see src/prefs_impl.nim for a complete list
|
||||
[Preferences]
|
||||
|
||||
+14
-15
@@ -10,25 +10,24 @@ bin = @["nitter"]
|
||||
|
||||
# Dependencies
|
||||
|
||||
requires "nim >= 1.4.8"
|
||||
requires "jester#baca3f"
|
||||
requires "karax#5cf360c"
|
||||
requires "sass#7dfdd03"
|
||||
requires "nimcrypto#4014ef9"
|
||||
requires "markdown#158efe3"
|
||||
requires "nim >= 2.0.0"
|
||||
requires "jester == 0.6.0"
|
||||
requires "karax == 1.5.0"
|
||||
requires "sass == 0.2.0"
|
||||
requires "nimcrypto == 0.7.3"
|
||||
requires "markdown == 0.8.8"
|
||||
requires "packedjson#9e6fbb6"
|
||||
requires "supersnappy#6c94198"
|
||||
requires "redpool#8b7c1db"
|
||||
requires "https://github.com/zedeus/redis#d0a0e6f"
|
||||
requires "zippy#ca5989a"
|
||||
requires "flatty#e668085"
|
||||
requires "jsony#ea811be"
|
||||
|
||||
requires "supersnappy == 2.1.4"
|
||||
requires "redpool == 0.2.2"
|
||||
requires "zippy == 0.10.19"
|
||||
requires "flatty == 0.4.0"
|
||||
requires "jsony == 1.1.6"
|
||||
requires "oauth == 0.11"
|
||||
|
||||
# Tasks
|
||||
|
||||
task scss, "Generate css":
|
||||
exec "nimble c --hint[Processing]:off -d:danger -r tools/gencss"
|
||||
exec "nim r --hint[Processing]:off tools/gencss"
|
||||
|
||||
task md, "Render md":
|
||||
exec "nimble c --hint[Processing]:off -d:danger -r tools/rendermd"
|
||||
exec "nim r --hint[Processing]:off tools/rendermd"
|
||||
|
||||
Vendored
+125
-30
@@ -1,53 +1,148 @@
|
||||
@font-face {
|
||||
font-family: 'fontello';
|
||||
src: url('/fonts/fontello.eot?21002321');
|
||||
src: url('/fonts/fontello.eot?21002321#iefix') format('embedded-opentype'),
|
||||
url('/fonts/fontello.woff2?21002321') format('woff2'),
|
||||
url('/fonts/fontello.woff?21002321') format('woff'),
|
||||
url('/fonts/fontello.ttf?21002321') format('truetype'),
|
||||
url('/fonts/fontello.svg?21002321#fontello') format('svg');
|
||||
font-family: "fontello";
|
||||
src: url("/fonts/fontello.eot?49059696");
|
||||
src:
|
||||
url("/fonts/fontello.eot?49059696#iefix") format("embedded-opentype"),
|
||||
url("/fonts/fontello.woff2?49059696") format("woff2"),
|
||||
url("/fonts/fontello.woff?49059696") format("woff"),
|
||||
url("/fonts/fontello.ttf?49059696") format("truetype"),
|
||||
url("/fonts/fontello.svg?49059696#fontello") format("svg");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
[class^="icon-"]:before, [class*=" icon-"]:before {
|
||||
[class^="icon-"]:before,
|
||||
[class*=" icon-"]:before {
|
||||
font-family: "fontello";
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
speak: never;
|
||||
|
||||
|
||||
display: inline-block;
|
||||
text-decoration: inherit;
|
||||
width: 1em;
|
||||
margin-right: 0.2em;
|
||||
text-align: center;
|
||||
|
||||
/* For safety - reset parent styles, that can break glyph codes*/
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
|
||||
|
||||
/* fix buttons height, for twitter bootstrap */
|
||||
line-height: 1em;
|
||||
|
||||
|
||||
/* Font smoothing. That was taken from TWBS */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-heart:before { content: '\2665'; } /* '♥' */
|
||||
.icon-quote:before { content: '\275e'; } /* '❞' */
|
||||
.icon-comment:before { content: '\e802'; } /* '' */
|
||||
.icon-ok:before { content: '\e803'; } /* '' */
|
||||
.icon-play:before { content: '\e804'; } /* '' */
|
||||
.icon-link:before { content: '\e805'; } /* '' */
|
||||
.icon-calendar:before { content: '\e806'; } /* '' */
|
||||
.icon-location:before { content: '\e807'; } /* '' */
|
||||
.icon-picture:before { content: '\e809'; } /* '' */
|
||||
.icon-lock:before { content: '\e80a'; } /* '' */
|
||||
.icon-down:before { content: '\e80b'; } /* '' */
|
||||
.icon-retweet:before { content: '\e80d'; } /* '' */
|
||||
.icon-search:before { content: '\e80e'; } /* '' */
|
||||
.icon-pin:before { content: '\e80f'; } /* '' */
|
||||
.icon-cog:before { content: '\e812'; } /* '' */
|
||||
.icon-rss-feed:before { content: '\e813'; } /* '' */
|
||||
.icon-info:before { content: '\f128'; } /* '' */
|
||||
.icon-bird:before { content: '\f309'; } /* '' */
|
||||
|
||||
.icon-views:before {
|
||||
content: "\e800";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-heart:before {
|
||||
content: "\e801";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-quote:before {
|
||||
content: "\e802";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-comment:before {
|
||||
content: "\e803";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-group:before {
|
||||
content: "\e804";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-play:before {
|
||||
content: "\e805";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-link:before {
|
||||
content: "\e806";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-calendar:before {
|
||||
content: "\e807";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-location:before {
|
||||
content: "\e808";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-picture:before {
|
||||
content: "\e809";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-lock:before {
|
||||
content: "\e80a";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-down:before {
|
||||
content: "\e80b";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-retweet:before {
|
||||
content: "\e80c";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-search:before {
|
||||
content: "\e80d";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-pin:before {
|
||||
content: "\e80e";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-cog:before {
|
||||
content: "\e80f";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-rss:before {
|
||||
content: "\e810";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-ok:before {
|
||||
content: "\e811";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-attention:before {
|
||||
content: "\e812";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-circle:before {
|
||||
content: "\f111";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-info:before {
|
||||
content: "\f128";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-bird:before {
|
||||
content: "\f309";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
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
|
||||
|
||||
Copyright (C) 2012 by Daniel Bruce
|
||||
@@ -37,12 +46,3 @@ Font license info
|
||||
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.
+23
-15
@@ -1,26 +1,28 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<metadata>Copyright (C) 2020 by original authors @ fontello.com</metadata>
|
||||
<metadata>Copyright (C) 2026 by original authors @ fontello.com</metadata>
|
||||
<defs>
|
||||
<font id="fontello" horiz-adv-x="1000" >
|
||||
<font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
|
||||
<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" />
|
||||
|
||||
@@ -28,19 +30,25 @@
|
||||
|
||||
<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="bird" unicode="" d="M920 636q-36-54-94-98l0-24q0-130-60-250t-186-203-290-83q-160 0-290 84 14-2 46-2 132 0 234 80-62 2-110 38t-66 94q10-4 34-4 26 0 50 6-66 14-108 66t-42 120l0 2q36-20 84-24-84 58-84 158 0 48 26 94 154-188 390-196-6 18-6 42 0 78 55 133t135 55q82 0 136-58 60 12 120 44-20-66-82-104 56 8 108 30z" horiz-adv-x="920" />
|
||||
</font>
|
||||
</defs>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
|
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) {
|
||||
const video = overlay.parentElement.querySelector('video');
|
||||
const url = video.getAttribute("data-url");
|
||||
const startTime = parseFloat(video.getAttribute("data-start") || "0");
|
||||
video.setAttribute("controls", "");
|
||||
overlay.style.display = "none";
|
||||
|
||||
@@ -12,12 +13,13 @@ function playVideo(overlay) {
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, function () {
|
||||
hls.loadLevel = hls.levels.length - 1;
|
||||
hls.startLoad();
|
||||
hls.startLoad(startTime);
|
||||
video.play();
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = url;
|
||||
video.addEventListener('canplay', function() {
|
||||
if (startTime > 0) video.currentTime = startTime;
|
||||
video.play();
|
||||
});
|
||||
}
|
||||
|
||||
+209
-50
@@ -1,66 +1,225 @@
|
||||
// @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
function insertBeforeLast(node, elem) {
|
||||
node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]);
|
||||
node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]);
|
||||
}
|
||||
|
||||
function getLoadMore(doc) {
|
||||
return doc.querySelector('.show-more:not(.timeline-item)');
|
||||
return doc.querySelector(".show-more:not(.timeline-item)");
|
||||
}
|
||||
|
||||
function isDuplicate(item, itemClass) {
|
||||
const tweet = item.querySelector(".tweet-link");
|
||||
if (tweet == null) return false;
|
||||
const href = tweet.getAttribute("href");
|
||||
return document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != null;
|
||||
function getHrefs(selector) {
|
||||
return new Set([...document.querySelectorAll(selector)].map(el => el.getAttribute("href")));
|
||||
}
|
||||
|
||||
window.onload = function() {
|
||||
const url = window.location.pathname;
|
||||
const isTweet = url.indexOf("/status/") !== -1;
|
||||
const containerClass = isTweet ? ".replies" : ".timeline";
|
||||
const itemClass = containerClass + ' > div:not(.top-ref)';
|
||||
function getTweetId(item) {
|
||||
const m = item.querySelector(".tweet-link")?.getAttribute("href")?.match(/\/status\/(\d+)/);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
|
||||
var html = document.querySelector("html");
|
||||
var container = document.querySelector(containerClass);
|
||||
var loading = false;
|
||||
function isDuplicate(item, hrefs) {
|
||||
return hrefs.has(item.querySelector(".tweet-link")?.getAttribute("href"));
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
if (loading) return;
|
||||
if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) {
|
||||
loading = true;
|
||||
var loadMore = getLoadMore(document);
|
||||
if (loadMore == null) return;
|
||||
const GAP = 10;
|
||||
|
||||
loadMore.children[0].text = "Loading...";
|
||||
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");
|
||||
|
||||
var url = new URL(loadMore.children[0].href);
|
||||
url.searchParams.append('scroll', 'true');
|
||||
|
||||
fetch(url.toString()).then(function (response) {
|
||||
return response.text();
|
||||
}).then(function (html) {
|
||||
var parser = new DOMParser();
|
||||
var doc = parser.parseFromString(html, 'text/html');
|
||||
loadMore.remove();
|
||||
|
||||
for (var item of doc.querySelectorAll(itemClass)) {
|
||||
if (item.className == "timeline-item show-more") continue;
|
||||
if (isDuplicate(item, itemClass)) continue;
|
||||
if (isTweet) container.appendChild(item);
|
||||
else insertBeforeLast(container, item);
|
||||
}
|
||||
|
||||
loading = false;
|
||||
const newLoadMore = getLoadMore(doc);
|
||||
if (newLoadMore == null) return;
|
||||
if (isTweet) container.appendChild(newLoadMore);
|
||||
else insertBeforeLast(container, newLoadMore);
|
||||
}).catch(function (err) {
|
||||
console.warn('Something went wrong.', err);
|
||||
loading = true;
|
||||
});
|
||||
}
|
||||
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 itemClass = containerClass + " > div:not(.top-ref)";
|
||||
const html = document.documentElement;
|
||||
const container = document.querySelector(containerClass);
|
||||
const masonryEl = container?.querySelector(".gallery-masonry");
|
||||
const masonry = masonryEl ? new Masonry(masonryEl) : null;
|
||||
let loading = false;
|
||||
|
||||
function handleScroll(failed) {
|
||||
if (loading || html.scrollTop + html.clientHeight < html.scrollHeight - 3000) return;
|
||||
|
||||
const loadMore = getLoadMore(document);
|
||||
if (!loadMore) return;
|
||||
loading = true;
|
||||
loadMore.children[0].text = "Loading...";
|
||||
|
||||
const url = new URL(loadMore.children[0].href);
|
||||
url.searchParams.append("scroll", "true");
|
||||
|
||||
fetch(url)
|
||||
.then(r => {
|
||||
if (r.status > 299) throw new Error("error");
|
||||
return r.text();
|
||||
})
|
||||
.then(responseText => {
|
||||
const doc = new DOMParser().parseFromString(responseText, "text/html");
|
||||
loadMore.remove();
|
||||
|
||||
if (masonry) {
|
||||
masonry.syncHeights();
|
||||
const newMasonry = doc.querySelector(".gallery-masonry");
|
||||
if (newMasonry) {
|
||||
const knownHrefs = getHrefs(".gallery-masonry .tweet-link");
|
||||
masonry.addAll([...newMasonry.querySelectorAll(".timeline-item")].filter(item => !isDuplicate(item, knownHrefs)));
|
||||
}
|
||||
} else {
|
||||
const knownHrefs = getHrefs(`${itemClass} .tweet-link`);
|
||||
for (const item of doc.querySelectorAll(itemClass)) {
|
||||
if (item.className === "timeline-item show-more" || isDuplicate(item, knownHrefs)) continue;
|
||||
isTweet ? container.appendChild(item) : insertBeforeLast(container, item);
|
||||
}
|
||||
}
|
||||
|
||||
loading = false;
|
||||
const newLoadMore = getLoadMore(doc);
|
||||
if (newLoadMore) {
|
||||
isTweet ? container.appendChild(newLoadMore) : insertBeforeLast(container, newLoadMore);
|
||||
if (masonry) newLoadMore.classList.add("masonry-visible");
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn("Something went wrong.", err);
|
||||
if (failed > 3) { loadMore.children[0].text = "Error"; return; }
|
||||
loading = false;
|
||||
handleScroll((failed || 0) + 1);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", () => handleScroll());
|
||||
});
|
||||
// @license-end
|
||||
|
||||
+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
|
||||
<https://github.com/zedeus/nitter>
|
||||
|
||||
* No JavaScript or ads
|
||||
* All requests go through the backend, client never talks to Twitter
|
||||
* Prevents Twitter from tracking your IP or JavaScript fingerprint
|
||||
* Uses Twitter's unofficial API (no rate limits or developer account required)
|
||||
* Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com)
|
||||
* RSS feeds
|
||||
* Themes
|
||||
* Mobile support (responsive design)
|
||||
* AGPLv3 licensed, no proprietary instances permitted
|
||||
- No JavaScript or ads
|
||||
- All requests go through the backend, client never talks to Twitter
|
||||
- Prevents Twitter from tracking your IP or JavaScript fingerprint
|
||||
- Uses Twitter's unofficial API (no developer account required)
|
||||
- Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com)
|
||||
- RSS feeds
|
||||
- Themes
|
||||
- Mobile support (responsive design)
|
||||
- AGPLv3 licensed, no proprietary instances permitted
|
||||
|
||||
Nitter's GitHub wiki contains
|
||||
[instances](https://github.com/zedeus/nitter/wiki/Instances) and
|
||||
@@ -21,12 +21,13 @@ maintained by the community.
|
||||
|
||||
## Why use Nitter?
|
||||
|
||||
It's impossible to use Twitter without JavaScript enabled. For privacy-minded
|
||||
folks, preventing JavaScript analytics and IP-based tracking is important, but
|
||||
apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind
|
||||
a VPN and using heavy-duty adblockers, you can get accurately tracked with your
|
||||
[browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/),
|
||||
[no JavaScript required](https://noscriptfingerprint.com/). This all became
|
||||
It's impossible to use Twitter without JavaScript enabled, and as of 2024 you
|
||||
need to sign up. For privacy-minded folks, preventing JavaScript analytics and
|
||||
IP-based tracking is important, but apart from using a VPN and uBlock/uMatrix,
|
||||
it's impossible. Despite being behind a VPN and using heavy-duty adblockers,
|
||||
you can get accurately tracked with your [browser's
|
||||
fingerprint](https://restoreprivacy.com/browser-fingerprinting/), [no
|
||||
JavaScript required](https://noscriptfingerprint.com/). This all became
|
||||
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)
|
||||
for users to control whether their data gets sent to advertisers.
|
||||
@@ -42,12 +43,13 @@ Twitter account.
|
||||
|
||||
## Donating
|
||||
|
||||
Liberapay: <https://liberapay.com/zedeus> \
|
||||
Patreon: <https://patreon.com/nitter> \
|
||||
BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \
|
||||
ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \
|
||||
LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \
|
||||
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
|
||||
Liberapay: https://liberapay.com/zedeus \
|
||||
Patreon: https://patreon.com/nitter \
|
||||
BTC: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55 \
|
||||
ETH: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460 \
|
||||
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL \
|
||||
SOL: ANsyGNXFo6osuFwr1YnUqif2RdoYRhc27WdyQNmmETSW \
|
||||
ZEC: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt
|
||||
|
||||
## Contact
|
||||
|
||||
|
||||
+151
-64
@@ -1,58 +1,123 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import asyncdispatch, httpclient, uri, strutils, sequtils, sugar
|
||||
import asyncdispatch, httpclient, strutils, sequtils, sugar
|
||||
import packedjson
|
||||
import types, query, formatters, consts, apiutils, parser
|
||||
import types, query, formatters, consts, apiutils, parser, utils
|
||||
import experimental/parser as newParser
|
||||
|
||||
# Helper to generate params object for GraphQL requests
|
||||
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.} =
|
||||
if username.len == 0: return
|
||||
let
|
||||
variables = %*{"screen_name": username}
|
||||
params = {"variables": $variables, "features": gqlFeatures}
|
||||
js = await fetchRaw(graphUser ? params, Api.userScreenName)
|
||||
let js = await fetchRaw(userUrl(username))
|
||||
result = parseGraphUser(js)
|
||||
|
||||
proc getGraphUserById*(id: string): Future[User] {.async.} =
|
||||
if id.len == 0 or id.any(c => not c.isDigit): return
|
||||
let
|
||||
variables = %*{"userId": id}
|
||||
params = {"variables": $variables, "features": gqlFeatures}
|
||||
js = await fetchRaw(graphUserById ? params, Api.userRestId)
|
||||
url = apiReq(graphUserById, """{"rest_id": "$1"}""" % id)
|
||||
js = await fetchRaw(url)
|
||||
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
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = userTweetsVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
(url, apiId) = case kind
|
||||
of TimelineKind.tweets: (graphUserTweets, Api.userTweets)
|
||||
of TimelineKind.replies: (graphUserTweetsAndReplies, Api.userTweetsAndReplies)
|
||||
of TimelineKind.media: (graphUserMedia, Api.userMedia)
|
||||
js = await fetch(url ? params, apiId)
|
||||
result = parseGraphTimeline(js, "user", after)
|
||||
url = case kind
|
||||
of TimelineKind.tweets: userTweetsUrl(id, cursor)
|
||||
of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
|
||||
of TimelineKind.media: mediaUrl(id, cursor, 100)
|
||||
js = await fetch(url)
|
||||
result = parseGraphTimeline(js, after)
|
||||
|
||||
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = listTweetsVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphListTweets ? params, Api.listTweets)
|
||||
result = parseGraphTimeline(js, "list", after)
|
||||
url = apiReq(graphListTweets, restIdVars % [id, cursor, "20"])
|
||||
js = await fetch(url)
|
||||
result = parseGraphTimeline(js, after).tweets
|
||||
|
||||
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
|
||||
let
|
||||
variables = %*{"screenName": name, "listSlug": list}
|
||||
params = {"variables": $variables, "features": gqlFeatures}
|
||||
result = parseGraphList(await fetch(graphListBySlug ? params, Api.listBySlug))
|
||||
url = apiReq(graphListBySlug, $variables)
|
||||
js = await fetch(url)
|
||||
result = parseGraphList(js)
|
||||
|
||||
proc getGraphList*(id: string): Future[List] {.async.} =
|
||||
let
|
||||
variables = %*{"listId": id}
|
||||
params = {"variables": $variables, "features": gqlFeatures}
|
||||
result = parseGraphList(await fetch(graphListById ? params, Api.list))
|
||||
let
|
||||
url = apiReq(graphListById, """{"listId": "$1"}""" % id)
|
||||
js = await fetch(url)
|
||||
result = parseGraphList(js)
|
||||
|
||||
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
|
||||
if list.id.len == 0: return
|
||||
@@ -66,24 +131,23 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
|
||||
}
|
||||
if after.len > 0:
|
||||
variables["cursor"] = % after
|
||||
let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures}
|
||||
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after)
|
||||
let
|
||||
url = apiReq(graphListMembers, $variables)
|
||||
js = await fetchRaw(url)
|
||||
result = parseGraphListMembers(js, after)
|
||||
|
||||
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
variables = tweetResultVariables % id
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphTweetResult ? params, Api.tweetResult)
|
||||
url = apiReq(graphTweetResult, """{"rest_id": "$1"}""" % id)
|
||||
js = await fetch(url)
|
||||
result = parseGraphTweetResult(js)
|
||||
|
||||
proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = tweetVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphTweet ? params, Api.tweetDetail)
|
||||
js = await fetch(tweetDetailUrl(id, cursor))
|
||||
result = parseGraphConversation(js, id)
|
||||
|
||||
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:
|
||||
result.replies = await getReplies(id, after)
|
||||
|
||||
proc getGraphSearch*(query: Query; after=""): Future[Result[Tweet]] {.async.} =
|
||||
let q = genQueryParam(query)
|
||||
proc getGraphEditHistory*(id: string): Future[EditHistory] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
url = apiReq(graphTweetEditHistory, tweetEditHistoryVars % id)
|
||||
js = await fetch(url)
|
||||
result = parseGraphEditHistory(js, id)
|
||||
|
||||
proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
|
||||
# workaround for #1372
|
||||
let maxId =
|
||||
if not after.startsWith("maxid:"): ""
|
||||
else: validateNumber(after[6..^1])
|
||||
|
||||
let q = genQueryParam(query, maxId)
|
||||
if q.len == 0 or q == emptyQuery:
|
||||
return Result[Tweet](query: query, beginning: true)
|
||||
return Timeline(query: query, beginning: true)
|
||||
|
||||
var
|
||||
variables = %*{
|
||||
"rawQuery": q,
|
||||
"count": 20,
|
||||
"querySource": "typed_query",
|
||||
"product": "Latest",
|
||||
"withDownvotePerspective": false,
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false
|
||||
"withGrokTranslatedBio":true,
|
||||
"withQuickPromoteEligibilityTweetFields":false
|
||||
}
|
||||
if after.len > 0:
|
||||
|
||||
if after.len > 0 and maxId.len == 0:
|
||||
variables["cursor"] = % after
|
||||
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
|
||||
result = parseGraphSearch(await fetch(url, Api.search), after)
|
||||
let
|
||||
url = apiReq(graphSearchTimeline, $variables)
|
||||
js = await fetch(url)
|
||||
result = parseGraphSearch[Tweets](js, after)
|
||||
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:
|
||||
return Result[User](query: query, beginning: true)
|
||||
|
||||
var url = userSearch ? {
|
||||
"q": query.text,
|
||||
"skip_status": "1",
|
||||
"count": "20",
|
||||
"page": page
|
||||
}
|
||||
var
|
||||
variables = %*{
|
||||
"rawQuery": query.text,
|
||||
"count": 20,
|
||||
"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))
|
||||
let
|
||||
url = apiReq(graphSearchTimeline, $variables)
|
||||
js = await fetch(url)
|
||||
result = parseGraphSearch[User](js, after)
|
||||
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
|
||||
ps = genParams({"screen_name": name, "trim_user": "true"},
|
||||
count="18", ext=false)
|
||||
url = photoRail ? ps
|
||||
result = parsePhotoRail(await fetch(url, Api.timeline))
|
||||
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.} =
|
||||
let client = newAsyncHttpClient(maxRedirects=0)
|
||||
|
||||
+189
-77
@@ -1,68 +1,132 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import httpclient, asyncdispatch, options, strutils, uri
|
||||
import jsony, packedjson, zippy
|
||||
import types, tokens, consts, parserutils, http_pool
|
||||
import httpclient, asyncdispatch, options, strutils, uri, times, math, tables
|
||||
import jsony, packedjson, zippy, oauth/oauth1
|
||||
import types, auth, consts, parserutils, http_pool, tid
|
||||
import experimental/types/common
|
||||
|
||||
const
|
||||
rlRemaining = "x-rate-limit-remaining"
|
||||
rlReset = "x-rate-limit-reset"
|
||||
rlLimit = "x-rate-limit-limit"
|
||||
errorsToSkip = {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="";
|
||||
count="20"; ext=true): seq[(string, string)] =
|
||||
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 setDisableTid*(disable: bool) =
|
||||
disableTid = disable
|
||||
|
||||
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({
|
||||
"connection": "keep-alive",
|
||||
"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": "*/*",
|
||||
"accept-encoding": "gzip",
|
||||
"accept-language": "en-US,en;q=0.9",
|
||||
"accept": "*/*",
|
||||
"DNT": "1"
|
||||
"connection": "keep-alive",
|
||||
"content-type": "application/json",
|
||||
"origin": "https://x.com",
|
||||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
|
||||
"x-twitter-active-user": "yes",
|
||||
"x-twitter-client-language": "en",
|
||||
"priority": "u=1, i"
|
||||
})
|
||||
|
||||
template updateToken() =
|
||||
if resp.headers.hasKey(rlRemaining):
|
||||
let
|
||||
remaining = parseInt(resp.headers[rlRemaining])
|
||||
reset = parseInt(resp.headers[rlReset])
|
||||
token.setRateLimit(api, remaining, reset)
|
||||
case session.kind
|
||||
of SessionKind.oauth:
|
||||
result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret)
|
||||
of SessionKind.cookie:
|
||||
result["x-twitter-auth-type"] = "OAuth2Session"
|
||||
result["x-csrf-token"] = session.ct0
|
||||
result["cookie"] = getCookieHeader(session.authToken, session.ct0)
|
||||
result["referer"] = "https://x.com/"
|
||||
result["sec-ch-ua"] = """"Google Chrome";v="142", "Chromium";v="142", "Not A(Brand";v="24""""
|
||||
result["sec-ch-ua-mobile"] = "?0"
|
||||
result["sec-ch-ua-platform"] = "Windows"
|
||||
result["sec-fetch-dest"] = "empty"
|
||||
result["sec-fetch-mode"] = "cors"
|
||||
result["sec-fetch-site"] = "same-origin"
|
||||
if disableTid or skipTid or "/1.1/" in url.path:
|
||||
result["authorization"] = bearerToken2
|
||||
else:
|
||||
result["authorization"] = bearerToken
|
||||
result["x-client-transaction-id"] = await genTid(url.path)
|
||||
|
||||
proc getAndValidateSession*(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.} =
|
||||
once:
|
||||
pool = HttpPool()
|
||||
|
||||
var token = await getToken(api)
|
||||
if token.tok.len == 0:
|
||||
raise rateLimitError()
|
||||
|
||||
try:
|
||||
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 =
|
||||
resp = await c.get($url)
|
||||
# TODO: this is a temporary simple implementation
|
||||
if apiProxy.len > 0 and "/1.1/" notin url.path:
|
||||
resp = await c.get(($url).replace("https://", apiProxy))
|
||||
else:
|
||||
resp = await c.get($url)
|
||||
result = await resp.body
|
||||
|
||||
getContent()
|
||||
@@ -71,57 +135,105 @@ template fetchImpl(result, fetchBody) {.dirty.} =
|
||||
badClient = true
|
||||
raise newException(BadClientError, "Bad client")
|
||||
|
||||
if resp.status == $Http404 and result.len == 0:
|
||||
echo "[sessions] transient 404 (empty body), retrying: ", url.path, ", 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 resp.headers.getOrDefault("content-encoding") == "gzip":
|
||||
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
|
||||
|
||||
release(token, used=true)
|
||||
|
||||
if resp.status == $Http400:
|
||||
echo "ERROR 400, ", url.path, ": ", result, ", session: ", session.pretty
|
||||
raise newException(InternalError, $url)
|
||||
except InternalError as e:
|
||||
raise e
|
||||
except BadClientError as e:
|
||||
release(token, used=true)
|
||||
raise e
|
||||
except OSError as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
echo "error: ", e.name, ", msg: ", e.msg, ", token: ", token[], ", url: ", url
|
||||
if "length" notin e.msg and "descriptor" notin e.msg:
|
||||
release(token, invalid=true)
|
||||
let s = session.pretty
|
||||
echo "error: ", e.name, ", msg: ", e.msg, ", session: ", s, ", url: ", url
|
||||
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()
|
||||
|
||||
proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} =
|
||||
var body: string
|
||||
fetchImpl body:
|
||||
if body.startsWith('{') or body.startsWith('['):
|
||||
result = parseJson(body)
|
||||
else:
|
||||
echo resp.status, ": ", body, " --- url: ", url
|
||||
result = newJNull()
|
||||
proc fetch*(req: ApiReq): Future[JsonNode] {.async.} =
|
||||
retry:
|
||||
var body: string
|
||||
session = await getAndValidateSession(req)
|
||||
|
||||
updateToken()
|
||||
let url = req.toUrl(session.kind)
|
||||
|
||||
let error = result.getError
|
||||
if error in {invalidToken, badToken}:
|
||||
echo "fetch error: ", result.getError
|
||||
release(token, invalid=true)
|
||||
raise rateLimitError()
|
||||
fetchImpl body:
|
||||
if body.startsWith('{') or body.startsWith('['):
|
||||
result = parseJson(body)
|
||||
else:
|
||||
echo resp.status, ": ", body, " --- url: ", url, ", session: ", session.pretty
|
||||
result = newJNull()
|
||||
|
||||
proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} =
|
||||
fetchImpl result:
|
||||
if not (result.startsWith('{') or result.startsWith('[')):
|
||||
echo resp.status, ": ", result, " --- url: ", url
|
||||
result.setLen(0)
|
||||
let error = result.getError
|
||||
if error != null and error notin errorsToSkip:
|
||||
echo "Fetch error, API: ", url.path, ", error: ", error, ", session: ", session.pretty
|
||||
if error in {expiredToken, badToken, locked}:
|
||||
invalidate(session)
|
||||
raise rateLimitError()
|
||||
|
||||
updateToken()
|
||||
proc fetchRaw*(req: ApiReq): Future[string] {.async.} =
|
||||
retry:
|
||||
session = await getAndValidateSession(req)
|
||||
let url = req.toUrl(session.kind)
|
||||
|
||||
if result.startsWith("{\"errors"):
|
||||
let errors = result.fromJson(Errors)
|
||||
if errors in {invalidToken, badToken}:
|
||||
echo "fetch error: ", errors
|
||||
release(token, invalid=true)
|
||||
raise rateLimitError()
|
||||
fetchImpl result:
|
||||
if not (result.startsWith('{') or result.startsWith('[')):
|
||||
echo resp.status, ": ", result, " --- url: ", url, ", session: ", session.pretty
|
||||
result.setLen(0)
|
||||
|
||||
+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) =
|
||||
var cfg = loadConfig(path)
|
||||
|
||||
let masterRss = cfg.get("Config", "enableRSS", true)
|
||||
|
||||
let conf = Config(
|
||||
# Server
|
||||
address: cfg.get("Server", "address", "0.0.0.0"),
|
||||
@@ -37,10 +39,19 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
|
||||
hmacKey: cfg.get("Config", "hmacKey", "secretkey"),
|
||||
base64Media: cfg.get("Config", "base64Media", false),
|
||||
minTokens: cfg.get("Config", "tokenCount", 10),
|
||||
enableRss: cfg.get("Config", "enableRSS", true),
|
||||
enableRSSUserTweets: masterRss and cfg.get("Config", "enableRSSUserTweets", true),
|
||||
enableRSSUserReplies: masterRss and cfg.get("Config", "enableRSSUserReplies", true),
|
||||
enableRSSUserMedia: masterRss and cfg.get("Config", "enableRSSUserMedia", true),
|
||||
enableRSSSearch: masterRss and cfg.get("Config", "enableRSSSearch", true),
|
||||
enableRSSList: masterRss and cfg.get("Config", "enableRSSList", true),
|
||||
enableDebug: cfg.get("Config", "enableDebug", false),
|
||||
proxy: cfg.get("Config", "proxy", ""),
|
||||
proxyAuth: cfg.get("Config", "proxyAuth", "")
|
||||
proxyAuth: cfg.get("Config", "proxyAuth", ""),
|
||||
apiProxy: cfg.get("Config", "apiProxy", ""),
|
||||
disableTid: cfg.get("Config", "disableTid", false),
|
||||
maxConcurrentReqs: cfg.get("Config", "maxConcurrentReqs", 2),
|
||||
maxRetries: cfg.get("Config", "maxRetries", 1),
|
||||
retryDelayMs: cfg.get("Config", "retryDelayMs", 150)
|
||||
)
|
||||
|
||||
return (conf, cfg)
|
||||
|
||||
+114
-99
@@ -1,121 +1,136 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import uri, sequtils, strutils
|
||||
import strutils
|
||||
|
||||
const
|
||||
auth* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
|
||||
consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
|
||||
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
|
||||
bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
|
||||
bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F"
|
||||
|
||||
api = parseUri("https://api.twitter.com")
|
||||
activate* = $(api / "1.1/guest/activate.json")
|
||||
graphUser* = "IGgvgiOx4QZndDHuD3x9TQ/UserByScreenName"
|
||||
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"
|
||||
userSearch* = api / "1.1/users/search.json"
|
||||
graphListById* = "t9AbdyHaJVfjL9jsODwgpQ/ListByRestId"
|
||||
graphListBySlug* = "LDQpQ89B5ipR8izCKrWU0g/ListBySlug"
|
||||
graphListMembers* = "EM7YRaM3gCnzDESmchA7RA/ListMembers"
|
||||
graphListTweets* = "0QJtcuMzVywHGAWD6Dtjlw/ListTimeline"
|
||||
graphAboutAccount* = "zUnx-DLN9dkwOkNhTLySjg/AboutAccountQuery"
|
||||
|
||||
graphql = api / "graphql"
|
||||
graphUser* = graphql / "pVrmNaXcxPjisIvKtLDMEA/UserByScreenName"
|
||||
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
|
||||
graphBroadcast* = "FJLCzpXCLPM1jUZqmM7oEA/BroadcastQuery"
|
||||
restLiveStream* = "1.1/live_video_stream/status/"
|
||||
|
||||
gqlFeatures* = """{
|
||||
"blue_business_profile_image_shape_enabled": false,
|
||||
"creator_subscriptions_tweet_preview_api_enabled": true,
|
||||
"freedom_of_speech_not_reach_fetch_enabled": false,
|
||||
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": false,
|
||||
"highlights_tweets_tab_ui_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,
|
||||
"rweb_video_screen_enabled": false,
|
||||
"rweb_cashtags_enabled": true,
|
||||
"profile_label_improvements_pcf_label_in_post_enabled": true,
|
||||
"responsive_web_profile_redirect_enabled": false,
|
||||
"rweb_tipjar_consumption_enabled": false,
|
||||
"verified_phone_label_enabled": false,
|
||||
"vibe_api_enabled": false,
|
||||
"view_counts_everywhere_api_enabled": false
|
||||
"creator_subscriptions_tweet_preview_api_enabled": true,
|
||||
"responsive_web_graphql_timeline_navigation_enabled": true,
|
||||
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
|
||||
"premium_content_api_read_enabled": false,
|
||||
"communities_web_enable_tweet_community_results_fetch": true,
|
||||
"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", "")
|
||||
|
||||
tweetVariables* = """{
|
||||
tweetVars* = """{
|
||||
"postId": "$1",
|
||||
$2
|
||||
"includeHasBirdwatchNotes": false,
|
||||
"includePromotedContent": false,
|
||||
"withBirdwatchNotes": true,
|
||||
"withVoice": false,
|
||||
"withV2Timeline": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
tweetDetailVars* = """{
|
||||
"focalTweetId": "$1",
|
||||
$2
|
||||
"withBirdwatchNotes": false,
|
||||
"includePromotedContent": false,
|
||||
"withDownvotePerspective": false,
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false,
|
||||
"withVoice": false
|
||||
}"""
|
||||
"referrer": "profile",
|
||||
"with_rux_injections": false,
|
||||
"rankingMode": "Relevance",
|
||||
"includePromotedContent": true,
|
||||
"withCommunity": true,
|
||||
"withQuickPromoteEligibilityTweetFields": true,
|
||||
"withBirdwatchNotes": true,
|
||||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
tweetResultVariables* = """{
|
||||
tweetEditHistoryVars* = """{
|
||||
"tweetId": "$1",
|
||||
"includePromotedContent": false,
|
||||
"withDownvotePerspective": false,
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false,
|
||||
"withVoice": false,
|
||||
"withCommunity": false
|
||||
}"""
|
||||
"withQuickPromoteEligibilityTweetFields": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
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
|
||||
"count": 20,
|
||||
"includePromotedContent": false,
|
||||
"withDownvotePerspective": false,
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false,
|
||||
"withVoice": false,
|
||||
"withV2Timeline": true
|
||||
}"""
|
||||
"withQuickPromoteEligibilityTweetFields": true,
|
||||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
listTweetsVariables* = """{
|
||||
"listId": "$1", $2
|
||||
userTweetsAndRepliesVars* = """{
|
||||
"userId": "$1", $2
|
||||
"count": 20,
|
||||
"includePromotedContent": false,
|
||||
"withDownvotePerspective": false,
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false,
|
||||
"withVoice": false
|
||||
}"""
|
||||
"withCommunity": true,
|
||||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
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 user, ../types/[graphuser, graphlistmembers]
|
||||
from ../../types import User, Result, Query, QueryKind
|
||||
import user, utils, ../types/[graphuser, graphlistmembers]
|
||||
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 =
|
||||
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)
|
||||
|
||||
result = toUser raw.data.user.result.legacy
|
||||
result.id = raw.data.user.result.restId
|
||||
result.verified = result.verified or raw.data.user.result.isBlueVerified
|
||||
result = parseUserResult(userResult)
|
||||
|
||||
proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
||||
result = Result[User](
|
||||
@@ -27,7 +63,7 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
||||
of TimelineTimelineItem:
|
||||
let userResult = entry.content.itemContent.userResults.result
|
||||
if userResult.restId.len > 0:
|
||||
result.content.add toUser userResult.legacy
|
||||
result.content.add parseUserResult(userResult)
|
||||
of TimelineTimelineCursor:
|
||||
if entry.content.cursorType == "Bottom":
|
||||
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
|
||||
name = $runes[rep.slice.a.succ .. rep.slice.b]
|
||||
symbol = $runes[rep.slice.a]
|
||||
result.add a(symbol & name, href = "/search?q=%23" & name)
|
||||
result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name)
|
||||
of rkMention:
|
||||
result.add a($runes[rep.slice], href = rep.url, title = rep.display)
|
||||
of rkUrl:
|
||||
|
||||
@@ -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 jsony
|
||||
import ../types/unifiedcard
|
||||
import user, ../types/unifiedcard
|
||||
import ../../formatters
|
||||
from ../../types import Card, CardKind, Video
|
||||
from ../../utils import twimg, https
|
||||
|
||||
@@ -27,6 +28,14 @@ proc parseMediaDetails(data: ComponentData; card: UnifiedCard; result: var Card)
|
||||
result.text = data.topicDetail.title
|
||||
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) =
|
||||
let app = card.appStoreData[data.appId][0]
|
||||
|
||||
@@ -69,6 +78,18 @@ proc parseMedia(component: Component; card: UnifiedCard; result: var Card) =
|
||||
of model3d:
|
||||
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 =
|
||||
let card = json.fromJson(UnifiedCard)
|
||||
|
||||
@@ -84,6 +105,10 @@ proc parseUnifiedCard*(json: string): Card =
|
||||
component.parseMedia(card, result)
|
||||
of buttonGroup:
|
||||
discard
|
||||
of grokShare:
|
||||
component.data.parseGrokShare(card, result)
|
||||
of ComponentType.jobDetails:
|
||||
component.data.parseJobDetails(card, result)
|
||||
of ComponentType.hidden:
|
||||
result.kind = CardKind.hidden
|
||||
of ComponentType.unknown:
|
||||
|
||||
@@ -9,7 +9,7 @@ let
|
||||
unReplace = "$1<a href=\"/$2\">@$2</a>"
|
||||
|
||||
htRegex = nre.re"""(*U)(^|[^\w-_.?])([##$])([\w_]*+)(?!</a>|">|#)"""
|
||||
htReplace = "$1<a href=\"/search?q=%23$3\">$2$3</a>"
|
||||
htReplace = "$1<a href=\"/search?f=tweets&q=%23$3\">$2$3</a>"
|
||||
|
||||
proc expandUserEntities(user: var User; raw: RawUser) =
|
||||
let
|
||||
@@ -56,32 +56,21 @@ proc toUser*(raw: RawUser): User =
|
||||
tweets: raw.statusesCount,
|
||||
likes: raw.favouritesCount,
|
||||
media: raw.mediaCount,
|
||||
verified: raw.verified,
|
||||
verifiedType: raw.verifiedType,
|
||||
protected: raw.protected,
|
||||
joinDate: parseTwitterDate(raw.createdAt),
|
||||
banner: getBanner(raw),
|
||||
userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "")
|
||||
)
|
||||
|
||||
if raw.createdAt.len > 0:
|
||||
result.joinDate = parseTwitterDate(raw.createdAt)
|
||||
|
||||
if raw.pinnedTweetIdsStr.len > 0:
|
||||
result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0])
|
||||
|
||||
result.expandUserEntities(raw)
|
||||
|
||||
proc parseUser*(json: string; username=""): User =
|
||||
handleErrors:
|
||||
case error.code
|
||||
of suspended: return User(username: username, suspended: true)
|
||||
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
|
||||
proc parseHook*(s: string; i: var int; v: var User) =
|
||||
var u: RawUser
|
||||
parseHook(s, i, u)
|
||||
v = toUser u
|
||||
|
||||
@@ -1,15 +1,48 @@
|
||||
import options
|
||||
import user
|
||||
import options, strutils
|
||||
from ../../types import User, VerifiedType
|
||||
|
||||
type
|
||||
GraphUser* = object
|
||||
data*: tuple[user: UserData]
|
||||
data*: tuple[userResult: Option[UserData], user: Option[UserData]]
|
||||
|
||||
UserData* = object
|
||||
result*: UserResult
|
||||
|
||||
UserResult = object
|
||||
legacy*: RawUser
|
||||
UserCore* = object
|
||||
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
|
||||
isBlueVerified*: bool
|
||||
core*: UserCore
|
||||
avatar*: UserAvatar
|
||||
unavailableReason*: Option[string]
|
||||
reason*: Option[string]
|
||||
privacy*: Option[Privacy]
|
||||
profileBio*: Option[UserBio]
|
||||
verification*: Option[Verification]
|
||||
location*: Option[Location]
|
||||
|
||||
proc enumHook*(s: string; v: var VerifiedType) =
|
||||
v = try:
|
||||
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
|
||||
from ../../types import VideoType, VideoVariant
|
||||
import std/[options, tables, times]
|
||||
import jsony
|
||||
from ../../types import VideoType, VideoVariant, User
|
||||
|
||||
type
|
||||
Text* = distinct string
|
||||
|
||||
UnifiedCard* = object
|
||||
componentObjects*: Table[string, Component]
|
||||
destinationObjects*: Table[string, Destination]
|
||||
@@ -13,11 +16,13 @@ type
|
||||
media
|
||||
swipeableMedia
|
||||
buttonGroup
|
||||
jobDetails
|
||||
appStoreDetails
|
||||
twitterListDetails
|
||||
communityDetails
|
||||
mediaWithDetailsHorizontal
|
||||
hidden
|
||||
grokShare
|
||||
unknown
|
||||
|
||||
Component* = object
|
||||
@@ -29,12 +34,16 @@ type
|
||||
appId*: string
|
||||
mediaId*: string
|
||||
destination*: string
|
||||
location*: string
|
||||
title*: Text
|
||||
subtitle*: Text
|
||||
name*: Text
|
||||
memberCount*: int
|
||||
mediaList*: seq[MediaItem]
|
||||
topicDetail*: tuple[title: Text]
|
||||
profileUser*: User
|
||||
shortDescriptionText*: string
|
||||
conversationPreview*: seq[GrokConversation]
|
||||
|
||||
MediaItem* = object
|
||||
id*: string
|
||||
@@ -69,12 +78,13 @@ type
|
||||
title*: Text
|
||||
category*: Text
|
||||
|
||||
Text = object
|
||||
content: string
|
||||
GrokConversation* = object
|
||||
message*: string
|
||||
sender*: string
|
||||
|
||||
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) =
|
||||
if fieldName == "type":
|
||||
@@ -86,11 +96,13 @@ proc enumHook*(s: string; v: var ComponentType) =
|
||||
of "media": media
|
||||
of "swipeable_media": swipeableMedia
|
||||
of "button_group": buttonGroup
|
||||
of "job_details": jobDetails
|
||||
of "app_store_details": appStoreDetails
|
||||
of "twitter_list_details": twitterListDetails
|
||||
of "community_details": communityDetails
|
||||
of "media_with_details_horizontal": mediaWithDetailsHorizontal
|
||||
of "commerce_drop_details": hidden
|
||||
of "grok_share": grokShare
|
||||
else: echo "ERROR: Unknown enum value (ComponentType): ", s; unknown
|
||||
|
||||
proc enumHook*(s: string; v: var AppType) =
|
||||
@@ -106,3 +118,18 @@ proc enumHook*(s: string; v: var MediaType) =
|
||||
of "photo": photo
|
||||
of "model3d": model3d
|
||||
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 common
|
||||
from ../../types import VerifiedType
|
||||
|
||||
type
|
||||
RawUser* = object
|
||||
@@ -15,7 +16,7 @@ type
|
||||
favouritesCount*: int
|
||||
statusesCount*: int
|
||||
mediaCount*: int
|
||||
verified*: bool
|
||||
verifiedType*: VerifiedType
|
||||
protected*: bool
|
||||
profileLinkColor*: string
|
||||
profileBannerUrl*: string
|
||||
|
||||
+83
-28
@@ -1,16 +1,18 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen
|
||||
import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen, math
|
||||
import std/[enumerate, re]
|
||||
import types, utils, query
|
||||
|
||||
const
|
||||
cards = "cards.twitter.com/cards"
|
||||
tco = "https://t.co"
|
||||
twitter = parseUri("https://twitter.com")
|
||||
twitter = parseUri("https://x.com")
|
||||
|
||||
let
|
||||
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
|
||||
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})
|
||||
|
||||
@@ -31,10 +33,13 @@ proc getUrlPrefix*(cfg: Config): string =
|
||||
if cfg.useHttps: https & cfg.hostname
|
||||
else: "http://" & cfg.hostname
|
||||
|
||||
proc shortLink*(text: string; length=28): string =
|
||||
result = text.replace(wwwRegex, "")
|
||||
proc shorten*(text: string; length=28): string =
|
||||
result = text
|
||||
if result.len > 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 =
|
||||
var html = parseHtml(text)
|
||||
@@ -54,19 +59,28 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
|
||||
result = body
|
||||
|
||||
if prefs.replaceYouTube.len > 0 and "youtu" in result:
|
||||
result = result.replace(ytRegex, prefs.replaceYouTube)
|
||||
let youtubeHost = strip(prefs.replaceYouTube, chars={'/'})
|
||||
result = result.replace(ytRegex, youtubeHost)
|
||||
|
||||
if prefs.replaceTwitter.len > 0 and ("twitter.com" in body or tco in body):
|
||||
result = result.replace(tco, https & prefs.replaceTwitter & "/t.co")
|
||||
result = result.replace(cards, prefs.replaceTwitter & "/cards")
|
||||
result = result.replace(twRegex, prefs.replaceTwitter)
|
||||
result = result.replacef(twLinkRegex, a(
|
||||
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
|
||||
if prefs.replaceTwitter.len > 0:
|
||||
let twitterHost = strip(prefs.replaceTwitter, chars={'/'})
|
||||
if tco in result:
|
||||
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(
|
||||
twitterHost & "$2", href = https & twitterHost & "$1"))
|
||||
|
||||
if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result):
|
||||
result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/")
|
||||
result = result.replace(rdRegex, prefs.replaceReddit)
|
||||
if prefs.replaceReddit in result and "/gallery/" in result:
|
||||
let redditHost = strip(prefs.replaceReddit, chars={'/'})
|
||||
result = result.replace(rdShortRegex, redditHost & "/comments/")
|
||||
result = result.replace(rdRegex, redditHost)
|
||||
if redditHost in result and "/gallery/" in result:
|
||||
result = result.replace("/gallery/", "/comments/")
|
||||
|
||||
if absolute.len > 0 and "href" in result:
|
||||
@@ -77,15 +91,31 @@ proc getM3u8Url*(content: string): string =
|
||||
if re.find(content, m3u8Regex, matches) != -1:
|
||||
result = matches[0]
|
||||
|
||||
proc proxifyVideo*(manifest: string; proxy: bool): string =
|
||||
proc proxifyVideo*(manifest: string; proxy: bool; manifestUrl = ""): string =
|
||||
let (baseUrl, basePath) =
|
||||
if manifestUrl.len > 0:
|
||||
let
|
||||
u = parseUri(manifestUrl)
|
||||
origin = u.scheme & "://" & u.hostname
|
||||
idx = manifestUrl.rfind('/')
|
||||
dirPath = if idx > 8: manifestUrl[0 .. idx] else: ""
|
||||
(origin, dirPath)
|
||||
else:
|
||||
("https://video.twimg.com", "")
|
||||
var replacements: seq[(string, string)]
|
||||
for line in manifest.splitLines:
|
||||
let url =
|
||||
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
|
||||
if url.startsWith('/'):
|
||||
let path = "https://video.twimg.com" & url
|
||||
replacements.add (url, if proxy: path.getVidUrl else: path)
|
||||
let resolved =
|
||||
if url.startsWith('/'): baseUrl & url
|
||||
elif basePath.len > 0 and url.len > 0 and not url.startsWith('#') and
|
||||
not url.startsWith("http") and ('.' in url): basePath & url
|
||||
else: ""
|
||||
if resolved.len > 0:
|
||||
replacements.add (url, if proxy: resolved.getVidUrl else: resolved)
|
||||
return manifest.multiReplace(replacements)
|
||||
|
||||
proc getUserPic*(userPic: string; style=""): string =
|
||||
@@ -110,25 +140,30 @@ proc pageDesc*(user: User): string =
|
||||
"The latest tweets from " & user.fullname
|
||||
|
||||
proc getJoinDate*(user: User): string =
|
||||
if user.joinDate.year == 0: return ""
|
||||
user.joinDate.format("'Joined' MMMM YYYY")
|
||||
|
||||
proc getJoinDateFull*(user: User): string =
|
||||
if user.joinDate.year == 0: return ""
|
||||
user.joinDate.format("h:mm tt - d MMM YYYY")
|
||||
|
||||
proc getTime*(tweet: Tweet): string =
|
||||
if tweet.time.year == 0: return ""
|
||||
tweet.time.format("MMM d', 'YYYY' · 'h:mm tt' UTC'")
|
||||
|
||||
proc getRfc822Time*(tweet: Tweet): string =
|
||||
if tweet.time.year == 0: return ""
|
||||
tweet.time.format("ddd', 'dd MMM yyyy HH:mm:ss 'GMT'")
|
||||
|
||||
proc getShortTime*(tweet: Tweet): string =
|
||||
proc getShortTime*(time: DateTime): string =
|
||||
if time.year == 0: return ""
|
||||
let now = now()
|
||||
let since = now - tweet.time
|
||||
let since = now - time
|
||||
|
||||
if now.year != tweet.time.year:
|
||||
result = tweet.time.format("d MMM yyyy")
|
||||
if now.year != time.year:
|
||||
result = time.format("d MMM yyyy")
|
||||
elif since.inDays >= 1:
|
||||
result = tweet.time.format("MMM d")
|
||||
result = time.format("MMM d")
|
||||
elif since.inHours >= 1:
|
||||
result = $since.inHours & "h"
|
||||
elif since.inMinutes >= 1:
|
||||
@@ -138,13 +173,33 @@ proc getShortTime*(tweet: Tweet): string =
|
||||
else:
|
||||
result = "now"
|
||||
|
||||
proc getShortTime*(tweet: Tweet): string =
|
||||
getShortTime(tweet.time)
|
||||
|
||||
proc getDuration*(ms: int): string =
|
||||
let
|
||||
sec = int(round(ms / 1000))
|
||||
min = floorDiv(sec, 60)
|
||||
hour = floorDiv(min, 60)
|
||||
if hour > 0:
|
||||
&"{hour}:{min mod 60:02}:{sec mod 60:02}"
|
||||
else:
|
||||
&"{min mod 60}:{sec mod 60:02}"
|
||||
|
||||
proc getDuration*(video: Video): string =
|
||||
getDuration(video.durationMs)
|
||||
|
||||
proc getLink*(id: int64; username="i"; focus=true): string =
|
||||
var username = username
|
||||
if username.len == 0:
|
||||
username = "i"
|
||||
result = &"/{username}/status/{id}"
|
||||
if focus: result &= "#m"
|
||||
|
||||
proc getLink*(tweet: Tweet; focus=true): string =
|
||||
if tweet.id == 0: return
|
||||
var username = tweet.user.username
|
||||
if username.len == 0:
|
||||
username = "i"
|
||||
result = &"/{username}/status/{tweet.id}"
|
||||
if focus: result &= "#m"
|
||||
return getLink(tweet.id, username, focus)
|
||||
|
||||
proc getTwitterLink*(path: string; params: Table[string, string]): string =
|
||||
var
|
||||
@@ -172,7 +227,7 @@ proc getTwitterLink*(path: string; params: Table[string, string]): string =
|
||||
proc getLocation*(u: User | Tweet): (string, string) =
|
||||
if "://" in u.location: return (u.location, "")
|
||||
let loc = u.location.split(":")
|
||||
let url = if loc.len > 1: "/search?q=place:" & loc[1] else: ""
|
||||
let url = if loc.len > 1: "/search?f=tweets&q=place:" & loc[1] else: ""
|
||||
(loc[0], url)
|
||||
|
||||
proc getSuspended*(username: string): string =
|
||||
|
||||
+2
-5
@@ -39,11 +39,8 @@ template use*(pool: HttpPool; heads: HttpHeaders; body: untyped): untyped =
|
||||
|
||||
try:
|
||||
body
|
||||
except ProtocolError:
|
||||
# Twitter closed the connection, retry
|
||||
body
|
||||
except BadClientError:
|
||||
# Twitter returned 503, we need a new client
|
||||
except BadClientError, ProtocolError:
|
||||
# Twitter returned 503 or closed the connection, we need a new client
|
||||
pool.release(c, true)
|
||||
badClient = false
|
||||
c = pool.acquire(heads)
|
||||
|
||||
+37
-12
@@ -2,21 +2,26 @@
|
||||
import asyncdispatch, strformat, logging
|
||||
from net import Port
|
||||
from htmlgen import a
|
||||
from os import getEnv
|
||||
from os import getEnv, normalizedPath
|
||||
|
||||
import jester
|
||||
|
||||
import types, config, prefs, formatters, redis_cache, http_pool, tokens
|
||||
import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
|
||||
import views/[general, about]
|
||||
import routes/[
|
||||
preferences, timeline, status, media, search, rss, list, debug,
|
||||
unsupported, embed, resolver, router_utils]
|
||||
unsupported, embed, resolver, broadcast, router_utils]
|
||||
|
||||
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
||||
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
||||
|
||||
let configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf")
|
||||
let (cfg, fullCfg) = getConfig(configPath)
|
||||
let
|
||||
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:
|
||||
# Silence Jester's query warning
|
||||
@@ -32,14 +37,17 @@ setHmacKey(cfg.hmacKey)
|
||||
setProxyEncoding(cfg.base64Media)
|
||||
setMaxHttpConns(cfg.httpMaxConns)
|
||||
setHttpProxy(cfg.proxy, cfg.proxyAuth)
|
||||
setApiProxy(cfg.apiProxy)
|
||||
setDisableTid(cfg.disableTid)
|
||||
setMaxConcurrentReqs(cfg.maxConcurrentReqs)
|
||||
setMaxRetries(cfg.maxRetries)
|
||||
setRetryDelayMs(cfg.retryDelayMs)
|
||||
initAboutPage(cfg.staticDir)
|
||||
|
||||
waitFor initRedisPool(cfg)
|
||||
stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
|
||||
stdout.flushFile
|
||||
|
||||
asyncCheck initTokenPool(cfg)
|
||||
|
||||
createUnsupportedRouter(cfg)
|
||||
createResolverRouter(cfg)
|
||||
createPrefRouter(cfg)
|
||||
@@ -50,20 +58,31 @@ createSearchRouter(cfg)
|
||||
createMediaRouter(cfg)
|
||||
createEmbedRouter(cfg)
|
||||
createRssRouter(cfg)
|
||||
createBroadcastRouter(cfg)
|
||||
createDebugRouter(cfg)
|
||||
|
||||
settings:
|
||||
port = Port(cfg.port)
|
||||
staticDir = cfg.staticDir
|
||||
staticDir = normalizedPath(cfg.staticDir)
|
||||
bindAddr = cfg.address
|
||||
reusePort = true
|
||||
maxBody = 64 * 1024
|
||||
|
||||
routes:
|
||||
before:
|
||||
# Reject malformed paths
|
||||
if request.path.len == 0 or request.path[0] != '/':
|
||||
halt Http400
|
||||
|
||||
# skip all file URLs
|
||||
cond "." notin request.path
|
||||
applyUrlPrefs()
|
||||
|
||||
get "/":
|
||||
resp renderMain(renderSearch(), request, cfg, themePrefs())
|
||||
resp renderMain(renderSearch(), request, cfg, requestPrefs())
|
||||
|
||||
get "/about":
|
||||
resp renderMain(renderAbout(), request, cfg, themePrefs())
|
||||
resp renderMain(renderAbout(), request, cfg, requestPrefs())
|
||||
|
||||
get "/explore":
|
||||
redirect("/about")
|
||||
@@ -74,7 +93,7 @@ routes:
|
||||
get "/i/redirect":
|
||||
let url = decodeUrl(@"url")
|
||||
if url.len == 0: resp Http404
|
||||
redirect(replaceUrls(url, cookiePrefs()))
|
||||
redirect(replaceUrls(url, requestPrefs()))
|
||||
|
||||
error Http404:
|
||||
resp Http404, showError("Page not found", cfg)
|
||||
@@ -87,13 +106,18 @@ routes:
|
||||
|
||||
error BadClientError:
|
||||
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:
|
||||
const link = a("another instance", href = instancesUrl)
|
||||
resp Http429, showError(
|
||||
&"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 status, ""
|
||||
extend search, ""
|
||||
@@ -103,5 +127,6 @@ routes:
|
||||
extend preferences, ""
|
||||
extend resolver, ""
|
||||
extend embed, ""
|
||||
extend broadcastRoute, ""
|
||||
extend debug, ""
|
||||
extend unsupported, ""
|
||||
|
||||
+564
-246
@@ -1,10 +1,20 @@
|
||||
# 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 types, parserutils, utils
|
||||
import experimental/parser/unifiedcard
|
||||
|
||||
proc parseGraphTweet(js: JsonNode): Tweet
|
||||
proc parseGraphTweet*(js: JsonNode): Tweet
|
||||
|
||||
proc parseVerifiedType(s: string; current: VerifiedType): VerifiedType =
|
||||
try: parseEnum[VerifiedType](s)
|
||||
except ValueError: current
|
||||
|
||||
proc parseCommunityNote(js: JsonNode): string =
|
||||
let subtitle = js{"subtitle"}
|
||||
result = subtitle{"text"}.getStr
|
||||
with entities, subtitle{"entities"}:
|
||||
result = expandBirdwatchEntities(result, entities)
|
||||
|
||||
proc parseUser(js: JsonNode; id=""): User =
|
||||
if js.isNull: return
|
||||
@@ -21,19 +31,105 @@ proc parseUser(js: JsonNode; id=""): User =
|
||||
tweets: js{"statuses_count"}.getInt,
|
||||
likes: js{"favourites_count"}.getInt,
|
||||
media: js{"media_count"}.getInt,
|
||||
verified: js{"verified"}.getBool or js{"ext_is_blue_verified"}.getBool,
|
||||
protected: js{"protected"}.getBool,
|
||||
protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool),
|
||||
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)
|
||||
|
||||
proc parseGraphUser(js: JsonNode): User =
|
||||
let user = ? js{"user_results", "result"}
|
||||
result = parseUser(user{"legacy"})
|
||||
var user = js{"user_result", "result"}
|
||||
if user.isNull:
|
||||
user = js{"user_results", "result"}
|
||||
|
||||
if "is_blue_verified" in user:
|
||||
result.verified = true
|
||||
if user.isNull:
|
||||
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 =
|
||||
if js.isNull: return
|
||||
@@ -72,39 +168,121 @@ proc parsePoll(js: JsonNode): Poll =
|
||||
result.leader = result.values.find(max(result.values))
|
||||
result.votes = result.values.sum
|
||||
|
||||
proc parseGif(js: JsonNode): Gif =
|
||||
result = Gif(
|
||||
url: js{"video_info", "variants"}[0]{"url"}.getImageStr,
|
||||
thumb: js{"media_url_https"}.getImageStr
|
||||
)
|
||||
proc parseVideoVariants(variants: JsonNode): seq[VideoVariant] =
|
||||
result = @[]
|
||||
for v in variants:
|
||||
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 =
|
||||
result = Video(
|
||||
thumb: js{"media_url_https"}.getImageStr,
|
||||
views: js{"ext", "mediaStats", "r", "ok", "viewCount"}.getStr($js{"mediaStats", "viewCount"}.getInt),
|
||||
available: js{"ext_media_availability", "status"}.getStr.toLowerAscii == "available",
|
||||
available: true,
|
||||
title: js{"ext_alt_text"}.getStr,
|
||||
durationMs: js{"video_info", "duration_millis"}.getInt
|
||||
# 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"}:
|
||||
result.title = title.getStr
|
||||
|
||||
with description, js{"additional_media_info", "description"}:
|
||||
result.description = description.getStr
|
||||
|
||||
for v in js{"video_info", "variants"}:
|
||||
let
|
||||
contentType = parseEnum[VideoType](v{"content_type"}.getStr("summary"))
|
||||
url = v{"url"}.getStr
|
||||
result.variants = parseVideoVariants(js{"video_info", "variants"})
|
||||
|
||||
result.variants.add VideoVariant(
|
||||
contentType: contentType,
|
||||
bitrate: v{"bitrate"}.getInt,
|
||||
url: url,
|
||||
resolution: if contentType == mp4: getMp4Resolution(url) else: 0
|
||||
)
|
||||
proc addMedia(media: var MediaEntities; photo: Photo) =
|
||||
media.add Media(kind: photoMedia, photo: photo)
|
||||
|
||||
proc addMedia(media: var MediaEntities; video: Video) =
|
||||
media.add Media(kind: videoMedia, video: video)
|
||||
|
||||
proc addMedia(media: var MediaEntities; gif: Gif) =
|
||||
media.add Media(kind: gifMedia, gif: gif)
|
||||
|
||||
proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
|
||||
with jsMedia, js{"extended_entities", "media"}:
|
||||
for m in jsMedia:
|
||||
case m.getTypeName:
|
||||
of "photo":
|
||||
result.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 =
|
||||
result = Video(
|
||||
@@ -127,14 +305,23 @@ proc parsePromoVideo(js: JsonNode): Video =
|
||||
result.variants.add variant
|
||||
|
||||
proc parseBroadcast(js: JsonNode): Card =
|
||||
let image = js{"broadcast_thumbnail_large"}.getImageVal
|
||||
let
|
||||
image = js{"broadcast_thumbnail_large"}.getImageVal
|
||||
broadcastUrl = js{"broadcast_url"}.getStrVal
|
||||
broadcastId = broadcastUrl.rsplit('/', maxsplit=1)[^1]
|
||||
streamUrl = "/i/broadcasts/" & broadcastId & "/stream"
|
||||
result = Card(
|
||||
kind: broadcast,
|
||||
url: js{"broadcast_url"}.getStrVal,
|
||||
url: "/i/broadcasts/" & broadcastId,
|
||||
title: js{"broadcaster_display_name"}.getStrVal,
|
||||
text: js{"broadcast_title"}.getStrVal,
|
||||
image: image,
|
||||
video: some Video(thumb: image)
|
||||
video: some Video(
|
||||
thumb: image,
|
||||
available: true,
|
||||
playbackType: m3u8,
|
||||
variants: @[VideoVariant(contentType: m3u8, url: streamUrl)]
|
||||
)
|
||||
)
|
||||
|
||||
proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
||||
@@ -184,7 +371,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
||||
|
||||
for u in ? urls:
|
||||
if u{"url"}.getStr == result.url:
|
||||
result.url = u{"expanded_url"}.getStr
|
||||
result.url = u.getExpandedUrl(result.url)
|
||||
break
|
||||
|
||||
if kind in {videoDirectMessage, imageDirectMessage}:
|
||||
@@ -194,14 +381,20 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
||||
result.url.len == 0 or result.url.startsWith("card://"):
|
||||
result.url = getPicUrl(result.image)
|
||||
|
||||
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
||||
if js.isNull: return
|
||||
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
|
||||
replyId: int64 = 0): Tweet =
|
||||
if js.isNull: return Tweet()
|
||||
|
||||
let time =
|
||||
if js{"created_at"}.notNull: js{"created_at"}.getTime
|
||||
else: js{"created_at_ms"}.getTimeFromMs
|
||||
|
||||
result = Tweet(
|
||||
id: js{"id_str"}.getId,
|
||||
threadId: js{"conversation_id_str"}.getId,
|
||||
replyId: js{"in_reply_to_status_id_str"}.getId,
|
||||
text: js{"full_text"}.getStr,
|
||||
time: js{"created_at"}.getTime,
|
||||
time: time,
|
||||
hasThread: js{"self_thread"}.notNull,
|
||||
available: true,
|
||||
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,
|
||||
retweets: js{"retweet_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
|
||||
if result.hasThread and result.threadId == 0:
|
||||
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)
|
||||
|
||||
# legacy
|
||||
@@ -230,37 +426,32 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
||||
# graphql
|
||||
with rt, js{"retweeted_status_result", "result"}:
|
||||
# needed due to weird edgecase where the actual tweet data isn't included
|
||||
if "legacy" in rt:
|
||||
if "legacy" in rt or "rest_id" in rt:
|
||||
result.retweet = some parseGraphTweet(rt)
|
||||
return
|
||||
|
||||
with reposts, js{"repostedStatusResults"}:
|
||||
with rt, reposts{"result"}:
|
||||
if "legacy" in rt or "rest_id" in rt:
|
||||
result.retweet = some parseGraphTweet(rt)
|
||||
return
|
||||
|
||||
if jsCard.kind != JNull:
|
||||
let name = jsCard{"name"}.getStr
|
||||
if "poll" in name:
|
||||
if "image" in name:
|
||||
result.photos.add jsCard{"binding_values", "image_large"}.getImageVal
|
||||
result.media.addMedia(Photo(
|
||||
url: jsCard{"binding_values", "image_large"}.getImageVal
|
||||
))
|
||||
|
||||
result.poll = some parsePoll(jsCard)
|
||||
elif name == "amplify":
|
||||
result.video = some(parsePromoVideo(jsCard{"binding_values"}))
|
||||
else:
|
||||
result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
|
||||
elif name.len > 0 and jsCard{"binding_values"}.notNull:
|
||||
result.card = some parseCard(jsCard, js{"entities", "urls"})
|
||||
|
||||
with jsMedia, js{"extended_entities", "media"}:
|
||||
for m in jsMedia:
|
||||
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
|
||||
result.expandTweetEntities(js)
|
||||
parseLegacyMediaEntities(js, result)
|
||||
|
||||
with jsWithheld, js{"withheld_in_countries"}:
|
||||
let withheldInCountries: seq[string] =
|
||||
@@ -276,242 +467,369 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
||||
result.text.removeSuffix(" Learn more.")
|
||||
result.available = false
|
||||
|
||||
proc finalizeTweet(global: GlobalObjects; id: string): 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 =
|
||||
proc parseGraphTweet*(js: JsonNode): Tweet =
|
||||
if js.kind == JNull:
|
||||
return Tweet()
|
||||
|
||||
case js{"__typename"}.getStr
|
||||
case js.getTypeName:
|
||||
of "TweetUnavailable":
|
||||
return Tweet()
|
||||
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":
|
||||
return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
|
||||
of "TweetWithVisibilityResults":
|
||||
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:
|
||||
var values = newJObject()
|
||||
for val in jsCard["binding_values"]:
|
||||
values[val["key"].getStr] = val["value"]
|
||||
jsCard["binding_values"] = values
|
||||
let legacyCard = jsCard{"legacy"}
|
||||
if legacyCard.kind != JNull:
|
||||
let bindingArray = legacyCard{"binding_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"})
|
||||
|
||||
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"}:
|
||||
result.expandNoteTweetEntities(noteTweet)
|
||||
|
||||
if result.quote.isSome:
|
||||
result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}))
|
||||
parseMediaEntities(js, 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] =
|
||||
let thread = js{"content", "items"}
|
||||
for t in js{"content", "items"}:
|
||||
let entryId = t{"entryId"}.getStr
|
||||
if "cursor-showmore" in entryId:
|
||||
let cursor = t{"item", "itemContent", "value"}
|
||||
for t in ? js{"content", "items"}:
|
||||
let entryId = t.getEntryId
|
||||
if "tweet-" in entryId and "promoted" notin entryId:
|
||||
let tweet = t.getTweetResult("item")
|
||||
if tweet.notNull:
|
||||
result.thread.content.add parseGraphTweet(tweet)
|
||||
|
||||
let tweetDisplayType = select(
|
||||
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.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 =
|
||||
with tweet, js{"data", "tweetResult", "result"}:
|
||||
with tweet, js{"data", "tweet_result", "result"}:
|
||||
result = parseGraphTweet(tweet)
|
||||
|
||||
proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
||||
result = Conversation(replies: Result[Chain](beginning: true))
|
||||
|
||||
let instructions = ? js{"data", "threaded_conversation_with_injections", "instructions"}
|
||||
if instructions.len == 0:
|
||||
return
|
||||
|
||||
for e in instructions[0]{"entries"}:
|
||||
let entryId = e{"entryId"}.getStr
|
||||
# echo entryId
|
||||
if entryId.startsWith("tweet"):
|
||||
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
|
||||
let tweet = parseGraphTweet(tweetResult)
|
||||
|
||||
if not tweet.available:
|
||||
tweet.id = parseBiggestInt(entryId.getId())
|
||||
|
||||
if $tweet.id == tweetId:
|
||||
result.tweet = tweet
|
||||
else:
|
||||
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"):
|
||||
result.replies.bottom = e{"content", "itemContent", "value"}.getStr
|
||||
|
||||
proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Timeline =
|
||||
result = Timeline(beginning: after.len == 0)
|
||||
|
||||
let instructions =
|
||||
if root == "list": ? js{"data", "list", "tweets_timeline", "timeline", "instructions"}
|
||||
else: ? js{"data", "user", "result", "timeline_v2", "timeline", "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:
|
||||
return
|
||||
|
||||
for i in instructions:
|
||||
if i{"type"}.getStr == "TimelineAddEntries":
|
||||
if i.getTypeName == "TimelineAddEntries":
|
||||
for e in i{"entries"}:
|
||||
let entryId = e{"entryId"}.getStr
|
||||
if entryId.startsWith("tweet"):
|
||||
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
|
||||
let entryId = e.getEntryId
|
||||
if entryId.startsWith("tweet-"):
|
||||
let tweetResult = getTweetResult(e)
|
||||
if tweetResult.notNull:
|
||||
let tweet = parseGraphTweet(tweetResult)
|
||||
|
||||
if not tweet.available:
|
||||
tweet.id = parseBiggestInt(entryId.getId())
|
||||
result.content.add tweet
|
||||
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:
|
||||
result.tweet = tweet
|
||||
else:
|
||||
result.before.content.add tweet
|
||||
elif entryId.startsWith("cursor-bottom"):
|
||||
result.bottom = e{"content", "value"}.getStr
|
||||
var cursorValue = select(
|
||||
e{"content", "value"},
|
||||
e{"content", "content", "value"},
|
||||
e{"content", "itemContent", "value"}
|
||||
)
|
||||
result.replies.bottom = cursorValue.getStr
|
||||
|
||||
proc parseGraphSearch*(js: JsonNode; after=""): Timeline =
|
||||
result = Timeline(beginning: after.len == 0)
|
||||
proc parseGraphEditHistory*(js: JsonNode; tweetId: string): EditHistory =
|
||||
let instructions = ? js{
|
||||
"data", "tweet_result_by_rest_id", "result",
|
||||
"edit_history_timeline", "timeline", "instructions"
|
||||
}
|
||||
if instructions.len == 0:
|
||||
return
|
||||
|
||||
let instructions = js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"}
|
||||
for i in instructions:
|
||||
if i.getTypeName == "TimelineAddEntries":
|
||||
for e in i{"entries"}:
|
||||
let entryId = e.getEntryId
|
||||
if entryId == "latestTweet":
|
||||
with item, e{"content", "items"}[0]:
|
||||
let tweetResult = item.getTweetResult("item")
|
||||
if tweetResult.notNull:
|
||||
result.latest = parseGraphTweet(tweetResult)
|
||||
elif entryId == "staleTweets":
|
||||
for item in e{"content", "items"}:
|
||||
let tweetResult = item.getTweetResult("item")
|
||||
if tweetResult.notNull:
|
||||
result.history.add parseGraphTweet(tweetResult)
|
||||
|
||||
proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] =
|
||||
with tweetResult, getTweetResult(e):
|
||||
var tweet = parseGraphTweet(tweetResult)
|
||||
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)
|
||||
if not tweet.available:
|
||||
tweet.id = item.getEntryId.getId
|
||||
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"):
|
||||
result.tweets.bottom = e{"content", "value"}.getStr
|
||||
|
||||
if 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
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
for instruction in instructions:
|
||||
let typ = instruction{"type"}.getStr
|
||||
let typ = getTypeName(instruction)
|
||||
if typ == "TimelineAddEntries":
|
||||
for e in instructions[0]{"entries"}:
|
||||
let entryId = e{"entryId"}.getStr
|
||||
if entryId.startsWith("tweet"):
|
||||
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
|
||||
let tweet = parseGraphTweet(tweetResult)
|
||||
if not tweet.available:
|
||||
tweet.id = parseBiggestInt(entryId.getId())
|
||||
result.content.add tweet
|
||||
elif entryId.startsWith("cursor-bottom"):
|
||||
for e in instruction{"entries"}:
|
||||
let entryId = e.getEntryId
|
||||
when T is Tweets:
|
||||
if entryId.startsWith("tweet"):
|
||||
with tweetRes, getTweetResult(e):
|
||||
let tweet = parseGraphTweet(tweetRes)
|
||||
if not tweet.available:
|
||||
tweet.id = entryId.getId
|
||||
result.content.add tweet
|
||||
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
|
||||
elif typ == "TimelineReplaceEntry":
|
||||
if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"):
|
||||
|
||||
+169
-36
@@ -1,15 +1,23 @@
|
||||
# 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
|
||||
from xmltree import escape
|
||||
import packedjson
|
||||
import types, utils, formatters
|
||||
|
||||
const
|
||||
unicodeOpen = "\uFFFA"
|
||||
unicodeClose = "\uFFFB"
|
||||
xmlOpen = escape("<")
|
||||
xmlClose = escape(">")
|
||||
|
||||
let
|
||||
unRegex = re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})"
|
||||
unReplace = "$1<a href=\"/$2\">@$2</a>"
|
||||
|
||||
htRegex = re"(^|[^\w-_./?])([#$]|#)([\w_]+)"
|
||||
htReplace = "$1<a href=\"/search?q=%23$3\">$2$3</a>"
|
||||
htReplace = "$1<a href=\"/search?f=tweets&q=%23$3\">$2$3</a>"
|
||||
|
||||
type
|
||||
ReplaceSliceKind = enum
|
||||
@@ -28,6 +36,12 @@ template `?`*(js: JsonNode): untyped =
|
||||
if j.isNull: return
|
||||
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 =
|
||||
if true:
|
||||
let ident {.inject.} = value
|
||||
@@ -45,6 +59,19 @@ template getError*(js: JsonNode): Error =
|
||||
if js.kind != JArray or js.len == 0: null
|
||||
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 =
|
||||
if time.len != flen: return
|
||||
parse(time, f, utc())
|
||||
@@ -55,29 +82,32 @@ proc getDateTime*(js: JsonNode): DateTime =
|
||||
proc getTime*(js: JsonNode): DateTime =
|
||||
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("-")
|
||||
if start < 0: return id
|
||||
id[start + 1 ..< id.len]
|
||||
if start < 0:
|
||||
return parseBiggestInt(id)
|
||||
return parseBiggestInt(id[start + 1 ..< id.len])
|
||||
|
||||
proc getId*(js: JsonNode): int64 {.inline.} =
|
||||
case js.kind
|
||||
of JString: return parseBiggestInt(js.getStr("0"))
|
||||
of JString: return js.getStr("0").getId
|
||||
of JInt: return js.getBiggestInt()
|
||||
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 =
|
||||
js{"string_value"}.getStr(default)
|
||||
|
||||
@@ -89,6 +119,9 @@ proc getImageStr*(js: JsonNode): string =
|
||||
template getImageVal*(js: JsonNode): string =
|
||||
js{"image_value", "url"}.getImageStr
|
||||
|
||||
template getExpandedUrl*(js: JsonNode; fallback=""): string =
|
||||
js{"expanded_url"}.getStr(js{"url"}.getStr(fallback))
|
||||
|
||||
proc getCardUrl*(js: JsonNode; kind: CardKind): string =
|
||||
result = js{"website_url"}.getStrVal
|
||||
if kind == promoVideoConvo:
|
||||
@@ -154,7 +187,7 @@ proc extractSlice(js: JsonNode): Slice[int] =
|
||||
proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
|
||||
textLen: int; hideTwitter = false) =
|
||||
let
|
||||
url = js["expanded_url"].getStr
|
||||
url = js.getExpandedUrl
|
||||
slice = js.extractSlice
|
||||
|
||||
if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
|
||||
@@ -169,19 +202,32 @@ proc extractHashtags(result: var seq[ReplaceSlice]; js: JsonNode) =
|
||||
|
||||
proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice];
|
||||
textSlice: Slice[int]): string =
|
||||
let
|
||||
runeLen = runes.len
|
||||
safeStart = max(0, textSlice.a)
|
||||
safeEnd = min(runeLen, textSlice.b)
|
||||
|
||||
var validRepls: seq[ReplaceSlice]
|
||||
for rep in repls:
|
||||
if rep.slice.a >= 0 and rep.slice.b >= 0 and rep.slice.b < runeLen and rep.slice.a <= rep.slice.b:
|
||||
validRepls.add rep
|
||||
|
||||
template extractLowerBound(i: int; idx): int =
|
||||
if i > 0: repls[idx].slice.b.succ else: textSlice.a
|
||||
if i > 0: min(validRepls[idx].slice.b.succ, runeLen) else: safeStart
|
||||
|
||||
result = newStringOfCap(runes.len)
|
||||
|
||||
for i, rep in repls:
|
||||
result.add $runes[extractLowerBound(i, i - 1) ..< rep.slice.a]
|
||||
for i, rep in validRepls:
|
||||
let lower = extractLowerBound(i, i - 1)
|
||||
if lower < rep.slice.a:
|
||||
result.add $runes[lower ..< rep.slice.a]
|
||||
case rep.kind
|
||||
of rkHashtag:
|
||||
let
|
||||
name = $runes[rep.slice.a.succ .. rep.slice.b]
|
||||
symbol = $runes[rep.slice.a]
|
||||
result.add a(symbol & name, href = "/search?q=%23" & name)
|
||||
if rep.slice.a.succ <= rep.slice.b:
|
||||
let
|
||||
name = $runes[rep.slice.a.succ .. rep.slice.b]
|
||||
symbol = $runes[rep.slice.a]
|
||||
result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name)
|
||||
of rkMention:
|
||||
result.add a($runes[rep.slice], href = rep.url, title = rep.display)
|
||||
of rkUrl:
|
||||
@@ -189,8 +235,8 @@ proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice];
|
||||
of rkRemove:
|
||||
discard
|
||||
|
||||
let rest = extractLowerBound(repls.len, ^1) ..< textSlice.b
|
||||
if rest.a <= rest.b:
|
||||
let rest = extractLowerBound(validRepls.len, ^1) ..< safeEnd
|
||||
if rest.a >= 0 and rest.a <= rest.b and rest.b <= runeLen:
|
||||
result.add $runes[rest]
|
||||
|
||||
proc deduplicate(s: var seq[ReplaceSlice]) =
|
||||
@@ -215,7 +261,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
|
||||
ent = ? js{"entities"}
|
||||
|
||||
with urls, ent{"url", "urls"}:
|
||||
user.website = urls[0]{"expanded_url"}.getStr
|
||||
user.website = urls[0].getExpandedUrl
|
||||
|
||||
var replacements = newSeq[ReplaceSlice]()
|
||||
|
||||
@@ -231,7 +277,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
|
||||
.replacef(htRegex, htReplace)
|
||||
|
||||
proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int];
|
||||
replyTo=""; hasQuote=false) =
|
||||
replyTo=""; hasRedundantLink=false) =
|
||||
let hasCard = tweet.card.isSome
|
||||
|
||||
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:
|
||||
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:
|
||||
get(tweet.card).url = u{"expanded_url"}.getStr
|
||||
get(tweet.card).url = u.getExpandedUrl
|
||||
|
||||
with media, entities{"media"}:
|
||||
for m in media:
|
||||
@@ -282,9 +328,10 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
|
||||
proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
|
||||
let
|
||||
entities = ? js{"entities"}
|
||||
hasQuote = js{"is_quote_status"}.getBool
|
||||
textRange = js{"display_text_range"}
|
||||
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 = ""
|
||||
if tweet.replyId != 0:
|
||||
@@ -292,12 +339,98 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
|
||||
replyTo = reply.getStr
|
||||
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) =
|
||||
let
|
||||
entities = ? js{"entity_set"}
|
||||
text = js{"text"}.getStr
|
||||
text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose))
|
||||
textSlice = 0..text.runeLen
|
||||
hasAttribution = tweet.attribution.isSome
|
||||
|
||||
tweet.expandTextEntities(entities, text, textSlice)
|
||||
tweet.expandTextEntities(entities, text, textSlice, hasRedundantLink=hasAttribution)
|
||||
|
||||
tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose))
|
||||
|
||||
proc expandBirdwatchEntities*(text: string; entities: JsonNode): string =
|
||||
let runes = text.toRunes
|
||||
var replacements: seq[ReplaceSlice]
|
||||
|
||||
for entity in entities:
|
||||
let
|
||||
fromIdx = entity{"from_index"}.getInt
|
||||
toIdx = entity{"to_index"}.getInt
|
||||
url = entity{"ref", "url"}.getStr
|
||||
if url.len > 0:
|
||||
replacements.add ReplaceSlice(
|
||||
kind: rkUrl,
|
||||
slice: fromIdx ..< toIdx,
|
||||
url: url,
|
||||
display: $runes[fromIdx ..< min(toIdx, runes.len)]
|
||||
)
|
||||
|
||||
replacements.sort(cmp)
|
||||
result = runes.replacedWith(replacements, 0 ..< runes.len)
|
||||
|
||||
proc extractGalleryPhoto*(t: Tweet): GalleryPhoto =
|
||||
let url =
|
||||
if t.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
|
||||
import tables
|
||||
import tables, strutils
|
||||
import types, prefs_impl
|
||||
from config import get
|
||||
from parsecfg import nil
|
||||
|
||||
export genUpdatePrefs, genResetPrefs
|
||||
export genUpdatePrefs, genResetPrefs, genApplyPrefs
|
||||
|
||||
var defaultPrefs*: Prefs
|
||||
|
||||
proc updateDefaultPrefs*(cfg: parsecfg.Config) =
|
||||
genDefaultPrefs()
|
||||
|
||||
proc getPrefs*(cookies: Table[string, string]): Prefs =
|
||||
proc getPrefs*(cookies, params: Table[string, string]): Prefs =
|
||||
result = defaultPrefs
|
||||
genCookiePrefs(cookies)
|
||||
genParsePrefs(cookies)
|
||||
genParsePrefs(params)
|
||||
|
||||
template getPref*(cookies: Table[string, string], pref): untyped =
|
||||
bind genCookiePref
|
||||
var res = defaultPrefs.`pref`
|
||||
genCookiePref(cookies, pref, res)
|
||||
res
|
||||
proc encodePrefs*(prefs: Prefs): string =
|
||||
var encPairs: seq[string]
|
||||
genEncodePrefs(prefs)
|
||||
encPairs.join(",")
|
||||
|
||||
+54
-27
@@ -60,6 +60,9 @@ genPrefs:
|
||||
stickyProfile(checkbox, true):
|
||||
"Make profile sidebar stick to top"
|
||||
|
||||
stickyNav(checkbox, true):
|
||||
"Keep navbar fixed to top"
|
||||
|
||||
bidiSupport(checkbox, false):
|
||||
"Support bidirectional text (makes clicking on tweets harder)"
|
||||
|
||||
@@ -75,6 +78,9 @@ genPrefs:
|
||||
hideReplies(checkbox, false):
|
||||
"Hide tweet replies"
|
||||
|
||||
hideCommunityNotes(checkbox, false):
|
||||
"Hide community notes"
|
||||
|
||||
squareAvatars(checkbox, false):
|
||||
"Square profile pictures"
|
||||
|
||||
@@ -94,6 +100,17 @@ genPrefs:
|
||||
autoplayGifs(checkbox, true):
|
||||
"Autoplay gifs"
|
||||
|
||||
compactGallery(checkbox, false):
|
||||
"Compact media gallery (no profile info or text)"
|
||||
|
||||
gallerySize(select, "Medium"):
|
||||
"Gallery column size"
|
||||
options: @["Small", "Medium", "Large"]
|
||||
|
||||
mediaView(select, "Timeline"):
|
||||
"Default media view"
|
||||
options: @["Timeline", "Grid", "Gallery"]
|
||||
|
||||
"Link replacements (blank to disable)":
|
||||
replaceTwitter(input, ""):
|
||||
"Twitter -> Nitter"
|
||||
@@ -127,7 +144,7 @@ macro genDefaultPrefs*(): untyped =
|
||||
result.add quote do:
|
||||
defaultPrefs.`ident` = cfg.get("Preferences", `name`, `default`)
|
||||
|
||||
macro genCookiePrefs*(cookies): untyped =
|
||||
macro genParsePrefs*(prefs): untyped =
|
||||
result = nnkStmtList.newTree()
|
||||
for pref in allPrefs():
|
||||
let
|
||||
@@ -137,37 +154,17 @@ macro genCookiePrefs*(cookies): untyped =
|
||||
options = pref.options
|
||||
|
||||
result.add quote do:
|
||||
if `name` in `cookies`:
|
||||
if `name` in `prefs`:
|
||||
when `kind` == input or `name` == "theme":
|
||||
result.`ident` = `cookies`[`name`]
|
||||
result.`ident` = `prefs`[`name`]
|
||||
elif `kind` == checkbox:
|
||||
result.`ident` = `cookies`[`name`] == "on"
|
||||
result.`ident` = `prefs`[`name`] == "on" or
|
||||
`prefs`[`name`] == "true" or
|
||||
`prefs`[`name`] == "1"
|
||||
else:
|
||||
let value = `cookies`[`name`]
|
||||
let value = `prefs`[`name`]
|
||||
if value in `options`: result.`ident` = value
|
||||
|
||||
macro genCookiePref*(cookies, prefName, res): untyped =
|
||||
result = nnkStmtList.newTree()
|
||||
for pref in allPrefs():
|
||||
let ident = ident(pref.name)
|
||||
if ident != prefName:
|
||||
continue
|
||||
|
||||
let
|
||||
name = pref.name
|
||||
kind = newLit(pref.kind)
|
||||
options = pref.options
|
||||
|
||||
result.add quote do:
|
||||
if `name` in `cookies`:
|
||||
when `kind` == input or `name` == "theme":
|
||||
`res` = `cookies`[`name`]
|
||||
elif `kind` == checkbox:
|
||||
`res` = `cookies`[`name`] == "on"
|
||||
else:
|
||||
let value = `cookies`[`name`]
|
||||
if value in `options`: `res` = value
|
||||
|
||||
macro genUpdatePrefs*(): untyped =
|
||||
result = nnkStmtList.newTree()
|
||||
let req = ident("request")
|
||||
@@ -202,6 +199,36 @@ macro genResetPrefs*(): untyped =
|
||||
result.add quote do:
|
||||
savePref(`name`, "", `req`, expire=true)
|
||||
|
||||
macro genEncodePrefs*(prefs): untyped =
|
||||
result = nnkStmtList.newTree()
|
||||
for pref in allPrefs():
|
||||
let
|
||||
name = newLit(pref.name)
|
||||
ident = ident(pref.name)
|
||||
kind = newLit(pref.kind)
|
||||
defaultIdent = nnkDotExpr.newTree(ident("defaultPrefs"), ident(pref.name))
|
||||
|
||||
result.add quote do:
|
||||
when `kind` == checkbox:
|
||||
if `prefs`.`ident` != `defaultIdent`:
|
||||
if `prefs`.`ident`:
|
||||
encPairs.add `name` & "=on"
|
||||
else:
|
||||
encPairs.add `name` & "="
|
||||
else:
|
||||
if `prefs`.`ident` != `defaultIdent`:
|
||||
encPairs.add `name` & "=" & `prefs`.`ident`
|
||||
|
||||
macro genApplyPrefs*(params, req): untyped =
|
||||
result = nnkStmtList.newTree()
|
||||
for pref in allPrefs():
|
||||
let name = newLit(pref.name)
|
||||
result.add quote do:
|
||||
if `name` in `params`:
|
||||
savePref(`name`, `params`[`name`], `req`)
|
||||
else:
|
||||
savePref(`name`, "", `req`, expire=true)
|
||||
|
||||
macro genPrefsType*(): untyped =
|
||||
let name = nnkPostfix.newTree(ident("*"), ident("Prefs"))
|
||||
result = quote do:
|
||||
|
||||
+46
-30
@@ -1,15 +1,14 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat, sequtils, tables, uri
|
||||
|
||||
import types
|
||||
import types, utils
|
||||
|
||||
const
|
||||
validFilters* = @[
|
||||
"media", "images", "twimg", "videos",
|
||||
"native_video", "consumer_video", "pro_video",
|
||||
"native_video", "consumer_video", "spaces",
|
||||
"links", "news", "quote", "mentions",
|
||||
"replies", "retweets", "nativeretweets",
|
||||
"verified", "safe"
|
||||
"replies", "retweets", "nativeretweets", "cashtags"
|
||||
]
|
||||
|
||||
emptyQuery* = "include:nativeretweets"
|
||||
@@ -21,12 +20,13 @@ template `@`(param: string): untyped =
|
||||
proc initQuery*(pms: Table[string, string]; name=""): Query =
|
||||
result = Query(
|
||||
kind: parseEnum[QueryKind](@"f", tweets),
|
||||
view: @"view",
|
||||
text: @"q",
|
||||
filters: validFilters.filterIt("f-" & it in pms),
|
||||
excludes: validFilters.filterIt("e-" & it in pms),
|
||||
since: @"since",
|
||||
until: @"until",
|
||||
near: @"near"
|
||||
minLikes: validateNumber(@"min_faves")
|
||||
)
|
||||
|
||||
if name.len > 0:
|
||||
@@ -46,7 +46,7 @@ proc getReplyQuery*(name: string): Query =
|
||||
fromUser: @[name]
|
||||
)
|
||||
|
||||
proc genQueryParam*(query: Query): string =
|
||||
proc genQueryParam*(query: Query; maxId=""): string =
|
||||
var
|
||||
filters: seq[string]
|
||||
param: string
|
||||
@@ -55,15 +55,20 @@ proc genQueryParam*(query: Query): string =
|
||||
return query.text
|
||||
|
||||
for i, user in query.fromUser:
|
||||
param &= &"from:{user} "
|
||||
if i == 0:
|
||||
param = "("
|
||||
|
||||
param &= &"from:{user}"
|
||||
if i < query.fromUser.high:
|
||||
param &= "OR "
|
||||
param &= " OR "
|
||||
else:
|
||||
param &= ")"
|
||||
|
||||
if query.fromUser.len > 0 and query.kind in {posts, media}:
|
||||
param &= "filter:self_threads OR-filter:replies "
|
||||
param &= " (filter:self_threads OR -filter:replies)"
|
||||
|
||||
if "nativeretweets" notin query.excludes:
|
||||
param &= "include:nativeretweets "
|
||||
param &= " include:nativeretweets"
|
||||
|
||||
for f in query.filters:
|
||||
filters.add "filter:" & f
|
||||
@@ -73,38 +78,49 @@ proc genQueryParam*(query: Query): string =
|
||||
for i in query.includes:
|
||||
filters.add "include:" & i
|
||||
|
||||
result = strip(param & filters.join(&" {query.sep} "))
|
||||
if filters.len > 0:
|
||||
result = strip(param & " (" & filters.join(&" {query.sep} ") & ")")
|
||||
else:
|
||||
result = strip(param)
|
||||
|
||||
if query.since.len > 0:
|
||||
result &= " since:" & query.since
|
||||
if query.until.len > 0:
|
||||
if query.until.len > 0 and maxId.len == 0:
|
||||
result &= " until:" & query.until
|
||||
if query.near.len > 0:
|
||||
result &= &" near:\"{query.near}\" within:15mi"
|
||||
if query.minLikes.len > 0:
|
||||
result &= " min_faves:" & query.minLikes
|
||||
if query.text.len > 0:
|
||||
if result.len > 0:
|
||||
result &= " " & query.text
|
||||
else:
|
||||
result = query.text
|
||||
|
||||
if result.len > 0 and maxId.len > 0:
|
||||
result &= " max_id:" & maxId
|
||||
|
||||
proc genQueryUrl*(query: Query): string =
|
||||
if query.kind notin {tweets, users}: return
|
||||
var params: seq[string]
|
||||
|
||||
var params = @[&"f={query.kind}"]
|
||||
if query.text.len > 0:
|
||||
params.add "q=" & encodeUrl(query.text)
|
||||
for f in query.filters:
|
||||
params.add &"f-{f}=on"
|
||||
for e in query.excludes:
|
||||
params.add &"e-{e}=on"
|
||||
for i in query.includes.filterIt(it != "nativeretweets"):
|
||||
params.add &"i-{i}=on"
|
||||
if query.view.len > 0:
|
||||
params.add "view=" & encodeUrl(query.view)
|
||||
|
||||
if query.since.len > 0:
|
||||
params.add "since=" & query.since
|
||||
if query.until.len > 0:
|
||||
params.add "until=" & query.until
|
||||
if query.near.len > 0:
|
||||
params.add "near=" & query.near
|
||||
if query.kind in {tweets, users}:
|
||||
params.add &"f={query.kind}"
|
||||
if query.text.len > 0:
|
||||
params.add "q=" & encodeUrl(query.text)
|
||||
for f in query.filters:
|
||||
params.add &"f-{f}=on"
|
||||
for e in query.excludes:
|
||||
params.add &"e-{e}=on"
|
||||
for i in query.includes.filterIt(it != "nativeretweets"):
|
||||
params.add &"i-{i}=on"
|
||||
|
||||
if query.since.len > 0:
|
||||
params.add "since=" & query.since
|
||||
if query.until.len > 0:
|
||||
params.add "until=" & query.until
|
||||
if query.minLikes.len > 0:
|
||||
params.add "min_faves=" & query.minLikes
|
||||
|
||||
if params.len > 0:
|
||||
result &= params.join("&")
|
||||
|
||||
+46
-17
@@ -52,6 +52,7 @@ proc initRedisPool*(cfg: Config) {.async.} =
|
||||
await migrate("profileDates", "p:*")
|
||||
await migrate("profileStats", "p:*")
|
||||
await migrate("userType", "p:*")
|
||||
await migrate("verifiedType", "p:*")
|
||||
|
||||
pool.withAcquire(r):
|
||||
# optimize memory usage for user ID buckets
|
||||
@@ -85,7 +86,7 @@ proc cache*(data: List) {.async.} =
|
||||
await setEx(data.listKey, listCacheTime, compress(toFlatty(data)))
|
||||
|
||||
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.} =
|
||||
if data.username.len == 0: return
|
||||
@@ -143,28 +144,56 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
|
||||
else:
|
||||
let user = await getGraphUserById(userId)
|
||||
result = user.username
|
||||
await setEx(key, baseCacheTime, result)
|
||||
if result.len > 0 and user.id.len > 0:
|
||||
await all(cacheUserId(result, user.id), cache(user))
|
||||
if result.len > 0:
|
||||
await setEx(key, baseCacheTime, result)
|
||||
if user.id.len > 0:
|
||||
await all(cacheUserId(result, user.id), cache(user))
|
||||
|
||||
proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
|
||||
if id == 0: return
|
||||
let tweet = await get(id.tweetKey)
|
||||
if tweet != redisNil:
|
||||
tweet.deserialize(Tweet)
|
||||
# proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
|
||||
# if id == 0: return
|
||||
# let tweet = await get(id.tweetKey)
|
||||
# if tweet != redisNil:
|
||||
# tweet.deserialize(Tweet)
|
||||
# else:
|
||||
# result = await getGraphTweetResult($id)
|
||||
# if not result.isNil:
|
||||
# await cache(result)
|
||||
|
||||
proc cache*(data: Broadcast) {.async.} =
|
||||
if data.id.len == 0: return
|
||||
await setEx("bc:" & data.id, baseCacheTime, compress(toFlatty(data)))
|
||||
|
||||
proc getCachedBroadcast*(id: string): Future[Broadcast] {.async.} =
|
||||
if id.len == 0: return
|
||||
let cached = await get("bc:" & id)
|
||||
if cached != redisNil:
|
||||
cached.deserialize(Broadcast)
|
||||
else:
|
||||
result = await getGraphTweetResult($id)
|
||||
if not result.isNil:
|
||||
await cache(result)
|
||||
result = await getBroadcastInfo(id)
|
||||
await cache(result)
|
||||
result.m3u8Url = await fetchBroadcastStream(result.mediaKey)
|
||||
|
||||
proc getCachedPhotoRail*(name: string): Future[PhotoRail] {.async.} =
|
||||
if name.len == 0: return
|
||||
let rail = await get("pr:" & toLower(name))
|
||||
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:
|
||||
rail.deserialize(PhotoRail)
|
||||
else:
|
||||
result = await getPhotoRail(name)
|
||||
await cache(result, name)
|
||||
result = await getPhotoRail(id)
|
||||
await cache(result, id)
|
||||
|
||||
proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
|
||||
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
|
||||
import jester
|
||||
import router_utils
|
||||
import ".."/[tokens, types]
|
||||
import ".."/[auth, types]
|
||||
|
||||
proc createDebugRouter*(cfg: Config) =
|
||||
router debug:
|
||||
get "/.tokens":
|
||||
get "/.health":
|
||||
respJson getSessionPoolHealth()
|
||||
|
||||
get "/.sessions":
|
||||
cond cfg.enableDebug
|
||||
respJson getPoolJson()
|
||||
respJson getSessionPoolDebug()
|
||||
|
||||
@@ -10,22 +10,22 @@ export api, embed, vdom, tweet, general, router_utils
|
||||
proc createEmbedRouter*(cfg: Config) =
|
||||
router embed:
|
||||
get "/i/videos/tweet/@id":
|
||||
let convo = await getTweet(@"id")
|
||||
if convo == nil or convo.tweet == nil or convo.tweet.video.isNone:
|
||||
let tweet = await getGraphTweetResult(@"id")
|
||||
if tweet == nil or not tweet.hasVideos:
|
||||
resp Http404
|
||||
|
||||
resp renderVideoEmbed(convo.tweet, cfg, request)
|
||||
resp renderVideoEmbed(tweet, cfg, request)
|
||||
|
||||
get "/@user/status/@id/embed":
|
||||
let
|
||||
convo = await getTweet(@"id")
|
||||
prefs = cookiePrefs()
|
||||
tweet = await getGraphTweetResult(@"id")
|
||||
prefs = requestPrefs()
|
||||
path = getPath()
|
||||
|
||||
if convo == nil or convo.tweet == nil:
|
||||
if tweet == nil:
|
||||
resp Http404
|
||||
|
||||
resp renderTweetEmbed(convo.tweet, path, prefs, cfg, request)
|
||||
resp renderTweetEmbed(tweet, path, prefs, cfg, request)
|
||||
|
||||
get "/embed/Tweet.html":
|
||||
let id = @"id"
|
||||
|
||||
+3
-3
@@ -13,7 +13,7 @@ template respList*(list, timeline, title, vnode: typed) =
|
||||
|
||||
let
|
||||
html = renderList(vnode, timeline.query, list)
|
||||
rss = &"""/i/lists/{@"id"}/rss"""
|
||||
rss = if cfg.enableRSSList: &"""/i/lists/{@"id"}/rss""" else: ""
|
||||
|
||||
resp renderMain(html, request, cfg, prefs, titleText=title, rss=rss, banner=list.banner)
|
||||
|
||||
@@ -36,7 +36,7 @@ proc createListRouter*(cfg: Config) =
|
||||
get "/i/lists/@id/?":
|
||||
cond '.' notin @"id"
|
||||
let
|
||||
prefs = cookiePrefs()
|
||||
prefs = requestPrefs()
|
||||
list = await getCachedList(id=(@"id"))
|
||||
timeline = await getGraphListTweets(list.id, getCursor())
|
||||
vnode = renderTimelineTweets(timeline, prefs, request.path)
|
||||
@@ -45,7 +45,7 @@ proc createListRouter*(cfg: Config) =
|
||||
get "/i/lists/@id/members":
|
||||
cond '.' notin @"id"
|
||||
let
|
||||
prefs = cookiePrefs()
|
||||
prefs = requestPrefs()
|
||||
list = await getCachedList(id=(@"id"))
|
||||
members = await getGraphListMembers(list, getCursor())
|
||||
respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path))
|
||||
|
||||
+19
-14
@@ -37,6 +37,8 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
|
||||
try:
|
||||
let res = await client.get(url)
|
||||
if res.status != "200 OK":
|
||||
if res.status != "404 Not Found":
|
||||
echo "[media] Proxying failed, status: $1, url: $2" % [res.status, url]
|
||||
return Http404
|
||||
|
||||
let hashed = $hash(url)
|
||||
@@ -50,10 +52,10 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
|
||||
""
|
||||
|
||||
let headers = newHttpHeaders({
|
||||
"Content-Type": res.headers["content-type", 0],
|
||||
"Content-Length": contentLength,
|
||||
"Cache-Control": maxAge,
|
||||
"ETag": hashed
|
||||
"content-type": res.headers["content-type", 0],
|
||||
"content-length": contentLength,
|
||||
"cache-control": maxAge,
|
||||
"etag": hashed
|
||||
})
|
||||
|
||||
respond(request, headers)
|
||||
@@ -65,6 +67,7 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
|
||||
await request.client.send(data)
|
||||
data.setLen 0
|
||||
except HttpRequestError, ProtocolError, OSError:
|
||||
echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url]
|
||||
result = Http404
|
||||
finally:
|
||||
client.close()
|
||||
@@ -83,6 +86,12 @@ proc decoded*(req: jester.Request; index: int): string =
|
||||
if based: decode(encoded)
|
||||
else: decodeUrl(encoded)
|
||||
|
||||
proc normalizeImgUrl*(url: var string) =
|
||||
if not url.startsWith("http"):
|
||||
if "twimg.com" notin url:
|
||||
url.insert(twimg)
|
||||
url.insert(https)
|
||||
|
||||
proc createMediaRouter*(cfg: Config) =
|
||||
router media:
|
||||
get "/pic/?":
|
||||
@@ -90,10 +99,8 @@ proc createMediaRouter*(cfg: Config) =
|
||||
|
||||
get re"^\/pic\/orig\/(enc)?\/?(.+)":
|
||||
var url = decoded(request, 1)
|
||||
if "twimg.com" notin url:
|
||||
url.insert(twimg)
|
||||
if not url.startsWith(https):
|
||||
url.insert(https)
|
||||
cond "/amplify_video/" notin url
|
||||
normalizeImgUrl(url)
|
||||
url.add("?name=orig")
|
||||
|
||||
let uri = parseUri(url)
|
||||
@@ -104,10 +111,8 @@ proc createMediaRouter*(cfg: Config) =
|
||||
|
||||
get re"^\/pic\/(enc)?\/?(.+)":
|
||||
var url = decoded(request, 1)
|
||||
if "twimg.com" notin url:
|
||||
url.insert(twimg)
|
||||
if not url.startsWith(https):
|
||||
url.insert(https)
|
||||
cond "/amplify_video/" notin url
|
||||
normalizeImgUrl(url)
|
||||
|
||||
let uri = parseUri(url)
|
||||
cond isTwitterUrl(uri) == true
|
||||
@@ -120,7 +125,7 @@ proc createMediaRouter*(cfg: Config) =
|
||||
cond "http" in url
|
||||
|
||||
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:
|
||||
let code = await proxyMedia(request, url)
|
||||
@@ -136,6 +141,6 @@ proc createMediaRouter*(cfg: Config) =
|
||||
|
||||
if ".m3u8" in url:
|
||||
let vid = await safeFetch(url)
|
||||
content = proxifyVideo(vid, cookiePref(proxyVideos))
|
||||
content = proxifyVideo(vid, requestPrefs().proxyVideos, url)
|
||||
|
||||
resp content, m3u8Mime
|
||||
|
||||
@@ -19,8 +19,10 @@ proc createPrefRouter*(cfg: Config) =
|
||||
router preferences:
|
||||
get "/settings":
|
||||
let
|
||||
prefs = cookiePrefs()
|
||||
html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir))
|
||||
prefs = requestPrefs()
|
||||
prefsCode = encodePrefs(prefs)
|
||||
prefsUrl = getUrlPrefix(cfg) & "/?prefs=" & prefsCode
|
||||
html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir), prefsUrl)
|
||||
resp renderMain(html, request, cfg, prefs, "Preferences")
|
||||
|
||||
get "/settings/@i?":
|
||||
|
||||
@@ -18,8 +18,8 @@ proc createResolverRouter*(cfg: Config) =
|
||||
router resolver:
|
||||
get "/cards/@card/@id":
|
||||
let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"]
|
||||
respResolved(await resolve(url, cookiePrefs()), "card")
|
||||
respResolved(await resolve(url, requestPrefs()), "card")
|
||||
|
||||
get "/t.co/@url":
|
||||
let url = "https://t.co/" & @"url"
|
||||
respResolved(await resolve(url, cookiePrefs()), "t.co")
|
||||
respResolved(await resolve(url, requestPrefs()), "t.co")
|
||||
|
||||
+28
-12
@@ -8,22 +8,15 @@ export utils, prefs, types, uri
|
||||
|
||||
template savePref*(pref, value: string; req: Request; expire=false) =
|
||||
if not expire or pref in cookies(req):
|
||||
let sameSite = if cfg.useHttps: None else: Lax
|
||||
setCookie(pref, value, daysForward(when expire: -10 else: 360),
|
||||
httpOnly=true, secure=cfg.useHttps, sameSite=None)
|
||||
httpOnly=true, secure=cfg.useHttps, sameSite=sameSite, path="/")
|
||||
|
||||
template cookiePrefs*(): untyped {.dirty.} =
|
||||
getPrefs(cookies(request))
|
||||
|
||||
template cookiePref*(pref): untyped {.dirty.} =
|
||||
getPref(cookies(request), pref)
|
||||
|
||||
template themePrefs*(): Prefs =
|
||||
var res = defaultPrefs
|
||||
res.theme = cookiePref(theme)
|
||||
res
|
||||
template requestPrefs*(): untyped {.dirty.} =
|
||||
getPrefs(cookies(request), params(request))
|
||||
|
||||
template showError*(error: string; cfg: Config): string =
|
||||
renderMain(renderError(error), request, cfg, themePrefs(), "Error")
|
||||
renderMain(renderError(error), request, cfg, requestPrefs(), "Error")
|
||||
|
||||
template getPath*(): untyped {.dirty.} =
|
||||
$(parseUri(request.path) ? filterParams(request.params))
|
||||
@@ -43,5 +36,28 @@ template getCursor*(req: Request): string =
|
||||
proc getNames*(name: string): seq[string] =
|
||||
name.strip(chars={'/'}).split(",").filterIt(it.len > 0)
|
||||
|
||||
template applyUrlPrefs*() {.dirty.} =
|
||||
if @"prefs".len > 0:
|
||||
var prefParams = initTable[string, string]()
|
||||
for pair in @"prefs".split(','):
|
||||
let kv = pair.split('=', maxsplit=1)
|
||||
if kv.len == 2:
|
||||
prefParams[kv[0]] = kv[1]
|
||||
elif kv.len == 1 and kv[0].len > 0:
|
||||
prefParams[kv[0]] = ""
|
||||
genApplyPrefs(prefParams, request)
|
||||
|
||||
# Rebuild URL without prefs param
|
||||
var params: seq[(string, string)]
|
||||
for k, v in request.params:
|
||||
if k != "prefs":
|
||||
params.add (k, v)
|
||||
|
||||
if params.len > 0:
|
||||
let cleanUrl = request.getNativeReq.url ? params
|
||||
redirect($cleanUrl)
|
||||
else:
|
||||
redirect(request.path)
|
||||
|
||||
template respJson*(node: JsonNode) =
|
||||
resp $node, "application/json"
|
||||
|
||||
+35
-22
@@ -15,7 +15,7 @@ proc redisKey*(page, name, cursor: string): string =
|
||||
if cursor.len > 0:
|
||||
result &= ":" & cursor
|
||||
|
||||
proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.} =
|
||||
proc timelineRss*(req: Request; cfg: Config; query: Query; prefs: Prefs): Future[Rss] {.async.} =
|
||||
var profile: Profile
|
||||
let
|
||||
name = req.params.getOrDefault("name")
|
||||
@@ -23,25 +23,23 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.
|
||||
names = getNames(name)
|
||||
|
||||
if names.len == 1:
|
||||
profile = await fetchProfile(after, query, skipRail=true, skipPinned=true)
|
||||
profile = await fetchProfile(after, query, skipRail=true)
|
||||
else:
|
||||
var q = query
|
||||
q.fromUser = names
|
||||
profile = Profile(
|
||||
tweets: await getGraphSearch(q, after),
|
||||
# this is kinda dumb
|
||||
user: User(
|
||||
username: name,
|
||||
fullname: names.join(" | "),
|
||||
userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png"
|
||||
)
|
||||
profile.tweets = await getGraphTweetSearch(q, after)
|
||||
# this is kinda dumb
|
||||
profile.user = User(
|
||||
username: name,
|
||||
fullname: names.join(" | "),
|
||||
userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png"
|
||||
)
|
||||
|
||||
if profile.user.suspended:
|
||||
return Rss(feed: profile.user.username, cursor: "suspended")
|
||||
|
||||
if profile.user.fullname.len > 0:
|
||||
let rss = renderTimelineRss(profile, cfg, multi=(names.len > 1))
|
||||
let rss = renderTimelineRss(profile, cfg, prefs, multi=(names.len > 1))
|
||||
return Rss(feed: rss, cursor: profile.tweets.bottom)
|
||||
|
||||
template respRss*(rss, page) =
|
||||
@@ -62,11 +60,14 @@ template respRss*(rss, page) =
|
||||
proc createRssRouter*(cfg: Config) =
|
||||
router rss:
|
||||
get "/search/rss":
|
||||
cond cfg.enableRss
|
||||
if not cfg.enableRSSSearch:
|
||||
resp Http403, showError("RSS feed is disabled", cfg)
|
||||
if @"q".len > 200:
|
||||
resp Http400, showError("Search input too long.", cfg)
|
||||
|
||||
let query = initQuery(params(request))
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
query = initQuery(params(request))
|
||||
if query.kind != tweets:
|
||||
resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg)
|
||||
|
||||
@@ -78,17 +79,19 @@ proc createRssRouter*(cfg: Config) =
|
||||
if rss.cursor.len > 0:
|
||||
respRss(rss, "Search")
|
||||
|
||||
let tweets = await getGraphSearch(query, cursor)
|
||||
let tweets = await getGraphTweetSearch(query, cursor)
|
||||
rss.cursor = tweets.bottom
|
||||
rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg)
|
||||
rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg, prefs)
|
||||
|
||||
await cacheRss(key, rss)
|
||||
respRss(rss, "Search")
|
||||
|
||||
get "/@name/rss":
|
||||
cond cfg.enableRss
|
||||
cond '.' notin @"name"
|
||||
if not cfg.enableRSSUserTweets:
|
||||
resp Http403, showError("RSS feed is disabled", cfg)
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
name = @"name"
|
||||
key = redisKey("twitter", name, getCursor())
|
||||
|
||||
@@ -96,16 +99,23 @@ proc createRssRouter*(cfg: Config) =
|
||||
if rss.cursor.len > 0:
|
||||
respRss(rss, "User")
|
||||
|
||||
rss = await timelineRss(request, cfg, Query(fromUser: @[name]))
|
||||
rss = await timelineRss(request, cfg, Query(fromUser: @[name]), prefs)
|
||||
|
||||
await cacheRss(key, rss)
|
||||
respRss(rss, "User")
|
||||
|
||||
get "/@name/@tab/rss":
|
||||
cond cfg.enableRss
|
||||
cond '.' notin @"name"
|
||||
cond @"tab" in ["with_replies", "media", "search"]
|
||||
let rssEnabled = case @"tab"
|
||||
of "with_replies": cfg.enableRSSUserReplies
|
||||
of "media": cfg.enableRSSUserMedia
|
||||
of "search": cfg.enableRSSSearch
|
||||
else: false
|
||||
if not rssEnabled:
|
||||
resp Http403, showError("RSS feed is disabled", cfg)
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
name = @"name"
|
||||
tab = @"tab"
|
||||
query =
|
||||
@@ -124,14 +134,15 @@ proc createRssRouter*(cfg: Config) =
|
||||
if rss.cursor.len > 0:
|
||||
respRss(rss, "User")
|
||||
|
||||
rss = await timelineRss(request, cfg, query)
|
||||
rss = await timelineRss(request, cfg, query, prefs)
|
||||
|
||||
await cacheRss(key, rss)
|
||||
respRss(rss, "User")
|
||||
|
||||
get "/@name/lists/@slug/rss":
|
||||
cond cfg.enableRss
|
||||
cond @"name" != "i"
|
||||
if not cfg.enableRSSList:
|
||||
resp Http403, showError("RSS feed is disabled", cfg)
|
||||
let
|
||||
slug = decodeUrl(@"slug")
|
||||
list = await getCachedList(@"name", slug)
|
||||
@@ -147,8 +158,10 @@ proc createRssRouter*(cfg: Config) =
|
||||
redirect(url)
|
||||
|
||||
get "/i/lists/@id/rss":
|
||||
cond cfg.enableRss
|
||||
if not cfg.enableRSSList:
|
||||
resp Http403, showError("RSS feed is disabled", cfg)
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
id = @"id"
|
||||
cursor = getCursor()
|
||||
key = redisKey("lists", id, cursor)
|
||||
@@ -161,7 +174,7 @@ proc createRssRouter*(cfg: Config) =
|
||||
list = await getCachedList(id=id)
|
||||
timeline = await getGraphListTweets(list.id, cursor)
|
||||
rss.cursor = timeline.bottom
|
||||
rss.feed = renderListRss(timeline.content, list, cfg)
|
||||
rss.feed = renderListRss(timeline.content, list, cfg, prefs)
|
||||
|
||||
await cacheRss(key, rss)
|
||||
respRss(rss, "List")
|
||||
|
||||
@@ -19,7 +19,7 @@ proc createSearchRouter*(cfg: Config) =
|
||||
resp Http400, showError("Search input too long.", cfg)
|
||||
|
||||
let
|
||||
prefs = cookiePrefs()
|
||||
prefs = requestPrefs()
|
||||
query = initQuery(params(request))
|
||||
title = "Search" & (if q.len > 0: " (" & q & ")" else: "")
|
||||
|
||||
@@ -29,23 +29,24 @@ proc createSearchRouter*(cfg: Config) =
|
||||
redirect("/" & q)
|
||||
var users: Result[User]
|
||||
try:
|
||||
users = await getUserSearch(query, getCursor())
|
||||
users = await getGraphUserSearch(query, getCursor())
|
||||
except InternalError:
|
||||
users = Result[User](beginning: true, query: query)
|
||||
resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title)
|
||||
of tweets:
|
||||
let
|
||||
tweets = await getGraphSearch(query, getCursor())
|
||||
rss = "/search/rss?" & genQueryUrl(query)
|
||||
tweets = await getGraphTweetSearch(query, getCursor())
|
||||
rss = if cfg.enableRSSSearch: "/search/rss?" & genQueryUrl(query) else: ""
|
||||
resp renderMain(renderTweetSearch(tweets, prefs, getPath()),
|
||||
request, cfg, prefs, title, rss=rss)
|
||||
else:
|
||||
resp Http404, showError("Invalid search", cfg)
|
||||
|
||||
get "/hashtag/@hash":
|
||||
redirect("/search?q=" & encodeUrl("#" & @"hash"))
|
||||
redirect("/search?f=tweets&q=" & encodeUrl("#" & @"hash"))
|
||||
|
||||
get "/opensearch":
|
||||
let url = getUrlPrefix(cfg) & "/search?q="
|
||||
resp Http200, {"Content-Type": "application/opensearchdescription+xml"},
|
||||
generateOpenSearchXML(cfg.title, cfg.hostname, url)
|
||||
let
|
||||
url = getUrlPrefix(cfg) & "/search?f=tweets&q="
|
||||
headers = {"Content-Type": "application/opensearchdescription+xml"}
|
||||
resp Http200, headers, generateOpenSearchXML(cfg.title, cfg.hostname, url)
|
||||
|
||||
+33
-11
@@ -21,18 +21,16 @@ proc createStatusRouter*(cfg: Config) =
|
||||
if id.len > 19 or id.any(c => not c.isDigit):
|
||||
resp Http404, showError("Invalid tweet ID", cfg)
|
||||
|
||||
let prefs = cookiePrefs()
|
||||
let prefs = requestPrefs()
|
||||
|
||||
# used for the infinite scroll feature
|
||||
if @"scroll".len > 0:
|
||||
let replies = await getReplies(id, getCursor())
|
||||
if replies.content.len == 0:
|
||||
resp Http404, ""
|
||||
resp Http204
|
||||
resp $renderReplies(replies, prefs, getPath())
|
||||
|
||||
let conv = await getTweet(id, getCursor())
|
||||
if conv == nil:
|
||||
echo "nil conv"
|
||||
|
||||
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
|
||||
var error = "Tweet not found"
|
||||
@@ -46,15 +44,19 @@ proc createStatusRouter*(cfg: Config) =
|
||||
desc = conv.tweet.text
|
||||
|
||||
var
|
||||
images = conv.tweet.photos
|
||||
images = conv.tweet.getPhotos.mapIt(it.url)
|
||||
video = ""
|
||||
|
||||
if conv.tweet.video.isSome():
|
||||
images = @[get(conv.tweet.video).thumb]
|
||||
let
|
||||
firstMediaKind = if conv.tweet.media.len > 0: conv.tweet.media[0].kind
|
||||
else: photoMedia
|
||||
|
||||
if firstMediaKind == videoMedia:
|
||||
images = @[conv.tweet.media[0].getThumb]
|
||||
video = getVideoEmbed(cfg, conv.tweet.id)
|
||||
elif conv.tweet.gif.isSome():
|
||||
images = @[get(conv.tweet.gif).thumb]
|
||||
video = getPicUrl(get(conv.tweet.gif).url)
|
||||
elif firstMediaKind == gifMedia:
|
||||
images = @[conv.tweet.media[0].getThumb]
|
||||
video = getPicUrl(conv.tweet.media[0].gif.url)
|
||||
elif conv.tweet.card.isSome():
|
||||
let card = conv.tweet.card.get()
|
||||
if card.image.len > 0:
|
||||
@@ -66,6 +68,26 @@ proc createStatusRouter*(cfg: Config) =
|
||||
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
|
||||
images=images, video=video)
|
||||
|
||||
get "/@name/status/@id/history/?":
|
||||
cond '.' notin @"name"
|
||||
let id = @"id"
|
||||
|
||||
if id.len > 19 or id.any(c => not c.isDigit):
|
||||
resp Http404, showError("Invalid tweet ID", cfg)
|
||||
|
||||
let edits = await getGraphEditHistory(id)
|
||||
if edits.latest == nil or edits.latest.id == 0:
|
||||
resp Http404, showError("Tweet history not found", cfg)
|
||||
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
title = "History for " & pageTitle(edits.latest)
|
||||
ogTitle = "Edit History for " & pageTitle(edits.latest.user)
|
||||
desc = edits.latest.text
|
||||
|
||||
let html = renderEditHistory(edits, prefs, getPath())
|
||||
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle)
|
||||
|
||||
get "/@name/@s/@id/@m/?@i?":
|
||||
cond @"s" in ["status", "statuses"]
|
||||
cond @"m" in ["video", "photo"]
|
||||
@@ -76,6 +98,6 @@ proc createStatusRouter*(cfg: Config) =
|
||||
|
||||
get "/i/web/status/@id":
|
||||
redirect("/i/status/" & @"id")
|
||||
|
||||
|
||||
get "/@name/thread/@id/?":
|
||||
redirect("/$1/status/$2" % [@"name", @"id"])
|
||||
|
||||
+66
-42
@@ -4,20 +4,28 @@ import jester, karax/vdom
|
||||
|
||||
import router_utils
|
||||
import ".."/[types, redis_cache, formatters, query, api]
|
||||
import ../views/[general, profile, timeline, status, search]
|
||||
import ../views/[general, profile, timeline, status, search, about_account]
|
||||
|
||||
export vdom
|
||||
export uri, sequtils
|
||||
export router_utils
|
||||
export redis_cache, formatters, query, api
|
||||
export profile, timeline, status
|
||||
export profile, timeline, status, about_account
|
||||
|
||||
proc getQuery*(request: Request; tab, name: string): Query =
|
||||
proc getQuery*(request: Request; tab, name: string; prefs: Prefs): Query =
|
||||
let view = request.params.getOrDefault("view")
|
||||
case tab
|
||||
of "with_replies": getReplyQuery(name)
|
||||
of "media": getMediaQuery(name)
|
||||
of "search": initQuery(params(request), name=name)
|
||||
else: Query(fromUser: @[name])
|
||||
of "with_replies":
|
||||
result = getReplyQuery(name)
|
||||
of "media":
|
||||
result = getMediaQuery(name)
|
||||
result.view =
|
||||
if view in ["timeline", "grid", "gallery"]: view
|
||||
else: prefs.mediaView.toLowerAscii
|
||||
of "search":
|
||||
result = initQuery(params(request), name=name)
|
||||
else:
|
||||
result = Query(fromUser: @[name])
|
||||
|
||||
template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
|
||||
if cond:
|
||||
@@ -27,8 +35,7 @@ template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
|
||||
else:
|
||||
body
|
||||
|
||||
proc fetchProfile*(after: string; query: Query; skipRail=false;
|
||||
skipPinned=false): Future[Profile] {.async.} =
|
||||
proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile] {.async.} =
|
||||
let
|
||||
name = query.fromUser[0]
|
||||
userId = await getUserId(name)
|
||||
@@ -45,37 +52,23 @@ proc fetchProfile*(after: string; query: Query; skipRail=false;
|
||||
after.setLen 0
|
||||
|
||||
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 =
|
||||
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]
|
||||
if not skipPinned and user.pinnedTweet > 0 and
|
||||
after.len == 0 and query.kind in {posts, replies}:
|
||||
let tweet = await getCachedTweet(user.pinnedTweet)
|
||||
if not tweet.isNil:
|
||||
tweet.pinned = true
|
||||
tweet.user = user
|
||||
pinned = some tweet
|
||||
result =
|
||||
case query.kind
|
||||
of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after)
|
||||
of replies: await getGraphUserTweets(userId, TimelineKind.replies, after)
|
||||
of media: await getGraphUserTweets(userId, TimelineKind.media, after)
|
||||
else: Profile(tweets: await getGraphTweetSearch(query, after))
|
||||
|
||||
result = Profile(
|
||||
user: user,
|
||||
pinned: pinned,
|
||||
tweets: await timeline,
|
||||
photoRail: await rail
|
||||
)
|
||||
|
||||
if result.user.protected or result.user.suspended:
|
||||
return
|
||||
result.user = await user
|
||||
result.photoRail = await rail
|
||||
result.accountInfo = await info
|
||||
|
||||
result.tweets.query = query
|
||||
|
||||
@@ -83,11 +76,11 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
|
||||
rss, after: string): Future[string] {.async.} =
|
||||
if query.fromUser.len != 1:
|
||||
let
|
||||
timeline = await getGraphSearch(query, after)
|
||||
timeline = await getGraphTweetSearch(query, after)
|
||||
html = renderTweetSearch(timeline, prefs, getPath())
|
||||
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
|
||||
|
||||
if u.suspended:
|
||||
@@ -122,24 +115,46 @@ proc createTimelineRouter*(cfg: Config) =
|
||||
get "/intent/user":
|
||||
respUserId()
|
||||
|
||||
get "/intent/follow/?":
|
||||
let username = request.params.getOrDefault("screen_name")
|
||||
if username.len == 0:
|
||||
resp Http400, showError("Missing screen_name parameter", cfg)
|
||||
redirect("/" & username)
|
||||
|
||||
get "/@name/about/?":
|
||||
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'})
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
name = @"name"
|
||||
info = await getCachedAccountInfo(name)
|
||||
if info.suspended:
|
||||
resp showError(getSuspended(name), cfg)
|
||||
if info.username.len == 0:
|
||||
resp Http404, showError("User \"" & name & "\" not found", cfg)
|
||||
let aboutHtml = renderAboutAccount(info)
|
||||
resp renderMain(aboutHtml, request, cfg, prefs,
|
||||
"About @" & info.username)
|
||||
|
||||
get "/@name/?@tab?/?":
|
||||
cond '.' notin @"name"
|
||||
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
|
||||
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','})
|
||||
cond @"tab" in ["with_replies", "media", "search", ""]
|
||||
let
|
||||
prefs = cookiePrefs()
|
||||
prefs = requestPrefs()
|
||||
after = getCursor()
|
||||
names = getNames(@"name")
|
||||
|
||||
var query = request.getQuery(@"tab", @"name")
|
||||
var query = request.getQuery(@"tab", @"name", prefs)
|
||||
if names.len != 1:
|
||||
query.fromUser = names
|
||||
|
||||
# used for the infinite scroll feature
|
||||
if @"scroll".len > 0:
|
||||
if query.fromUser.len != 1:
|
||||
var timeline = await getGraphSearch(query, after)
|
||||
if timeline.content.len == 0: resp Http404
|
||||
var timeline = await getGraphTweetSearch(query, after)
|
||||
if timeline.content.len == 0:
|
||||
resp Http204
|
||||
timeline.beginning = true
|
||||
resp $renderTweetSearch(timeline, prefs, getPath())
|
||||
else:
|
||||
@@ -148,8 +163,17 @@ proc createTimelineRouter*(cfg: Config) =
|
||||
profile.tweets.beginning = true
|
||||
resp $renderTimelineTweets(profile.tweets, prefs, getPath())
|
||||
|
||||
let rssEnabled =
|
||||
if @"tab".len == 0: cfg.enableRSSUserTweets
|
||||
elif @"tab" == "with_replies": cfg.enableRSSUserReplies
|
||||
elif @"tab" == "media": cfg.enableRSSUserMedia
|
||||
elif @"tab" == "search": cfg.enableRSSSearch
|
||||
else: false
|
||||
|
||||
let rss =
|
||||
if @"tab".len == 0:
|
||||
if not rssEnabled:
|
||||
""
|
||||
elif @"tab".len == 0:
|
||||
"/$1/rss" % @"name"
|
||||
elif @"tab" == "search":
|
||||
"/$1/search/rss?$2" % [@"name", genQueryUrl(query)]
|
||||
|
||||
@@ -10,14 +10,14 @@ export feature
|
||||
proc createUnsupportedRouter*(cfg: Config) =
|
||||
router unsupported:
|
||||
template feature {.dirty.} =
|
||||
resp renderMain(renderFeature(), request, cfg, themePrefs())
|
||||
resp renderMain(renderFeature(), request, cfg, requestPrefs())
|
||||
|
||||
get "/about/feature": feature()
|
||||
get "/login/?@i?": feature()
|
||||
get "/@name/lists/?": feature()
|
||||
|
||||
get "/intent/?@i?":
|
||||
cond @"i" notin ["user"]
|
||||
cond @"i" notin ["user", "follow"]
|
||||
feature()
|
||||
|
||||
get "/i/@i?/?@j?":
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+29
-28
@@ -1,39 +1,40 @@
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
.panel-container {
|
||||
margin: auto;
|
||||
font-size: 130%;
|
||||
margin: auto;
|
||||
font-size: 130%;
|
||||
}
|
||||
|
||||
.error-panel {
|
||||
@include center-panel(var(--error_red));
|
||||
text-align: center;
|
||||
@include center-panel(var(--error_red));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search-bar > form {
|
||||
@include center-panel(var(--darkest_grey));
|
||||
@include center-panel(var(--darkest_grey));
|
||||
|
||||
button {
|
||||
background: var(--bg_elements);
|
||||
color: var(--fg_color);
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
button {
|
||||
background: var(--bg_elements);
|
||||
color: var(--fg_color);
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0px 5px 1px 8px;
|
||||
}
|
||||
|
||||
input {
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
background: var(--bg_elements);
|
||||
color: var(--fg_color);
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
margin-right: 8px;
|
||||
height: unset;
|
||||
}
|
||||
input {
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
background: var(--bg_elements);
|
||||
color: var(--fg_color);
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
margin-right: 8px;
|
||||
height: unset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,18 +66,7 @@
|
||||
}
|
||||
|
||||
#search-panel-toggle:checked ~ .search-panel {
|
||||
@if $rows == 6 {
|
||||
max-height: 200px !important;
|
||||
}
|
||||
@if $rows == 5 {
|
||||
max-height: 300px !important;
|
||||
}
|
||||
@if $rows == 4 {
|
||||
max-height: 300px !important;
|
||||
}
|
||||
@if $rows == 3 {
|
||||
max-height: 365px !important;
|
||||
}
|
||||
max-height: 380px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,43 @@
|
||||
// colors
|
||||
$bg_color: #0F0F0F;
|
||||
$fg_color: #F8F8F2;
|
||||
$fg_faded: #F8F8F2CF;
|
||||
$fg_dark: #FF6C60;
|
||||
$fg_nav: #FF6C60;
|
||||
$bg_color: #0f0f0f;
|
||||
$fg_color: #f8f8f2;
|
||||
$fg_faded: #f8f8f2cf;
|
||||
$fg_dark: #ff6c60;
|
||||
$fg_nav: #ff6c60;
|
||||
|
||||
$bg_panel: #161616;
|
||||
$bg_elements: #121212;
|
||||
$bg_overlays: #1F1F1F;
|
||||
$bg_hover: #1A1A1A;
|
||||
$bg_overlays: #1f1f1f;
|
||||
$bg_hover: #1a1a1a;
|
||||
|
||||
$grey: #888889;
|
||||
$dark_grey: #404040;
|
||||
$darker_grey: #282828;
|
||||
$darkest_grey: #222222;
|
||||
$border_grey: #3E3E35;
|
||||
$border_grey: #3e3e35;
|
||||
|
||||
$accent: #FF6C60;
|
||||
$accent_light: #FFACA0;
|
||||
$accent_dark: #8A3731;
|
||||
$accent_border: #FF6C6091;
|
||||
$accent: #ff6c60;
|
||||
$accent_light: #ffaca0;
|
||||
$accent_dark: #8a3731;
|
||||
$accent_border: #ff6c6091;
|
||||
|
||||
$play_button: #D8574D;
|
||||
$play_button_hover: #FF6C60;
|
||||
$play_button: #d8574d;
|
||||
$play_button_hover: #ff6c60;
|
||||
|
||||
$more_replies_dots: #AD433B;
|
||||
$error_red: #420A05;
|
||||
$more_replies_dots: #ad433b;
|
||||
$error_red: #420a05;
|
||||
|
||||
$verified_blue: #1DA1F2;
|
||||
$verified_blue: #1da1f2;
|
||||
$verified_business: #fac82b;
|
||||
$verified_government: #c1b6a4;
|
||||
$icon_text: $fg_color;
|
||||
|
||||
$tab: $fg_color;
|
||||
$tab_selected: $accent;
|
||||
|
||||
$shadow: rgba(0,0,0,.6);
|
||||
$shadow_dark: rgba(0,0,0,.2);
|
||||
$shadow: rgba(0, 0, 0, 0.6);
|
||||
$shadow_dark: rgba(0, 0, 0, 0.2);
|
||||
|
||||
//fonts
|
||||
$font_0: Helvetica Neue;
|
||||
$font_1: Helvetica;
|
||||
$font_2: Arial;
|
||||
$font_3: sans-serif;
|
||||
$font_4: fontello;
|
||||
$font_0: sans-serif;
|
||||
$font_1: fontello;
|
||||
|
||||
+155
-103
@@ -1,165 +1,217 @@
|
||||
@import '_variables';
|
||||
@import "_variables";
|
||||
|
||||
@import 'tweet/_base';
|
||||
@import 'profile/_base';
|
||||
@import 'general';
|
||||
@import 'navbar';
|
||||
@import 'inputs';
|
||||
@import 'timeline';
|
||||
@import 'search';
|
||||
@import "tweet/_base";
|
||||
@import "profile/_base";
|
||||
@import "general";
|
||||
@import "navbar";
|
||||
@import "inputs";
|
||||
@import "timeline";
|
||||
@import "search";
|
||||
@import "broadcast";
|
||||
|
||||
body {
|
||||
// colors
|
||||
--bg_color: #{$bg_color};
|
||||
--fg_color: #{$fg_color};
|
||||
--fg_faded: #{$fg_faded};
|
||||
--fg_dark: #{$fg_dark};
|
||||
--fg_nav: #{$fg_nav};
|
||||
// colors
|
||||
--bg_color: #{$bg_color};
|
||||
--fg_color: #{$fg_color};
|
||||
--fg_faded: #{$fg_faded};
|
||||
--fg_dark: #{$fg_dark};
|
||||
--fg_nav: #{$fg_nav};
|
||||
|
||||
--bg_panel: #{$bg_panel};
|
||||
--bg_elements: #{$bg_elements};
|
||||
--bg_overlays: #{$bg_overlays};
|
||||
--bg_hover: #{$bg_hover};
|
||||
--bg_panel: #{$bg_panel};
|
||||
--bg_elements: #{$bg_elements};
|
||||
--bg_overlays: #{$bg_overlays};
|
||||
--bg_hover: #{$bg_hover};
|
||||
|
||||
--grey: #{$grey};
|
||||
--dark_grey: #{$dark_grey};
|
||||
--darker_grey: #{$darker_grey};
|
||||
--darkest_grey: #{$darkest_grey};
|
||||
--border_grey: #{$border_grey};
|
||||
--grey: #{$grey};
|
||||
--dark_grey: #{$dark_grey};
|
||||
--darker_grey: #{$darker_grey};
|
||||
--darkest_grey: #{$darkest_grey};
|
||||
--border_grey: #{$border_grey};
|
||||
|
||||
--accent: #{$accent};
|
||||
--accent_light: #{$accent_light};
|
||||
--accent_dark: #{$accent_dark};
|
||||
--accent_border: #{$accent_border};
|
||||
--accent: #{$accent};
|
||||
--accent_light: #{$accent_light};
|
||||
--accent_dark: #{$accent_dark};
|
||||
--accent_border: #{$accent_border};
|
||||
|
||||
--play_button: #{$play_button};
|
||||
--play_button_hover: #{$play_button_hover};
|
||||
--play_button: #{$play_button};
|
||||
--play_button_hover: #{$play_button_hover};
|
||||
|
||||
--more_replies_dots: #{$more_replies_dots};
|
||||
--error_red: #{$error_red};
|
||||
--more_replies_dots: #{$more_replies_dots};
|
||||
--error_red: #{$error_red};
|
||||
|
||||
--verified_blue: #{$verified_blue};
|
||||
--icon_text: #{$icon_text};
|
||||
--verified_blue: #{$verified_blue};
|
||||
--verified_business: #{$verified_business};
|
||||
--verified_government: #{$verified_government};
|
||||
--icon_text: #{$icon_text};
|
||||
|
||||
--tab: #{$fg_color};
|
||||
--tab_selected: #{$accent};
|
||||
--tab: #{$fg_color};
|
||||
--tab_selected: #{$accent};
|
||||
|
||||
--profile_stat: #{$fg_color};
|
||||
--profile_stat: #{$fg_color};
|
||||
|
||||
background-color: var(--bg_color);
|
||||
color: var(--fg_color);
|
||||
font-family: $font_0, $font_1, $font_2, $font_3;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
background-color: var(--bg_color);
|
||||
color: var(--fg_color);
|
||||
font-family: $font_0, $font_1;
|
||||
font-size: 15px;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
outline: unset;
|
||||
margin: 0;
|
||||
text-decoration: none;
|
||||
outline: unset;
|
||||
margin: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
img {
|
||||
dynamic-range-limit: standard;
|
||||
}
|
||||
|
||||
h1 {
|
||||
display: inline;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
h2, h3 {
|
||||
font-weight: normal;
|
||||
h2,
|
||||
h3 {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 14px 0;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
color: var(--accent);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin-top: -0.6em;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin-top: -0.6em;
|
||||
}
|
||||
|
||||
legend {
|
||||
width: 100%;
|
||||
padding: .6em 0 .3em 0;
|
||||
border: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--border_grey);
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
padding: 0.6em 0 0.3em 0;
|
||||
border: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--border_grey);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.preferences .note {
|
||||
.preferences {
|
||||
.note {
|
||||
border-top: 1px solid var(--border_grey);
|
||||
border-bottom: 1px solid var(--border_grey);
|
||||
padding: 6px 0 8px 0;
|
||||
margin-bottom: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.bookmark-note {
|
||||
margin: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 1.3em;
|
||||
padding-left: 1.3em;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
box-sizing: border-box;
|
||||
padding-top: 50px;
|
||||
margin: auto;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
box-sizing: border-box;
|
||||
margin: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body.fixed-nav .container {
|
||||
padding-top: 50px;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
display: inline;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.overlay-panel {
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
margin-top: 10px;
|
||||
background-color: var(--bg_overlays);
|
||||
padding: 10px 15px;
|
||||
align-self: start;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
margin-top: 10px;
|
||||
background-color: var(--bg_overlays);
|
||||
padding: 10px 15px;
|
||||
align-self: start;
|
||||
|
||||
ul {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
ul {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
p {
|
||||
word-break: break-word;
|
||||
}
|
||||
p {
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.verified-icon {
|
||||
color: var(--icon_text);
|
||||
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;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-bottom: 2px;
|
||||
|
||||
@media(max-width: 600px) {
|
||||
.preferences-container {
|
||||
max-width: 95vw;
|
||||
.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);
|
||||
}
|
||||
|
||||
.nav-item, .nav-item .icon-container {
|
||||
font-size: 16px;
|
||||
.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) {
|
||||
.preferences-container {
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
.nav-item,
|
||||
.nav-item .icon-container {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
+155
-124
@@ -1,185 +1,216 @@
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
button {
|
||||
@include input-colors;
|
||||
background-color: var(--bg_elements);
|
||||
color: var(--fg_color);
|
||||
border: 1px solid var(--accent_border);
|
||||
padding: 3px 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
float: right;
|
||||
@include input-colors;
|
||||
background-color: var(--bg_elements);
|
||||
color: var(--fg_color);
|
||||
border: 1px solid var(--accent_border);
|
||||
padding: 3px 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
float: right;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="date"],
|
||||
input[type="number"],
|
||||
select {
|
||||
@include input-colors;
|
||||
background-color: var(--bg_elements);
|
||||
padding: 1px 4px;
|
||||
color: var(--fg_color);
|
||||
border: 1px solid var(--accent_border);
|
||||
border-radius: 0;
|
||||
font-size: 14px;
|
||||
@include input-colors;
|
||||
background-color: var(--bg_elements);
|
||||
padding: 1px 4px;
|
||||
color: var(--fg_color);
|
||||
border: 1px solid var(--accent_border);
|
||||
border-radius: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
height: 16px;
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"] {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
select {
|
||||
height: 20px;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
height: 20px;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
input[type="date"]::-webkit-inner-spin-button {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"]::-webkit-outer-spin-button {
|
||||
display: none;
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type="date"]::-webkit-clear-button {
|
||||
margin-left: 17px;
|
||||
filter: grayscale(100%);
|
||||
filter: hue-rotate(120deg);
|
||||
margin-left: 17px;
|
||||
filter: grayscale(100%);
|
||||
filter: hue-rotate(120deg);
|
||||
}
|
||||
|
||||
input::-webkit-calendar-picker-indicator {
|
||||
opacity: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
input::-webkit-datetime-edit-day-field:focus,
|
||||
input::-webkit-datetime-edit-month-field:focus,
|
||||
input::-webkit-datetime-edit-year-field:focus {
|
||||
background-color: var(--accent);
|
||||
color: var(--fg_color);
|
||||
outline: none;
|
||||
background-color: var(--accent);
|
||||
color: var(--fg_color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.date-range {
|
||||
.date-input {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
.date-input {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 5px;
|
||||
}
|
||||
.icon-container {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
.search-title {
|
||||
margin: 0 2px;
|
||||
}
|
||||
.search-title {
|
||||
margin: 0 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-button button {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: none;
|
||||
float: none;
|
||||
padding: unset;
|
||||
padding-left: 4px;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: none;
|
||||
float: none;
|
||||
padding: unset;
|
||||
padding-left: 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent_light);
|
||||
}
|
||||
&:hover {
|
||||
color: var(--accent_light);
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 0;
|
||||
height: 17px;
|
||||
width: 17px;
|
||||
background-color: var(--bg_elements);
|
||||
border: 1px solid var(--accent_border);
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 0;
|
||||
height: 17px;
|
||||
width: 17px;
|
||||
background-color: var(--bg_elements);
|
||||
border: 1px solid var(--accent_border);
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-container {
|
||||
display: block;
|
||||
position: relative;
|
||||
margin-bottom: 5px;
|
||||
display: block;
|
||||
position: relative;
|
||||
margin-bottom: 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding-right: 22px;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding-right: 22px;
|
||||
height: 0;
|
||||
width: 0;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
height: 0;
|
||||
width: 0;
|
||||
|
||||
&:checked ~ .checkbox:after {
|
||||
display: block;
|
||||
}
|
||||
&:checked ~ .checkbox:after {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover input ~ .checkbox {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
&:hover input ~ .checkbox {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
&:active input ~ .checkbox {
|
||||
border-color: var(--accent_light);
|
||||
}
|
||||
&:active input ~ .checkbox {
|
||||
border-color: var(--accent_light);
|
||||
}
|
||||
|
||||
.checkbox:after {
|
||||
left: 2px;
|
||||
bottom: 0;
|
||||
font-size: 13px;
|
||||
font-family: $font_4;
|
||||
content: '\e803';
|
||||
}
|
||||
.checkbox:after {
|
||||
left: 2px;
|
||||
bottom: 0;
|
||||
font-size: 13px;
|
||||
font-family: $font_1;
|
||||
content: "\e811";
|
||||
}
|
||||
}
|
||||
|
||||
.pref-group {
|
||||
display: inline;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.preferences {
|
||||
button {
|
||||
margin: 6px 0 3px 0;
|
||||
}
|
||||
button {
|
||||
margin: 6px 0 3px 0;
|
||||
}
|
||||
|
||||
label {
|
||||
padding-right: 150px;
|
||||
}
|
||||
label {
|
||||
padding-right: 150px;
|
||||
}
|
||||
|
||||
select {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: block;
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
select {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: block;
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
max-width: 140px;
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="number"] {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.pref-group {
|
||||
display: block;
|
||||
}
|
||||
.pref-group {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pref-input {
|
||||
position: relative;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.pref-input {
|
||||
position: relative;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.pref-reset {
|
||||
float: left;
|
||||
}
|
||||
.pref-reset {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.prefs-code {
|
||||
background-color: var(--bg_elements);
|
||||
border: 1px solid var(--accent_border);
|
||||
color: var(--fg_color);
|
||||
font-size: 13px;
|
||||
padding: 6px 8px;
|
||||
margin: 4px 0;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
user-select: all;
|
||||
}
|
||||
}
|
||||
|
||||
+62
-60
@@ -1,88 +1,90 @@
|
||||
@import '_variables';
|
||||
@import "_variables";
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
background-color: var(--bg_overlays);
|
||||
box-shadow: 0 0 4px $shadow;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
z-index: 1000;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--bg_overlays);
|
||||
box-shadow: 0 0 4px $shadow;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
z-index: 1000;
|
||||
font-size: 16px;
|
||||
|
||||
a, .icon-button button {
|
||||
color: var(--fg_nav);
|
||||
}
|
||||
a,
|
||||
.icon-button button {
|
||||
color: var(--fg_nav);
|
||||
}
|
||||
|
||||
body.fixed-nav & {
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
.inner-nav {
|
||||
margin: auto;
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-basis: 920px;
|
||||
height: 50px;
|
||||
margin: auto;
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-basis: 920px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent_light);
|
||||
text-decoration: unset;
|
||||
}
|
||||
&:hover {
|
||||
color: var(--accent_light);
|
||||
text-decoration: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.site-logo {
|
||||
display: block;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
display: block;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
line-height: 50px;
|
||||
height: 50px;
|
||||
overflow: hidden;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
line-height: 50px;
|
||||
height: 50px;
|
||||
overflow: hidden;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
|
||||
&.right {
|
||||
text-align: right;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
&.right {
|
||||
text-align: right;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&.right a {
|
||||
padding-left: 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent_light);
|
||||
text-decoration: unset;
|
||||
}
|
||||
}
|
||||
&.right a:hover {
|
||||
color: var(--accent_light);
|
||||
text-decoration: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.lp {
|
||||
height: 14px;
|
||||
margin-top: 2px;
|
||||
display: block;
|
||||
fill: var(--fg_nav);
|
||||
height: 14px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
fill: var(--fg_nav);
|
||||
|
||||
&:hover {
|
||||
fill: var(--accent_light);
|
||||
}
|
||||
&:hover {
|
||||
fill: var(--accent_light);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-info:before {
|
||||
margin: 0 -3px;
|
||||
.icon-info {
|
||||
margin: 0 -3px;
|
||||
}
|
||||
|
||||
.icon-cog {
|
||||
font-size: 15px;
|
||||
font-size: 15px;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
+88
-54
@@ -1,83 +1,117 @@
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
@import 'card';
|
||||
@import 'photo-rail';
|
||||
@import "card";
|
||||
@import "about-account";
|
||||
@import "photo-rail";
|
||||
|
||||
.profile-tabs {
|
||||
@include panel(auto, 900px);
|
||||
@include panel(auto, 900px);
|
||||
|
||||
.timeline-container {
|
||||
float: right;
|
||||
width: 68% !important;
|
||||
max-width: unset;
|
||||
}
|
||||
.timeline-container {
|
||||
float: right;
|
||||
width: 68% !important;
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-banner {
|
||||
margin-bottom: 4px;
|
||||
background-color: var(--bg_panel);
|
||||
margin-bottom: 4px;
|
||||
background-color: var(--bg_panel);
|
||||
|
||||
a {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding: 33.34% 0 0 0;
|
||||
}
|
||||
a {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding: 33.34% 0 0 0;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-tab {
|
||||
padding: 0 4px 0 0;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
max-width: 32%;
|
||||
padding: 0 4px 0 0;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
max-width: 32%;
|
||||
top: 0;
|
||||
|
||||
body.fixed-nav & {
|
||||
top: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-result {
|
||||
min-height: 54px;
|
||||
min-height: 54px;
|
||||
|
||||
.username {
|
||||
margin: 0 !important;
|
||||
}
|
||||
.username {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.tweet-header {
|
||||
margin-bottom: unset;
|
||||
}
|
||||
.tweet-header {
|
||||
margin-bottom: unset;
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width: 700px) {
|
||||
.profile-tabs {
|
||||
width: 100vw;
|
||||
max-width: 600px;
|
||||
.profile-tabs.media-only {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
|
||||
.timeline-container {
|
||||
width: 100% !important;
|
||||
.timeline-container {
|
||||
float: none;
|
||||
width: 100% !important;
|
||||
max-width: none;
|
||||
padding: 0 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tab-item wide {
|
||||
flex-grow: 1.4;
|
||||
}
|
||||
}
|
||||
.timeline-container > .tab {
|
||||
max-width: 900px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.profile-tabs {
|
||||
width: 100vw;
|
||||
max-width: 600px;
|
||||
|
||||
.timeline-container {
|
||||
width: 100% !important;
|
||||
|
||||
.tab-item wide {
|
||||
flex-grow: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profile-tab {
|
||||
width: 100%;
|
||||
max-width: unset;
|
||||
position: initial !important;
|
||||
padding: 0;
|
||||
.profile-tabs.media-only {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
|
||||
.timeline-container {
|
||||
width: 100vw !important;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-tab {
|
||||
width: 100%;
|
||||
max-width: unset;
|
||||
position: initial !important;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-height: 900px) {
|
||||
.profile-tab.sticky {
|
||||
position: sticky;
|
||||
}
|
||||
.profile-tab.sticky {
|
||||
position: sticky;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@include breakable;
|
||||
flex-shrink: 100;
|
||||
}
|
||||
|
||||
.profile-card-avatar {
|
||||
|
||||
+86
-86
@@ -1,120 +1,120 @@
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
.search-title {
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
button {
|
||||
margin: 0 2px 0 0;
|
||||
padding: 0px 1px 1px 4px;
|
||||
height: 23px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 0 2px 0 0;
|
||||
height: 23px;
|
||||
}
|
||||
.pref-input {
|
||||
margin: 0 4px 0 0;
|
||||
flex-grow: 1;
|
||||
height: 23px;
|
||||
}
|
||||
|
||||
.pref-input {
|
||||
margin: 0 4px 0 0;
|
||||
flex-grow: 1;
|
||||
height: 23px;
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="number"] {
|
||||
height: calc(100% - 4px);
|
||||
width: calc(100% - 8px);
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
height: calc(100% - 4px);
|
||||
width: calc(100% - 8px);
|
||||
}
|
||||
> label {
|
||||
display: inline;
|
||||
background-color: var(--bg_elements);
|
||||
color: var(--fg_color);
|
||||
border: 1px solid var(--accent_border);
|
||||
padding: 1px 1px 2px 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 2px;
|
||||
|
||||
> label {
|
||||
display: inline;
|
||||
background-color: var(--bg_elements);
|
||||
color: var(--fg_color);
|
||||
border: 1px solid var(--accent_border);
|
||||
padding: 1px 6px 2px 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 2px;
|
||||
@include input-colors;
|
||||
}
|
||||
|
||||
@include input-colors;
|
||||
}
|
||||
|
||||
@include create-toggle(search-panel, 200px);
|
||||
@include create-toggle(search-panel, 380px);
|
||||
}
|
||||
|
||||
.search-panel {
|
||||
width: 100%;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.4s;
|
||||
width: 100%;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.4s;
|
||||
|
||||
flex-grow: 1;
|
||||
font-weight: initial;
|
||||
text-align: left;
|
||||
flex-grow: 1;
|
||||
font-weight: initial;
|
||||
text-align: left;
|
||||
|
||||
> div {
|
||||
line-height: 1.7em;
|
||||
}
|
||||
.checkbox-container {
|
||||
display: inline;
|
||||
padding-right: unset;
|
||||
margin-bottom: 5px;
|
||||
margin-left: 23px;
|
||||
}
|
||||
|
||||
.checkbox-container {
|
||||
display: inline;
|
||||
padding-right: unset;
|
||||
margin-bottom: unset;
|
||||
margin-left: 23px;
|
||||
}
|
||||
.checkbox {
|
||||
right: unset;
|
||||
left: -22px;
|
||||
line-height: 1.6em;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
right: unset;
|
||||
left: -22px;
|
||||
}
|
||||
|
||||
.checkbox-container .checkbox:after {
|
||||
top: -4px;
|
||||
}
|
||||
.checkbox-container .checkbox:after {
|
||||
top: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
line-height: unset;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
line-height: unset;
|
||||
|
||||
> div {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
> div {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
input {
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.pref-input {
|
||||
display: block;
|
||||
padding-bottom: 5px;
|
||||
|
||||
input {
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.pref-input {
|
||||
display: block;
|
||||
padding-bottom: 5px;
|
||||
|
||||
input {
|
||||
height: 21px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
height: 21px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-toggles {
|
||||
flex-grow: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, auto);
|
||||
grid-column-gap: 10px;
|
||||
flex-grow: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, auto);
|
||||
grid-column-gap: 10px;
|
||||
}
|
||||
|
||||
.profile-tabs {
|
||||
@include search-resize(820px, 5);
|
||||
@include search-resize(725px, 4);
|
||||
@include search-resize(600px, 6);
|
||||
@include search-resize(560px, 5);
|
||||
@include search-resize(480px, 4);
|
||||
@include search-resize(410px, 3);
|
||||
@include search-resize(820px, 5);
|
||||
@include search-resize(715px, 4);
|
||||
@include search-resize(700px, 5);
|
||||
@include search-resize(485px, 4);
|
||||
@include search-resize(410px, 3);
|
||||
}
|
||||
|
||||
@include search-resize(560px, 5);
|
||||
@include search-resize(480px, 4);
|
||||
@include search-resize(700px, 5);
|
||||
@include search-resize(485px, 4);
|
||||
@include search-resize(410px, 3);
|
||||
|
||||
+429
-106
@@ -1,162 +1,485 @@
|
||||
@import '_variables';
|
||||
@import "_variables";
|
||||
|
||||
.timeline-container {
|
||||
@include panel(100%, 600px);
|
||||
@include panel(100%, 600px);
|
||||
}
|
||||
|
||||
.timeline {
|
||||
background-color: var(--bg_panel);
|
||||
|
||||
> div:not(:first-child) {
|
||||
border-top: 1px solid var(--border_grey);
|
||||
}
|
||||
.timeline > div:not(:first-child) {
|
||||
border-top: 1px solid var(--border_grey);
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
width: 100%;
|
||||
background-color: var(--bg_panel);
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
background-color: var(--bg_panel);
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
box-sizing: border-box;
|
||||
|
||||
button {
|
||||
float: unset;
|
||||
}
|
||||
button {
|
||||
float: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-banner img {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.timeline-description {
|
||||
font-weight: normal;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.tab {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
list-style: none;
|
||||
margin: 0 0 5px 0;
|
||||
background-color: var(--bg_panel);
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
list-style: none;
|
||||
margin: 0 0 4px 0;
|
||||
background-color: var(--bg_panel);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1 1 0;
|
||||
text-align: center;
|
||||
margin-top: 0;
|
||||
flex: 1 1 0;
|
||||
text-align: center;
|
||||
margin-top: 0;
|
||||
|
||||
a {
|
||||
border-bottom: .1rem solid transparent;
|
||||
color: var(--tab);
|
||||
display: block;
|
||||
padding: 8px 0;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
a {
|
||||
border-bottom: 0.1rem solid transparent;
|
||||
color: var(--tab);
|
||||
display: block;
|
||||
padding: 8px 0;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-bottom-color: var(--tab_selected);
|
||||
color: var(--tab_selected);
|
||||
}
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&.active a {
|
||||
border-bottom-color: var(--tab_selected);
|
||||
color: var(--tab_selected);
|
||||
&.active {
|
||||
border-bottom-color: var(--tab_selected);
|
||||
color: var(--tab_selected);
|
||||
}
|
||||
}
|
||||
|
||||
&.wide {
|
||||
flex-grow: 1.2;
|
||||
flex-basis: 50px;
|
||||
}
|
||||
&.active a {
|
||||
border-bottom-color: var(--tab_selected);
|
||||
color: var(--tab_selected);
|
||||
}
|
||||
|
||||
&.wide {
|
||||
flex-grow: 1.2;
|
||||
flex-basis: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-footer {
|
||||
background-color: var(--bg_panel);
|
||||
padding: 6px 0;
|
||||
background-color: var(--bg_panel);
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.timeline-protected {
|
||||
text-align: center;
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: var(--accent);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-none {
|
||||
h2 {
|
||||
color: var(--accent);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-none {
|
||||
color: var(--accent);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.timeline-end {
|
||||
background-color: var(--bg_panel);
|
||||
color: var(--accent);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
background-color: var(--bg_panel);
|
||||
color: var(--accent);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.show-more {
|
||||
background-color: var(--bg_panel);
|
||||
text-align: center;
|
||||
padding: .75em 0;
|
||||
display: block !important;
|
||||
background-color: var(--bg_panel);
|
||||
text-align: center;
|
||||
padding: 0.75em 0;
|
||||
display: block !important;
|
||||
|
||||
a {
|
||||
background-color: var(--darkest_grey);
|
||||
display: inline-block;
|
||||
height: 2em;
|
||||
padding: 0 2em;
|
||||
line-height: 2em;
|
||||
a {
|
||||
background-color: var(--darkest_grey);
|
||||
display: inline-block;
|
||||
height: 2em;
|
||||
padding: 0 2em;
|
||||
line-height: 2em;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--darker_grey);
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--darker_grey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.top-ref {
|
||||
background-color: var(--bg_color);
|
||||
border-top: none !important;
|
||||
background-color: var(--bg_color);
|
||||
border-top: none !important;
|
||||
|
||||
.icon-down {
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
.icon-down {
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent_light);
|
||||
}
|
||||
|
||||
&::before {
|
||||
transform: rotate(180deg) translateY(-1px);
|
||||
}
|
||||
&:hover {
|
||||
color: var(--accent_light);
|
||||
}
|
||||
|
||||
&::before {
|
||||
transform: rotate(180deg) translateY(-1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
overflow-wrap: break-word;
|
||||
border-left-width: 0;
|
||||
min-width: 0;
|
||||
padding: .75em;
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow-wrap: break-word;
|
||||
border-left-width: 0;
|
||||
min-width: 0;
|
||||
padding: 0.75em;
|
||||
display: flex;
|
||||
position: relative;
|
||||
background-color: var(--bg_panel);
|
||||
}
|
||||
|
||||
.timeline.media-grid-view,
|
||||
.timeline.media-gallery-view {
|
||||
> div:not(:first-child) {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.timeline-item::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline.media-grid-view,
|
||||
.timeline.media-gallery-view .gallery-masonry.compact {
|
||||
.tweet-header,
|
||||
.replying-to,
|
||||
.retweet-header,
|
||||
.pinned,
|
||||
.tweet-stats,
|
||||
.attribution,
|
||||
.poll,
|
||||
.quote,
|
||||
.community-note,
|
||||
.media-tag-block,
|
||||
.tweet-content,
|
||||
.card-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: unset;
|
||||
|
||||
.card-container {
|
||||
border: unset;
|
||||
border-radius: unset;
|
||||
|
||||
.card-image-container {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.card-content-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline.media-grid-view {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
> div:not(:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tweet-link {
|
||||
z-index: 1000;
|
||||
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
> .show-more,
|
||||
> .top-ref,
|
||||
> .timeline-footer,
|
||||
> .timeline-header {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.tweet-body {
|
||||
height: 100%;
|
||||
margin-left: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
aspect-ratio: 1/1;
|
||||
}
|
||||
|
||||
.gallery-row + .gallery-row {
|
||||
margin-top: 0.25em !important;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
background-color: var(--darkest_grey);
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.attachments,
|
||||
.gallery-row,
|
||||
.still-image {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.still-image img,
|
||||
.attachment > video,
|
||||
.attachment > img {
|
||||
object-fit: cover;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.attachment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.gallery-video {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.media-gif {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.timeline-item:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.alt-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline.media-gallery-view {
|
||||
.gallery-masonry {
|
||||
margin: 10px 0;
|
||||
column-gap: 10px;
|
||||
column-width: unquote("clamp(190px, 22vw, 350px)");
|
||||
|
||||
&[data-col-size="small"] {
|
||||
column-width: unquote("max(130px, 11vw)");
|
||||
}
|
||||
|
||||
&[data-col-size="large"] {
|
||||
column-width: unquote("clamp(350px, 22vw, 480px)");
|
||||
}
|
||||
|
||||
&.masonry-active {
|
||||
column-width: unset;
|
||||
column-gap: unset;
|
||||
position: relative;
|
||||
|
||||
.timeline-item {
|
||||
animation: none;
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.compact {
|
||||
.tweet-body {
|
||||
padding: 0;
|
||||
|
||||
> .attachments {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-image-container img {
|
||||
max-height: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes masonry-init {
|
||||
to {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Start hidden. CSS animation reveals after a delay as a no-JS fallback.
|
||||
// With JS, masonry-active cancels the animation and masonry-visible reveals.
|
||||
.gallery-masonry .timeline-item,
|
||||
> .show-more,
|
||||
> .top-ref,
|
||||
> .timeline-footer {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
animation: masonry-init 0.2s 0.3s forwards;
|
||||
}
|
||||
|
||||
.gallery-masonry.masonry-active .timeline-item.masonry-visible,
|
||||
> .show-more.masonry-visible,
|
||||
> .top-ref.masonry-visible,
|
||||
> .timeline-footer.masonry-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transition: opacity 0.15s ease;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
margin-bottom: 10px;
|
||||
break-inside: avoid;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
> .show-more,
|
||||
> .top-ref,
|
||||
> .timeline-footer,
|
||||
> .timeline-header {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
> .show-more {
|
||||
padding: 0;
|
||||
margin-top: 8px;
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.tweet-content {
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.tweet-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
margin-left: 0;
|
||||
padding: 10px;
|
||||
|
||||
> .attachments {
|
||||
align-self: stretch;
|
||||
border-radius: 0;
|
||||
margin: -10px -10px 10px;
|
||||
max-height: none;
|
||||
order: -1;
|
||||
width: auto;
|
||||
background-color: var(--bg_elements);
|
||||
|
||||
.gallery-row {
|
||||
max-height: none;
|
||||
max-width: none;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.still-image img,
|
||||
.attachment > video,
|
||||
.attachment > img {
|
||||
max-height: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.attachment:last-child {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.card-container {
|
||||
border: unset;
|
||||
border-radius: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.tweet-stat {
|
||||
padding-top: unset;
|
||||
}
|
||||
|
||||
.quote {
|
||||
margin-bottom: 5px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.replying-to {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tweet-header {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 0.75em;
|
||||
margin-bottom: 0;
|
||||
|
||||
.tweet-avatar {
|
||||
img {
|
||||
float: none;
|
||||
height: 42px;
|
||||
margin: 0;
|
||||
width: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
.tweet-name-row {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fullname-and-username {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fullname {
|
||||
max-width: calc(100% - 18px);
|
||||
}
|
||||
|
||||
.verified-icon {
|
||||
margin-left: 4px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.username {
|
||||
display: block;
|
||||
flex-basis: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.timeline.media-gallery-view {
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
+224
-153
@@ -1,240 +1,311 @@
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import 'thread';
|
||||
@import 'media';
|
||||
@import 'video';
|
||||
@import 'embed';
|
||||
@import 'card';
|
||||
@import 'poll';
|
||||
@import 'quote';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
@import "thread";
|
||||
@import "media";
|
||||
@import "video";
|
||||
@import "embed";
|
||||
@import "card";
|
||||
@import "poll";
|
||||
@import "quote";
|
||||
|
||||
.tweet-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 58px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 58px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tweet-content {
|
||||
font-family: $font_3;
|
||||
line-height: 1.3em;
|
||||
pointer-events: all;
|
||||
display: inline;
|
||||
line-height: 1.3em;
|
||||
pointer-events: all;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.tweet-bidi {
|
||||
display: block !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.tweet-header {
|
||||
padding: 0;
|
||||
vertical-align: bottom;
|
||||
flex-basis: 100%;
|
||||
margin-bottom: .2em;
|
||||
padding: 0;
|
||||
vertical-align: bottom;
|
||||
flex-basis: 100%;
|
||||
margin-bottom: 0.2em;
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
pointer-events: all;
|
||||
}
|
||||
a {
|
||||
display: inline-block;
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
.tweet-name-row {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.verified-icon {
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.fullname-and-username {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fullname {
|
||||
@include ellipsis;
|
||||
flex-shrink: 2;
|
||||
max-width: 80%;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--fg_color);
|
||||
@include ellipsis;
|
||||
flex-shrink: 2;
|
||||
max-width: 80%;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--fg_color);
|
||||
}
|
||||
|
||||
.username {
|
||||
@include ellipsis;
|
||||
min-width: 1.6em;
|
||||
margin-left: .4em;
|
||||
word-wrap: normal;
|
||||
@include ellipsis;
|
||||
min-width: 1.6em;
|
||||
margin-left: 0.4em;
|
||||
word-wrap: normal;
|
||||
}
|
||||
|
||||
.tweet-date {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.tweet-date a, .username, .show-more a {
|
||||
color: var(--fg_dark);
|
||||
.tweet-date a,
|
||||
.username,
|
||||
.show-more a {
|
||||
color: var(--fg_dark);
|
||||
}
|
||||
|
||||
.tweet-published {
|
||||
margin: 0;
|
||||
margin-top: 5px;
|
||||
color: var(--grey);
|
||||
pointer-events: all;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 0px;
|
||||
color: var(--grey);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.tweet-avatar {
|
||||
display: contents !important;
|
||||
display: contents !important;
|
||||
|
||||
img {
|
||||
float: left;
|
||||
margin-top: 3px;
|
||||
margin-left: -58px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
img {
|
||||
float: left;
|
||||
margin-top: 3px;
|
||||
margin-left: -58px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
&.round {
|
||||
border-radius: 50%;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
&.mini {
|
||||
position: unset;
|
||||
margin-right: 5px;
|
||||
margin-top: -1px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
&.round {
|
||||
border-radius: 50%;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
&.mini {
|
||||
position: unset;
|
||||
margin-right: 5px;
|
||||
margin-top: -1px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.tweet-embed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background-color: var(--bg_panel);
|
||||
|
||||
.tweet-content {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.tweet-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background-color: var(--bg_panel);
|
||||
max-height: calc(100vh - 0.75em * 2);
|
||||
}
|
||||
|
||||
.tweet-content {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.tweet-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 0.75em * 2);
|
||||
}
|
||||
.card-image img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.card-image img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: absolute;
|
||||
}
|
||||
.avatar {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
.attribution {
|
||||
display: flex;
|
||||
pointer-events: all;
|
||||
margin: 5px 0;
|
||||
display: flex;
|
||||
pointer-events: all;
|
||||
margin: 5px 0;
|
||||
|
||||
strong {
|
||||
color: var(--fg_color);
|
||||
}
|
||||
strong {
|
||||
color: var(--fg_color);
|
||||
}
|
||||
}
|
||||
|
||||
.media-tag-block {
|
||||
padding-top: 5px;
|
||||
pointer-events: all;
|
||||
padding-top: 5px;
|
||||
pointer-events: all;
|
||||
color: var(--fg_faded);
|
||||
|
||||
.icon-container {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.media-tag,
|
||||
.icon-container {
|
||||
color: var(--fg_faded);
|
||||
|
||||
.icon-container {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.media-tag, .icon-container {
|
||||
color: var(--fg_faded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-container .media-tag-block {
|
||||
font-size: 13px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tweet-geo {
|
||||
color: var(--fg_faded);
|
||||
color: var(--fg_faded);
|
||||
}
|
||||
|
||||
.replying-to {
|
||||
color: var(--fg_faded);
|
||||
margin: -2px 0 4px;
|
||||
color: var(--fg_faded);
|
||||
margin: -2px 0 4px;
|
||||
|
||||
a {
|
||||
pointer-events: all;
|
||||
}
|
||||
a {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
.retweet-header, .pinned, .tweet-stats {
|
||||
align-content: center;
|
||||
color: var(--grey);
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
.retweet-header,
|
||||
.pinned,
|
||||
.tweet-stats {
|
||||
align-content: center;
|
||||
color: var(--grey);
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
|
||||
span {
|
||||
@include ellipsis;
|
||||
}
|
||||
span {
|
||||
@include ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.retweet-header {
|
||||
margin-top: -5px !important;
|
||||
margin-top: -5px !important;
|
||||
}
|
||||
|
||||
.tweet-stats {
|
||||
margin-bottom: -3px;
|
||||
-webkit-user-select: none;
|
||||
margin-bottom: -3px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.tweet-stat {
|
||||
padding-top: 5px;
|
||||
min-width: 1em;
|
||||
margin-right: 0.8em;
|
||||
padding-top: 5px;
|
||||
min-width: 1em;
|
||||
margin-right: 0.8em;
|
||||
}
|
||||
|
||||
.show-thread {
|
||||
display: block;
|
||||
pointer-events: all;
|
||||
padding-top: 2px;
|
||||
display: block;
|
||||
pointer-events: all;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.unavailable-box {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
border: solid 1px var(--dark_grey);
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
background-color: var(--bg_color);
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
border: solid 1px var(--dark_grey);
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
background-color: var(--bg_color);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tweet-link {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
-webkit-user-select: none;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg_hover);
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--bg_hover);
|
||||
}
|
||||
}
|
||||
|
||||
.latest-post-version {
|
||||
border-bottom: 1px solid var(--dark_grey);
|
||||
border-top: 1px solid var(--dark_grey);
|
||||
padding: 01ch 0px;
|
||||
margin: 1ch 0px;
|
||||
color: var(--grey);
|
||||
|
||||
a {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
.community-note {
|
||||
background-color: var(--bg_elements);
|
||||
margin-top: 10px;
|
||||
border: solid 1px var(--dark_grey);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
pointer-events: all;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg_panel);
|
||||
border-color: var(--grey);
|
||||
}
|
||||
}
|
||||
|
||||
.community-note-header {
|
||||
background-color: var(--bg_hover);
|
||||
font-weight: 700;
|
||||
padding: 8px 10px;
|
||||
padding-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
.icon-container {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.community-note-text {
|
||||
white-space: pre-line;
|
||||
padding: 10px 10px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.disclosures {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--grey);
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
margin-bottom: -2px;
|
||||
|
||||
.icon-attention {
|
||||
margin-right: -3px;
|
||||
}
|
||||
}
|
||||
|
||||
+79
-78
@@ -1,118 +1,119 @@
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
.card {
|
||||
margin: 5px 0;
|
||||
pointer-events: all;
|
||||
max-height: unset;
|
||||
margin: 5px 0;
|
||||
pointer-events: all;
|
||||
max-height: unset;
|
||||
}
|
||||
|
||||
.card-container {
|
||||
border-radius: 10px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: var(--dark_grey);
|
||||
background-color: var(--bg_elements);
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
text-decoration: none !important;
|
||||
border: solid 1px var(--dark_grey);
|
||||
border-radius: 10px;
|
||||
background-color: var(--bg_elements);
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
text-decoration: none !important;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--grey);
|
||||
}
|
||||
&:hover {
|
||||
border-color: var(--grey);
|
||||
}
|
||||
|
||||
.attachments {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.attachments {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 0.5em;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@include ellipsis;
|
||||
white-space: unset;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
@include ellipsis;
|
||||
white-space: unset;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
margin: 0.3em 0;
|
||||
margin: 0.3em 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.card-destination {
|
||||
@include ellipsis;
|
||||
color: var(--grey);
|
||||
display: block;
|
||||
@include ellipsis;
|
||||
color: var(--grey);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-content-container {
|
||||
color: unset;
|
||||
overflow: auto;
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
color: unset;
|
||||
overflow: auto;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.card-image-container {
|
||||
width: 98px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
padding-top: 100%;
|
||||
}
|
||||
width: 98px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
padding-top: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.card-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background-color: var(--bg_overlays);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background-color: var(--bg_overlays);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 400px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 400px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.card-overlay {
|
||||
@include play-button;
|
||||
opacity: 0.8;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@include play-button;
|
||||
opacity: 0.8;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.large {
|
||||
.card-container {
|
||||
display: block;
|
||||
}
|
||||
.card-container {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-image-container {
|
||||
width: unset;
|
||||
.card-image-container {
|
||||
width: unset;
|
||||
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.card-image {
|
||||
position: unset;
|
||||
border-style: solid;
|
||||
border-color: var(--dark_grey);
|
||||
border-width: 0;
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
.card-image {
|
||||
position: unset;
|
||||
border-style: solid;
|
||||
border-color: var(--dark_grey);
|
||||
border-width: 0;
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
+13
-13
@@ -1,17 +1,17 @@
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
.embed-video {
|
||||
.gallery-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background-color: black;
|
||||
top: 0%;
|
||||
left: 0%;
|
||||
}
|
||||
.gallery-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background-color: black;
|
||||
top: 0%;
|
||||
left: 0%;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
max-height: unset;
|
||||
}
|
||||
.gallery-video > .attachment {
|
||||
max-height: unset;
|
||||
}
|
||||
}
|
||||
|
||||
+145
-99
@@ -1,119 +1,165 @@
|
||||
@import '_variables';
|
||||
@import "_variables";
|
||||
|
||||
.gallery-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
max-height: 379.5px;
|
||||
max-width: 533px;
|
||||
pointer-events: all;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
max-height: 379.5px;
|
||||
max-width: 533px;
|
||||
pointer-events: all;
|
||||
|
||||
&.mixed-row {
|
||||
.attachment {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1 1 0;
|
||||
max-height: 379.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #101010;
|
||||
}
|
||||
|
||||
.still-image,
|
||||
.still-image img,
|
||||
.attachment > video,
|
||||
.attachment > img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.still-image {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
display: flex;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.still-image img {
|
||||
flex-basis: auto;
|
||||
flex-grow: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.attachment > video,
|
||||
.attachment > img {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.attachment > video {
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attachments {
|
||||
margin-top: .35em;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
max-height: 600px;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
flex-flow: column;
|
||||
background-color: var(--bg_color);
|
||||
align-items: center;
|
||||
pointer-events: all;
|
||||
|
||||
.image-attachment {
|
||||
width: 100%;
|
||||
}
|
||||
margin-top: 0.35em;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
max-height: 600px;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
flex-flow: column;
|
||||
background-color: var(--bg_color);
|
||||
align-items: center;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.attachment {
|
||||
position: relative;
|
||||
line-height: 0;
|
||||
overflow: hidden;
|
||||
margin: 0 .25em 0 0;
|
||||
flex-grow: 1;
|
||||
box-sizing: border-box;
|
||||
min-width: 2em;
|
||||
position: relative;
|
||||
line-height: 0;
|
||||
overflow: hidden;
|
||||
margin: 0 0.25em 0 0;
|
||||
flex-grow: 1;
|
||||
box-sizing: border-box;
|
||||
min-width: 2em;
|
||||
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
max-height: 530px;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-gif video {
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
max-height: 530px;
|
||||
background-color: #101010;
|
||||
}
|
||||
|
||||
.still-image {
|
||||
max-height: 379.5px;
|
||||
max-width: 533px;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
max-width: 100%;
|
||||
max-height: 379.5px;
|
||||
flex-basis: 300px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
// .single-image {
|
||||
// display: inline-block;
|
||||
// width: 100%;
|
||||
// max-height: 600px;
|
||||
|
||||
// .attachments {
|
||||
// width: unset;
|
||||
// max-height: unset;
|
||||
// display: inherit;
|
||||
// }
|
||||
// }
|
||||
|
||||
.overlay-circle {
|
||||
border-radius: 50%;
|
||||
background-color: var(--dark_grey);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
border-width: 5px;
|
||||
border-color: var(--play_button);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.overlay-triangle {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 12px 0 12px 17px;
|
||||
border-color: transparent transparent transparent var(--play_button);
|
||||
margin-left: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.media-gif {
|
||||
display: table;
|
||||
background-color: unset;
|
||||
width: unset;
|
||||
display: table;
|
||||
background-color: unset;
|
||||
width: unset;
|
||||
max-height: unset;
|
||||
}
|
||||
|
||||
.media-gif video {
|
||||
max-height: 530px;
|
||||
background-color: #101010;
|
||||
}
|
||||
|
||||
.still-image {
|
||||
max-height: 379.5px;
|
||||
max-width: 533px;
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
max-width: 100%;
|
||||
max-height: 379.5px;
|
||||
flex-basis: 300px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.alt-text {
|
||||
margin: 0px;
|
||||
padding: 11px 7px;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
width: 2.98em;
|
||||
max-height: 25px;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
color: var(--fg_color);
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.alt-text:hover {
|
||||
padding: 7px;
|
||||
width: Min(230px, calc(100% - 10px * 2));
|
||||
max-height: calc(100% - 10px);
|
||||
line-height: 1.2em;
|
||||
white-space: pre-wrap;
|
||||
transition-duration: 0.4s;
|
||||
transition-property: max-height;
|
||||
}
|
||||
|
||||
.overlay-circle {
|
||||
border-radius: 50%;
|
||||
background-color: var(--dark_grey);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
border-width: 5px;
|
||||
border-color: var(--play_button);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.overlay-triangle {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 12px 0 12px 17px;
|
||||
border-color: transparent transparent transparent var(--play_button);
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.media-body {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
white-space: pre-wrap;
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
+24
-24
@@ -1,42 +1,42 @@
|
||||
@import '_variables';
|
||||
@import "_variables";
|
||||
|
||||
.poll-meter {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
margin: 6px 0;
|
||||
height: 26px;
|
||||
background: var(--bg_color);
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
margin: 6px 0;
|
||||
height: 26px;
|
||||
background: var(--bg_color);
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.poll-choice-bar {
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background: var(--dark_grey);
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background: var(--dark_grey);
|
||||
}
|
||||
|
||||
.poll-choice-value {
|
||||
position: relative;
|
||||
font-weight: bold;
|
||||
margin-left: 5px;
|
||||
margin-right: 6px;
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
pointer-events: all;
|
||||
position: relative;
|
||||
font-weight: bold;
|
||||
margin-left: 5px;
|
||||
margin-right: 6px;
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.poll-choice-option {
|
||||
position: relative;
|
||||
pointer-events: all;
|
||||
position: relative;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.poll-info {
|
||||
color: var(--grey);
|
||||
pointer-events: all;
|
||||
color: var(--grey);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.leader .poll-choice-bar {
|
||||
background: var(--accent_dark);
|
||||
background: var(--accent_dark);
|
||||
}
|
||||
|
||||
+97
-71
@@ -1,94 +1,120 @@
|
||||
@import '_variables';
|
||||
@import "_variables";
|
||||
|
||||
.quote {
|
||||
margin-top: 10px;
|
||||
border: solid 1px var(--dark_grey);
|
||||
border-radius: 10px;
|
||||
background-color: var(--bg_elements);
|
||||
margin-top: 10px;
|
||||
border: solid 1px var(--dark_grey);
|
||||
border-radius: 10px;
|
||||
background-color: var(--bg_elements);
|
||||
overflow: hidden;
|
||||
pointer-events: all;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--grey);
|
||||
}
|
||||
|
||||
&.unavailable:hover {
|
||||
border-color: var(--dark_grey);
|
||||
}
|
||||
|
||||
.tweet-name-row {
|
||||
padding: 8px 10px 6px 10px;
|
||||
}
|
||||
|
||||
.quote-text {
|
||||
overflow: hidden;
|
||||
pointer-events: all;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
padding: 10px;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.show-thread {
|
||||
padding: 0px 10px 6px 10px;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.quote-latest {
|
||||
padding: 0px 10px 6px 10px;
|
||||
color: var(--grey);
|
||||
}
|
||||
|
||||
.replying-to {
|
||||
padding: 0px 10px;
|
||||
padding-bottom: 4px;
|
||||
margin: unset;
|
||||
}
|
||||
|
||||
.community-note {
|
||||
background-color: var(--bg_panel);
|
||||
border: unset;
|
||||
border-top: solid 1px var(--dark_grey);
|
||||
border-radius: unset;
|
||||
margin-top: 0;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--grey);
|
||||
border-top-color: var(--grey);
|
||||
}
|
||||
|
||||
&.unavailable:hover {
|
||||
border-color: var(--dark_grey);
|
||||
}
|
||||
|
||||
.tweet-name-row {
|
||||
padding: 6px 8px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.quote-text {
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
padding: 0px 8px 8px 8px;
|
||||
}
|
||||
|
||||
.show-thread {
|
||||
padding: 0px 8px 6px 8px;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.replying-to {
|
||||
padding: 0px 8px;
|
||||
margin: unset;
|
||||
.community-note-header {
|
||||
background-color: var(--bg_panel);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.unavailable-quote {
|
||||
padding: 12px;
|
||||
padding: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.quote-link {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.quote-media-container {
|
||||
max-height: 300px;
|
||||
max-height: 300px;
|
||||
display: flex;
|
||||
|
||||
.card {
|
||||
margin: unset;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.media-gif {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: unset;
|
||||
.media-gif > .attachment {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: var(--bg_color);
|
||||
|
||||
video {
|
||||
height: unset;
|
||||
width: unset;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.attachments {
|
||||
border-radius: 0;
|
||||
}
|
||||
.gallery-row .attachment,
|
||||
.gallery-row .attachment > video,
|
||||
.gallery-row .attachment > img {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.media-gif {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gallery-gif .attachment {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: var(--bg_color);
|
||||
|
||||
video {
|
||||
height: unset;
|
||||
width: unset;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-video, .gallery-gif {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.still-image img {
|
||||
max-height: 250px
|
||||
}
|
||||
.still-image img {
|
||||
max-height: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
+130
-88
@@ -1,112 +1,154 @@
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
.conversation {
|
||||
@include panel(100%, 600px);
|
||||
.conversation,
|
||||
.edit-history {
|
||||
@include panel(100%, 600px);
|
||||
|
||||
.show-more {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.show-more {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.main-thread {
|
||||
margin-bottom: 20px;
|
||||
background-color: var(--bg_panel);
|
||||
}
|
||||
|
||||
.main-tweet, .replies {
|
||||
padding-top: 50px;
|
||||
margin-top: -50px;
|
||||
}
|
||||
|
||||
.main-tweet .tweet-content {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@media(max-width: 600px) {
|
||||
.main-tweet .tweet-content {
|
||||
font-size: 16px;
|
||||
}
|
||||
.main-thread,
|
||||
.latest-edit {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.reply {
|
||||
background-color: var(--bg_panel);
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.main-tweet,
|
||||
.replies,
|
||||
.edit-history > div {
|
||||
body.fixed-nav & {
|
||||
padding-top: 50px;
|
||||
margin-top: -50px;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-history-header {
|
||||
padding: 10px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
background-color: var(--bg_panel);
|
||||
}
|
||||
|
||||
.tweet-edit {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.main-tweet .tweet-content {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.main-tweet .tweet-content {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.thread-line {
|
||||
.timeline-item::before,
|
||||
&.timeline-item::before {
|
||||
background: var(--accent_dark);
|
||||
content: '';
|
||||
position: relative;
|
||||
min-width: 3px;
|
||||
width: 3px;
|
||||
left: 26px;
|
||||
border-radius: 2px;
|
||||
margin-left: -3px;
|
||||
margin-bottom: 37px;
|
||||
top: 56px;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.timeline-item::before,
|
||||
&.timeline-item::before {
|
||||
background: var(--accent_dark);
|
||||
content: "";
|
||||
position: relative;
|
||||
min-width: 3px;
|
||||
width: 3px;
|
||||
left: 26px;
|
||||
border-radius: 2px;
|
||||
margin-left: -3px;
|
||||
margin-bottom: 37px;
|
||||
top: 56px;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.with-header:not(:first-child)::after {
|
||||
background: var(--accent_dark);
|
||||
content: '';
|
||||
position: relative;
|
||||
float: left;
|
||||
min-width: 3px;
|
||||
width: 3px;
|
||||
right: calc(100% - 26px);
|
||||
border-radius: 2px;
|
||||
margin-left: -3px;
|
||||
margin-bottom: 37px;
|
||||
bottom: 10px;
|
||||
height: 30px;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.with-header:not(:first-child)::after {
|
||||
background: var(--accent_dark);
|
||||
content: "";
|
||||
position: relative;
|
||||
float: left;
|
||||
min-width: 3px;
|
||||
width: 3px;
|
||||
right: calc(100% - 26px);
|
||||
border-radius: 2px;
|
||||
margin-left: -3px;
|
||||
margin-bottom: 37px;
|
||||
bottom: 10px;
|
||||
height: 30px;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.unavailable::before {
|
||||
top: 48px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.unavailable::before {
|
||||
top: 48px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.more-replies::before {
|
||||
content: '...';
|
||||
background: unset;
|
||||
color: var(--more_replies_dots);
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
line-height: 0.25em;
|
||||
left: 1.2em;
|
||||
width: 5px;
|
||||
top: 2px;
|
||||
margin-bottom: 0;
|
||||
margin-left: -2.5px;
|
||||
}
|
||||
.more-replies::before {
|
||||
content: "...";
|
||||
background: unset;
|
||||
color: var(--more_replies_dots);
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
line-height: 0.25em;
|
||||
left: 1.2em;
|
||||
width: 5px;
|
||||
top: 2px;
|
||||
margin-bottom: 0;
|
||||
margin-left: -2.5px;
|
||||
}
|
||||
|
||||
.earlier-replies {
|
||||
padding-bottom: 0;
|
||||
margin-bottom: -5px;
|
||||
}
|
||||
.earlier-replies {
|
||||
padding-bottom: 0;
|
||||
margin-bottom: -5px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-item.thread-last::before {
|
||||
background: unset;
|
||||
min-width: unset;
|
||||
width: 0;
|
||||
margin: 0;
|
||||
background: unset;
|
||||
min-width: unset;
|
||||
width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.more-replies {
|
||||
padding-top: 0.3em !important;
|
||||
padding-top: 0.3em !important;
|
||||
}
|
||||
|
||||
.more-replies-text {
|
||||
@include ellipsis;
|
||||
display: block;
|
||||
margin-left: 58px;
|
||||
padding: 7px 0;
|
||||
@include ellipsis;
|
||||
display: block;
|
||||
margin-left: 58px;
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.timeline-item.thread.more-replies-thread {
|
||||
padding: 0 0.75em;
|
||||
|
||||
&::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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+57
-46
@@ -1,66 +1,77 @@
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
video {
|
||||
max-height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gallery-video {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gallery-video.card-container {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
&.card-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
> .attachment {
|
||||
min-height: 80px;
|
||||
min-width: 200px;
|
||||
max-height: 530px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-overlay {
|
||||
@include play-button;
|
||||
background-color: $shadow;
|
||||
@include play-button;
|
||||
background-color: $shadow;
|
||||
|
||||
p {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
text-align: center;
|
||||
top: calc(50% - 20px);
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
margin: 0 20px;
|
||||
}
|
||||
p {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
text-align: center;
|
||||
top: calc(50% - 20px);
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
margin: 0 20px;
|
||||
}
|
||||
|
||||
div {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
top: calc(50% - 20px);
|
||||
margin: 0 auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.overlay-circle {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
top: calc(50% - 20px);
|
||||
margin: 0 auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
form {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
}
|
||||
.overlay-duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
background-color: #0000007a;
|
||||
line-height: 1em;
|
||||
padding: 4px 6px 4px 6px;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 5px 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
form {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 5px 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
+62
@@ -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)
|
||||
+146
-35
@@ -6,62 +6,77 @@ genPrefsType()
|
||||
|
||||
type
|
||||
RateLimitError* = object of CatchableError
|
||||
NoSessionsError* = object of CatchableError
|
||||
InternalError* = object of CatchableError
|
||||
BadClientError* = object of CatchableError
|
||||
|
||||
TimelineKind* {.pure.} = enum
|
||||
tweets
|
||||
replies
|
||||
media
|
||||
tweets, replies, media
|
||||
|
||||
Api* {.pure.} = enum
|
||||
tweetDetail
|
||||
tweetResult
|
||||
timeline
|
||||
search
|
||||
userSearch
|
||||
list
|
||||
listBySlug
|
||||
listMembers
|
||||
listTweets
|
||||
userRestId
|
||||
userScreenName
|
||||
userTweets
|
||||
userTweetsAndReplies
|
||||
userMedia
|
||||
ApiUrl* = object
|
||||
endpoint*: string
|
||||
params*: seq[(string, string)]
|
||||
skipTid*: bool
|
||||
|
||||
ApiReq* = object
|
||||
oauth*: ApiUrl
|
||||
cookie*: ApiUrl
|
||||
|
||||
RateLimit* = object
|
||||
limit*: int
|
||||
remaining*: int
|
||||
reset*: int
|
||||
|
||||
Token* = ref object
|
||||
tok*: string
|
||||
init*: Time
|
||||
lastUse*: Time
|
||||
SessionKind* = enum
|
||||
oauth
|
||||
cookie
|
||||
|
||||
Session* = ref object
|
||||
id*: int64
|
||||
username*: string
|
||||
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
|
||||
null = 0
|
||||
noUserMatches = 17
|
||||
protectedUser = 22
|
||||
missingParams = 25
|
||||
timeout = 29
|
||||
couldntAuth = 32
|
||||
doesntExist = 34
|
||||
unauthorized = 37
|
||||
invalidParam = 47
|
||||
userNotFound = 50
|
||||
suspended = 63
|
||||
rateLimited = 88
|
||||
invalidToken = 89
|
||||
expiredToken = 89
|
||||
listIdOrSlug = 112
|
||||
tweetNotFound = 144
|
||||
tweetNotAuthorized = 179
|
||||
forbidden = 200
|
||||
badRequest = 214
|
||||
badToken = 239
|
||||
locked = 326
|
||||
noCsrf = 353
|
||||
tweetUnavailable = 421
|
||||
tweetCensored = 422
|
||||
|
||||
VerifiedType* = enum
|
||||
none = "None"
|
||||
blue = "Blue"
|
||||
business = "Business"
|
||||
government = "Government"
|
||||
|
||||
User* = object
|
||||
id*: string
|
||||
username*: string
|
||||
@@ -77,11 +92,42 @@ type
|
||||
tweets*: int
|
||||
likes*: int
|
||||
media*: int
|
||||
verified*: bool
|
||||
verifiedType*: VerifiedType
|
||||
protected*: bool
|
||||
suspended*: bool
|
||||
joinDate*: DateTime
|
||||
|
||||
AccountInfo* = object
|
||||
username*: string
|
||||
fullname*: string
|
||||
userPic*: string
|
||||
joinDate*: DateTime
|
||||
verifiedType*: VerifiedType
|
||||
suspended*: bool
|
||||
basedIn*: string
|
||||
source*: string
|
||||
usernameChanges*: int
|
||||
lastUsernameChange*: DateTime
|
||||
affiliateUsername*: string
|
||||
affiliateLabel*: string
|
||||
isIdentityVerified*: bool
|
||||
verifiedSince*: DateTime
|
||||
overrideVerifiedYear*: int
|
||||
|
||||
Broadcast* = object
|
||||
id*: string
|
||||
title*: string
|
||||
state*: string
|
||||
thumb*: string
|
||||
mediaKey*: string
|
||||
m3u8Url*: string
|
||||
totalWatched*: int
|
||||
startTime*: DateTime
|
||||
endTime*: DateTime
|
||||
replayStart*: int
|
||||
availableForReplay*: bool
|
||||
user*: User
|
||||
|
||||
VideoType* = enum
|
||||
m3u8 = "application/x-mpegURL"
|
||||
mp4 = "video/mp4"
|
||||
@@ -97,7 +143,6 @@ type
|
||||
durationMs*: int
|
||||
url*: string
|
||||
thumb*: string
|
||||
views*: string
|
||||
available*: bool
|
||||
reason*: string
|
||||
title*: string
|
||||
@@ -110,6 +155,7 @@ type
|
||||
|
||||
Query* = object
|
||||
kind*: QueryKind
|
||||
view*: string
|
||||
text*: string
|
||||
filters*: seq[string]
|
||||
includes*: seq[string]
|
||||
@@ -117,12 +163,33 @@ type
|
||||
fromUser*: seq[string]
|
||||
since*: string
|
||||
until*: string
|
||||
near*: string
|
||||
minLikes*: string
|
||||
sep*: string
|
||||
|
||||
Gif* = object
|
||||
url*: string
|
||||
thumb*: string
|
||||
altText*: string
|
||||
|
||||
Photo* = object
|
||||
url*: string
|
||||
altText*: string
|
||||
|
||||
MediaKind* = enum
|
||||
photoMedia
|
||||
videoMedia
|
||||
gifMedia
|
||||
|
||||
Media* = object
|
||||
case kind*: MediaKind
|
||||
of photoMedia:
|
||||
photo*: Photo
|
||||
of videoMedia:
|
||||
video*: Video
|
||||
of gifMedia:
|
||||
gif*: Gif
|
||||
|
||||
MediaEntities* = seq[Media]
|
||||
|
||||
GalleryPhoto* = object
|
||||
url*: string
|
||||
@@ -161,9 +228,10 @@ type
|
||||
imageDirectMessage = "image_direct_message"
|
||||
audiospace = "audiospace"
|
||||
newsletterPublication = "newsletter_publication"
|
||||
jobDetails = "job_details"
|
||||
hidden
|
||||
unknown
|
||||
|
||||
|
||||
Card* = object
|
||||
kind*: CardKind
|
||||
url*: string
|
||||
@@ -177,7 +245,7 @@ type
|
||||
replies*: int
|
||||
retweets*: int
|
||||
likes*: int
|
||||
quotes*: int
|
||||
views*: int
|
||||
|
||||
Tweet* = ref object
|
||||
id*: int64
|
||||
@@ -197,13 +265,18 @@ type
|
||||
stats*: TweetStats
|
||||
retweet*: Option[Tweet]
|
||||
attribution*: Option[User]
|
||||
attributionLink*: string
|
||||
mediaTags*: seq[User]
|
||||
quote*: Option[Tweet]
|
||||
card*: Option[Card]
|
||||
poll*: Option[Poll]
|
||||
gif*: Option[Gif]
|
||||
video*: Option[Video]
|
||||
photos*: seq[string]
|
||||
media*: MediaEntities
|
||||
history*: seq[int64]
|
||||
note*: string
|
||||
isAd*: bool
|
||||
isAI*: bool
|
||||
|
||||
Tweets* = seq[Tweet]
|
||||
|
||||
Result*[T] = object
|
||||
content*: seq[T]
|
||||
@@ -212,7 +285,7 @@ type
|
||||
query*: Query
|
||||
|
||||
Chain* = object
|
||||
content*: seq[Tweet]
|
||||
content*: Tweets
|
||||
hasMore*: bool
|
||||
cursor*: string
|
||||
|
||||
@@ -222,13 +295,18 @@ type
|
||||
after*: Chain
|
||||
replies*: Result[Chain]
|
||||
|
||||
Timeline* = Result[Tweet]
|
||||
EditHistory* = object
|
||||
latest*: Tweet
|
||||
history*: Tweets
|
||||
|
||||
Timeline* = Result[Tweets]
|
||||
|
||||
Profile* = object
|
||||
user*: User
|
||||
photoRail*: PhotoRail
|
||||
pinned*: Option[Tweet]
|
||||
tweets*: Timeline
|
||||
accountInfo*: AccountInfo
|
||||
|
||||
List* = object
|
||||
id*: string
|
||||
@@ -255,10 +333,19 @@ type
|
||||
hmacKey*: string
|
||||
base64Media*: bool
|
||||
minTokens*: int
|
||||
enableRss*: bool
|
||||
enableRSSUserTweets*: bool
|
||||
enableRSSUserReplies*: bool
|
||||
enableRSSUserMedia*: bool
|
||||
enableRSSSearch*: bool
|
||||
enableRSSList*: bool
|
||||
enableDebug*: bool
|
||||
proxy*: string
|
||||
proxyAuth*: string
|
||||
apiProxy*: string
|
||||
disableTid*: bool
|
||||
maxConcurrentReqs*: int
|
||||
maxRetries*: int
|
||||
retryDelayMs*: int
|
||||
|
||||
rssCacheTime*: int
|
||||
listCacheTime*: int
|
||||
@@ -274,3 +361,27 @@ type
|
||||
|
||||
proc contains*(thread: Chain; tweet: Tweet): bool =
|
||||
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
|
||||
import strutils, strformat, uri, tables, base64
|
||||
import sequtils, strutils, strformat, uri, tables, base64
|
||||
import nimcrypto
|
||||
|
||||
var
|
||||
@@ -9,14 +9,17 @@ var
|
||||
const
|
||||
https* = "https://"
|
||||
twimg* = "pbs.twimg.com/"
|
||||
nitterParams = ["name", "tab", "id", "list", "referer", "scroll"]
|
||||
nitterParams* = ["name", "tab", "id", "list", "referer", "scroll", "prefs"]
|
||||
twitterDomains = @[
|
||||
"twitter.com",
|
||||
"pic.twitter.com",
|
||||
"twimg.com",
|
||||
"abs.twimg.com",
|
||||
"pbs.twimg.com",
|
||||
"video.twimg.com"
|
||||
"video.twimg.com",
|
||||
"x.com",
|
||||
"pscp.tv",
|
||||
"video.pscp.tv"
|
||||
]
|
||||
|
||||
proc setHmacKey*(key: string) =
|
||||
@@ -54,7 +57,13 @@ proc filterParams*(params: Table): seq[(string, string)] =
|
||||
result.add p
|
||||
|
||||
proc isTwitterUrl*(uri: Uri): bool =
|
||||
uri.hostname in twitterDomains
|
||||
uri.hostname in twitterDomains or
|
||||
uri.hostname.endsWith(".video.pscp.tv")
|
||||
|
||||
proc isTwitterUrl*(url: string): bool =
|
||||
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"
|
||||
|
||||
proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
|
||||
let thumb = get(tweet.video).thumb
|
||||
let vidUrl = getVideoEmbed(cfg, tweet.id)
|
||||
let prefs = Prefs(hlsPlayback: true)
|
||||
let
|
||||
video = tweet.getVideos()[0]
|
||||
thumb = video.thumb
|
||||
vidUrl = getVideoEmbed(cfg, tweet.id)
|
||||
prefs = Prefs(hlsPlayback: true, mp4Playback: true)
|
||||
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb]))
|
||||
|
||||
body:
|
||||
tdiv(class="embed-video"):
|
||||
renderVideo(get(tweet.video), prefs, "")
|
||||
renderVideo(video, prefs, "")
|
||||
|
||||
result = doctype & $node
|
||||
|
||||
+18
-18
@@ -29,19 +29,17 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
|
||||
|
||||
tdiv(class="nav-item right"):
|
||||
icon "search", title="Search", href="/search"
|
||||
if cfg.enableRss and rss.len > 0:
|
||||
icon "rss-feed", title="RSS Feed", href=rss
|
||||
icon "bird", title="Open in Twitter", href=canonical
|
||||
if rss.len > 0:
|
||||
icon "rss", title="RSS Feed", href=rss
|
||||
icon "bird", title="Open in X", href=canonical
|
||||
a(href="https://liberapay.com/zedeus"): verbatim lp
|
||||
icon "info", title="About", href="/about"
|
||||
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
|
||||
|
||||
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
|
||||
rss=""; canonical=""): VNode =
|
||||
var theme = prefs.theme.toTheme
|
||||
if "theme" in req.params:
|
||||
theme = req.params["theme"].toTheme
|
||||
rss=""; alternate=""): VNode =
|
||||
let theme = prefs.theme.toTheme
|
||||
|
||||
let ogType =
|
||||
if video.len > 0: "video"
|
||||
@@ -52,8 +50,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
||||
|
||||
buildHtml(head):
|
||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=18")
|
||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2")
|
||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=35")
|
||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5")
|
||||
|
||||
if theme.len > 0:
|
||||
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
|
||||
@@ -66,14 +64,14 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
|
||||
href=opensearchUrl)
|
||||
|
||||
if canonical.len > 0:
|
||||
link(rel="canonical", href=canonical)
|
||||
if alternate.len > 0:
|
||||
link(rel="alternate", href=alternate, title="View on X")
|
||||
|
||||
if cfg.enableRss and rss.len > 0:
|
||||
if rss.len > 0:
|
||||
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
|
||||
|
||||
if prefs.hlsPlayback:
|
||||
script(src="/js/hls.light.min.js", `defer`="")
|
||||
script(src="/js/hls.min.js", `defer`="")
|
||||
script(src="/js/hlsPlayback.js", `defer`="")
|
||||
|
||||
if prefs.infiniteScroll:
|
||||
@@ -86,6 +84,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
text cfg.title
|
||||
|
||||
meta(name="viewport", content="width=device-width, initial-scale=1.0")
|
||||
meta(name="referrer", content="same-origin")
|
||||
meta(name="theme-color", content="#1F1F1F")
|
||||
meta(property="og:type", content=ogType)
|
||||
meta(property="og:title", content=(if ogTitle.len > 0: ogTitle else: titleText))
|
||||
@@ -119,20 +118,21 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
# this is last so images are also preloaded
|
||||
# if this is done earlier, Chrome only preloads one image for some reason
|
||||
link(rel="preload", type="font/woff2", `as`="font",
|
||||
href="/fonts/fontello.woff2?21002321", crossorigin="anonymous")
|
||||
href="/fonts/fontello.woff2?61663884", crossorigin="anonymous")
|
||||
|
||||
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
|
||||
titleText=""; desc=""; ogTitle=""; rss=""; video="";
|
||||
images: seq[string] = @[]; banner=""): string =
|
||||
|
||||
let canonical = getTwitterLink(req.path, req.params)
|
||||
let twitterLink = getTwitterLink(req.path, req.params)
|
||||
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
|
||||
rss, canonical)
|
||||
rss, twitterLink)
|
||||
|
||||
body:
|
||||
renderNavbar(cfg, req, rss, canonical)
|
||||
let bodyClass = if prefs.stickyNav: "fixed-nav" else: ""
|
||||
body(class=bodyClass):
|
||||
renderNavbar(cfg, req, rss, twitterLink)
|
||||
|
||||
tdiv(class="container"):
|
||||
body
|
||||
|
||||
@@ -32,7 +32,8 @@ macro renderPrefs*(): untyped =
|
||||
|
||||
result[2].add stmt
|
||||
|
||||
proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode =
|
||||
proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string];
|
||||
prefsUrl: string): VNode =
|
||||
buildHtml(tdiv(class="overlay-panel")):
|
||||
fieldset(class="preferences"):
|
||||
form(`method`="post", action="/saveprefs", autocomplete="off"):
|
||||
@@ -40,6 +41,14 @@ proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode
|
||||
|
||||
renderPrefs()
|
||||
|
||||
legend: text "Bookmark"
|
||||
p(class="bookmark-note"):
|
||||
text "Save this URL to restore your preferences (?prefs works on all pages)"
|
||||
pre(class="prefs-code"):
|
||||
text prefsUrl
|
||||
p(class="bookmark-note"):
|
||||
verbatim "You can override preferences with query parameters (e.g. <code>?hlsPlayback=on</code>). These overrides aren't saved to cookies, and links won't retain the parameters. Intended for configuring RSS feeds and other cookieless environments. Hover over a preference to see its name."
|
||||
|
||||
h4(class="note"):
|
||||
text "Preferences are stored client-side using cookies without any personal information."
|
||||
|
||||
|
||||
+20
-9
@@ -12,7 +12,7 @@ proc renderStat(num: int; class: string; text=""): VNode =
|
||||
span(class="profile-stat-num"):
|
||||
text insertSep($num, ',')
|
||||
|
||||
proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
||||
proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
|
||||
buildHtml(tdiv(class="profile-card")):
|
||||
tdiv(class="profile-card-info"):
|
||||
let
|
||||
@@ -26,6 +26,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
||||
|
||||
tdiv(class="profile-card-tabs-name"):
|
||||
linkUser(user, class="profile-card-fullname")
|
||||
verifiedIcon(user)
|
||||
linkUser(user, class="profile-card-username")
|
||||
|
||||
tdiv(class="profile-card-extra"):
|
||||
@@ -45,6 +46,11 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
||||
else:
|
||||
span: text place
|
||||
|
||||
if info.basedIn.len > 0:
|
||||
tdiv(class="profile-location"):
|
||||
span: icon "location"
|
||||
span: text "Based in " & info.basedIn
|
||||
|
||||
if user.website.len > 0:
|
||||
tdiv(class="profile-website"):
|
||||
span:
|
||||
@@ -53,7 +59,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
||||
a(href=url): text url.shortLink
|
||||
|
||||
tdiv(class="profile-joindate"):
|
||||
span(title=getJoinDateFull(user)):
|
||||
a(href=(&"/{user.username}/about"), title=getJoinDateFull(user)):
|
||||
icon "calendar", getJoinDate(user)
|
||||
|
||||
tdiv(class="profile-card-extra-links"):
|
||||
@@ -101,17 +107,22 @@ proc renderProtected(username: string): VNode =
|
||||
|
||||
proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
|
||||
profile.tweets.query.fromUser = @[profile.user.username]
|
||||
let
|
||||
isGalleryView = profile.tweets.query.kind == media and
|
||||
profile.tweets.query.view == "gallery"
|
||||
viewClass = if isGalleryView: " media-only" else: ""
|
||||
|
||||
buildHtml(tdiv(class="profile-tabs")):
|
||||
if not prefs.hideBanner:
|
||||
buildHtml(tdiv(class=("profile-tabs" & viewClass))):
|
||||
if not isGalleryView and not prefs.hideBanner:
|
||||
tdiv(class="profile-banner"):
|
||||
renderBanner(profile.user.banner)
|
||||
|
||||
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
||||
tdiv(class=("profile-tab" & sticky)):
|
||||
renderUserCard(profile.user, prefs)
|
||||
if profile.photoRail.len > 0:
|
||||
renderPhotoRail(profile)
|
||||
if not isGalleryView:
|
||||
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
||||
tdiv(class=("profile-tab" & sticky)):
|
||||
renderUserCard(profile.user, prefs, profile.accountInfo)
|
||||
if profile.photoRail.len > 0:
|
||||
renderPhotoRail(profile)
|
||||
|
||||
if profile.user.protected:
|
||||
renderProtected(profile.user.username)
|
||||
|
||||
+32
-10
@@ -4,6 +4,7 @@ import karax/[karaxdsl, vdom, vstyles]
|
||||
import ".."/[types, utils]
|
||||
|
||||
const smallWebp* = "?name=small&format=webp"
|
||||
const mediumWebp* = "?name=medium&format=webp"
|
||||
|
||||
proc getSmallPic*(url: string): string =
|
||||
result = url
|
||||
@@ -11,6 +12,12 @@ proc getSmallPic*(url: string): string =
|
||||
result &= smallWebp
|
||||
result = getPicUrl(result)
|
||||
|
||||
proc getMediumPic*(url: string): string =
|
||||
result = url
|
||||
if "?" notin url and not url.endsWith("placeholder.png"):
|
||||
result &= mediumWebp
|
||||
result = getPicUrl(result)
|
||||
|
||||
proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
|
||||
var c = "icon-" & icon
|
||||
if class.len > 0: c = &"{c} {class}"
|
||||
@@ -23,6 +30,15 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
|
||||
if text.len > 0:
|
||||
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 =
|
||||
let
|
||||
isName = "username" notin class
|
||||
@@ -32,11 +48,10 @@ proc linkUser*(user: User, class=""): VNode =
|
||||
|
||||
buildHtml(a(href=href, class=class, title=nameText)):
|
||||
text nameText
|
||||
if isName and user.verified:
|
||||
icon "ok", class="verified-icon", title="Verified account"
|
||||
if isName and user.protected:
|
||||
text " "
|
||||
icon "lock", title="Protected account"
|
||||
if isName:
|
||||
if user.protected:
|
||||
text " "
|
||||
icon "lock", title="Protected account"
|
||||
|
||||
proc linkText*(text: string; class=""): VNode =
|
||||
let url = if "http" notin text: https & text else: text
|
||||
@@ -57,20 +72,20 @@ proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNod
|
||||
text text
|
||||
|
||||
proc genCheckbox*(pref, label: string; state: bool): VNode =
|
||||
buildHtml(label(class="pref-group checkbox-container")):
|
||||
buildHtml(label(class="pref-group checkbox-container", title=pref)):
|
||||
text label
|
||||
input(name=pref, `type`="checkbox", checked=state)
|
||||
span(class="checkbox")
|
||||
|
||||
proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode =
|
||||
let p = placeholder
|
||||
buildHtml(tdiv(class=("pref-group pref-input " & class))):
|
||||
buildHtml(tdiv(class=("pref-group pref-input " & class), title=pref)):
|
||||
if label.len > 0:
|
||||
label(`for`=pref): text label
|
||||
input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0))
|
||||
|
||||
proc genSelect*(pref, label, state: string; options: seq[string]): VNode =
|
||||
buildHtml(tdiv(class="pref-group pref-input")):
|
||||
buildHtml(tdiv(class="pref-group pref-input", title=pref)):
|
||||
label(`for`=pref): text label
|
||||
select(name=pref):
|
||||
for opt in options:
|
||||
@@ -82,9 +97,16 @@ proc genDate*(pref, state: string): VNode =
|
||||
input(name=pref, `type`="date", value=state)
|
||||
icon "calendar"
|
||||
|
||||
proc genImg*(url: string; class=""): VNode =
|
||||
proc genNumberInput*(pref, label, state, placeholder: string; class=""; autofocus=true; min="0"): VNode =
|
||||
let p = placeholder
|
||||
buildHtml(tdiv(class=("pref-group pref-input " & class))):
|
||||
if label.len > 0:
|
||||
label(`for`=pref): text label
|
||||
input(name=pref, `type`="number", placeholder=p, value=state, autofocus=(autofocus and state.len == 0), min=min, step="1")
|
||||
|
||||
proc genImg*(url: string; class=""; alt=""): VNode =
|
||||
buildHtml():
|
||||
img(src=getPicUrl(url), class=class, alt="")
|
||||
img(src=getPicUrl(url), class=class, alt=alt, loading="lazy")
|
||||
|
||||
proc getTabClass*(query: Query; tab: QueryKind): string =
|
||||
if query.kind == tab: "tab-item active"
|
||||
|
||||
+121
-54
@@ -1,83 +1,149 @@
|
||||
#? stdtmpl(subsChar = '$', metaChar = '#')
|
||||
## SPDX-License-Identifier: AGPL-3.0-only
|
||||
#import strutils, xmltree, strformat, options, unicode
|
||||
#import strutils, sequtils, xmltree, strformat, options, unicode
|
||||
#import ../types, ../utils, ../formatters, ../prefs
|
||||
## Snowflake ID cutoff for RSS GUID format transition
|
||||
## Corresponds to approximately December 14, 2025 UTC
|
||||
#const guidCutoff = 2000000000000000000'i64
|
||||
#
|
||||
#proc getTitle(tweet: Tweet; retweet: string): string =
|
||||
#if tweet.pinned: result = "Pinned: "
|
||||
#elif retweet.len > 0: result = &"RT by @{retweet}: "
|
||||
#elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: "
|
||||
#var prefix = ""
|
||||
#if tweet.pinned: prefix = "Pinned: "
|
||||
#elif retweet.len > 0: prefix = &"RT by @{retweet}: "
|
||||
#elif tweet.reply.len > 0: prefix = &"R to @{tweet.reply[0]}: "
|
||||
#end if
|
||||
#var text = stripHtml(tweet.text)
|
||||
##if unicode.runeLen(text) > 32:
|
||||
## text = unicode.runeSubStr(text, 0, 32) & "..."
|
||||
##end if
|
||||
#result &= xmltree.escape(text)
|
||||
#if result.len > 0: return
|
||||
#text = xmltree.escape(text)
|
||||
#if text.len > 0:
|
||||
# result = prefix & text
|
||||
# return
|
||||
#end if
|
||||
#if tweet.photos.len > 0:
|
||||
# result &= "Image"
|
||||
#elif tweet.video.isSome:
|
||||
# result &= "Video"
|
||||
#elif tweet.gif.isSome:
|
||||
# result &= "Gif"
|
||||
#if tweet.media.len > 0:
|
||||
# result = prefix
|
||||
# let firstKind = tweet.media[0].kind
|
||||
# if tweet.media.anyIt(it.kind != firstKind):
|
||||
# result &= "Media"
|
||||
# else:
|
||||
# case firstKind
|
||||
# of photoMedia: result &= "Image"
|
||||
# of videoMedia: result &= "Video"
|
||||
# of gifMedia: result &= "Gif"
|
||||
# end case
|
||||
# end if
|
||||
#end if
|
||||
#end proc
|
||||
#
|
||||
#proc getDescription(desc: string; cfg: Config): string =
|
||||
Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
||||
Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
|
||||
#end proc
|
||||
#
|
||||
#proc renderRssTweet(tweet: Tweet; cfg: Config): string =
|
||||
#let tweet = tweet.retweet.get(tweet)
|
||||
#let urlPrefix = getUrlPrefix(cfg)
|
||||
#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix)
|
||||
<p>${text.replace("\n", "<br>\n")}</p>
|
||||
#if tweet.quote.isSome and get(tweet.quote).available:
|
||||
# let quoteLink = getLink(get(tweet.quote))
|
||||
<p><a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p>
|
||||
#end if
|
||||
#if tweet.photos.len > 0:
|
||||
# for photo in tweet.photos:
|
||||
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" />
|
||||
# end for
|
||||
#elif tweet.video.isSome:
|
||||
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
|
||||
#elif tweet.gif.isSome:
|
||||
# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
|
||||
# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
|
||||
#proc renderRssMedia(media: Media; tweet: Tweet; urlPrefix: string): string =
|
||||
#case media.kind
|
||||
#of photoMedia:
|
||||
# let photo = media.photo
|
||||
<img src="${urlPrefix}${getPicUrl(photo.url)}" style="max-width:250px;" />
|
||||
#of videoMedia:
|
||||
# let video = media.video
|
||||
<a href="${urlPrefix}${tweet.getLink}">
|
||||
<br>Video<br>
|
||||
<img src="${urlPrefix}${getPicUrl(video.thumb)}" style="max-width:250px;" />
|
||||
</a>
|
||||
#of gifMedia:
|
||||
# let gif = media.gif
|
||||
# let thumb = &"{urlPrefix}{getPicUrl(gif.thumb)}"
|
||||
# let url = &"{urlPrefix}{getPicUrl(gif.url)}"
|
||||
<video poster="${thumb}" autoplay muted loop style="max-width:250px;">
|
||||
<source src="${url}" type="video/mp4"></video>
|
||||
#end case
|
||||
#end proc
|
||||
#
|
||||
#proc getTweetsWithPinned(profile: Profile): seq[Tweets] =
|
||||
#result = profile.tweets.content
|
||||
#if profile.pinned.isSome and result.len > 0:
|
||||
# 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:
|
||||
# let card = tweet.card.get()
|
||||
# if card.image.len > 0:
|
||||
<img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" />
|
||||
# end if
|
||||
#end if
|
||||
#if tweet.note.len > 0 and not prefs.hideCommunityNotes:
|
||||
<p><b>Community note:</b> ${replaceUrls(tweet.note, prefs, absolute=urlPrefix)}</p>
|
||||
#end if
|
||||
#if tweet.quote.isSome and get(tweet.quote).available:
|
||||
# let quoteTweet = get(tweet.quote)
|
||||
# let quoteLink = urlPrefix & getLink(quoteTweet)
|
||||
<hr/>
|
||||
<blockquote>
|
||||
<b>${quoteTweet.user.fullname} (@${quoteTweet.user.username})</b>
|
||||
<p>
|
||||
${renderRssTweet(quoteTweet, cfg, prefs)}
|
||||
</p>
|
||||
<footer>
|
||||
— <cite><a href="${quoteLink}">${quoteLink}</a>
|
||||
</footer>
|
||||
</blockquote>
|
||||
#end if
|
||||
#end proc
|
||||
#
|
||||
#proc renderRssTweets(tweets: seq[Tweet]; cfg: Config): string =
|
||||
#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; prefs: Prefs; userId=""): string =
|
||||
#let urlPrefix = getUrlPrefix(cfg)
|
||||
#var links: seq[string]
|
||||
#for t in tweets:
|
||||
# let retweet = if t.retweet.isSome: t.user.username else: ""
|
||||
# let tweet = if retweet.len > 0: t.retweet.get else: t
|
||||
# let link = getLink(tweet)
|
||||
# if link in links: continue
|
||||
# end if
|
||||
# links.add link
|
||||
<item>
|
||||
<title>${getTitle(tweet, retweet)}</title>
|
||||
<dc:creator>@${tweet.user.username}</dc:creator>
|
||||
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
|
||||
<pubDate>${getRfc822Time(tweet)}</pubDate>
|
||||
<guid>${urlPrefix & link}</guid>
|
||||
<link>${urlPrefix & link}</link>
|
||||
</item>
|
||||
#for thread in tweets:
|
||||
# for tweet in thread:
|
||||
# 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)
|
||||
# if link in links: continue
|
||||
# end if
|
||||
# links.add link
|
||||
# let useGlobalGuid = tweet.id >= guidCutoff
|
||||
<item>
|
||||
<title>${getTitle(tweet, retweet)}</title>
|
||||
<dc:creator>@${tweet.user.username}</dc:creator>
|
||||
<description><![CDATA[${renderRssTweet(tweet, cfg, prefs).strip(chars={'\n'})}]]></description>
|
||||
<pubDate>${getRfc822Time(tweet)}</pubDate>
|
||||
#if useGlobalGuid:
|
||||
<guid isPermaLink="false">${tweet.id}</guid>
|
||||
#else:
|
||||
<guid>${urlPrefix & link}</guid>
|
||||
#end if
|
||||
<link>${urlPrefix & link}</link>
|
||||
</item>
|
||||
# end for
|
||||
#end for
|
||||
#end proc
|
||||
#
|
||||
#proc renderTimelineRss*(profile: Profile; cfg: Config; multi=false): string =
|
||||
#proc renderTimelineRss*(profile: Profile; cfg: Config; prefs: Prefs; multi=false): string =
|
||||
#let urlPrefix = getUrlPrefix(cfg)
|
||||
#result = ""
|
||||
#let handle = (if multi: "" else: "@") & profile.user.username
|
||||
@@ -101,14 +167,15 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
||||
<width>128</width>
|
||||
<height>128</height>
|
||||
</image>
|
||||
#if profile.tweets.content.len > 0:
|
||||
${renderRssTweets(profile.tweets.content, cfg)}
|
||||
#let tweetsList = getTweetsWithPinned(profile)
|
||||
#if tweetsList.len > 0:
|
||||
${renderRssTweets(tweetsList, cfg, prefs, userId=profile.user.id)}
|
||||
#end if
|
||||
</channel>
|
||||
</rss>
|
||||
#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}"
|
||||
#result = ""
|
||||
<?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>
|
||||
<language>en-us</language>
|
||||
<ttl>40</ttl>
|
||||
${renderRssTweets(tweets, cfg)}
|
||||
${renderRssTweets(tweets, cfg, prefs)}
|
||||
</channel>
|
||||
</rss>
|
||||
#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 escName = xmltree.escape(name)
|
||||
#result = ""
|
||||
@@ -138,7 +205,7 @@ ${renderRssTweets(tweets, cfg)}
|
||||
<description>${getDescription(&"Search \"{escName}\"", cfg)}</description>
|
||||
<language>en-us</language>
|
||||
<ttl>40</ttl>
|
||||
${renderRssTweets(tweets, cfg)}
|
||||
${renderRssTweets(tweets, cfg, prefs)}
|
||||
</channel>
|
||||
</rss>
|
||||
#end proc
|
||||
|
||||
+25
-10
@@ -10,23 +10,22 @@ const toggles = {
|
||||
"media": "Media",
|
||||
"videos": "Videos",
|
||||
"news": "News",
|
||||
"verified": "Verified",
|
||||
"native_video": "Native videos",
|
||||
"replies": "Replies",
|
||||
"links": "Links",
|
||||
"images": "Images",
|
||||
"safe": "Safe",
|
||||
"quote": "Quotes",
|
||||
"pro_video": "Pro videos"
|
||||
"spaces": "Spaces",
|
||||
"cashtags": "Cashtags"
|
||||
}.toOrderedTable
|
||||
|
||||
proc renderSearch*(): VNode =
|
||||
buildHtml(tdiv(class="panel-container")):
|
||||
tdiv(class="search-bar"):
|
||||
form(`method`="get", action="/search", autocomplete="off"):
|
||||
hiddenField("f", "users")
|
||||
hiddenField("f", "tweets")
|
||||
input(`type`="text", name="q", autofocus="",
|
||||
placeholder="Enter username...", dir="auto")
|
||||
placeholder="Search...", dir="auto")
|
||||
button(`type`="submit"): icon "search"
|
||||
|
||||
proc renderProfileTabs*(query: Query; username: string): VNode =
|
||||
@@ -41,6 +40,19 @@ proc renderProfileTabs*(query: Query; username: string): VNode =
|
||||
li(class=query.getTabClass(tweets)):
|
||||
a(href=(link & "/search")): text "Search"
|
||||
|
||||
proc renderMediaViewTabs*(query: Query; username: string): VNode =
|
||||
let currentView = if query.view.len > 0: query.view else: "timeline"
|
||||
let base = "/" & username & "/media?view="
|
||||
func cls(view: string): string =
|
||||
if currentView == view: "tab-item active" else: "tab-item"
|
||||
buildHtml(ul(class="tab media-view-tabs")):
|
||||
li(class=cls("timeline")):
|
||||
a(href=(base & "timeline")): text "Timeline"
|
||||
li(class=cls("grid")):
|
||||
a(href=(base & "grid")): text "Grid"
|
||||
li(class=cls("gallery")):
|
||||
a(href=(base & "gallery")): text "Gallery"
|
||||
|
||||
proc renderSearchTabs*(query: Query): VNode =
|
||||
var q = query
|
||||
buildHtml(ul(class="tab")):
|
||||
@@ -53,7 +65,7 @@ proc renderSearchTabs*(query: Query): VNode =
|
||||
|
||||
proc isPanelOpen(q: Query): bool =
|
||||
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or
|
||||
@[q.near, q.until, q.since].anyIt(it.len > 0))
|
||||
@[q.minLikes, q.until, q.since].anyIt(it.len > 0))
|
||||
|
||||
proc renderSearchPanel*(query: Query): VNode =
|
||||
let user = query.fromUser.join(",")
|
||||
@@ -85,10 +97,10 @@ proc renderSearchPanel*(query: Query): VNode =
|
||||
span(class="search-title"): text "-"
|
||||
genDate("until", query.until)
|
||||
tdiv:
|
||||
span(class="search-title"): text "Near"
|
||||
genInput("near", "", query.near, "Location...", autofocus=false)
|
||||
span(class="search-title"): text "Minimum likes"
|
||||
genNumberInput("min_faves", "", query.minLikes, "Number...", autofocus=false)
|
||||
|
||||
proc renderTweetSearch*(results: Result[Tweet]; prefs: Prefs; path: string;
|
||||
proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
|
||||
pinned=none(Tweet)): VNode =
|
||||
let query = results.query
|
||||
buildHtml(tdiv(class="timeline-container")):
|
||||
@@ -97,7 +109,10 @@ proc renderTweetSearch*(results: Result[Tweet]; prefs: Prefs; path: string;
|
||||
text query.fromUser.join(" | ")
|
||||
|
||||
if query.fromUser.len > 0:
|
||||
renderProfileTabs(query, query.fromUser.join(","))
|
||||
if query.kind != media or query.view != "gallery":
|
||||
renderProfileTabs(query, query.fromUser.join(","))
|
||||
if query.kind == media and query.fromUser.len == 1:
|
||||
renderMediaViewTabs(query, query.fromUser[0])
|
||||
|
||||
if query.fromUser.len == 0 or query.kind == tweets:
|
||||
tdiv(class="timeline-header"):
|
||||
|
||||
+23
-4
@@ -28,14 +28,19 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode =
|
||||
if thread.hasMore:
|
||||
renderMoreReplies(thread)
|
||||
|
||||
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode =
|
||||
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: Tweet = nil): VNode =
|
||||
buildHtml(tdiv(class="replies", id="r")):
|
||||
var hasReplies = false
|
||||
var replyCount = 0
|
||||
for thread in replies.content:
|
||||
if thread.content.len == 0: continue
|
||||
hasReplies = true
|
||||
replyCount += thread.content.len
|
||||
renderReplyThread(thread, prefs, path)
|
||||
|
||||
if replies.bottom.len > 0:
|
||||
renderMore(Query(), replies.bottom, focus="#r")
|
||||
if hasReplies and replies.bottom.len > 0:
|
||||
if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies:
|
||||
renderMore(Query(), replies.bottom, focus="#r")
|
||||
|
||||
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode =
|
||||
let hasAfter = conv.after.content.len > 0
|
||||
@@ -70,6 +75,20 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
|
||||
if not conv.replies.beginning:
|
||||
renderNewer(Query(), getLink(conv.tweet), focus="#r")
|
||||
if conv.replies.content.len > 0 or conv.replies.bottom.len > 0:
|
||||
renderReplies(conv.replies, prefs, path)
|
||||
renderReplies(conv.replies, prefs, path, conv.tweet)
|
||||
|
||||
renderToTop(focus="#m")
|
||||
|
||||
proc renderEditHistory*(edits: EditHistory; prefs: Prefs; path: string): VNode =
|
||||
buildHtml(tdiv(class="edit-history")):
|
||||
tdiv(class="latest-edit"):
|
||||
tdiv(class="edit-history-header"):
|
||||
text "Latest post"
|
||||
renderTweet(edits.latest, prefs, path)
|
||||
|
||||
tdiv(class="previous-edits"):
|
||||
tdiv(class="edit-history-header"):
|
||||
text "Version history"
|
||||
for tweet in edits.history:
|
||||
tdiv(class="tweet-edit"):
|
||||
renderTweet(tweet, prefs, path)
|
||||
|
||||
+71
-36
@@ -1,16 +1,42 @@
|
||||
# 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 ".."/[types, query, formatters]
|
||||
import tweet, renderutils
|
||||
|
||||
proc timelineViewClass(query: Query): string =
|
||||
if query.kind != media:
|
||||
return "timeline"
|
||||
|
||||
case query.view
|
||||
of "grid": "timeline media-grid-view"
|
||||
of "gallery": "timeline media-gallery-view"
|
||||
else: "timeline"
|
||||
|
||||
proc getQuery(query: Query): string =
|
||||
if query.kind != posts:
|
||||
result = genQueryUrl(query)
|
||||
if result.len > 0:
|
||||
result &= "&"
|
||||
|
||||
proc getSearchMaxId(results: Timeline; path: string): string =
|
||||
if results.query.kind != tweets or results.content.len == 0 or
|
||||
results.query.until.len == 0:
|
||||
return
|
||||
|
||||
let lastThread = results.content[^1]
|
||||
if lastThread.len == 0 or lastThread[^1].id == 0:
|
||||
return
|
||||
|
||||
# 2000000 is the minimum decrement to guarantee no result overlap
|
||||
var maxId = lastThread[^1].id - 2_000_000'i64
|
||||
if maxId <= 0:
|
||||
maxId = lastThread[^1].id - 1
|
||||
|
||||
if maxId > 0:
|
||||
return "maxid:" & $maxId
|
||||
|
||||
proc renderToTop*(focus="#"): VNode =
|
||||
buildHtml(tdiv(class="top-ref")):
|
||||
icon "down", href=focus
|
||||
@@ -39,26 +65,24 @@ proc renderNoneFound(): VNode =
|
||||
h2(class="timeline-none"):
|
||||
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")):
|
||||
let sortedThread = thread.sortedByIt(it.id)
|
||||
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 header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: ""
|
||||
renderTweet(tweet, prefs, path, class=(header & "thread"),
|
||||
index=i, last=(i == thread.high), showThread=show)
|
||||
|
||||
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
|
||||
index=i, last=(i == thread.high), bigThumb=bigThumb)
|
||||
|
||||
proc renderUser(user: User; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv(class="timeline-item")):
|
||||
buildHtml(tdiv(class="timeline-item", data-username=user.username)):
|
||||
a(class="tweet-link", href=("/" & user.username))
|
||||
tdiv(class="tweet-body profile-result"):
|
||||
tdiv(class="tweet-header"):
|
||||
@@ -68,6 +92,7 @@ proc renderUser(user: User; prefs: Prefs): VNode =
|
||||
tdiv(class="tweet-name-row"):
|
||||
tdiv(class="fullname-and-username"):
|
||||
linkUser(user, class="fullname")
|
||||
verifiedIcon(user)
|
||||
linkUser(user, class="username")
|
||||
|
||||
tdiv(class="tweet-content media-body", dir="auto"):
|
||||
@@ -89,15 +114,28 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode =
|
||||
else:
|
||||
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 =
|
||||
buildHtml(tdiv(class="timeline")):
|
||||
buildHtml(tdiv(class=results.query.timelineViewClass)):
|
||||
if not results.beginning:
|
||||
renderNewer(results.query, parseUri(path).path)
|
||||
|
||||
if not prefs.hidePins and pinned.isSome:
|
||||
let tweet = get pinned
|
||||
renderTweet(tweet, prefs, path, showThread=tweet.hasThread)
|
||||
renderTweet(tweet, prefs, path)
|
||||
|
||||
if results.content.len == 0:
|
||||
if not results.beginning:
|
||||
@@ -105,26 +143,23 @@ proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string;
|
||||
else:
|
||||
renderNoneFound()
|
||||
else:
|
||||
var
|
||||
threads: seq[int64]
|
||||
retweets: seq[int64]
|
||||
let filtered = filterThreads(results.content, prefs)
|
||||
|
||||
for tweet in results.content:
|
||||
let rt = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
|
||||
if results.query.view == "gallery":
|
||||
let bigThumb = prefs.gallerySize == "Large"
|
||||
let galClass = if prefs.compactGallery: "gallery-masonry compact" else: "gallery-masonry"
|
||||
tdiv(class=galClass, `data-col-size`=prefs.gallerySize.toLowerAscii):
|
||||
for thread in filtered:
|
||||
if thread.len == 1: renderTweet(thread[0], prefs, path, bigThumb=bigThumb)
|
||||
else: renderThread(thread, prefs, path, bigThumb)
|
||||
else:
|
||||
for thread in filtered:
|
||||
if thread.len == 1: renderTweet(thread[0], prefs, path)
|
||||
else: renderThread(thread, prefs, path)
|
||||
|
||||
if tweet.id in threads or rt in retweets or tweet.id in retweets or
|
||||
tweet.pinned and prefs.hidePins: continue
|
||||
|
||||
let thread = results.content.threadFilter(threads, tweet)
|
||||
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:
|
||||
renderThread(thread, prefs, path)
|
||||
threads &= thread.mapIt(it.id)
|
||||
|
||||
renderMore(results.query, results.bottom)
|
||||
var cursor = getSearchMaxId(results, path)
|
||||
if cursor.len > 0:
|
||||
renderMore(results.query, cursor)
|
||||
elif results.bottom.len > 0:
|
||||
renderMore(results.query, results.bottom)
|
||||
renderToTop()
|
||||
|
||||
+179
-101
@@ -10,19 +10,16 @@ import general
|
||||
const doctype = "<!DOCTYPE html>\n"
|
||||
|
||||
proc renderMiniAvatar(user: User; prefs: Prefs): VNode =
|
||||
let url = getPicUrl(user.getUserPic("_mini"))
|
||||
buildHtml():
|
||||
img(class=(prefs.getAvatarClass & " mini"), src=url)
|
||||
genImg(user.getUserPic("_mini"), class=(prefs.getAvatarClass & " mini"))
|
||||
|
||||
proc renderHeader(tweet: Tweet; retweet: string; prefs: Prefs): VNode =
|
||||
proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv):
|
||||
if retweet.len > 0:
|
||||
tdiv(class="retweet-header"):
|
||||
span: icon "retweet", retweet & " retweeted"
|
||||
|
||||
if tweet.pinned:
|
||||
if pinned:
|
||||
tdiv(class="pinned"):
|
||||
span: icon "pin", "Pinned Tweet"
|
||||
elif retweet.len > 0:
|
||||
tdiv(class="retweet-header"):
|
||||
span: icon "retweet", retweet & " retweeted"
|
||||
|
||||
tdiv(class="tweet-header"):
|
||||
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="fullname-and-username"):
|
||||
linkUser(tweet.user, class="fullname")
|
||||
verifiedIcon(tweet.user)
|
||||
linkUser(tweet.user, class="username")
|
||||
|
||||
span(class="tweet-date"):
|
||||
a(href=getLink(tweet), title=tweet.getTime):
|
||||
text tweet.getShortTime
|
||||
|
||||
proc renderAlbum(tweet: Tweet): VNode =
|
||||
let
|
||||
groups = if tweet.photos.len < 3: @[tweet.photos]
|
||||
else: tweet.photos.distribute(2)
|
||||
proc renderAltText(altText: string): VNode =
|
||||
buildHtml(p(class="alt-text")):
|
||||
text "ALT " & altText
|
||||
|
||||
buildHtml(tdiv(class="attachments")):
|
||||
for i, photos in groups:
|
||||
let margin = if i > 0: ".25em" else: ""
|
||||
tdiv(class="gallery-row", style={marginTop: margin}):
|
||||
for photo in photos:
|
||||
tdiv(class="attachment image"):
|
||||
let
|
||||
named = "name=" in photo
|
||||
small = if named: photo else: photo & smallWebp
|
||||
a(href=getOrigPicUrl(photo), class="still-image", target="_blank"):
|
||||
genImg(small)
|
||||
proc renderPhotoAttachment(photo: Photo; bigThumb=false): VNode =
|
||||
buildHtml(tdiv(class="attachment")):
|
||||
let
|
||||
named = "name=" in photo.url
|
||||
thumb = if named: photo.url
|
||||
elif bigThumb: photo.url & mediumWebp
|
||||
else: photo.url & smallWebp
|
||||
a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"):
|
||||
genImg(thumb, alt=photo.altText)
|
||||
if photo.altText.len > 0:
|
||||
renderAltText(photo.altText)
|
||||
|
||||
proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
|
||||
case playbackType
|
||||
@@ -65,7 +62,7 @@ proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
|
||||
proc hasMp4Url(video: Video): bool =
|
||||
video.variants.anyIt(it.contentType == mp4)
|
||||
|
||||
proc renderVideoDisabled(playbackType: VideoType; path: string): VNode =
|
||||
proc renderVideoDisabled(playbackType: VideoType; path=""): VNode =
|
||||
buildHtml(tdiv(class="video-overlay")):
|
||||
case playbackType
|
||||
of mp4:
|
||||
@@ -81,51 +78,98 @@ proc renderVideoUnavailable(video: Video): VNode =
|
||||
else:
|
||||
p: text "This media is unavailable"
|
||||
|
||||
proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode =
|
||||
proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""; bigThumb=false): VNode =
|
||||
let
|
||||
container = if video.description.len == 0 and video.title.len == 0: ""
|
||||
else: " card-container"
|
||||
playbackType = if not prefs.proxyVideos and video.hasMp4Url: mp4
|
||||
else: video.playbackType
|
||||
playbackType = if not prefs.proxyVideos and videoData.hasMp4Url: mp4
|
||||
else: videoData.playbackType
|
||||
thumb = if bigThumb: getMediumPic(videoData.thumb) else: getSmallPic(videoData.thumb)
|
||||
|
||||
buildHtml(tdiv(class="attachment")):
|
||||
if not videoData.available:
|
||||
img(src=thumb, loading="lazy")
|
||||
renderVideoUnavailable(videoData)
|
||||
elif not prefs.isPlaybackEnabled(playbackType):
|
||||
img(src=thumb, loading="lazy")
|
||||
renderVideoDisabled(playbackType, path)
|
||||
else:
|
||||
let
|
||||
vars = videoData.variants.filterIt(it.contentType == playbackType)
|
||||
vidUrl = vars.sortedByIt(it.resolution)[^1].url
|
||||
source = if prefs.proxyVideos and vidUrl.startsWith("http"):
|
||||
getVidUrl(vidUrl) else: vidUrl
|
||||
case playbackType
|
||||
of mp4:
|
||||
video(poster=thumb, controls="", muted=prefs.muteVideos):
|
||||
source(src=source, `type`="video/mp4")
|
||||
of m3u8, vmap:
|
||||
video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos)
|
||||
verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
|
||||
tdiv(class="overlay-circle"): span(class="overlay-triangle")
|
||||
if videoData.durationMs > 0:
|
||||
tdiv(class="overlay-duration"): text getDuration(videoData)
|
||||
verbatim "</div>"
|
||||
|
||||
proc renderVideo*(video: Video; prefs: Prefs; path: string; bigThumb=false): VNode =
|
||||
let hasCardContent = video.description.len > 0 or video.title.len > 0
|
||||
|
||||
buildHtml(tdiv(class="attachments card")):
|
||||
tdiv(class="gallery-video" & container):
|
||||
tdiv(class="attachment video-container"):
|
||||
let thumb = getSmallPic(video.thumb)
|
||||
if not video.available:
|
||||
img(src=thumb)
|
||||
renderVideoUnavailable(video)
|
||||
elif not prefs.isPlaybackEnabled(playbackType):
|
||||
img(src=thumb)
|
||||
renderVideoDisabled(playbackType, path)
|
||||
else:
|
||||
let
|
||||
vars = video.variants.filterIt(it.contentType == playbackType)
|
||||
vidUrl = vars.sortedByIt(it.resolution)[^1].url
|
||||
source = if prefs.proxyVideos: getVidUrl(vidUrl)
|
||||
else: vidUrl
|
||||
case playbackType
|
||||
of mp4:
|
||||
video(poster=thumb, controls="", muted=prefs.muteVideos):
|
||||
source(src=source, `type`="video/mp4")
|
||||
of m3u8, vmap:
|
||||
video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos)
|
||||
verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
|
||||
tdiv(class="overlay-circle"): span(class="overlay-triangle")
|
||||
verbatim "</div>"
|
||||
if container.len > 0:
|
||||
tdiv(class=("gallery-video" & (if hasCardContent: " card-container" else: ""))):
|
||||
renderVideoAttachment(video, prefs, path, bigThumb)
|
||||
if hasCardContent:
|
||||
tdiv(class="card-content"):
|
||||
h2(class="card-title"): text video.title
|
||||
if video.description.len > 0:
|
||||
p(class="card-description"): text video.description
|
||||
|
||||
proc renderGifAttachment(gif: Gif; prefs: Prefs): VNode =
|
||||
let thumb = getSmallPic(gif.thumb)
|
||||
|
||||
buildHtml(tdiv(class="attachment")):
|
||||
if not prefs.mp4Playback:
|
||||
img(src=thumb, loading="lazy")
|
||||
renderVideoDisabled(mp4)
|
||||
elif prefs.autoplayGifs:
|
||||
video(class="gif", poster=thumb, autoplay="", muted="", loop=""):
|
||||
source(src=getPicUrl(gif.url), `type`="video/mp4")
|
||||
else:
|
||||
video(class="gif", poster=thumb, controls="", muted="", loop=""):
|
||||
source(src=getPicUrl(gif.url), `type`="video/mp4")
|
||||
if gif.altText.len > 0:
|
||||
renderAltText(gif.altText)
|
||||
|
||||
proc renderGif(gif: Gif; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv(class="attachments media-gif")):
|
||||
tdiv(class="gallery-gif", style={maxHeight: "unset"}):
|
||||
tdiv(class="attachment"):
|
||||
video(class="gif", poster=getSmallPic(gif.thumb), autoplay=prefs.autoplayGifs,
|
||||
controls="", muted="", loop=""):
|
||||
source(src=getPicUrl(gif.url), `type`="video/mp4")
|
||||
renderGifAttachment(gif, prefs)
|
||||
|
||||
proc renderMedia(media: seq[Media]; prefs: Prefs; path: string; bigThumb=false): VNode =
|
||||
if media.len == 0:
|
||||
return nil
|
||||
|
||||
if media.len == 1:
|
||||
let item = media[0]
|
||||
if item.kind == videoMedia:
|
||||
return renderVideo(item.video, prefs, path, bigThumb)
|
||||
if item.kind == gifMedia:
|
||||
return renderGif(item.gif, prefs)
|
||||
|
||||
let
|
||||
groups = if media.len < 3: @[media]
|
||||
else: media.distribute(2)
|
||||
|
||||
buildHtml(tdiv(class="attachments")):
|
||||
for i, mediaGroup in groups:
|
||||
let margin = if i > 0: ".25em" else: ""
|
||||
let rowClass = "gallery-row" &
|
||||
(if mediaGroup.allIt(it.kind == photoMedia): "" else: " mixed-row")
|
||||
tdiv(class=rowClass, style={marginTop: margin}):
|
||||
for mediaItem in mediaGroup:
|
||||
case mediaItem.kind
|
||||
of photoMedia:
|
||||
renderPhotoAttachment(mediaItem.photo, bigThumb)
|
||||
of videoMedia:
|
||||
renderVideoAttachment(mediaItem.video, prefs, path, bigThumb)
|
||||
of gifMedia:
|
||||
renderGifAttachment(mediaItem.gif, prefs)
|
||||
|
||||
proc renderPoll(poll: Poll): VNode =
|
||||
buildHtml(tdiv(class="poll")):
|
||||
@@ -145,7 +189,7 @@ proc renderPoll(poll: Poll): VNode =
|
||||
proc renderCardImage(card: Card): VNode =
|
||||
buildHtml(tdiv(class="card-image-container")):
|
||||
tdiv(class="card-image"):
|
||||
img(src=getPicUrl(card.image), alt="")
|
||||
genImg(card.image)
|
||||
if card.kind == player:
|
||||
tdiv(class="card-overlay"):
|
||||
tdiv(class="overlay-circle"):
|
||||
@@ -181,14 +225,12 @@ func formatStat(stat: int): string =
|
||||
if stat > 0: insertSep($stat, ',')
|
||||
else: ""
|
||||
|
||||
proc renderStats(stats: TweetStats; views: string): VNode =
|
||||
proc renderStats(stats: TweetStats): VNode =
|
||||
buildHtml(tdiv(class="tweet-stats")):
|
||||
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
|
||||
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)
|
||||
if views.len > 0:
|
||||
span(class="tweet-stat"): icon "play", insertSep(views, ',')
|
||||
span(class="tweet-stat"): icon "views", formatStat(stats.views)
|
||||
|
||||
proc renderReply(tweet: Tweet): VNode =
|
||||
buildHtml(tdiv(class="replying-to")):
|
||||
@@ -197,12 +239,12 @@ proc renderReply(tweet: Tweet): VNode =
|
||||
if i > 0: text " "
|
||||
a(href=("/" & u)): text "@" & u
|
||||
|
||||
proc renderAttribution(user: User; prefs: Prefs): VNode =
|
||||
buildHtml(a(class="attribution", href=("/" & user.username))):
|
||||
proc renderAttribution(user: User; prefs: Prefs; link = ""): VNode =
|
||||
let href = if link.len > 0: link else: "/" & user.username
|
||||
buildHtml(a(class="attribution", href=href)):
|
||||
renderMiniAvatar(user, prefs)
|
||||
strong: text user.fullname
|
||||
if user.verified:
|
||||
icon "ok", class="verified-icon", title="Verified account"
|
||||
verifiedIcon(user)
|
||||
|
||||
proc renderMediaTags(tags: seq[User]): VNode =
|
||||
buildHtml(tdiv(class="media-tag-block")):
|
||||
@@ -213,19 +255,28 @@ proc renderMediaTags(tags: seq[User]): VNode =
|
||||
if i < tags.high:
|
||||
text ", "
|
||||
|
||||
proc renderLatestPost(username: string; id: int64): VNode =
|
||||
buildHtml(tdiv(class="latest-post-version")):
|
||||
text "There's a new version of this post. "
|
||||
a(href=getLink(id, username)):
|
||||
text "See the latest post"
|
||||
|
||||
proc renderCommunityNote(note: string; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv(class="community-note")):
|
||||
tdiv(class="community-note-header"):
|
||||
icon "group"
|
||||
span: text "Community note"
|
||||
tdiv(class="community-note-text", dir="auto"):
|
||||
verbatim replaceUrls(note, prefs)
|
||||
|
||||
proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode =
|
||||
buildHtml(tdiv(class="quote-media-container")):
|
||||
if quote.photos.len > 0:
|
||||
renderAlbum(quote)
|
||||
elif quote.video.isSome:
|
||||
renderVideo(quote.video.get(), prefs, path)
|
||||
elif quote.gif.isSome:
|
||||
renderGif(quote.gif.get(), prefs)
|
||||
renderMedia(quote.media, prefs, path)
|
||||
|
||||
proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
|
||||
if not quote.available:
|
||||
return buildHtml(tdiv(class="quote unavailable")):
|
||||
tdiv(class="unavailable-quote"):
|
||||
a(class="unavailable-quote", href=getLink(quote, focus=false)):
|
||||
if quote.tombstone.len > 0:
|
||||
text quote.tombstone
|
||||
elif quote.text.len > 0:
|
||||
@@ -240,6 +291,7 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
|
||||
tdiv(class="fullname-and-username"):
|
||||
renderMiniAvatar(quote.user, prefs)
|
||||
linkUser(quote.user, class="fullname")
|
||||
verifiedIcon(quote.user)
|
||||
linkUser(quote.user, class="username")
|
||||
|
||||
span(class="tweet-date"):
|
||||
@@ -253,12 +305,28 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
|
||||
tdiv(class="quote-text", dir="auto"):
|
||||
verbatim replaceUrls(quote.text, prefs)
|
||||
|
||||
if quote.media.len > 0:
|
||||
renderQuoteMedia(quote, prefs, path)
|
||||
|
||||
if quote.note.len > 0 and not prefs.hideCommunityNotes:
|
||||
renderCommunityNote(quote.note, prefs)
|
||||
|
||||
if quote.hasThread:
|
||||
a(class="show-thread", href=getLink(quote)):
|
||||
text "Show this thread"
|
||||
|
||||
if quote.photos.len > 0 or quote.video.isSome or quote.gif.isSome:
|
||||
renderQuoteMedia(quote, prefs, path)
|
||||
if quote.history.len > 0 and quote.id != max(quote.history):
|
||||
tdiv(class="quote-latest"):
|
||||
text "There's a new version of this post"
|
||||
|
||||
proc renderDisclosures*(tweet: Tweet): VNode =
|
||||
buildHtml(tdiv(class="disclosures")):
|
||||
if tweet.isAI:
|
||||
span(data-disclosure="ai"):
|
||||
icon "attention", "Made with AI"
|
||||
if tweet.isAd:
|
||||
span(data-disclosure="ad"):
|
||||
icon "attention", "Paid partnership (ad)"
|
||||
|
||||
proc renderLocation*(tweet: Tweet): string =
|
||||
let (place, url) = tweet.getLocation()
|
||||
@@ -272,14 +340,14 @@ proc renderLocation*(tweet: Tweet): string =
|
||||
return $node
|
||||
|
||||
proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||
last=false; showThread=false; mainTweet=false; afterTweet=false): VNode =
|
||||
last=false; mainTweet=false; afterTweet=false; bigThumb=false): VNode =
|
||||
var divClass = class
|
||||
if index == -1 or last:
|
||||
divClass = "thread-last " & class
|
||||
|
||||
if not tweet.available:
|
||||
return buildHtml(tdiv(class=divClass & "unavailable timeline-item")):
|
||||
tdiv(class="unavailable-box"):
|
||||
return buildHtml(tdiv(class=divClass & "unavailable timeline-item", data-username=tweet.user.username)):
|
||||
a(class="unavailable-box", href=getLink(tweet)):
|
||||
if tweet.tombstone.len > 0:
|
||||
text tweet.tombstone
|
||||
elif tweet.text.len > 0:
|
||||
@@ -290,23 +358,25 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||
if tweet.quote.isSome:
|
||||
renderQuote(tweet.quote.get(), prefs, path)
|
||||
|
||||
let fullTweet = tweet
|
||||
let
|
||||
fullTweet = tweet
|
||||
pinned = tweet.pinned
|
||||
|
||||
var retweet: string
|
||||
var tweet = fullTweet
|
||||
if tweet.retweet.isSome:
|
||||
tweet = tweet.retweet.get
|
||||
retweet = fullTweet.user.fullname
|
||||
|
||||
buildHtml(tdiv(class=("timeline-item " & divClass))):
|
||||
buildHtml(tdiv(class=("timeline-item " & divClass), data-username=tweet.user.username)):
|
||||
if not mainTweet:
|
||||
a(class="tweet-link", href=getLink(tweet))
|
||||
|
||||
tdiv(class="tweet-body"):
|
||||
var views = ""
|
||||
renderHeader(tweet, retweet, prefs)
|
||||
renderHeader(tweet, retweet, pinned, prefs)
|
||||
|
||||
if not afterTweet and index == 0 and tweet.reply.len > 0 and
|
||||
(tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username):
|
||||
(tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username or pinned):
|
||||
renderReply(tweet)
|
||||
|
||||
var tweetClass = "tweet-content media-body"
|
||||
@@ -317,19 +387,13 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||
verbatim replaceUrls(tweet.text, prefs) & renderLocation(tweet)
|
||||
|
||||
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:
|
||||
renderCard(tweet.card.get(), prefs, path)
|
||||
|
||||
if tweet.photos.len > 0:
|
||||
renderAlbum(tweet)
|
||||
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.media.len > 0:
|
||||
renderMedia(tweet.media, prefs, path, bigThumb)
|
||||
|
||||
if tweet.poll.isSome:
|
||||
renderPoll(tweet.poll.get())
|
||||
@@ -337,18 +401,32 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||
if tweet.quote.isSome:
|
||||
renderQuote(tweet.quote.get(), prefs, path)
|
||||
|
||||
if tweet.note.len > 0 and not prefs.hideCommunityNotes:
|
||||
renderCommunityNote(tweet.note, prefs)
|
||||
|
||||
if tweet.isAI or tweet.isAd:
|
||||
renderDisclosures(tweet)
|
||||
|
||||
let
|
||||
hasEdits = tweet.history.len > 1
|
||||
isLatest = hasEdits and tweet.id == max(tweet.history)
|
||||
|
||||
if mainTweet:
|
||||
p(class="tweet-published"): text &"{getTime(tweet)}"
|
||||
p(class="tweet-published"):
|
||||
if hasEdits and isLatest:
|
||||
a(href=(getLink(tweet, focus=false) & "/history")):
|
||||
text &"Last edited {getTime(tweet)}"
|
||||
else:
|
||||
text &"{getTime(tweet)}"
|
||||
|
||||
if hasEdits and not isLatest:
|
||||
renderLatestPost(tweet.user.username, max(tweet.history))
|
||||
|
||||
if tweet.mediaTags.len > 0:
|
||||
renderMediaTags(tweet.mediaTags)
|
||||
|
||||
if not prefs.hideTweetStats:
|
||||
renderStats(tweet.stats, views)
|
||||
|
||||
if showThread:
|
||||
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
|
||||
text "Show this thread"
|
||||
renderStats(tweet.stats)
|
||||
|
||||
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
|
||||
let node = buildHtml(html(lang="en")):
|
||||
|
||||
+8
-1
@@ -54,6 +54,13 @@ class Timeline(object):
|
||||
none = '.timeline-none'
|
||||
protected = '.timeline-protected'
|
||||
photo_rail = '.photo-rail-grid'
|
||||
media_view_tabs = '.media-view-tabs'
|
||||
media_view_timeline = '.media-view-tabs a[href$="media?view=timeline"]'
|
||||
media_view_grid = '.media-view-tabs a[href$="media?view=grid"]'
|
||||
media_view_gallery = '.media-view-tabs a[href$="media?view=gallery"]'
|
||||
media_view_active = '.media-view-tabs .tab-item.active a'
|
||||
grid_view = '.timeline.media-grid-view'
|
||||
gallery_view = '.timeline.media-gallery-view'
|
||||
|
||||
|
||||
class Conversation(object):
|
||||
@@ -79,7 +86,7 @@ class Media(object):
|
||||
row = '.gallery-row'
|
||||
image = '.still-image'
|
||||
video = '.gallery-video'
|
||||
gif = '.gallery-gif'
|
||||
gif = '.media-gif'
|
||||
|
||||
|
||||
class BaseTestCase(BaseCase):
|
||||
|
||||
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