mirror of
https://github.com/zedeus/nitter.git
synced 2025-12-06 03:55:36 -05:00
Compare commits
292 Commits
tweets-par
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
e3b3b38a2d | ||
|
|
b516ec367b | ||
|
|
774063fb5a | ||
|
|
2254a0728c | ||
|
|
fe22a45c7e | ||
|
|
b67f3062fe | ||
|
|
a64e2286d5 | ||
|
|
1ac389e7c7 | ||
|
|
e2560dc1f1 | ||
|
|
95893eedaa | ||
|
|
78cb405acd | ||
|
|
23f4c6114c | ||
|
|
e93de59118 | ||
|
|
bc219aa792 | ||
|
|
7edc5646fe | ||
|
|
ad9083149c | ||
|
|
56420a28bd | ||
|
|
725f601c9b | ||
|
|
aea884c48e | ||
|
|
368974c803 | ||
|
|
3a5faded86 | ||
|
|
756a39bdd2 | ||
|
|
33e34c2d17 | ||
|
|
514b16c417 | ||
|
|
f4c20a48a0 | ||
|
|
c21511037e | ||
|
|
54cc790bdb | ||
|
|
2751504d2f | ||
|
|
85f14c9a64 | ||
|
|
670a3bca6e | ||
|
|
36c72f9860 | ||
|
|
19adc658c3 | ||
|
|
22b51b414b | ||
|
|
ff61d97a1d | ||
|
|
d38b63f5a9 | ||
|
|
cd163b26a3 | ||
|
|
88c6135030 | ||
|
|
6c4c691fe7 | ||
|
|
b5dee25b37 | ||
|
|
d549a77ebe | ||
|
|
687447cea4 | ||
|
|
d923c20aa6 | ||
|
|
9bae009bbc | ||
|
|
da4d6eabdd | ||
|
|
6a7e56b613 | ||
|
|
78b6877b02 | ||
|
|
096d44856f | ||
|
|
310b0ab9a1 | ||
|
|
85316f8f8d | ||
|
|
92a6eb0339 | ||
|
|
b1ef505cff | ||
|
|
9ef0f649fd | ||
|
|
8ad8cef875 | ||
|
|
dd18471568 | ||
|
|
02557b67c3 | ||
|
|
6272297d64 | ||
|
|
2ac3afa5b2 | ||
|
|
1cf8e67567 | ||
|
|
8edfda39ec | ||
|
|
077ebc95fa | ||
|
|
5f31e86e0e | ||
|
|
17cb977e70 | ||
|
|
0200cc21a3 | ||
|
|
81ec41328d | ||
|
|
5e827c91aa | ||
|
|
b398136742 | ||
|
|
74c13b372d | ||
|
|
5cceca4e93 | ||
|
|
d407051b66 | ||
|
|
38bbc67757 | ||
|
|
adaa94d998 | ||
|
|
138826fb4f | ||
|
|
dfb2519870 | ||
|
|
c543a1df8c | ||
|
|
778c6c64cb | ||
|
|
21e8f04fa4 | ||
|
|
6709f6f1b5 | ||
|
|
2a5541c126 | ||
|
|
93f605f4fe | ||
|
|
6c83e87292 | ||
|
|
90135d44e1 | ||
|
|
e2b8e17f85 | ||
|
|
0633ec2c39 | ||
|
|
683c052036 | ||
|
|
a98cf9245a | ||
|
|
3710a4ddec | ||
|
|
3526318482 | ||
|
|
6116a44d83 | ||
|
|
3e7898ecb5 | ||
|
|
a21a33f835 | ||
|
|
b5262dc010 | ||
|
|
2536370cd7 | ||
|
|
6884f05041 | ||
|
|
2b20b24abf | ||
|
|
731f814d37 | ||
|
|
2dc91f8453 | ||
|
|
9b97ddc8d8 | ||
|
|
36a24cf553 | ||
|
|
ce5cbaeb87 | ||
|
|
baf5bf89e7 | ||
|
|
8f65bd583d | ||
|
|
b031ea762e | ||
|
|
ecb6fe4162 | ||
|
|
e789fdbe5a | ||
|
|
716e869594 | ||
|
|
c7a78a7f43 | ||
|
|
3590ded482 | ||
|
|
139b7edebc | ||
|
|
2c97cb0763 | ||
|
|
67c937a3a2 | ||
|
|
7ca3591920 | ||
|
|
6695784050 | ||
|
|
a7f585feaa | ||
|
|
3e2756df6f | ||
|
|
b01b5eb397 | ||
|
|
6e4620070e | ||
|
|
644fe41a08 | ||
|
|
4cdb8f78cb | ||
|
|
adfd31c530 | ||
|
|
8b2fa986b5 | ||
|
|
e01f3960cb |
49
.github/workflows/build-docker.yml
vendored
49
.github/workflows/build-docker.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: CI/CD
|
name: Docker
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -8,31 +8,56 @@ on:
|
|||||||
- master
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-docker:
|
tests:
|
||||||
runs-on: ubuntu-latest
|
uses: ./.github/workflows/run-tests.yml
|
||||||
|
secrets: inherit
|
||||||
|
build-docker-amd64:
|
||||||
|
needs: [tests]
|
||||||
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v1
|
|
||||||
with:
|
|
||||||
platforms: all
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v2
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Build and push
|
- name: Build and push AMD64 Docker image
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }}
|
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
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
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
|
||||||
|
|||||||
108
.github/workflows/run-tests.yml
vendored
Normal file
108
.github/workflows/run-tests.yml
vendored
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths-ignore:
|
||||||
|
- "*.md"
|
||||||
|
branches-ignore:
|
||||||
|
- 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:
|
||||||
|
build-test:
|
||||||
|
name: Build and test
|
||||||
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
nim: ["2.0.x", "2.2.x", "devel"]
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Cache Nimble Dependencies
|
||||||
|
id: cache-nimble
|
||||||
|
uses: buildjet/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.nimble
|
||||||
|
key: ${{ matrix.nim }}-nimble-v2-${{ hashFiles('*.nimble') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ matrix.nim }}-nimble-v2-
|
||||||
|
|
||||||
|
- name: Setup Nim
|
||||||
|
uses: jiro4989/setup-nim-action@v2
|
||||||
|
with:
|
||||||
|
nim-version: ${{ matrix.nim }}
|
||||||
|
use-nightlies: true
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build Project
|
||||||
|
run: nimble build -d:release -Y
|
||||||
|
|
||||||
|
integration-test:
|
||||||
|
needs: [build-test]
|
||||||
|
name: Integration test
|
||||||
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Cache Nimble Dependencies
|
||||||
|
id: cache-nimble
|
||||||
|
uses: buildjet/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.nimble
|
||||||
|
key: devel-nimble-v2-${{ hashFiles('*.nimble') }}
|
||||||
|
restore-keys: |
|
||||||
|
devel-nimble-v2-
|
||||||
|
|
||||||
|
- name: Setup Python (3.10) with pip cache
|
||||||
|
uses: buildjet/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: "3.10"
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Setup Nim
|
||||||
|
uses: jiro4989/setup-nim-action@v2
|
||||||
|
with:
|
||||||
|
nim-version: devel
|
||||||
|
use-nightlies: true
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build Project
|
||||||
|
run: nimble build -d:release -Y
|
||||||
|
|
||||||
|
- name: Install SeleniumBase and Chromedriver
|
||||||
|
run: |
|
||||||
|
pip install seleniumbase
|
||||||
|
seleniumbase install chromedriver
|
||||||
|
|
||||||
|
- name: Start Redis Service
|
||||||
|
uses: supercharge/redis-github-action@1.5.0
|
||||||
|
|
||||||
|
- name: Prepare Nitter Environment
|
||||||
|
run: |
|
||||||
|
sudo apt-get update && sudo apt-get install -y libsass-dev
|
||||||
|
cp nitter.example.conf nitter.conf
|
||||||
|
sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf
|
||||||
|
nimble md
|
||||||
|
nimble scss
|
||||||
|
echo '${{ secrets.SESSIONS }}' | head -n1
|
||||||
|
echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl
|
||||||
|
|
||||||
|
- name: Run Tests
|
||||||
|
run: |
|
||||||
|
./nitter &
|
||||||
|
pytest -n1 tests
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -3,9 +3,13 @@ nitter
|
|||||||
*.db
|
*.db
|
||||||
/tests/__pycache__
|
/tests/__pycache__
|
||||||
/tests/geckodriver.log
|
/tests/geckodriver.log
|
||||||
/tests/downloaded_files/*
|
/tests/downloaded_files
|
||||||
|
/tests/latest_logs
|
||||||
/tools/gencss
|
/tools/gencss
|
||||||
/tools/rendermd
|
/tools/rendermd
|
||||||
/public/css/style.css
|
/public/css/style.css
|
||||||
/public/md/*.html
|
/public/md/*.html
|
||||||
nitter.conf
|
nitter.conf
|
||||||
|
guest_accounts.json*
|
||||||
|
sessions.json*
|
||||||
|
dump.rdb
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@@ -1,6 +1,5 @@
|
|||||||
FROM nimlang/nim:1.6.2-alpine-regular as nim
|
FROM nimlang/nim:2.2.0-alpine-regular as nim
|
||||||
LABEL maintainer="setenforce@protonmail.com"
|
LABEL maintainer="setenforce@protonmail.com"
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
RUN apk --no-cache add libsass-dev pcre
|
RUN apk --no-cache add libsass-dev pcre
|
||||||
|
|
||||||
@@ -10,14 +9,17 @@ COPY nitter.nimble .
|
|||||||
RUN nimble install -y --depsOnly
|
RUN nimble install -y --depsOnly
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN nimble build -d:danger -d:lto -d:strip \
|
RUN nimble build -d:danger -d:lto -d:strip --mm:refc \
|
||||||
&& nimble scss \
|
&& nimble scss \
|
||||||
&& nimble md
|
&& nimble md
|
||||||
|
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
WORKDIR /src/
|
WORKDIR /src/
|
||||||
RUN apk --no-cache add pcre
|
RUN apk --no-cache add pcre ca-certificates
|
||||||
COPY --from=nim /src/nitter/nitter ./
|
COPY --from=nim /src/nitter/nitter ./
|
||||||
COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf
|
COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf
|
||||||
COPY --from=nim /src/nitter/public ./public
|
COPY --from=nim /src/nitter/public ./public
|
||||||
|
EXPOSE 8080
|
||||||
|
RUN adduser -h /src/ -D -s /bin/sh nitter
|
||||||
|
USER nitter
|
||||||
CMD ./nitter
|
CMD ./nitter
|
||||||
|
|||||||
25
Dockerfile.arm64
Normal file
25
Dockerfile.arm64
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
FROM alpine:3.20.6 as nim
|
||||||
|
LABEL maintainer="setenforce@protonmail.com"
|
||||||
|
|
||||||
|
RUN apk --no-cache add libsass-dev pcre gcc git libc-dev nim nimble
|
||||||
|
|
||||||
|
WORKDIR /src/nitter
|
||||||
|
|
||||||
|
COPY nitter.nimble .
|
||||||
|
RUN nimble install -y --depsOnly
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN nimble build -d:danger -d:lto -d:strip --mm:refc \
|
||||||
|
&& nimble scss \
|
||||||
|
&& nimble md
|
||||||
|
|
||||||
|
FROM alpine:3.20.6
|
||||||
|
WORKDIR /src/
|
||||||
|
RUN apk --no-cache add pcre ca-certificates openssl
|
||||||
|
COPY --from=nim /src/nitter/nitter ./
|
||||||
|
COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf
|
||||||
|
COPY --from=nim /src/nitter/public ./public
|
||||||
|
EXPOSE 8080
|
||||||
|
RUN adduser -h /src/ -D -s /bin/sh nitter
|
||||||
|
USER nitter
|
||||||
|
CMD ./nitter
|
||||||
76
README.md
76
README.md
@@ -1,29 +1,38 @@
|
|||||||
# Nitter
|
# Nitter
|
||||||
|
|
||||||
[](https://github.com/zedeus/nitter/actions?query=workflow%3ACI/CD)
|
[](https://github.com/zedeus/nitter/actions/workflows/run-tests.yml)
|
||||||
|
[](https://github.com/zedeus/nitter/actions/workflows/build-docker.yml)
|
||||||
[](#license)
|
[](#license)
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Running a Nitter instance now requires real accounts, since Twitter removed the previous methods. \
|
||||||
|
> This does not affect users. \
|
||||||
|
> For instructions on how to obtain session tokens, see [Creating session tokens](https://github.com/zedeus/nitter/wiki/Creating-session-tokens).
|
||||||
|
|
||||||
A free and open source alternative Twitter front-end focused on privacy and
|
A free and open source alternative Twitter front-end focused on privacy and
|
||||||
performance. \
|
performance. \
|
||||||
Inspired by the [Invidious](https://github.com/iv-org/invidious)
|
Inspired by the [Invidious](https://github.com/iv-org/invidious) project.
|
||||||
project.
|
|
||||||
|
|
||||||
- No JavaScript or ads
|
- No JavaScript or ads
|
||||||
- All requests go through the backend, client never talks to Twitter
|
- All requests go through the backend, client never talks to Twitter
|
||||||
- Prevents Twitter from tracking your IP or JavaScript fingerprint
|
- Prevents Twitter from tracking your IP or JavaScript fingerprint
|
||||||
- Uses Twitter's unofficial API (no rate limits or developer account required)
|
- Uses Twitter's unofficial API (no developer account required)
|
||||||
- Lightweight (for [@nim_lang](https://nitter.net/nim_lang), 60KB vs 784KB from twitter.com)
|
- Lightweight (for [@nim_lang](https://nitter.net/nim_lang), 60KB vs 784KB from twitter.com)
|
||||||
- RSS feeds
|
- RSS feeds
|
||||||
- Themes
|
- Themes
|
||||||
- Mobile support (responsive design)
|
- Mobile support (responsive design)
|
||||||
- AGPLv3 licensed, no proprietary instances permitted
|
- AGPLv3 licensed, no proprietary instances permitted
|
||||||
|
|
||||||
Liberapay: https://liberapay.com/zedeus \
|
<details>
|
||||||
Patreon: https://patreon.com/nitter \
|
<summary>Donations</summary>
|
||||||
BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \
|
Liberapay: https://liberapay.com/zedeus<br>
|
||||||
ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \
|
Patreon: https://patreon.com/nitter<br>
|
||||||
LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \
|
BTC: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55<br>
|
||||||
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
|
ETH: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460<br>
|
||||||
|
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL<br>
|
||||||
|
SOL: ANsyGNXFo6osuFwr1YnUqif2RdoYRhc27WdyQNmmETSW<br>
|
||||||
|
ZEC: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt
|
||||||
|
</details>
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
@@ -34,19 +43,20 @@ XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscW
|
|||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
|
|
||||||
The wiki contains
|
The wiki contains
|
||||||
[a list of instances](https://github.com/zedeus/nitter/wiki/Instances) and
|
[a list of instances](https://github.com/zedeus/nitter/wiki/Instances) and
|
||||||
[browser extensions](https://github.com/zedeus/nitter/wiki/Extensions)
|
[browser extensions](https://github.com/zedeus/nitter/wiki/Extensions)
|
||||||
maintained by the community.
|
maintained by the community.
|
||||||
|
|
||||||
## Why?
|
## Why?
|
||||||
|
|
||||||
It's impossible to use Twitter without JavaScript enabled. For privacy-minded
|
It's impossible to use Twitter without JavaScript enabled, and as of 2024 you
|
||||||
folks, preventing JavaScript analytics and IP-based tracking is important, but
|
need to sign up. For privacy-minded folks, preventing JavaScript analytics and
|
||||||
apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind
|
IP-based tracking is important, but apart from using a VPN and uBlock/uMatrix,
|
||||||
a VPN and using heavy-duty adblockers, you can get accurately tracked with your
|
it's impossible. Despite being behind a VPN and using heavy-duty adblockers,
|
||||||
[browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/),
|
you can get accurately tracked with your [browser's
|
||||||
[no JavaScript required](https://noscriptfingerprint.com/). This all became
|
fingerprint](https://restoreprivacy.com/browser-fingerprinting/), [no
|
||||||
|
JavaScript required](https://noscriptfingerprint.com/). This all became
|
||||||
particularly important after Twitter [removed the
|
particularly important after Twitter [removed the
|
||||||
ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws)
|
ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws)
|
||||||
for users to control whether their data gets sent to advertisers.
|
for users to control whether their data gets sent to advertisers.
|
||||||
@@ -67,21 +77,24 @@ Twitter account.
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
* libpcre
|
|
||||||
* libsass
|
- libpcre
|
||||||
* redis
|
- libsass
|
||||||
|
- redis/valkey
|
||||||
|
|
||||||
To compile Nitter you need a Nim installation, see
|
To compile Nitter you need a Nim installation, see
|
||||||
[nim-lang.org](https://nim-lang.org/install.html) for details. It is possible to
|
[nim-lang.org](https://nim-lang.org/install.html) for details. It is possible
|
||||||
install it system-wide or in the user directory you create below.
|
to install it system-wide or in the user directory you create below.
|
||||||
|
|
||||||
To compile the scss files, you need to install `libsass`. On Ubuntu and Debian,
|
To compile the scss files, you need to install `libsass`. On Ubuntu and Debian,
|
||||||
you can use `libsass-dev`.
|
you can use `libsass-dev`.
|
||||||
|
|
||||||
Redis is required for caching and in the future for account info. It should be
|
Redis is required for caching and in the future for account info. As of 2024
|
||||||
available on most distros as `redis` or `redis-server` (Ubuntu/Debian).
|
Redis is no longer open source, so using the fork Valkey is recommended. It
|
||||||
Running it with the default config is fine, Nitter's default config is set to
|
should be available on most distros as `redis` or `redis-server`
|
||||||
use the default Redis port and localhost.
|
(Ubuntu/Debian), or `valkey`/`valkey-server`. Running it with the default
|
||||||
|
config is fine, Nitter's default config is set to use the default port and
|
||||||
|
localhost.
|
||||||
|
|
||||||
Here's how to create a `nitter` user, clone the repo, and build the project
|
Here's how to create a `nitter` user, clone the repo, and build the project
|
||||||
along with the scss and md files.
|
along with the scss and md files.
|
||||||
@@ -91,7 +104,7 @@ along with the scss and md files.
|
|||||||
# su nitter
|
# su nitter
|
||||||
$ git clone https://github.com/zedeus/nitter
|
$ git clone https://github.com/zedeus/nitter
|
||||||
$ cd nitter
|
$ cd nitter
|
||||||
$ nimble build -d:release
|
$ nimble build -d:danger --mm:refc
|
||||||
$ nimble scss
|
$ nimble scss
|
||||||
$ nimble md
|
$ nimble md
|
||||||
$ cp nitter.example.conf nitter.conf
|
$ cp nitter.example.conf nitter.conf
|
||||||
@@ -108,25 +121,32 @@ performance reasons.
|
|||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
#### NOTE: For ARM64/ARM support, please use [unixfox's image](https://quay.io/repository/unixfox/nitter?tab=tags), more info [here](https://github.com/zedeus/nitter/issues/399#issuecomment-997263495)
|
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).
|
||||||
|
|
||||||
To run Nitter with Docker, you'll need to install and run Redis separately
|
To run Nitter with Docker, you'll need to install and run Redis separately
|
||||||
before you can run the container. See below for how to also run Redis using
|
before you can run the container. See below for how to also run Redis using
|
||||||
Docker.
|
Docker.
|
||||||
|
|
||||||
To build and run Nitter in Docker:
|
To build and run Nitter in Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t nitter:latest .
|
docker build -t nitter:latest .
|
||||||
docker run -v $(pwd)/nitter.conf:/src/nitter.conf -d --network host nitter:latest
|
docker run -v $(pwd)/nitter.conf:/src/nitter.conf -d --network host nitter:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note: For ARM64, use this Dockerfile: [`Dockerfile.arm64`](https://github.com/zedeus/nitter/blob/master/Dockerfile.arm64).
|
||||||
|
|
||||||
A prebuilt Docker image is provided as well:
|
A prebuilt Docker image is provided as well:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -v $(pwd)/nitter.conf:/src/nitter.conf -d --network host zedeus/nitter:latest
|
docker run -v $(pwd)/nitter.conf:/src/nitter.conf -d --network host zedeus/nitter:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Using docker-compose to run both Nitter and Redis as different containers:
|
Using docker-compose to run both Nitter and Redis as different containers:
|
||||||
Change `redisHost` from `localhost` to `nitter-redis` in `nitter.conf`, then run:
|
Change `redisHost` from `localhost` to `nitter-redis` in `nitter.conf`, then run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
--define:ssl
|
--define:ssl
|
||||||
--define:useStdLib
|
--define:useStdLib
|
||||||
|
--threads:off
|
||||||
|
|
||||||
# workaround httpbeast file upload bug
|
# workaround httpbeast file upload bug
|
||||||
--assertions:off
|
--assertions:off
|
||||||
|
|
||||||
# disable annoying warnings
|
# disable annoying warnings
|
||||||
warning("GcUnsafe2", off)
|
warning("GcUnsafe2", off)
|
||||||
|
warning("HoleEnumConv", off)
|
||||||
hint("XDeclaredButNotUsed", off)
|
hint("XDeclaredButNotUsed", off)
|
||||||
hint("XCannotRaiseY", off)
|
hint("XCannotRaiseY", off)
|
||||||
hint("User", off)
|
hint("User", off)
|
||||||
|
|
||||||
const
|
|
||||||
nimVersion = (major: NimMajor, minor: NimMinor, patch: NimPatch)
|
|
||||||
|
|
||||||
when nimVersion >= (1, 6, 0):
|
|
||||||
warning("HoleEnumConv", off)
|
|
||||||
|
|||||||
@@ -8,10 +8,22 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080" # Replace with "8080:8080" if you don't use a reverse proxy
|
- "127.0.0.1:8080:8080" # Replace with "8080:8080" if you don't use a reverse proxy
|
||||||
volumes:
|
volumes:
|
||||||
- ./nitter.conf:/src/nitter.conf:ro
|
- ./nitter.conf:/src/nitter.conf:Z,ro
|
||||||
|
- ./sessions.jsonl:/src/sessions.jsonl:Z,ro # Run get_sessions.py to get the credentials
|
||||||
depends_on:
|
depends_on:
|
||||||
- nitter-redis
|
- nitter-redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: wget -nv --tries=1 --spider http://127.0.0.1:8080/Jack/status/20 || exit 1
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 2
|
||||||
|
user: "998:998"
|
||||||
|
read_only: true
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
|
||||||
nitter-redis:
|
nitter-redis:
|
||||||
image: redis:6-alpine
|
image: redis:6-alpine
|
||||||
@@ -20,6 +32,17 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- nitter-redis:/data
|
- nitter-redis:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: redis-cli ping
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 2
|
||||||
|
user: "999:1000"
|
||||||
|
read_only: true
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
nitter-redis:
|
nitter-redis:
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
[Server]
|
[Server]
|
||||||
|
hostname = "nitter.net" # for generating links, change this to your own domain/ip
|
||||||
|
title = "nitter"
|
||||||
address = "0.0.0.0"
|
address = "0.0.0.0"
|
||||||
port = 8080
|
port = 8080
|
||||||
https = false # disable to enable cookies when not using https
|
https = false # disable to enable cookies when not using https
|
||||||
httpMaxConnections = 100
|
httpMaxConnections = 100
|
||||||
staticDir = "./public"
|
staticDir = "./public"
|
||||||
title = "nitter"
|
|
||||||
hostname = "nitter.net"
|
|
||||||
|
|
||||||
[Cache]
|
[Cache]
|
||||||
listMinutes = 240 # how long to cache list info (not the tweets, so keep it high)
|
listMinutes = 240 # how long to cache list info (not the tweets, so keep it high)
|
||||||
@@ -13,9 +13,9 @@ rssMinutes = 10 # how long to cache rss queries
|
|||||||
redisHost = "localhost" # Change to "nitter-redis" if using docker-compose
|
redisHost = "localhost" # Change to "nitter-redis" if using docker-compose
|
||||||
redisPort = 6379
|
redisPort = 6379
|
||||||
redisPassword = ""
|
redisPassword = ""
|
||||||
redisConnections = 20 # connection pool size
|
redisConnections = 20 # minimum open connections in pool
|
||||||
redisMaxConnections = 30
|
redisMaxConnections = 30
|
||||||
# max, new connections are opened when none are available, but if the pool size
|
# new connections are opened when none are available, but if the pool size
|
||||||
# goes above this, they're closed when released. don't worry about this unless
|
# goes above this, they're closed when released. don't worry about this unless
|
||||||
# you receive tons of requests per second
|
# you receive tons of requests per second
|
||||||
|
|
||||||
@@ -23,23 +23,18 @@ redisMaxConnections = 30
|
|||||||
hmacKey = "secretkey" # random key for cryptographic signing of video urls
|
hmacKey = "secretkey" # random key for cryptographic signing of video urls
|
||||||
base64Media = false # use base64 encoding for proxied media urls
|
base64Media = false # use base64 encoding for proxied media urls
|
||||||
enableRSS = true # set this to false to disable RSS feeds
|
enableRSS = true # set this to false to disable RSS feeds
|
||||||
enableDebug = false # enable request logs and debug endpoints
|
enableDebug = false # enable request logs and debug endpoints (/.sessions)
|
||||||
proxy = "" # http/https url, SOCKS proxies are not supported
|
proxy = "" # http/https url, SOCKS proxies are not supported
|
||||||
proxyAuth = ""
|
proxyAuth = ""
|
||||||
tokenCount = 10
|
apiProxy = "" # nitter-proxy host, e.g. localhost:7000
|
||||||
# minimum amount of usable tokens. tokens are used to authorize API requests,
|
disableTid = false # enable this if cookie-based auth is failing
|
||||||
# but they expire after ~1 hour, and have a limit of 187 requests.
|
|
||||||
# the limit gets reset every 15 minutes, and the pool is filled up so there's
|
|
||||||
# always at least $tokenCount usable tokens. again, only increase this if
|
|
||||||
# you receive major bursts all the time
|
|
||||||
|
|
||||||
# Change default preferences here, see src/prefs_impl.nim for a complete list
|
# Change default preferences here, see src/prefs_impl.nim for a complete list
|
||||||
[Preferences]
|
[Preferences]
|
||||||
theme = "Nitter"
|
theme = "Nitter"
|
||||||
replaceTwitter = "nitter.net"
|
replaceTwitter = "nitter.net"
|
||||||
replaceYouTube = "piped.kavin.rocks"
|
replaceYouTube = "piped.video"
|
||||||
replaceReddit = "teddit.net"
|
replaceReddit = "teddit.net"
|
||||||
replaceInstagram = ""
|
|
||||||
proxyVideos = true
|
proxyVideos = true
|
||||||
hlsPlayback = false
|
hlsPlayback = false
|
||||||
infiniteScroll = false
|
infiniteScroll = false
|
||||||
|
|||||||
@@ -10,20 +10,20 @@ bin = @["nitter"]
|
|||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
|
|
||||||
requires "nim >= 1.4.8"
|
requires "nim >= 2.0.0"
|
||||||
requires "jester >= 0.5.0"
|
requires "jester#baca3f"
|
||||||
requires "karax#c71bc92"
|
requires "karax#5cf360c"
|
||||||
requires "sass#e683aa1"
|
requires "sass#7dfdd03"
|
||||||
requires "nimcrypto#a5742a9"
|
requires "nimcrypto#a079df9"
|
||||||
requires "markdown#abdbe5e"
|
requires "markdown#158efe3"
|
||||||
requires "packedjson#d11d167"
|
requires "packedjson#9e6fbb6"
|
||||||
requires "supersnappy#2.1.1"
|
requires "supersnappy#6c94198"
|
||||||
requires "redpool#8b7c1db"
|
requires "redpool#8b7c1db"
|
||||||
requires "https://github.com/zedeus/redis#d0a0e6f"
|
requires "https://github.com/zedeus/redis#d0a0e6f"
|
||||||
requires "zippy#0.7.3"
|
requires "zippy#ca5989a"
|
||||||
requires "flatty#0.2.3"
|
requires "flatty#e668085"
|
||||||
requires "jsony#d0e69bd"
|
requires "jsony#1de1f08"
|
||||||
|
requires "oauth#b8c163b"
|
||||||
|
|
||||||
# Tasks
|
# Tasks
|
||||||
|
|
||||||
|
|||||||
145
public/css/fontello.css
vendored
145
public/css/fontello.css
vendored
@@ -1,53 +1,138 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'fontello';
|
font-family: "fontello";
|
||||||
src: url('/fonts/fontello.eot?21002321');
|
src: url("/fonts/fontello.eot?77185648");
|
||||||
src: url('/fonts/fontello.eot?21002321#iefix') format('embedded-opentype'),
|
src:
|
||||||
url('/fonts/fontello.woff2?21002321') format('woff2'),
|
url("/fonts/fontello.eot?77185648#iefix") format("embedded-opentype"),
|
||||||
url('/fonts/fontello.woff?21002321') format('woff'),
|
url("/fonts/fontello.woff2?77185648") format("woff2"),
|
||||||
url('/fonts/fontello.ttf?21002321') format('truetype'),
|
url("/fonts/fontello.woff?77185648") format("woff"),
|
||||||
url('/fonts/fontello.svg?21002321#fontello') format('svg');
|
url("/fonts/fontello.ttf?77185648") format("truetype"),
|
||||||
|
url("/fonts/fontello.svg?77185648#fontello") format("svg");
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
[class^="icon-"]:before, [class*=" icon-"]:before {
|
[class^="icon-"]:before,
|
||||||
|
[class*=" icon-"]:before {
|
||||||
font-family: "fontello";
|
font-family: "fontello";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
speak: never;
|
speak: never;
|
||||||
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
text-decoration: inherit;
|
text-decoration: inherit;
|
||||||
width: 1em;
|
width: 1em;
|
||||||
|
margin-right: 0.2em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
/* For safety - reset parent styles, that can break glyph codes*/
|
/* For safety - reset parent styles, that can break glyph codes*/
|
||||||
font-variant: normal;
|
font-variant: normal;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
|
|
||||||
/* fix buttons height, for twitter bootstrap */
|
/* fix buttons height, for twitter bootstrap */
|
||||||
line-height: 1em;
|
line-height: 1em;
|
||||||
|
|
||||||
/* Font smoothing. That was taken from TWBS */
|
/* Font smoothing. That was taken from TWBS */
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-heart:before { content: '\2665'; } /* '♥' */
|
.icon-views:before {
|
||||||
.icon-quote:before { content: '\275e'; } /* '❞' */
|
content: "\e800";
|
||||||
.icon-comment:before { content: '\e802'; } /* '' */
|
}
|
||||||
.icon-ok:before { content: '\e803'; } /* '' */
|
|
||||||
.icon-play:before { content: '\e804'; } /* '' */
|
/* '' */
|
||||||
.icon-link:before { content: '\e805'; } /* '' */
|
.icon-heart:before {
|
||||||
.icon-calendar:before { content: '\e806'; } /* '' */
|
content: "\e801";
|
||||||
.icon-location:before { content: '\e807'; } /* '' */
|
}
|
||||||
.icon-picture:before { content: '\e809'; } /* '' */
|
|
||||||
.icon-lock:before { content: '\e80a'; } /* '' */
|
/* '' */
|
||||||
.icon-down:before { content: '\e80b'; } /* '' */
|
.icon-quote:before {
|
||||||
.icon-retweet:before { content: '\e80d'; } /* '' */
|
content: "\e802";
|
||||||
.icon-search:before { content: '\e80e'; } /* '' */
|
}
|
||||||
.icon-pin:before { content: '\e80f'; } /* '' */
|
|
||||||
.icon-cog:before { content: '\e812'; } /* '' */
|
/* '' */
|
||||||
.icon-rss-feed:before { content: '\e813'; } /* '' */
|
.icon-comment:before {
|
||||||
.icon-info:before { content: '\f128'; } /* '' */
|
content: "\e803";
|
||||||
.icon-bird:before { content: '\f309'; } /* '' */
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-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-circle:before {
|
||||||
|
content: "\f111";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-info:before {
|
||||||
|
content: "\f128";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
.icon-bird:before {
|
||||||
|
content: "\f309";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* '' */
|
||||||
|
|||||||
41
public/css/themes/dracula.css
Normal file
41
public/css/themes/dracula.css
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
body {
|
||||||
|
--bg_color: #282a36;
|
||||||
|
--fg_color: #f8f8f2;
|
||||||
|
--fg_faded: #818eb6;
|
||||||
|
--fg_dark: var(--fg_faded);
|
||||||
|
--fg_nav: var(--accent);
|
||||||
|
|
||||||
|
--bg_panel: #343746;
|
||||||
|
--bg_elements: #292b36;
|
||||||
|
--bg_overlays: #44475a;
|
||||||
|
--bg_hover: #2f323f;
|
||||||
|
|
||||||
|
--grey: var(--fg_faded);
|
||||||
|
--dark_grey: #44475a;
|
||||||
|
--darker_grey: #3d4051;
|
||||||
|
--darkest_grey: #363948;
|
||||||
|
--border_grey: #44475a;
|
||||||
|
|
||||||
|
--accent: #bd93f9;
|
||||||
|
--accent_light: #caa9fa;
|
||||||
|
--accent_dark: var(--accent);
|
||||||
|
--accent_border: #ff79c696;
|
||||||
|
|
||||||
|
--play_button: #ffb86c;
|
||||||
|
--play_button_hover: #ffc689;
|
||||||
|
|
||||||
|
--more_replies_dots: #bd93f9;
|
||||||
|
--error_red: #ff5555;
|
||||||
|
|
||||||
|
--verified_blue: var(--accent);
|
||||||
|
--icon_text: ##F8F8F2;
|
||||||
|
|
||||||
|
--tab: #6272a4;
|
||||||
|
--tab_selected: var(--accent);
|
||||||
|
|
||||||
|
--profile_stat: #919cbf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar > form input::placeholder{
|
||||||
|
color: var(--fg_faded);
|
||||||
|
}
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
Font license info
|
Font license info
|
||||||
|
|
||||||
|
|
||||||
|
## Modern Pictograms
|
||||||
|
|
||||||
|
Copyright (c) 2012 by John Caserta. All rights reserved.
|
||||||
|
|
||||||
|
Author: John Caserta
|
||||||
|
License: SIL (http://scripts.sil.org/OFL)
|
||||||
|
Homepage: http://thedesignoffice.org/project/modern-pictograms/
|
||||||
|
|
||||||
|
|
||||||
## Entypo
|
## Entypo
|
||||||
|
|
||||||
Copyright (C) 2012 by Daniel Bruce
|
Copyright (C) 2012 by Daniel Bruce
|
||||||
@@ -37,12 +46,3 @@ Font license info
|
|||||||
Homepage: http://aristeides.com/
|
Homepage: http://aristeides.com/
|
||||||
|
|
||||||
|
|
||||||
## Modern Pictograms
|
|
||||||
|
|
||||||
Copyright (c) 2012 by John Caserta. All rights reserved.
|
|
||||||
|
|
||||||
Author: John Caserta
|
|
||||||
License: SIL (http://scripts.sil.org/OFL)
|
|
||||||
Homepage: http://thedesignoffice.org/project/modern-pictograms/
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -1,26 +1,26 @@
|
|||||||
<?xml version="1.0" standalone="no"?>
|
<?xml version="1.0" standalone="no"?>
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
<metadata>Copyright (C) 2020 by original authors @ fontello.com</metadata>
|
<metadata>Copyright (C) 2025 by original authors @ fontello.com</metadata>
|
||||||
<defs>
|
<defs>
|
||||||
<font id="fontello" horiz-adv-x="1000" >
|
<font id="fontello" horiz-adv-x="1000" >
|
||||||
<font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
|
<font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
|
||||||
<missing-glyph horiz-adv-x="1000" />
|
<missing-glyph horiz-adv-x="1000" />
|
||||||
<glyph glyph-name="heart" unicode="♥" d="M790 644q70-64 70-156t-70-158l-360-330-360 330q-70 66-70 158t70 156q62 58 151 58t153-58l56-52 58 52q62 58 150 58t152-58z" horiz-adv-x="860" />
|
<glyph glyph-name="views" unicode="" d="M180 516l0-538-180 0 0 538 180 0z m250-138l0-400-180 0 0 400 180 0z m250 344l0-744-180 0 0 744 180 0z" horiz-adv-x="680" />
|
||||||
|
|
||||||
<glyph glyph-name="quote" unicode="❞" d="M18 685l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z m558 0l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z" horiz-adv-x="928" />
|
<glyph glyph-name="heart" unicode="" d="M790 644q70-64 70-156t-70-158l-360-330-360 330q-70 66-70 158t70 156q62 58 151 58t153-58l56-52 58 52q62 58 150 58t152-58z" horiz-adv-x="860" />
|
||||||
|
|
||||||
<glyph glyph-name="comment" unicode="" d="M1000 350q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12-10-1-17 5t-10 16v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 73 40 139t106 114 160 76 194 28q136 0 251-48t182-130 67-179z" horiz-adv-x="1000" />
|
<glyph glyph-name="quote" unicode="" d="M18 685l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z m558 0l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z" horiz-adv-x="928" />
|
||||||
|
|
||||||
<glyph glyph-name="ok" unicode="" d="M0 260l162 162 166-164 508 510 164-164-510-510-162-162-162 164z" horiz-adv-x="1000" />
|
<glyph glyph-name="comment" unicode="" d="M1000 350q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12-10-1-17 5t-10 16v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 73 40 139t106 114 160 76 194 28q136 0 251-48t182-130 67-179z" horiz-adv-x="1000" />
|
||||||
|
|
||||||
<glyph glyph-name="play" unicode="" d="M772 333l-741-412q-13-7-22-2t-9 20v822q0 14 9 20t22-2l741-412q13-7 13-17t-13-17z" horiz-adv-x="785.7" />
|
<glyph glyph-name="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="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="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="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="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="location" unicode="" d="M250 750q104 0 177-73t73-177q0-106-62-243t-126-223l-62-84q-10 12-27 35t-60 89-76 130-60 147-27 149q0 104 73 177t177 73z m0-388q56 0 96 40t40 96-40 95-96 39-95-39-39-95 39-96 95-40z" horiz-adv-x="500" />
|
||||||
|
|
||||||
<glyph glyph-name="picture" unicode="" d="M357 529q0-45-31-76t-76-32-76 32-31 76 31 76 76 31 76-31 31-76z m572-215v-250h-786v107l178 179 90-89 285 285z m53 393h-893q-7 0-12-5t-6-13v-678q0-7 6-13t12-5h893q7 0 13 5t5 13v678q0 8-5 13t-13 5z m89-18v-678q0-37-26-63t-63-27h-893q-36 0-63 27t-26 63v678q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" />
|
<glyph glyph-name="picture" unicode="" d="M357 529q0-45-31-76t-76-32-76 32-31 76 31 76 76 31 76-31 31-76z m572-215v-250h-786v107l178 179 90-89 285 285z m53 393h-893q-7 0-12-5t-6-13v-678q0-7 6-13t12-5h893q7 0 13 5t5 13v678q0 8-5 13t-13 5z m89-18v-678q0-37-26-63t-63-27h-893q-36 0-63 27t-26 63v678q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" />
|
||||||
|
|
||||||
@@ -28,19 +28,23 @@
|
|||||||
|
|
||||||
<glyph glyph-name="down" unicode="" d="M939 399l-414-413q-10-11-25-11t-25 11l-414 413q-11 11-11 26t11 25l93 92q10 11 25 11t25-11l296-296 296 296q11 11 25 11t26-11l92-92q11-11 11-25t-11-26z" horiz-adv-x="1000" />
|
<glyph glyph-name="down" unicode="" d="M939 399l-414-413q-10-11-25-11t-25 11l-414 413q-11 11-11 26t11 25l93 92q10 11 25 11t25-11l296-296 296 296q11 11 25 11t26-11l92-92q11-11 11-25t-11-26z" horiz-adv-x="1000" />
|
||||||
|
|
||||||
<glyph glyph-name="retweet" unicode="" d="M714 11q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" />
|
<glyph glyph-name="retweet" unicode="" d="M714 11q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" />
|
||||||
|
|
||||||
<glyph glyph-name="search" unicode="" d="M772 78q30-34 6-62l-46-46q-36-32-68 0l-190 190q-74-42-156-42-128 0-223 95t-95 223 90 219 218 91 224-95 96-223q0-88-46-162z m-678 358q0-88 68-156t156-68 151 63 63 153q0 88-68 155t-156 67-151-63-63-151z" horiz-adv-x="789" />
|
<glyph glyph-name="search" unicode="" d="M772 78q30-34 6-62l-46-46q-36-32-68 0l-190 190q-74-42-156-42-128 0-223 95t-95 223 90 219 218 91 224-95 96-223q0-88-46-162z m-678 358q0-88 68-156t156-68 151 63 63 153q0 88-68 155t-156 67-151-63-63-151z" horiz-adv-x="789" />
|
||||||
|
|
||||||
<glyph glyph-name="pin" unicode="" d="M268 368v250q0 8-5 13t-13 5-13-5-5-13v-250q0-8 5-13t13-5 13 5 5 13z m375-197q0-14-11-25t-25-10h-239l-29-270q-1-7-6-11t-11-5h-1q-15 0-17 15l-43 271h-225q-15 0-25 10t-11 25q0 69 44 124t99 55v286q-29 0-50 21t-22 50 22 50 50 22h357q29 0 50-22t21-50-21-50-50-21v-286q55 0 99-55t44-124z" horiz-adv-x="642.9" />
|
<glyph glyph-name="pin" unicode="" d="M268 368v250q0 8-5 13t-13 5-13-5-5-13v-250q0-8 5-13t13-5 13 5 5 13z m375-197q0-14-11-25t-25-10h-239l-29-270q-1-7-6-11t-11-5h-1q-15 0-17 15l-43 271h-225q-15 0-25 10t-11 25q0 69 44 124t99 55v286q-29 0-50 21t-22 50 22 50 50 22h357q29 0 50-22t21-50-21-50-50-21v-286q55 0 99-55t44-124z" horiz-adv-x="642.9" />
|
||||||
|
|
||||||
<glyph glyph-name="cog" unicode="" d="M911 295l-133-56q-8-22-12-31l55-133-79-79-135 53q-9-4-31-12l-55-134-112 0-56 133q-11 4-33 13l-132-55-78 79 53 134q-1 3-4 9t-6 12-4 11l-131 55 0 112 131 56 14 33-54 132 78 79 133-54q22 9 33 13l55 132 112 0 56-132q14-5 31-13l133 55 80-79-54-135q6-12 12-30l133-56 0-112z m-447-111q69 0 118 48t49 118-49 119-118 50-119-50-49-119 49-118 119-48z" horiz-adv-x="928" />
|
<glyph glyph-name="cog" unicode="" d="M911 295l-133-56q-8-22-12-31l55-133-79-79-135 53q-9-4-31-12l-55-134-112 0-56 133q-11 4-33 13l-132-55-78 79 53 134q-1 3-4 9t-6 12-4 11l-131 55 0 112 131 56 14 33-54 132 78 79 133-54q22 9 33 13l55 132 112 0 56-132q14-5 31-13l133 55 80-79-54-135q6-12 12-30l133-56 0-112z m-447-111q69 0 118 48t49 118-49 119-118 50-119-50-49-119 49-118 119-48z" horiz-adv-x="928" />
|
||||||
|
|
||||||
<glyph glyph-name="rss-feed" unicode="" d="M184 93c0-51-43-91-93-91s-91 40-91 91c0 50 41 91 91 91s93-41 93-91z m261-85l-125 0c0 174-140 323-315 323l0 118c231 0 440-163 440-441z m259 0l-136 0c0 300-262 561-563 561l0 129c370 0 699-281 699-690z" horiz-adv-x="704" />
|
<glyph glyph-name="rss" unicode="" d="M184 93c0-51-43-91-93-91s-91 40-91 91c0 50 41 91 91 91s93-41 93-91z m261-85l-125 0c0 174-140 323-315 323l0 118c231 0 440-163 440-441z m259 0l-136 0c0 300-262 561-563 561l0 129c370 0 699-281 699-690z" horiz-adv-x="704" />
|
||||||
|
|
||||||
|
<glyph glyph-name="ok" unicode="" d="M933 534q0-22-16-38l-404-404-76-76q-16-15-38-15t-38 15l-76 76-202 202q-15 16-15 38t15 38l76 76q16 16 38 16t38-16l164-165 366 367q16 16 38 16t38-16l76-76q16-15 16-38z" horiz-adv-x="1000" />
|
||||||
|
|
||||||
|
<glyph glyph-name="circle" unicode="" d="M857 350q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" />
|
||||||
|
|
||||||
<glyph glyph-name="info" unicode="" d="M393 149v-134q0-9-7-15t-15-7h-134q-9 0-16 7t-7 15v134q0 9 7 16t16 6h134q9 0 15-6t7-16z m176 335q0-30-8-56t-20-43-31-33-32-25-34-19q-23-13-38-37t-15-37q0-10-7-18t-16-9h-134q-8 0-14 11t-6 20v26q0 46 37 87t79 60q33 16 47 32t14 42q0 24-26 41t-60 18q-36 0-60-16-20-14-60-64-7-9-17-9-7 0-14 4l-91 70q-8 6-9 14t3 16q89 148 259 148 45 0 90-17t81-46 59-72 23-88z" horiz-adv-x="571.4" />
|
<glyph glyph-name="info" unicode="" d="M393 149v-134q0-9-7-15t-15-7h-134q-9 0-16 7t-7 15v134q0 9 7 16t16 6h134q9 0 15-6t7-16z m176 335q0-30-8-56t-20-43-31-33-32-25-34-19q-23-13-38-37t-15-37q0-10-7-18t-16-9h-134q-8 0-14 11t-6 20v26q0 46 37 87t79 60q33 16 47 32t14 42q0 24-26 41t-60 18q-36 0-60-16-20-14-60-64-7-9-17-9-7 0-14 4l-91 70q-8 6-9 14t3 16q89 148 259 148 45 0 90-17t81-46 59-72 23-88z" horiz-adv-x="571.4" />
|
||||||
|
|
||||||
<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" />
|
<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>
|
</font>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 6.4 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
5
public/js/hls.light.min.js
vendored
5
public/js/hls.light.min.js
vendored
File diff suppressed because one or more lines are too long
5
public/js/hls.min.js
vendored
Normal file
5
public/js/hls.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@ function insertBeforeLast(node, elem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getLoadMore(doc) {
|
function getLoadMore(doc) {
|
||||||
return doc.querySelector('.show-more:not(.timeline-item)');
|
return doc.querySelector(".show-more:not(.timeline-item)");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDuplicate(item, itemClass) {
|
function isDuplicate(item, itemClass) {
|
||||||
@@ -15,18 +15,19 @@ function isDuplicate(item, itemClass) {
|
|||||||
return document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != null;
|
return document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.onload = function() {
|
window.onload = function () {
|
||||||
const url = window.location.pathname;
|
const url = window.location.pathname;
|
||||||
const isTweet = url.indexOf("/status/") !== -1;
|
const isTweet = url.indexOf("/status/") !== -1;
|
||||||
const containerClass = isTweet ? ".replies" : ".timeline";
|
const containerClass = isTweet ? ".replies" : ".timeline";
|
||||||
const itemClass = containerClass + ' > div:not(.top-ref)';
|
const itemClass = containerClass + " > div:not(.top-ref)";
|
||||||
|
|
||||||
var html = document.querySelector("html");
|
var html = document.querySelector("html");
|
||||||
var container = document.querySelector(containerClass);
|
var container = document.querySelector(containerClass);
|
||||||
var loading = false;
|
var loading = false;
|
||||||
|
|
||||||
window.addEventListener('scroll', function() {
|
function handleScroll(failed) {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
|
|
||||||
if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) {
|
if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) {
|
||||||
loading = true;
|
loading = true;
|
||||||
var loadMore = getLoadMore(document);
|
var loadMore = getLoadMore(document);
|
||||||
@@ -35,13 +36,15 @@ window.onload = function() {
|
|||||||
loadMore.children[0].text = "Loading...";
|
loadMore.children[0].text = "Loading...";
|
||||||
|
|
||||||
var url = new URL(loadMore.children[0].href);
|
var url = new URL(loadMore.children[0].href);
|
||||||
url.searchParams.append('scroll', 'true');
|
url.searchParams.append("scroll", "true");
|
||||||
|
|
||||||
fetch(url.toString()).then(function (response) {
|
fetch(url.toString()).then(function (response) {
|
||||||
|
if (response.status === 404) throw "error";
|
||||||
|
|
||||||
return response.text();
|
return response.text();
|
||||||
}).then(function (html) {
|
}).then(function (html) {
|
||||||
var parser = new DOMParser();
|
var parser = new DOMParser();
|
||||||
var doc = parser.parseFromString(html, 'text/html');
|
var doc = parser.parseFromString(html, "text/html");
|
||||||
loadMore.remove();
|
loadMore.remove();
|
||||||
|
|
||||||
for (var item of doc.querySelectorAll(itemClass)) {
|
for (var item of doc.querySelectorAll(itemClass)) {
|
||||||
@@ -57,10 +60,18 @@ window.onload = function() {
|
|||||||
if (isTweet) container.appendChild(newLoadMore);
|
if (isTweet) container.appendChild(newLoadMore);
|
||||||
else insertBeforeLast(container, newLoadMore);
|
else insertBeforeLast(container, newLoadMore);
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
console.warn('Something went wrong.', err);
|
console.warn("Something went wrong.", err);
|
||||||
loading = true;
|
if (failed > 3) {
|
||||||
|
loadMore.children[0].text = "Error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
handleScroll((failed || 0) + 1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
window.addEventListener("scroll", () => handleScroll());
|
||||||
};
|
};
|
||||||
// @license-end
|
// @license-end
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ Nitter is a free and open source alternative Twitter front-end focused on
|
|||||||
privacy and performance. The source is available on GitHub at
|
privacy and performance. The source is available on GitHub at
|
||||||
<https://github.com/zedeus/nitter>
|
<https://github.com/zedeus/nitter>
|
||||||
|
|
||||||
* No JavaScript or ads
|
- No JavaScript or ads
|
||||||
* All requests go through the backend, client never talks to Twitter
|
- All requests go through the backend, client never talks to Twitter
|
||||||
* Prevents Twitter from tracking your IP or JavaScript fingerprint
|
- Prevents Twitter from tracking your IP or JavaScript fingerprint
|
||||||
* Uses Twitter's unofficial API (no rate limits or developer account required)
|
- Uses Twitter's unofficial API (no developer account required)
|
||||||
* Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com)
|
- Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com)
|
||||||
* RSS feeds
|
- RSS feeds
|
||||||
* Themes
|
- Themes
|
||||||
* Mobile support (responsive design)
|
- Mobile support (responsive design)
|
||||||
* AGPLv3 licensed, no proprietary instances permitted
|
- AGPLv3 licensed, no proprietary instances permitted
|
||||||
|
|
||||||
Nitter's GitHub wiki contains
|
Nitter's GitHub wiki contains
|
||||||
[instances](https://github.com/zedeus/nitter/wiki/Instances) and
|
[instances](https://github.com/zedeus/nitter/wiki/Instances) and
|
||||||
@@ -21,12 +21,13 @@ maintained by the community.
|
|||||||
|
|
||||||
## Why use Nitter?
|
## Why use Nitter?
|
||||||
|
|
||||||
It's impossible to use Twitter without JavaScript enabled. For privacy-minded
|
It's impossible to use Twitter without JavaScript enabled, and as of 2024 you
|
||||||
folks, preventing JavaScript analytics and IP-based tracking is important, but
|
need to sign up. For privacy-minded folks, preventing JavaScript analytics and
|
||||||
apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind
|
IP-based tracking is important, but apart from using a VPN and uBlock/uMatrix,
|
||||||
a VPN and using heavy-duty adblockers, you can get accurately tracked with your
|
it's impossible. Despite being behind a VPN and using heavy-duty adblockers,
|
||||||
[browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/),
|
you can get accurately tracked with your [browser's
|
||||||
[no JavaScript required](https://noscriptfingerprint.com/). This all became
|
fingerprint](https://restoreprivacy.com/browser-fingerprinting/), [no
|
||||||
|
JavaScript required](https://noscriptfingerprint.com/). This all became
|
||||||
particularly important after Twitter [removed the
|
particularly important after Twitter [removed the
|
||||||
ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws)
|
ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws)
|
||||||
for users to control whether their data gets sent to advertisers.
|
for users to control whether their data gets sent to advertisers.
|
||||||
@@ -42,12 +43,13 @@ Twitter account.
|
|||||||
|
|
||||||
## Donating
|
## Donating
|
||||||
|
|
||||||
Liberapay: <https://liberapay.com/zedeus> \
|
Liberapay: https://liberapay.com/zedeus \
|
||||||
Patreon: <https://patreon.com/nitter> \
|
Patreon: https://patreon.com/nitter \
|
||||||
BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \
|
BTC: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55 \
|
||||||
ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \
|
ETH: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460 \
|
||||||
LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \
|
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL \
|
||||||
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
|
SOL: ANsyGNXFo6osuFwr1YnUqif2RdoYRhc27WdyQNmmETSW \
|
||||||
|
ZEC: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt
|
||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
|
|
||||||
|
|||||||
5
public/robots.txt
Normal file
5
public/robots.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
|
Crawl-delay: 1
|
||||||
|
User-agent: Twitterbot
|
||||||
|
Disallow:
|
||||||
240
src/api.nim
240
src/api.nim
@@ -1,124 +1,194 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import asyncdispatch, httpclient, uri, strutils, sequtils, sugar
|
import asyncdispatch, httpclient, strutils, sequtils, sugar
|
||||||
import packedjson
|
import packedjson
|
||||||
import types, query, formatters, consts, apiutils, parser
|
import types, query, formatters, consts, apiutils, parser
|
||||||
import experimental/parser as newParser
|
import experimental/parser as newParser
|
||||||
|
|
||||||
proc getGraphUser*(id: string): Future[User] {.async.} =
|
# 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 = ""): ApiUrl =
|
||||||
|
return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles))
|
||||||
|
|
||||||
|
proc apiReq(endpoint, variables: string; fieldToggles = ""): ApiReq =
|
||||||
|
let url = apiUrl(endpoint, variables, fieldToggles)
|
||||||
|
return ApiReq(cookie: url, oauth: url)
|
||||||
|
|
||||||
|
proc mediaUrl(id: string; cursor: string): ApiReq =
|
||||||
|
result = ApiReq(
|
||||||
|
cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor]),
|
||||||
|
oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor])
|
||||||
|
)
|
||||||
|
|
||||||
|
proc userTweetsUrl(id: string; cursor: string): ApiReq =
|
||||||
|
result = ApiReq(
|
||||||
|
# cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles),
|
||||||
|
oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor])
|
||||||
|
)
|
||||||
|
# might change this in the future pending testing
|
||||||
|
result.cookie = result.oauth
|
||||||
|
|
||||||
|
proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
|
||||||
|
let cookieVars = userTweetsAndRepliesVars % [id, cursor]
|
||||||
|
result = ApiReq(
|
||||||
|
cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles),
|
||||||
|
oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor])
|
||||||
|
)
|
||||||
|
|
||||||
|
proc tweetDetailUrl(id: string; cursor: string): ApiReq =
|
||||||
|
let cookieVars = tweetDetailVars % [id, cursor]
|
||||||
|
result = ApiReq(
|
||||||
|
# cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles),
|
||||||
|
cookie: apiUrl(graphTweet, tweetVars % [id, cursor]),
|
||||||
|
oauth: apiUrl(graphTweet, tweetVars % [id, cursor])
|
||||||
|
)
|
||||||
|
|
||||||
|
proc userUrl(username: string): ApiReq =
|
||||||
|
let cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username
|
||||||
|
result = ApiReq(
|
||||||
|
cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles),
|
||||||
|
oauth: apiUrl(graphUserV2, """{"screen_name": "$1"}""" % username)
|
||||||
|
)
|
||||||
|
|
||||||
|
proc getGraphUser*(username: string): Future[User] {.async.} =
|
||||||
|
if username.len == 0: return
|
||||||
|
let 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
|
if id.len == 0 or id.any(c => not c.isDigit): return
|
||||||
let
|
let
|
||||||
variables = %*{"userId": id, "withSuperFollowsUserFields": true}
|
url = apiReq(graphUserById, """{"rest_id": "$1"}""" % id)
|
||||||
js = await fetchRaw(graphUser ? {"variables": $variables}, Api.userRestId)
|
js = await fetchRaw(url)
|
||||||
result = parseGraphUser(js)
|
result = parseGraphUser(js)
|
||||||
|
|
||||||
|
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
|
||||||
|
if id.len == 0: return
|
||||||
|
let
|
||||||
|
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||||
|
url = case kind
|
||||||
|
of TimelineKind.tweets: userTweetsUrl(id, cursor)
|
||||||
|
of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
|
||||||
|
of TimelineKind.media: mediaUrl(id, cursor)
|
||||||
|
js = await fetch(url)
|
||||||
|
result = parseGraphTimeline(js, after)
|
||||||
|
|
||||||
|
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
|
||||||
|
if id.len == 0: return
|
||||||
|
let
|
||||||
|
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||||
|
url = apiReq(graphListTweets, restIdVars % [id, cursor])
|
||||||
|
js = await fetch(url)
|
||||||
|
result = parseGraphTimeline(js, after).tweets
|
||||||
|
|
||||||
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
|
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
|
||||||
let
|
let
|
||||||
variables = %*{"screenName": name, "listSlug": list, "withHighlightedLabel": false}
|
variables = %*{"screenName": name, "listSlug": list}
|
||||||
url = graphListBySlug ? {"variables": $variables}
|
url = apiReq(graphListBySlug, $variables)
|
||||||
result = parseGraphList(await fetch(url, Api.listBySlug))
|
js = await fetch(url)
|
||||||
|
result = parseGraphList(js)
|
||||||
|
|
||||||
proc getGraphList*(id: string): Future[List] {.async.} =
|
proc getGraphList*(id: string): Future[List] {.async.} =
|
||||||
let
|
let
|
||||||
variables = %*{"listId": id, "withHighlightedLabel": false}
|
url = apiReq(graphListById, """{"listId": "$1"}""" % id)
|
||||||
url = graphList ? {"variables": $variables}
|
js = await fetch(url)
|
||||||
result = parseGraphList(await fetch(url, Api.list))
|
result = parseGraphList(js)
|
||||||
|
|
||||||
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
|
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
|
||||||
if list.id.len == 0: return
|
if list.id.len == 0: return
|
||||||
let
|
var
|
||||||
variables = %*{
|
variables = %*{
|
||||||
"listId": list.id,
|
"listId": list.id,
|
||||||
"cursor": after,
|
|
||||||
"withSuperFollowsUserFields": false,
|
|
||||||
"withBirdwatchPivots": false,
|
"withBirdwatchPivots": false,
|
||||||
"withDownvotePerspective": false,
|
"withDownvotePerspective": false,
|
||||||
"withReactionsMetadata": false,
|
"withReactionsMetadata": false,
|
||||||
"withReactionsPerspective": false,
|
"withReactionsPerspective": false
|
||||||
"withSuperFollowsTweetFields": false
|
|
||||||
}
|
}
|
||||||
url = graphListMembers ? {"variables": $variables}
|
if after.len > 0:
|
||||||
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after)
|
variables["cursor"] = % after
|
||||||
|
let
|
||||||
|
url = apiReq(graphListMembers, $variables)
|
||||||
|
js = await fetchRaw(url)
|
||||||
|
result = parseGraphListMembers(js, after)
|
||||||
|
|
||||||
proc getListTimeline*(id: string; after=""): Future[Timeline] {.async.} =
|
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
let
|
||||||
ps = genParams({"list_id": id, "ranking_mode": "reverse_chronological"}, after)
|
url = apiReq(graphTweetResult, """{"rest_id": "$1"}""" % id)
|
||||||
url = listTimeline ? ps
|
js = await fetch(url)
|
||||||
result = parseTimeline(await fetch(url, Api.timeline), after)
|
result = parseGraphTweetResult(js)
|
||||||
|
|
||||||
proc getUser*(username: string): Future[User] {.async.} =
|
proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
|
||||||
if username.len == 0: return
|
|
||||||
let
|
|
||||||
ps = genParams({"screen_name": username})
|
|
||||||
json = await fetchRaw(userShow ? ps, Api.userShow)
|
|
||||||
result = parseUser(json, username)
|
|
||||||
|
|
||||||
proc getUserById*(userId: string): Future[User] {.async.} =
|
|
||||||
if userId.len == 0: return
|
|
||||||
let
|
|
||||||
ps = genParams({"user_id": userId})
|
|
||||||
json = await fetchRaw(userShow ? ps, Api.userShow)
|
|
||||||
result = parseUser(json)
|
|
||||||
|
|
||||||
proc getTimeline*(id: string; after=""; replies=false): Future[Timeline] {.async.} =
|
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
let
|
||||||
ps = genParams({"userId": id, "include_tweet_replies": $replies}, after)
|
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||||
url = timeline / (id & ".json") ? ps
|
js = await fetch(tweetDetailUrl(id, cursor))
|
||||||
result = parseTimeline(await fetch(url, Api.timeline), after)
|
result = parseGraphConversation(js, id)
|
||||||
|
|
||||||
proc getMediaTimeline*(id: string; after=""): Future[Timeline] {.async.} =
|
|
||||||
if id.len == 0: return
|
|
||||||
let url = mediaTimeline / (id & ".json") ? genParams(cursor=after)
|
|
||||||
result = parseTimeline(await fetch(url, Api.timeline), after)
|
|
||||||
|
|
||||||
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 getSearch*[T](query: Query; after=""): Future[Result[T]] {.async.} =
|
|
||||||
when T is User:
|
|
||||||
const
|
|
||||||
searchMode = ("result_filter", "user")
|
|
||||||
parse = parseUsers
|
|
||||||
fetchFunc = fetchRaw
|
|
||||||
else:
|
|
||||||
const
|
|
||||||
searchMode = ("tweet_search_mode", "live")
|
|
||||||
parse = parseTweets
|
|
||||||
fetchFunc = fetchRaw
|
|
||||||
|
|
||||||
let q = genQueryParam(query)
|
|
||||||
if q.len == 0 or q == emptyQuery:
|
|
||||||
return Result[T](beginning: true, query: query)
|
|
||||||
|
|
||||||
let url = search ? genParams(searchParams & @[("q", q), searchMode], after)
|
|
||||||
try:
|
|
||||||
result = parse(await fetchFunc(url, Api.search), after)
|
|
||||||
result.query = query
|
|
||||||
except InternalError:
|
|
||||||
return Result[T](beginning: true, query: query)
|
|
||||||
|
|
||||||
proc getTweetImpl(id: string; after=""): Future[Conversation] {.async.} =
|
|
||||||
let url = tweet / (id & ".json") ? genParams(cursor=after)
|
|
||||||
result = parseConversation(await fetch(url, Api.tweet), id)
|
|
||||||
|
|
||||||
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
|
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
|
||||||
result = (await getTweetImpl(id, after)).replies
|
result = (await getGraphTweet(id, after)).replies
|
||||||
result.beginning = after.len == 0
|
result.beginning = after.len == 0
|
||||||
|
|
||||||
proc getTweet*(id: string; after=""): Future[Conversation] {.async.} =
|
proc getTweet*(id: string; after=""): Future[Conversation] {.async.} =
|
||||||
result = await getTweetImpl(id)
|
result = await getGraphTweet(id)
|
||||||
if after.len > 0:
|
if after.len > 0:
|
||||||
result.replies = await getReplies(id, after)
|
result.replies = await getReplies(id, after)
|
||||||
|
|
||||||
proc getStatus*(id: string): Future[Tweet] {.async.} =
|
proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
|
||||||
let url = status / (id & ".json") ? genParams()
|
let q = genQueryParam(query)
|
||||||
result = parseStatus(await fetch(url, Api.status))
|
if q.len == 0 or q == emptyQuery:
|
||||||
|
return Timeline(query: query, beginning: true)
|
||||||
|
|
||||||
|
var
|
||||||
|
variables = %*{
|
||||||
|
"rawQuery": q,
|
||||||
|
"query_source": "typedQuery",
|
||||||
|
"count": 20,
|
||||||
|
"product": "Latest",
|
||||||
|
"withDownvotePerspective": false,
|
||||||
|
"withReactionsMetadata": false,
|
||||||
|
"withReactionsPerspective": false
|
||||||
|
}
|
||||||
|
if after.len > 0:
|
||||||
|
variables["cursor"] = % after
|
||||||
|
let
|
||||||
|
url = apiReq(graphSearchTimeline, $variables)
|
||||||
|
js = await fetch(url)
|
||||||
|
result = parseGraphSearch[Tweets](js, after)
|
||||||
|
result.query = query
|
||||||
|
|
||||||
|
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
|
||||||
|
if query.text.len == 0:
|
||||||
|
return Result[User](query: query, beginning: true)
|
||||||
|
|
||||||
|
var
|
||||||
|
variables = %*{
|
||||||
|
"rawQuery": query.text,
|
||||||
|
"query_source": "typedQuery",
|
||||||
|
"count": 20,
|
||||||
|
"product": "People",
|
||||||
|
"withDownvotePerspective": false,
|
||||||
|
"withReactionsMetadata": false,
|
||||||
|
"withReactionsPerspective": false
|
||||||
|
}
|
||||||
|
if after.len > 0:
|
||||||
|
variables["cursor"] = % after
|
||||||
|
result.beginning = false
|
||||||
|
|
||||||
|
let
|
||||||
|
url = apiReq(graphSearchTimeline, $variables)
|
||||||
|
js = await fetch(url)
|
||||||
|
result = parseGraphSearch[User](js, after)
|
||||||
|
result.query = query
|
||||||
|
|
||||||
|
proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
|
||||||
|
if id.len == 0: return
|
||||||
|
let js = await fetch(mediaUrl(id, ""))
|
||||||
|
result = parseGraphPhotoRail(js)
|
||||||
|
|
||||||
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
|
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
|
||||||
let client = newAsyncHttpClient(maxRedirects=0)
|
let client = newAsyncHttpClient(maxRedirects=0)
|
||||||
|
|||||||
238
src/apiutils.nim
238
src/apiutils.nim
@@ -1,121 +1,203 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import httpclient, asyncdispatch, options, strutils, uri
|
import httpclient, asyncdispatch, options, strutils, uri, times, math, tables
|
||||||
import jsony, packedjson, zippy
|
import jsony, packedjson, zippy, oauth1
|
||||||
import types, tokens, consts, parserutils, http_pool
|
import types, auth, consts, parserutils, http_pool, tid
|
||||||
import experimental/types/common
|
import experimental/types/common
|
||||||
|
|
||||||
const
|
const
|
||||||
rlRemaining = "x-rate-limit-remaining"
|
rlRemaining = "x-rate-limit-remaining"
|
||||||
rlReset = "x-rate-limit-reset"
|
rlReset = "x-rate-limit-reset"
|
||||||
|
rlLimit = "x-rate-limit-limit"
|
||||||
|
errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest}
|
||||||
|
|
||||||
var pool: HttpPool
|
var
|
||||||
|
pool: HttpPool
|
||||||
|
disableTid: bool
|
||||||
|
apiProxy: string
|
||||||
|
|
||||||
proc genParams*(pars: openArray[(string, string)] = @[]; cursor="";
|
proc setDisableTid*(disable: bool) =
|
||||||
count="20"; ext=true): seq[(string, string)] =
|
disableTid = disable
|
||||||
result = timelineParams
|
|
||||||
for p in pars:
|
|
||||||
result &= p
|
|
||||||
if ext:
|
|
||||||
result &= ("ext", "mediaStats")
|
|
||||||
result &= ("include_ext_alt_text", "true")
|
|
||||||
result &= ("include_ext_media_availability", "true")
|
|
||||||
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 them back into a plus
|
|
||||||
if " " in cursor:
|
|
||||||
result &= ("cursor", cursor.replace(" ", "+"))
|
|
||||||
else:
|
|
||||||
result &= ("cursor", cursor)
|
|
||||||
|
|
||||||
proc genHeaders*(token: Token = nil): HttpHeaders =
|
proc setApiProxy*(url: string) =
|
||||||
|
if url.len > 0:
|
||||||
|
apiProxy = url.strip(chars={'/'}) & "/"
|
||||||
|
if "http" notin apiProxy:
|
||||||
|
apiProxy = "http://" & apiProxy
|
||||||
|
|
||||||
|
proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri =
|
||||||
|
case sessionKind
|
||||||
|
of oauth:
|
||||||
|
let o = req.oauth
|
||||||
|
parseUri("https://api.x.com/graphql") / o.endpoint ? o.params
|
||||||
|
of cookie:
|
||||||
|
let c = req.cookie
|
||||||
|
parseUri("https://x.com/i/api/graphql") / c.endpoint ? c.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): Future[HttpHeaders] {.async.} =
|
||||||
result = newHttpHeaders({
|
result = newHttpHeaders({
|
||||||
"connection": "keep-alive",
|
"accept": "*/*",
|
||||||
"authorization": auth,
|
|
||||||
"content-type": "application/json",
|
|
||||||
"x-guest-token": if token == nil: "" else: token.tok,
|
|
||||||
"x-twitter-active-user": "yes",
|
|
||||||
"authority": "api.twitter.com",
|
|
||||||
"accept-encoding": "gzip",
|
"accept-encoding": "gzip",
|
||||||
"accept-language": "en-US,en;q=0.9",
|
"accept-language": "en-US,en;q=0.9",
|
||||||
"accept": "*/*",
|
"connection": "keep-alive",
|
||||||
"DNT": "1"
|
"content-type": "application/json",
|
||||||
|
"origin": "https://x.com",
|
||||||
|
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
|
||||||
|
"x-twitter-active-user": "yes",
|
||||||
|
"x-twitter-client-language": "en",
|
||||||
|
"priority": "u=1, i"
|
||||||
})
|
})
|
||||||
|
|
||||||
template updateToken() =
|
case session.kind
|
||||||
if api != Api.search and resp.headers.hasKey(rlRemaining):
|
of SessionKind.oauth:
|
||||||
let
|
result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret)
|
||||||
remaining = parseInt(resp.headers[rlRemaining])
|
of SessionKind.cookie:
|
||||||
reset = parseInt(resp.headers[rlReset])
|
result["x-twitter-auth-type"] = "OAuth2Session"
|
||||||
token.setRateLimit(api, remaining, reset)
|
result["x-csrf-token"] = session.ct0
|
||||||
|
result["cookie"] = getCookieHeader(session.authToken, session.ct0)
|
||||||
|
result["sec-ch-ua"] = """"Google Chrome";v="142", "Chromium";v="142", "Not A(Brand";v="24""""
|
||||||
|
result["sec-ch-ua-mobile"] = "?0"
|
||||||
|
result["sec-ch-ua-platform"] = "Windows"
|
||||||
|
result["sec-fetch-dest"] = "empty"
|
||||||
|
result["sec-fetch-mode"] = "cors"
|
||||||
|
result["sec-fetch-site"] = "same-site"
|
||||||
|
if disableTid:
|
||||||
|
result["authorization"] = bearerToken2
|
||||||
|
else:
|
||||||
|
result["authorization"] = bearerToken
|
||||||
|
result["x-client-transaction-id"] = await genTid(url.path)
|
||||||
|
|
||||||
|
proc getAndValidateSession*(req: ApiReq): Future[Session] {.async.} =
|
||||||
|
result = await getSession(req)
|
||||||
|
case result.kind
|
||||||
|
of SessionKind.oauth:
|
||||||
|
if result.oauthToken.len == 0:
|
||||||
|
echo "[sessions] Empty oauth token, session: ", result.pretty
|
||||||
|
raise rateLimitError()
|
||||||
|
of SessionKind.cookie:
|
||||||
|
if result.authToken.len == 0 or result.ct0.len == 0:
|
||||||
|
echo "[sessions] Empty cookie credentials, session: ", result.pretty
|
||||||
|
raise rateLimitError()
|
||||||
|
|
||||||
template fetchImpl(result, fetchBody) {.dirty.} =
|
template fetchImpl(result, fetchBody) {.dirty.} =
|
||||||
once:
|
once:
|
||||||
pool = HttpPool()
|
pool = HttpPool()
|
||||||
|
|
||||||
var token = await getToken(api)
|
|
||||||
if token.tok.len == 0:
|
|
||||||
raise rateLimitError()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
var resp: AsyncResponse
|
var resp: AsyncResponse
|
||||||
pool.use(genHeaders(token)):
|
pool.use(await genHeaders(session, url)):
|
||||||
resp = await c.get($url)
|
template getContent =
|
||||||
result = await resp.body
|
# TODO: this is a temporary simple implementation
|
||||||
|
if apiProxy.len > 0:
|
||||||
|
resp = await c.get(($url).replace("https://", apiProxy))
|
||||||
|
else:
|
||||||
|
resp = await c.get($url)
|
||||||
|
result = await resp.body
|
||||||
|
|
||||||
|
getContent()
|
||||||
|
|
||||||
if resp.status == $Http503:
|
if resp.status == $Http503:
|
||||||
badClient = true
|
badClient = true
|
||||||
raise newException(InternalError, result)
|
raise newException(BadClientError, "Bad client")
|
||||||
|
|
||||||
|
if resp.headers.hasKey(rlRemaining):
|
||||||
|
let
|
||||||
|
remaining = parseInt(resp.headers[rlRemaining])
|
||||||
|
reset = parseInt(resp.headers[rlReset])
|
||||||
|
limit = parseInt(resp.headers[rlLimit])
|
||||||
|
session.setRateLimit(req, remaining, reset, limit)
|
||||||
|
|
||||||
if result.len > 0:
|
if result.len > 0:
|
||||||
if resp.headers.getOrDefault("content-encoding") == "gzip":
|
if resp.headers.getOrDefault("content-encoding") == "gzip":
|
||||||
result = uncompress(result, dfGzip)
|
result = uncompress(result, dfGzip)
|
||||||
else:
|
|
||||||
echo "non-gzip body, url: ", url, ", body: ", result
|
if result.startsWith("{\"errors"):
|
||||||
|
let errors = result.fromJson(Errors)
|
||||||
|
if errors notin errorsToSkip:
|
||||||
|
echo "Fetch error, API: ", url.path, ", errors: ", errors
|
||||||
|
if errors in {expiredToken, badToken, locked}:
|
||||||
|
invalidate(session)
|
||||||
|
raise rateLimitError()
|
||||||
|
elif errors in {rateLimited}:
|
||||||
|
# rate limit hit, resets after 24 hours
|
||||||
|
setLimited(session, req)
|
||||||
|
raise rateLimitError()
|
||||||
|
elif result.startsWith("429 Too Many Requests"):
|
||||||
|
echo "[sessions] 429 error, API: ", url.path, ", session: ", session.pretty
|
||||||
|
raise rateLimitError()
|
||||||
|
|
||||||
fetchBody
|
fetchBody
|
||||||
|
|
||||||
release(token, used=true)
|
|
||||||
|
|
||||||
if resp.status == $Http400:
|
if resp.status == $Http400:
|
||||||
|
echo "ERROR 400, ", url.path, ": ", result
|
||||||
raise newException(InternalError, $url)
|
raise newException(InternalError, $url)
|
||||||
except InternalError as e:
|
except InternalError as e:
|
||||||
raise e
|
raise e
|
||||||
|
except BadClientError as e:
|
||||||
|
raise e
|
||||||
|
except OSError as e:
|
||||||
|
raise e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
echo "error: ", e.name, ", msg: ", e.msg, ", token: ", token[], ", url: ", url
|
let s = session.pretty
|
||||||
if "length" notin e.msg and "descriptor" notin e.msg:
|
echo "error: ", e.name, ", msg: ", e.msg, ", session: ", s, ", url: ", url
|
||||||
release(token, invalid=true)
|
|
||||||
raise rateLimitError()
|
raise rateLimitError()
|
||||||
|
finally:
|
||||||
|
release(session)
|
||||||
|
|
||||||
proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} =
|
template retry(bod) =
|
||||||
var body: string
|
try:
|
||||||
fetchImpl body:
|
bod
|
||||||
if body.startsWith('{') or body.startsWith('['):
|
except RateLimitError:
|
||||||
result = parseJson(body)
|
echo "[sessions] Rate limited, retrying ", req.cookie.endpoint, " request..."
|
||||||
else:
|
bod
|
||||||
echo resp.status, ": ", body, " --- url: ", url
|
|
||||||
result = newJNull()
|
|
||||||
|
|
||||||
updateToken()
|
proc fetch*(req: ApiReq): Future[JsonNode] {.async.} =
|
||||||
|
retry:
|
||||||
|
var
|
||||||
|
body: string
|
||||||
|
session = await getAndValidateSession(req)
|
||||||
|
|
||||||
let error = result.getError
|
let url = req.toUrl(session.kind)
|
||||||
if error in {invalidToken, forbidden, badToken}:
|
|
||||||
echo "fetch error: ", result.getError
|
|
||||||
release(token, invalid=true)
|
|
||||||
raise rateLimitError()
|
|
||||||
|
|
||||||
proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} =
|
fetchImpl body:
|
||||||
fetchImpl result:
|
if body.startsWith('{') or body.startsWith('['):
|
||||||
if not (result.startsWith('{') or result.startsWith('[')):
|
result = parseJson(body)
|
||||||
echo resp.status, ": ", result, " --- url: ", url
|
else:
|
||||||
result.setLen(0)
|
echo resp.status, ": ", body, " --- url: ", url
|
||||||
|
result = newJNull()
|
||||||
|
|
||||||
updateToken()
|
let error = result.getError
|
||||||
|
if error != null and error notin errorsToSkip:
|
||||||
|
echo "Fetch error, API: ", url.path, ", error: ", error
|
||||||
|
if error in {expiredToken, badToken, locked}:
|
||||||
|
invalidate(session)
|
||||||
|
raise rateLimitError()
|
||||||
|
|
||||||
if result.startsWith("{\"errors"):
|
proc fetchRaw*(req: ApiReq): Future[string] {.async.} =
|
||||||
let errors = result.fromJson(Errors)
|
retry:
|
||||||
if errors in {invalidToken, forbidden, badToken}:
|
var session = await getAndValidateSession(req)
|
||||||
echo "fetch error: ", errors
|
let url = req.toUrl(session.kind)
|
||||||
release(token, invalid=true)
|
|
||||||
raise rateLimitError()
|
fetchImpl result:
|
||||||
|
if not (result.startsWith('{') or result.startsWith('[')):
|
||||||
|
echo resp.status, ": ", result, " --- url: ", url
|
||||||
|
result.setLen(0)
|
||||||
|
|||||||
210
src/auth.nim
Normal file
210
src/auth.nim
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
#SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os]
|
||||||
|
import types, consts
|
||||||
|
import experimental/parser/session
|
||||||
|
|
||||||
|
# max requests at a time per session to avoid race conditions
|
||||||
|
const
|
||||||
|
maxConcurrentReqs = 2
|
||||||
|
hourInSeconds = 60 * 60
|
||||||
|
|
||||||
|
var
|
||||||
|
sessionPool: seq[Session]
|
||||||
|
enableLogging = false
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
for session in sessionPool:
|
||||||
|
let created = snowflakeToEpoch(session.id)
|
||||||
|
if created > newest:
|
||||||
|
newest = created
|
||||||
|
if created < oldest:
|
||||||
|
oldest = created
|
||||||
|
average += created
|
||||||
|
|
||||||
|
if session.limited:
|
||||||
|
limited.incl session.id
|
||||||
|
|
||||||
|
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,
|
||||||
|
"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 = %*{
|
||||||
|
"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:
|
||||||
|
log "no sessions available for API: ", req.cookie.endpoint
|
||||||
|
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"
|
||||||
@@ -40,7 +40,9 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
|
|||||||
enableRss: cfg.get("Config", "enableRSS", true),
|
enableRss: cfg.get("Config", "enableRSS", true),
|
||||||
enableDebug: cfg.get("Config", "enableDebug", false),
|
enableDebug: cfg.get("Config", "enableDebug", false),
|
||||||
proxy: cfg.get("Config", "proxy", ""),
|
proxy: cfg.get("Config", "proxy", ""),
|
||||||
proxyAuth: cfg.get("Config", "proxyAuth", "")
|
proxyAuth: cfg.get("Config", "proxyAuth", ""),
|
||||||
|
apiProxy: cfg.get("Config", "apiProxy", ""),
|
||||||
|
disableTid: cfg.get("Config", "disableTid", false)
|
||||||
)
|
)
|
||||||
|
|
||||||
return (conf, cfg)
|
return (conf, cfg)
|
||||||
|
|||||||
204
src/consts.nim
204
src/consts.nim
@@ -1,59 +1,163 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import uri, sequtils
|
import strutils
|
||||||
|
|
||||||
const
|
const
|
||||||
auth* = "Bearer AAAAAAAAAAAAAAAAAAAAAPYXBAAAAAAACLXUNDekMxqa8h%2F40K4moUkGsoc%3DTYfbDKbT3jJPCEVnMYqilB28NHfOPqkca3qaAxGfsyKCs0wRbw"
|
consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
|
||||||
|
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
|
||||||
|
bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
|
||||||
|
bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F"
|
||||||
|
|
||||||
api = parseUri("https://api.twitter.com")
|
graphUser* = "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName"
|
||||||
activate* = $(api / "1.1/guest/activate.json")
|
graphUserV2* = "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery"
|
||||||
|
graphUserById* = "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery"
|
||||||
|
graphUserTweetsV2* = "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2"
|
||||||
|
graphUserTweetsAndRepliesV2* = "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2"
|
||||||
|
graphUserTweets* = "oRJs8SLCRNRbQzuZG93_oA/UserTweets"
|
||||||
|
graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
|
||||||
|
graphUserMedia* = "36oKqyQ7E_9CmtONGjJRsA/UserMedia"
|
||||||
|
graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2"
|
||||||
|
graphTweet* = "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline"
|
||||||
|
graphTweetDetail* = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail"
|
||||||
|
graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery"
|
||||||
|
graphSearchTimeline* = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline"
|
||||||
|
graphListById* = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId"
|
||||||
|
graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
|
||||||
|
graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
|
||||||
|
graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
|
||||||
|
|
||||||
userShow* = api / "1.1/users/show.json"
|
gqlFeatures* = """{
|
||||||
photoRail* = api / "1.1/statuses/media_timeline.json"
|
"android_ad_formats_media_component_render_overlay_enabled": false,
|
||||||
status* = api / "1.1/statuses/show"
|
"android_graphql_skip_api_media_color_palette": false,
|
||||||
search* = api / "2/search/adaptive.json"
|
"android_professional_link_spotlight_display_enabled": false,
|
||||||
|
"blue_business_profile_image_shape_enabled": false,
|
||||||
|
"commerce_android_shop_module_enabled": false,
|
||||||
|
"creator_subscriptions_subscription_count_enabled": false,
|
||||||
|
"creator_subscriptions_tweet_preview_api_enabled": true,
|
||||||
|
"freedom_of_speech_not_reach_fetch_enabled": true,
|
||||||
|
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
|
||||||
|
"hidden_profile_likes_enabled": false,
|
||||||
|
"highlights_tweets_tab_ui_enabled": false,
|
||||||
|
"interactive_text_enabled": false,
|
||||||
|
"longform_notetweets_consumption_enabled": true,
|
||||||
|
"longform_notetweets_inline_media_enabled": true,
|
||||||
|
"longform_notetweets_rich_text_read_enabled": true,
|
||||||
|
"longform_notetweets_richtext_consumption_enabled": true,
|
||||||
|
"mobile_app_spotlight_module_enabled": false,
|
||||||
|
"responsive_web_edit_tweet_api_enabled": true,
|
||||||
|
"responsive_web_enhance_cards_enabled": false,
|
||||||
|
"responsive_web_graphql_exclude_directive_enabled": true,
|
||||||
|
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
|
||||||
|
"responsive_web_graphql_timeline_navigation_enabled": true,
|
||||||
|
"responsive_web_media_download_video_enabled": false,
|
||||||
|
"responsive_web_text_conversations_enabled": false,
|
||||||
|
"responsive_web_twitter_article_tweet_consumption_enabled": true,
|
||||||
|
"unified_cards_destination_url_params_enabled": false,
|
||||||
|
"responsive_web_twitter_blue_verified_badge_is_enabled": true,
|
||||||
|
"rweb_lists_timeline_redesign_enabled": true,
|
||||||
|
"spaces_2022_h2_clipping": true,
|
||||||
|
"spaces_2022_h2_spaces_communities": true,
|
||||||
|
"standardized_nudges_misinfo": true,
|
||||||
|
"subscriptions_verification_info_enabled": true,
|
||||||
|
"subscriptions_verification_info_reason_enabled": true,
|
||||||
|
"subscriptions_verification_info_verified_since_enabled": true,
|
||||||
|
"super_follow_badge_privacy_enabled": false,
|
||||||
|
"super_follow_exclusive_tweet_notifications_enabled": false,
|
||||||
|
"super_follow_tweet_api_enabled": false,
|
||||||
|
"super_follow_user_api_enabled": false,
|
||||||
|
"tweet_awards_web_tipping_enabled": false,
|
||||||
|
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
|
||||||
|
"tweetypie_unmention_optimization_enabled": false,
|
||||||
|
"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
|
||||||
|
"verified_phone_label_enabled": false,
|
||||||
|
"vibe_api_enabled": false,
|
||||||
|
"view_counts_everywhere_api_enabled": true,
|
||||||
|
"premium_content_api_read_enabled": false,
|
||||||
|
"communities_web_enable_tweet_community_results_fetch": true,
|
||||||
|
"responsive_web_jetfuel_frame": true,
|
||||||
|
"responsive_web_grok_analyze_button_fetch_trends_enabled": false,
|
||||||
|
"responsive_web_grok_image_annotation_enabled": true,
|
||||||
|
"responsive_web_grok_imagine_annotation_enabled": true,
|
||||||
|
"rweb_tipjar_consumption_enabled": true,
|
||||||
|
"profile_label_improvements_pcf_label_in_post_enabled": true,
|
||||||
|
"creator_subscriptions_quote_tweet_preview_enabled": false,
|
||||||
|
"c9s_tweet_anatomy_moderator_badge_enabled": true,
|
||||||
|
"responsive_web_grok_analyze_post_followups_enabled": true,
|
||||||
|
"rweb_video_timestamps_enabled": false,
|
||||||
|
"responsive_web_grok_share_attachment_enabled": true,
|
||||||
|
"articles_preview_enabled": true,
|
||||||
|
"immersive_video_status_linkable_timestamps": false,
|
||||||
|
"articles_api_enabled": false,
|
||||||
|
"responsive_web_grok_analysis_button_from_backend": true,
|
||||||
|
"rweb_video_screen_enabled": false,
|
||||||
|
"payments_enabled": false,
|
||||||
|
"responsive_web_profile_redirect_enabled": false,
|
||||||
|
"responsive_web_grok_show_grok_translated_post": false,
|
||||||
|
"responsive_web_grok_community_note_auto_translation_is_enabled": false,
|
||||||
|
"profile_label_improvements_pcf_label_in_profile_enabled": false,
|
||||||
|
"grok_android_analyze_trend_fetch_enabled": false,
|
||||||
|
"grok_translations_community_note_auto_translation_is_enabled": false,
|
||||||
|
"grok_translations_post_auto_translation_is_enabled": false,
|
||||||
|
"grok_translations_community_note_translation_is_enabled": false,
|
||||||
|
"grok_translations_timeline_user_bio_auto_translation_is_enabled": false,
|
||||||
|
"subscriptions_feature_can_gift_premium": false,
|
||||||
|
"responsive_web_twitter_article_notes_tab_enabled": false,
|
||||||
|
"subscriptions_verification_info_is_identity_verified_enabled": false,
|
||||||
|
"hidden_profile_subscriptions_enabled": false
|
||||||
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
timelineApi = api / "2/timeline"
|
tweetVars* = """{
|
||||||
timeline* = timelineApi / "profile"
|
"postId": "$1",
|
||||||
mediaTimeline* = timelineApi / "media"
|
$2
|
||||||
listTimeline* = timelineApi / "list.json"
|
"includeHasBirdwatchNotes": false,
|
||||||
tweet* = timelineApi / "conversation"
|
"includePromotedContent": false,
|
||||||
|
"withBirdwatchNotes": false,
|
||||||
|
"withVoice": false,
|
||||||
|
"withV2Timeline": true
|
||||||
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
graphql = api / "graphql"
|
tweetDetailVars* = """{
|
||||||
graphUser* = graphql / "I5nvpI91ljifos1Y3Lltyg/UserByRestId"
|
"focalTweetId": "$1",
|
||||||
graphList* = graphql / "JADTh6cjebfgetzvF3tQvQ/List"
|
$2
|
||||||
graphListBySlug* = graphql / "ErWsz9cObLel1BF-HjuBlA/ListBySlug"
|
"referrer": "profile",
|
||||||
graphListMembers* = graphql / "Ke6urWMeCV2UlKXGRy4sow/ListMembers"
|
"with_rux_injections": false,
|
||||||
|
"rankingMode": "Relevance",
|
||||||
|
"includePromotedContent": true,
|
||||||
|
"withCommunity": true,
|
||||||
|
"withQuickPromoteEligibilityTweetFields": true,
|
||||||
|
"withBirdwatchNotes": true,
|
||||||
|
"withVoice": true
|
||||||
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
timelineParams* = {
|
restIdVars* = """{
|
||||||
"include_profile_interstitial_type": "0",
|
"rest_id": "$1", $2
|
||||||
"include_blocking": "0",
|
"count": 20
|
||||||
"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",
|
|
||||||
"skip_status": "1",
|
|
||||||
"cards_platform": "Web-12",
|
|
||||||
"include_cards": "1",
|
|
||||||
"include_composer_source": "false",
|
|
||||||
"include_reply_count": "1",
|
|
||||||
"tweet_mode": "extended",
|
|
||||||
"include_entities": "true",
|
|
||||||
"include_user_entities": "true",
|
|
||||||
"include_ext_media_color": "false",
|
|
||||||
"send_error_codes": "true",
|
|
||||||
"simple_quoted_tweet": "true",
|
|
||||||
"include_quote_count": "true"
|
|
||||||
}.toSeq
|
|
||||||
|
|
||||||
searchParams* = {
|
userMediaVars* = """{
|
||||||
"query_source": "typed_query",
|
"userId": "$1", $2
|
||||||
"pc": "1",
|
"count": 20,
|
||||||
"spelling_corrections": "1"
|
"includePromotedContent": false,
|
||||||
}.toSeq
|
"withClientEventToken": false,
|
||||||
## top: nothing
|
"withBirdwatchNotes": false,
|
||||||
## latest: "tweet_search_mode: live"
|
"withVoice": true
|
||||||
## user: "result_filter: user"
|
}""".replace(" ", "").replace("\n", "")
|
||||||
## photos: "result_filter: photos"
|
|
||||||
## videos: "result_filter: videos"
|
userTweetsVars* = """{
|
||||||
|
"userId": "$1", $2
|
||||||
|
"count": 20,
|
||||||
|
"includePromotedContent": false,
|
||||||
|
"withQuickPromoteEligibilityTweetFields": true,
|
||||||
|
"withVoice": true
|
||||||
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
|
userTweetsAndRepliesVars* = """{
|
||||||
|
"userId": "$1", $2
|
||||||
|
"count": 20,
|
||||||
|
"includePromotedContent": 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,2 +1,2 @@
|
|||||||
import parser/[user, graphql, timeline]
|
import parser/[user, graphql]
|
||||||
export user, graphql, timeline
|
export user, graphql
|
||||||
|
|||||||
@@ -1,11 +1,53 @@
|
|||||||
|
import options, strutils
|
||||||
import jsony
|
import jsony
|
||||||
import user, ../types/[graphuser, graphlistmembers]
|
import user, utils, ../types/[graphuser, graphlistmembers]
|
||||||
from ../../types import User, Result, Query, QueryKind
|
from ../../types import User, VerifiedType, Result, Query, QueryKind
|
||||||
|
|
||||||
|
proc parseUserResult*(userResult: UserResult): User =
|
||||||
|
result = userResult.legacy
|
||||||
|
|
||||||
|
if result.verifiedType == none and userResult.isBlueVerified:
|
||||||
|
result.verifiedType = blue
|
||||||
|
|
||||||
|
if result.username.len == 0 and userResult.core.screenName.len > 0:
|
||||||
|
result.id = userResult.restId
|
||||||
|
result.username = userResult.core.screenName
|
||||||
|
result.fullname = userResult.core.name
|
||||||
|
result.userPic = userResult.avatar.imageUrl.replace("_normal", "")
|
||||||
|
|
||||||
|
if userResult.privacy.isSome:
|
||||||
|
result.protected = userResult.privacy.get.protected
|
||||||
|
|
||||||
|
if userResult.location.isSome:
|
||||||
|
result.location = userResult.location.get.location
|
||||||
|
|
||||||
|
if userResult.core.createdAt.len > 0:
|
||||||
|
result.joinDate = parseTwitterDate(userResult.core.createdAt)
|
||||||
|
|
||||||
|
if userResult.verification.isSome:
|
||||||
|
let v = userResult.verification.get
|
||||||
|
if v.verifiedType != VerifiedType.none:
|
||||||
|
result.verifiedType = v.verifiedType
|
||||||
|
|
||||||
|
if userResult.profileBio.isSome and result.bio.len == 0:
|
||||||
|
result.bio = userResult.profileBio.get.description
|
||||||
|
|
||||||
proc parseGraphUser*(json: string): User =
|
proc parseGraphUser*(json: string): User =
|
||||||
let raw = json.fromJson(GraphUser)
|
if json.len == 0 or json[0] != '{':
|
||||||
result = toUser raw.data.user.result.legacy
|
return
|
||||||
result.id = raw.data.user.result.restId
|
|
||||||
|
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 = parseUserResult(userResult)
|
||||||
|
|
||||||
proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
||||||
result = Result[User](
|
result = Result[User](
|
||||||
@@ -21,7 +63,7 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
|||||||
of TimelineTimelineItem:
|
of TimelineTimelineItem:
|
||||||
let userResult = entry.content.itemContent.userResults.result
|
let userResult = entry.content.itemContent.userResults.result
|
||||||
if userResult.restId.len > 0:
|
if userResult.restId.len > 0:
|
||||||
result.content.add toUser userResult.legacy
|
result.content.add parseUserResult(userResult)
|
||||||
of TimelineTimelineCursor:
|
of TimelineTimelineCursor:
|
||||||
if entry.content.cursorType == "Bottom":
|
if entry.content.cursorType == "Bottom":
|
||||||
result.bottom = entry.content.value
|
result.bottom = entry.content.value
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import std/[json, strutils, times, math]
|
|
||||||
import utils
|
|
||||||
import ".."/types/[media, tweet]
|
|
||||||
from ../../types import Poll, Gif, Video, VideoVariant, VideoType
|
|
||||||
|
|
||||||
proc parseVideo*(entity: Entity): Video =
|
|
||||||
result = Video(
|
|
||||||
thumb: entity.mediaUrlHttps.getImageUrl,
|
|
||||||
views: entity.ext.mediaStats{"r", "ok", "viewCount"}.getStr,
|
|
||||||
available: entity.extMediaAvailability.status == "available",
|
|
||||||
title: entity.extAltText,
|
|
||||||
durationMs: entity.videoInfo.durationMillis,
|
|
||||||
description: entity.additionalMediaInfo.description,
|
|
||||||
variants: entity.videoInfo.variants
|
|
||||||
# playbackType: mp4
|
|
||||||
)
|
|
||||||
|
|
||||||
if entity.additionalMediaInfo.title.len > 0:
|
|
||||||
result.title = entity.additionalMediaInfo.title
|
|
||||||
|
|
||||||
proc parseGif*(entity: Entity): Gif =
|
|
||||||
result = Gif(
|
|
||||||
url: entity.videoInfo.variants[0].url.getImageUrl,
|
|
||||||
thumb: entity.getImageUrl
|
|
||||||
)
|
|
||||||
|
|
||||||
proc parsePoll*(card: Card): Poll =
|
|
||||||
let vals = card.bindingValues
|
|
||||||
|
|
||||||
# name format is pollNchoice_*
|
|
||||||
for i in '1' .. card.name[4]:
|
|
||||||
let choice = "choice" & i
|
|
||||||
result.values.add parseInt(vals{choice & "_count", "string_value"}.getStr("0"))
|
|
||||||
result.options.add vals{choice & "_label", "string_value"}.getStr
|
|
||||||
|
|
||||||
let time = vals{"end_datetime_utc", "string_value"}.getStr.parseIsoDate
|
|
||||||
if time > now():
|
|
||||||
let timeLeft = $(time - now())
|
|
||||||
result.status = timeLeft[0 ..< timeLeft.find(",")]
|
|
||||||
else:
|
|
||||||
result.status = "Final results"
|
|
||||||
|
|
||||||
result.leader = result.values.find(max(result.values))
|
|
||||||
result.votes = result.values.sum
|
|
||||||
30
src/experimental/parser/session.nim
Normal file
30
src/experimental/parser/session.nim
Normal file
@@ -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)
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import std/[macros, htmlgen, unicode]
|
import std/[macros, htmlgen, unicode]
|
||||||
|
import ../types/common
|
||||||
import ".."/../[formatters, utils]
|
import ".."/../[formatters, utils]
|
||||||
|
|
||||||
type
|
type
|
||||||
ReplaceSliceKind* = enum
|
ReplaceSliceKind = enum
|
||||||
rkRemove, rkUrl, rkHashtag, rkMention
|
rkRemove, rkUrl, rkHashtag, rkMention
|
||||||
|
|
||||||
ReplaceSlice* = object
|
ReplaceSlice* = object
|
||||||
slice*: Slice[int]
|
slice: Slice[int]
|
||||||
kind*: ReplaceSliceKind
|
kind: ReplaceSliceKind
|
||||||
url*, display*: string
|
url, display: string
|
||||||
|
|
||||||
proc cmp*(x, y: ReplaceSlice): int = cmp(x.slice.a, y.slice.b)
|
proc cmp*(x, y: ReplaceSlice): int = cmp(x.slice.a, y.slice.b)
|
||||||
|
|
||||||
@@ -26,14 +27,11 @@ proc dedupSlices*(s: var seq[ReplaceSlice]) =
|
|||||||
inc j
|
inc j
|
||||||
inc i
|
inc i
|
||||||
|
|
||||||
proc extractHashtags*(result: var seq[ReplaceSlice]; slice: Slice[int]) =
|
proc extractUrls*(result: var seq[ReplaceSlice]; url: Url;
|
||||||
result.add ReplaceSlice(kind: rkHashtag, slice: slice)
|
textLen: int; hideTwitter = false) =
|
||||||
|
|
||||||
proc extractUrls*[T](result: var seq[ReplaceSlice]; entity: T;
|
|
||||||
textLen: int; hideTwitter = false) =
|
|
||||||
let
|
let
|
||||||
link = entity.expandedUrl
|
link = url.expandedUrl
|
||||||
slice = entity.indices
|
slice = url.indices[0] ..< url.indices[1]
|
||||||
|
|
||||||
if hideTwitter and slice.b.succ >= textLen and link.isTwitterUrl:
|
if hideTwitter and slice.b.succ >= textLen and link.isTwitterUrl:
|
||||||
if slice.a < textLen:
|
if slice.a < textLen:
|
||||||
|
|||||||
8
src/experimental/parser/tid.nim
Normal file
8
src/experimental/parser/tid.nim
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import jsony
|
||||||
|
import ../types/tid
|
||||||
|
export TidPair
|
||||||
|
|
||||||
|
proc parseTidPairs*(raw: string): seq[TidPair] =
|
||||||
|
result = raw.fromJson(seq[TidPair])
|
||||||
|
if result.len == 0:
|
||||||
|
raise newException(ValueError, "Parsing pairs failed: " & raw)
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import std/[strutils, tables, options]
|
|
||||||
import jsony
|
|
||||||
import user, tweet, utils, ../types/timeline
|
|
||||||
from ../../types import Result, User, Tweet
|
|
||||||
|
|
||||||
proc parseHook(s: string; i: var int; v: var Slice[int]) =
|
|
||||||
var slice: array[2, int]
|
|
||||||
parseHook(s, i, slice)
|
|
||||||
v = slice[0] ..< slice[1]
|
|
||||||
|
|
||||||
proc getId(id: string): string {.inline.} =
|
|
||||||
let start = id.rfind("-")
|
|
||||||
if start < 0: return id
|
|
||||||
id[start + 1 ..< id.len]
|
|
||||||
|
|
||||||
proc processTweet(id: string; objects: GlobalObjects;
|
|
||||||
userCache: var Table[string, User]): Tweet =
|
|
||||||
let raw = objects.tweets[id]
|
|
||||||
result = toTweet raw
|
|
||||||
|
|
||||||
let uid = result.user.id
|
|
||||||
if uid.len > 0 and uid in objects.users:
|
|
||||||
if uid notin userCache:
|
|
||||||
userCache[uid] = toUser objects.users[uid]
|
|
||||||
result.user = userCache[uid]
|
|
||||||
|
|
||||||
let rtId = raw.retweetedStatusIdStr
|
|
||||||
if rtId.len > 0:
|
|
||||||
if rtId in objects.tweets:
|
|
||||||
result.retweet = some processTweet(rtId, objects, userCache)
|
|
||||||
else:
|
|
||||||
result.retweet = some Tweet(id: rtId.toId)
|
|
||||||
|
|
||||||
let qId = raw.quotedStatusIdStr
|
|
||||||
if qId.len > 0:
|
|
||||||
if qId in objects.tweets:
|
|
||||||
result.quote = some processTweet(qId, objects, userCache)
|
|
||||||
else:
|
|
||||||
result.quote = some Tweet(id: qId.toId)
|
|
||||||
|
|
||||||
proc parseCursor[T](e: Entry; result: var Result[T]) =
|
|
||||||
let cursor = e.content.operation.cursor
|
|
||||||
if cursor.cursorType == "Top":
|
|
||||||
result.top = cursor.value
|
|
||||||
elif cursor.cursorType == "Bottom":
|
|
||||||
result.bottom = cursor.value
|
|
||||||
|
|
||||||
proc parseUsers*(json: string; after=""): Result[User] =
|
|
||||||
result = Result[User](beginning: after.len == 0)
|
|
||||||
|
|
||||||
let raw = json.fromJson(Search)
|
|
||||||
if raw.timeline.instructions.len == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
for e in raw.timeline.instructions[0].addEntries.entries:
|
|
||||||
let
|
|
||||||
eId = e.entryId
|
|
||||||
id = eId.getId
|
|
||||||
|
|
||||||
if eId.startsWith("user") or eId.startsWith("sq-U"):
|
|
||||||
if id in raw.globalObjects.users:
|
|
||||||
result.content.add toUser raw.globalObjects.users[id]
|
|
||||||
elif eId.startsWith("cursor") or eId.startsWith("sq-C"):
|
|
||||||
parseCursor(e, result)
|
|
||||||
|
|
||||||
proc parseTweets*(json: string; after=""): Result[Tweet] =
|
|
||||||
result = Result[Tweet](beginning: after.len == 0)
|
|
||||||
|
|
||||||
let raw = json.fromJson(Search)
|
|
||||||
if raw.timeline.instructions.len == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
var userCache: Table[string, User]
|
|
||||||
|
|
||||||
for e in raw.timeline.instructions[0].addEntries.entries:
|
|
||||||
let
|
|
||||||
eId = e.entryId
|
|
||||||
id = eId.getId
|
|
||||||
|
|
||||||
if eId.startsWith("tweet") or eId.startsWith("sq-I-t"):
|
|
||||||
if id in raw.globalObjects.tweets:
|
|
||||||
result.content.add processTweet(id, raw.globalObjects, userCache)
|
|
||||||
elif eId.startsWith("cursor") or eId.startsWith("sq-C"):
|
|
||||||
parseCursor(e, result)
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import std/[strutils, options, algorithm, json]
|
|
||||||
import std/unicode except strip
|
|
||||||
import utils, slices, media, user
|
|
||||||
import ../types/tweet
|
|
||||||
from ../types/media as mediaTypes import MediaType
|
|
||||||
from ../../types import Tweet, User, TweetStats
|
|
||||||
|
|
||||||
proc expandTweetEntities(tweet: var Tweet; raw: RawTweet) =
|
|
||||||
let
|
|
||||||
orig = raw.fullText.toRunes
|
|
||||||
textRange = raw.displayTextRange
|
|
||||||
textSlice = textRange[0] .. textRange[1]
|
|
||||||
hasCard = raw.card.isSome
|
|
||||||
|
|
||||||
var replyTo = ""
|
|
||||||
if tweet.replyId > 0:
|
|
||||||
tweet.reply.add raw.inReplyToScreenName
|
|
||||||
replyTo = raw.inReplyToScreenName
|
|
||||||
|
|
||||||
var replacements = newSeq[ReplaceSlice]()
|
|
||||||
|
|
||||||
for u in raw.entities.urls:
|
|
||||||
if u.url.len == 0 or u.url notin raw.fullText:
|
|
||||||
continue
|
|
||||||
|
|
||||||
replacements.extractUrls(u, textSlice.b, hideTwitter=raw.isQuoteStatus)
|
|
||||||
# if hasCard and u.url == get(tweet.card).url:
|
|
||||||
# get(tweet.card).url = u.expandedUrl
|
|
||||||
|
|
||||||
for m in raw.entities.media:
|
|
||||||
replacements.extractUrls(m, textSlice.b, hideTwitter=true)
|
|
||||||
|
|
||||||
for hashtag in raw.entities.hashtags:
|
|
||||||
replacements.extractHashtags(hashtag.indices)
|
|
||||||
|
|
||||||
for symbol in raw.entities.symbols:
|
|
||||||
replacements.extractHashtags(symbol.indices)
|
|
||||||
|
|
||||||
for mention in raw.entities.userMentions:
|
|
||||||
let
|
|
||||||
name = mention.screenName
|
|
||||||
idx = tweet.reply.find(name)
|
|
||||||
|
|
||||||
if mention.indices.a >= textSlice.a:
|
|
||||||
replacements.add ReplaceSlice(kind: rkMention, slice: mention.indices,
|
|
||||||
url: "/" & name, display: mention.name)
|
|
||||||
if idx > -1 and name != replyTo:
|
|
||||||
tweet.reply.delete idx
|
|
||||||
elif idx == -1 and tweet.replyId != 0:
|
|
||||||
tweet.reply.add name
|
|
||||||
|
|
||||||
replacements.dedupSlices
|
|
||||||
replacements.sort(cmp)
|
|
||||||
|
|
||||||
tweet.text = orig.replacedWith(replacements, textSlice)
|
|
||||||
.strip(leading=false)
|
|
||||||
|
|
||||||
proc toTweet*(raw: RawTweet): Tweet =
|
|
||||||
result = Tweet(
|
|
||||||
id: raw.idStr.toId,
|
|
||||||
threadId: raw.conversationIdStr.toId,
|
|
||||||
replyId: raw.inReplyToStatusIdStr.toId,
|
|
||||||
time: parseTwitterDate(raw.createdAt),
|
|
||||||
hasThread: raw.selfThread.idStr.len > 0,
|
|
||||||
available: true,
|
|
||||||
user: User(id: raw.userIdStr),
|
|
||||||
stats: TweetStats(
|
|
||||||
replies: raw.replyCount,
|
|
||||||
retweets: raw.retweetCount,
|
|
||||||
likes: raw.favoriteCount,
|
|
||||||
quotes: raw.quoteCount
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result.expandTweetEntities(raw)
|
|
||||||
|
|
||||||
if raw.card.isSome:
|
|
||||||
let card = raw.card.get
|
|
||||||
if "poll" in card.name:
|
|
||||||
result.poll = some parsePoll(card)
|
|
||||||
if "image" in card.name:
|
|
||||||
result.photos.add card.bindingValues{"image_large", "image_value", "url"}
|
|
||||||
.getStr.getImageUrl
|
|
||||||
# elif card.name == "amplify":
|
|
||||||
# discard
|
|
||||||
# # result.video = some(parsePromoVideo(jsCard{"binding_values"}))
|
|
||||||
# else:
|
|
||||||
# result.card = some parseCard(card, raw.entities.urls)
|
|
||||||
|
|
||||||
for m in raw.extendedEntities.media:
|
|
||||||
case m.kind
|
|
||||||
of photo: result.photos.add m.getImageUrl
|
|
||||||
of video:
|
|
||||||
result.video = some parseVideo(m)
|
|
||||||
if m.additionalMediaInfo.sourceUser.isSome:
|
|
||||||
result.attribution = some toUser get(m.additionalMediaInfo.sourceUser)
|
|
||||||
of animatedGif: result.gif = some parseGif(m)
|
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import std/[options, tables, strformat]
|
import std/[options, tables, strutils, strformat, sugar]
|
||||||
import jsony
|
import jsony
|
||||||
import utils
|
import user, ../types/unifiedcard
|
||||||
import ".."/types/[unifiedcard, media]
|
import ../../formatters
|
||||||
from ../../types import Card, CardKind, Video
|
from ../../types import Card, CardKind, Video
|
||||||
|
from ../../utils import twimg, https
|
||||||
|
|
||||||
|
proc getImageUrl(entity: MediaEntity): string =
|
||||||
|
entity.mediaUrlHttps.dup(removePrefix(twimg), removePrefix(https))
|
||||||
|
|
||||||
proc parseDestination(id: string; card: UnifiedCard; result: var Card) =
|
proc parseDestination(id: string; card: UnifiedCard; result: var Card) =
|
||||||
let destination = card.destinationObjects[id].data
|
let destination = card.destinationObjects[id].data
|
||||||
@@ -24,6 +28,14 @@ proc parseMediaDetails(data: ComponentData; card: UnifiedCard; result: var Card)
|
|||||||
result.text = data.topicDetail.title
|
result.text = data.topicDetail.title
|
||||||
result.dest = "Topic"
|
result.dest = "Topic"
|
||||||
|
|
||||||
|
proc parseJobDetails(data: ComponentData; card: UnifiedCard; result: var Card) =
|
||||||
|
data.destination.parseDestination(card, result)
|
||||||
|
|
||||||
|
result.kind = CardKind.jobDetails
|
||||||
|
result.title = data.title
|
||||||
|
result.text = data.shortDescriptionText
|
||||||
|
result.dest = &"@{data.profileUser.username} · {data.location}"
|
||||||
|
|
||||||
proc parseAppDetails(data: ComponentData; card: UnifiedCard; result: var Card) =
|
proc parseAppDetails(data: ComponentData; card: UnifiedCard; result: var Card) =
|
||||||
let app = card.appStoreData[data.appId][0]
|
let app = card.appStoreData[data.appId][0]
|
||||||
|
|
||||||
@@ -63,7 +75,20 @@ proc parseMedia(component: Component; card: UnifiedCard; result: var Card) =
|
|||||||
durationMs: videoInfo.durationMillis,
|
durationMs: videoInfo.durationMillis,
|
||||||
variants: videoInfo.variants
|
variants: videoInfo.variants
|
||||||
)
|
)
|
||||||
of animatedGif: discard
|
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 =
|
proc parseUnifiedCard*(json: string): Card =
|
||||||
let card = json.fromJson(UnifiedCard)
|
let card = json.fromJson(UnifiedCard)
|
||||||
@@ -76,10 +101,18 @@ proc parseUnifiedCard*(json: string): Card =
|
|||||||
component.data.parseAppDetails(card, result)
|
component.data.parseAppDetails(card, result)
|
||||||
of mediaWithDetailsHorizontal:
|
of mediaWithDetailsHorizontal:
|
||||||
component.data.parseMediaDetails(card, result)
|
component.data.parseMediaDetails(card, result)
|
||||||
of ComponentType.media, swipeableMedia:
|
of media, swipeableMedia:
|
||||||
component.parseMedia(card, result)
|
component.parseMedia(card, result)
|
||||||
of buttonGroup:
|
of buttonGroup:
|
||||||
discard
|
discard
|
||||||
|
of grokShare:
|
||||||
|
component.data.parseGrokShare(card, result)
|
||||||
|
of ComponentType.jobDetails:
|
||||||
|
component.data.parseJobDetails(card, result)
|
||||||
|
of ComponentType.hidden:
|
||||||
|
result.kind = CardKind.hidden
|
||||||
|
of ComponentType.unknown:
|
||||||
|
echo "ERROR: Unknown component type: ", json
|
||||||
|
|
||||||
case component.kind
|
case component.kind
|
||||||
of twitterListDetails:
|
of twitterListDetails:
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import std/[algorithm, unicode, re, strutils, strformat, options]
|
import std/[algorithm, unicode, re, strutils, strformat, options, nre]
|
||||||
import jsony
|
import jsony
|
||||||
import utils, slices
|
import utils, slices
|
||||||
import ../types/user as userType
|
import ../types/user as userType
|
||||||
from ../../types import User, Error
|
from ../../types import Result, User, Error
|
||||||
|
|
||||||
let
|
let
|
||||||
unRegex = re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})"
|
unRegex = re.re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})"
|
||||||
unReplace = "$1<a href=\"/$2\">@$2</a>"
|
unReplace = "$1<a href=\"/$2\">@$2</a>"
|
||||||
|
|
||||||
htRegex = re"(^|[^\w-_./?])([##$])([\w_]+)"
|
htRegex = nre.re"""(*U)(^|[^\w-_.?])([##$])([\w_]*+)(?!</a>|">|#)"""
|
||||||
htReplace = "$1<a href=\"/search?q=%23$3\">$2$3</a>"
|
htReplace = "$1<a href=\"/search?q=%23$3\">$2$3</a>"
|
||||||
|
|
||||||
proc expandUserEntities(user: var User; raw: RawUser) =
|
proc expandUserEntities(user: var User; raw: RawUser) =
|
||||||
@@ -29,7 +29,7 @@ proc expandUserEntities(user: var User; raw: RawUser) =
|
|||||||
|
|
||||||
user.bio = orig.replacedWith(replacements, 0 .. orig.len)
|
user.bio = orig.replacedWith(replacements, 0 .. orig.len)
|
||||||
.replacef(unRegex, unReplace)
|
.replacef(unRegex, unReplace)
|
||||||
.replacef(htRegex, htReplace)
|
.replace(htRegex, htReplace)
|
||||||
|
|
||||||
proc getBanner(user: RawUser): string =
|
proc getBanner(user: RawUser): string =
|
||||||
if user.profileBannerUrl.len > 0:
|
if user.profileBannerUrl.len > 0:
|
||||||
@@ -56,23 +56,21 @@ proc toUser*(raw: RawUser): User =
|
|||||||
tweets: raw.statusesCount,
|
tweets: raw.statusesCount,
|
||||||
likes: raw.favouritesCount,
|
likes: raw.favouritesCount,
|
||||||
media: raw.mediaCount,
|
media: raw.mediaCount,
|
||||||
verified: raw.verified,
|
verifiedType: raw.verifiedType,
|
||||||
protected: raw.protected,
|
protected: raw.protected,
|
||||||
joinDate: parseTwitterDate(raw.createdAt),
|
|
||||||
banner: getBanner(raw),
|
banner: getBanner(raw),
|
||||||
userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "")
|
userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if raw.createdAt.len > 0:
|
||||||
|
result.joinDate = parseTwitterDate(raw.createdAt)
|
||||||
|
|
||||||
if raw.pinnedTweetIdsStr.len > 0:
|
if raw.pinnedTweetIdsStr.len > 0:
|
||||||
result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0])
|
result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0])
|
||||||
|
|
||||||
result.expandUserEntities(raw)
|
result.expandUserEntities(raw)
|
||||||
|
|
||||||
proc parseUser*(json: string; username=""): User =
|
proc parseHook*(s: string; i: var int; v: var User) =
|
||||||
handleErrors:
|
var u: RawUser
|
||||||
case error.code
|
parseHook(s, i, u)
|
||||||
of suspended: return User(username: username, suspended: true)
|
v = toUser u
|
||||||
of userNotFound: return
|
|
||||||
else: echo "[error - parseUser]: ", error
|
|
||||||
|
|
||||||
result = toUser json.fromJson(RawUser)
|
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import std/[sugar, strutils, times]
|
import std/[sugar, strutils, times]
|
||||||
import ".."/types/[common, media, tweet]
|
import ../types/common
|
||||||
import ../../utils as uutils
|
import ../../utils as uutils
|
||||||
|
|
||||||
template parseTime(time: string; f: static string; flen: int): DateTime =
|
template parseTime(time: string; f: static string; flen: int): DateTime =
|
||||||
if time.len != flen: return
|
if time.len != flen: return
|
||||||
parse(time, f, utc())
|
parse(time, f, utc())
|
||||||
|
|
||||||
proc toId*(id: string): int64 =
|
|
||||||
if id.len == 0: 0'i64
|
|
||||||
else: parseBiggestInt(id)
|
|
||||||
|
|
||||||
proc parseIsoDate*(date: string): DateTime =
|
proc parseIsoDate*(date: string): DateTime =
|
||||||
date.parseTime("yyyy-MM-dd\'T\'HH:mm:ss\'Z\'", 20)
|
date.parseTime("yyyy-MM-dd\'T\'HH:mm:ss\'Z\'", 20)
|
||||||
|
|
||||||
@@ -20,9 +16,6 @@ proc parseTwitterDate*(date: string): DateTime =
|
|||||||
proc getImageUrl*(url: string): string =
|
proc getImageUrl*(url: string): string =
|
||||||
url.dup(removePrefix(twimg), removePrefix(https))
|
url.dup(removePrefix(twimg), removePrefix(https))
|
||||||
|
|
||||||
proc getImageUrl*(entity: MediaEntity | Entity): string =
|
|
||||||
entity.mediaUrlHttps.getImageUrl
|
|
||||||
|
|
||||||
template handleErrors*(body) =
|
template handleErrors*(body) =
|
||||||
if json.startsWith("{\"errors"):
|
if json.startsWith("{\"errors"):
|
||||||
for error {.inject.} in json.fromJson(Errors).errors:
|
for error {.inject.} in json.fromJson(Errors).errors:
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import jsony
|
|
||||||
from ../../types import Error
|
from ../../types import Error
|
||||||
|
|
||||||
type
|
type
|
||||||
@@ -6,7 +5,7 @@ type
|
|||||||
url*: string
|
url*: string
|
||||||
expandedUrl*: string
|
expandedUrl*: string
|
||||||
displayUrl*: string
|
displayUrl*: string
|
||||||
indices*: Slice[int]
|
indices*: array[2, int]
|
||||||
|
|
||||||
ErrorObj* = object
|
ErrorObj* = object
|
||||||
code*: Error
|
code*: Error
|
||||||
@@ -19,8 +18,3 @@ proc contains*(codes: set[Error]; errors: Errors): bool =
|
|||||||
for e in errors.errors:
|
for e in errors.errors:
|
||||||
if e.code in codes:
|
if e.code in codes:
|
||||||
return true
|
return true
|
||||||
|
|
||||||
proc parseHook*(s: string; i: var int; v: var Slice[int]) =
|
|
||||||
var slice: array[2, int]
|
|
||||||
parseHook(s, i, slice)
|
|
||||||
v = slice[0] ..< slice[1]
|
|
||||||
|
|||||||
@@ -1,12 +1,48 @@
|
|||||||
import user
|
import options, strutils
|
||||||
|
from ../../types import User, VerifiedType
|
||||||
|
|
||||||
type
|
type
|
||||||
GraphUser* = object
|
GraphUser* = object
|
||||||
data*: tuple[user: UserData]
|
data*: tuple[userResult: Option[UserData], user: Option[UserData]]
|
||||||
|
|
||||||
UserData* = object
|
UserData* = object
|
||||||
result*: UserResult
|
result*: UserResult
|
||||||
|
|
||||||
UserResult = object
|
UserCore* = object
|
||||||
legacy*: RawUser
|
name*: string
|
||||||
|
screenName*: string
|
||||||
|
createdAt*: string
|
||||||
|
|
||||||
|
UserBio* = object
|
||||||
|
description*: string
|
||||||
|
|
||||||
|
UserAvatar* = object
|
||||||
|
imageUrl*: string
|
||||||
|
|
||||||
|
Verification* = object
|
||||||
|
verifiedType*: VerifiedType
|
||||||
|
|
||||||
|
Location* = object
|
||||||
|
location*: string
|
||||||
|
|
||||||
|
Privacy* = object
|
||||||
|
protected*: bool
|
||||||
|
|
||||||
|
UserResult* = object
|
||||||
|
legacy*: User
|
||||||
restId*: string
|
restId*: string
|
||||||
|
isBlueVerified*: bool
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import options
|
|
||||||
from ../../types import VideoType, VideoVariant
|
|
||||||
|
|
||||||
type
|
|
||||||
MediaType* = enum
|
|
||||||
photo, video, animatedGif
|
|
||||||
|
|
||||||
MediaEntity* = object
|
|
||||||
kind*: MediaType
|
|
||||||
mediaUrlHttps*: string
|
|
||||||
videoInfo*: Option[VideoInfo]
|
|
||||||
|
|
||||||
VideoInfo* = object
|
|
||||||
durationMillis*: int
|
|
||||||
variants*: seq[VideoVariant]
|
|
||||||
9
src/experimental/types/session.nim
Normal file
9
src/experimental/types/session.nim
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
type
|
||||||
|
RawSession* = object
|
||||||
|
kind*: string
|
||||||
|
id*: string
|
||||||
|
username*: string
|
||||||
|
oauthToken*: string
|
||||||
|
oauthTokenSecret*: string
|
||||||
|
authToken*: string
|
||||||
|
ct0*: string
|
||||||
4
src/experimental/types/tid.nim
Normal file
4
src/experimental/types/tid.nim
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type
|
||||||
|
TidPair* = object
|
||||||
|
animationKey*: string
|
||||||
|
verification*: string
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import std/tables
|
|
||||||
import user, tweet
|
|
||||||
|
|
||||||
type
|
|
||||||
Search* = object
|
|
||||||
globalObjects*: GlobalObjects
|
|
||||||
timeline*: Timeline
|
|
||||||
|
|
||||||
GlobalObjects* = object
|
|
||||||
users*: Table[string, RawUser]
|
|
||||||
tweets*: Table[string, RawTweet]
|
|
||||||
|
|
||||||
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]
|
|
||||||
|
|
||||||
proc renameHook*(v: var Entity; fieldName: var string) =
|
|
||||||
if fieldName == "type":
|
|
||||||
fieldName = "kind"
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import options
|
|
||||||
import jsony
|
|
||||||
from json import JsonNode
|
|
||||||
import user, media, common
|
|
||||||
|
|
||||||
type
|
|
||||||
RawTweet* = object
|
|
||||||
createdAt*: string
|
|
||||||
idStr*: string
|
|
||||||
fullText*: string
|
|
||||||
displayTextRange*: array[2, int]
|
|
||||||
entities*: Entities
|
|
||||||
extendedEntities*: ExtendedEntities
|
|
||||||
inReplyToStatusIdStr*: string
|
|
||||||
inReplyToScreenName*: string
|
|
||||||
userIdStr*: string
|
|
||||||
isQuoteStatus*: bool
|
|
||||||
replyCount*: int
|
|
||||||
retweetCount*: int
|
|
||||||
favoriteCount*: int
|
|
||||||
quoteCount*: int
|
|
||||||
conversationIdStr*: string
|
|
||||||
favorited*: bool
|
|
||||||
retweeted*: bool
|
|
||||||
selfThread*: tuple[idStr: string]
|
|
||||||
card*: Option[Card]
|
|
||||||
quotedStatusIdStr*: string
|
|
||||||
retweetedStatusIdStr*: string
|
|
||||||
|
|
||||||
Card* = object
|
|
||||||
name*: string
|
|
||||||
url*: string
|
|
||||||
bindingValues*: JsonNode
|
|
||||||
|
|
||||||
Entities* = object
|
|
||||||
hashtags*: seq[Hashtag]
|
|
||||||
symbols*: seq[Hashtag]
|
|
||||||
userMentions*: seq[UserMention]
|
|
||||||
urls*: seq[Url]
|
|
||||||
media*: seq[Entity]
|
|
||||||
|
|
||||||
Hashtag* = object
|
|
||||||
indices*: Slice[int]
|
|
||||||
|
|
||||||
UserMention* = object
|
|
||||||
screenName*: string
|
|
||||||
name*: string
|
|
||||||
indices*: Slice[int]
|
|
||||||
|
|
||||||
ExtendedEntities* = object
|
|
||||||
media*: seq[Entity]
|
|
||||||
|
|
||||||
Entity* = object
|
|
||||||
kind*: MediaType
|
|
||||||
indices*: Slice[int]
|
|
||||||
mediaUrlHttps*: string
|
|
||||||
url*: string
|
|
||||||
expandedUrl*: string
|
|
||||||
videoInfo*: VideoInfo
|
|
||||||
ext*: Ext
|
|
||||||
extMediaAvailability*: tuple[status: string]
|
|
||||||
extAltText*: string
|
|
||||||
additionalMediaInfo*: AdditionalMediaInfo
|
|
||||||
sourceStatusIdStr*: string
|
|
||||||
sourceUserIdStr*: string
|
|
||||||
|
|
||||||
AdditionalMediaInfo* = object
|
|
||||||
sourceUser*: Option[RawUser]
|
|
||||||
title*: string
|
|
||||||
description*: string
|
|
||||||
|
|
||||||
Ext* = object
|
|
||||||
mediaStats*: JsonNode
|
|
||||||
|
|
||||||
MediaStats* = object
|
|
||||||
ok*: tuple[viewCount: string]
|
|
||||||
|
|
||||||
proc renameHook*(v: var Entity; fieldName: var string) =
|
|
||||||
if fieldName == "type":
|
|
||||||
fieldName = "kind"
|
|
||||||
|
|
||||||
proc parseHook*(s: string; i: var int; v: var Slice[int]) =
|
|
||||||
var slice: array[2, int]
|
|
||||||
parseHook(s, i, slice)
|
|
||||||
v = slice[0] ..< slice[1]
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import options, tables
|
import std/[options, tables, times]
|
||||||
import media as mediaTypes
|
import jsony
|
||||||
|
from ../../types import VideoType, VideoVariant, User
|
||||||
|
|
||||||
type
|
type
|
||||||
|
Text* = distinct string
|
||||||
|
|
||||||
UnifiedCard* = object
|
UnifiedCard* = object
|
||||||
componentObjects*: Table[string, Component]
|
componentObjects*: Table[string, Component]
|
||||||
destinationObjects*: Table[string, Destination]
|
destinationObjects*: Table[string, Destination]
|
||||||
@@ -13,10 +16,14 @@ type
|
|||||||
media
|
media
|
||||||
swipeableMedia
|
swipeableMedia
|
||||||
buttonGroup
|
buttonGroup
|
||||||
|
jobDetails
|
||||||
appStoreDetails
|
appStoreDetails
|
||||||
twitterListDetails
|
twitterListDetails
|
||||||
communityDetails
|
communityDetails
|
||||||
mediaWithDetailsHorizontal
|
mediaWithDetailsHorizontal
|
||||||
|
hidden
|
||||||
|
grokShare
|
||||||
|
unknown
|
||||||
|
|
||||||
Component* = object
|
Component* = object
|
||||||
kind*: ComponentType
|
kind*: ComponentType
|
||||||
@@ -27,24 +34,40 @@ type
|
|||||||
appId*: string
|
appId*: string
|
||||||
mediaId*: string
|
mediaId*: string
|
||||||
destination*: string
|
destination*: string
|
||||||
|
location*: string
|
||||||
title*: Text
|
title*: Text
|
||||||
subtitle*: Text
|
subtitle*: Text
|
||||||
name*: Text
|
name*: Text
|
||||||
memberCount*: int
|
memberCount*: int
|
||||||
mediaList*: seq[MediaItem]
|
mediaList*: seq[MediaItem]
|
||||||
topicDetail*: tuple[title: Text]
|
topicDetail*: tuple[title: Text]
|
||||||
|
profileUser*: User
|
||||||
|
shortDescriptionText*: string
|
||||||
|
conversationPreview*: seq[GrokConversation]
|
||||||
|
|
||||||
MediaItem* = object
|
MediaItem* = object
|
||||||
id*: string
|
id*: string
|
||||||
destination*: string
|
destination*: string
|
||||||
|
|
||||||
|
Destination* = object
|
||||||
|
kind*: string
|
||||||
|
data*: tuple[urlData: UrlData]
|
||||||
|
|
||||||
UrlData* = object
|
UrlData* = object
|
||||||
url*: string
|
url*: string
|
||||||
vanity*: string
|
vanity*: string
|
||||||
|
|
||||||
Destination* = object
|
MediaType* = enum
|
||||||
kind*: string
|
photo, video, model3d
|
||||||
data*: tuple[urlData: UrlData]
|
|
||||||
|
MediaEntity* = object
|
||||||
|
kind*: MediaType
|
||||||
|
mediaUrlHttps*: string
|
||||||
|
videoInfo*: Option[VideoInfo]
|
||||||
|
|
||||||
|
VideoInfo* = object
|
||||||
|
durationMillis*: int
|
||||||
|
variants*: seq[VideoVariant]
|
||||||
|
|
||||||
AppType* = enum
|
AppType* = enum
|
||||||
androidApp, iPhoneApp, iPadApp
|
androidApp, iPhoneApp, iPadApp
|
||||||
@@ -55,13 +78,58 @@ type
|
|||||||
title*: Text
|
title*: Text
|
||||||
category*: Text
|
category*: Text
|
||||||
|
|
||||||
Text = object
|
GrokConversation* = object
|
||||||
content: string
|
message*: string
|
||||||
|
sender*: string
|
||||||
|
|
||||||
HasTypeField = Component | Destination | MediaEntity | AppStoreData
|
TypeField = Component | Destination | MediaEntity | AppStoreData
|
||||||
|
|
||||||
converter fromText*(text: Text): string = text.content
|
converter fromText*(text: Text): string = string(text)
|
||||||
|
|
||||||
proc renameHook*(v: var HasTypeField; fieldName: var string) =
|
proc renameHook*(v: var TypeField; fieldName: var string) =
|
||||||
if fieldName == "type":
|
if fieldName == "type":
|
||||||
fieldName = "kind"
|
fieldName = "kind"
|
||||||
|
|
||||||
|
proc enumHook*(s: string; v: var ComponentType) =
|
||||||
|
v = case s
|
||||||
|
of "details": details
|
||||||
|
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) =
|
||||||
|
v = case s
|
||||||
|
of "android_app": androidApp
|
||||||
|
of "iphone_app": iPhoneApp
|
||||||
|
of "ipad_app": iPadApp
|
||||||
|
else: echo "ERROR: Unknown enum value (AppType): ", s; androidApp
|
||||||
|
|
||||||
|
proc enumHook*(s: string; v: var MediaType) =
|
||||||
|
v = case s
|
||||||
|
of "video": video
|
||||||
|
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,6 +1,6 @@
|
|||||||
import options
|
import options
|
||||||
import jsony
|
|
||||||
import common
|
import common
|
||||||
|
from ../../types import VerifiedType
|
||||||
|
|
||||||
type
|
type
|
||||||
RawUser* = object
|
RawUser* = object
|
||||||
@@ -16,7 +16,7 @@ type
|
|||||||
favouritesCount*: int
|
favouritesCount*: int
|
||||||
statusesCount*: int
|
statusesCount*: int
|
||||||
mediaCount*: int
|
mediaCount*: int
|
||||||
verified*: bool
|
verifiedType*: VerifiedType
|
||||||
protected*: bool
|
protected*: bool
|
||||||
profileLinkColor*: string
|
profileLinkColor*: string
|
||||||
profileBannerUrl*: string
|
profileBannerUrl*: string
|
||||||
@@ -42,8 +42,3 @@ type
|
|||||||
|
|
||||||
Color* = object
|
Color* = object
|
||||||
red*, green*, blue*: int
|
red*, green*, blue*: int
|
||||||
|
|
||||||
proc parseHook*(s: string; i: var int; v: var Slice[int]) =
|
|
||||||
var slice: array[2, int]
|
|
||||||
parseHook(s, i, slice)
|
|
||||||
v = slice[0] ..< slice[1]
|
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen
|
import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen, math
|
||||||
import std/[enumerate, re]
|
import std/[enumerate, re]
|
||||||
import types, utils, query
|
import types, utils, query
|
||||||
|
|
||||||
const
|
const
|
||||||
cards = "cards.twitter.com/cards"
|
cards = "cards.twitter.com/cards"
|
||||||
tco = "https://t.co"
|
tco = "https://t.co"
|
||||||
twitter = parseUri("https://twitter.com")
|
twitter = parseUri("https://x.com")
|
||||||
|
|
||||||
let
|
let
|
||||||
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
|
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
|
||||||
twLinkRegex = re"""<a href="https:\/\/twitter.com([^"]+)">twitter\.com(\S+)</a>"""
|
twLinkRegex = re"""<a href="https:\/\/twitter.com([^"]+)">twitter\.com(\S+)</a>"""
|
||||||
|
xRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?x\.com"
|
||||||
|
xLinkRegex = re"""<a href="https:\/\/x.com([^"]+)">x\.com(\S+)</a>"""
|
||||||
|
|
||||||
ytRegex = re"([A-z.]+\.)?youtu(be\.com|\.be)"
|
ytRegex = re(r"([A-z.]+\.)?youtu(be\.com|\.be)", {reStudy, reIgnoreCase})
|
||||||
igRegex = re"(www\.)?instagram\.com"
|
|
||||||
|
|
||||||
rdRegex = re"(?<![.b])((www|np|new|amp|old)\.)?reddit.com"
|
rdRegex = re"(?<![.b])((www|np|new|amp|old)\.)?reddit.com"
|
||||||
rdShortRegex = re"(?<![.b])redd\.it\/"
|
rdShortRegex = re"(?<![.b])redd\.it\/"
|
||||||
@@ -32,10 +33,13 @@ proc getUrlPrefix*(cfg: Config): string =
|
|||||||
if cfg.useHttps: https & cfg.hostname
|
if cfg.useHttps: https & cfg.hostname
|
||||||
else: "http://" & cfg.hostname
|
else: "http://" & cfg.hostname
|
||||||
|
|
||||||
proc shortLink*(text: string; length=28): string =
|
proc shorten*(text: string; length=28): string =
|
||||||
result = text.replace(wwwRegex, "")
|
result = text
|
||||||
if result.len > length:
|
if result.len > length:
|
||||||
result = result[0 ..< length] & "…"
|
result = result[0 ..< length] & "…"
|
||||||
|
|
||||||
|
proc shortLink*(text: string; length=28): string =
|
||||||
|
result = text.replace(wwwRegex, "").shorten(length)
|
||||||
|
|
||||||
proc stripHtml*(text: string; shorten=false): string =
|
proc stripHtml*(text: string; shorten=false): string =
|
||||||
var html = parseHtml(text)
|
var html = parseHtml(text)
|
||||||
@@ -55,28 +59,32 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
|
|||||||
result = body
|
result = body
|
||||||
|
|
||||||
if prefs.replaceYouTube.len > 0 and "youtu" in result:
|
if prefs.replaceYouTube.len > 0 and "youtu" in result:
|
||||||
result = result.replace(ytRegex, prefs.replaceYouTube)
|
let youtubeHost = strip(prefs.replaceYouTube, chars={'/'})
|
||||||
if prefs.replaceYouTube in result:
|
result = result.replace(ytRegex, youtubeHost)
|
||||||
result = result.replace("/c/", "/")
|
|
||||||
|
|
||||||
if prefs.replaceTwitter.len > 0 and ("twitter.com" in body or tco in body):
|
if prefs.replaceTwitter.len > 0:
|
||||||
result = result.replace(tco, https & prefs.replaceTwitter & "/t.co")
|
let twitterHost = strip(prefs.replaceTwitter, chars={'/'})
|
||||||
result = result.replace(cards, prefs.replaceTwitter & "/cards")
|
if tco in result:
|
||||||
result = result.replace(twRegex, prefs.replaceTwitter)
|
result = result.replace(tco, https & twitterHost & "/t.co")
|
||||||
result = result.replacef(twLinkRegex, a(
|
if "x.com" in result:
|
||||||
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
|
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):
|
if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result):
|
||||||
result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/")
|
let redditHost = strip(prefs.replaceReddit, chars={'/'})
|
||||||
result = result.replace(rdRegex, prefs.replaceReddit)
|
result = result.replace(rdShortRegex, redditHost & "/comments/")
|
||||||
if prefs.replaceReddit in result and "/gallery/" in result:
|
result = result.replace(rdRegex, redditHost)
|
||||||
|
if redditHost in result and "/gallery/" in result:
|
||||||
result = result.replace("/gallery/", "/comments/")
|
result = result.replace("/gallery/", "/comments/")
|
||||||
|
|
||||||
if prefs.replaceInstagram.len > 0 and "instagram.com" in result:
|
|
||||||
result = result.replace(igRegex, prefs.replaceInstagram)
|
|
||||||
|
|
||||||
if absolute.len > 0 and "href" in result:
|
if absolute.len > 0 and "href" in result:
|
||||||
result = result.replace("href=\"/", "href=\"" & absolute & "/")
|
result = result.replace("href=\"/", &"href=\"{absolute}/")
|
||||||
|
|
||||||
proc getM3u8Url*(content: string): string =
|
proc getM3u8Url*(content: string): string =
|
||||||
var matches: array[1, string]
|
var matches: array[1, string]
|
||||||
@@ -88,6 +96,8 @@ proc proxifyVideo*(manifest: string; proxy: bool): string =
|
|||||||
for line in manifest.splitLines:
|
for line in manifest.splitLines:
|
||||||
let url =
|
let url =
|
||||||
if line.startsWith("#EXT-X-MAP:URI"): line[16 .. ^2]
|
if line.startsWith("#EXT-X-MAP:URI"): line[16 .. ^2]
|
||||||
|
elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line:
|
||||||
|
line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))]
|
||||||
else: line
|
else: line
|
||||||
if url.startsWith('/'):
|
if url.startsWith('/'):
|
||||||
let path = "https://video.twimg.com" & url
|
let path = "https://video.twimg.com" & url
|
||||||
@@ -144,6 +154,17 @@ proc getShortTime*(tweet: Tweet): string =
|
|||||||
else:
|
else:
|
||||||
result = "now"
|
result = "now"
|
||||||
|
|
||||||
|
proc getDuration*(video: Video): string =
|
||||||
|
let
|
||||||
|
ms = video.durationMs
|
||||||
|
sec = int(round(ms / 1000))
|
||||||
|
min = floorDiv(sec, 60)
|
||||||
|
hour = floorDiv(min, 60)
|
||||||
|
if hour > 0:
|
||||||
|
return &"{hour}:{min mod 60}:{sec mod 60:02}"
|
||||||
|
else:
|
||||||
|
return &"{min mod 60}:{sec mod 60:02}"
|
||||||
|
|
||||||
proc getLink*(tweet: Tweet; focus=true): string =
|
proc getLink*(tweet: Tweet; focus=true): string =
|
||||||
if tweet.id == 0: return
|
if tweet.id == 0: return
|
||||||
var username = tweet.user.username
|
var username = tweet.user.username
|
||||||
|
|||||||
@@ -39,8 +39,11 @@ template use*(pool: HttpPool; heads: HttpHeaders; body: untyped): untyped =
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
body
|
body
|
||||||
except ProtocolError:
|
except BadClientError, ProtocolError:
|
||||||
# Twitter closed the connection, retry
|
# Twitter returned 503 or closed the connection, we need a new client
|
||||||
|
pool.release(c, true)
|
||||||
|
badClient = false
|
||||||
|
c = pool.acquire(heads)
|
||||||
body
|
body
|
||||||
finally:
|
finally:
|
||||||
pool.release(c, badClient)
|
pool.release(c, badClient)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from os import getEnv
|
|||||||
|
|
||||||
import jester
|
import jester
|
||||||
|
|
||||||
import types, config, prefs, formatters, redis_cache, http_pool, tokens
|
import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
|
||||||
import views/[general, about]
|
import views/[general, about]
|
||||||
import routes/[
|
import routes/[
|
||||||
preferences, timeline, status, media, search, rss, list, debug,
|
preferences, timeline, status, media, search, rss, list, debug,
|
||||||
@@ -15,8 +15,13 @@ import routes/[
|
|||||||
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
||||||
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
||||||
|
|
||||||
let configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf")
|
let
|
||||||
let (cfg, fullCfg) = getConfig(configPath)
|
configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf")
|
||||||
|
(cfg, fullCfg) = getConfig(configPath)
|
||||||
|
|
||||||
|
sessionsPath = getEnv("NITTER_SESSIONS_FILE", "./sessions.jsonl")
|
||||||
|
|
||||||
|
initSessionPool(cfg, sessionsPath)
|
||||||
|
|
||||||
if not cfg.enableDebug:
|
if not cfg.enableDebug:
|
||||||
# Silence Jester's query warning
|
# Silence Jester's query warning
|
||||||
@@ -32,14 +37,14 @@ setHmacKey(cfg.hmacKey)
|
|||||||
setProxyEncoding(cfg.base64Media)
|
setProxyEncoding(cfg.base64Media)
|
||||||
setMaxHttpConns(cfg.httpMaxConns)
|
setMaxHttpConns(cfg.httpMaxConns)
|
||||||
setHttpProxy(cfg.proxy, cfg.proxyAuth)
|
setHttpProxy(cfg.proxy, cfg.proxyAuth)
|
||||||
|
setApiProxy(cfg.apiProxy)
|
||||||
|
setDisableTid(cfg.disableTid)
|
||||||
initAboutPage(cfg.staticDir)
|
initAboutPage(cfg.staticDir)
|
||||||
|
|
||||||
waitFor initRedisPool(cfg)
|
waitFor initRedisPool(cfg)
|
||||||
stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
|
stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
|
||||||
stdout.flushFile
|
stdout.flushFile
|
||||||
|
|
||||||
asyncCheck initTokenPool(cfg)
|
|
||||||
|
|
||||||
createUnsupportedRouter(cfg)
|
createUnsupportedRouter(cfg)
|
||||||
createResolverRouter(cfg)
|
createResolverRouter(cfg)
|
||||||
createPrefRouter(cfg)
|
createPrefRouter(cfg)
|
||||||
@@ -56,6 +61,7 @@ settings:
|
|||||||
port = Port(cfg.port)
|
port = Port(cfg.port)
|
||||||
staticDir = cfg.staticDir
|
staticDir = cfg.staticDir
|
||||||
bindAddr = cfg.address
|
bindAddr = cfg.address
|
||||||
|
reusePort = true
|
||||||
|
|
||||||
routes:
|
routes:
|
||||||
get "/":
|
get "/":
|
||||||
@@ -84,20 +90,28 @@ routes:
|
|||||||
resp Http500, showError(
|
resp Http500, showError(
|
||||||
&"An error occurred, please {link} with the URL you tried to visit.", cfg)
|
&"An error occurred, please {link} with the URL you tried to visit.", cfg)
|
||||||
|
|
||||||
error RateLimitError:
|
error BadClientError:
|
||||||
echo error.exc.name, ": ", error.exc.msg
|
echo error.exc.name, ": ", error.exc.msg
|
||||||
|
resp Http500, showError("Network error occurred, please try again.", cfg)
|
||||||
|
|
||||||
|
error RateLimitError:
|
||||||
const link = a("another instance", href = instancesUrl)
|
const link = a("another instance", href = instancesUrl)
|
||||||
resp Http429, showError(
|
resp Http429, showError(
|
||||||
&"Instance has been rate limited.<br>Use {link} or try again later.", cfg)
|
&"Instance has been rate limited.<br>Use {link} or try again later.", cfg)
|
||||||
|
|
||||||
extend unsupported, ""
|
error NoSessionsError:
|
||||||
extend preferences, ""
|
const link = a("another instance", href = instancesUrl)
|
||||||
extend resolver, ""
|
resp Http429, showError(
|
||||||
|
&"Instance has no auth tokens, or is fully rate limited.<br>Use {link} or try again later.", cfg)
|
||||||
|
|
||||||
extend rss, ""
|
extend rss, ""
|
||||||
|
extend status, ""
|
||||||
extend search, ""
|
extend search, ""
|
||||||
extend timeline, ""
|
extend timeline, ""
|
||||||
extend list, ""
|
|
||||||
extend status, ""
|
|
||||||
extend media, ""
|
extend media, ""
|
||||||
|
extend list, ""
|
||||||
|
extend preferences, ""
|
||||||
|
extend resolver, ""
|
||||||
extend embed, ""
|
extend embed, ""
|
||||||
extend debug, ""
|
extend debug, ""
|
||||||
|
extend unsupported, ""
|
||||||
|
|||||||
555
src/parser.nim
555
src/parser.nim
@@ -1,9 +1,11 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import strutils, options, tables, times, math
|
import strutils, options, times, math, tables
|
||||||
import packedjson, packedjson/deserialiser
|
import packedjson, packedjson/deserialiser
|
||||||
import types, parserutils, utils
|
import types, parserutils, utils
|
||||||
import experimental/parser/unifiedcard
|
import experimental/parser/unifiedcard
|
||||||
|
|
||||||
|
proc parseGraphTweet(js: JsonNode): Tweet
|
||||||
|
|
||||||
proc parseUser(js: JsonNode; id=""): User =
|
proc parseUser(js: JsonNode; id=""): User =
|
||||||
if js.isNull: return
|
if js.isNull: return
|
||||||
result = User(
|
result = User(
|
||||||
@@ -19,13 +21,46 @@ proc parseUser(js: JsonNode; id=""): User =
|
|||||||
tweets: js{"statuses_count"}.getInt,
|
tweets: js{"statuses_count"}.getInt,
|
||||||
likes: js{"favourites_count"}.getInt,
|
likes: js{"favourites_count"}.getInt,
|
||||||
media: js{"media_count"}.getInt,
|
media: js{"media_count"}.getInt,
|
||||||
verified: js{"verified"}.getBool,
|
protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool),
|
||||||
protected: js{"protected"}.getBool,
|
|
||||||
joinDate: js{"created_at"}.getTime
|
joinDate: js{"created_at"}.getTime
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if js{"is_blue_verified"}.getBool(false):
|
||||||
|
result.verifiedType = blue
|
||||||
|
|
||||||
|
with verifiedType, js{"verified_type"}:
|
||||||
|
result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
|
||||||
|
|
||||||
result.expandUserEntities(js)
|
result.expandUserEntities(js)
|
||||||
|
|
||||||
|
proc parseGraphUser(js: JsonNode): User =
|
||||||
|
var user = js{"user_result", "result"}
|
||||||
|
if user.isNull:
|
||||||
|
user = ? js{"user_results", "result"}
|
||||||
|
|
||||||
|
if user.isNull:
|
||||||
|
if js{"core"}.notNull and js{"legacy"}.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.username = user{"core", "screen_name"}.getStr
|
||||||
|
result.fullname = user{"core", "name"}.getStr
|
||||||
|
result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "")
|
||||||
|
|
||||||
|
if user{"is_blue_verified"}.getBool(false):
|
||||||
|
result.verifiedType = blue
|
||||||
|
|
||||||
|
with verifiedType, user{"verification", "verified_type"}:
|
||||||
|
result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
|
||||||
|
|
||||||
proc parseGraphList*(js: JsonNode): List =
|
proc parseGraphList*(js: JsonNode): List =
|
||||||
if js.isNull: return
|
if js.isNull: return
|
||||||
|
|
||||||
@@ -38,14 +73,13 @@ proc parseGraphList*(js: JsonNode): List =
|
|||||||
result = List(
|
result = List(
|
||||||
id: list{"id_str"}.getStr,
|
id: list{"id_str"}.getStr,
|
||||||
name: list{"name"}.getStr,
|
name: list{"name"}.getStr,
|
||||||
username: list{"user", "legacy", "screen_name"}.getStr,
|
username: list{"user_results", "result", "legacy", "screen_name"}.getStr,
|
||||||
userId: list{"user", "rest_id"}.getStr,
|
userId: list{"user_results", "result", "rest_id"}.getStr,
|
||||||
description: list{"description"}.getStr,
|
description: list{"description"}.getStr,
|
||||||
members: list{"member_count"}.getInt,
|
members: list{"member_count"}.getInt,
|
||||||
banner: list{"custom_banner_media", "media_info", "url"}.getImageStr
|
banner: list{"custom_banner_media", "media_info", "original_img_url"}.getImageStr
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
proc parsePoll(js: JsonNode): Poll =
|
proc parsePoll(js: JsonNode): Poll =
|
||||||
let vals = js{"binding_values"}
|
let vals = js{"binding_values"}
|
||||||
# name format is pollNchoice_*
|
# name format is pollNchoice_*
|
||||||
@@ -64,34 +98,96 @@ proc parsePoll(js: JsonNode): Poll =
|
|||||||
result.leader = result.values.find(max(result.values))
|
result.leader = result.values.find(max(result.values))
|
||||||
result.votes = result.values.sum
|
result.votes = result.values.sum
|
||||||
|
|
||||||
proc parseGif(js: JsonNode): Gif =
|
proc parseVideoVariants(variants: JsonNode): seq[VideoVariant] =
|
||||||
result = Gif(
|
result = @[]
|
||||||
url: js{"video_info", "variants"}[0]{"url"}.getImageStr,
|
for v in variants:
|
||||||
thumb: js{"media_url_https"}.getImageStr
|
let
|
||||||
)
|
url = v{"url"}.getStr
|
||||||
|
contentType = parseEnum[VideoType](v{"content_type"}.getStr("video/mp4"))
|
||||||
|
bitrate = v{"bit_rate"}.getInt(v{"bitrate"}.getInt(0))
|
||||||
|
|
||||||
|
result.add VideoVariant(
|
||||||
|
contentType: contentType,
|
||||||
|
bitrate: bitrate,
|
||||||
|
url: url,
|
||||||
|
resolution: if contentType == mp4: getMp4Resolution(url) else: 0
|
||||||
|
)
|
||||||
|
|
||||||
proc parseVideo(js: JsonNode): Video =
|
proc parseVideo(js: JsonNode): Video =
|
||||||
result = Video(
|
result = Video(
|
||||||
thumb: js{"media_url_https"}.getImageStr,
|
thumb: js{"media_url_https"}.getImageStr,
|
||||||
views: js{"ext", "mediaStats", "r", "ok", "viewCount"}.getStr,
|
available: true,
|
||||||
available: js{"ext_media_availability", "status"}.getStr == "available",
|
|
||||||
title: js{"ext_alt_text"}.getStr,
|
title: js{"ext_alt_text"}.getStr,
|
||||||
durationMs: js{"video_info", "duration_millis"}.getInt
|
durationMs: js{"video_info", "duration_millis"}.getInt
|
||||||
# playbackType: mp4
|
# playbackType: mp4
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with status, js{"ext_media_availability", "status"}:
|
||||||
|
if status.getStr.len > 0 and status.getStr.toLowerAscii != "available":
|
||||||
|
result.available = false
|
||||||
|
|
||||||
with title, js{"additional_media_info", "title"}:
|
with title, js{"additional_media_info", "title"}:
|
||||||
result.title = title.getStr
|
result.title = title.getStr
|
||||||
|
|
||||||
with description, js{"additional_media_info", "description"}:
|
with description, js{"additional_media_info", "description"}:
|
||||||
result.description = description.getStr
|
result.description = description.getStr
|
||||||
|
|
||||||
for v in js{"video_info", "variants"}:
|
result.variants = parseVideoVariants(js{"video_info", "variants"})
|
||||||
result.variants.add VideoVariant(
|
|
||||||
contentType: parseEnum[VideoType](v{"content_type"}.getStr("summary")),
|
proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
|
||||||
bitrate: v{"bitrate"}.getInt,
|
with jsMedia, js{"extended_entities", "media"}:
|
||||||
url: v{"url"}.getStr
|
for m in jsMedia:
|
||||||
)
|
case m.getTypeName:
|
||||||
|
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 Gif(
|
||||||
|
url: m{"video_info", "variants"}[0]{"url"}.getImageStr,
|
||||||
|
thumb: m{"media_url_https"}.getImageStr
|
||||||
|
)
|
||||||
|
else: discard
|
||||||
|
|
||||||
|
with url, m{"url"}:
|
||||||
|
if result.text.endsWith(url.getStr):
|
||||||
|
result.text.removeSuffix(url.getStr)
|
||||||
|
result.text = result.text.strip()
|
||||||
|
|
||||||
|
proc parseMediaEntities(js: JsonNode; result: var Tweet) =
|
||||||
|
with mediaEntities, js{"media_entities"}:
|
||||||
|
for mediaEntity in mediaEntities:
|
||||||
|
with mediaInfo, mediaEntity{"media_results", "result", "media_info"}:
|
||||||
|
case mediaInfo.getTypeName
|
||||||
|
of "ApiImage":
|
||||||
|
result.photos.add mediaInfo{"original_img_url"}.getImageStr
|
||||||
|
of "ApiVideo":
|
||||||
|
let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"}
|
||||||
|
result.video = some Video(
|
||||||
|
available: status.getStr == "Available",
|
||||||
|
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
|
||||||
|
durationMs: mediaInfo{"duration_millis"}.getInt,
|
||||||
|
variants: parseVideoVariants(mediaInfo{"variants"})
|
||||||
|
)
|
||||||
|
of "ApiGif":
|
||||||
|
result.gif = some Gif(
|
||||||
|
url: mediaInfo{"variants"}[0]{"url"}.getImageStr,
|
||||||
|
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr
|
||||||
|
)
|
||||||
|
else: discard
|
||||||
|
|
||||||
|
# Remove media URLs from text
|
||||||
|
with mediaList, js{"legacy", "entities", "media"}:
|
||||||
|
for url in mediaList:
|
||||||
|
let expandedUrl = url.getExpandedUrl
|
||||||
|
if result.text.endsWith(expandedUrl):
|
||||||
|
result.text.removeSuffix(expandedUrl)
|
||||||
|
result.text = result.text.strip()
|
||||||
|
|
||||||
proc parsePromoVideo(js: JsonNode): Video =
|
proc parsePromoVideo(js: JsonNode): Video =
|
||||||
result = Video(
|
result = Video(
|
||||||
@@ -171,7 +267,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
|||||||
|
|
||||||
for u in ? urls:
|
for u in ? urls:
|
||||||
if u{"url"}.getStr == result.url:
|
if u{"url"}.getStr == result.url:
|
||||||
result.url = u{"expanded_url"}.getStr
|
result.url = u.getExpandedUrl(result.url)
|
||||||
break
|
break
|
||||||
|
|
||||||
if kind in {videoDirectMessage, imageDirectMessage}:
|
if kind in {videoDirectMessage, imageDirectMessage}:
|
||||||
@@ -181,14 +277,19 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
|||||||
result.url.len == 0 or result.url.startsWith("card://"):
|
result.url.len == 0 or result.url.startsWith("card://"):
|
||||||
result.url = getPicUrl(result.image)
|
result.url = getPicUrl(result.image)
|
||||||
|
|
||||||
proc parseTweet(js: JsonNode): Tweet =
|
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
||||||
if js.isNull: return
|
if js.isNull: return
|
||||||
|
|
||||||
|
let time =
|
||||||
|
if js{"created_at"}.notNull: js{"created_at"}.getTime
|
||||||
|
else: js{"created_at_ms"}.getTimeFromMs
|
||||||
|
|
||||||
result = Tweet(
|
result = Tweet(
|
||||||
id: js{"id_str"}.getId,
|
id: js{"id_str"}.getId,
|
||||||
threadId: js{"conversation_id_str"}.getId,
|
threadId: js{"conversation_id_str"}.getId,
|
||||||
replyId: js{"in_reply_to_status_id_str"}.getId,
|
replyId: js{"in_reply_to_status_id_str"}.getId,
|
||||||
text: js{"full_text"}.getStr,
|
text: js{"full_text"}.getStr,
|
||||||
time: js{"created_at"}.getTime,
|
time: time,
|
||||||
hasThread: js{"self_thread"}.notNull,
|
hasThread: js{"self_thread"}.notNull,
|
||||||
available: true,
|
available: true,
|
||||||
user: User(id: js{"user_id_str"}.getStr),
|
user: User(id: js{"user_id_str"}.getStr),
|
||||||
@@ -196,20 +297,38 @@ proc parseTweet(js: JsonNode): Tweet =
|
|||||||
replies: js{"reply_count"}.getInt,
|
replies: js{"reply_count"}.getInt,
|
||||||
retweets: js{"retweet_count"}.getInt,
|
retweets: js{"retweet_count"}.getInt,
|
||||||
likes: js{"favorite_count"}.getInt,
|
likes: js{"favorite_count"}.getInt,
|
||||||
quotes: js{"quote_count"}.getInt
|
views: js{"views_count"}.getInt
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
result.expandTweetEntities(js)
|
# 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)
|
result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId)
|
||||||
|
|
||||||
|
# legacy
|
||||||
with rt, js{"retweeted_status_id_str"}:
|
with rt, js{"retweeted_status_id_str"}:
|
||||||
result.retweet = some Tweet(id: rt.getId)
|
result.retweet = some Tweet(id: rt.getId)
|
||||||
return
|
return
|
||||||
|
|
||||||
with jsCard, js{"card"}:
|
# 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:
|
||||||
|
result.retweet = some parseGraphTweet(rt)
|
||||||
|
return
|
||||||
|
|
||||||
|
with reposts, js{"repostedStatusResults"}:
|
||||||
|
with rt, reposts{"result"}:
|
||||||
|
if "legacy" in rt:
|
||||||
|
result.retweet = some parseGraphTweet(rt)
|
||||||
|
return
|
||||||
|
|
||||||
|
if jsCard.kind != JNull:
|
||||||
let name = jsCard{"name"}.getStr
|
let name = jsCard{"name"}.getStr
|
||||||
if "poll" in name:
|
if "poll" in name:
|
||||||
if "image" in name:
|
if "image" in name:
|
||||||
@@ -221,18 +340,8 @@ proc parseTweet(js: JsonNode): Tweet =
|
|||||||
else:
|
else:
|
||||||
result.card = some parseCard(jsCard, js{"entities", "urls"})
|
result.card = some parseCard(jsCard, js{"entities", "urls"})
|
||||||
|
|
||||||
with jsMedia, js{"extended_entities", "media"}:
|
result.expandTweetEntities(js)
|
||||||
for m in jsMedia:
|
parseLegacyMediaEntities(js, result)
|
||||||
case m{"type"}.getStr
|
|
||||||
of "photo":
|
|
||||||
result.photos.add m{"media_url_https"}.getImageStr
|
|
||||||
of "video":
|
|
||||||
result.video = some(parseVideo(m))
|
|
||||||
with user, m{"additional_media_info", "source_user"}:
|
|
||||||
result.attribution = some(parseUser(user))
|
|
||||||
of "animated_gif":
|
|
||||||
result.gif = some(parseGif(m))
|
|
||||||
else: discard
|
|
||||||
|
|
||||||
with jsWithheld, js{"withheld_in_countries"}:
|
with jsWithheld, js{"withheld_in_countries"}:
|
||||||
let withheldInCountries: seq[string] =
|
let withheldInCountries: seq[string] =
|
||||||
@@ -248,159 +357,265 @@ proc parseTweet(js: JsonNode): Tweet =
|
|||||||
result.text.removeSuffix(" Learn more.")
|
result.text.removeSuffix(" Learn more.")
|
||||||
result.available = false
|
result.available = false
|
||||||
|
|
||||||
proc finalizeTweet(global: GlobalObjects; id: string): Tweet =
|
proc parseGraphTweet(js: JsonNode): Tweet =
|
||||||
let intId = if id.len > 0: parseBiggestInt(id) else: 0
|
if js.kind == JNull:
|
||||||
result = global.tweets.getOrDefault(id, Tweet(id: intId))
|
return Tweet()
|
||||||
|
|
||||||
|
case js.getTypeName:
|
||||||
|
of "TweetUnavailable":
|
||||||
|
return Tweet()
|
||||||
|
of "TweetTombstone":
|
||||||
|
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
|
||||||
|
|
||||||
|
if not js.hasKey("legacy"):
|
||||||
|
return Tweet()
|
||||||
|
|
||||||
|
var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"})
|
||||||
|
if jsCard.kind != JNull:
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
result = parseTweet(js{"legacy"}, jsCard)
|
||||||
|
result.id = js{"rest_id"}.getId
|
||||||
|
result.user = parseGraphUser(js{"core"})
|
||||||
|
|
||||||
|
if result.replyId == 0:
|
||||||
|
result.replyId = js{"reply_to_results", "rest_id"}.getId
|
||||||
|
|
||||||
|
with count, js{"views", "count"}:
|
||||||
|
result.stats.views = count.getStr("0").parseInt
|
||||||
|
|
||||||
|
with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
|
||||||
|
result.expandNoteTweetEntities(noteTweet)
|
||||||
|
|
||||||
|
parseMediaEntities(js, result)
|
||||||
|
|
||||||
if result.quote.isSome:
|
if result.quote.isSome:
|
||||||
let quote = get(result.quote).id
|
result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}))
|
||||||
if $quote in global.tweets:
|
|
||||||
result.quote = some global.tweets[$quote]
|
|
||||||
else:
|
|
||||||
result.quote = some Tweet()
|
|
||||||
|
|
||||||
if result.retweet.isSome:
|
with quoted, js{"quotedPostResults", "result"}:
|
||||||
let rt = get(result.retweet).id
|
result.quote = some(parseGraphTweet(quoted))
|
||||||
if $rt in global.tweets:
|
|
||||||
result.retweet = some finalizeTweet(global, $rt)
|
|
||||||
else:
|
|
||||||
result.retweet = some Tweet()
|
|
||||||
|
|
||||||
proc parsePin(js: JsonNode; global: GlobalObjects): Tweet =
|
proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
|
||||||
let pin = js{"pinEntry", "entry", "entryId"}.getStr
|
for t in ? js{"content", "items"}:
|
||||||
if pin.len == 0: return
|
let entryId = t.getEntryId
|
||||||
|
if "cursor-showmore" in entryId:
|
||||||
|
let cursor = t{"item", "content", "value"}
|
||||||
|
result.thread.cursor = cursor.getStr
|
||||||
|
result.thread.hasMore = true
|
||||||
|
elif "tweet" in entryId and "promoted" notin entryId:
|
||||||
|
with tweet, t.getTweetResult("item"):
|
||||||
|
result.thread.content.add parseGraphTweet(tweet)
|
||||||
|
|
||||||
let id = pin.getId
|
let tweetDisplayType = select(
|
||||||
if id notin global.tweets: return
|
t{"item", "content", "tweet_display_type"},
|
||||||
|
t{"item", "itemContent", "tweetDisplayType"}
|
||||||
|
)
|
||||||
|
if tweetDisplayType.getStr == "SelfThread":
|
||||||
|
result.self = true
|
||||||
|
|
||||||
global.tweets[id].pinned = true
|
proc parseGraphTweetResult*(js: JsonNode): Tweet =
|
||||||
return finalizeTweet(global, id)
|
with tweet, js{"data", "tweet_result", "result"}:
|
||||||
|
result = parseGraphTweet(tweet)
|
||||||
|
|
||||||
proc parseGlobalObjects(js: JsonNode): GlobalObjects =
|
proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
||||||
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)
|
|
||||||
if tweet.user.id in result.users:
|
|
||||||
tweet.user = result.users[tweet.user.id]
|
|
||||||
result.tweets[k] = tweet
|
|
||||||
|
|
||||||
proc parseThread(js: JsonNode; global: GlobalObjects): tuple[thread: Chain, self: bool] =
|
|
||||||
result.thread = Chain()
|
|
||||||
|
|
||||||
let thread = js{"content", "item", "content", "conversationThread"}
|
|
||||||
with cursor, thread{"showMoreCursor"}:
|
|
||||||
result.thread.cursor = cursor{"value"}.getStr
|
|
||||||
result.thread.hasMore = true
|
|
||||||
|
|
||||||
for t in thread{"conversationComponents"}:
|
|
||||||
let content = t{"conversationTweetComponent", "tweet"}
|
|
||||||
|
|
||||||
if content{"displayType"}.getStr == "SelfThread":
|
|
||||||
result.self = true
|
|
||||||
|
|
||||||
var tweet = finalizeTweet(global, content{"id"}.getStr)
|
|
||||||
if not tweet.available:
|
|
||||||
tweet.tombstone = getTombstone(content{"tombstone"})
|
|
||||||
result.thread.content.add tweet
|
|
||||||
|
|
||||||
proc parseConversation*(js: JsonNode; tweetId: string): Conversation =
|
|
||||||
result = Conversation(replies: Result[Chain](beginning: true))
|
result = Conversation(replies: Result[Chain](beginning: true))
|
||||||
let global = parseGlobalObjects(? js)
|
|
||||||
|
|
||||||
let instructions = ? js{"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:
|
if instructions.len == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
for e in instructions[0]{"addEntries", "entries"}:
|
for i in instructions:
|
||||||
let entry = e{"entryId"}.getStr
|
if i.getTypeName == "TimelineAddEntries":
|
||||||
if "tweet" in entry or "tombstone" in entry:
|
for e in i{"entries"}:
|
||||||
let tweet = finalizeTweet(global, e.getEntryId)
|
let entryId = e.getEntryId
|
||||||
if $tweet.id != tweetId:
|
if entryId.startsWith("tweet"):
|
||||||
result.before.content.add tweet
|
let tweetResult = getTweetResult(e)
|
||||||
else:
|
if tweetResult.notNull:
|
||||||
result.tweet = tweet
|
let tweet = parseGraphTweet(tweetResult)
|
||||||
elif "conversationThread" in entry:
|
|
||||||
let (thread, self) = parseThread(e, global)
|
|
||||||
if thread.content.len > 0:
|
|
||||||
if self:
|
|
||||||
result.after = thread
|
|
||||||
else:
|
|
||||||
result.replies.content.add thread
|
|
||||||
elif "cursor-showMore" in entry:
|
|
||||||
result.replies.bottom = e.getCursor
|
|
||||||
elif "cursor-bottom" in entry:
|
|
||||||
result.replies.bottom = e.getCursor
|
|
||||||
|
|
||||||
proc parseStatus*(js: JsonNode): Tweet =
|
if not tweet.available:
|
||||||
with e, js{"errors"}:
|
tweet.id = entryId.getId
|
||||||
if e.getError == tweetNotFound:
|
|
||||||
return
|
|
||||||
|
|
||||||
result = parseTweet(js)
|
if $tweet.id == tweetId:
|
||||||
if not result.isNil:
|
result.tweet = tweet
|
||||||
result.user = parseUser(js{"user"})
|
else:
|
||||||
|
result.before.content.add tweet
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
with quote, js{"quoted_status"}:
|
if $tweet.id == tweetId:
|
||||||
result.quote = some parseStatus(js{"quoted_status"})
|
result.tweet = tweet
|
||||||
|
else:
|
||||||
|
result.before.content.add tweet
|
||||||
|
elif entryId.startsWith("cursor-bottom"):
|
||||||
|
var cursorValue = select(
|
||||||
|
e{"content", "value"},
|
||||||
|
e{"content", "content", "value"},
|
||||||
|
e{"content", "itemContent", "value"}
|
||||||
|
)
|
||||||
|
result.replies.bottom = cursorValue.getStr
|
||||||
|
|
||||||
proc parseInstructions[T](res: var Result[T]; global: GlobalObjects; js: JsonNode) =
|
proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] =
|
||||||
if js.kind != JArray or js.len == 0:
|
with tweetResult, getTweetResult(e):
|
||||||
|
var tweet = parseGraphTweet(tweetResult)
|
||||||
|
if not tweet.available:
|
||||||
|
tweet.id = e.getEntryId.getId
|
||||||
|
result.add tweet
|
||||||
return
|
return
|
||||||
|
|
||||||
for i in js:
|
for item in e{"content", "items"}:
|
||||||
when T is Tweet:
|
with tweetResult, item.getTweetResult("item"):
|
||||||
if res.beginning and i{"pinEntry"}.notNull:
|
var tweet = parseGraphTweet(tweetResult)
|
||||||
with pin, parsePin(i, global):
|
if not tweet.available:
|
||||||
res.content.add pin
|
tweet.id = item.getEntryId.getId
|
||||||
|
result.add tweet
|
||||||
|
|
||||||
with r, i{"replaceEntry", "entry"}:
|
proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
|
||||||
if "top" in r{"entryId"}.getStr:
|
result = Profile(tweets: Timeline(beginning: after.len == 0))
|
||||||
res.top = r.getCursor
|
|
||||||
elif "bottom" in r{"entryId"}.getStr:
|
|
||||||
res.bottom = r.getCursor
|
|
||||||
|
|
||||||
proc parseTimeline*(js: JsonNode; after=""): Timeline =
|
let instructions = ? select(
|
||||||
result = Timeline(beginning: after.len == 0)
|
js{"data", "list", "timeline_response", "timeline", "instructions"},
|
||||||
let global = parseGlobalObjects(? js)
|
js{"data", "user", "result", "timeline", "timeline", "instructions"},
|
||||||
|
js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
|
||||||
|
)
|
||||||
|
if instructions.len == 0:
|
||||||
|
return
|
||||||
|
|
||||||
let instructions = ? js{"timeline", "instructions"}
|
for i in instructions:
|
||||||
if instructions.len == 0: return
|
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
|
||||||
|
|
||||||
result.parseInstructions(global, instructions)
|
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
|
||||||
|
|
||||||
for e in instructions[0]{"addEntries", "entries"}:
|
if after.len == 0:
|
||||||
let entry = e{"entryId"}.getStr
|
if i.getTypeName == "TimelinePinEntry":
|
||||||
if "tweet" in entry or entry.startsWith("sq-I-t") or "tombstone" in entry:
|
let tweets = extractTweetsFromEntry(i{"entry"})
|
||||||
let tweet = finalizeTweet(global, e.getEntryId)
|
if tweets.len > 0:
|
||||||
if not tweet.available: continue
|
var tweet = tweets[0]
|
||||||
result.content.add tweet
|
tweet.pinned = true
|
||||||
elif "cursor-top" in entry:
|
result.pinned = some tweet
|
||||||
result.top = e.getCursor
|
|
||||||
elif "cursor-bottom" in entry:
|
|
||||||
result.bottom = e.getCursor
|
|
||||||
elif entry.startsWith("sq-C"):
|
|
||||||
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 =
|
proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
|
||||||
for tweet in js:
|
result = @[]
|
||||||
let
|
|
||||||
t = parseTweet(tweet)
|
|
||||||
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
|
let instructions = select(
|
||||||
result.add GalleryPhoto(url: url, tweetId: $t.id)
|
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 = getTypeName(instruction)
|
||||||
|
if typ == "TimelineAddEntries":
|
||||||
|
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"):
|
||||||
|
result.bottom = instruction{"entry", "content", "value"}.getStr
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import std/[strutils, times, macros, htmlgen, options, algorithm, re]
|
import std/[times, macros, htmlgen, options, algorithm, re]
|
||||||
|
import std/strutils except escape
|
||||||
import std/unicode except strip
|
import std/unicode except strip
|
||||||
|
from xmltree import escape
|
||||||
import packedjson
|
import packedjson
|
||||||
import types, utils, formatters
|
import types, utils, formatters
|
||||||
|
|
||||||
|
const
|
||||||
|
unicodeOpen = "\uFFFA"
|
||||||
|
unicodeClose = "\uFFFB"
|
||||||
|
xmlOpen = escape("<")
|
||||||
|
xmlClose = escape(">")
|
||||||
|
|
||||||
let
|
let
|
||||||
unRegex = re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})"
|
unRegex = re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})"
|
||||||
unReplace = "$1<a href=\"/$2\">@$2</a>"
|
unReplace = "$1<a href=\"/$2\">@$2</a>"
|
||||||
@@ -28,13 +36,19 @@ template `?`*(js: JsonNode): untyped =
|
|||||||
if j.isNull: return
|
if j.isNull: return
|
||||||
j
|
j
|
||||||
|
|
||||||
template `with`*(ident, value, body): untyped =
|
template select*(a, b: JsonNode): untyped =
|
||||||
block:
|
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
|
let ident {.inject.} = value
|
||||||
if ident != nil: body
|
if ident != nil: body
|
||||||
|
|
||||||
template `with`*(ident; value: JsonNode; body): untyped =
|
template with*(ident; value: JsonNode; body): untyped =
|
||||||
block:
|
if true:
|
||||||
let ident {.inject.} = value
|
let ident {.inject.} = value
|
||||||
if value.notNull: body
|
if value.notNull: body
|
||||||
|
|
||||||
@@ -45,6 +59,20 @@ template getError*(js: JsonNode): Error =
|
|||||||
if js.kind != JArray or js.len == 0: null
|
if js.kind != JArray or js.len == 0: null
|
||||||
else: Error(js[0]{"code"}.getInt)
|
else: Error(js[0]{"code"}.getInt)
|
||||||
|
|
||||||
|
proc getTweetResult*(js: JsonNode; root="content"): JsonNode =
|
||||||
|
select(
|
||||||
|
js{root, "content", "tweet_results", "result"},
|
||||||
|
js{root, "itemContent", "tweet_results", "result"},
|
||||||
|
js{root, "content", "tweetResult", "result"}
|
||||||
|
)
|
||||||
|
|
||||||
|
template getTypeName*(js: JsonNode): string =
|
||||||
|
js{"__typename"}.getStr(js{"type"}.getStr)
|
||||||
|
|
||||||
|
template getEntryId*(e: JsonNode): string =
|
||||||
|
e{"entryId"}.getStr(e{"entry_id"}.getStr)
|
||||||
|
|
||||||
|
|
||||||
template parseTime(time: string; f: static string; flen: int): DateTime =
|
template parseTime(time: string; f: static string; flen: int): DateTime =
|
||||||
if time.len != flen: return
|
if time.len != flen: return
|
||||||
parse(time, f, utc())
|
parse(time, f, utc())
|
||||||
@@ -55,29 +83,24 @@ proc getDateTime*(js: JsonNode): DateTime =
|
|||||||
proc getTime*(js: JsonNode): DateTime =
|
proc getTime*(js: JsonNode): DateTime =
|
||||||
parseTime(js.getStr, "ddd MMM dd hh:mm:ss \'+0000\' yyyy", 30)
|
parseTime(js.getStr, "ddd MMM dd hh:mm:ss \'+0000\' yyyy", 30)
|
||||||
|
|
||||||
proc getId*(id: string): string {.inline.} =
|
proc getTimeFromMs*(js: JsonNode): DateTime =
|
||||||
|
let ms = js.getInt(0)
|
||||||
|
if ms == 0: return
|
||||||
|
let seconds = ms div 1000
|
||||||
|
return fromUnix(seconds).utc()
|
||||||
|
|
||||||
|
proc getId*(id: string): int64 {.inline.} =
|
||||||
let start = id.rfind("-")
|
let start = id.rfind("-")
|
||||||
if start < 0: return id
|
if start < 0:
|
||||||
id[start + 1 ..< id.len]
|
return parseBiggestInt(id)
|
||||||
|
return parseBiggestInt(id[start + 1 ..< id.len])
|
||||||
|
|
||||||
proc getId*(js: JsonNode): int64 {.inline.} =
|
proc getId*(js: JsonNode): int64 {.inline.} =
|
||||||
case js.kind
|
case js.kind
|
||||||
of JString: return parseBiggestInt(js.getStr("0"))
|
of JString: return js.getStr("0").getId
|
||||||
of JInt: return js.getBiggestInt()
|
of JInt: return js.getBiggestInt()
|
||||||
else: return 0
|
else: return 0
|
||||||
|
|
||||||
proc getEntryId*(js: JsonNode): string {.inline.} =
|
|
||||||
let entry = js{"entryId"}.getStr
|
|
||||||
if entry.len == 0: return
|
|
||||||
|
|
||||||
if "tweet" in entry or "sq-I-t" in entry:
|
|
||||||
return entry.getId
|
|
||||||
elif "tombstone" in entry:
|
|
||||||
return js{"content", "item", "content", "tombstone", "tweet", "id"}.getStr
|
|
||||||
else:
|
|
||||||
echo "unknown entry: ", entry
|
|
||||||
return
|
|
||||||
|
|
||||||
template getStrVal*(js: JsonNode; default=""): string =
|
template getStrVal*(js: JsonNode; default=""): string =
|
||||||
js{"string_value"}.getStr(default)
|
js{"string_value"}.getStr(default)
|
||||||
|
|
||||||
@@ -89,6 +112,9 @@ proc getImageStr*(js: JsonNode): string =
|
|||||||
template getImageVal*(js: JsonNode): string =
|
template getImageVal*(js: JsonNode): string =
|
||||||
js{"image_value", "url"}.getImageStr
|
js{"image_value", "url"}.getImageStr
|
||||||
|
|
||||||
|
template getExpandedUrl*(js: JsonNode; fallback=""): string =
|
||||||
|
js{"expanded_url"}.getStr(js{"url"}.getStr(fallback))
|
||||||
|
|
||||||
proc getCardUrl*(js: JsonNode; kind: CardKind): string =
|
proc getCardUrl*(js: JsonNode; kind: CardKind): string =
|
||||||
result = js{"website_url"}.getStrVal
|
result = js{"website_url"}.getStrVal
|
||||||
if kind == promoVideoConvo:
|
if kind == promoVideoConvo:
|
||||||
@@ -130,16 +156,31 @@ proc getBanner*(js: JsonNode): string =
|
|||||||
return
|
return
|
||||||
|
|
||||||
proc getTombstone*(js: JsonNode): string =
|
proc getTombstone*(js: JsonNode): string =
|
||||||
result = js{"tombstoneInfo", "richText", "text"}.getStr
|
result = js{"text"}.getStr
|
||||||
result.removeSuffix(" Learn more")
|
result.removeSuffix(" Learn more")
|
||||||
|
|
||||||
|
proc getMp4Resolution*(url: string): int =
|
||||||
|
# parses the height out of a URL like this one:
|
||||||
|
# https://video.twimg.com/ext_tw_video/<tweet-id>/pu/vid/720x1280/<random>.mp4
|
||||||
|
const vidSep = "/vid/"
|
||||||
|
let
|
||||||
|
vidIdx = url.find(vidSep) + vidSep.len
|
||||||
|
resIdx = url.find('x', vidIdx) + 1
|
||||||
|
res = url[resIdx ..< url.find("/", resIdx)]
|
||||||
|
|
||||||
|
try:
|
||||||
|
return parseInt(res)
|
||||||
|
except ValueError:
|
||||||
|
# cannot determine resolution (e.g. m3u8/non-mp4 video)
|
||||||
|
return 0
|
||||||
|
|
||||||
proc extractSlice(js: JsonNode): Slice[int] =
|
proc extractSlice(js: JsonNode): Slice[int] =
|
||||||
result = js["indices"][0].getInt ..< js["indices"][1].getInt
|
result = js["indices"][0].getInt ..< js["indices"][1].getInt
|
||||||
|
|
||||||
proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
|
proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
|
||||||
textLen: int; hideTwitter = false) =
|
textLen: int; hideTwitter = false) =
|
||||||
let
|
let
|
||||||
url = js["expanded_url"].getStr
|
url = js.getExpandedUrl
|
||||||
slice = js.extractSlice
|
slice = js.extractSlice
|
||||||
|
|
||||||
if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
|
if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
|
||||||
@@ -200,7 +241,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
|
|||||||
ent = ? js{"entities"}
|
ent = ? js{"entities"}
|
||||||
|
|
||||||
with urls, ent{"url", "urls"}:
|
with urls, ent{"url", "urls"}:
|
||||||
user.website = urls[0]{"expanded_url"}.getStr
|
user.website = urls[0].getExpandedUrl
|
||||||
|
|
||||||
var replacements = newSeq[ReplaceSlice]()
|
var replacements = newSeq[ReplaceSlice]()
|
||||||
|
|
||||||
@@ -215,47 +256,37 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
|
|||||||
user.bio = user.bio.replacef(unRegex, unReplace)
|
user.bio = user.bio.replacef(unRegex, unReplace)
|
||||||
.replacef(htRegex, htReplace)
|
.replacef(htRegex, htReplace)
|
||||||
|
|
||||||
proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
|
proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int];
|
||||||
let
|
replyTo=""; hasRedundantLink=false) =
|
||||||
orig = tweet.text.toRunes
|
let hasCard = tweet.card.isSome
|
||||||
textRange = js{"display_text_range"}
|
|
||||||
textSlice = textRange{0}.getInt .. textRange{1}.getInt
|
|
||||||
hasQuote = js{"is_quote_status"}.getBool
|
|
||||||
hasCard = tweet.card.isSome
|
|
||||||
|
|
||||||
var replyTo = ""
|
|
||||||
if tweet.replyId != 0:
|
|
||||||
with reply, js{"in_reply_to_screen_name"}:
|
|
||||||
tweet.reply.add reply.getStr
|
|
||||||
replyTo = reply.getStr
|
|
||||||
|
|
||||||
let ent = ? js{"entities"}
|
|
||||||
|
|
||||||
var replacements = newSeq[ReplaceSlice]()
|
var replacements = newSeq[ReplaceSlice]()
|
||||||
|
|
||||||
with urls, ent{"urls"}:
|
with urls, entities{"urls"}:
|
||||||
for u in urls:
|
for u in urls:
|
||||||
let urlStr = u["url"].getStr
|
let urlStr = u["url"].getStr
|
||||||
if urlStr.len == 0 or urlStr notin tweet.text:
|
if urlStr.len == 0 or urlStr notin text:
|
||||||
continue
|
continue
|
||||||
replacements.extractUrls(u, textSlice.b, hideTwitter = hasQuote)
|
|
||||||
if hasCard and u{"url"}.getStr == get(tweet.card).url:
|
|
||||||
get(tweet.card).url = u{"expanded_url"}.getStr
|
|
||||||
|
|
||||||
with media, ent{"media"}:
|
replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink)
|
||||||
|
|
||||||
|
if hasCard and u{"url"}.getStr == get(tweet.card).url:
|
||||||
|
get(tweet.card).url = u.getExpandedUrl
|
||||||
|
|
||||||
|
with media, entities{"media"}:
|
||||||
for m in media:
|
for m in media:
|
||||||
replacements.extractUrls(m, textSlice.b, hideTwitter = true)
|
replacements.extractUrls(m, textSlice.b, hideTwitter = true)
|
||||||
|
|
||||||
if "hashtags" in ent:
|
if "hashtags" in entities:
|
||||||
for hashtag in ent["hashtags"]:
|
for hashtag in entities["hashtags"]:
|
||||||
replacements.extractHashtags(hashtag)
|
replacements.extractHashtags(hashtag)
|
||||||
|
|
||||||
if "symbols" in ent:
|
if "symbols" in entities:
|
||||||
for symbol in ent["symbols"]:
|
for symbol in entities["symbols"]:
|
||||||
replacements.extractHashtags(symbol)
|
replacements.extractHashtags(symbol)
|
||||||
|
|
||||||
if "user_mentions" in ent:
|
if "user_mentions" in entities:
|
||||||
for mention in ent["user_mentions"]:
|
for mention in entities["user_mentions"]:
|
||||||
let
|
let
|
||||||
name = mention{"screen_name"}.getStr
|
name = mention{"screen_name"}.getStr
|
||||||
slice = mention.extractSlice
|
slice = mention.extractSlice
|
||||||
@@ -272,5 +303,40 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
|
|||||||
replacements.deduplicate
|
replacements.deduplicate
|
||||||
replacements.sort(cmp)
|
replacements.sort(cmp)
|
||||||
|
|
||||||
tweet.text = orig.replacedWith(replacements, textSlice)
|
tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false)
|
||||||
.strip(leading=false)
|
|
||||||
|
proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
|
||||||
|
let
|
||||||
|
entities = ? js{"entities"}
|
||||||
|
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:
|
||||||
|
with reply, js{"in_reply_to_screen_name"}:
|
||||||
|
replyTo = reply.getStr
|
||||||
|
tweet.reply.add replyTo
|
||||||
|
|
||||||
|
tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote or hasJobCard)
|
||||||
|
|
||||||
|
proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
|
||||||
|
let
|
||||||
|
entities = ? js{"entity_set"}
|
||||||
|
text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose))
|
||||||
|
textSlice = 0..text.runeLen
|
||||||
|
|
||||||
|
tweet.expandTextEntities(entities, text, textSlice)
|
||||||
|
|
||||||
|
tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose))
|
||||||
|
|
||||||
|
proc extractGalleryPhoto*(t: Tweet): GalleryPhoto =
|
||||||
|
let url =
|
||||||
|
if t.photos.len > 0: t.photos[0]
|
||||||
|
elif t.video.isSome: get(t.video).thumb
|
||||||
|
elif t.gif.isSome: get(t.gif).thumb
|
||||||
|
elif t.card.isSome: get(t.card).image
|
||||||
|
else: ""
|
||||||
|
|
||||||
|
result = GalleryPhoto(url: url, tweetId: $t.id)
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ genPrefs:
|
|||||||
"Enable mp4 video playback (only for gifs)"
|
"Enable mp4 video playback (only for gifs)"
|
||||||
|
|
||||||
hlsPlayback(checkbox, false):
|
hlsPlayback(checkbox, false):
|
||||||
"Enable hls video streaming (requires JavaScript)"
|
"Enable HLS video streaming (requires JavaScript)"
|
||||||
|
|
||||||
proxyVideos(checkbox, true):
|
proxyVideos(checkbox, true):
|
||||||
"Proxy video streaming through the server (might be slow)"
|
"Proxy video streaming through the server (might be slow)"
|
||||||
@@ -107,10 +107,6 @@ genPrefs:
|
|||||||
"Reddit -> Teddit/Libreddit"
|
"Reddit -> Teddit/Libreddit"
|
||||||
placeholder: "Teddit hostname"
|
placeholder: "Teddit hostname"
|
||||||
|
|
||||||
replaceInstagram(input, ""):
|
|
||||||
"Instagram -> Bibliogram"
|
|
||||||
placeholder: "Bibliogram hostname"
|
|
||||||
|
|
||||||
iterator allPrefs*(): Pref =
|
iterator allPrefs*(): Pref =
|
||||||
for k, v in prefList:
|
for k, v in prefList:
|
||||||
for pref in v:
|
for pref in v:
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import types
|
|||||||
const
|
const
|
||||||
validFilters* = @[
|
validFilters* = @[
|
||||||
"media", "images", "twimg", "videos",
|
"media", "images", "twimg", "videos",
|
||||||
"native_video", "consumer_video", "pro_video",
|
"native_video", "consumer_video", "spaces",
|
||||||
"links", "news", "quote", "mentions",
|
"links", "news", "quote", "mentions",
|
||||||
"replies", "retweets", "nativeretweets",
|
"replies", "retweets", "nativeretweets"
|
||||||
"verified", "safe"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
emptyQuery* = "include:nativeretweets"
|
emptyQuery* = "include:nativeretweets"
|
||||||
@@ -18,6 +17,11 @@ template `@`(param: string): untyped =
|
|||||||
if param in pms: pms[param]
|
if param in pms: pms[param]
|
||||||
else: ""
|
else: ""
|
||||||
|
|
||||||
|
proc validateNumber(value: string): string =
|
||||||
|
if value.anyIt(not it.isDigit):
|
||||||
|
return ""
|
||||||
|
return value
|
||||||
|
|
||||||
proc initQuery*(pms: Table[string, string]; name=""): Query =
|
proc initQuery*(pms: Table[string, string]; name=""): Query =
|
||||||
result = Query(
|
result = Query(
|
||||||
kind: parseEnum[QueryKind](@"f", tweets),
|
kind: parseEnum[QueryKind](@"f", tweets),
|
||||||
@@ -26,7 +30,7 @@ proc initQuery*(pms: Table[string, string]; name=""): Query =
|
|||||||
excludes: validFilters.filterIt("e-" & it in pms),
|
excludes: validFilters.filterIt("e-" & it in pms),
|
||||||
since: @"since",
|
since: @"since",
|
||||||
until: @"until",
|
until: @"until",
|
||||||
near: @"near"
|
minLikes: validateNumber(@"min_faves")
|
||||||
)
|
)
|
||||||
|
|
||||||
if name.len > 0:
|
if name.len > 0:
|
||||||
@@ -60,7 +64,7 @@ proc genQueryParam*(query: Query): string =
|
|||||||
param &= "OR "
|
param &= "OR "
|
||||||
|
|
||||||
if query.fromUser.len > 0 and query.kind in {posts, media}:
|
if query.fromUser.len > 0 and query.kind in {posts, media}:
|
||||||
param &= "filter:self_threads OR-filter:replies "
|
param &= "filter:self_threads OR -filter:replies "
|
||||||
|
|
||||||
if "nativeretweets" notin query.excludes:
|
if "nativeretweets" notin query.excludes:
|
||||||
param &= "include:nativeretweets "
|
param &= "include:nativeretweets "
|
||||||
@@ -78,8 +82,8 @@ proc genQueryParam*(query: Query): string =
|
|||||||
result &= " since:" & query.since
|
result &= " since:" & query.since
|
||||||
if query.until.len > 0:
|
if query.until.len > 0:
|
||||||
result &= " until:" & query.until
|
result &= " until:" & query.until
|
||||||
if query.near.len > 0:
|
if query.minLikes.len > 0:
|
||||||
result &= &" near:\"{query.near}\" within:15mi"
|
result &= " min_faves:" & query.minLikes
|
||||||
if query.text.len > 0:
|
if query.text.len > 0:
|
||||||
if result.len > 0:
|
if result.len > 0:
|
||||||
result &= " " & query.text
|
result &= " " & query.text
|
||||||
@@ -93,18 +97,18 @@ proc genQueryUrl*(query: Query): string =
|
|||||||
if query.text.len > 0:
|
if query.text.len > 0:
|
||||||
params.add "q=" & encodeUrl(query.text)
|
params.add "q=" & encodeUrl(query.text)
|
||||||
for f in query.filters:
|
for f in query.filters:
|
||||||
params.add "f-" & f & "=on"
|
params.add &"f-{f}=on"
|
||||||
for e in query.excludes:
|
for e in query.excludes:
|
||||||
params.add "e-" & e & "=on"
|
params.add &"e-{e}=on"
|
||||||
for i in query.includes.filterIt(it != "nativeretweets"):
|
for i in query.includes.filterIt(it != "nativeretweets"):
|
||||||
params.add "i-" & i & "=on"
|
params.add &"i-{i}=on"
|
||||||
|
|
||||||
if query.since.len > 0:
|
if query.since.len > 0:
|
||||||
params.add "since=" & query.since
|
params.add "since=" & query.since
|
||||||
if query.until.len > 0:
|
if query.until.len > 0:
|
||||||
params.add "until=" & query.until
|
params.add "until=" & query.until
|
||||||
if query.near.len > 0:
|
if query.minLikes.len > 0:
|
||||||
params.add "near=" & query.near
|
params.add "min_faves=" & query.minLikes
|
||||||
|
|
||||||
if params.len > 0:
|
if params.len > 0:
|
||||||
result &= params.join("&")
|
result &= params.join("&")
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ proc initRedisPool*(cfg: Config) {.async.} =
|
|||||||
await migrate("profileDates", "p:*")
|
await migrate("profileDates", "p:*")
|
||||||
await migrate("profileStats", "p:*")
|
await migrate("profileStats", "p:*")
|
||||||
await migrate("userType", "p:*")
|
await migrate("userType", "p:*")
|
||||||
|
await migrate("verifiedType", "p:*")
|
||||||
|
|
||||||
pool.withAcquire(r):
|
pool.withAcquire(r):
|
||||||
# optimize memory usage for user ID buckets
|
# optimize memory usage for user ID buckets
|
||||||
@@ -85,7 +86,7 @@ proc cache*(data: List) {.async.} =
|
|||||||
await setEx(data.listKey, listCacheTime, compress(toFlatty(data)))
|
await setEx(data.listKey, listCacheTime, compress(toFlatty(data)))
|
||||||
|
|
||||||
proc cache*(data: PhotoRail; name: string) {.async.} =
|
proc cache*(data: PhotoRail; name: string) {.async.} =
|
||||||
await setEx("pr:" & toLower(name), baseCacheTime, compress(toFlatty(data)))
|
await setEx("pr2:" & toLower(name), baseCacheTime * 2, compress(toFlatty(data)))
|
||||||
|
|
||||||
proc cache*(data: User) {.async.} =
|
proc cache*(data: User) {.async.} =
|
||||||
if data.username.len == 0: return
|
if data.username.len == 0: return
|
||||||
@@ -118,11 +119,11 @@ proc getUserId*(username: string): Future[string] {.async.} =
|
|||||||
pool.withAcquire(r):
|
pool.withAcquire(r):
|
||||||
result = await r.hGet(name.uidKey, name)
|
result = await r.hGet(name.uidKey, name)
|
||||||
if result == redisNil:
|
if result == redisNil:
|
||||||
let user = await getUser(username)
|
let user = await getGraphUser(username)
|
||||||
if user.suspended:
|
if user.suspended:
|
||||||
return "suspended"
|
return "suspended"
|
||||||
else:
|
else:
|
||||||
await cacheUserId(name, user.id)
|
await all(cacheUserId(name, user.id), cache(user))
|
||||||
return user.id
|
return user.id
|
||||||
|
|
||||||
proc getCachedUser*(username: string; fetch=true): Future[User] {.async.} =
|
proc getCachedUser*(username: string; fetch=true): Future[User] {.async.} =
|
||||||
@@ -130,8 +131,7 @@ proc getCachedUser*(username: string; fetch=true): Future[User] {.async.} =
|
|||||||
if prof != redisNil:
|
if prof != redisNil:
|
||||||
prof.deserialize(User)
|
prof.deserialize(User)
|
||||||
elif fetch:
|
elif fetch:
|
||||||
let userId = await getUserId(username)
|
result = await getGraphUser(username)
|
||||||
result = await getGraphUser(userId)
|
|
||||||
await cache(result)
|
await cache(result)
|
||||||
|
|
||||||
proc getCachedUsername*(userId: string): Future[string] {.async.} =
|
proc getCachedUsername*(userId: string): Future[string] {.async.} =
|
||||||
@@ -142,28 +142,30 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
|
|||||||
if username != redisNil:
|
if username != redisNil:
|
||||||
result = username
|
result = username
|
||||||
else:
|
else:
|
||||||
let user = await getUserById(userId)
|
let user = await getGraphUserById(userId)
|
||||||
result = user.username
|
result = user.username
|
||||||
await setEx(key, baseCacheTime, result)
|
await setEx(key, baseCacheTime, result)
|
||||||
|
if result.len > 0 and user.id.len > 0:
|
||||||
|
await all(cacheUserId(result, user.id), cache(user))
|
||||||
|
|
||||||
proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
|
# proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
|
||||||
if id == 0: return
|
# if id == 0: return
|
||||||
let tweet = await get(id.tweetKey)
|
# let tweet = await get(id.tweetKey)
|
||||||
if tweet != redisNil:
|
# if tweet != redisNil:
|
||||||
tweet.deserialize(Tweet)
|
# tweet.deserialize(Tweet)
|
||||||
else:
|
# else:
|
||||||
result = await getStatus($id)
|
# result = await getGraphTweetResult($id)
|
||||||
if result.isNil:
|
# if not result.isNil:
|
||||||
await cache(result)
|
# await cache(result)
|
||||||
|
|
||||||
proc getCachedPhotoRail*(name: string): Future[PhotoRail] {.async.} =
|
proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} =
|
||||||
if name.len == 0: return
|
if id.len == 0: return
|
||||||
let rail = await get("pr:" & toLower(name))
|
let rail = await get("pr2:" & toLower(id))
|
||||||
if rail != redisNil:
|
if rail != redisNil:
|
||||||
rail.deserialize(PhotoRail)
|
rail.deserialize(PhotoRail)
|
||||||
else:
|
else:
|
||||||
result = await getPhotoRail(name)
|
result = await getPhotoRail(id)
|
||||||
await cache(result, name)
|
await cache(result, id)
|
||||||
|
|
||||||
proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
|
proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
|
||||||
let list = if id.len == 0: redisNil
|
let list = if id.len == 0: redisNil
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import jester
|
import jester
|
||||||
import router_utils
|
import router_utils
|
||||||
import ".."/[tokens, types]
|
import ".."/[auth, types]
|
||||||
|
|
||||||
proc createDebugRouter*(cfg: Config) =
|
proc createDebugRouter*(cfg: Config) =
|
||||||
router debug:
|
router debug:
|
||||||
get "/.tokens":
|
get "/.health":
|
||||||
|
respJson getSessionPoolHealth()
|
||||||
|
|
||||||
|
get "/.sessions":
|
||||||
cond cfg.enableDebug
|
cond cfg.enableDebug
|
||||||
respJson getPoolJson()
|
respJson getSessionPoolDebug()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import asyncdispatch, strutils, options
|
import asyncdispatch, strutils, strformat, options
|
||||||
import jester, karax/vdom
|
import jester, karax/vdom
|
||||||
import ".."/[types, api]
|
import ".."/[types, api]
|
||||||
import ../views/[embed, tweet, general]
|
import ../views/[embed, tweet, general]
|
||||||
@@ -10,27 +10,27 @@ export api, embed, vdom, tweet, general, router_utils
|
|||||||
proc createEmbedRouter*(cfg: Config) =
|
proc createEmbedRouter*(cfg: Config) =
|
||||||
router embed:
|
router embed:
|
||||||
get "/i/videos/tweet/@id":
|
get "/i/videos/tweet/@id":
|
||||||
let convo = await getTweet(@"id")
|
let tweet = await getGraphTweetResult(@"id")
|
||||||
if convo == nil or convo.tweet == nil or convo.tweet.video.isNone:
|
if tweet == nil or tweet.video.isNone:
|
||||||
resp Http404
|
resp Http404
|
||||||
|
|
||||||
resp renderVideoEmbed(convo.tweet, cfg, request)
|
resp renderVideoEmbed(tweet, cfg, request)
|
||||||
|
|
||||||
get "/@user/status/@id/embed":
|
get "/@user/status/@id/embed":
|
||||||
let
|
let
|
||||||
convo = await getTweet(@"id")
|
tweet = await getGraphTweetResult(@"id")
|
||||||
prefs = cookiePrefs()
|
prefs = cookiePrefs()
|
||||||
path = getPath()
|
path = getPath()
|
||||||
|
|
||||||
if convo == nil or convo.tweet == nil:
|
if tweet == nil:
|
||||||
resp Http404
|
resp Http404
|
||||||
|
|
||||||
resp $renderTweetEmbed(convo.tweet, path, prefs, cfg, request)
|
resp renderTweetEmbed(tweet, path, prefs, cfg, request)
|
||||||
|
|
||||||
get "/embed/Tweet.html":
|
get "/embed/Tweet.html":
|
||||||
let id = @"id"
|
let id = @"id"
|
||||||
|
|
||||||
if id.len > 0:
|
if id.len > 0:
|
||||||
redirect("/i/status/" & id & "/embed")
|
redirect(&"/i/status/{id}/embed")
|
||||||
else:
|
else:
|
||||||
resp Http404
|
resp Http404
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import strutils, uri
|
import strutils, strformat, uri
|
||||||
|
|
||||||
import jester
|
import jester
|
||||||
|
|
||||||
import router_utils
|
import router_utils
|
||||||
import ".."/[types, redis_cache, api]
|
import ".."/[types, redis_cache, api]
|
||||||
import ../views/[general, timeline, list]
|
import ../views/[general, timeline, list]
|
||||||
export getListTimeline, getGraphList
|
|
||||||
|
|
||||||
template respList*(list, timeline, title, vnode: typed) =
|
template respList*(list, timeline, title, vnode: typed) =
|
||||||
if list.id.len == 0 or list.name.len == 0:
|
if list.id.len == 0 or list.name.len == 0:
|
||||||
resp Http404, showError("List " & @"id" & " not found", cfg)
|
resp Http404, showError(&"""List "{@"id"}" not found""", cfg)
|
||||||
|
|
||||||
let
|
let
|
||||||
html = renderList(vnode, timeline.query, list)
|
html = renderList(vnode, timeline.query, list)
|
||||||
rss = "/i/lists/$1/rss" % [@"id"]
|
rss = &"""/i/lists/{@"id"}/rss"""
|
||||||
|
|
||||||
resp renderMain(html, request, cfg, prefs, titleText=title, rss=rss, banner=list.banner)
|
resp renderMain(html, request, cfg, prefs, titleText=title, rss=rss, banner=list.banner)
|
||||||
|
|
||||||
|
proc title*(list: List): string =
|
||||||
|
&"@{list.username}/{list.name}"
|
||||||
|
|
||||||
proc createListRouter*(cfg: Config) =
|
proc createListRouter*(cfg: Config) =
|
||||||
router list:
|
router list:
|
||||||
get "/@name/lists/@slug/?":
|
get "/@name/lists/@slug/?":
|
||||||
@@ -28,24 +30,22 @@ proc createListRouter*(cfg: Config) =
|
|||||||
slug = decodeUrl(@"slug")
|
slug = decodeUrl(@"slug")
|
||||||
list = await getCachedList(@"name", slug)
|
list = await getCachedList(@"name", slug)
|
||||||
if list.id.len == 0:
|
if list.id.len == 0:
|
||||||
resp Http404, showError("List \"" & @"slug" & "\" not found", cfg)
|
resp Http404, showError(&"""List "{@"slug"}" not found""", cfg)
|
||||||
redirect("/i/lists/" & list.id)
|
redirect(&"/i/lists/{list.id}")
|
||||||
|
|
||||||
get "/i/lists/@id/?":
|
get "/i/lists/@id/?":
|
||||||
cond '.' notin @"id"
|
cond '.' notin @"id"
|
||||||
let
|
let
|
||||||
prefs = cookiePrefs()
|
prefs = cookiePrefs()
|
||||||
list = await getCachedList(id=(@"id"))
|
list = await getCachedList(id=(@"id"))
|
||||||
title = "@" & list.username & "/" & list.name
|
timeline = await getGraphListTweets(list.id, getCursor())
|
||||||
timeline = await getListTimeline(list.id, getCursor())
|
|
||||||
vnode = renderTimelineTweets(timeline, prefs, request.path)
|
vnode = renderTimelineTweets(timeline, prefs, request.path)
|
||||||
respList(list, timeline, title, vnode)
|
respList(list, timeline, list.title, vnode)
|
||||||
|
|
||||||
get "/i/lists/@id/members":
|
get "/i/lists/@id/members":
|
||||||
cond '.' notin @"id"
|
cond '.' notin @"id"
|
||||||
let
|
let
|
||||||
prefs = cookiePrefs()
|
prefs = cookiePrefs()
|
||||||
list = await getCachedList(id=(@"id"))
|
list = await getCachedList(id=(@"id"))
|
||||||
title = "@" & list.username & "/" & list.name
|
|
||||||
members = await getGraphListMembers(list, getCursor())
|
members = await getGraphListMembers(list, getCursor())
|
||||||
respList(list, members, title, renderTimelineUsers(members, prefs, request.path))
|
respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path))
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
|
|||||||
try:
|
try:
|
||||||
let res = await client.get(url)
|
let res = await client.get(url)
|
||||||
if res.status != "200 OK":
|
if res.status != "200 OK":
|
||||||
|
if res.status != "404 Not Found":
|
||||||
|
echo "[media] Proxying failed, status: $1, url: $2" % [res.status, url]
|
||||||
return Http404
|
return Http404
|
||||||
|
|
||||||
let hashed = $hash(url)
|
let hashed = $hash(url)
|
||||||
@@ -50,10 +52,10 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
|
|||||||
""
|
""
|
||||||
|
|
||||||
let headers = newHttpHeaders({
|
let headers = newHttpHeaders({
|
||||||
"Content-Type": res.headers["content-type", 0],
|
"content-type": res.headers["content-type", 0],
|
||||||
"Content-Length": contentLength,
|
"content-length": contentLength,
|
||||||
"Cache-Control": maxAge,
|
"cache-control": maxAge,
|
||||||
"ETag": hashed
|
"etag": hashed
|
||||||
})
|
})
|
||||||
|
|
||||||
respond(request, headers)
|
respond(request, headers)
|
||||||
@@ -65,6 +67,7 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
|
|||||||
await request.client.send(data)
|
await request.client.send(data)
|
||||||
data.setLen 0
|
data.setLen 0
|
||||||
except HttpRequestError, ProtocolError, OSError:
|
except HttpRequestError, ProtocolError, OSError:
|
||||||
|
echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url]
|
||||||
result = Http404
|
result = Http404
|
||||||
finally:
|
finally:
|
||||||
client.close()
|
client.close()
|
||||||
@@ -88,6 +91,20 @@ proc createMediaRouter*(cfg: Config) =
|
|||||||
get "/pic/?":
|
get "/pic/?":
|
||||||
resp Http404
|
resp Http404
|
||||||
|
|
||||||
|
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)
|
||||||
|
url.add("?name=orig")
|
||||||
|
|
||||||
|
let uri = parseUri(url)
|
||||||
|
cond isTwitterUrl(uri) == true
|
||||||
|
|
||||||
|
let code = await proxyMedia(request, url)
|
||||||
|
check code
|
||||||
|
|
||||||
get re"^\/pic\/(enc)?\/?(.+)":
|
get re"^\/pic\/(enc)?\/?(.+)":
|
||||||
var url = decoded(request, 1)
|
var url = decoded(request, 1)
|
||||||
if "twimg.com" notin url:
|
if "twimg.com" notin url:
|
||||||
@@ -106,7 +123,7 @@ proc createMediaRouter*(cfg: Config) =
|
|||||||
cond "http" in url
|
cond "http" in url
|
||||||
|
|
||||||
if getHmac(url) != request.matches[1]:
|
if getHmac(url) != request.matches[1]:
|
||||||
resp showError("Failed to verify signature", cfg)
|
resp Http403, showError("Failed to verify signature", cfg)
|
||||||
|
|
||||||
if ".mp4" in url or ".ts" in url or ".m4s" in url:
|
if ".mp4" in url or ".ts" in url or ".m4s" in url:
|
||||||
let code = await proxyMedia(request, url)
|
let code = await proxyMedia(request, url)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import asyncdispatch, strutils, tables, times, hashes, uri
|
import asyncdispatch, tables, times, hashes, uri
|
||||||
|
|
||||||
import jester
|
import jester
|
||||||
|
|
||||||
@@ -10,6 +10,11 @@ include "../views/rss.nimf"
|
|||||||
|
|
||||||
export times, hashes
|
export times, hashes
|
||||||
|
|
||||||
|
proc redisKey*(page, name, cursor: string): string =
|
||||||
|
result = page & ":" & name
|
||||||
|
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): Future[Rss] {.async.} =
|
||||||
var profile: Profile
|
var profile: Profile
|
||||||
let
|
let
|
||||||
@@ -18,18 +23,16 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.
|
|||||||
names = getNames(name)
|
names = getNames(name)
|
||||||
|
|
||||||
if names.len == 1:
|
if names.len == 1:
|
||||||
profile = await fetchProfile(after, query, skipRail=true, skipPinned=true)
|
profile = await fetchProfile(after, query, skipRail=true)
|
||||||
else:
|
else:
|
||||||
var q = query
|
var q = query
|
||||||
q.fromUser = names
|
q.fromUser = names
|
||||||
profile = Profile(
|
profile.tweets = await getGraphTweetSearch(q, after)
|
||||||
tweets: await getSearch[Tweet](q, after),
|
# this is kinda dumb
|
||||||
# this is kinda dumb
|
profile.user = User(
|
||||||
user: User(
|
username: name,
|
||||||
username: name,
|
fullname: names.join(" | "),
|
||||||
fullname: names.join(" | "),
|
userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png"
|
||||||
userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if profile.user.suspended:
|
if profile.user.suspended:
|
||||||
@@ -42,8 +45,8 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.
|
|||||||
template respRss*(rss, page) =
|
template respRss*(rss, page) =
|
||||||
if rss.cursor.len == 0:
|
if rss.cursor.len == 0:
|
||||||
let info = case page
|
let info = case page
|
||||||
of "User": " \"$1\" " % @"name"
|
of "User": " \"" & @"name" & "\" "
|
||||||
of "List": " $1 " % @"id"
|
of "List": " \"" & @"id" & "\" "
|
||||||
else: " "
|
else: " "
|
||||||
|
|
||||||
resp Http404, showError(page & info & "not found", cfg)
|
resp Http404, showError(page & info & "not found", cfg)
|
||||||
@@ -67,13 +70,13 @@ proc createRssRouter*(cfg: Config) =
|
|||||||
|
|
||||||
let
|
let
|
||||||
cursor = getCursor()
|
cursor = getCursor()
|
||||||
key = "search:" & $hash(genQueryUrl(query)) & ":" & cursor
|
key = redisKey("search", $hash(genQueryUrl(query)), cursor)
|
||||||
|
|
||||||
var rss = await getCachedRss(key)
|
var rss = await getCachedRss(key)
|
||||||
if rss.cursor.len > 0:
|
if rss.cursor.len > 0:
|
||||||
respRss(rss, "Search")
|
respRss(rss, "Search")
|
||||||
|
|
||||||
let tweets = await getSearch[Tweet](query, cursor)
|
let tweets = await getGraphTweetSearch(query, cursor)
|
||||||
rss.cursor = tweets.bottom
|
rss.cursor = tweets.bottom
|
||||||
rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg)
|
rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg)
|
||||||
|
|
||||||
@@ -84,9 +87,8 @@ proc createRssRouter*(cfg: Config) =
|
|||||||
cond cfg.enableRss
|
cond cfg.enableRss
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
let
|
let
|
||||||
cursor = getCursor()
|
|
||||||
name = @"name"
|
name = @"name"
|
||||||
key = "twitter:" & name & ":" & cursor
|
key = redisKey("twitter", name, getCursor())
|
||||||
|
|
||||||
var rss = await getCachedRss(key)
|
var rss = await getCachedRss(key)
|
||||||
if rss.cursor.len > 0:
|
if rss.cursor.len > 0:
|
||||||
@@ -101,18 +103,20 @@ proc createRssRouter*(cfg: Config) =
|
|||||||
cond cfg.enableRss
|
cond cfg.enableRss
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
cond @"tab" in ["with_replies", "media", "search"]
|
cond @"tab" in ["with_replies", "media", "search"]
|
||||||
let name = @"name"
|
let
|
||||||
let query =
|
name = @"name"
|
||||||
case @"tab"
|
tab = @"tab"
|
||||||
of "with_replies": getReplyQuery(name)
|
query =
|
||||||
of "media": getMediaQuery(name)
|
case tab
|
||||||
of "search": initQuery(params(request), name=name)
|
of "with_replies": getReplyQuery(name)
|
||||||
else: Query(fromUser: @[name])
|
of "media": getMediaQuery(name)
|
||||||
|
of "search": initQuery(params(request), name=name)
|
||||||
|
else: Query(fromUser: @[name])
|
||||||
|
|
||||||
var key = @"tab" & ":" & @"name" & ":"
|
let searchKey = if tab != "search": ""
|
||||||
if @"tab" == "search":
|
else: ":" & $hash(genQueryUrl(query))
|
||||||
key &= $hash(genQueryUrl(query)) & ":"
|
|
||||||
key &= getCursor()
|
let key = redisKey(tab, name & searchKey, getCursor())
|
||||||
|
|
||||||
var rss = await getCachedRss(key)
|
var rss = await getCachedRss(key)
|
||||||
if rss.cursor.len > 0:
|
if rss.cursor.len > 0:
|
||||||
@@ -143,18 +147,17 @@ proc createRssRouter*(cfg: Config) =
|
|||||||
get "/i/lists/@id/rss":
|
get "/i/lists/@id/rss":
|
||||||
cond cfg.enableRss
|
cond cfg.enableRss
|
||||||
let
|
let
|
||||||
|
id = @"id"
|
||||||
cursor = getCursor()
|
cursor = getCursor()
|
||||||
key =
|
key = redisKey("lists", id, cursor)
|
||||||
if cursor.len == 0: "lists:" & @"id"
|
|
||||||
else: "lists:" & @"id" & ":" & cursor
|
|
||||||
|
|
||||||
var rss = await getCachedRss(key)
|
var rss = await getCachedRss(key)
|
||||||
if rss.cursor.len > 0:
|
if rss.cursor.len > 0:
|
||||||
respRss(rss, "List")
|
respRss(rss, "List")
|
||||||
|
|
||||||
let
|
let
|
||||||
list = await getCachedList(id=(@"id"))
|
list = await getCachedList(id=id)
|
||||||
timeline = await getListTimeline(list.id, cursor)
|
timeline = await getGraphListTweets(list.id, cursor)
|
||||||
rss.cursor = timeline.bottom
|
rss.cursor = timeline.bottom
|
||||||
rss.feed = renderListRss(timeline.content, list, cfg)
|
rss.feed = renderListRss(timeline.content, list, cfg)
|
||||||
|
|
||||||
|
|||||||
@@ -14,25 +14,31 @@ export search
|
|||||||
proc createSearchRouter*(cfg: Config) =
|
proc createSearchRouter*(cfg: Config) =
|
||||||
router search:
|
router search:
|
||||||
get "/search/?":
|
get "/search/?":
|
||||||
if @"q".len > 500:
|
let q = @"q"
|
||||||
|
if q.len > 500:
|
||||||
resp Http400, showError("Search input too long.", cfg)
|
resp Http400, showError("Search input too long.", cfg)
|
||||||
|
|
||||||
let
|
let
|
||||||
prefs = cookiePrefs()
|
prefs = cookiePrefs()
|
||||||
query = initQuery(params(request))
|
query = initQuery(params(request))
|
||||||
|
title = "Search" & (if q.len > 0: " (" & q & ")" else: "")
|
||||||
|
|
||||||
case query.kind
|
case query.kind
|
||||||
of users:
|
of users:
|
||||||
if "," in @"q":
|
if "," in q:
|
||||||
redirect("/" & @"q")
|
redirect("/" & q)
|
||||||
let users = await getSearch[User](query, getCursor())
|
var users: Result[User]
|
||||||
resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs)
|
try:
|
||||||
|
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:
|
of tweets:
|
||||||
let
|
let
|
||||||
tweets = await getSearch[Tweet](query, getCursor())
|
tweets = await getGraphTweetSearch(query, getCursor())
|
||||||
rss = "/search/rss?" & genQueryUrl(query)
|
rss = "/search/rss?" & genQueryUrl(query)
|
||||||
resp renderMain(renderTweetSearch(tweets, prefs, getPath()),
|
resp renderMain(renderTweetSearch(tweets, prefs, getPath()),
|
||||||
request, cfg, prefs, rss=rss)
|
request, cfg, prefs, title, rss=rss)
|
||||||
else:
|
else:
|
||||||
resp Http404, showError("Invalid search", cfg)
|
resp Http404, showError("Invalid search", cfg)
|
||||||
|
|
||||||
@@ -42,4 +48,4 @@ proc createSearchRouter*(cfg: Config) =
|
|||||||
get "/opensearch":
|
get "/opensearch":
|
||||||
let url = getUrlPrefix(cfg) & "/search?q="
|
let url = getUrlPrefix(cfg) & "/search?q="
|
||||||
resp Http200, {"Content-Type": "application/opensearchdescription+xml"},
|
resp Http200, {"Content-Type": "application/opensearchdescription+xml"},
|
||||||
generateOpenSearchXML(cfg.title, cfg.hostname, url)
|
generateOpenSearchXML(cfg.title, cfg.hostname, url)
|
||||||
|
|||||||
@@ -16,19 +16,21 @@ proc createStatusRouter*(cfg: Config) =
|
|||||||
router status:
|
router status:
|
||||||
get "/@name/status/@id/?":
|
get "/@name/status/@id/?":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
cond not @"id".any(c => not c.isDigit)
|
let id = @"id"
|
||||||
|
|
||||||
|
if id.len > 19 or id.any(c => not c.isDigit):
|
||||||
|
resp Http404, showError("Invalid tweet ID", cfg)
|
||||||
|
|
||||||
let prefs = cookiePrefs()
|
let prefs = cookiePrefs()
|
||||||
|
|
||||||
# used for the infinite scroll feature
|
# used for the infinite scroll feature
|
||||||
if @"scroll".len > 0:
|
if @"scroll".len > 0:
|
||||||
let replies = await getReplies(@"id", getCursor())
|
let replies = await getReplies(id, getCursor())
|
||||||
if replies.content.len == 0:
|
if replies.content.len == 0:
|
||||||
resp Http404, ""
|
resp Http404, ""
|
||||||
resp $renderReplies(replies, prefs, getPath())
|
resp $renderReplies(replies, prefs, getPath())
|
||||||
|
|
||||||
let conv = await getTweet(@"id", getCursor())
|
let conv = await getTweet(id, getCursor())
|
||||||
if conv == nil:
|
|
||||||
echo "nil conv"
|
|
||||||
|
|
||||||
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
|
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
|
||||||
var error = "Tweet not found"
|
var error = "Tweet not found"
|
||||||
@@ -64,7 +66,7 @@ proc createStatusRouter*(cfg: Config) =
|
|||||||
|
|
||||||
get "/@name/@s/@id/@m/?@i?":
|
get "/@name/@s/@id/@m/?@i?":
|
||||||
cond @"s" in ["status", "statuses"]
|
cond @"s" in ["status", "statuses"]
|
||||||
cond @"m" in ["video", "photo"]
|
cond @"m" in ["video", "photo", "history"]
|
||||||
redirect("/$1/status/$2" % [@"name", @"id"])
|
redirect("/$1/status/$2" % [@"name", @"id"])
|
||||||
|
|
||||||
get "/@name/statuses/@id/?":
|
get "/@name/statuses/@id/?":
|
||||||
@@ -72,3 +74,6 @@ proc createStatusRouter*(cfg: Config) =
|
|||||||
|
|
||||||
get "/i/web/status/@id":
|
get "/i/web/status/@id":
|
||||||
redirect("/i/status/" & @"id")
|
redirect("/i/status/" & @"id")
|
||||||
|
|
||||||
|
get "/@name/thread/@id/?":
|
||||||
|
redirect("/$1/status/$2" % [@"name", @"id"])
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
|
|||||||
else:
|
else:
|
||||||
body
|
body
|
||||||
|
|
||||||
proc fetchProfile*(after: string; query: Query; skipRail=false;
|
proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile] {.async.} =
|
||||||
skipPinned=false): Future[Profile] {.async.} =
|
|
||||||
let
|
let
|
||||||
name = query.fromUser[0]
|
name = query.fromUser[0]
|
||||||
userId = await getUserId(name)
|
userId = await getUserId(name)
|
||||||
@@ -45,36 +44,21 @@ proc fetchProfile*(after: string; query: Query; skipRail=false;
|
|||||||
after.setLen 0
|
after.setLen 0
|
||||||
|
|
||||||
let
|
let
|
||||||
timeline =
|
|
||||||
case query.kind
|
|
||||||
of posts: getTimeline(userId, after)
|
|
||||||
of replies: getTimeline(userId, after, replies=true)
|
|
||||||
of media: getMediaTimeline(userId, after)
|
|
||||||
else: getSearch[Tweet](query, after)
|
|
||||||
|
|
||||||
rail =
|
rail =
|
||||||
skipIf(skipRail or query.kind == media, @[]):
|
skipIf(skipRail or query.kind == media, @[]):
|
||||||
getCachedPhotoRail(name)
|
getCachedPhotoRail(userId)
|
||||||
|
|
||||||
user = await getCachedUser(name)
|
user = getCachedUser(name)
|
||||||
|
|
||||||
var pinned: Option[Tweet]
|
result =
|
||||||
if not skipPinned and user.pinnedTweet > 0 and
|
case query.kind
|
||||||
after.len == 0 and query.kind in {posts, replies}:
|
of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after)
|
||||||
let tweet = await getCachedTweet(user.pinnedTweet)
|
of replies: await getGraphUserTweets(userId, TimelineKind.replies, after)
|
||||||
if not tweet.isNil:
|
of media: await getGraphUserTweets(userId, TimelineKind.media, after)
|
||||||
tweet.pinned = true
|
else: Profile(tweets: await getGraphTweetSearch(query, after))
|
||||||
pinned = some tweet
|
|
||||||
|
|
||||||
result = Profile(
|
result.user = await user
|
||||||
user: user,
|
result.photoRail = await rail
|
||||||
pinned: pinned,
|
|
||||||
tweets: await timeline,
|
|
||||||
photoRail: await rail
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.user.protected or result.user.suspended:
|
|
||||||
return
|
|
||||||
|
|
||||||
result.tweets.query = query
|
result.tweets.query = query
|
||||||
|
|
||||||
@@ -82,11 +66,11 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
|
|||||||
rss, after: string): Future[string] {.async.} =
|
rss, after: string): Future[string] {.async.} =
|
||||||
if query.fromUser.len != 1:
|
if query.fromUser.len != 1:
|
||||||
let
|
let
|
||||||
timeline = await getSearch[Tweet](query, after)
|
timeline = await getGraphTweetSearch(query, after)
|
||||||
html = renderTweetSearch(timeline, prefs, getPath())
|
html = renderTweetSearch(timeline, prefs, getPath())
|
||||||
return renderMain(html, request, cfg, prefs, "Multi", rss=rss)
|
return renderMain(html, request, cfg, prefs, "Multi", rss=rss)
|
||||||
|
|
||||||
var profile = await fetchProfile(after, query, skipPinned=prefs.hidePins)
|
var profile = await fetchProfile(after, query)
|
||||||
template u: untyped = profile.user
|
template u: untyped = profile.user
|
||||||
|
|
||||||
if u.suspended:
|
if u.suspended:
|
||||||
@@ -121,9 +105,16 @@ proc createTimelineRouter*(cfg: Config) =
|
|||||||
get "/intent/user":
|
get "/intent/user":
|
||||||
respUserId()
|
respUserId()
|
||||||
|
|
||||||
|
get "/intent/follow/?":
|
||||||
|
let username = request.params.getOrDefault("screen_name")
|
||||||
|
if username.len == 0:
|
||||||
|
resp Http400, showError("Missing screen_name parameter", cfg)
|
||||||
|
redirect("/" & username)
|
||||||
|
|
||||||
get "/@name/?@tab?/?":
|
get "/@name/?@tab?/?":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
cond @"name" notin ["pic", "gif", "video"]
|
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
|
||||||
|
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','})
|
||||||
cond @"tab" in ["with_replies", "media", "search", ""]
|
cond @"tab" in ["with_replies", "media", "search", ""]
|
||||||
let
|
let
|
||||||
prefs = cookiePrefs()
|
prefs = cookiePrefs()
|
||||||
@@ -137,7 +128,7 @@ proc createTimelineRouter*(cfg: Config) =
|
|||||||
# used for the infinite scroll feature
|
# used for the infinite scroll feature
|
||||||
if @"scroll".len > 0:
|
if @"scroll".len > 0:
|
||||||
if query.fromUser.len != 1:
|
if query.fromUser.len != 1:
|
||||||
var timeline = await getSearch[Tweet](query, after)
|
var timeline = await getGraphTweetSearch(query, after)
|
||||||
if timeline.content.len == 0: resp Http404
|
if timeline.content.len == 0: resp Http404
|
||||||
timeline.beginning = true
|
timeline.beginning = true
|
||||||
resp $renderTweetSearch(timeline, prefs, getPath())
|
resp $renderTweetSearch(timeline, prefs, getPath())
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ proc createUnsupportedRouter*(cfg: Config) =
|
|||||||
get "/@name/lists/?": feature()
|
get "/@name/lists/?": feature()
|
||||||
|
|
||||||
get "/intent/?@i?":
|
get "/intent/?@i?":
|
||||||
cond @"i" notin ["user"]
|
cond @"i" notin ["user", "follow"]
|
||||||
feature()
|
feature()
|
||||||
|
|
||||||
get "/i/@i?/?@j?":
|
get "/i/@i?/?@j?":
|
||||||
|
|||||||
@@ -1,39 +1,40 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
|
|
||||||
.panel-container {
|
.panel-container {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
font-size: 130%;
|
font-size: 130%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-panel {
|
.error-panel {
|
||||||
@include center-panel(var(--error_red));
|
@include center-panel(var(--error_red));
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-bar > form {
|
.search-bar > form {
|
||||||
@include center-panel(var(--darkest_grey));
|
@include center-panel(var(--darkest_grey));
|
||||||
|
|
||||||
button {
|
button {
|
||||||
background: var(--bg_elements);
|
background: var(--bg_elements);
|
||||||
color: var(--fg_color);
|
color: var(--fg_color);
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
}
|
padding: 0px 5px 1px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--bg_elements);
|
background: var(--bg_elements);
|
||||||
color: var(--fg_color);
|
color: var(--fg_color);
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
height: unset;
|
height: unset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,18 +66,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#search-panel-toggle:checked ~ .search-panel {
|
#search-panel-toggle:checked ~ .search-panel {
|
||||||
@if $rows == 6 {
|
max-height: 380px !important;
|
||||||
max-height: 200px !important;
|
|
||||||
}
|
|
||||||
@if $rows == 5 {
|
|
||||||
max-height: 300px !important;
|
|
||||||
}
|
|
||||||
@if $rows == 4 {
|
|
||||||
max-height: 300px !important;
|
|
||||||
}
|
|
||||||
@if $rows == 3 {
|
|
||||||
max-height: 365px !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,43 @@
|
|||||||
// colors
|
// colors
|
||||||
$bg_color: #0F0F0F;
|
$bg_color: #0f0f0f;
|
||||||
$fg_color: #F8F8F2;
|
$fg_color: #f8f8f2;
|
||||||
$fg_faded: #F8F8F2CF;
|
$fg_faded: #f8f8f2cf;
|
||||||
$fg_dark: #FF6C60;
|
$fg_dark: #ff6c60;
|
||||||
$fg_nav: #FF6C60;
|
$fg_nav: #ff6c60;
|
||||||
|
|
||||||
$bg_panel: #161616;
|
$bg_panel: #161616;
|
||||||
$bg_elements: #121212;
|
$bg_elements: #121212;
|
||||||
$bg_overlays: #1F1F1F;
|
$bg_overlays: #1f1f1f;
|
||||||
$bg_hover: #1A1A1A;
|
$bg_hover: #1a1a1a;
|
||||||
|
|
||||||
$grey: #888889;
|
$grey: #888889;
|
||||||
$dark_grey: #404040;
|
$dark_grey: #404040;
|
||||||
$darker_grey: #282828;
|
$darker_grey: #282828;
|
||||||
$darkest_grey: #222222;
|
$darkest_grey: #222222;
|
||||||
$border_grey: #3E3E35;
|
$border_grey: #3e3e35;
|
||||||
|
|
||||||
$accent: #FF6C60;
|
$accent: #ff6c60;
|
||||||
$accent_light: #FFACA0;
|
$accent_light: #ffaca0;
|
||||||
$accent_dark: #8A3731;
|
$accent_dark: #8a3731;
|
||||||
$accent_border: #FF6C6091;
|
$accent_border: #ff6c6091;
|
||||||
|
|
||||||
$play_button: #D8574D;
|
$play_button: #d8574d;
|
||||||
$play_button_hover: #FF6C60;
|
$play_button_hover: #ff6c60;
|
||||||
|
|
||||||
$more_replies_dots: #AD433B;
|
$more_replies_dots: #ad433b;
|
||||||
$error_red: #420A05;
|
$error_red: #420a05;
|
||||||
|
|
||||||
$verified_blue: #1DA1F2;
|
$verified_blue: #1da1f2;
|
||||||
|
$verified_business: #fac82b;
|
||||||
|
$verified_government: #c1b6a4;
|
||||||
$icon_text: $fg_color;
|
$icon_text: $fg_color;
|
||||||
|
|
||||||
$tab: $fg_color;
|
$tab: $fg_color;
|
||||||
$tab_selected: $accent;
|
$tab_selected: $accent;
|
||||||
|
|
||||||
$shadow: rgba(0,0,0,.6);
|
$shadow: rgba(0, 0, 0, 0.6);
|
||||||
$shadow_dark: rgba(0,0,0,.2);
|
$shadow_dark: rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
//fonts
|
//fonts
|
||||||
$font_0: Helvetica Neue;
|
$font_0: sans-serif;
|
||||||
$font_1: Helvetica;
|
$font_1: fontello;
|
||||||
$font_2: Arial;
|
|
||||||
$font_3: sans-serif;
|
|
||||||
$font_4: fontello;
|
|
||||||
|
|||||||
@@ -1,165 +1,202 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
|
|
||||||
@import 'tweet/_base';
|
@import "tweet/_base";
|
||||||
@import 'profile/_base';
|
@import "profile/_base";
|
||||||
@import 'general';
|
@import "general";
|
||||||
@import 'navbar';
|
@import "navbar";
|
||||||
@import 'inputs';
|
@import "inputs";
|
||||||
@import 'timeline';
|
@import "timeline";
|
||||||
@import 'search';
|
@import "search";
|
||||||
|
|
||||||
body {
|
body {
|
||||||
// colors
|
// colors
|
||||||
--bg_color: #{$bg_color};
|
--bg_color: #{$bg_color};
|
||||||
--fg_color: #{$fg_color};
|
--fg_color: #{$fg_color};
|
||||||
--fg_faded: #{$fg_faded};
|
--fg_faded: #{$fg_faded};
|
||||||
--fg_dark: #{$fg_dark};
|
--fg_dark: #{$fg_dark};
|
||||||
--fg_nav: #{$fg_nav};
|
--fg_nav: #{$fg_nav};
|
||||||
|
|
||||||
--bg_panel: #{$bg_panel};
|
--bg_panel: #{$bg_panel};
|
||||||
--bg_elements: #{$bg_elements};
|
--bg_elements: #{$bg_elements};
|
||||||
--bg_overlays: #{$bg_overlays};
|
--bg_overlays: #{$bg_overlays};
|
||||||
--bg_hover: #{$bg_hover};
|
--bg_hover: #{$bg_hover};
|
||||||
|
|
||||||
--grey: #{$grey};
|
--grey: #{$grey};
|
||||||
--dark_grey: #{$dark_grey};
|
--dark_grey: #{$dark_grey};
|
||||||
--darker_grey: #{$darker_grey};
|
--darker_grey: #{$darker_grey};
|
||||||
--darkest_grey: #{$darkest_grey};
|
--darkest_grey: #{$darkest_grey};
|
||||||
--border_grey: #{$border_grey};
|
--border_grey: #{$border_grey};
|
||||||
|
|
||||||
--accent: #{$accent};
|
--accent: #{$accent};
|
||||||
--accent_light: #{$accent_light};
|
--accent_light: #{$accent_light};
|
||||||
--accent_dark: #{$accent_dark};
|
--accent_dark: #{$accent_dark};
|
||||||
--accent_border: #{$accent_border};
|
--accent_border: #{$accent_border};
|
||||||
|
|
||||||
--play_button: #{$play_button};
|
--play_button: #{$play_button};
|
||||||
--play_button_hover: #{$play_button_hover};
|
--play_button_hover: #{$play_button_hover};
|
||||||
|
|
||||||
--more_replies_dots: #{$more_replies_dots};
|
--more_replies_dots: #{$more_replies_dots};
|
||||||
--error_red: #{$error_red};
|
--error_red: #{$error_red};
|
||||||
|
|
||||||
--verified_blue: #{$verified_blue};
|
--verified_blue: #{$verified_blue};
|
||||||
--icon_text: #{$icon_text};
|
--verified_business: #{$verified_business};
|
||||||
|
--verified_government: #{$verified_government};
|
||||||
|
--icon_text: #{$icon_text};
|
||||||
|
|
||||||
--tab: #{$fg_color};
|
--tab: #{$fg_color};
|
||||||
--tab_selected: #{$accent};
|
--tab_selected: #{$accent};
|
||||||
|
|
||||||
--profile_stat: #{$fg_color};
|
--profile_stat: #{$fg_color};
|
||||||
|
|
||||||
background-color: var(--bg_color);
|
background-color: var(--bg_color);
|
||||||
color: var(--fg_color);
|
color: var(--fg_color);
|
||||||
font-family: $font_0, $font_1, $font_2, $font_3;
|
font-family: $font_0, $font_1;
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
outline: unset;
|
outline: unset;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2, h3 {
|
h2,
|
||||||
font-weight: normal;
|
h3 {
|
||||||
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 14px 0;
|
margin: 14px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldset {
|
fieldset {
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin-top: -0.6em;
|
margin-top: -0.6em;
|
||||||
}
|
}
|
||||||
|
|
||||||
legend {
|
legend {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: .6em 0 .3em 0;
|
padding: 0.6em 0 0.3em 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-bottom: 1px solid var(--border_grey);
|
border-bottom: 1px solid var(--border_grey);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preferences .note {
|
.preferences .note {
|
||||||
border-top: 1px solid var(--border_grey);
|
border-top: 1px solid var(--border_grey);
|
||||||
border-bottom: 1px solid var(--border_grey);
|
border-bottom: 1px solid var(--border_grey);
|
||||||
padding: 6px 0 8px 0;
|
padding: 6px 0 8px 0;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
padding-left: 1.3em;
|
padding-left: 1.3em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding-top: 50px;
|
padding-top: 50px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-container {
|
.icon-container {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-panel {
|
.overlay-panel {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
background-color: var(--bg_overlays);
|
background-color: var(--bg_overlays);
|
||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.verified-icon {
|
.verified-icon {
|
||||||
color: var(--icon_text);
|
display: inline-block;
|
||||||
background-color: var(--verified_blue);
|
width: 14px;
|
||||||
border-radius: 50%;
|
height: 14px;
|
||||||
flex-shrink: 0;
|
margin-left: 2px;
|
||||||
margin: 2px 0 3px 3px;
|
|
||||||
padding-top: 2px;
|
|
||||||
height: 12px;
|
|
||||||
width: 14px;
|
|
||||||
font-size: 8px;
|
|
||||||
display: inline-block;
|
|
||||||
text-align: center;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media(max-width: 600px) {
|
.verified-icon-circle {
|
||||||
.preferences-container {
|
position: absolute;
|
||||||
max-width: 95vw;
|
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 {
|
.verified-icon-check {
|
||||||
font-size: 16px;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,185 +1,203 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@include input-colors;
|
@include input-colors;
|
||||||
background-color: var(--bg_elements);
|
background-color: var(--bg_elements);
|
||||||
color: var(--fg_color);
|
color: var(--fg_color);
|
||||||
border: 1px solid var(--accent_border);
|
border: 1px solid var(--accent_border);
|
||||||
padding: 3px 6px;
|
padding: 3px 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
float: right;
|
float: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="date"],
|
input[type="date"],
|
||||||
|
input[type="number"],
|
||||||
select {
|
select {
|
||||||
@include input-colors;
|
@include input-colors;
|
||||||
background-color: var(--bg_elements);
|
background-color: var(--bg_elements);
|
||||||
padding: 1px 4px;
|
padding: 1px 4px;
|
||||||
color: var(--fg_color);
|
color: var(--fg_color);
|
||||||
border: 1px solid var(--accent_border);
|
border: 1px solid var(--accent_border);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"] {
|
input[type="number"] {
|
||||||
height: 16px;
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"] {
|
||||||
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
height: 20px;
|
height: 20px;
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="date"]::-webkit-inner-spin-button {
|
input[type="date"]::-webkit-inner-spin-button {
|
||||||
display: none;
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"]::-webkit-inner-spin-button,
|
||||||
|
input[type="number"]::-webkit-outer-spin-button {
|
||||||
|
display: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="date"]::-webkit-clear-button {
|
input[type="date"]::-webkit-clear-button {
|
||||||
margin-left: 17px;
|
margin-left: 17px;
|
||||||
filter: grayscale(100%);
|
filter: grayscale(100%);
|
||||||
filter: hue-rotate(120deg);
|
filter: hue-rotate(120deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
input::-webkit-calendar-picker-indicator {
|
input::-webkit-calendar-picker-indicator {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
input::-webkit-datetime-edit-day-field:focus,
|
input::-webkit-datetime-edit-day-field:focus,
|
||||||
input::-webkit-datetime-edit-month-field:focus,
|
input::-webkit-datetime-edit-month-field:focus,
|
||||||
input::-webkit-datetime-edit-year-field:focus {
|
input::-webkit-datetime-edit-year-field:focus {
|
||||||
background-color: var(--accent);
|
background-color: var(--accent);
|
||||||
color: var(--fg_color);
|
color: var(--fg_color);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-range {
|
.date-range {
|
||||||
.date-input {
|
.date-input {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-container {
|
.icon-container {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
right: 5px;
|
right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-title {
|
.search-title {
|
||||||
margin: 0 2px;
|
margin: 0 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-button button {
|
.icon-button button {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
float: none;
|
float: none;
|
||||||
padding: unset;
|
padding: unset;
|
||||||
padding-left: 4px;
|
padding-left: 4px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--accent_light);
|
color: var(--accent_light);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 1px;
|
top: 1px;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 17px;
|
height: 17px;
|
||||||
width: 17px;
|
width: 17px;
|
||||||
background-color: var(--bg_elements);
|
background-color: var(--bg_elements);
|
||||||
border: 1px solid var(--accent_border);
|
border: 1px solid var(--accent_border);
|
||||||
|
|
||||||
&:after {
|
&:after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-container {
|
.checkbox-container {
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
padding-right: 22px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
height: 0;
|
||||||
padding-right: 22px;
|
width: 0;
|
||||||
|
|
||||||
input {
|
&:checked ~ .checkbox:after {
|
||||||
position: absolute;
|
display: block;
|
||||||
opacity: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
height: 0;
|
|
||||||
width: 0;
|
|
||||||
|
|
||||||
&:checked ~ .checkbox:after {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:hover input ~ .checkbox {
|
&:hover input ~ .checkbox {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active input ~ .checkbox {
|
&:active input ~ .checkbox {
|
||||||
border-color: var(--accent_light);
|
border-color: var(--accent_light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox:after {
|
.checkbox:after {
|
||||||
left: 2px;
|
left: 2px;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-family: $font_4;
|
font-family: $font_1;
|
||||||
content: '\e803';
|
content: "\e811";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pref-group {
|
.pref-group {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preferences {
|
.preferences {
|
||||||
button {
|
button {
|
||||||
margin: 6px 0 3px 0;
|
margin: 6px 0 3px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
padding-right: 150px;
|
padding-right: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
display: block;
|
display: block;
|
||||||
-moz-appearance: none;
|
-moz-appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"] {
|
input[type="text"],
|
||||||
position: absolute;
|
input[type="number"] {
|
||||||
right: 0;
|
position: absolute;
|
||||||
max-width: 140px;
|
right: 0;
|
||||||
}
|
max-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
.pref-group {
|
.pref-group {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pref-input {
|
.pref-input {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pref-reset {
|
.pref-reset {
|
||||||
float: left;
|
float: left;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,88 +1,87 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
background-color: var(--bg_overlays);
|
background-color: var(--bg_overlays);
|
||||||
box-shadow: 0 0 4px $shadow;
|
box-shadow: 0 0 4px $shadow;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|
||||||
a, .icon-button button {
|
a,
|
||||||
color: var(--fg_nav);
|
.icon-button button {
|
||||||
}
|
color: var(--fg_nav);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.inner-nav {
|
.inner-nav {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-basis: 920px;
|
flex-basis: 920px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-name {
|
.site-name {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--accent_light);
|
color: var(--accent_light);
|
||||||
text-decoration: unset;
|
text-decoration: unset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-logo {
|
.site-logo {
|
||||||
display: block;
|
display: block;
|
||||||
width: 35px;
|
width: 35px;
|
||||||
height: 35px;
|
height: 35px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
line-height: 50px;
|
line-height: 50px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
&.right {
|
&.right {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.right a {
|
&.right a:hover {
|
||||||
padding-left: 4px;
|
color: var(--accent_light);
|
||||||
|
text-decoration: unset;
|
||||||
&:hover {
|
}
|
||||||
color: var(--accent_light);
|
|
||||||
text-decoration: unset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.lp {
|
.lp {
|
||||||
height: 14px;
|
height: 14px;
|
||||||
margin-top: 2px;
|
display: inline-block;
|
||||||
display: block;
|
position: relative;
|
||||||
fill: var(--fg_nav);
|
top: 2px;
|
||||||
|
fill: var(--fg_nav);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
fill: var(--accent_light);
|
fill: var(--accent_light);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-info:before {
|
.icon-info {
|
||||||
margin: 0 -3px;
|
margin: 0 -3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-cog {
|
.icon-cog {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
padding-left: 0 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,9 +73,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-joindate, .profile-location, profile-website {
|
.profile-joindate, .profile-location, .profile-website {
|
||||||
color: var(--fg_faded);
|
color: var(--fg_faded);
|
||||||
margin: 2px 0;
|
margin: 1px 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-tabs-name {
|
.profile-card-tabs-name {
|
||||||
@include breakable;
|
flex-shrink: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-avatar {
|
.profile-card-avatar {
|
||||||
|
|||||||
@@ -1,120 +1,120 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
|
|
||||||
.search-title {
|
.search-title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-field {
|
.search-field {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin: 0 2px 0 0;
|
||||||
|
padding: 0px 1px 1px 4px;
|
||||||
|
height: 23px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
.pref-input {
|
||||||
margin: 0 2px 0 0;
|
margin: 0 4px 0 0;
|
||||||
height: 23px;
|
flex-grow: 1;
|
||||||
}
|
height: 23px;
|
||||||
|
}
|
||||||
|
|
||||||
.pref-input {
|
input[type="text"],
|
||||||
margin: 0 4px 0 0;
|
input[type="number"] {
|
||||||
flex-grow: 1;
|
height: calc(100% - 4px);
|
||||||
height: 23px;
|
width: calc(100% - 8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"] {
|
> label {
|
||||||
height: calc(100% - 4px);
|
display: inline;
|
||||||
width: calc(100% - 8px);
|
background-color: var(--bg_elements);
|
||||||
}
|
color: var(--fg_color);
|
||||||
|
border: 1px solid var(--accent_border);
|
||||||
|
padding: 1px 1px 2px 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
|
||||||
> label {
|
@include input-colors;
|
||||||
display: inline;
|
}
|
||||||
background-color: var(--bg_elements);
|
|
||||||
color: var(--fg_color);
|
|
||||||
border: 1px solid var(--accent_border);
|
|
||||||
padding: 1px 6px 2px 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
|
|
||||||
@include input-colors;
|
@include create-toggle(search-panel, 380px);
|
||||||
}
|
|
||||||
|
|
||||||
@include create-toggle(search-panel, 200px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-panel {
|
.search-panel {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: max-height 0.4s;
|
transition: max-height 0.4s;
|
||||||
|
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
font-weight: initial;
|
font-weight: initial;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
> div {
|
.checkbox-container {
|
||||||
line-height: 1.7em;
|
display: inline;
|
||||||
}
|
padding-right: unset;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
margin-left: 23px;
|
||||||
|
}
|
||||||
|
|
||||||
.checkbox-container {
|
.checkbox {
|
||||||
display: inline;
|
right: unset;
|
||||||
padding-right: unset;
|
left: -22px;
|
||||||
margin-bottom: unset;
|
line-height: 1.6em;
|
||||||
margin-left: 23px;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox {
|
.checkbox-container .checkbox:after {
|
||||||
right: unset;
|
top: -4px;
|
||||||
left: -22px;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-container .checkbox:after {
|
|
||||||
top: -4px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-row {
|
.search-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
line-height: unset;
|
line-height: unset;
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
height: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pref-input {
|
||||||
|
display: block;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
height: 21px;
|
height: 21px;
|
||||||
}
|
margin-top: 1px;
|
||||||
|
|
||||||
.pref-input {
|
|
||||||
display: block;
|
|
||||||
padding-bottom: 5px;
|
|
||||||
|
|
||||||
input {
|
|
||||||
height: 21px;
|
|
||||||
margin-top: 1px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-toggles {
|
.search-toggles {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, auto);
|
grid-template-columns: repeat(5, auto);
|
||||||
grid-column-gap: 10px;
|
grid-column-gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-tabs {
|
.profile-tabs {
|
||||||
@include search-resize(820px, 5);
|
@include search-resize(820px, 5);
|
||||||
@include search-resize(725px, 4);
|
@include search-resize(715px, 4);
|
||||||
@include search-resize(600px, 6);
|
@include search-resize(700px, 5);
|
||||||
@include search-resize(560px, 5);
|
@include search-resize(485px, 4);
|
||||||
@include search-resize(480px, 4);
|
@include search-resize(410px, 3);
|
||||||
@include search-resize(410px, 3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@include search-resize(560px, 5);
|
@include search-resize(700px, 5);
|
||||||
@include search-resize(480px, 4);
|
@include search-resize(485px, 4);
|
||||||
@include search-resize(410px, 3);
|
@include search-resize(410px, 3);
|
||||||
|
|||||||
@@ -1,162 +1,162 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
|
|
||||||
.timeline-container {
|
.timeline-container {
|
||||||
@include panel(100%, 600px);
|
@include panel(100%, 600px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline {
|
.timeline {
|
||||||
background-color: var(--bg_panel);
|
background-color: var(--bg_panel);
|
||||||
|
|
||||||
> div:not(:first-child) {
|
> div:not(:first-child) {
|
||||||
border-top: 1px solid var(--border_grey);
|
border-top: 1px solid var(--border_grey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-header {
|
.timeline-header {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: var(--bg_panel);
|
background-color: var(--bg_panel);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
button {
|
button {
|
||||||
float: unset;
|
float: unset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-banner img {
|
.timeline-banner img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-description {
|
.timeline-description {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0 0 5px 0;
|
margin: 0 0 5px 0;
|
||||||
background-color: var(--bg_panel);
|
background-color: var(--bg_panel);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-item {
|
.tab-item {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
border-bottom: .1rem solid transparent;
|
border-bottom: 0.1rem solid transparent;
|
||||||
color: var(--tab);
|
color: var(--tab);
|
||||||
display: block;
|
display: block;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
border-bottom-color: var(--tab_selected);
|
|
||||||
color: var(--tab_selected);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active a {
|
&.active {
|
||||||
border-bottom-color: var(--tab_selected);
|
border-bottom-color: var(--tab_selected);
|
||||||
color: var(--tab_selected);
|
color: var(--tab_selected);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.wide {
|
&.active a {
|
||||||
flex-grow: 1.2;
|
border-bottom-color: var(--tab_selected);
|
||||||
flex-basis: 50px;
|
color: var(--tab_selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.wide {
|
||||||
|
flex-grow: 1.2;
|
||||||
|
flex-basis: 50px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-footer {
|
.timeline-footer {
|
||||||
background-color: var(--bg_panel);
|
background-color: var(--bg_panel);
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-protected {
|
.timeline-protected {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
color: var(--accent);
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-none {
|
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-none {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-end {
|
.timeline-end {
|
||||||
background-color: var(--bg_panel);
|
background-color: var(--bg_panel);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.show-more {
|
.show-more {
|
||||||
background-color: var(--bg_panel);
|
background-color: var(--bg_panel);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: .75em 0;
|
padding: 0.75em 0;
|
||||||
display: block !important;
|
display: block !important;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
background-color: var(--darkest_grey);
|
background-color: var(--darkest_grey);
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
height: 2em;
|
height: 2em;
|
||||||
padding: 0 2em;
|
padding: 0 2em;
|
||||||
line-height: 2em;
|
line-height: 2em;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--darker_grey);
|
background-color: var(--darker_grey);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-ref {
|
.top-ref {
|
||||||
background-color: var(--bg_color);
|
background-color: var(--bg_color);
|
||||||
border-top: none !important;
|
border-top: none !important;
|
||||||
|
|
||||||
.icon-down {
|
.icon-down {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--accent_light);
|
color: var(--accent_light);
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
transform: rotate(180deg) translateY(-1px);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
transform: rotate(180deg) translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-item {
|
.timeline-item {
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
border-left-width: 0;
|
border-left-width: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: .75em;
|
padding: 0.75em;
|
||||||
display: flex;
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,231 +1,244 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
@import 'thread';
|
@import "thread";
|
||||||
@import 'media';
|
@import "media";
|
||||||
@import 'video';
|
@import "video";
|
||||||
@import 'embed';
|
@import "embed";
|
||||||
@import 'card';
|
@import "card";
|
||||||
@import 'poll';
|
@import "poll";
|
||||||
@import 'quote';
|
@import "quote";
|
||||||
|
|
||||||
.tweet-body {
|
.tweet-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-left: 58px;
|
margin-left: 58px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-content {
|
.tweet-content {
|
||||||
font-family: $font_3;
|
line-height: 1.3em;
|
||||||
line-height: 1.3em;
|
pointer-events: all;
|
||||||
pointer-events: all;
|
display: inline;
|
||||||
display: inline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-bidi {
|
.tweet-bidi {
|
||||||
display: block !important;
|
display: block !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-header {
|
.tweet-header {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
vertical-align: bottom;
|
vertical-align: bottom;
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
margin-bottom: .2em;
|
margin-bottom: 0.2em;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-name-row {
|
.tweet-name-row {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullname-and-username {
|
.fullname-and-username {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullname {
|
.fullname {
|
||||||
@include ellipsis;
|
@include ellipsis;
|
||||||
flex-shrink: 2;
|
flex-shrink: 2;
|
||||||
max-width: 80%;
|
max-width: 80%;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--fg_color);
|
color: var(--fg_color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.username {
|
.username {
|
||||||
@include ellipsis;
|
@include ellipsis;
|
||||||
min-width: 1.6em;
|
min-width: 1.6em;
|
||||||
margin-left: .4em;
|
margin-left: 0.4em;
|
||||||
word-wrap: normal;
|
word-wrap: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-date {
|
.tweet-date {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-date a, .username, .show-more a {
|
.tweet-date a,
|
||||||
color: var(--fg_dark);
|
.username,
|
||||||
|
.show-more a {
|
||||||
|
color: var(--fg_dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-published {
|
.tweet-published {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
color: var(--grey);
|
color: var(--grey);
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-avatar {
|
.tweet-avatar {
|
||||||
display: contents !important;
|
display: contents !important;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
float: left;
|
float: left;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
margin-left: -58px;
|
margin-left: -58px;
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
position: absolute;
|
&.round {
|
||||||
|
border-radius: 50%;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
&.round {
|
&.mini {
|
||||||
border-radius: 50%;
|
position: unset;
|
||||||
}
|
margin-right: 5px;
|
||||||
|
margin-top: -1px;
|
||||||
&.mini {
|
width: 20px;
|
||||||
position: unset;
|
height: 20px;
|
||||||
margin-right: 5px;
|
}
|
||||||
margin-top: -1px;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-embed {
|
.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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
max-height: calc(100vh - 0.75em * 2);
|
||||||
height: 100%;
|
}
|
||||||
background-color: var(--bg_panel);
|
|
||||||
|
|
||||||
.tweet-content {
|
.card-image img {
|
||||||
font-size: 18px
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-body {
|
.avatar {
|
||||||
display: flex;
|
position: absolute;
|
||||||
flex-direction: column;
|
}
|
||||||
max-height: calc(100vh - 0.75em * 2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.attribution {
|
.attribution {
|
||||||
display: flex;
|
display: flex;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
color: var(--fg_color);
|
color: var(--fg_color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-tag-block {
|
.media-tag-block {
|
||||||
padding-top: 5px;
|
padding-top: 5px;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
|
color: var(--fg_faded);
|
||||||
|
|
||||||
|
.icon-container {
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-tag,
|
||||||
|
.icon-container {
|
||||||
color: var(--fg_faded);
|
color: var(--fg_faded);
|
||||||
|
}
|
||||||
.icon-container {
|
|
||||||
padding-right: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-tag, .icon-container {
|
|
||||||
color: var(--fg_faded);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-container .media-tag-block {
|
.timeline-container .media-tag-block {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-geo {
|
.tweet-geo {
|
||||||
color: var(--fg_faded);
|
color: var(--fg_faded);
|
||||||
}
|
}
|
||||||
|
|
||||||
.replying-to {
|
.replying-to {
|
||||||
color: var(--fg_faded);
|
color: var(--fg_faded);
|
||||||
margin: -2px 0 4px;
|
margin: -2px 0 4px;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.retweet-header, .pinned, .tweet-stats {
|
.retweet-header,
|
||||||
align-content: center;
|
.pinned,
|
||||||
color: var(--grey);
|
.tweet-stats {
|
||||||
display: flex;
|
align-content: center;
|
||||||
flex-shrink: 0;
|
color: var(--grey);
|
||||||
flex-wrap: wrap;
|
display: flex;
|
||||||
font-size: 14px;
|
flex-shrink: 0;
|
||||||
font-weight: 600;
|
flex-wrap: wrap;
|
||||||
line-height: 22px;
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 22px;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
@include ellipsis;
|
@include ellipsis;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.retweet-header {
|
.retweet-header {
|
||||||
margin-top: -5px !important;
|
margin-top: -5px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-stats {
|
.tweet-stats {
|
||||||
margin-bottom: -3px;
|
margin-bottom: -3px;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-stat {
|
.tweet-stat {
|
||||||
padding-top: 5px;
|
padding-top: 5px;
|
||||||
min-width: 1em;
|
min-width: 1em;
|
||||||
margin-right: 0.8em;
|
margin-right: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.show-thread {
|
.show-thread {
|
||||||
display: block;
|
display: block;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unavailable-box {
|
.unavailable-box {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: solid 1px var(--dark_grey);
|
border: solid 1px var(--dark_grey);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background-color: var(--bg_color);
|
background-color: var(--bg_color);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-link {
|
.tweet-link {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--bg_hover);
|
background-color: var(--bg_hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
|
|
||||||
.card-description {
|
.card-description {
|
||||||
margin: 0.3em 0;
|
margin: 0.3em 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-destination {
|
.card-destination {
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
|
|
||||||
.embed-video {
|
.embed-video {
|
||||||
.gallery-video {
|
.gallery-video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background-color: black;
|
background-color: black;
|
||||||
top: 0%;
|
top: 0%;
|
||||||
left: 0%;
|
left: 0%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-container {
|
.video-container {
|
||||||
max-height: unset;
|
max-height: unset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +1,76 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
|
|
||||||
.gallery-row {
|
.gallery-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
max-height: 379.5px;
|
max-height: 379.5px;
|
||||||
max-width: 533px;
|
max-width: 533px;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
|
|
||||||
.still-image {
|
.still-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachments {
|
.attachments {
|
||||||
margin-top: .35em;
|
margin-top: 0.35em;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-flow: column;
|
flex-flow: column;
|
||||||
background-color: var(--bg_color);
|
background-color: var(--bg_color);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
|
|
||||||
.image-attachment {
|
.image-attachment {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment {
|
.attachment {
|
||||||
position: relative;
|
position: relative;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0 .25em 0 0;
|
margin: 0 0.25em 0 0;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-width: 2em;
|
min-width: 2em;
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
max-height: 530px;
|
max-height: 530px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-gif video {
|
.gallery-gif video {
|
||||||
max-height: 530px;
|
max-height: 530px;
|
||||||
background-color: #101010;
|
background-color: #101010;
|
||||||
}
|
}
|
||||||
|
|
||||||
.still-image {
|
.still-image {
|
||||||
max-height: 379.5px;
|
max-height: 379.5px;
|
||||||
max-width: 533px;
|
max-width: 533px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 379.5px;
|
max-height: 379.5px;
|
||||||
flex-basis: 300px;
|
flex-basis: 300px;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.image {
|
.image {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
// .single-image {
|
// .single-image {
|
||||||
@@ -86,34 +86,34 @@
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
.overlay-circle {
|
.overlay-circle {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: var(--dark_grey);
|
background-color: var(--dark_grey);
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
border-width: 5px;
|
border-width: 5px;
|
||||||
border-color: var(--play_button);
|
border-color: var(--play_button);
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-triangle {
|
.overlay-triangle {
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 12px 0 12px 17px;
|
border-width: 12px 0 12px 17px;
|
||||||
border-color: transparent transparent transparent var(--play_button);
|
border-color: transparent transparent transparent var(--play_button);
|
||||||
margin-left: 14px;
|
margin-left: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-gif {
|
.media-gif {
|
||||||
display: table;
|
display: table;
|
||||||
background-color: unset;
|
background-color: unset;
|
||||||
width: unset;
|
width: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-body {
|
.media-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,42 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
|
|
||||||
.poll-meter {
|
.poll-meter {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: 6px 0;
|
margin: 6px 0;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
background: var(--bg_color);
|
background: var(--bg_color);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.poll-choice-bar {
|
.poll-choice-bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background: var(--dark_grey);
|
background: var(--dark_grey);
|
||||||
}
|
}
|
||||||
|
|
||||||
.poll-choice-value {
|
.poll-choice-value {
|
||||||
position: relative;
|
position: relative;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
min-width: 30px;
|
min-width: 30px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.poll-choice-option {
|
.poll-choice-option {
|
||||||
position: relative;
|
position: relative;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.poll-info {
|
.poll-info {
|
||||||
color: var(--grey);
|
color: var(--grey);
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leader .poll-choice-bar {
|
.leader .poll-choice-bar {
|
||||||
background: var(--accent_dark);
|
background: var(--accent_dark);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,94 +1,95 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
|
|
||||||
.quote {
|
.quote {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
border: solid 1px var(--dark_grey);
|
border: solid 1px var(--dark_grey);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background-color: var(--bg_elements);
|
background-color: var(--bg_elements);
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: all;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.unavailable:hover {
|
||||||
|
border-color: var(--dark_grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tweet-name-row {
|
||||||
|
padding: 6px 8px;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-text {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
pointer-events: all;
|
white-space: pre-wrap;
|
||||||
position: relative;
|
word-wrap: break-word;
|
||||||
width: 100%;
|
padding: 0px 8px 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
.show-thread {
|
||||||
border-color: var(--grey);
|
padding: 0px 8px 6px 8px;
|
||||||
}
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
&.unavailable:hover {
|
.replying-to {
|
||||||
border-color: var(--dark_grey);
|
padding: 0px 8px;
|
||||||
}
|
margin: unset;
|
||||||
|
}
|
||||||
.tweet-name-row {
|
|
||||||
padding: 6px 8px;
|
|
||||||
margin-top: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quote-text {
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
padding: 0px 8px 8px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.show-thread {
|
|
||||||
padding: 0px 8px 6px 8px;
|
|
||||||
margin-top: -6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.replying-to {
|
|
||||||
padding: 0px 8px;
|
|
||||||
margin: unset;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.unavailable-quote {
|
.unavailable-quote {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quote-link {
|
.quote-link {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quote-media-container {
|
.quote-media-container {
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.card {
|
||||||
|
margin: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-gif {
|
||||||
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.gallery-gif .attachment {
|
||||||
margin: unset;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--bg_color);
|
||||||
|
|
||||||
|
video {
|
||||||
|
height: unset;
|
||||||
|
width: unset;
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.attachments {
|
.gallery-video,
|
||||||
border-radius: 0;
|
.gallery-gif {
|
||||||
}
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
.media-gif {
|
.still-image img {
|
||||||
width: 100%;
|
max-height: 250px;
|
||||||
display: flex;
|
}
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gallery-gif .attachment {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
background-color: var(--bg_color);
|
|
||||||
|
|
||||||
video {
|
|
||||||
height: unset;
|
|
||||||
width: unset;
|
|
||||||
max-height: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.gallery-video, .gallery-gif {
|
|
||||||
max-height: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.still-image img {
|
|
||||||
max-height: 250px
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,113 +1,139 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
|
|
||||||
.conversation {
|
.conversation {
|
||||||
@include panel(100%, 600px);
|
@include panel(100%, 600px);
|
||||||
|
|
||||||
.show-more {
|
.show-more {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-thread {
|
.main-thread {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
background-color: var(--bg_panel);
|
background-color: var(--bg_panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-tweet, .replies {
|
.main-tweet,
|
||||||
padding-top: 50px;
|
.replies {
|
||||||
margin-top: -50px;
|
padding-top: 50px;
|
||||||
|
margin-top: -50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-tweet .tweet-content {
|
.main-tweet .tweet-content {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
@media(max-width: 600px) {
|
.main-tweet .tweet-content {
|
||||||
.main-tweet .tweet-content {
|
font-size: 16px;
|
||||||
font-size: 16px;
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.reply {
|
.reply {
|
||||||
background-color: var(--bg_panel);
|
background-color: var(--bg_panel);
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thread-line {
|
.thread-line {
|
||||||
.timeline-item::before,
|
.timeline-item::before,
|
||||||
&.timeline-item::before {
|
&.timeline-item::before {
|
||||||
background: var(--accent_dark);
|
background: var(--accent_dark);
|
||||||
content: '';
|
content: "";
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 3px;
|
min-width: 3px;
|
||||||
width: 3px;
|
width: 3px;
|
||||||
left: 26px;
|
left: 26px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
margin-left: -3px;
|
margin-left: -3px;
|
||||||
margin-bottom: 37px;
|
margin-bottom: 37px;
|
||||||
top: 56px;
|
top: 56px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.with-header:not(:first-child)::after {
|
.with-header:not(:first-child)::after {
|
||||||
background: var(--accent_dark);
|
background: var(--accent_dark);
|
||||||
content: '';
|
content: "";
|
||||||
position: relative;
|
position: relative;
|
||||||
float: left;
|
float: left;
|
||||||
min-width: 3px;
|
min-width: 3px;
|
||||||
width: 3px;
|
width: 3px;
|
||||||
right: calc(100% - 26px);
|
right: calc(100% - 26px);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
margin-left: -3px;
|
margin-left: -3px;
|
||||||
margin-bottom: 37px;
|
margin-bottom: 37px;
|
||||||
bottom: 10px;
|
bottom: 10px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unavailable::before {
|
.unavailable::before {
|
||||||
top: 48px;
|
top: 48px;
|
||||||
margin-bottom: 28px;
|
margin-bottom: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.more-replies::before {
|
.more-replies::before {
|
||||||
content: '...';
|
content: "...";
|
||||||
background: unset;
|
background: unset;
|
||||||
color: var(--more_replies_dots);
|
color: var(--more_replies_dots);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
line-height: 0.25em;
|
line-height: 0.25em;
|
||||||
left: 1.2em;
|
left: 1.2em;
|
||||||
width: 5px;
|
width: 5px;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
margin-left: -2.5px;
|
margin-left: -2.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.earlier-replies {
|
.earlier-replies {
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
margin-bottom: -5px;
|
margin-bottom: -5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-item.thread-last::before {
|
.timeline-item.thread-last::before {
|
||||||
background: unset;
|
background: unset;
|
||||||
min-width: unset;
|
min-width: unset;
|
||||||
width: 0;
|
width: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.more-replies {
|
.more-replies {
|
||||||
padding-top: 0.3em !important;
|
padding-top: 0.3em !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.more-replies-text {
|
.more-replies-text {
|
||||||
@include ellipsis;
|
@include ellipsis;
|
||||||
display: block;
|
display: block;
|
||||||
margin-left: 58px;
|
margin-left: 58px;
|
||||||
padding: 7px 0;
|
padding: 7px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item.thread.more-replies-thread {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,77 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
|
|
||||||
video {
|
video {
|
||||||
max-height: 100%;
|
height: 100%;
|
||||||
max-width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-video {
|
.gallery-video {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-video.card-container {
|
.gallery-video.card-container {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-container {
|
.video-container {
|
||||||
max-height: 530px;
|
min-height: 80px;
|
||||||
margin: 0;
|
min-width: 200px;
|
||||||
display: flex;
|
max-height: 530px;
|
||||||
align-items: center;
|
margin: 0;
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
img {
|
img {
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-overlay {
|
.video-overlay {
|
||||||
@include play-button;
|
@include play-button;
|
||||||
background-color: $shadow;
|
background-color: $shadow;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
top: calc(50% - 20px);
|
top: calc(50% - 20px);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
margin: 0 20px;
|
margin: 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
div {
|
.overlay-circle {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
top: calc(50% - 20px);
|
top: calc(50% - 20px);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
form {
|
.overlay-duration {
|
||||||
width: 100%;
|
position: absolute;
|
||||||
height: 100%;
|
bottom: 8px;
|
||||||
align-items: center;
|
left: 8px;
|
||||||
justify-content: center;
|
background-color: #0000007a;
|
||||||
display: flex;
|
line-height: 1em;
|
||||||
}
|
padding: 4px 6px 4px 6px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
form {
|
||||||
padding: 5px 8px;
|
width: 100%;
|
||||||
font-size: 16px;
|
height: 100%;
|
||||||
}
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
62
src/tid.nim
Normal file
62
src/tid.nim
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import std/[asyncdispatch, base64, httpclient, random, strutils, sequtils, times]
|
||||||
|
import nimcrypto
|
||||||
|
import experimental/parser/tid
|
||||||
|
|
||||||
|
randomize()
|
||||||
|
|
||||||
|
const defaultKeyword = "obfiowerehiring";
|
||||||
|
const pairsUrl =
|
||||||
|
"https://raw.githubusercontent.com/fa0311/x-client-transaction-id-pair-dict/refs/heads/main/pair.json";
|
||||||
|
|
||||||
|
var
|
||||||
|
cachedPairs: seq[TidPair] = @[]
|
||||||
|
lastCached = 0
|
||||||
|
# refresh every hour
|
||||||
|
ttlSec = 60 * 60
|
||||||
|
|
||||||
|
proc getPair(): Future[TidPair] {.async.} =
|
||||||
|
if cachedPairs.len == 0 or int(epochTime()) - lastCached > ttlSec:
|
||||||
|
lastCached = int(epochTime())
|
||||||
|
|
||||||
|
let client = newAsyncHttpClient()
|
||||||
|
defer: client.close()
|
||||||
|
|
||||||
|
let resp = await client.get(pairsUrl)
|
||||||
|
if resp.status == $Http200:
|
||||||
|
cachedPairs = parseTidPairs(await resp.body)
|
||||||
|
|
||||||
|
return sample(cachedPairs)
|
||||||
|
|
||||||
|
proc encodeSha256(text: string): array[32, byte] =
|
||||||
|
let
|
||||||
|
data = cast[ptr byte](addr text[0])
|
||||||
|
dataLen = uint(len(text))
|
||||||
|
digest = sha256.digest(data, dataLen)
|
||||||
|
return digest.data
|
||||||
|
|
||||||
|
proc encodeBase64[T](data: T): string =
|
||||||
|
return encode(data).replace("=", "")
|
||||||
|
|
||||||
|
proc decodeBase64(data: string): seq[byte] =
|
||||||
|
return cast[seq[byte]](decode(data))
|
||||||
|
|
||||||
|
proc genTid*(path: string): Future[string] {.async.} =
|
||||||
|
let
|
||||||
|
pair = await getPair()
|
||||||
|
|
||||||
|
timeNow = int(epochTime() - 1682924400)
|
||||||
|
timeNowBytes = @[
|
||||||
|
byte(timeNow and 0xff),
|
||||||
|
byte((timeNow shr 8) and 0xff),
|
||||||
|
byte((timeNow shr 16) and 0xff),
|
||||||
|
byte((timeNow shr 24) and 0xff)
|
||||||
|
]
|
||||||
|
|
||||||
|
data = "GET!" & path & "!" & $timeNow & defaultKeyword & pair.animationKey
|
||||||
|
hashBytes = encodeSha256(data)
|
||||||
|
keyBytes = decodeBase64(pair.verification)
|
||||||
|
bytesArr = keyBytes & timeNowBytes & hashBytes[0 ..< 16] & @[3'u8]
|
||||||
|
randomNum = byte(rand(256))
|
||||||
|
tid = @[randomNum] & bytesArr.mapIt(it xor randomNum)
|
||||||
|
|
||||||
|
return encodeBase64(tid)
|
||||||
154
src/tokens.nim
154
src/tokens.nim
@@ -1,154 +0,0 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
import asyncdispatch, httpclient, times, sequtils, json, random
|
|
||||||
import strutils, tables
|
|
||||||
import zippy
|
|
||||||
import types, consts, http_pool
|
|
||||||
|
|
||||||
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
|
|
||||||
clientPool: HttpPool
|
|
||||||
tokenPool: seq[Token]
|
|
||||||
lastFailed: Time
|
|
||||||
|
|
||||||
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.listMembers, Api.listBySlug, Api.list, Api.userRestId: 500
|
|
||||||
of Api.timeline: 187
|
|
||||||
else: 180
|
|
||||||
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 headers = newHttpHeaders({
|
|
||||||
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
|
||||||
"accept-encoding": "gzip",
|
|
||||||
"accept-language": "en-US,en;q=0.5",
|
|
||||||
"connection": "keep-alive",
|
|
||||||
"authorization": auth
|
|
||||||
})
|
|
||||||
|
|
||||||
try:
|
|
||||||
let
|
|
||||||
resp = clientPool.use(headers): await c.postContent(activate)
|
|
||||||
tokNode = parseJson(uncompress(resp))["guest_token"]
|
|
||||||
tok = tokNode.getStr($(tokNode.getInt))
|
|
||||||
time = getTime()
|
|
||||||
|
|
||||||
return Token(tok: tok, init: time, lastUse: time)
|
|
||||||
except Exception as e:
|
|
||||||
lastFailed = getTime()
|
|
||||||
echo "fetching token failed: ", e.msg
|
|
||||||
|
|
||||||
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:
|
|
||||||
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()
|
|
||||||
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:
|
|
||||||
tokenPool.add newToken
|
|
||||||
|
|
||||||
proc initTokenPool*(cfg: Config) {.async.} =
|
|
||||||
clientPool = HttpPool()
|
|
||||||
|
|
||||||
while true:
|
|
||||||
if tokenPool.countIt(not it.isLimited(Api.timeline)) < cfg.minTokens:
|
|
||||||
await poolTokens(min(4, cfg.minTokens - tokenPool.len))
|
|
||||||
await sleepAsync(2000)
|
|
||||||
@@ -6,45 +6,75 @@ genPrefsType()
|
|||||||
|
|
||||||
type
|
type
|
||||||
RateLimitError* = object of CatchableError
|
RateLimitError* = object of CatchableError
|
||||||
|
NoSessionsError* = object of CatchableError
|
||||||
InternalError* = object of CatchableError
|
InternalError* = object of CatchableError
|
||||||
|
BadClientError* = object of CatchableError
|
||||||
|
|
||||||
Api* {.pure.} = enum
|
TimelineKind* {.pure.} = enum
|
||||||
userShow
|
tweets, replies, media
|
||||||
timeline
|
|
||||||
search
|
ApiUrl* = object
|
||||||
tweet
|
endpoint*: string
|
||||||
list
|
params*: seq[(string, string)]
|
||||||
listBySlug
|
|
||||||
listMembers
|
ApiReq* = object
|
||||||
userRestId
|
oauth*: ApiUrl
|
||||||
status
|
cookie*: ApiUrl
|
||||||
|
|
||||||
RateLimit* = object
|
RateLimit* = object
|
||||||
|
limit*: int
|
||||||
remaining*: int
|
remaining*: int
|
||||||
reset*: int
|
reset*: int
|
||||||
|
|
||||||
Token* = ref object
|
SessionKind* = enum
|
||||||
tok*: string
|
oauth
|
||||||
init*: Time
|
cookie
|
||||||
lastUse*: Time
|
|
||||||
|
Session* = ref object
|
||||||
|
id*: int64
|
||||||
|
username*: string
|
||||||
pending*: int
|
pending*: int
|
||||||
apis*: Table[Api, RateLimit]
|
limited*: bool
|
||||||
|
limitedAt*: int
|
||||||
|
apis*: Table[string, RateLimit]
|
||||||
|
case kind*: SessionKind
|
||||||
|
of oauth:
|
||||||
|
oauthToken*: string
|
||||||
|
oauthSecret*: string
|
||||||
|
of cookie:
|
||||||
|
authToken*: string
|
||||||
|
ct0*: string
|
||||||
|
|
||||||
Error* = enum
|
Error* = enum
|
||||||
null = 0
|
null = 0
|
||||||
noUserMatches = 17
|
noUserMatches = 17
|
||||||
protectedUser = 22
|
protectedUser = 22
|
||||||
|
missingParams = 25
|
||||||
|
timeout = 29
|
||||||
couldntAuth = 32
|
couldntAuth = 32
|
||||||
doesntExist = 34
|
doesntExist = 34
|
||||||
|
unauthorized = 37
|
||||||
|
invalidParam = 47
|
||||||
userNotFound = 50
|
userNotFound = 50
|
||||||
suspended = 63
|
suspended = 63
|
||||||
rateLimited = 88
|
rateLimited = 88
|
||||||
invalidToken = 89
|
expiredToken = 89
|
||||||
listIdOrSlug = 112
|
listIdOrSlug = 112
|
||||||
tweetNotFound = 144
|
tweetNotFound = 144
|
||||||
|
tweetNotAuthorized = 179
|
||||||
forbidden = 200
|
forbidden = 200
|
||||||
|
badRequest = 214
|
||||||
badToken = 239
|
badToken = 239
|
||||||
|
locked = 326
|
||||||
noCsrf = 353
|
noCsrf = 353
|
||||||
|
tweetUnavailable = 421
|
||||||
|
tweetCensored = 422
|
||||||
|
|
||||||
|
VerifiedType* = enum
|
||||||
|
none = "None"
|
||||||
|
blue = "Blue"
|
||||||
|
business = "Business"
|
||||||
|
government = "Government"
|
||||||
|
|
||||||
User* = object
|
User* = object
|
||||||
id*: string
|
id*: string
|
||||||
@@ -61,7 +91,7 @@ type
|
|||||||
tweets*: int
|
tweets*: int
|
||||||
likes*: int
|
likes*: int
|
||||||
media*: int
|
media*: int
|
||||||
verified*: bool
|
verifiedType*: VerifiedType
|
||||||
protected*: bool
|
protected*: bool
|
||||||
suspended*: bool
|
suspended*: bool
|
||||||
joinDate*: DateTime
|
joinDate*: DateTime
|
||||||
@@ -75,12 +105,12 @@ type
|
|||||||
contentType*: VideoType
|
contentType*: VideoType
|
||||||
url*: string
|
url*: string
|
||||||
bitrate*: int
|
bitrate*: int
|
||||||
|
resolution*: int
|
||||||
|
|
||||||
Video* = object
|
Video* = object
|
||||||
durationMs*: int
|
durationMs*: int
|
||||||
url*: string
|
url*: string
|
||||||
thumb*: string
|
thumb*: string
|
||||||
views*: string
|
|
||||||
available*: bool
|
available*: bool
|
||||||
reason*: string
|
reason*: string
|
||||||
title*: string
|
title*: string
|
||||||
@@ -100,7 +130,7 @@ type
|
|||||||
fromUser*: seq[string]
|
fromUser*: seq[string]
|
||||||
since*: string
|
since*: string
|
||||||
until*: string
|
until*: string
|
||||||
near*: string
|
minLikes*: string
|
||||||
sep*: string
|
sep*: string
|
||||||
|
|
||||||
Gif* = object
|
Gif* = object
|
||||||
@@ -144,8 +174,10 @@ type
|
|||||||
imageDirectMessage = "image_direct_message"
|
imageDirectMessage = "image_direct_message"
|
||||||
audiospace = "audiospace"
|
audiospace = "audiospace"
|
||||||
newsletterPublication = "newsletter_publication"
|
newsletterPublication = "newsletter_publication"
|
||||||
|
jobDetails = "job_details"
|
||||||
|
hidden
|
||||||
unknown
|
unknown
|
||||||
|
|
||||||
Card* = object
|
Card* = object
|
||||||
kind*: CardKind
|
kind*: CardKind
|
||||||
url*: string
|
url*: string
|
||||||
@@ -159,7 +191,7 @@ type
|
|||||||
replies*: int
|
replies*: int
|
||||||
retweets*: int
|
retweets*: int
|
||||||
likes*: int
|
likes*: int
|
||||||
quotes*: int
|
views*: int
|
||||||
|
|
||||||
Tweet* = ref object
|
Tweet* = ref object
|
||||||
id*: int64
|
id*: int64
|
||||||
@@ -174,6 +206,8 @@ type
|
|||||||
available*: bool
|
available*: bool
|
||||||
tombstone*: string
|
tombstone*: string
|
||||||
location*: string
|
location*: string
|
||||||
|
# Unused, needed for backwards compat
|
||||||
|
source*: string
|
||||||
stats*: TweetStats
|
stats*: TweetStats
|
||||||
retweet*: Option[Tweet]
|
retweet*: Option[Tweet]
|
||||||
attribution*: Option[User]
|
attribution*: Option[User]
|
||||||
@@ -185,6 +219,8 @@ type
|
|||||||
video*: Option[Video]
|
video*: Option[Video]
|
||||||
photos*: seq[string]
|
photos*: seq[string]
|
||||||
|
|
||||||
|
Tweets* = seq[Tweet]
|
||||||
|
|
||||||
Result*[T] = object
|
Result*[T] = object
|
||||||
content*: seq[T]
|
content*: seq[T]
|
||||||
top*, bottom*: string
|
top*, bottom*: string
|
||||||
@@ -192,7 +228,7 @@ type
|
|||||||
query*: Query
|
query*: Query
|
||||||
|
|
||||||
Chain* = object
|
Chain* = object
|
||||||
content*: seq[Tweet]
|
content*: Tweets
|
||||||
hasMore*: bool
|
hasMore*: bool
|
||||||
cursor*: string
|
cursor*: string
|
||||||
|
|
||||||
@@ -202,7 +238,7 @@ type
|
|||||||
after*: Chain
|
after*: Chain
|
||||||
replies*: Result[Chain]
|
replies*: Result[Chain]
|
||||||
|
|
||||||
Timeline* = Result[Tweet]
|
Timeline* = Result[Tweets]
|
||||||
|
|
||||||
Profile* = object
|
Profile* = object
|
||||||
user*: User
|
user*: User
|
||||||
@@ -239,6 +275,8 @@ type
|
|||||||
enableDebug*: bool
|
enableDebug*: bool
|
||||||
proxy*: string
|
proxy*: string
|
||||||
proxyAuth*: string
|
proxyAuth*: string
|
||||||
|
apiProxy*: string
|
||||||
|
disableTid*: bool
|
||||||
|
|
||||||
rssCacheTime*: int
|
rssCacheTime*: int
|
||||||
listCacheTime*: int
|
listCacheTime*: int
|
||||||
@@ -254,3 +292,6 @@ type
|
|||||||
|
|
||||||
proc contains*(thread: Chain; tweet: Tweet): bool =
|
proc contains*(thread: Chain; tweet: Tweet): bool =
|
||||||
thread.content.anyIt(it.id == tweet.id)
|
thread.content.anyIt(it.id == tweet.id)
|
||||||
|
|
||||||
|
proc add*(timeline: var seq[Tweets]; tweet: Tweet) =
|
||||||
|
timeline.add @[tweet]
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ const
|
|||||||
"twimg.com",
|
"twimg.com",
|
||||||
"abs.twimg.com",
|
"abs.twimg.com",
|
||||||
"pbs.twimg.com",
|
"pbs.twimg.com",
|
||||||
"video.twimg.com"
|
"video.twimg.com",
|
||||||
|
"x.com"
|
||||||
]
|
]
|
||||||
|
|
||||||
proc setHmacKey*(key: string) =
|
proc setHmacKey*(key: string) =
|
||||||
@@ -42,6 +43,12 @@ proc getPicUrl*(link: string): string =
|
|||||||
else:
|
else:
|
||||||
&"/pic/{encodeUrl(link)}"
|
&"/pic/{encodeUrl(link)}"
|
||||||
|
|
||||||
|
proc getOrigPicUrl*(link: string): string =
|
||||||
|
if base64Media:
|
||||||
|
&"/pic/orig/enc/{encode(link, safe=true)}"
|
||||||
|
else:
|
||||||
|
&"/pic/orig/{encodeUrl(link)}"
|
||||||
|
|
||||||
proc filterParams*(params: Table): seq[(string, string)] =
|
proc filterParams*(params: Table): seq[(string, string)] =
|
||||||
for p in params.pairs():
|
for p in params.pairs():
|
||||||
if p[1].len > 0 and p[0] notin nitterParams:
|
if p[1].len > 0 and p[0] notin nitterParams:
|
||||||
@@ -51,4 +58,4 @@ proc isTwitterUrl*(uri: Uri): bool =
|
|||||||
uri.hostname in twitterDomains
|
uri.hostname in twitterDomains
|
||||||
|
|
||||||
proc isTwitterUrl*(url: string): bool =
|
proc isTwitterUrl*(url: string): bool =
|
||||||
parseUri(url).hostname in twitterDomains
|
isTwitterUrl(parseUri(url))
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ const doctype = "<!DOCTYPE html>\n"
|
|||||||
proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
|
proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
|
||||||
let thumb = get(tweet.video).thumb
|
let thumb = get(tweet.video).thumb
|
||||||
let vidUrl = getVideoEmbed(cfg, tweet.id)
|
let vidUrl = getVideoEmbed(cfg, tweet.id)
|
||||||
let prefs = Prefs(hlsPlayback: true)
|
let prefs = Prefs(hlsPlayback: true, mp4Playback: true)
|
||||||
let node = buildHtml(html(lang="en")):
|
let node = buildHtml(html(lang="en")):
|
||||||
renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb]))
|
renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb]))
|
||||||
|
|
||||||
tdiv(class="embed-video"):
|
body:
|
||||||
renderVideo(get(tweet.video), prefs, "")
|
tdiv(class="embed-video"):
|
||||||
|
renderVideo(get(tweet.video), prefs, "")
|
||||||
|
|
||||||
result = doctype & $node
|
result = doctype & $node
|
||||||
|
|||||||
@@ -30,15 +30,15 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
|
|||||||
tdiv(class="nav-item right"):
|
tdiv(class="nav-item right"):
|
||||||
icon "search", title="Search", href="/search"
|
icon "search", title="Search", href="/search"
|
||||||
if cfg.enableRss and rss.len > 0:
|
if cfg.enableRss and rss.len > 0:
|
||||||
icon "rss-feed", title="RSS Feed", href=rss
|
icon "rss", title="RSS Feed", href=rss
|
||||||
icon "bird", title="Open in Twitter", href=canonical
|
icon "bird", title="Open in X", href=canonical
|
||||||
a(href="https://liberapay.com/zedeus"): verbatim lp
|
a(href="https://liberapay.com/zedeus"): verbatim lp
|
||||||
icon "info", title="About", href="/about"
|
icon "info", title="About", href="/about"
|
||||||
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
|
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
|
||||||
|
|
||||||
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||||
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
|
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
|
||||||
rss=""; canonical=""): VNode =
|
rss=""; alternate=""): VNode =
|
||||||
var theme = prefs.theme.toTheme
|
var theme = prefs.theme.toTheme
|
||||||
if "theme" in req.params:
|
if "theme" in req.params:
|
||||||
theme = req.params["theme"].toTheme
|
theme = req.params["theme"].toTheme
|
||||||
@@ -52,8 +52,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||||||
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
||||||
|
|
||||||
buildHtml(head):
|
buildHtml(head):
|
||||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=16")
|
link(rel="stylesheet", type="text/css", href="/css/style.css?v=22")
|
||||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2")
|
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4")
|
||||||
|
|
||||||
if theme.len > 0:
|
if theme.len > 0:
|
||||||
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
|
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
|
||||||
@@ -66,14 +66,14 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||||||
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
|
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
|
||||||
href=opensearchUrl)
|
href=opensearchUrl)
|
||||||
|
|
||||||
if canonical.len > 0:
|
if alternate.len > 0:
|
||||||
link(rel="canonical", href=canonical)
|
link(rel="alternate", href=alternate, title="View on X")
|
||||||
|
|
||||||
if cfg.enableRss and rss.len > 0:
|
if cfg.enableRss and rss.len > 0:
|
||||||
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
|
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
|
||||||
|
|
||||||
if prefs.hlsPlayback:
|
if prefs.hlsPlayback:
|
||||||
script(src="/js/hls.light.min.js", `defer`="")
|
script(src="/js/hls.min.js", `defer`="")
|
||||||
script(src="/js/hlsPlayback.js", `defer`="")
|
script(src="/js/hlsPlayback.js", `defer`="")
|
||||||
|
|
||||||
if prefs.infiniteScroll:
|
if prefs.infiniteScroll:
|
||||||
@@ -93,14 +93,13 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||||||
meta(property="og:site_name", content="Nitter")
|
meta(property="og:site_name", content="Nitter")
|
||||||
meta(property="og:locale", content="en_US")
|
meta(property="og:locale", content="en_US")
|
||||||
|
|
||||||
if banner.len > 0:
|
if banner.len > 0 and not banner.startsWith('#'):
|
||||||
let bannerUrl = getPicUrl(banner)
|
let bannerUrl = getPicUrl(banner)
|
||||||
link(rel="preload", type="image/png", href=bannerUrl, `as`="image")
|
link(rel="preload", type="image/png", href=bannerUrl, `as`="image")
|
||||||
|
|
||||||
for url in images:
|
for url in images:
|
||||||
let suffix = if "400x400" in url or url.endsWith("placeholder.png"): ""
|
let preloadUrl = if "400x400" in url: getPicUrl(url)
|
||||||
else: "?name=small"
|
else: getSmallPic(url)
|
||||||
let preloadUrl = getPicUrl(url & suffix)
|
|
||||||
link(rel="preload", type="image/png", href=preloadUrl, `as`="image")
|
link(rel="preload", type="image/png", href=preloadUrl, `as`="image")
|
||||||
|
|
||||||
let image = getUrlPrefix(cfg) & getPicUrl(url)
|
let image = getUrlPrefix(cfg) & getPicUrl(url)
|
||||||
@@ -120,20 +119,20 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||||||
# this is last so images are also preloaded
|
# this is last so images are also preloaded
|
||||||
# if this is done earlier, Chrome only preloads one image for some reason
|
# if this is done earlier, Chrome only preloads one image for some reason
|
||||||
link(rel="preload", type="font/woff2", `as`="font",
|
link(rel="preload", type="font/woff2", `as`="font",
|
||||||
href="/fonts/fontello.woff2?21002321", crossorigin="anonymous")
|
href="/fonts/fontello.woff2?61663884", crossorigin="anonymous")
|
||||||
|
|
||||||
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
|
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
|
||||||
titleText=""; desc=""; ogTitle=""; rss=""; video="";
|
titleText=""; desc=""; ogTitle=""; rss=""; video="";
|
||||||
images: seq[string] = @[]; banner=""): string =
|
images: seq[string] = @[]; banner=""): string =
|
||||||
|
|
||||||
let canonical = getTwitterLink(req.path, req.params)
|
let twitterLink = getTwitterLink(req.path, req.params)
|
||||||
|
|
||||||
let node = buildHtml(html(lang="en")):
|
let node = buildHtml(html(lang="en")):
|
||||||
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
|
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
|
||||||
rss, canonical)
|
rss, twitterLink)
|
||||||
|
|
||||||
body:
|
body:
|
||||||
renderNavbar(cfg, req, rss, canonical)
|
renderNavbar(cfg, req, rss, twitterLink)
|
||||||
|
|
||||||
tdiv(class="container"):
|
tdiv(class="container"):
|
||||||
body
|
body
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
|||||||
span:
|
span:
|
||||||
let url = replaceUrls(user.website, prefs)
|
let url = replaceUrls(user.website, prefs)
|
||||||
icon "link"
|
icon "link"
|
||||||
a(href=url): text shortLink(url)
|
a(href=url): text url.shortLink
|
||||||
|
|
||||||
tdiv(class="profile-joindate"):
|
tdiv(class="profile-joindate"):
|
||||||
span(title=getJoinDateFull(user)):
|
span(title=getJoinDateFull(user)):
|
||||||
@@ -78,8 +78,11 @@ proc renderPhotoRail(profile: Profile): VNode =
|
|||||||
tdiv(class="photo-rail-grid"):
|
tdiv(class="photo-rail-grid"):
|
||||||
for i, photo in profile.photoRail:
|
for i, photo in profile.photoRail:
|
||||||
if i == 16: break
|
if i == 16: break
|
||||||
|
let photoSuffix =
|
||||||
|
if "format" in photo.url or "placeholder" in photo.url: ""
|
||||||
|
else: ":thumb"
|
||||||
a(href=(&"/{profile.user.username}/status/{photo.tweetId}#m")):
|
a(href=(&"/{profile.user.username}/status/{photo.tweetId}#m")):
|
||||||
genImg(photo.url & (if "format" in photo.url: "" else: ":thumb"))
|
genImg(photo.url & photoSuffix)
|
||||||
|
|
||||||
proc renderBanner(banner: string): VNode =
|
proc renderBanner(banner: string): VNode =
|
||||||
buildHtml():
|
buildHtml():
|
||||||
@@ -105,7 +108,7 @@ proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
|
|||||||
renderBanner(profile.user.banner)
|
renderBanner(profile.user.banner)
|
||||||
|
|
||||||
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
||||||
tdiv(class=(&"profile-tab{sticky}")):
|
tdiv(class=("profile-tab" & sticky)):
|
||||||
renderUserCard(profile.user, prefs)
|
renderUserCard(profile.user, prefs)
|
||||||
if profile.photoRail.len > 0:
|
if profile.photoRail.len > 0:
|
||||||
renderPhotoRail(profile)
|
renderPhotoRail(profile)
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import strutils
|
import strutils, strformat
|
||||||
import karax/[karaxdsl, vdom, vstyles]
|
import karax/[karaxdsl, vdom, vstyles]
|
||||||
import ".."/[types, utils]
|
import ".."/[types, utils]
|
||||||
|
|
||||||
|
const smallWebp* = "?name=small&format=webp"
|
||||||
|
|
||||||
|
proc getSmallPic*(url: string): string =
|
||||||
|
result = url
|
||||||
|
if "?" notin url and not url.endsWith("placeholder.png"):
|
||||||
|
result &= smallWebp
|
||||||
|
result = getPicUrl(result)
|
||||||
|
|
||||||
proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
|
proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
|
||||||
var c = "icon-" & icon
|
var c = "icon-" & icon
|
||||||
if class.len > 0: c = c & " " & class
|
if class.len > 0: c = &"{c} {class}"
|
||||||
buildHtml(tdiv(class="icon-container")):
|
buildHtml(tdiv(class="icon-container")):
|
||||||
if href.len > 0:
|
if href.len > 0:
|
||||||
a(class=c, title=title, href=href)
|
a(class=c, title=title, href=href)
|
||||||
@@ -15,6 +23,15 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
|
|||||||
if text.len > 0:
|
if text.len > 0:
|
||||||
text " " & text
|
text " " & text
|
||||||
|
|
||||||
|
template verifiedIcon*(user: User): untyped {.dirty.} =
|
||||||
|
if user.verifiedType != VerifiedType.none:
|
||||||
|
let lower = ($user.verifiedType).toLowerAscii()
|
||||||
|
buildHtml(tdiv(class=(&"verified-icon {lower}"))):
|
||||||
|
icon "circle", class="verified-icon-circle", title=(&"Verified {lower} account")
|
||||||
|
icon "ok", class="verified-icon-check", title=(&"Verified {lower} account")
|
||||||
|
else:
|
||||||
|
text ""
|
||||||
|
|
||||||
proc linkUser*(user: User, class=""): VNode =
|
proc linkUser*(user: User, class=""): VNode =
|
||||||
let
|
let
|
||||||
isName = "username" notin class
|
isName = "username" notin class
|
||||||
@@ -24,11 +41,11 @@ proc linkUser*(user: User, class=""): VNode =
|
|||||||
|
|
||||||
buildHtml(a(href=href, class=class, title=nameText)):
|
buildHtml(a(href=href, class=class, title=nameText)):
|
||||||
text nameText
|
text nameText
|
||||||
if isName and user.verified:
|
if isName:
|
||||||
icon "ok", class="verified-icon", title="Verified account"
|
verifiedIcon(user)
|
||||||
if isName and user.protected:
|
if user.protected:
|
||||||
text " "
|
text " "
|
||||||
icon "lock", title="Protected account"
|
icon "lock", title="Protected account"
|
||||||
|
|
||||||
proc linkText*(text: string; class=""): VNode =
|
proc linkText*(text: string; class=""): VNode =
|
||||||
let url = if "http" notin text: https & text else: text
|
let url = if "http" notin text: https & text else: text
|
||||||
@@ -51,46 +68,44 @@ proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNod
|
|||||||
proc genCheckbox*(pref, label: string; state: bool): VNode =
|
proc genCheckbox*(pref, label: string; state: bool): VNode =
|
||||||
buildHtml(label(class="pref-group checkbox-container")):
|
buildHtml(label(class="pref-group checkbox-container")):
|
||||||
text label
|
text label
|
||||||
if state: input(name=pref, `type`="checkbox", checked="")
|
input(name=pref, `type`="checkbox", checked=state)
|
||||||
else: input(name=pref, `type`="checkbox")
|
|
||||||
span(class="checkbox")
|
span(class="checkbox")
|
||||||
|
|
||||||
proc genInput*(pref, label, state, placeholder: string; class=""): VNode =
|
proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode =
|
||||||
let p = placeholder
|
let p = placeholder
|
||||||
buildHtml(tdiv(class=("pref-group pref-input " & class))):
|
buildHtml(tdiv(class=("pref-group pref-input " & class))):
|
||||||
if label.len > 0:
|
if label.len > 0:
|
||||||
label(`for`=pref): text label
|
label(`for`=pref): text label
|
||||||
if state.len == 0:
|
input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0))
|
||||||
input(name=pref, `type`="text", placeholder=p, value=state, autofocus="")
|
|
||||||
else:
|
|
||||||
input(name=pref, `type`="text", placeholder=p, value=state)
|
|
||||||
|
|
||||||
proc genSelect*(pref, label, state: string; options: seq[string]): VNode =
|
proc genSelect*(pref, label, state: string; options: seq[string]): VNode =
|
||||||
buildHtml(tdiv(class="pref-group pref-input")):
|
buildHtml(tdiv(class="pref-group pref-input")):
|
||||||
label(`for`=pref): text label
|
label(`for`=pref): text label
|
||||||
select(name=pref):
|
select(name=pref):
|
||||||
for opt in options:
|
for opt in options:
|
||||||
if opt == state:
|
option(value=opt, selected=(opt == state)):
|
||||||
option(value=opt, selected=""): text opt
|
text opt
|
||||||
else:
|
|
||||||
option(value=opt): text opt
|
|
||||||
|
|
||||||
proc genDate*(pref, state: string): VNode =
|
proc genDate*(pref, state: string): VNode =
|
||||||
buildHtml(span(class="date-input")):
|
buildHtml(span(class="date-input")):
|
||||||
input(name=pref, `type`="date", value=state)
|
input(name=pref, `type`="date", value=state)
|
||||||
icon "calendar"
|
icon "calendar"
|
||||||
|
|
||||||
|
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=""): VNode =
|
proc genImg*(url: string; class=""): VNode =
|
||||||
buildHtml():
|
buildHtml():
|
||||||
img(src=getPicUrl(url), class=class, alt="")
|
img(src=getPicUrl(url), class=class, alt="", loading="lazy")
|
||||||
|
|
||||||
proc getTabClass*(query: Query; tab: QueryKind): string =
|
proc getTabClass*(query: Query; tab: QueryKind): string =
|
||||||
result = "tab-item"
|
if query.kind == tab: "tab-item active"
|
||||||
if query.kind == tab:
|
else: "tab-item"
|
||||||
result &= " active"
|
|
||||||
|
|
||||||
proc getAvatarClass*(prefs: Prefs): string =
|
proc getAvatarClass*(prefs: Prefs): string =
|
||||||
if prefs.squareAvatars:
|
if prefs.squareAvatars: "avatar"
|
||||||
"avatar"
|
else: "avatar round"
|
||||||
else:
|
|
||||||
"avatar round"
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
## SPDX-License-Identifier: AGPL-3.0-only
|
## SPDX-License-Identifier: AGPL-3.0-only
|
||||||
#import strutils, xmltree, strformat, options, unicode
|
#import strutils, xmltree, strformat, options, unicode
|
||||||
#import ../types, ../utils, ../formatters, ../prefs
|
#import ../types, ../utils, ../formatters, ../prefs
|
||||||
|
## Snowflake ID cutoff for RSS GUID format transition
|
||||||
|
## Corresponds to approximately December 14, 2025 UTC
|
||||||
|
#const guidCutoff = 2000000000000000000'i64
|
||||||
#
|
#
|
||||||
#proc getTitle(tweet: Tweet; retweet: string): string =
|
#proc getTitle(tweet: Tweet; retweet: string): string =
|
||||||
#if tweet.pinned: result = "Pinned: "
|
#if tweet.pinned: result = "Pinned: "
|
||||||
@@ -25,7 +28,25 @@
|
|||||||
#end proc
|
#end proc
|
||||||
#
|
#
|
||||||
#proc getDescription(desc: string; cfg: Config): string =
|
#proc getDescription(desc: string; cfg: Config): string =
|
||||||
Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
|
||||||
|
#end proc
|
||||||
|
#
|
||||||
|
#proc 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
|
#end proc
|
||||||
#
|
#
|
||||||
#proc renderRssTweet(tweet: Tweet; cfg: Config): string =
|
#proc renderRssTweet(tweet: Tweet; cfg: Config): string =
|
||||||
@@ -33,47 +54,70 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
|||||||
#let urlPrefix = getUrlPrefix(cfg)
|
#let urlPrefix = getUrlPrefix(cfg)
|
||||||
#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix)
|
#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix)
|
||||||
<p>${text.replace("\n", "<br>\n")}</p>
|
<p>${text.replace("\n", "<br>\n")}</p>
|
||||||
#if tweet.quote.isSome and get(tweet.quote).available:
|
|
||||||
# let quoteLink = getLink(get(tweet.quote))
|
|
||||||
<p><a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p>
|
|
||||||
#end if
|
|
||||||
#if tweet.photos.len > 0:
|
#if tweet.photos.len > 0:
|
||||||
# for photo in tweet.photos:
|
# for photo in tweet.photos:
|
||||||
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" />
|
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" />
|
||||||
# end for
|
# end for
|
||||||
#elif tweet.video.isSome:
|
#elif tweet.video.isSome:
|
||||||
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
|
<a href="${urlPrefix}${tweet.getLink}">
|
||||||
|
<br>Video<br>
|
||||||
|
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
|
||||||
|
</a>
|
||||||
#elif tweet.gif.isSome:
|
#elif tweet.gif.isSome:
|
||||||
# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
|
# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
|
||||||
# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
|
# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
|
||||||
<video poster="${thumb}" autoplay muted loop style="max-width:250px;">
|
<video poster="${thumb}" autoplay muted loop style="max-width:250px;">
|
||||||
<source src="${url}" type="video/mp4"</source></video>
|
<source src="${url}" type="video/mp4"></video>
|
||||||
#elif tweet.card.isSome:
|
#elif tweet.card.isSome:
|
||||||
# let card = tweet.card.get()
|
# let card = tweet.card.get()
|
||||||
# if card.image.len > 0:
|
# if card.image.len > 0:
|
||||||
<img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" />
|
<img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" />
|
||||||
# end if
|
# end if
|
||||||
#end if
|
#end if
|
||||||
|
#if tweet.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)}
|
||||||
|
</p>
|
||||||
|
<footer>
|
||||||
|
— <cite><a href="${quoteLink}">${quoteLink}</a>
|
||||||
|
</footer>
|
||||||
|
</blockquote>
|
||||||
|
#end if
|
||||||
#end proc
|
#end proc
|
||||||
#
|
#
|
||||||
#proc renderRssTweets(tweets: seq[Tweet]; cfg: Config): string =
|
#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string =
|
||||||
#let urlPrefix = getUrlPrefix(cfg)
|
#let urlPrefix = getUrlPrefix(cfg)
|
||||||
#var links: seq[string]
|
#var links: seq[string]
|
||||||
#for t in tweets:
|
#for thread in tweets:
|
||||||
# let retweet = if t.retweet.isSome: t.user.username else: ""
|
# for tweet in thread:
|
||||||
# let tweet = if retweet.len > 0: t.retweet.get else: t
|
# if userId.len > 0 and tweet.user.id != userId: continue
|
||||||
# let link = getLink(tweet)
|
# end if
|
||||||
# if link in links: continue
|
#
|
||||||
# end if
|
# let retweet = if tweet.retweet.isSome: tweet.user.username else: ""
|
||||||
# links.add link
|
# let tweet = if retweet.len > 0: tweet.retweet.get else: tweet
|
||||||
<item>
|
# let link = getLink(tweet)
|
||||||
<title>${getTitle(tweet, retweet)}</title>
|
# if link in links: continue
|
||||||
<dc:creator>@${tweet.user.username}</dc:creator>
|
# end if
|
||||||
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
|
# links.add link
|
||||||
<pubDate>${getRfc822Time(tweet)}</pubDate>
|
# let useGlobalGuid = tweet.id >= guidCutoff
|
||||||
<guid>${urlPrefix & link}</guid>
|
<item>
|
||||||
<link>${urlPrefix & link}</link>
|
<title>${getTitle(tweet, retweet)}</title>
|
||||||
</item>
|
<dc:creator>@${tweet.user.username}</dc:creator>
|
||||||
|
<description><![CDATA[${renderRssTweet(tweet, cfg).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 for
|
||||||
#end proc
|
#end proc
|
||||||
#
|
#
|
||||||
@@ -101,14 +145,15 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
|||||||
<width>128</width>
|
<width>128</width>
|
||||||
<height>128</height>
|
<height>128</height>
|
||||||
</image>
|
</image>
|
||||||
#if profile.tweets.content.len > 0:
|
#let tweetsList = getTweetsWithPinned(profile)
|
||||||
${renderRssTweets(profile.tweets.content, cfg)}
|
#if tweetsList.len > 0:
|
||||||
|
${renderRssTweets(tweetsList, cfg, userId=profile.user.id)}
|
||||||
#end if
|
#end if
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
#end proc
|
#end proc
|
||||||
#
|
#
|
||||||
#proc renderListRss*(tweets: seq[Tweet]; list: List; cfg: Config): string =
|
#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config): string =
|
||||||
#let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}"
|
#let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}"
|
||||||
#result = ""
|
#result = ""
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
@@ -117,7 +162,7 @@ ${renderRssTweets(profile.tweets.content, cfg)}
|
|||||||
<atom:link href="${link}" rel="self" type="application/rss+xml" />
|
<atom:link href="${link}" rel="self" type="application/rss+xml" />
|
||||||
<title>${xmltree.escape(list.name)} / @${list.username}</title>
|
<title>${xmltree.escape(list.name)} / @${list.username}</title>
|
||||||
<link>${link}</link>
|
<link>${link}</link>
|
||||||
<description>${getDescription(list.name & " by @" & list.username, cfg)}</description>
|
<description>${getDescription(&"{list.name} by @{list.username}", cfg)}</description>
|
||||||
<language>en-us</language>
|
<language>en-us</language>
|
||||||
<ttl>40</ttl>
|
<ttl>40</ttl>
|
||||||
${renderRssTweets(tweets, cfg)}
|
${renderRssTweets(tweets, cfg)}
|
||||||
@@ -125,7 +170,7 @@ ${renderRssTweets(tweets, cfg)}
|
|||||||
</rss>
|
</rss>
|
||||||
#end proc
|
#end proc
|
||||||
#
|
#
|
||||||
#proc renderSearchRss*(tweets: seq[Tweet]; name, param: string; cfg: Config): string =
|
#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config): string =
|
||||||
#let link = &"{getUrlPrefix(cfg)}/search"
|
#let link = &"{getUrlPrefix(cfg)}/search"
|
||||||
#let escName = xmltree.escape(name)
|
#let escName = xmltree.escape(name)
|
||||||
#result = ""
|
#result = ""
|
||||||
@@ -135,7 +180,7 @@ ${renderRssTweets(tweets, cfg)}
|
|||||||
<atom:link href="${link}" rel="self" type="application/rss+xml" />
|
<atom:link href="${link}" rel="self" type="application/rss+xml" />
|
||||||
<title>Search results for "${escName}"</title>
|
<title>Search results for "${escName}"</title>
|
||||||
<link>${link}</link>
|
<link>${link}</link>
|
||||||
<description>${getDescription("Search \"" & escName & "\"", cfg)}</description>
|
<description>${getDescription(&"Search \"{escName}\"", cfg)}</description>
|
||||||
<language>en-us</language>
|
<language>en-us</language>
|
||||||
<ttl>40</ttl>
|
<ttl>40</ttl>
|
||||||
${renderRssTweets(tweets, cfg)}
|
${renderRssTweets(tweets, cfg)}
|
||||||
|
|||||||
@@ -10,23 +10,21 @@ const toggles = {
|
|||||||
"media": "Media",
|
"media": "Media",
|
||||||
"videos": "Videos",
|
"videos": "Videos",
|
||||||
"news": "News",
|
"news": "News",
|
||||||
"verified": "Verified",
|
|
||||||
"native_video": "Native videos",
|
"native_video": "Native videos",
|
||||||
"replies": "Replies",
|
"replies": "Replies",
|
||||||
"links": "Links",
|
"links": "Links",
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
"safe": "Safe",
|
|
||||||
"quote": "Quotes",
|
"quote": "Quotes",
|
||||||
"pro_video": "Pro videos"
|
"spaces": "Spaces"
|
||||||
}.toOrderedTable
|
}.toOrderedTable
|
||||||
|
|
||||||
proc renderSearch*(): VNode =
|
proc renderSearch*(): VNode =
|
||||||
buildHtml(tdiv(class="panel-container")):
|
buildHtml(tdiv(class="panel-container")):
|
||||||
tdiv(class="search-bar"):
|
tdiv(class="search-bar"):
|
||||||
form(`method`="get", action="/search", autocomplete="off"):
|
form(`method`="get", action="/search", autocomplete="off"):
|
||||||
hiddenField("f", "users")
|
hiddenField("f", "tweets")
|
||||||
input(`type`="text", name="q", autofocus="",
|
input(`type`="text", name="q", autofocus="",
|
||||||
placeholder="Enter username...", dir="auto")
|
placeholder="Search...", dir="auto")
|
||||||
button(`type`="submit"): icon "search"
|
button(`type`="submit"): icon "search"
|
||||||
|
|
||||||
proc renderProfileTabs*(query: Query; username: string): VNode =
|
proc renderProfileTabs*(query: Query; username: string): VNode =
|
||||||
@@ -53,7 +51,7 @@ proc renderSearchTabs*(query: Query): VNode =
|
|||||||
|
|
||||||
proc isPanelOpen(q: Query): bool =
|
proc isPanelOpen(q: Query): bool =
|
||||||
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or
|
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or
|
||||||
@[q.near, q.until, q.since].anyIt(it.len > 0))
|
@[q.minLikes, q.until, q.since].anyIt(it.len > 0))
|
||||||
|
|
||||||
proc renderSearchPanel*(query: Query): VNode =
|
proc renderSearchPanel*(query: Query): VNode =
|
||||||
let user = query.fromUser.join(",")
|
let user = query.fromUser.join(",")
|
||||||
@@ -63,12 +61,10 @@ proc renderSearchPanel*(query: Query): VNode =
|
|||||||
hiddenField("f", "tweets")
|
hiddenField("f", "tweets")
|
||||||
genInput("q", "", query.text, "Enter search...", class="pref-inline")
|
genInput("q", "", query.text, "Enter search...", class="pref-inline")
|
||||||
button(`type`="submit"): icon "search"
|
button(`type`="submit"): icon "search"
|
||||||
if isPanelOpen(query):
|
|
||||||
input(id="search-panel-toggle", `type`="checkbox", checked="")
|
input(id="search-panel-toggle", `type`="checkbox", checked=isPanelOpen(query))
|
||||||
else:
|
label(`for`="search-panel-toggle"): icon "down"
|
||||||
input(id="search-panel-toggle", `type`="checkbox")
|
|
||||||
label(`for`="search-panel-toggle"):
|
|
||||||
icon "down"
|
|
||||||
tdiv(class="search-panel"):
|
tdiv(class="search-panel"):
|
||||||
for f in @["filter", "exclude"]:
|
for f in @["filter", "exclude"]:
|
||||||
span(class="search-title"): text capitalize(f)
|
span(class="search-title"): text capitalize(f)
|
||||||
@@ -87,10 +83,10 @@ proc renderSearchPanel*(query: Query): VNode =
|
|||||||
span(class="search-title"): text "-"
|
span(class="search-title"): text "-"
|
||||||
genDate("until", query.until)
|
genDate("until", query.until)
|
||||||
tdiv:
|
tdiv:
|
||||||
span(class="search-title"): text "Near"
|
span(class="search-title"): text "Minimum likes"
|
||||||
genInput("near", "", query.near, placeholder="Location...")
|
genNumberInput("min_faves", "", query.minLikes, "Number...", autofocus=false)
|
||||||
|
|
||||||
proc renderTweetSearch*(results: Result[Tweet]; prefs: Prefs; path: string;
|
proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
|
||||||
pinned=none(Tweet)): VNode =
|
pinned=none(Tweet)): VNode =
|
||||||
let query = results.query
|
let query = results.query
|
||||||
buildHtml(tdiv(class="timeline-container")):
|
buildHtml(tdiv(class="timeline-container")):
|
||||||
|
|||||||
@@ -28,14 +28,19 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode =
|
|||||||
if thread.hasMore:
|
if thread.hasMore:
|
||||||
renderMoreReplies(thread)
|
renderMoreReplies(thread)
|
||||||
|
|
||||||
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode =
|
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: Tweet = nil): VNode =
|
||||||
buildHtml(tdiv(class="replies", id="r")):
|
buildHtml(tdiv(class="replies", id="r")):
|
||||||
|
var hasReplies = false
|
||||||
|
var replyCount = 0
|
||||||
for thread in replies.content:
|
for thread in replies.content:
|
||||||
if thread.content.len == 0: continue
|
if thread.content.len == 0: continue
|
||||||
|
hasReplies = true
|
||||||
|
replyCount += thread.content.len
|
||||||
renderReplyThread(thread, prefs, path)
|
renderReplyThread(thread, prefs, path)
|
||||||
|
|
||||||
if replies.bottom.len > 0:
|
if hasReplies and replies.bottom.len > 0:
|
||||||
renderMore(Query(), replies.bottom, focus="#r")
|
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 =
|
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode =
|
||||||
let hasAfter = conv.after.content.len > 0
|
let hasAfter = conv.after.content.len > 0
|
||||||
@@ -70,6 +75,6 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
|
|||||||
if not conv.replies.beginning:
|
if not conv.replies.beginning:
|
||||||
renderNewer(Query(), getLink(conv.tweet), focus="#r")
|
renderNewer(Query(), getLink(conv.tweet), focus="#r")
|
||||||
if conv.replies.content.len > 0 or conv.replies.bottom.len > 0:
|
if conv.replies.content.len > 0 or conv.replies.bottom.len > 0:
|
||||||
renderReplies(conv.replies, prefs, path)
|
renderReplies(conv.replies, prefs, path, conv.tweet)
|
||||||
|
|
||||||
renderToTop(focus="#m")
|
renderToTop(focus="#m")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import strutils, strformat, sequtils, algorithm, uri, options
|
import strutils, strformat, algorithm, uri, options
|
||||||
import karax/[karaxdsl, vdom]
|
import karax/[karaxdsl, vdom]
|
||||||
|
|
||||||
import ".."/[types, query, formatters]
|
import ".."/[types, query, formatters]
|
||||||
@@ -39,26 +39,24 @@ proc renderNoneFound(): VNode =
|
|||||||
h2(class="timeline-none"):
|
h2(class="timeline-none"):
|
||||||
text "No items found"
|
text "No items found"
|
||||||
|
|
||||||
proc renderThread(thread: seq[Tweet]; prefs: Prefs; path: string): VNode =
|
proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
|
||||||
buildHtml(tdiv(class="thread-line")):
|
buildHtml(tdiv(class="thread-line")):
|
||||||
let sortedThread = thread.sortedByIt(it.id)
|
let sortedThread = thread.sortedByIt(it.id)
|
||||||
for i, tweet in sortedThread:
|
for i, tweet in sortedThread:
|
||||||
|
# thread has a gap, display "more replies" link
|
||||||
|
if i > 0 and tweet.replyId != sortedThread[i - 1].id:
|
||||||
|
tdiv(class="timeline-item thread more-replies-thread"):
|
||||||
|
tdiv(class="more-replies"):
|
||||||
|
a(class="more-replies-text", href=getLink(tweet)):
|
||||||
|
text "more replies"
|
||||||
|
|
||||||
let show = i == thread.high and sortedThread[0].id != tweet.threadId
|
let show = i == thread.high and sortedThread[0].id != tweet.threadId
|
||||||
let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: ""
|
let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: ""
|
||||||
renderTweet(tweet, prefs, path, class=(header & "thread"),
|
renderTweet(tweet, prefs, path, class=(header & "thread"),
|
||||||
index=i, last=(i == thread.high), showThread=show)
|
index=i, last=(i == thread.high), 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
|
|
||||||
|
|
||||||
proc renderUser(user: User; prefs: Prefs): VNode =
|
proc renderUser(user: User; prefs: Prefs): VNode =
|
||||||
buildHtml(tdiv(class="timeline-item")):
|
buildHtml(tdiv(class="timeline-item", data-username=user.username)):
|
||||||
a(class="tweet-link", href=("/" & user.username))
|
a(class="tweet-link", href=("/" & user.username))
|
||||||
tdiv(class="tweet-body profile-result"):
|
tdiv(class="tweet-body profile-result"):
|
||||||
tdiv(class="tweet-header"):
|
tdiv(class="tweet-header"):
|
||||||
@@ -89,7 +87,7 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode =
|
|||||||
else:
|
else:
|
||||||
renderNoMore()
|
renderNoMore()
|
||||||
|
|
||||||
proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string;
|
proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
|
||||||
pinned=none(Tweet)): VNode =
|
pinned=none(Tweet)): VNode =
|
||||||
buildHtml(tdiv(class="timeline")):
|
buildHtml(tdiv(class="timeline")):
|
||||||
if not results.beginning:
|
if not results.beginning:
|
||||||
@@ -105,26 +103,26 @@ proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string;
|
|||||||
else:
|
else:
|
||||||
renderNoneFound()
|
renderNoneFound()
|
||||||
else:
|
else:
|
||||||
var
|
var retweets: seq[int64]
|
||||||
threads: seq[int64]
|
|
||||||
retweets: seq[int64]
|
|
||||||
|
|
||||||
for tweet in results.content:
|
for thread in results.content:
|
||||||
let rt = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
|
if thread.len == 1:
|
||||||
|
let
|
||||||
|
tweet = thread[0]
|
||||||
|
retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
|
||||||
|
|
||||||
if tweet.id in threads or rt in retweets or tweet.id in retweets or
|
if retweetId in retweets or tweet.id in retweets or
|
||||||
tweet.pinned and prefs.hidePins: continue
|
tweet.pinned and prefs.hidePins:
|
||||||
|
continue
|
||||||
|
|
||||||
let thread = results.content.threadFilter(threads, tweet)
|
|
||||||
if thread.len < 2:
|
|
||||||
var hasThread = tweet.hasThread
|
var hasThread = tweet.hasThread
|
||||||
if rt != 0:
|
if retweetId != 0 and tweet.retweet.isSome:
|
||||||
retweets &= rt
|
retweets &= retweetId
|
||||||
hasThread = get(tweet.retweet).hasThread
|
hasThread = get(tweet.retweet).hasThread
|
||||||
renderTweet(tweet, prefs, path, showThread=hasThread)
|
renderTweet(tweet, prefs, path, showThread=hasThread)
|
||||||
else:
|
else:
|
||||||
renderThread(thread, prefs, path)
|
renderThread(thread, prefs, path)
|
||||||
threads &= thread.mapIt(it.id)
|
|
||||||
|
|
||||||
renderMore(results.query, results.bottom)
|
if results.bottom.len > 0:
|
||||||
|
renderMore(results.query, results.bottom)
|
||||||
renderToTop()
|
renderToTop()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import strutils, sequtils, strformat, options
|
import strutils, sequtils, strformat, options, algorithm
|
||||||
import karax/[karaxdsl, vdom, vstyles]
|
import karax/[karaxdsl, vdom, vstyles]
|
||||||
from jester import Request
|
from jester import Request
|
||||||
|
|
||||||
@@ -7,26 +7,19 @@ import renderutils
|
|||||||
import ".."/[types, utils, formatters]
|
import ".."/[types, utils, formatters]
|
||||||
import general
|
import general
|
||||||
|
|
||||||
proc getSmallPic(url: string): string =
|
const doctype = "<!DOCTYPE html>\n"
|
||||||
result = url
|
|
||||||
if "?" notin url and not url.endsWith("placeholder.png"):
|
|
||||||
result &= ":small"
|
|
||||||
result = getPicUrl(result)
|
|
||||||
|
|
||||||
proc renderMiniAvatar(user: User; prefs: Prefs): VNode =
|
proc renderMiniAvatar(user: User; prefs: Prefs): VNode =
|
||||||
let url = getPicUrl(user.getUserPic("_mini"))
|
genImg(user.getUserPic("_mini"), class=(prefs.getAvatarClass & " mini"))
|
||||||
buildHtml():
|
|
||||||
img(class=(prefs.getAvatarClass & " mini"), src=url)
|
|
||||||
|
|
||||||
proc renderHeader(tweet: Tweet; retweet: string; prefs: Prefs): VNode =
|
proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode =
|
||||||
buildHtml(tdiv):
|
buildHtml(tdiv):
|
||||||
if retweet.len > 0:
|
if pinned:
|
||||||
tdiv(class="retweet-header"):
|
|
||||||
span: icon "retweet", retweet & " retweeted"
|
|
||||||
|
|
||||||
if tweet.pinned:
|
|
||||||
tdiv(class="pinned"):
|
tdiv(class="pinned"):
|
||||||
span: icon "pin", "Pinned Tweet"
|
span: icon "pin", "Pinned Tweet"
|
||||||
|
elif retweet.len > 0:
|
||||||
|
tdiv(class="retweet-header"):
|
||||||
|
span: icon "retweet", retweet & " retweeted"
|
||||||
|
|
||||||
tdiv(class="tweet-header"):
|
tdiv(class="tweet-header"):
|
||||||
a(class="tweet-avatar", href=("/" & tweet.user.username)):
|
a(class="tweet-avatar", href=("/" & tweet.user.username)):
|
||||||
@@ -57,19 +50,21 @@ proc renderAlbum(tweet: Tweet): VNode =
|
|||||||
tdiv(class="attachment image"):
|
tdiv(class="attachment image"):
|
||||||
let
|
let
|
||||||
named = "name=" in photo
|
named = "name=" in photo
|
||||||
orig = if named: photo else: photo & "?name=orig"
|
small = if named: photo else: photo & smallWebp
|
||||||
small = if named: photo else: photo & "?name=small"
|
a(href=getOrigPicUrl(photo), class="still-image", target="_blank"):
|
||||||
a(href=getPicUrl(orig), class="still-image", target="_blank"):
|
|
||||||
genImg(small)
|
genImg(small)
|
||||||
|
|
||||||
proc isPlaybackEnabled(prefs: Prefs; video: Video): bool =
|
proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
|
||||||
case video.playbackType
|
case playbackType
|
||||||
of mp4: prefs.mp4Playback
|
of mp4: prefs.mp4Playback
|
||||||
of m3u8, vmap: prefs.hlsPlayback
|
of m3u8, vmap: prefs.hlsPlayback
|
||||||
|
|
||||||
proc renderVideoDisabled(video: Video; path: string): VNode =
|
proc hasMp4Url(video: Video): bool =
|
||||||
|
video.variants.anyIt(it.contentType == mp4)
|
||||||
|
|
||||||
|
proc renderVideoDisabled(playbackType: VideoType; path: string): VNode =
|
||||||
buildHtml(tdiv(class="video-overlay")):
|
buildHtml(tdiv(class="video-overlay")):
|
||||||
case video.playbackType
|
case playbackType
|
||||||
of mp4:
|
of mp4:
|
||||||
p: text "mp4 playback disabled in preferences"
|
p: text "mp4 playback disabled in preferences"
|
||||||
of m3u8, vmap:
|
of m3u8, vmap:
|
||||||
@@ -84,35 +79,37 @@ proc renderVideoUnavailable(video: Video): VNode =
|
|||||||
p: text "This media is unavailable"
|
p: text "This media is unavailable"
|
||||||
|
|
||||||
proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode =
|
proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode =
|
||||||
let container =
|
let
|
||||||
if video.description.len > 0 or video.title.len > 0: " card-container"
|
container = if video.description.len == 0 and video.title.len == 0: ""
|
||||||
else: ""
|
else: " card-container"
|
||||||
|
playbackType = if not prefs.proxyVideos and video.hasMp4Url: mp4
|
||||||
|
else: video.playbackType
|
||||||
|
|
||||||
buildHtml(tdiv(class="attachments card")):
|
buildHtml(tdiv(class="attachments card")):
|
||||||
tdiv(class="gallery-video" & container):
|
tdiv(class="gallery-video" & container):
|
||||||
tdiv(class="attachment video-container"):
|
tdiv(class="attachment video-container"):
|
||||||
let thumb = getSmallPic(video.thumb)
|
let thumb = getSmallPic(video.thumb)
|
||||||
if not video.available:
|
if not video.available:
|
||||||
img(src=thumb)
|
img(src=thumb, loading="lazy")
|
||||||
renderVideoUnavailable(video)
|
renderVideoUnavailable(video)
|
||||||
elif not prefs.isPlaybackEnabled(video):
|
elif not prefs.isPlaybackEnabled(playbackType):
|
||||||
img(src=thumb)
|
img(src=thumb, loading="lazy")
|
||||||
renderVideoDisabled(video, path)
|
renderVideoDisabled(playbackType, path)
|
||||||
else:
|
else:
|
||||||
let vid = video.variants.filterIt(it.contentType == video.playbackType)
|
let
|
||||||
let source = getVidUrl(vid[0].url)
|
vars = video.variants.filterIt(it.contentType == playbackType)
|
||||||
case video.playbackType
|
vidUrl = vars.sortedByIt(it.resolution)[^1].url
|
||||||
|
source = if prefs.proxyVideos: getVidUrl(vidUrl)
|
||||||
|
else: vidUrl
|
||||||
|
case playbackType
|
||||||
of mp4:
|
of mp4:
|
||||||
if prefs.muteVideos:
|
video(poster=thumb, controls="", muted=prefs.muteVideos):
|
||||||
video(poster=thumb, controls="", muted=""):
|
source(src=source, `type`="video/mp4")
|
||||||
source(src=source, `type`="video/mp4")
|
|
||||||
else:
|
|
||||||
video(poster=thumb, controls=""):
|
|
||||||
source(src=source, `type`="video/mp4")
|
|
||||||
of m3u8, vmap:
|
of m3u8, vmap:
|
||||||
video(poster=thumb, data-url=source, data-autoload="false")
|
video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos)
|
||||||
verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
|
verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
|
||||||
tdiv(class="overlay-circle"): span(class="overlay-triangle")
|
tdiv(class="overlay-circle"): span(class="overlay-triangle")
|
||||||
|
tdiv(class="overlay-duration"): text getDuration(video)
|
||||||
verbatim "</div>"
|
verbatim "</div>"
|
||||||
if container.len > 0:
|
if container.len > 0:
|
||||||
tdiv(class="card-content"):
|
tdiv(class="card-content"):
|
||||||
@@ -124,14 +121,9 @@ proc renderGif(gif: Gif; prefs: Prefs): VNode =
|
|||||||
buildHtml(tdiv(class="attachments media-gif")):
|
buildHtml(tdiv(class="attachments media-gif")):
|
||||||
tdiv(class="gallery-gif", style={maxHeight: "unset"}):
|
tdiv(class="gallery-gif", style={maxHeight: "unset"}):
|
||||||
tdiv(class="attachment"):
|
tdiv(class="attachment"):
|
||||||
let thumb = getSmallPic(gif.thumb)
|
video(class="gif", poster=getSmallPic(gif.thumb), autoplay=prefs.autoplayGifs,
|
||||||
let url = getPicUrl(gif.url)
|
controls="", muted="", loop=""):
|
||||||
if prefs.autoplayGifs:
|
source(src=getPicUrl(gif.url), `type`="video/mp4")
|
||||||
video(class="gif", poster=thumb, controls="", autoplay="", muted="", loop=""):
|
|
||||||
source(src=url, `type`="video/mp4")
|
|
||||||
else:
|
|
||||||
video(class="gif", poster=thumb, controls="", muted="", loop=""):
|
|
||||||
source(src=url, `type`="video/mp4")
|
|
||||||
|
|
||||||
proc renderPoll(poll: Poll): VNode =
|
proc renderPoll(poll: Poll): VNode =
|
||||||
buildHtml(tdiv(class="poll")):
|
buildHtml(tdiv(class="poll")):
|
||||||
@@ -146,12 +138,12 @@ proc renderPoll(poll: Poll): VNode =
|
|||||||
span(class="poll-choice-value"): text percStr
|
span(class="poll-choice-value"): text percStr
|
||||||
span(class="poll-choice-option"): text poll.options[i]
|
span(class="poll-choice-option"): text poll.options[i]
|
||||||
span(class="poll-info"):
|
span(class="poll-info"):
|
||||||
text insertSep($poll.votes, ',') & " votes • " & poll.status
|
text &"{insertSep($poll.votes, ',')} votes • {poll.status}"
|
||||||
|
|
||||||
proc renderCardImage(card: Card): VNode =
|
proc renderCardImage(card: Card): VNode =
|
||||||
buildHtml(tdiv(class="card-image-container")):
|
buildHtml(tdiv(class="card-image-container")):
|
||||||
tdiv(class="card-image"):
|
tdiv(class="card-image"):
|
||||||
img(src=getPicUrl(card.image), alt="")
|
genImg(card.image)
|
||||||
if card.kind == player:
|
if card.kind == player:
|
||||||
tdiv(class="card-overlay"):
|
tdiv(class="card-overlay"):
|
||||||
tdiv(class="overlay-circle"):
|
tdiv(class="overlay-circle"):
|
||||||
@@ -187,14 +179,12 @@ func formatStat(stat: int): string =
|
|||||||
if stat > 0: insertSep($stat, ',')
|
if stat > 0: insertSep($stat, ',')
|
||||||
else: ""
|
else: ""
|
||||||
|
|
||||||
proc renderStats(stats: TweetStats; views: string): VNode =
|
proc renderStats(stats: TweetStats): VNode =
|
||||||
buildHtml(tdiv(class="tweet-stats")):
|
buildHtml(tdiv(class="tweet-stats")):
|
||||||
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
|
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
|
||||||
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
|
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
|
||||||
span(class="tweet-stat"): icon "quote", formatStat(stats.quotes)
|
|
||||||
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
|
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
|
||||||
if views.len > 0:
|
span(class="tweet-stat"): icon "views", formatStat(stats.views)
|
||||||
span(class="tweet-stat"): icon "play", insertSep(views, ',')
|
|
||||||
|
|
||||||
proc renderReply(tweet: Tweet): VNode =
|
proc renderReply(tweet: Tweet): VNode =
|
||||||
buildHtml(tdiv(class="replying-to")):
|
buildHtml(tdiv(class="replying-to")):
|
||||||
@@ -207,8 +197,7 @@ proc renderAttribution(user: User; prefs: Prefs): VNode =
|
|||||||
buildHtml(a(class="attribution", href=("/" & user.username))):
|
buildHtml(a(class="attribution", href=("/" & user.username))):
|
||||||
renderMiniAvatar(user, prefs)
|
renderMiniAvatar(user, prefs)
|
||||||
strong: text user.fullname
|
strong: text user.fullname
|
||||||
if user.verified:
|
verifiedIcon(user)
|
||||||
icon "ok", class="verified-icon", title="Verified account"
|
|
||||||
|
|
||||||
proc renderMediaTags(tags: seq[User]): VNode =
|
proc renderMediaTags(tags: seq[User]): VNode =
|
||||||
buildHtml(tdiv(class="media-tag-block")):
|
buildHtml(tdiv(class="media-tag-block")):
|
||||||
@@ -284,7 +273,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||||||
divClass = "thread-last " & class
|
divClass = "thread-last " & class
|
||||||
|
|
||||||
if not tweet.available:
|
if not tweet.available:
|
||||||
return buildHtml(tdiv(class=divClass & "unavailable timeline-item")):
|
return buildHtml(tdiv(class=divClass & "unavailable timeline-item", data-username=tweet.user.username)):
|
||||||
tdiv(class="unavailable-box"):
|
tdiv(class="unavailable-box"):
|
||||||
if tweet.tombstone.len > 0:
|
if tweet.tombstone.len > 0:
|
||||||
text tweet.tombstone
|
text tweet.tombstone
|
||||||
@@ -296,20 +285,22 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||||||
if tweet.quote.isSome:
|
if tweet.quote.isSome:
|
||||||
renderQuote(tweet.quote.get(), prefs, path)
|
renderQuote(tweet.quote.get(), prefs, path)
|
||||||
|
|
||||||
let fullTweet = tweet
|
let
|
||||||
|
fullTweet = tweet
|
||||||
|
pinned = tweet.pinned
|
||||||
|
|
||||||
var retweet: string
|
var retweet: string
|
||||||
var tweet = fullTweet
|
var tweet = fullTweet
|
||||||
if tweet.retweet.isSome:
|
if tweet.retweet.isSome:
|
||||||
tweet = tweet.retweet.get
|
tweet = tweet.retweet.get
|
||||||
retweet = fullTweet.user.fullname
|
retweet = fullTweet.user.fullname
|
||||||
|
|
||||||
buildHtml(tdiv(class=("timeline-item " & divClass))):
|
buildHtml(tdiv(class=("timeline-item " & divClass), data-username=tweet.user.username)):
|
||||||
if not mainTweet:
|
if not mainTweet:
|
||||||
a(class="tweet-link", href=getLink(tweet))
|
a(class="tweet-link", href=getLink(tweet))
|
||||||
|
|
||||||
tdiv(class="tweet-body"):
|
tdiv(class="tweet-body"):
|
||||||
var views = ""
|
renderHeader(tweet, retweet, pinned, prefs)
|
||||||
renderHeader(tweet, retweet, prefs)
|
|
||||||
|
|
||||||
if not afterTweet and index == 0 and tweet.reply.len > 0 and
|
if not afterTweet and index == 0 and tweet.reply.len > 0 and
|
||||||
(tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username):
|
(tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username):
|
||||||
@@ -325,17 +316,15 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||||||
if tweet.attribution.isSome:
|
if tweet.attribution.isSome:
|
||||||
renderAttribution(tweet.attribution.get(), prefs)
|
renderAttribution(tweet.attribution.get(), prefs)
|
||||||
|
|
||||||
if tweet.card.isSome:
|
if tweet.card.isSome and tweet.card.get().kind != hidden:
|
||||||
renderCard(tweet.card.get(), prefs, path)
|
renderCard(tweet.card.get(), prefs, path)
|
||||||
|
|
||||||
if tweet.photos.len > 0:
|
if tweet.photos.len > 0:
|
||||||
renderAlbum(tweet)
|
renderAlbum(tweet)
|
||||||
elif tweet.video.isSome:
|
elif tweet.video.isSome:
|
||||||
renderVideo(tweet.video.get(), prefs, path)
|
renderVideo(tweet.video.get(), prefs, path)
|
||||||
views = tweet.video.get().views
|
|
||||||
elif tweet.gif.isSome:
|
elif tweet.gif.isSome:
|
||||||
renderGif(tweet.gif.get(), prefs)
|
renderGif(tweet.gif.get(), prefs)
|
||||||
views = "GIF"
|
|
||||||
|
|
||||||
if tweet.poll.isSome:
|
if tweet.poll.isSome:
|
||||||
renderPoll(tweet.poll.get())
|
renderPoll(tweet.poll.get())
|
||||||
@@ -344,19 +333,24 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||||||
renderQuote(tweet.quote.get(), prefs, path)
|
renderQuote(tweet.quote.get(), prefs, path)
|
||||||
|
|
||||||
if mainTweet:
|
if mainTweet:
|
||||||
p(class="tweet-published"): text getTime(tweet)
|
p(class="tweet-published"): text &"{getTime(tweet)}"
|
||||||
|
|
||||||
if tweet.mediaTags.len > 0:
|
if tweet.mediaTags.len > 0:
|
||||||
renderMediaTags(tweet.mediaTags)
|
renderMediaTags(tweet.mediaTags)
|
||||||
|
|
||||||
if not prefs.hideTweetStats:
|
if not prefs.hideTweetStats:
|
||||||
renderStats(tweet.stats, views)
|
renderStats(tweet.stats)
|
||||||
|
|
||||||
if showThread:
|
if showThread:
|
||||||
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
|
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
|
||||||
text "Show this thread"
|
text "Show this thread"
|
||||||
|
|
||||||
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): VNode =
|
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
|
||||||
buildHtml(tdiv(class="tweet-embed")):
|
let node = buildHtml(html(lang="en")):
|
||||||
renderHead(prefs, cfg, req)
|
renderHead(prefs, cfg, req)
|
||||||
renderTweet(tweet, prefs, path, mainTweet=true)
|
|
||||||
|
body:
|
||||||
|
tdiv(class="tweet-embed"):
|
||||||
|
renderTweet(tweet, prefs, path, mainTweet=true)
|
||||||
|
|
||||||
|
result = doctype & $node
|
||||||
|
|||||||
1
tests/requirements.txt
Normal file
1
tests/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
seleniumbase
|
||||||
@@ -3,11 +3,6 @@ from parameterized import parameterized
|
|||||||
|
|
||||||
|
|
||||||
card = [
|
card = [
|
||||||
['Thom_Wolf/status/1122466524860702729',
|
|
||||||
'pytorch/fairseq',
|
|
||||||
'Facebook AI Research Sequence-to-Sequence Toolkit written in Python. - GitHub - pytorch/fairseq: Facebook AI Research Sequence-to-Sequence Toolkit written in Python.',
|
|
||||||
'github.com', True],
|
|
||||||
|
|
||||||
['nim_lang/status/1136652293510717440',
|
['nim_lang/status/1136652293510717440',
|
||||||
'Version 0.20.0 released',
|
'Version 0.20.0 released',
|
||||||
'We are very proud to announce Nim version 0.20. This is a massive release, both literally and figuratively. It contains more than 1,000 commits and it marks our release candidate for version 1.0!',
|
'We are very proud to announce Nim version 0.20. This is a massive release, both literally and figuratively. It contains more than 1,000 commits and it marks our release candidate for version 1.0!',
|
||||||
@@ -16,39 +11,29 @@ card = [
|
|||||||
['voidtarget/status/1094632512926605312',
|
['voidtarget/status/1094632512926605312',
|
||||||
'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too)',
|
'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too)',
|
||||||
'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim',
|
'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim',
|
||||||
'gist.github.com', True],
|
'gist.github.com', True]
|
||||||
|
|
||||||
['FluentAI/status/1116417904831029248',
|
|
||||||
'Amazon’s Alexa isn’t just AI — thousands of humans are listening',
|
|
||||||
'One of the only ways to improve Alexa is to have human beings check it for errors',
|
|
||||||
'theverge.com', True]
|
|
||||||
]
|
]
|
||||||
|
|
||||||
no_thumb = [
|
no_thumb = [
|
||||||
['Bountysource/status/1141879700639215617',
|
['FluentAI/status/1116417904831029248',
|
||||||
'Post a bounty on kivy/plyer!',
|
'LinkedIn',
|
||||||
'Automation and Screen Reader Support',
|
'This link will take you to a page that’s not on LinkedIn',
|
||||||
'bountysource.com'],
|
'lnkd.in'],
|
||||||
|
|
||||||
|
['Thom_Wolf/status/1122466524860702729',
|
||||||
|
'GitHub - facebookresearch/fairseq: Facebook AI Research Sequence-to-Sequence Toolkit written in',
|
||||||
|
'',
|
||||||
|
'github.com'],
|
||||||
|
|
||||||
['brent_p/status/1088857328680488961',
|
['brent_p/status/1088857328680488961',
|
||||||
'Hts Nim Sugar',
|
'GitHub - brentp/hts-nim: nim wrapper for htslib for parsing genomics data files',
|
||||||
'hts-nim is a library that allows one to use htslib via the nim programming language. Nim is a garbage-collected language that compiles to C and often has similar performance. I have become very...',
|
'',
|
||||||
'brentp.github.io'],
|
'github.com'],
|
||||||
|
|
||||||
['voidtarget/status/1133028231672582145',
|
['voidtarget/status/1133028231672582145',
|
||||||
'sinkingsugar/nimqt-example',
|
'sinkingsugar/nimqt-example',
|
||||||
'A sample of a Qt app written using mostly nim. Contribute to sinkingsugar/nimqt-example development by creating an account on GitHub.',
|
'A sample of a Qt app written using mostly nim. Contribute to sinkingsugar/nimqt-example development by creating an account on GitHub.',
|
||||||
'github.com'],
|
'github.com']
|
||||||
|
|
||||||
['mobile_test/status/490378953744318464',
|
|
||||||
'Nantasket Beach',
|
|
||||||
'Explore this photo titled Nantasket Beach by Ben Sandofsky (@sandofsky) on 500px',
|
|
||||||
'500px.com'],
|
|
||||||
|
|
||||||
['nim_lang/status/1082989146040340480',
|
|
||||||
'Nim in 2018: A short recap',
|
|
||||||
'Posted in r/programming by u/miran1',
|
|
||||||
'reddit.com']
|
|
||||||
]
|
]
|
||||||
|
|
||||||
playable = [
|
playable = [
|
||||||
@@ -63,17 +48,6 @@ playable = [
|
|||||||
'youtube.com']
|
'youtube.com']
|
||||||
]
|
]
|
||||||
|
|
||||||
# promo = [
|
|
||||||
# ['BangOlufsen/status/1145698701517754368',
|
|
||||||
# 'Upgrade your journey', '',
|
|
||||||
# 'www.bang-olufsen.com'],
|
|
||||||
|
|
||||||
# ['BangOlufsen/status/1154934429900406784',
|
|
||||||
# 'Learn more about Beosound Shape', '',
|
|
||||||
# 'www.bang-olufsen.com']
|
|
||||||
# ]
|
|
||||||
|
|
||||||
|
|
||||||
class CardTest(BaseTestCase):
|
class CardTest(BaseTestCase):
|
||||||
@parameterized.expand(card)
|
@parameterized.expand(card)
|
||||||
def test_card(self, tweet, title, description, destination, large):
|
def test_card(self, tweet, title, description, destination, large):
|
||||||
@@ -81,7 +55,7 @@ class CardTest(BaseTestCase):
|
|||||||
c = Card(Conversation.main + " ")
|
c = Card(Conversation.main + " ")
|
||||||
self.assert_text(title, c.title)
|
self.assert_text(title, c.title)
|
||||||
self.assert_text(destination, c.destination)
|
self.assert_text(destination, c.destination)
|
||||||
self.assertIn('_img', self.get_image_url(c.image + ' img'))
|
self.assertIn('/pic/', self.get_image_url(c.image + ' img'))
|
||||||
if len(description) > 0:
|
if len(description) > 0:
|
||||||
self.assert_text(description, c.description)
|
self.assert_text(description, c.description)
|
||||||
if large:
|
if large:
|
||||||
@@ -104,17 +78,7 @@ class CardTest(BaseTestCase):
|
|||||||
c = Card(Conversation.main + " ")
|
c = Card(Conversation.main + " ")
|
||||||
self.assert_text(title, c.title)
|
self.assert_text(title, c.title)
|
||||||
self.assert_text(destination, c.destination)
|
self.assert_text(destination, c.destination)
|
||||||
self.assertIn('_img', self.get_image_url(c.image + ' img'))
|
self.assertIn('/pic/', self.get_image_url(c.image + ' img'))
|
||||||
self.assert_element_visible('.card-overlay')
|
self.assert_element_visible('.card-overlay')
|
||||||
if len(description) > 0:
|
if len(description) > 0:
|
||||||
self.assert_text(description, c.description)
|
self.assert_text(description, c.description)
|
||||||
|
|
||||||
# @parameterized.expand(promo)
|
|
||||||
# def test_card_promo(self, tweet, title, description, destination):
|
|
||||||
# self.open_nitter(tweet)
|
|
||||||
# c = Card(Conversation.main + " ")
|
|
||||||
# self.assert_text(title, c.title)
|
|
||||||
# self.assert_text(destination, c.destination)
|
|
||||||
# self.assert_element_visible('.video-overlay')
|
|
||||||
# if len(description) > 0:
|
|
||||||
# self.assert_text(description, c.description)
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from parameterized import parameterized
|
|||||||
profiles = [
|
profiles = [
|
||||||
['mobile_test', 'Test account',
|
['mobile_test', 'Test account',
|
||||||
'Test Account. test test Testing username with @mobile_test_2 and a #hashtag',
|
'Test Account. test test Testing username with @mobile_test_2 and a #hashtag',
|
||||||
'San Francisco, CA', 'example.com/foobar', 'Joined October 2009', '100'],
|
'San Francisco, CA', 'example.com/foobar', 'Joined October 2009', '97'],
|
||||||
['mobile_test_2', 'mobile test 2', '', '', '', 'Joined January 2011', '13']
|
['mobile_test_2', 'mobile test 2', '', '', '', 'Joined January 2011', '13']
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -15,11 +15,18 @@ protected = [
|
|||||||
['Poop', 'Randy', 'Social media fanatic.']
|
['Poop', 'Randy', 'Social media fanatic.']
|
||||||
]
|
]
|
||||||
|
|
||||||
invalid = [['thisprofiledoesntexist'], ['%']]
|
invalid = [['thisprofiledoesntexist']]
|
||||||
|
|
||||||
banner_color = [
|
malformed = [
|
||||||
['nim_lang', '22, 25, 32'],
|
['${userId}'],
|
||||||
['rustlang', '35, 31, 32']
|
['$%7BuserId%7D'], # URL encoded version
|
||||||
|
['%'], # Percent sign is invalid
|
||||||
|
['user@name'],
|
||||||
|
['user.name'],
|
||||||
|
['user-name'],
|
||||||
|
['user$name'],
|
||||||
|
['user{name}'],
|
||||||
|
['user name'], # space
|
||||||
]
|
]
|
||||||
|
|
||||||
banner_image = [
|
banner_image = [
|
||||||
@@ -70,15 +77,16 @@ class ProfileTest(BaseTestCase):
|
|||||||
self.open_nitter(username)
|
self.open_nitter(username)
|
||||||
self.assert_text(f'User "{username}" not found')
|
self.assert_text(f'User "{username}" not found')
|
||||||
|
|
||||||
def test_suspended(self):
|
@parameterized.expand(malformed)
|
||||||
self.open_nitter('user')
|
def test_malformed_username(self, username):
|
||||||
self.assert_text('User "user" has been suspended')
|
"""Test that malformed usernames (with invalid characters) return 404"""
|
||||||
|
|
||||||
@parameterized.expand(banner_color)
|
|
||||||
def test_banner_color(self, username, color):
|
|
||||||
self.open_nitter(username)
|
self.open_nitter(username)
|
||||||
banner = self.find_element(Profile.banner + ' a')
|
# Malformed usernames should return 404 page not found, not try to fetch from Twitter
|
||||||
self.assertIn(color, banner.value_of_css_property('background-color'))
|
self.assert_text('Page not found')
|
||||||
|
|
||||||
|
def test_suspended(self):
|
||||||
|
self.open_nitter('suspendme')
|
||||||
|
self.assert_text('User "suspendme" has been suspended')
|
||||||
|
|
||||||
@parameterized.expand(banner_image)
|
@parameterized.expand(banner_image)
|
||||||
def test_banner_image(self, username, url):
|
def test_banner_image(self, username, url):
|
||||||
|
|||||||
@@ -2,15 +2,13 @@ from base import BaseTestCase, Quote, Conversation
|
|||||||
from parameterized import parameterized
|
from parameterized import parameterized
|
||||||
|
|
||||||
text = [
|
text = [
|
||||||
['elonmusk/status/1138136540096319488',
|
['nim_lang/status/1491461266849808397#m',
|
||||||
'Tesla Owners Online', '@Model3Owners',
|
'Nim', '@nim_lang',
|
||||||
"""As of March 58.4% of new car sales in Norway are electric.
|
"""What's better than Nim 1.6.0?
|
||||||
|
|
||||||
What are we doing wrong? reuters.com/article/us-norwa…"""],
|
Nim 1.6.2 :)
|
||||||
|
|
||||||
['nim_lang/status/924694255364341760',
|
nim-lang.org/blog/2021/12/17…"""]
|
||||||
'Hacker News', '@newsycombinator',
|
|
||||||
'Why Rust fails hard at scientific computing andre-ratsimbazafy.com/why-r…']
|
|
||||||
]
|
]
|
||||||
|
|
||||||
image = [
|
image = [
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ from base import BaseTestCase
|
|||||||
from parameterized import parameterized
|
from parameterized import parameterized
|
||||||
|
|
||||||
|
|
||||||
class SearchTest(BaseTestCase):
|
#class SearchTest(BaseTestCase):
|
||||||
@parameterized.expand([['@mobile_test'], ['@mobile_test_2']])
|
#@parameterized.expand([['@mobile_test'], ['@mobile_test_2']])
|
||||||
def test_username_search(self, username):
|
#def test_username_search(self, username):
|
||||||
self.search_username(username)
|
#self.search_username(username)
|
||||||
self.assert_text(f'{username}')
|
#self.assert_text(f'{username}')
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user