mirror of
https://github.com/zedeus/nitter
synced 2026-09-05 22:59:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4aefbc210 | ||
|
|
f75bf5872e |
||
|
|
8142bab1a9 | ||
|
|
a68b57629a | ||
|
|
d8c1c98329 | ||
|
|
f9b8e11665 | ||
|
|
11d4eb9e39 | ||
|
|
0bc9702854 | ||
|
|
2ccebaf22a | ||
|
|
28b55a5017 | ||
|
|
5858809a08 | ||
|
|
62aa2f2823 | ||
|
|
fcaab5079e | ||
|
|
0ac9bc2678 | ||
|
|
206622ea38 | ||
|
|
607765c463 | ||
|
|
f74b2282be | ||
|
|
06e59f0615 | ||
|
|
7f6c083dce | ||
|
|
989b1f5f64 | ||
|
|
756beab8a6 | ||
|
|
1d61f8cf07 | ||
|
|
5fa4eefb9d | ||
|
|
b76e3c3913 | ||
|
|
67f620c83c | ||
|
|
ca19250a62 | ||
|
|
fe8f482d5c | ||
|
|
5733297f92 | ||
|
|
a00c6cc11d | ||
|
|
530437167e | ||
|
|
a39e17abbe | ||
|
|
3bfbb7b3fd | ||
|
|
dc66bd6375 | ||
|
|
ccde22aa0b | ||
|
|
6d41157603 | ||
|
|
9be0b8f826 | ||
|
|
06c52473ab | ||
|
|
3bc78801b3 | ||
|
|
5100290f76 | ||
|
|
092397cd6f | ||
|
|
8394b044ef | ||
|
|
61246df9de | ||
|
|
0375145ea2 | ||
|
|
6b86aaa27f | ||
|
|
2f07fa6151 | ||
|
|
7f7092fbf5 | ||
|
|
4b3ad16cc8 |
||
|
|
279fa09771 | ||
|
|
0344b99b69 | ||
|
|
3e290eab6a | ||
|
|
c136e47748 | ||
|
|
446f06f9d6 |
||
|
|
d0c351403d |
||
|
|
55a331cc50 | ||
|
|
1e03595784 | ||
|
|
9d16db8903 | ||
|
|
327e0161f1 | ||
|
|
57d831f82a | ||
|
|
5a2df5df2e | ||
|
|
1639315699 | ||
|
|
96a850c963 | ||
|
|
db5a229bfe | ||
|
|
17ad0fec34 | ||
|
|
581c6f1714 | ||
|
|
bfcf75af7f | ||
|
|
44b2f096f6 | ||
|
|
7b27c2c629 | ||
|
|
35882ed88d | ||
|
|
bd9d492d36 | ||
|
|
7eed720894 | ||
|
|
ac5ba9469e | ||
|
|
63891a04ff | ||
|
|
ac2f93b361 | ||
|
|
1e33ca045d | ||
|
|
5a4c8dd12c | ||
|
|
fb9107cff6 | ||
|
|
60cb10229f | ||
|
|
ef1de42593 | ||
|
|
9b9c86a15c | ||
|
|
55d067957c | ||
|
|
a8bc1bbb2d | ||
|
|
f629507537 | ||
|
|
40d17bf042 | ||
|
|
6ab2143df0 | ||
|
|
a86be15f85 | ||
|
|
c956f7c373 | ||
|
|
e4e6dd13e6 | ||
|
|
083d65a8cf | ||
|
|
1d57f1f432 | ||
|
|
d5ff410c5d | ||
|
|
5a4faa0367 |
||
|
|
82099de55b | ||
|
|
74f5ff8acc | ||
|
|
4e38317582 | ||
|
|
8114eefa19 | ||
|
|
7d431781c3 | ||
|
|
0c7583432c | ||
|
|
e7e7050c6e | ||
|
|
741060c78b | ||
|
|
3429667414 | ||
|
|
fea6f59005 | ||
|
|
b6ccea0c7a | ||
|
|
b726767df4 | ||
|
|
33bf2c2397 | ||
|
|
7ce29bd8f1 | ||
|
|
91ff936cb3 | ||
|
|
0fefcf9917 | ||
|
|
35a929c415 | ||
|
|
4bf3df94f8 | ||
|
|
2898efab6b | ||
|
|
b0773dd934 | ||
|
|
d187b1cc3f | ||
|
|
95a9ee8dc5 |
||
|
|
61b6748d97 | ||
|
|
2bd664ae7d | ||
|
|
a15d1ce16b | ||
|
|
f257ce53ae | ||
|
|
d45545cd53 | ||
|
|
90b664ffb7 | ||
|
|
cbce620692 | ||
|
|
05b6dd2a43 | ||
|
|
dcec1eb458 | ||
|
|
1c06a67afd | ||
|
|
40b1ba4e4e | ||
|
|
b85e8c5d7d | ||
|
|
db36f75519 | ||
|
|
5d28bd18c6 | ||
|
|
0a6e79e626 | ||
|
|
33dd9b6668 | ||
|
|
a45227b883 |
||
|
|
a92e79ebc3 |
||
|
|
baeaf685d3 |
||
|
|
51b54852dc | ||
|
|
663f5a52e1 | ||
|
|
17fc2628f9 | ||
|
|
e741385828 | ||
|
|
693a189462 | ||
|
|
7734d976f7 | ||
|
|
a62ec9cbb4 | ||
|
|
4b9aec6fde | ||
|
|
064ec88080 | ||
|
|
71e65c84d7 | ||
|
|
436a873e4b | ||
|
|
96ec75fc7f | ||
|
|
7a08a9e132 | ||
|
|
31d210ca47 |
||
|
|
dae68b4f13 | ||
|
|
8516ebe2b7 | ||
|
|
b83227aaf5 | ||
|
|
404b06b5f3 |
||
|
|
2b922c049a |
||
|
|
78101df2cc | ||
|
|
12bbddf204 | ||
|
|
4979d07f2e | ||
|
|
f038b53fa2 | ||
|
|
4748311f8d | ||
|
|
d47eb8f0eb | ||
|
|
1657eeb769 | ||
|
|
25df682094 | ||
|
|
53edbbc4e9 | ||
|
|
5b4a3fe691 |
129 changed files with 11832 additions and 2525 deletions
108
.github/workflows/build-docker.yml
vendored
108
.github/workflows/build-docker.yml
vendored
|
|
@ -7,57 +7,105 @@ on:
|
|||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
group: docker-publish-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
IMAGE: zedeus/nitter
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
uses: ./.github/workflows/run-tests.yml
|
||||
secrets: inherit
|
||||
build-docker-amd64:
|
||||
|
||||
build:
|
||||
needs: [tests]
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-24.04
|
||||
platform: linux/amd64
|
||||
- runner: ubuntu-24.04-arm
|
||||
platform: linux/arm64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Prepare platform name
|
||||
run: echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
|
||||
env:
|
||||
platform: ${{ matrix.platform }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push AMD64 Docker image
|
||||
uses: docker/build-push-action@v3
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }}
|
||||
build-docker-arm64:
|
||||
needs: [tests]
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p "${{ runner.temp }}/digests"
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Combine the per-arch digests into one multi-arch manifest so that
|
||||
# `docker pull zedeus/nitter:latest` serves the right image on any CPU.
|
||||
merge:
|
||||
needs: [build]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push ARM64 Docker image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.arm64
|
||||
platforms: linux/arm64
|
||||
push: true
|
||||
tags: zedeus/nitter:latest-arm64,zedeus/nitter:${{ github.sha }}-arm64
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.IMAGE }}:latest \
|
||||
-t ${{ env.IMAGE }}:latest-arm64 \
|
||||
-t ${{ env.IMAGE }}:${{ github.sha }} \
|
||||
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ github.sha }}
|
||||
|
|
|
|||
116
.github/workflows/run-tests.yml
vendored
116
.github/workflows/run-tests.yml
vendored
|
|
@ -20,24 +20,24 @@ defaults:
|
|||
jobs:
|
||||
build-test:
|
||||
name: Build and test
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
nim: ["2.0.x", "2.2.x", "devel"]
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Cache Nimble Dependencies
|
||||
id: cache-nimble
|
||||
uses: buildjet/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.nimble
|
||||
key: ${{ matrix.nim }}-nimble-v2-${{ hashFiles('*.nimble') }}
|
||||
path: |
|
||||
~/.nimble/pkgcache
|
||||
~/.nimble/packages_official.json
|
||||
key: ${{ matrix.nim }}-nimble-v6-${{ hashFiles('*.nimble') }}
|
||||
restore-keys: |
|
||||
${{ matrix.nim }}-nimble-v2-
|
||||
${{ matrix.nim }}-nimble-v6-
|
||||
|
||||
- name: Setup Nim
|
||||
uses: jiro4989/setup-nim-action@v2
|
||||
|
|
@ -47,62 +47,106 @@ jobs:
|
|||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Project
|
||||
run: nimble build -d:release -Y
|
||||
run: nimble build -Y
|
||||
|
||||
- name: Upload 2.2.x build artifact
|
||||
if: matrix.nim == '2.2.x'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: nitter-linux-nim-2.2.x-${{ github.sha }}
|
||||
path: |
|
||||
./nitter
|
||||
if-no-files-found: error
|
||||
|
||||
integration-test:
|
||||
needs: [build-test]
|
||||
name: Integration test
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
steps:
|
||||
- name: Install runtime deps
|
||||
run: |
|
||||
sudo apt-get install -y --no-install-recommends libsass-dev libpcre3
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Cache pipx (poetry)
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
path: |
|
||||
~/.local/pipx
|
||||
~/.local/bin
|
||||
key: pipx-poetry-${{ runner.os }}
|
||||
|
||||
- name: Install poetry
|
||||
env:
|
||||
PIPX_HOME: ~/.local/pipx
|
||||
PIPX_BIN_DIR: ~/.local/bin
|
||||
run: command -v poetry >/dev/null 2>&1 || pipx install poetry
|
||||
|
||||
- name: Setup Python (3.14) with Poetry cache
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
cache: poetry
|
||||
cache-dependency-path: tests/poetry.lock
|
||||
|
||||
- name: Install Python deps
|
||||
working-directory: tests
|
||||
run: poetry sync
|
||||
|
||||
- name: Cache Nimble Dependencies
|
||||
id: cache-nimble
|
||||
uses: buildjet/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.nimble
|
||||
key: devel-nimble-v2-${{ hashFiles('*.nimble') }}
|
||||
path: |
|
||||
~/.nimble/pkgcache
|
||||
~/.nimble/packages_official.json
|
||||
key: 2.2.x-nimble-v6-${{ hashFiles('*.nimble') }}
|
||||
restore-keys: |
|
||||
devel-nimble-v2-
|
||||
|
||||
- name: Setup Python (3.10) with pip cache
|
||||
uses: buildjet/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: pip
|
||||
2.2.x-nimble-v6-
|
||||
|
||||
- name: Setup Nim
|
||||
uses: jiro4989/setup-nim-action@v2
|
||||
with:
|
||||
nim-version: devel
|
||||
nim-version: 2.2.x
|
||||
use-nightlies: true
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Project
|
||||
run: nimble build -d:release -Y
|
||||
- name: Install Nimble dependencies
|
||||
run: nimble install -y --depsOnly
|
||||
|
||||
- name: Install SeleniumBase and Chromedriver
|
||||
run: |
|
||||
pip install seleniumbase
|
||||
seleniumbase install chromedriver
|
||||
- name: Download 2.2.x build artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: nitter-linux-nim-2.2.x-${{ github.sha }}
|
||||
path: .
|
||||
|
||||
- name: Start Redis Service
|
||||
uses: supercharge/redis-github-action@1.5.0
|
||||
- name: Make nitter binary executable
|
||||
run: chmod +x ./nitter
|
||||
|
||||
- name: Prepare Nitter Environment
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y libsass-dev
|
||||
cp nitter.example.conf nitter.conf
|
||||
sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf
|
||||
nimble md
|
||||
nimble scss
|
||||
sed -i 's/maxRetries = 1/maxRetries = 3/g' nitter.conf
|
||||
sed -i 's/hostname = "nitter.net"/hostname = "localhost:8080"/g' nitter.conf
|
||||
|
||||
nim r tools/rendermd.nim
|
||||
nim r tools/gencss.nim
|
||||
|
||||
echo '${{ secrets.SESSIONS }}' | head -n1
|
||||
echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
./nitter &
|
||||
pytest -n1 tests
|
||||
cd tests
|
||||
poetry run pytest -n2 --rs .
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -13,3 +13,8 @@ nitter.conf
|
|||
guest_accounts.json*
|
||||
sessions.json*
|
||||
dump.rdb
|
||||
*.bak
|
||||
/tools/*.json*
|
||||
nimbledeps/
|
||||
nimble.paths
|
||||
nimble.develop
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM nimlang/nim:2.2.0-alpine-regular as nim
|
||||
FROM nimlang/nim:2.2.6-alpine-regular as nim
|
||||
LABEL maintainer="setenforce@protonmail.com"
|
||||
|
||||
RUN apk --no-cache add libsass-dev pcre
|
||||
|
|
@ -15,7 +15,7 @@ RUN nimble build -d:danger -d:lto -d:strip --mm:refc \
|
|||
|
||||
FROM alpine:latest
|
||||
WORKDIR /src/
|
||||
RUN apk --no-cache add pcre ca-certificates
|
||||
RUN apk --no-cache add pcre ca-certificates openssl
|
||||
COPY --from=nim /src/nitter/nitter ./
|
||||
COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf
|
||||
COPY --from=nim /src/nitter/public ./public
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
FROM alpine:3.20.6 as nim
|
||||
LABEL maintainer="setenforce@protonmail.com"
|
||||
|
||||
RUN apk --no-cache add libsass-dev pcre gcc git libc-dev nim nimble
|
||||
|
||||
WORKDIR /src/nitter
|
||||
|
||||
COPY nitter.nimble .
|
||||
RUN nimble install -y --depsOnly
|
||||
|
||||
COPY . .
|
||||
RUN nimble build -d:danger -d:lto -d:strip --mm:refc \
|
||||
&& nimble scss \
|
||||
&& nimble md
|
||||
|
||||
FROM alpine:3.20.6
|
||||
WORKDIR /src/
|
||||
RUN apk --no-cache add pcre ca-certificates openssl
|
||||
COPY --from=nim /src/nitter/nitter ./
|
||||
COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf
|
||||
COPY --from=nim /src/nitter/public ./public
|
||||
EXPOSE 8080
|
||||
RUN adduser -h /src/ -D -s /bin/sh nitter
|
||||
USER nitter
|
||||
CMD ./nitter
|
||||
63
README.md
63
README.md
|
|
@ -1,18 +1,26 @@
|
|||
# Nitter
|
||||
|
||||
[](https://github.com/zedeus/nitter/actions/workflows/run-tests.yml)
|
||||
[](https://github.com/zedeus/nitter/actions/workflows/build-docker.yml)
|
||||
[](#license)
|
||||
|
||||
> [!NOTE]
|
||||
> Running a Nitter instance now requires real accounts, since Twitter removed the previous methods. \
|
||||
> This does not affect users. \
|
||||
> For instructions on how to obtain session tokens, see [Creating session tokens](https://github.com/zedeus/nitter/wiki/Creating-session-tokens).
|
||||
> On 24 August 2026 cease and desist letters were sent by X Corp. demanding a permanent takedown of Nitter instances and the project's repository.
|
||||
|
||||
A free and open source alternative Twitter front-end focused on privacy and
|
||||
performance. \
|
||||
Inspired by the [Invidious](https://github.com/iv-org/invidious) project.
|
||||
|
||||
## Donations
|
||||
|
||||
**Liberapay**: https://liberapay.com/zedeus<br>
|
||||
**Patreon**: https://patreon.com/nitter<br>
|
||||
**Ko-fi**: https://ko-fi.com/zedeus<br>
|
||||
**BTC**: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55<br>
|
||||
**ETH**: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460<br>
|
||||
**XMR**: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL<br>
|
||||
**SOL**: FF5bheiD5AqPEdc3eyjymJ8AoMRF1hS78Ht6FiSZZF1t<br>
|
||||
**$Nitter**: 4fSxCKc91ELQYVdv3tmHW8R15KoALPwEngyoQe1Xpump<br>
|
||||
**ZEC**: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt
|
||||
|
||||
## Features
|
||||
|
||||
- No JavaScript or ads
|
||||
- All requests go through the backend, client never talks to Twitter
|
||||
- Prevents Twitter from tracking your IP or JavaScript fingerprint
|
||||
|
|
@ -23,17 +31,6 @@ Inspired by the [Invidious](https://github.com/iv-org/invidious) project.
|
|||
- Mobile support (responsive design)
|
||||
- AGPLv3 licensed, no proprietary instances permitted
|
||||
|
||||
<details>
|
||||
<summary>Donations</summary>
|
||||
Liberapay: https://liberapay.com/zedeus<br>
|
||||
Patreon: https://patreon.com/nitter<br>
|
||||
BTC: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55<br>
|
||||
ETH: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460<br>
|
||||
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL<br>
|
||||
SOL: ANsyGNXFo6osuFwr1YnUqif2RdoYRhc27WdyQNmmETSW<br>
|
||||
ZEC: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt
|
||||
</details>
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Embeds
|
||||
|
|
@ -104,9 +101,9 @@ along with the scss and md files.
|
|||
# su nitter
|
||||
$ git clone https://github.com/zedeus/nitter
|
||||
$ cd nitter
|
||||
$ nimble build -d:danger --mm:refc
|
||||
$ nimble scss
|
||||
$ nimble md
|
||||
$ nimble -l build -d:danger --mm:refc
|
||||
$ nimble -l scss
|
||||
$ nimble -l md
|
||||
$ cp nitter.example.conf nitter.conf
|
||||
```
|
||||
|
||||
|
|
@ -123,12 +120,23 @@ performance reasons.
|
|||
|
||||
Page for the Docker image: https://hub.docker.com/r/zedeus/nitter
|
||||
|
||||
#### NOTE: For ARM64 support, please use the separate ARM64 docker image: [`zedeus/nitter:latest-arm64`](https://hub.docker.com/r/zedeus/nitter/tags).
|
||||
#### NOTE: The published image is multi-arch — `zedeus/nitter:latest` runs natively on both `amd64` and `arm64`.
|
||||
|
||||
To run Nitter with Docker, you'll need to install and run Redis separately
|
||||
before you can run the container. See below for how to also run Redis using
|
||||
Docker.
|
||||
|
||||
First create your config file. The Docker commands mount it into the container,
|
||||
so it has to exist on the host beforehand. If you've cloned the repo:
|
||||
|
||||
```bash
|
||||
cp nitter.example.conf nitter.conf
|
||||
```
|
||||
|
||||
If you're using the prebuilt image without a local clone, download
|
||||
[`nitter.example.conf`](https://raw.githubusercontent.com/zedeus/nitter/master/nitter.example.conf)
|
||||
and save it as `nitter.conf` instead.
|
||||
|
||||
To build and run Nitter in Docker:
|
||||
|
||||
```bash
|
||||
|
|
@ -136,8 +144,6 @@ docker build -t nitter:latest .
|
|||
docker run -v $(pwd)/nitter.conf:/src/nitter.conf -d --network host nitter:latest
|
||||
```
|
||||
|
||||
Note: For ARM64, use this Dockerfile: [`Dockerfile.arm64`](https://github.com/zedeus/nitter/blob/master/Dockerfile.arm64).
|
||||
|
||||
A prebuilt Docker image is provided as well:
|
||||
|
||||
```bash
|
||||
|
|
@ -151,8 +157,11 @@ Change `redisHost` from `localhost` to `nitter-redis` in `nitter.conf`, then run
|
|||
docker-compose up -d
|
||||
```
|
||||
|
||||
Note the Docker commands expect a `nitter.conf` file in the directory you run
|
||||
them.
|
||||
Note the Docker commands mount `nitter.conf` (and `sessions.jsonl` for
|
||||
docker-compose) from the directory you run them in. If a mounted file doesn't
|
||||
exist, Docker silently creates a directory in its place and the container fails
|
||||
with `not a directory: Are you trying to mount a directory onto a file`. Remove
|
||||
that directory and create the file as shown above.
|
||||
|
||||
### systemd
|
||||
|
||||
|
|
@ -197,3 +206,5 @@ lines). If you're running the Docker image, you can do this:
|
|||
|
||||
Feel free to join our [Matrix channel](https://matrix.to/#/#nitter:matrix.org).
|
||||
You can email me at zedeus@pm.me if you wish to contact me personally.
|
||||
|
||||
For legal inquiries, contact legal@poast.org
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
version: "3"
|
||||
|
||||
services:
|
||||
|
||||
nitter:
|
||||
|
|
@ -11,3 +11,7 @@ warning("HoleEnumConv", off)
|
|||
hint("XDeclaredButNotUsed", off)
|
||||
hint("XCannotRaiseY", off)
|
||||
hint("User", off)
|
||||
# begin Nimble config (version 2)
|
||||
when withDir(thisDir(), system.fileExists("nimble.paths")):
|
||||
include "nimble.paths"
|
||||
# end Nimble config
|
||||
|
|
|
|||
|
|
@ -1,31 +1,42 @@
|
|||
[Server]
|
||||
hostname = "nitter.net" # for generating links, change this to your own domain/ip
|
||||
hostname = "nitter.net" # for generating links, change this to your own domain/ip
|
||||
title = "nitter"
|
||||
address = "0.0.0.0"
|
||||
port = 8080
|
||||
https = false # disable to enable cookies when not using https
|
||||
https = false # disable to enable cookies when not using https
|
||||
httpMaxConnections = 100
|
||||
staticDir = "./public"
|
||||
|
||||
[Cache]
|
||||
listMinutes = 240 # how long to cache list info (not the tweets, so keep it high)
|
||||
rssMinutes = 10 # how long to cache rss queries
|
||||
redisHost = "localhost" # Change to "nitter-redis" if using docker-compose
|
||||
listMinutes = 240 # how long to cache list info (not the tweets, so keep it high)
|
||||
rssMinutes = 10 # how long to cache rss queries
|
||||
redisHost = "localhost" # Change to "nitter-redis" if using docker-compose
|
||||
redisPort = 6379
|
||||
redisPassword = ""
|
||||
redisConnections = 20 # minimum open connections in pool
|
||||
redisConnections = 20 # minimum open connections in pool
|
||||
redisMaxConnections = 30
|
||||
# new connections are opened when none are available, but if the pool size
|
||||
# goes above this, they're closed when released. don't worry about this unless
|
||||
# you receive tons of requests per second
|
||||
|
||||
[Config]
|
||||
hmacKey = "secretkey" # random key for cryptographic signing of video urls
|
||||
base64Media = false # use base64 encoding for proxied media urls
|
||||
enableRSS = true # set this to false to disable RSS feeds
|
||||
enableDebug = false # enable request logs and debug endpoints (/.sessions)
|
||||
proxy = "" # http/https url, SOCKS proxies are not supported
|
||||
hmacKey = "secretkey" # CHANGE THIS to a unique random value (e.g. `openssl rand -hex 32`); signs media urls
|
||||
base64Media = false # use base64 encoding for proxied media urls
|
||||
enableRSS = true # master switch, set to false to disable all RSS feeds
|
||||
enableRSSUserTweets = true # /@user/rss
|
||||
enableRSSUserReplies = true # /@user/with_replies/rss
|
||||
enableRSSUserMedia = true # /@user/media/rss
|
||||
enableRSSUserArticles = true # /@user/articles/rss
|
||||
enableRSSSearch = true # /search/rss and /@user/search/rss
|
||||
enableRSSList = true # list RSS feeds
|
||||
enableDebug = false # enable request logs and debug endpoints (/.sessions)
|
||||
proxy = "" # http/https url, SOCKS proxies are not supported
|
||||
proxyAuth = ""
|
||||
apiProxy = "" # nitter-proxy host, e.g. localhost:7000
|
||||
disableTid = false # enable this if cookie-based auth is failing
|
||||
maxConcurrentReqs = 2 # max requests at a time per session to avoid race conditions
|
||||
maxRetries = 1 # max number of retries on rate limit errors
|
||||
retryDelayMs = 150 # delay in ms between retries
|
||||
|
||||
# Change default preferences here, see src/prefs_impl.nim for a complete list
|
||||
[Preferences]
|
||||
|
|
|
|||
|
|
@ -11,24 +11,23 @@ bin = @["nitter"]
|
|||
# Dependencies
|
||||
|
||||
requires "nim >= 2.0.0"
|
||||
requires "jester#baca3f"
|
||||
requires "karax#5cf360c"
|
||||
requires "sass#7dfdd03"
|
||||
requires "nimcrypto#a079df9"
|
||||
requires "markdown#158efe3"
|
||||
requires "jester == 0.6.0"
|
||||
requires "karax == 1.5.0"
|
||||
requires "sass == 0.2.0"
|
||||
requires "nimcrypto == 0.7.3"
|
||||
requires "markdown == 0.8.8"
|
||||
requires "packedjson#9e6fbb6"
|
||||
requires "supersnappy#6c94198"
|
||||
requires "redpool#8b7c1db"
|
||||
requires "https://github.com/zedeus/redis#d0a0e6f"
|
||||
requires "zippy#ca5989a"
|
||||
requires "flatty#e668085"
|
||||
requires "jsony#1de1f08"
|
||||
requires "oauth#b8c163b"
|
||||
requires "supersnappy == 2.1.4"
|
||||
requires "redpool == 0.2.2"
|
||||
requires "zippy == 0.10.19"
|
||||
requires "flatty == 0.4.0"
|
||||
requires "jsony == 1.1.6"
|
||||
requires "oauth == 0.11"
|
||||
|
||||
# Tasks
|
||||
|
||||
task scss, "Generate css":
|
||||
exec "nimble c --hint[Processing]:off -d:danger -r tools/gencss"
|
||||
exec "nim r --hint[Processing]:off tools/gencss"
|
||||
|
||||
task md, "Render md":
|
||||
exec "nimble c --hint[Processing]:off -d:danger -r tools/rendermd"
|
||||
exec "nim r --hint[Processing]:off tools/rendermd"
|
||||
|
|
|
|||
160
public/css/fontello.css
vendored
160
public/css/fontello.css
vendored
|
|
@ -1,53 +1,153 @@
|
|||
@font-face {
|
||||
font-family: 'fontello';
|
||||
src: url('/fonts/fontello.eot?61663884');
|
||||
src: url('/fonts/fontello.eot?61663884#iefix') format('embedded-opentype'),
|
||||
url('/fonts/fontello.woff2?61663884') format('woff2'),
|
||||
url('/fonts/fontello.woff?61663884') format('woff'),
|
||||
url('/fonts/fontello.ttf?61663884') format('truetype'),
|
||||
url('/fonts/fontello.svg?61663884#fontello') format('svg');
|
||||
font-family: "fontello";
|
||||
src: url("/fonts/fontello.eot?59696369");
|
||||
src:
|
||||
url("/fonts/fontello.eot?59696369#iefix") format("embedded-opentype"),
|
||||
url("/fonts/fontello.woff2?59696369") format("woff2"),
|
||||
url("/fonts/fontello.woff?59696369") format("woff"),
|
||||
url("/fonts/fontello.ttf?59696369") format("truetype"),
|
||||
url("/fonts/fontello.svg?59696369#fontello") format("svg");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
[class^="icon-"]:before, [class*=" icon-"]:before {
|
||||
|
||||
[class^="icon-"]:before,
|
||||
[class*=" icon-"]:before {
|
||||
font-family: "fontello";
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
speak: never;
|
||||
|
||||
|
||||
display: inline-block;
|
||||
text-decoration: inherit;
|
||||
width: 1em;
|
||||
margin-right: 0.2em;
|
||||
text-align: center;
|
||||
|
||||
/* For safety - reset parent styles, that can break glyph codes*/
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
|
||||
|
||||
/* fix buttons height, for twitter bootstrap */
|
||||
line-height: 1em;
|
||||
|
||||
|
||||
/* Font smoothing. That was taken from TWBS */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-views:before { content: '\e800'; } /* '' */
|
||||
.icon-heart:before { content: '\e801'; } /* '' */
|
||||
.icon-quote:before { content: '\e802'; } /* '' */
|
||||
.icon-comment:before { content: '\e803'; } /* '' */
|
||||
.icon-ok:before { content: '\e804'; } /* '' */
|
||||
.icon-play:before { content: '\e805'; } /* '' */
|
||||
.icon-link:before { content: '\e806'; } /* '' */
|
||||
.icon-calendar:before { content: '\e807'; } /* '' */
|
||||
.icon-location:before { content: '\e808'; } /* '' */
|
||||
.icon-picture:before { content: '\e809'; } /* '' */
|
||||
.icon-lock:before { content: '\e80a'; } /* '' */
|
||||
.icon-down:before { content: '\e80b'; } /* '' */
|
||||
.icon-retweet:before { content: '\e80c'; } /* '' */
|
||||
.icon-search:before { content: '\e80d'; } /* '' */
|
||||
.icon-pin:before { content: '\e80e'; } /* '' */
|
||||
.icon-cog:before { content: '\e80f'; } /* '' */
|
||||
.icon-rss:before { content: '\e810'; } /* '' */
|
||||
.icon-info:before { content: '\f128'; } /* '' */
|
||||
.icon-bird:before { content: '\f309'; } /* '' */
|
||||
.icon-group:before {
|
||||
content: "\e0c3";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-views:before {
|
||||
content: "\e800";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-heart:before {
|
||||
content: "\e801";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-quote:before {
|
||||
content: "\e802";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-comment:before {
|
||||
content: "\e803";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-play:before {
|
||||
content: "\e805";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-link:before {
|
||||
content: "\e806";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-calendar:before {
|
||||
content: "\e807";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-location:before {
|
||||
content: "\e808";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-picture:before {
|
||||
content: "\e809";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-lock:before {
|
||||
content: "\e80a";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-down:before {
|
||||
content: "\e80b";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-retweet:before {
|
||||
content: "\e80c";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-search:before {
|
||||
content: "\e80d";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-pin:before {
|
||||
content: "\e80e";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-cog:before {
|
||||
content: "\e80f";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-rss:before {
|
||||
content: "\e810";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-ok:before {
|
||||
content: "\e811";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-attention-circled:before {
|
||||
content: "\e812";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-download-alt:before {
|
||||
content: "\e813";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-circle:before {
|
||||
content: "\f111";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-info:before {
|
||||
content: "\f128";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-bird:before {
|
||||
content: "\f309";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,11 +1,13 @@
|
|||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<metadata>Copyright (C) 2025 by original authors @ fontello.com</metadata>
|
||||
<metadata>Copyright (C) 2026 by original authors @ fontello.com</metadata>
|
||||
<defs>
|
||||
<font id="fontello" horiz-adv-x="1000" >
|
||||
<font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
|
||||
<missing-glyph horiz-adv-x="1000" />
|
||||
<glyph glyph-name="group" unicode="" d="M0 106l0 134q0 26 18 32l171 80q-66 39-68 131 0 56 35 103 37 41 90 43 31 0 63-19-49-125 23-237-12-11-25-19l-114-55q-48-23-52-84l0-143-114 0q-25 0-27 34z m193-59l0 168q0 27 22 37l152 70 57 28q-37 23-60 66t-22 94q0 76 46 130t110 54 109-54 45-130q0-105-78-158l61-30 146-70q24-10 24-37l0-168q-2-37-37-41l-541 0q-14 2-24 14t-10 27z m473 330q68 106 22 231 31 19 66 21 49 0 90-43 35-41 35-103 0-82-65-131l168-80q18-10 18-32l0-134q0-32-27-34l-118 0 0 143q0 57-50 84l-110 53q-15 8-29 25z" horiz-adv-x="1000" />
|
||||
|
||||
<glyph glyph-name="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="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" />
|
||||
|
|
@ -14,8 +16,6 @@
|
|||
|
||||
<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="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="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" />
|
||||
|
|
@ -40,6 +40,14 @@
|
|||
|
||||
<glyph glyph-name="rss" unicode="" d="M184 93c0-51-43-91-93-91s-91 40-91 91c0 50 41 91 91 91s93-41 93-91z m261-85l-125 0c0 174-140 323-315 323l0 118c231 0 440-163 440-441z m259 0l-136 0c0 300-262 561-563 561l0 129c370 0 699-281 699-690z" horiz-adv-x="704" />
|
||||
|
||||
<glyph glyph-name="ok" unicode="" d="M933 534q0-22-16-38l-404-404-76-76q-16-15-38-15t-38 15l-76 76-202 202q-15 16-15 38t15 38l76 76q16 16 38 16t38-16l164-165 366 367q16 16 38 16t38-16l76-76q16-15 16-38z" horiz-adv-x="1000" />
|
||||
|
||||
<glyph glyph-name="attention-circled" unicode="" d="M429 779q116 0 215-58t156-156 57-215-57-215-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58z m71-696v106q0 8-5 13t-12 5h-107q-8 0-13-5t-6-13v-106q0-8 6-13t13-6h107q7 0 12 6t5 13z m-1 192l10 346q0 7-6 10-5 5-13 5h-123q-8 0-13-5-6-3-6-10l10-346q0-6 5-10t14-4h103q8 0 13 4t6 10z" horiz-adv-x="857.1" />
|
||||
|
||||
<glyph glyph-name="download-alt" unicode="" d="M0-150l0 135 1000 0 0-135-1000 0z m88 586l228 0 0 414 370 0 0-414 228 0-414-385z" horiz-adv-x="1000" />
|
||||
|
||||
<glyph glyph-name="circle" unicode="" d="M857 350q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" />
|
||||
|
||||
<glyph glyph-name="info" unicode="" d="M393 149v-134q0-9-7-15t-15-7h-134q-9 0-16 7t-7 15v134q0 9 7 16t16 6h134q9 0 15-6t7-16z m176 335q0-30-8-56t-20-43-31-33-32-25-34-19q-23-13-38-37t-15-37q0-10-7-18t-16-9h-134q-8 0-14 11t-6 20v26q0 46 37 87t79 60q33 16 47 32t14 42q0 24-26 41t-60 18q-36 0-60-16-20-14-60-64-7-9-17-9-7 0-14 4l-91 70q-8 6-9 14t3 16q89 148 259 148 45 0 90-17t81-46 59-72 23-88z" horiz-adv-x="571.4" />
|
||||
|
||||
<glyph glyph-name="bird" unicode="" d="M920 636q-36-54-94-98l0-24q0-130-60-250t-186-203-290-83q-160 0-290 84 14-2 46-2 132 0 234 80-62 2-110 38t-66 94q10-4 34-4 26 0 50 6-66 14-108 66t-42 120l0 2q36-20 84-24-84 58-84 158 0 48 26 94 154-188 390-196-6 18-6 42 0 78 55 133t135 55q82 0 136-58 60 12 120 44-20-66-82-104 56 8 108 30z" horiz-adv-x="920" />
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 7.4 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
34
public/js/embedResize.js
Normal file
34
public/js/embedResize.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
(function () {
|
||||
var embed = document.querySelector(".embed-wrapper, .embed-video");
|
||||
if (!embed) return;
|
||||
|
||||
var video = embed.querySelector("video");
|
||||
if (video) {
|
||||
video.onplay = function () {
|
||||
embed.classList.add("video-playing");
|
||||
};
|
||||
video.onpause = video.onended = function () {
|
||||
embed.classList.remove("video-playing");
|
||||
};
|
||||
}
|
||||
|
||||
var lastHeight = 0;
|
||||
|
||||
function sendHeight() {
|
||||
var h = embed.offsetHeight;
|
||||
if (h !== lastHeight && h > 0) {
|
||||
lastHeight = h;
|
||||
window.parent.postMessage(["resizeIframe", { h: h }], "*");
|
||||
}
|
||||
}
|
||||
|
||||
// MessageChannel height request (used by oEmbed)
|
||||
window.addEventListener("message", function (e) {
|
||||
if (e.source === window.parent && e.ports && e.ports[0]) {
|
||||
e.ports[0].postMessage(embed.offsetHeight);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("load", sendHeight);
|
||||
new ResizeObserver(sendHeight).observe(embed);
|
||||
})();
|
||||
|
|
@ -1,25 +1,30 @@
|
|||
// @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
function playVideo(overlay) {
|
||||
const video = overlay.parentElement.querySelector('video');
|
||||
const url = video.getAttribute("data-url");
|
||||
video.setAttribute("controls", "");
|
||||
function playMedia(overlay, tagName) {
|
||||
const media = overlay.parentElement.querySelector(tagName);
|
||||
const url = media.getAttribute("data-url");
|
||||
const startTime = parseFloat(media.getAttribute("data-start") || "0");
|
||||
media.setAttribute("controls", "");
|
||||
overlay.style.display = "none";
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
var hls = new Hls({autoStartLoad: false});
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
hls.attachMedia(media);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, function () {
|
||||
hls.loadLevel = hls.levels.length - 1;
|
||||
hls.startLoad();
|
||||
video.play();
|
||||
hls.startLoad(startTime);
|
||||
media.play();
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = url;
|
||||
video.addEventListener('canplay', function() {
|
||||
video.play();
|
||||
} else if (media.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
media.src = url;
|
||||
media.addEventListener('canplay', function() {
|
||||
if (startTime > 0) media.currentTime = startTime;
|
||||
media.play();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function playVideo(overlay) { playMedia(overlay, 'video'); }
|
||||
function playAudio(overlay) { playMedia(overlay, 'audio'); }
|
||||
// @license-end
|
||||
|
|
|
|||
|
|
@ -1,77 +1,225 @@
|
|||
// @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
function insertBeforeLast(node, elem) {
|
||||
node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]);
|
||||
node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]);
|
||||
}
|
||||
|
||||
function getLoadMore(doc) {
|
||||
return doc.querySelector(".show-more:not(.timeline-item)");
|
||||
return doc.querySelector(".show-more:not(.timeline-item)");
|
||||
}
|
||||
|
||||
function isDuplicate(item, itemClass) {
|
||||
const tweet = item.querySelector(".tweet-link");
|
||||
if (tweet == null) return false;
|
||||
const href = tweet.getAttribute("href");
|
||||
return document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != null;
|
||||
function getHrefs(selector) {
|
||||
return new Set([...document.querySelectorAll(selector)].map(el => el.getAttribute("href")));
|
||||
}
|
||||
|
||||
window.onload = function () {
|
||||
const url = window.location.pathname;
|
||||
const isTweet = url.indexOf("/status/") !== -1;
|
||||
const containerClass = isTweet ? ".replies" : ".timeline";
|
||||
const itemClass = containerClass + " > div:not(.top-ref)";
|
||||
function getTweetId(item) {
|
||||
const m = item.querySelector(".tweet-link")?.getAttribute("href")?.match(/\/status\/(\d+)/);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
|
||||
var html = document.querySelector("html");
|
||||
var container = document.querySelector(containerClass);
|
||||
var loading = false;
|
||||
function isDuplicate(item, hrefs) {
|
||||
return hrefs.has(item.querySelector(".tweet-link")?.getAttribute("href"));
|
||||
}
|
||||
|
||||
function handleScroll(failed) {
|
||||
if (loading) return;
|
||||
const GAP = 10;
|
||||
|
||||
if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) {
|
||||
loading = true;
|
||||
var loadMore = getLoadMore(document);
|
||||
if (loadMore == null) return;
|
||||
class Masonry {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
const colSizes = {
|
||||
small: w => Math.max(130, w * 0.11),
|
||||
medium: w => Math.max(190, Math.min(350, w * 0.22)),
|
||||
large: w => Math.max(350, Math.min(480, w * 0.22)),
|
||||
};
|
||||
const size = container.dataset.colSize || "medium";
|
||||
this._targetWidth = colSizes[size] || colSizes.medium;
|
||||
this.colHeights = [];
|
||||
this.colCounts = [];
|
||||
this.colCount = 0;
|
||||
this._lastWidth = 0;
|
||||
this._colWidthCache = 0;
|
||||
this._items = [];
|
||||
this._revealTimer = null;
|
||||
this.container.classList.add("masonry-active");
|
||||
|
||||
loadMore.children[0].text = "Loading...";
|
||||
let resizeTimer;
|
||||
window.addEventListener("resize", () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => this._rebuild(), 50);
|
||||
});
|
||||
|
||||
var url = new URL(loadMore.children[0].href);
|
||||
url.searchParams.append("scroll", "true");
|
||||
// Re-sync positions whenever images finish loading and items grow taller.
|
||||
// Must be set up before _rebuild() so initial items get observed on first pass.
|
||||
let syncTimer;
|
||||
this._observer = window.ResizeObserver ? new ResizeObserver(() => {
|
||||
clearTimeout(syncTimer);
|
||||
syncTimer = setTimeout(() => this.syncHeights(), 100);
|
||||
}) : null;
|
||||
|
||||
fetch(url.toString()).then(function (response) {
|
||||
if (response.status === 404) throw "error";
|
||||
this._rebuild();
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}).then(function (html) {
|
||||
var parser = new DOMParser();
|
||||
var doc = parser.parseFromString(html, "text/html");
|
||||
loadMore.remove();
|
||||
// Reveal all items and gallery siblings (show-more, top-ref). Idempotent.
|
||||
_revealAll() {
|
||||
clearTimeout(this._revealTimer);
|
||||
for (const item of this._items) item.classList.add("masonry-visible");
|
||||
for (const el of this.container.parentElement.querySelectorAll(":scope > .show-more, :scope > .top-ref, :scope > .timeline-footer"))
|
||||
el.classList.add("masonry-visible");
|
||||
}
|
||||
|
||||
for (var item of doc.querySelectorAll(itemClass)) {
|
||||
if (item.className == "timeline-item show-more") continue;
|
||||
if (isDuplicate(item, itemClass)) continue;
|
||||
if (isTweet) container.appendChild(item);
|
||||
else insertBeforeLast(container, item);
|
||||
}
|
||||
// Height-primary, count-as-tiebreaker: handles both tall tweets and unloaded images.
|
||||
_pickCol() {
|
||||
return this.colHeights.reduce((min, h, i) => {
|
||||
const m = this.colHeights[min];
|
||||
return (h < m || (h === m && this.colCounts[i] < this.colCounts[min])) ? i : min;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
loading = false;
|
||||
const newLoadMore = getLoadMore(doc);
|
||||
if (newLoadMore == null) return;
|
||||
if (isTweet) container.appendChild(newLoadMore);
|
||||
else insertBeforeLast(container, newLoadMore);
|
||||
}).catch(function (err) {
|
||||
console.warn("Something went wrong.", err);
|
||||
if (failed > 3) {
|
||||
loadMore.children[0].text = "Error";
|
||||
return;
|
||||
}
|
||||
// Position items using current column state. Updates colHeights, colCounts, container height.
|
||||
_position(items, heights, colWidth) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const col = this._pickCol();
|
||||
items[i].style.left = `${col * (colWidth + GAP)}px`;
|
||||
items[i].style.top = `${this.colHeights[col]}px`;
|
||||
this.colHeights[col] += heights[i] + GAP;
|
||||
this.colCounts[col]++;
|
||||
}
|
||||
this.container.style.height = `${Math.max(0, ...this.colHeights)}px`;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
handleScroll((failed || 0) + 1);
|
||||
});
|
||||
}
|
||||
// Full reset and re-place all items.
|
||||
_place(items, heights, n, colWidth) {
|
||||
this.colHeights = new Array(n).fill(0);
|
||||
this.colCounts = new Array(n).fill(0);
|
||||
this.colCount = n;
|
||||
this._position(items, heights, colWidth);
|
||||
}
|
||||
|
||||
_rebuild() {
|
||||
const w = this.container.clientWidth;
|
||||
const n = Math.max(1, Math.floor(w / this._targetWidth(w)));
|
||||
if (n === this.colCount && w === this._lastWidth) return;
|
||||
|
||||
const isFirst = this.colCount === 0;
|
||||
|
||||
if (isFirst) {
|
||||
this._items = [...this.container.querySelectorAll(".timeline-item")];
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", () => handleScroll());
|
||||
};
|
||||
// Sort newest-first by tweet ID (snowflake IDs exceed Number precision, compare as strings).
|
||||
this._items.sort((a, b) => {
|
||||
const idA = getTweetId(a), idB = getTweetId(b);
|
||||
if (idA.length !== idB.length) return idB.length - idA.length;
|
||||
return idB < idA ? -1 : idB > idA ? 1 : 0;
|
||||
});
|
||||
|
||||
// Pre-set widths BEFORE reading heights so measurements reflect the new column width.
|
||||
const colWidth = this._colWidthCache = Math.floor((w - GAP * (n - 1)) / n);
|
||||
for (const item of this._items) item.style.width = `${colWidth}px`;
|
||||
|
||||
this._place(this._items, this._items.map(item => item.offsetHeight), n, colWidth);
|
||||
this._lastWidth = w;
|
||||
|
||||
if (isFirst) {
|
||||
if (this._observer) this._items.forEach(item => this._observer.observe(item));
|
||||
// Reveal immediately if all images are cached, else wait for syncHeights.
|
||||
const hasUnloaded = this._items.some(item =>
|
||||
[...item.querySelectorAll("img")].some(img => !img.complete));
|
||||
if (hasUnloaded) {
|
||||
this._revealTimer = setTimeout(() => this._revealAll(), 1000);
|
||||
} else {
|
||||
this._revealAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read actual heights and re-place all items. Fixes drift after images load.
|
||||
syncHeights() {
|
||||
this._place(this._items, this._items.map(item => item.offsetHeight), this.colCount, this._colWidthCache);
|
||||
this._revealAll();
|
||||
}
|
||||
|
||||
// Batch-add items in three phases to avoid O(N) reflows:
|
||||
// 1. writes: set widths, append all — no reads, no reflows
|
||||
// 2. one read: batch offsetHeight
|
||||
// 3. writes: assign columns, set left/top
|
||||
addAll(newItems) {
|
||||
if (!newItems.length) return;
|
||||
const colWidth = this._colWidthCache;
|
||||
|
||||
for (const item of newItems) {
|
||||
item.style.width = `${colWidth}px`;
|
||||
this.container.appendChild(item);
|
||||
}
|
||||
|
||||
this._position(newItems, newItems.map(item => item.offsetHeight), colWidth);
|
||||
this._items.push(...newItems);
|
||||
|
||||
if (this._observer) newItems.forEach(item => this._observer.observe(item));
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const isTweet = location.pathname.includes("/status/");
|
||||
const containerClass = isTweet ? ".replies" : ".timeline";
|
||||
const itemClass = containerClass + " > div:not(.top-ref)";
|
||||
const html = document.documentElement;
|
||||
const container = document.querySelector(containerClass);
|
||||
const masonryEl = container?.querySelector(".gallery-masonry");
|
||||
const masonry = masonryEl ? new Masonry(masonryEl) : null;
|
||||
let loading = false;
|
||||
|
||||
function handleScroll(failed) {
|
||||
if (loading || html.scrollTop + html.clientHeight < html.scrollHeight - 3000) return;
|
||||
|
||||
const loadMore = getLoadMore(document);
|
||||
if (!loadMore) return;
|
||||
loading = true;
|
||||
loadMore.children[0].text = "Loading...";
|
||||
|
||||
const url = new URL(loadMore.children[0].href);
|
||||
url.searchParams.append("scroll", "true");
|
||||
|
||||
fetch(url)
|
||||
.then(r => {
|
||||
if (r.status > 299) throw new Error("error");
|
||||
return r.text();
|
||||
})
|
||||
.then(responseText => {
|
||||
const doc = new DOMParser().parseFromString(responseText, "text/html");
|
||||
loadMore.remove();
|
||||
|
||||
if (masonry) {
|
||||
masonry.syncHeights();
|
||||
const newMasonry = doc.querySelector(".gallery-masonry");
|
||||
if (newMasonry) {
|
||||
const knownHrefs = getHrefs(".gallery-masonry .tweet-link");
|
||||
masonry.addAll([...newMasonry.querySelectorAll(".timeline-item")].filter(item => !isDuplicate(item, knownHrefs)));
|
||||
}
|
||||
} else {
|
||||
const knownHrefs = getHrefs(`${itemClass} .tweet-link`);
|
||||
for (const item of doc.querySelectorAll(itemClass)) {
|
||||
if (item.className === "timeline-item show-more" || isDuplicate(item, knownHrefs)) continue;
|
||||
isTweet ? container.appendChild(item) : insertBeforeLast(container, item);
|
||||
}
|
||||
}
|
||||
|
||||
loading = false;
|
||||
const newLoadMore = getLoadMore(doc);
|
||||
if (newLoadMore) {
|
||||
isTweet ? container.appendChild(newLoadMore) : insertBeforeLast(container, newLoadMore);
|
||||
if (masonry) newLoadMore.classList.add("masonry-visible");
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn("Something went wrong.", err);
|
||||
if (failed > 3) { loadMore.children[0].text = "Error"; return; }
|
||||
loading = false;
|
||||
handleScroll((failed || 0) + 1);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", () => handleScroll());
|
||||
});
|
||||
// @license-end
|
||||
|
|
|
|||
221
public/js/widgets.js
Normal file
221
public/js/widgets.js
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
/**
|
||||
* Drop-in replacement for Twitter's widgets.js
|
||||
* Redirects twitter-tweet blockquotes to Nitter embeds
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
if (window.__nitterWidgets) return;
|
||||
window.__nitterWidgets = true;
|
||||
|
||||
var NITTER = new URL(document.currentScript.src).origin;
|
||||
|
||||
var TWEET_RE = /(?:twitter\.com|x\.com)\/([^\/]+)\/status\/(\d+)/i;
|
||||
var SELECTOR = "blockquote.twitter-tweet, blockquote.twitter-video";
|
||||
|
||||
var readyCallbacks = [];
|
||||
var eventCallbacks = {};
|
||||
var isReady = false;
|
||||
|
||||
function safeCall(fn, arg) {
|
||||
try {
|
||||
fn(arg);
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
function fireEvent(name, data) {
|
||||
(eventCallbacks[name] || []).forEach(function (cb) {
|
||||
safeCall(cb, data);
|
||||
});
|
||||
}
|
||||
|
||||
function parseTweetUrl(url) {
|
||||
if (!url) return null;
|
||||
var m = TWEET_RE.exec(url);
|
||||
if (m) return { user: m[1], id: m[2] };
|
||||
m = url.match(/(\d{15,})/);
|
||||
return m ? { user: null, id: m[1] } : null;
|
||||
}
|
||||
|
||||
function createIframe(tweet, opts) {
|
||||
var url;
|
||||
if (opts.videoOnly) {
|
||||
url = NITTER + "/i/videos/tweet/" + tweet.id;
|
||||
} else {
|
||||
var path = tweet.user ? "/" + tweet.user : "/i";
|
||||
url = NITTER + path + "/status/" + tweet.id + "/embed";
|
||||
if (opts.theme) {
|
||||
var theme =
|
||||
opts.theme === "dark"
|
||||
? "nitter"
|
||||
: opts.theme === "light"
|
||||
? "twitter"
|
||||
: opts.theme;
|
||||
url += "?theme=" + encodeURIComponent(theme);
|
||||
}
|
||||
}
|
||||
|
||||
var iframe = document.createElement("iframe");
|
||||
iframe.src = url;
|
||||
iframe.className = "nitter-embed-frame";
|
||||
iframe.loading = "lazy";
|
||||
iframe.setAttribute("allowtransparency", "true");
|
||||
iframe.setAttribute("frameborder", "0");
|
||||
iframe.setAttribute("scrolling", "no");
|
||||
if (opts.videoOnly) iframe.setAttribute("allowfullscreen", "true");
|
||||
|
||||
var width = opts.width || 550;
|
||||
var margin =
|
||||
opts.align === "center"
|
||||
? "10px auto"
|
||||
: opts.align === "right"
|
||||
? "10px 0 10px auto"
|
||||
: "10px 0";
|
||||
iframe.style.cssText =
|
||||
"width:100%;max-width:" +
|
||||
width +
|
||||
"px;height:300px;" +
|
||||
"border:none;display:block;margin:" +
|
||||
margin;
|
||||
|
||||
iframe.addEventListener("load", function () {
|
||||
fireEvent("rendered", { target: iframe });
|
||||
});
|
||||
|
||||
return iframe;
|
||||
}
|
||||
|
||||
function processBlockquote(bq) {
|
||||
if (bq.dataset.nitterProcessed) return false;
|
||||
bq.dataset.nitterProcessed = "true";
|
||||
|
||||
var tweet = null;
|
||||
var links = bq.querySelectorAll("a[href]");
|
||||
for (var i = 0; i < links.length && !tweet; i++) {
|
||||
tweet = parseTweetUrl(links[i].href);
|
||||
}
|
||||
if (!tweet) return false;
|
||||
|
||||
var d = bq.dataset;
|
||||
var iframe = createIframe(tweet, {
|
||||
width: d.mediaMaxWidth || d.width,
|
||||
align: d.align,
|
||||
theme: d.theme,
|
||||
videoOnly: d.mediaMaxWidth !== undefined,
|
||||
});
|
||||
|
||||
bq.style.display = "none";
|
||||
bq.parentNode.insertBefore(iframe, bq.nextSibling);
|
||||
return true;
|
||||
}
|
||||
|
||||
function processEmbeds(root) {
|
||||
var bqs = (root || document).querySelectorAll(
|
||||
SELECTOR + ":not([data-nitter-processed])",
|
||||
);
|
||||
for (var i = 0; i < bqs.length; i++) processBlockquote(bqs[i]);
|
||||
}
|
||||
|
||||
function handleResize(e) {
|
||||
if (!Array.isArray(e.data) || e.data[0] !== "resizeIframe") return;
|
||||
var h = e.data[1] && e.data[1].h;
|
||||
if (!h || h <= 0 || h > 10000) return; // Cap at 10000px for sanity
|
||||
|
||||
var frames = document.querySelectorAll("iframe.nitter-embed-frame");
|
||||
for (var i = 0; i < frames.length; i++) {
|
||||
if (frames[i].contentWindow === e.source) {
|
||||
frames[i].style.height = h + "px";
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function observeDOM() {
|
||||
if (!window.MutationObserver || !document.body) return;
|
||||
|
||||
function matches(el) {
|
||||
return el.matches(SELECTOR) || el.querySelector(SELECTOR);
|
||||
}
|
||||
|
||||
new MutationObserver(function (muts) {
|
||||
var found = muts.some(function (mut) {
|
||||
return Array.prototype.some.call(mut.addedNodes, function (n) {
|
||||
return n.nodeType === 1 && matches(n);
|
||||
});
|
||||
});
|
||||
if (found) processEmbeds();
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
function embedTweet(id, container, opts) {
|
||||
if (!container) return Promise.reject("No container");
|
||||
var iframe = createIframe({ id: id, user: null }, opts || {});
|
||||
container.appendChild(iframe);
|
||||
return Promise.resolve(iframe);
|
||||
}
|
||||
|
||||
var prevTwttr = window.twttr;
|
||||
window.twttr = {
|
||||
widgets: {
|
||||
load: processEmbeds,
|
||||
createTweet: embedTweet,
|
||||
createTweetEmbed: embedTweet,
|
||||
createVideo: embedTweet,
|
||||
loaded: true,
|
||||
},
|
||||
events: {
|
||||
bind: function (name, cb) {
|
||||
if (typeof cb !== "function") return;
|
||||
if (!eventCallbacks[name]) eventCallbacks[name] = [];
|
||||
eventCallbacks[name].push(cb);
|
||||
},
|
||||
unbind: function (name, cb) {
|
||||
if (!eventCallbacks[name]) return;
|
||||
eventCallbacks[name] = cb
|
||||
? eventCallbacks[name].filter(function (f) {
|
||||
return f !== cb;
|
||||
})
|
||||
: [];
|
||||
},
|
||||
},
|
||||
ready: function (cb) {
|
||||
if (typeof cb !== "function") return;
|
||||
if (isReady) cb(window.twttr);
|
||||
else readyCallbacks.push(cb);
|
||||
},
|
||||
_e: [],
|
||||
};
|
||||
|
||||
// Process callbacks queued before load (twttr._e pattern)
|
||||
if (prevTwttr && prevTwttr._e) {
|
||||
prevTwttr._e.forEach(function (cb) {
|
||||
safeCall(cb);
|
||||
});
|
||||
}
|
||||
|
||||
// Remove any Twitter scripts that snuck through
|
||||
document
|
||||
.querySelectorAll(
|
||||
'script[src*="platform.twitter.com"], script[src*="platform.x.com"]',
|
||||
)
|
||||
.forEach(function (s) {
|
||||
s.remove();
|
||||
});
|
||||
|
||||
function init() {
|
||||
window.addEventListener("message", handleResize);
|
||||
processEmbeds();
|
||||
observeDOM();
|
||||
isReady = true;
|
||||
readyCallbacks.forEach(function (cb) {
|
||||
safeCall(cb, window.twttr);
|
||||
});
|
||||
readyCallbacks = [];
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
356
src/api.nim
356
src/api.nim
|
|
@ -1,8 +1,8 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import asyncdispatch, httpclient, uri, strutils, sequtils, sugar, tables
|
||||
import asyncdispatch, httpclient, strutils, sequtils, sugar
|
||||
import packedjson
|
||||
import types, query, formatters, consts, apiutils, parser
|
||||
import experimental/parser as newParser
|
||||
import types, query, formatters, consts, apiutils, parser, utils
|
||||
import experimental/parser
|
||||
|
||||
# Helper to generate params object for GraphQL requests
|
||||
proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
|
||||
|
|
@ -11,88 +11,188 @@ proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
|
|||
if fieldToggles.len > 0:
|
||||
result.add ("fieldToggles", fieldToggles)
|
||||
|
||||
proc mediaUrl(id: string; cursor: string): SessionAwareUrl =
|
||||
let
|
||||
cookieVariables = userMediaVariables % [id, cursor]
|
||||
oauthVariables = restIdVariables % [id, cursor]
|
||||
result = SessionAwareUrl(
|
||||
cookieUrl: graphUserMedia ? genParams(cookieVariables),
|
||||
oauthUrl: graphUserMediaV2 ? genParams(oauthVariables)
|
||||
proc apiUrl(endpoint, variables: string; fieldToggles = ""; skipTid = false): ApiUrl =
|
||||
return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles), skipTid: skipTid)
|
||||
|
||||
proc apiReq(endpoint, variables: string; fieldToggles = ""; skipTid = false): ApiReq =
|
||||
let url = apiUrl(endpoint, variables, fieldToggles, skipTid)
|
||||
return ApiReq(cookie: url, oauth: url)
|
||||
|
||||
proc cursorParam(after: string): string =
|
||||
## JSON-escape the user-supplied cursor so it cannot break out of the GraphQL
|
||||
## variables object (same input-validation class as the #1411 media SSRF).
|
||||
if after.len > 0: "\"cursor\":" & $(%after) & "," else: ""
|
||||
|
||||
proc mediaUrl(id, cursor: string): ApiReq =
|
||||
result = ApiReq(
|
||||
cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor, "100"]),
|
||||
oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor, "100"])
|
||||
)
|
||||
|
||||
proc userTweetsUrl(id: string; cursor: string): SessionAwareUrl =
|
||||
let
|
||||
cookieVariables = userTweetsVariables % [id, cursor]
|
||||
oauthVariables = restIdVariables % [id, cursor]
|
||||
result = SessionAwareUrl(
|
||||
# cookieUrl: graphUserTweets ? genParams(cookieVariables, fieldToggles),
|
||||
oauthUrl: graphUserTweetsV2 ? genParams(oauthVariables)
|
||||
)
|
||||
# might change this in the future pending testing
|
||||
result.cookieUrl = result.oauthUrl
|
||||
proc userTweetsUrl(id: string; cursor: string): ApiReq =
|
||||
return apiReq(graphUserTweetsV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles)
|
||||
|
||||
proc userTweetsAndRepliesUrl(id: string; cursor: string): SessionAwareUrl =
|
||||
let
|
||||
cookieVariables = userTweetsAndRepliesVariables % [id, cursor]
|
||||
oauthVariables = restIdVariables % [id, cursor]
|
||||
result = SessionAwareUrl(
|
||||
cookieUrl: graphUserTweetsAndReplies ? genParams(cookieVariables, fieldToggles),
|
||||
oauthUrl: graphUserTweetsAndRepliesV2 ? genParams(oauthVariables)
|
||||
proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
|
||||
result = ApiReq(
|
||||
cookie: apiUrl(graphUserTweetsAndReplies, userTweetsAndRepliesVars % [id, cursor], userTweetsFieldToggles),
|
||||
oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles, skipTid=true)
|
||||
)
|
||||
|
||||
proc tweetDetailUrl(id: string; cursor: string): SessionAwareUrl =
|
||||
let
|
||||
cookieVariables = tweetDetailVariables % [id, cursor]
|
||||
oauthVariables = tweetVariables % [id, cursor]
|
||||
result = SessionAwareUrl(
|
||||
cookieUrl: graphTweetDetail ? genParams(cookieVariables, tweetDetailFieldToggles),
|
||||
oauthUrl: graphTweet ? genParams(oauthVariables)
|
||||
proc userArticlesUrl(id: string; cursor: string): ApiReq =
|
||||
result = ApiReq(
|
||||
cookie: apiUrl(graphUserArticles, userArticlesVars % [id, cursor], userTweetsFieldToggles),
|
||||
oauth: apiUrl(graphUserArticlesV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles)
|
||||
)
|
||||
|
||||
proc tweetDetailUrl(id, cursor: string; mode = Relevance): ApiReq =
|
||||
return apiReq(graphTweet, tweetVars % [id, cursor, $mode])
|
||||
# let cookieVars = tweetDetailVars % [id, cursor]
|
||||
# result = ApiReq(
|
||||
# cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles),
|
||||
# oauth: apiUrl(graphTweet, tweetVars % [id, cursor])
|
||||
# )
|
||||
|
||||
proc userUrl(username: string): ApiReq =
|
||||
let cookieVars = $(%*{"screen_name": username, "withGrokTranslatedBio": false})
|
||||
result = ApiReq(
|
||||
cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles),
|
||||
oauth: apiUrl(graphUserV2, $(%*{"screen_name": username}))
|
||||
)
|
||||
|
||||
proc getGraphUser*(username: string): Future[User] {.async.} =
|
||||
if username.len == 0: return
|
||||
let
|
||||
url = graphUser ? genParams("""{"screen_name": "$1"}""" % username)
|
||||
js = await fetchRaw(url, Api.userScreenName)
|
||||
let js = await fetchRaw(userUrl(username))
|
||||
result = parseGraphUser(js)
|
||||
|
||||
proc getGraphUserById*(id: string): Future[User] {.async.} =
|
||||
if id.len == 0 or id.any(c => not c.isDigit): return
|
||||
let
|
||||
url = graphUserById ? genParams("""{"rest_id": "$1"}""" % id)
|
||||
js = await fetchRaw(url, Api.userRestId)
|
||||
url = apiReq(graphUserById, userByRestIdVars % id)
|
||||
js = await fetchRaw(url)
|
||||
result = parseGraphUser(js)
|
||||
|
||||
proc getAboutAccount*(username: string): Future[AccountInfo] {.async.} =
|
||||
if username.len == 0: return
|
||||
let
|
||||
url = apiReq(graphAboutAccount, $(%*{"screenName": username}))
|
||||
js = await fetch(url)
|
||||
result = parseAboutAccount(js)
|
||||
|
||||
proc restReq(endpoint: string; params: seq[(string, string)] = @[]): ApiReq =
|
||||
let url = ApiUrl(endpoint: endpoint, params: params)
|
||||
ApiReq(cookie: url, oauth: url)
|
||||
|
||||
proc getBroadcastInfo*(id: string): Future[Broadcast] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
req = apiReq(graphBroadcast, $(%*{"id": id}))
|
||||
js = await fetch(req)
|
||||
result = parseBroadcastInfo(js)
|
||||
|
||||
proc fetchBroadcastStream*(mediaKey: string): Future[string] {.async.} =
|
||||
if mediaKey.len == 0: return
|
||||
let
|
||||
streamReq = restReq(restLiveStream & mediaKey)
|
||||
streamJs = await fetch(streamReq)
|
||||
result = streamJs{"source", "noRedirectPlaybackUrl"}.getStr(
|
||||
streamJs{"source", "location"}.getStr)
|
||||
|
||||
proc getAudioSpace*(id: string): Future[AudioSpace] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
variables = %*{
|
||||
"id": id,
|
||||
"isMetatagsQuery": false,
|
||||
"withReplays": true,
|
||||
"withListeners": true
|
||||
}
|
||||
req = apiReq(graphAudioSpace, $variables)
|
||||
js = await fetch(req)
|
||||
result = parseAudioSpace(js)
|
||||
|
||||
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
js = case kind
|
||||
of TimelineKind.tweets:
|
||||
await fetch(userTweetsUrl(id, cursor), Api.userTweets)
|
||||
of TimelineKind.replies:
|
||||
await fetch(userTweetsAndRepliesUrl(id, cursor), Api.userTweetsAndReplies)
|
||||
of TimelineKind.media:
|
||||
await fetch(mediaUrl(id, cursor), Api.userMedia)
|
||||
cursor = cursorParam(after)
|
||||
url = case kind
|
||||
of TimelineKind.tweets: userTweetsUrl(id, cursor)
|
||||
of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
|
||||
of TimelineKind.media: mediaUrl(id, cursor)
|
||||
of TimelineKind.articles: userArticlesUrl(id, cursor)
|
||||
js = await fetch(url)
|
||||
result = parseGraphTimeline(js, after)
|
||||
|
||||
proc getGraphCommunity*(id: string): Future[Community] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
url = apiReq(graphCommunity, $(%*{"communityId": id}))
|
||||
js = await fetch(url)
|
||||
result = parseGraphCommunity(js)
|
||||
|
||||
proc getGraphCommunityTweets*(id: string; rankingMode: string; after=""): Future[Timeline] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = cursorParam(after)
|
||||
url = apiReq(graphCommunityTweets, communityTweetsVars % [id, cursor, rankingMode])
|
||||
js = await fetch(url)
|
||||
result = parseGraphCommunityTimeline(js, after)
|
||||
|
||||
proc getGraphCommunityMedia*(id: string; after=""): Future[Timeline] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = cursorParam(after)
|
||||
url = apiReq(graphCommunityMedia, communityMediaVars % [id, cursor])
|
||||
js = await fetch(url)
|
||||
result = parseGraphCommunityTimeline(js, after)
|
||||
|
||||
proc communitySliceReq(endpoint, variables: string): ApiReq =
|
||||
let url = ApiUrl(endpoint: endpoint, params: @[("variables", variables)])
|
||||
ApiReq(cookie: url, oauth: url)
|
||||
|
||||
proc getGraphCommunityMembers*(id: string; after=""): Future[Result[User]] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: $(%after) else: "null"
|
||||
url = communitySliceReq(graphCommunityMembers, communityMembersVars % [id, cursor])
|
||||
js = await fetch(url)
|
||||
result = parseGraphCommunityMembers(js, after)
|
||||
|
||||
proc getGraphCommunityModerators*(id: string): Future[Result[User]] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
url = communitySliceReq(graphCommunityModerators, communityMembersVars % [id, "null"])
|
||||
js = await fetch(url)
|
||||
result = parseGraphCommunityMembers(js)
|
||||
|
||||
proc getGraphCommunityHashtags*(id, hashtag: string; after=""): Future[Timeline] {.async.} =
|
||||
if id.len == 0 or hashtag.len == 0: return
|
||||
let
|
||||
safeTag = multiReplace(hashtag, ("\"", ""), ("\\", ""))
|
||||
cursor = cursorParam(after)
|
||||
url = apiReq(graphCommunityHashtags, communityHashtagsVars % [id, cursor, safeTag])
|
||||
js = await fetch(url)
|
||||
result = parseGraphCommunityTimeline(js, after)
|
||||
|
||||
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
url = graphListTweets ? genParams(restIdVariables % [id, cursor])
|
||||
result = parseGraphTimeline(await fetch(url, Api.listTweets), after).tweets
|
||||
cursor = cursorParam(after)
|
||||
url = apiReq(graphListTweets, restIdVars % [id, cursor, "20"])
|
||||
js = await fetch(url)
|
||||
result = parseGraphTimeline(js, after).tweets
|
||||
|
||||
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
|
||||
let
|
||||
variables = %*{"screenName": name, "listSlug": list}
|
||||
url = graphListBySlug ? genParams($variables)
|
||||
result = parseGraphList(await fetch(url, Api.listBySlug))
|
||||
url = apiReq(graphListBySlug, $variables)
|
||||
js = await fetch(url)
|
||||
result = parseGraphList(js)
|
||||
|
||||
proc getGraphList*(id: string): Future[List] {.async.} =
|
||||
let
|
||||
url = graphListById ? genParams("""{"listId": "$1"}""" % id)
|
||||
result = parseGraphList(await fetch(url, Api.list))
|
||||
let
|
||||
url = apiReq(graphListById, $(%*{"listId": id}))
|
||||
js = await fetch(url)
|
||||
result = parseGraphList(js)
|
||||
|
||||
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
|
||||
if list.id.len == 0: return
|
||||
|
|
@ -106,81 +206,161 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
|
|||
}
|
||||
if after.len > 0:
|
||||
variables["cursor"] = % after
|
||||
let url = graphListMembers ? genParams($variables)
|
||||
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after)
|
||||
let
|
||||
url = apiReq(graphListMembers, $variables)
|
||||
js = await fetchRaw(url)
|
||||
result = parseGraphListMembers(js, after)
|
||||
|
||||
proc getGraphUserConnections(userId: string; endpoint: string; kind: QueryKind;
|
||||
after=""): Future[Result[User]] {.async.} =
|
||||
if userId.len == 0: return
|
||||
var variables = %*{
|
||||
"userId": userId,
|
||||
"count": 20,
|
||||
"includePromotedContent": false,
|
||||
"withGrokTranslatedBio": true
|
||||
}
|
||||
if after.len > 0:
|
||||
variables["cursor"] = %after
|
||||
let
|
||||
url = apiReq(endpoint, $variables)
|
||||
js = await fetchRaw(url)
|
||||
result = parseGraphFollowers(js, after, kind)
|
||||
|
||||
proc getGraphFollowers*(userId: string; after=""): Future[Result[User]] {.async.} =
|
||||
result = await getGraphUserConnections(userId, graphFollowers, followers, after)
|
||||
|
||||
proc getGraphFollowing*(userId: string; after=""): Future[Result[User]] {.async.} =
|
||||
result = await getGraphUserConnections(userId, graphFollowing, following, after)
|
||||
|
||||
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
variables = """{"rest_id": "$1"}""" % id
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphTweetResult ? params, Api.tweetResult)
|
||||
url = apiReq(graphTweetResult, $(%*{"rest_id": id}))
|
||||
js = await fetch(url)
|
||||
result = parseGraphTweetResult(js)
|
||||
|
||||
proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
|
||||
proc getTweetByRestId*(id: string): Future[Tweet] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
js = await fetch(tweetDetailUrl(id, cursor), Api.tweetDetail)
|
||||
url = apiReq(graphTweetResultByRestId, tweetByRestIdVars % id, articleFieldToggles)
|
||||
js = await fetch(url)
|
||||
result = parseTweetByRestId(js)
|
||||
|
||||
proc getGraphTweet(id: string; after=""; mode = Relevance): Future[Conversation] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = cursorParam(after)
|
||||
js = await fetch(tweetDetailUrl(id, cursor, mode))
|
||||
result = parseGraphConversation(js, id)
|
||||
|
||||
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
|
||||
result = (await getGraphTweet(id, after)).replies
|
||||
proc getReplies*(id, after: string; mode = Relevance): Future[Result[Chain]] {.async.} =
|
||||
result = (await getGraphTweet(id, after, mode)).replies
|
||||
result.beginning = after.len == 0
|
||||
|
||||
proc getTweet*(id: string; after=""): Future[Conversation] {.async.} =
|
||||
result = await getGraphTweet(id)
|
||||
proc getTweet*(id: string; after=""; mode = Relevance): Future[Conversation] {.async.} =
|
||||
result = await getGraphTweet(id, mode=mode)
|
||||
if after.len > 0:
|
||||
result.replies = await getReplies(id, after)
|
||||
result.replies = await getReplies(id, after, mode)
|
||||
|
||||
proc getGraphEditHistory*(id: string): Future[EditHistory] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
url = apiReq(graphTweetEditHistory, tweetEditHistoryVars % id)
|
||||
js = await fetch(url)
|
||||
result = parseGraphEditHistory(js, id)
|
||||
|
||||
proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
|
||||
let q = genQueryParam(query)
|
||||
# workaround for #1372
|
||||
let maxId =
|
||||
if not after.startsWith("maxid:"): ""
|
||||
else: validateNumber(after[6..^1])
|
||||
|
||||
let q = genQueryParam(query, maxId)
|
||||
if q.len == 0 or q == emptyQuery:
|
||||
return Timeline(query: query, beginning: true)
|
||||
|
||||
let product =
|
||||
case query.kind
|
||||
of top: "Top"
|
||||
# profile media feeds (RSS, multi-user timelines) must stay chronological
|
||||
of media: (if query.fromUser.len == 0: "Media" else: "Latest")
|
||||
else: "Latest"
|
||||
|
||||
var
|
||||
variables = %*{
|
||||
"rawQuery": q,
|
||||
"query_source": "typedQuery",
|
||||
"count": 20,
|
||||
"product": "Latest",
|
||||
"withDownvotePerspective": false,
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false
|
||||
"querySource": "typed_query",
|
||||
"product": product,
|
||||
"withGrokTranslatedBio":true,
|
||||
"withQuickPromoteEligibilityTweetFields":false
|
||||
}
|
||||
if after.len > 0:
|
||||
|
||||
if after.len > 0 and maxId.len == 0:
|
||||
variables["cursor"] = % after
|
||||
let url = graphSearchTimeline ? genParams($variables)
|
||||
result = parseGraphSearch[Tweets](await fetch(url, Api.search), after)
|
||||
let
|
||||
url = apiReq(graphSearchTimeline, $variables)
|
||||
js = await fetch(url)
|
||||
result = parseGraphSearch[Tweets](js, after)
|
||||
result.query = query
|
||||
|
||||
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
|
||||
# when no more items are available the API just returns the last page in
|
||||
# full. this detects that and clears the page instead.
|
||||
let prefix = min(64, min(after.len, result.bottom.len))
|
||||
if prefix > 0 and maxId.len == 0 and
|
||||
after[0..<prefix] == result.bottom[0..<prefix]:
|
||||
result.content.setLen(0)
|
||||
|
||||
proc getGraphProductSearch[T](query: Query; product: string;
|
||||
after=""): Future[Result[T]] {.async.} =
|
||||
if query.text.len == 0:
|
||||
return Result[User](query: query, beginning: true)
|
||||
return Result[T](query: query, beginning: true)
|
||||
|
||||
var
|
||||
variables = %*{
|
||||
"rawQuery": query.text,
|
||||
"query_source": "typedQuery",
|
||||
"count": 20,
|
||||
"product": "People",
|
||||
"withDownvotePerspective": false,
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false
|
||||
"querySource": "typed_query",
|
||||
"product": product,
|
||||
"withGrokTranslatedBio":true,
|
||||
"withQuickPromoteEligibilityTweetFields":false
|
||||
}
|
||||
if after.len > 0:
|
||||
variables["cursor"] = % after
|
||||
result.beginning = false
|
||||
|
||||
let url = graphSearchTimeline ? genParams($variables)
|
||||
result = parseGraphSearch[User](await fetch(url, Api.search), after)
|
||||
let
|
||||
url = apiReq(graphSearchTimeline, $variables)
|
||||
js = await fetch(url)
|
||||
result = parseGraphSearch[T](js, after)
|
||||
result.query = query
|
||||
|
||||
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] =
|
||||
getGraphProductSearch[User](query, "People", after)
|
||||
|
||||
proc getGraphListSearch*(query: Query; after=""): Future[Result[ListSearchResult]] =
|
||||
getGraphProductSearch[ListSearchResult](query, "Lists", after)
|
||||
|
||||
proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
|
||||
if id.len == 0: return
|
||||
let js = await fetch(mediaUrl(id, ""), Api.userMedia)
|
||||
let js = await fetch(mediaUrl(id, ""))
|
||||
result = parseGraphPhotoRail(js)
|
||||
|
||||
proc getGraphArticle*(id: string): Future[Article] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
url = apiReq(graphTweetResultByRestId, articleVars % id, articleFieldToggles)
|
||||
json = await fetchRaw(url)
|
||||
result = parseGraphArticle(json)
|
||||
|
||||
proc getGraphTweetResults*(ids: seq[string]): Future[seq[Tweet]] {.async.} =
|
||||
if ids.len == 0: return
|
||||
let
|
||||
idsJson = "[" & ids.mapIt("\"" & it & "\"").join(",") & "]"
|
||||
url = apiReq(graphTweetResultsByRestIds, articleBatchVars % idsJson, articleFieldToggles)
|
||||
js = await fetch(url)
|
||||
result = parseGraphTweetResults(js)
|
||||
|
||||
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
|
||||
let client = newAsyncHttpClient(maxRedirects=0)
|
||||
try:
|
||||
|
|
|
|||
185
src/apiutils.nim
185
src/apiutils.nim
|
|
@ -1,16 +1,62 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import httpclient, asyncdispatch, options, strutils, uri, times, math, tables
|
||||
import jsony, packedjson, zippy, oauth1
|
||||
import types, auth, consts, parserutils, http_pool
|
||||
import jsony, packedjson, zippy, oauth/oauth1
|
||||
import types, auth, consts, parserutils, http_pool, tid
|
||||
import experimental/types/common
|
||||
|
||||
const
|
||||
rlRemaining = "x-rate-limit-remaining"
|
||||
rlReset = "x-rate-limit-reset"
|
||||
rlLimit = "x-rate-limit-limit"
|
||||
errorsToSkip = {doesntExist, tweetNotFound, timeout, unauthorized, badRequest}
|
||||
npCache = "x-np-cache"
|
||||
errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest}
|
||||
|
||||
var pool: HttpPool
|
||||
proc isCloudflareHtml*(body: string): bool =
|
||||
## Detect Cloudflare HTML error pages returned instead of JSON
|
||||
if body.len < 14 or body[0] != '<': return false
|
||||
body[0 ..< 14].toLowerAscii() == "<!doctype html" and "Cloudflare" in body
|
||||
|
||||
proc cfTitle*(body: string): string =
|
||||
## Extract <title> from Cloudflare HTML for log diagnostics
|
||||
let start = body.find("<title>")
|
||||
if start < 0: return "unknown"
|
||||
let contentStart = start + 7
|
||||
let stop = body.find("</title>", contentStart)
|
||||
if stop < 0: return "unknown"
|
||||
body[contentStart ..< stop].splitWhitespace().join(" ")
|
||||
|
||||
var
|
||||
pool: HttpPool
|
||||
disableTid: bool
|
||||
apiProxy: string
|
||||
maxRetries: int
|
||||
retryDelayMs: int
|
||||
|
||||
proc setDisableTid*(disable: bool) =
|
||||
disableTid = disable
|
||||
|
||||
proc setMaxRetries*(n: int) =
|
||||
maxRetries = n
|
||||
|
||||
proc setRetryDelayMs*(ms: int) =
|
||||
retryDelayMs = ms
|
||||
|
||||
proc setApiProxy*(url: string) =
|
||||
apiProxy = ""
|
||||
if url.len > 0:
|
||||
apiProxy = url.strip(chars={'/'}) & "/"
|
||||
if "http" notin apiProxy:
|
||||
apiProxy = "http://" & apiProxy
|
||||
|
||||
proc toUrl*(req: ApiReq; sessionKind: SessionKind): Uri =
|
||||
let url = case sessionKind
|
||||
of oauth: req.oauth
|
||||
of cookie: req.cookie
|
||||
let base = case sessionKind
|
||||
of oauth: "https://api.x.com"
|
||||
of cookie: "https://x.com/i/api"
|
||||
let prefix = if url.endpoint.startsWith("1.1/"): "" else: "graphql/"
|
||||
parseUri(base) / (prefix & url.endpoint) ? url.params
|
||||
|
||||
proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
|
||||
let
|
||||
|
|
@ -32,31 +78,41 @@ proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
|
|||
proc getCookieHeader(authToken, ct0: string): string =
|
||||
"auth_token=" & authToken & "; ct0=" & ct0
|
||||
|
||||
proc genHeaders*(session: Session, url: string): HttpHeaders =
|
||||
proc genHeaders*(session: Session, url: Uri, skipTid: bool): Future[HttpHeaders] {.async.} =
|
||||
result = newHttpHeaders({
|
||||
"connection": "keep-alive",
|
||||
"content-type": "application/json",
|
||||
"x-twitter-active-user": "yes",
|
||||
"x-twitter-client-language": "en",
|
||||
"authority": "api.x.com",
|
||||
"accept": "*/*",
|
||||
"accept-encoding": "gzip",
|
||||
"accept-language": "en-US,en;q=0.9",
|
||||
"accept": "*/*",
|
||||
"DNT": "1",
|
||||
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||
})
|
||||
"content-type": "application/json",
|
||||
"origin": "https://x.com",
|
||||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
|
||||
"x-twitter-active-user": "yes",
|
||||
"x-twitter-client-language": "en",
|
||||
"priority": "u=1, i"
|
||||
}, titleCase=true)
|
||||
|
||||
case session.kind
|
||||
of SessionKind.oauth:
|
||||
result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret)
|
||||
result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret)
|
||||
of SessionKind.cookie:
|
||||
result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF"
|
||||
result["x-twitter-auth-type"] = "OAuth2Session"
|
||||
result["x-csrf-token"] = session.ct0
|
||||
result["cookie"] = getCookieHeader(session.authToken, session.ct0)
|
||||
result["referer"] = "https://x.com/"
|
||||
result["sec-ch-ua"] = """"Google Chrome";v="142", "Chromium";v="142", "Not A(Brand";v="24""""
|
||||
result["sec-ch-ua-mobile"] = "?0"
|
||||
result["sec-ch-ua-platform"] = "Windows"
|
||||
result["sec-fetch-dest"] = "empty"
|
||||
result["sec-fetch-mode"] = "cors"
|
||||
result["sec-fetch-site"] = "same-origin"
|
||||
if disableTid or skipTid or "/1.1/" in url.path:
|
||||
result["authorization"] = bearerToken2
|
||||
else:
|
||||
result["authorization"] = bearerToken
|
||||
result["x-client-transaction-id"] = await genTid(url.path)
|
||||
|
||||
proc getAndValidateSession*(api: Api): Future[Session] {.async.} =
|
||||
result = await getSession(api)
|
||||
proc getAndValidateSession*(req: ApiReq): Future[Session] {.async.} =
|
||||
result = await getSession(req)
|
||||
case result.kind
|
||||
of SessionKind.oauth:
|
||||
if result.oauthToken.len == 0:
|
||||
|
|
@ -73,9 +129,18 @@ template fetchImpl(result, fetchBody) {.dirty.} =
|
|||
|
||||
try:
|
||||
var resp: AsyncResponse
|
||||
pool.use(genHeaders(session, $url)):
|
||||
let skipTid = case session.kind
|
||||
of oauth: req.oauth.skipTid
|
||||
of cookie: req.cookie.skipTid
|
||||
let headers = await genHeaders(session, url, skipTid)
|
||||
|
||||
pool.use(headers):
|
||||
template getContent =
|
||||
resp = await c.get($url)
|
||||
# TODO: this is a temporary simple implementation
|
||||
if apiProxy.len > 0 and "/1.1/" notin url.path:
|
||||
resp = await c.get(($url).replace("https://", apiProxy))
|
||||
else:
|
||||
resp = await c.get($url)
|
||||
result = await resp.body
|
||||
|
||||
getContent()
|
||||
|
|
@ -84,38 +149,45 @@ template fetchImpl(result, fetchBody) {.dirty.} =
|
|||
badClient = true
|
||||
raise newException(BadClientError, "Bad client")
|
||||
|
||||
if resp.headers.hasKey(rlRemaining):
|
||||
if resp.status == $Http404 and result.len == 0:
|
||||
echo "[sessions] transient 404 (empty body), retrying: ", url.path, ", session: ", session.pretty
|
||||
raise rateLimitError()
|
||||
|
||||
let cacheStatus = resp.headers.getOrDefault(npCache)
|
||||
if cacheStatus notin ["HIT", "STALE"] and resp.headers.hasKey(rlRemaining):
|
||||
let
|
||||
remaining = parseInt(resp.headers[rlRemaining])
|
||||
reset = parseInt(resp.headers[rlReset])
|
||||
limit = parseInt(resp.headers[rlLimit])
|
||||
session.setRateLimit(api, remaining, reset, limit)
|
||||
session.setRateLimit(req, remaining, reset, limit)
|
||||
|
||||
if result.len > 0:
|
||||
if resp.headers.getOrDefault("content-encoding") == "gzip":
|
||||
result = uncompress(result, dfGzip)
|
||||
|
||||
if isCloudflareHtml(result):
|
||||
echo "[cloudflare] ", resp.status, " (", cfTitle(result), "), API: ", url.path, ", session: ", session.pretty
|
||||
raise rateLimitError()
|
||||
|
||||
if result.startsWith("{\"errors"):
|
||||
let errors = result.fromJson(Errors)
|
||||
if errors notin errorsToSkip:
|
||||
echo "Fetch error, API: ", api, ", errors: ", errors
|
||||
echo "Fetch error, API: ", url.path, ", errors: ", errors, ", session: ", session.pretty
|
||||
if errors in {expiredToken, badToken, locked}:
|
||||
invalidate(session)
|
||||
raise rateLimitError()
|
||||
elif errors in {rateLimited}:
|
||||
# rate limit hit, resets after 24 hours
|
||||
setLimited(session, api)
|
||||
setLimited(session, req)
|
||||
raise rateLimitError()
|
||||
elif result.startsWith("429 Too Many Requests"):
|
||||
echo "[sessions] 429 error, API: ", api, ", session: ", session.pretty
|
||||
session.apis[api].remaining = 0
|
||||
# rate limit hit, resets after the 15 minute window
|
||||
echo "[sessions] 429 error, API: ", url.path, ", session: ", session.pretty
|
||||
raise rateLimitError()
|
||||
|
||||
fetchBody
|
||||
|
||||
if resp.status == $Http400:
|
||||
echo "ERROR 400, ", api, ": ", result
|
||||
echo "ERROR 400, ", url.path, ": ", result, ", session: ", session.pretty
|
||||
raise newException(InternalError, $url)
|
||||
except InternalError as e:
|
||||
raise e
|
||||
|
|
@ -130,48 +202,57 @@ template fetchImpl(result, fetchBody) {.dirty.} =
|
|||
finally:
|
||||
release(session)
|
||||
|
||||
template retry(bod) =
|
||||
try:
|
||||
bod
|
||||
except RateLimitError:
|
||||
echo "[sessions] Rate limited, retrying ", api, " request..."
|
||||
bod
|
||||
template retry(bod) {.dirty.} =
|
||||
var session: Session
|
||||
var retrySuccess = false
|
||||
for i in 0 ..< maxRetries:
|
||||
try:
|
||||
session = nil
|
||||
bod
|
||||
retrySuccess = true
|
||||
break
|
||||
except RateLimitError:
|
||||
let api = if session.isNil: req.cookie.endpoint
|
||||
else: req.endpoint(session)
|
||||
if session.isNil:
|
||||
echo "[sessions] Rate limited, retrying ", api,
|
||||
" request (", i, "/", maxRetries, ")..."
|
||||
else:
|
||||
echo "[sessions] Rate limited, retrying ", api,
|
||||
" request (", i, "/", maxRetries, ")..., session: ", session.pretty
|
||||
session = nil
|
||||
if retryDelayMs > 0:
|
||||
await sleepAsync(retryDelayMs)
|
||||
if not retrySuccess:
|
||||
raise rateLimitError()
|
||||
|
||||
proc fetch*(url: Uri | SessionAwareUrl; api: Api): Future[JsonNode] {.async.} =
|
||||
proc fetch*(req: ApiReq): Future[JsonNode] {.async.} =
|
||||
retry:
|
||||
var
|
||||
body: string
|
||||
session = await getAndValidateSession(api)
|
||||
var body: string
|
||||
session = await getAndValidateSession(req)
|
||||
|
||||
when url is SessionAwareUrl:
|
||||
let url = case session.kind
|
||||
of SessionKind.oauth: url.oauthUrl
|
||||
of SessionKind.cookie: url.cookieUrl
|
||||
let url = req.toUrl(session.kind)
|
||||
|
||||
fetchImpl body:
|
||||
if body.startsWith('{') or body.startsWith('['):
|
||||
result = parseJson(body)
|
||||
else:
|
||||
echo resp.status, ": ", body, " --- url: ", url
|
||||
echo resp.status, ": ", body, " --- url: ", url, ", session: ", session.pretty
|
||||
result = newJNull()
|
||||
|
||||
let error = result.getError
|
||||
if error != null and error notin errorsToSkip:
|
||||
echo "Fetch error, API: ", api, ", error: ", error
|
||||
echo "Fetch error, API: ", url.path, ", error: ", error, ", session: ", session.pretty
|
||||
if error in {expiredToken, badToken, locked}:
|
||||
invalidate(session)
|
||||
raise rateLimitError()
|
||||
|
||||
proc fetchRaw*(url: Uri | SessionAwareUrl; api: Api): Future[string] {.async.} =
|
||||
proc fetchRaw*(req: ApiReq): Future[string] {.async.} =
|
||||
retry:
|
||||
var session = await getAndValidateSession(api)
|
||||
|
||||
when url is SessionAwareUrl:
|
||||
let url = case session.kind
|
||||
of SessionKind.oauth: url.oauthUrl
|
||||
of SessionKind.cookie: url.cookieUrl
|
||||
session = await getAndValidateSession(req)
|
||||
let url = req.toUrl(session.kind)
|
||||
|
||||
fetchImpl result:
|
||||
if not (result.startsWith('{') or result.startsWith('[')):
|
||||
echo resp.status, ": ", result, " --- url: ", url
|
||||
echo resp.status, ": ", result, " --- url: ", url, ", session: ", session.pretty
|
||||
result.setLen(0)
|
||||
|
|
|
|||
58
src/auth.nim
58
src/auth.nim
|
|
@ -1,20 +1,28 @@
|
|||
#SPDX-License-Identifier: AGPL-3.0-only
|
||||
import std/[asyncdispatch, times, json, random, sequtils, strutils, tables, packedsets, os]
|
||||
import types
|
||||
import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os]
|
||||
import types, consts
|
||||
import experimental/parser/session
|
||||
|
||||
# max requests at a time per session to avoid race conditions
|
||||
const
|
||||
maxConcurrentReqs = 2
|
||||
hourInSeconds = 60 * 60
|
||||
const hourInSeconds = 60 * 60
|
||||
|
||||
var
|
||||
sessionPool: seq[Session]
|
||||
enableLogging = false
|
||||
# max requests at a time per session to avoid race conditions
|
||||
maxConcurrentReqs = 2
|
||||
|
||||
proc setMaxConcurrentReqs*(reqs: int) =
|
||||
if reqs > 0:
|
||||
maxConcurrentReqs = reqs
|
||||
|
||||
template log(str: varargs[string, `$`]) =
|
||||
echo "[sessions] ", str.join("")
|
||||
|
||||
proc endpoint*(req: ApiReq; session: Session): string =
|
||||
case session.kind
|
||||
of oauth: req.oauth.endpoint
|
||||
of cookie: req.cookie.endpoint
|
||||
|
||||
proc pretty*(session: Session): string =
|
||||
if session.isNil:
|
||||
return "<null>"
|
||||
|
|
@ -42,6 +50,8 @@ proc getSessionPoolHealth*(): JsonNode =
|
|||
oldest = now.int64
|
||||
newest = 0'i64
|
||||
average = 0'i64
|
||||
oauthTotal, cookieTotal = 0
|
||||
oauthLimited, cookieLimited = 0
|
||||
|
||||
for session in sessionPool:
|
||||
let created = snowflakeToEpoch(session.id)
|
||||
|
|
@ -51,8 +61,15 @@ proc getSessionPoolHealth*(): JsonNode =
|
|||
oldest = created
|
||||
average += created
|
||||
|
||||
case session.kind
|
||||
of oauth: inc oauthTotal
|
||||
of cookie: inc cookieTotal
|
||||
|
||||
if session.limited:
|
||||
limited.incl session.id
|
||||
case session.kind
|
||||
of oauth: inc oauthLimited
|
||||
of cookie: inc cookieLimited
|
||||
|
||||
for api in session.apis.keys:
|
||||
let
|
||||
|
|
@ -76,6 +93,8 @@ proc getSessionPoolHealth*(): JsonNode =
|
|||
"sessions": %*{
|
||||
"total": sessionPool.len,
|
||||
"limited": limited.card,
|
||||
"oauth": %*{"total": oauthTotal, "limited": oauthLimited},
|
||||
"cookie": %*{"total": cookieTotal, "limited": cookieLimited},
|
||||
"oldest": $fromUnix(oldest),
|
||||
"newest": $fromUnix(newest),
|
||||
"average": $fromUnix(average)
|
||||
|
|
@ -92,6 +111,7 @@ proc getSessionPoolDebug*(): JsonNode =
|
|||
|
||||
for session in sessionPool:
|
||||
let sessionJson = %*{
|
||||
"kind": $session.kind,
|
||||
"apis": newJObject(),
|
||||
"pending": session.pending,
|
||||
}
|
||||
|
|
@ -122,11 +142,12 @@ proc rateLimitError*(): ref RateLimitError =
|
|||
proc noSessionsError*(): ref NoSessionsError =
|
||||
newException(NoSessionsError, "no sessions available")
|
||||
|
||||
proc isLimited(session: Session; api: Api): bool =
|
||||
proc isLimited(session: Session; req: ApiReq): bool =
|
||||
if session.isNil:
|
||||
return true
|
||||
|
||||
if session.limited and api != Api.userTweets:
|
||||
let api = req.endpoint(session)
|
||||
if session.limited and api != graphUserTweetsV2:
|
||||
if (epochTime().int - session.limitedAt) > hourInSeconds:
|
||||
session.limited = false
|
||||
log "resetting limit: ", session.pretty
|
||||
|
|
@ -140,8 +161,8 @@ proc isLimited(session: Session; api: Api): bool =
|
|||
else:
|
||||
return false
|
||||
|
||||
proc isReady(session: Session; api: Api): bool =
|
||||
not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(api))
|
||||
proc isReady(session: Session; req: ApiReq): bool =
|
||||
not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(req))
|
||||
|
||||
proc invalidate*(session: var Session) =
|
||||
if session.isNil: return
|
||||
|
|
@ -156,24 +177,29 @@ proc release*(session: Session) =
|
|||
if session.isNil: return
|
||||
dec session.pending
|
||||
|
||||
proc getSession*(api: Api): Future[Session] {.async.} =
|
||||
proc getSession*(req: ApiReq): Future[Session] {.async.} =
|
||||
for i in 0 ..< sessionPool.len:
|
||||
if result.isReady(api): break
|
||||
if result.isReady(req): break
|
||||
result = sessionPool.sample()
|
||||
|
||||
if not result.isNil and result.isReady(api):
|
||||
if not result.isNil and result.isReady(req):
|
||||
inc result.pending
|
||||
else:
|
||||
log "no sessions available for API: ", api
|
||||
if result.isNil:
|
||||
log "no sessions available for API: ", req.cookie.endpoint
|
||||
else:
|
||||
log "no sessions available for API: ", req.endpoint(result), ", last tried: ", result.pretty
|
||||
raise noSessionsError()
|
||||
|
||||
proc setLimited*(session: Session; api: Api) =
|
||||
proc setLimited*(session: Session; req: ApiReq) =
|
||||
let api = req.endpoint(session)
|
||||
session.limited = true
|
||||
session.limitedAt = epochTime().int
|
||||
log "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", ", session.pretty
|
||||
|
||||
proc setRateLimit*(session: Session; api: Api; remaining, reset, limit: int) =
|
||||
proc setRateLimit*(session: Session; req: ApiReq; remaining, reset, limit: int) =
|
||||
# avoid undefined behavior in race conditions
|
||||
let api = req.endpoint(session)
|
||||
if api in session.apis:
|
||||
let rateLimit = session.apis[api]
|
||||
if rateLimit.reset >= reset and rateLimit.remaining < remaining:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
|
|||
proc getConfig*(path: string): (Config, parseCfg.Config) =
|
||||
var cfg = loadConfig(path)
|
||||
|
||||
let masterRss = cfg.get("Config", "enableRSS", true)
|
||||
|
||||
let conf = Config(
|
||||
# Server
|
||||
address: cfg.get("Server", "address", "0.0.0.0"),
|
||||
|
|
@ -37,10 +39,20 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
|
|||
hmacKey: cfg.get("Config", "hmacKey", "secretkey"),
|
||||
base64Media: cfg.get("Config", "base64Media", false),
|
||||
minTokens: cfg.get("Config", "tokenCount", 10),
|
||||
enableRss: cfg.get("Config", "enableRSS", true),
|
||||
enableRSSUserTweets: masterRss and cfg.get("Config", "enableRSSUserTweets", true),
|
||||
enableRSSUserReplies: masterRss and cfg.get("Config", "enableRSSUserReplies", true),
|
||||
enableRSSUserMedia: masterRss and cfg.get("Config", "enableRSSUserMedia", true),
|
||||
enableRSSUserArticles: masterRss and cfg.get("Config", "enableRSSUserArticles", true),
|
||||
enableRSSSearch: masterRss and cfg.get("Config", "enableRSSSearch", true),
|
||||
enableRSSList: masterRss and cfg.get("Config", "enableRSSList", true),
|
||||
enableDebug: cfg.get("Config", "enableDebug", false),
|
||||
proxy: cfg.get("Config", "proxy", ""),
|
||||
proxyAuth: cfg.get("Config", "proxyAuth", "")
|
||||
proxyAuth: cfg.get("Config", "proxyAuth", ""),
|
||||
apiProxy: cfg.get("Config", "apiProxy", ""),
|
||||
disableTid: cfg.get("Config", "disableTid", false),
|
||||
maxConcurrentReqs: cfg.get("Config", "maxConcurrentReqs", 2),
|
||||
maxRetries: cfg.get("Config", "maxRetries", 1),
|
||||
retryDelayMs: cfg.get("Config", "retryDelayMs", 150)
|
||||
)
|
||||
|
||||
return (conf, cfg)
|
||||
|
|
|
|||
256
src/consts.nim
256
src/consts.nim
|
|
@ -1,116 +1,108 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import uri, strutils
|
||||
import strutils
|
||||
|
||||
const
|
||||
consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
|
||||
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
|
||||
bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
|
||||
bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F"
|
||||
|
||||
gql = parseUri("https://api.x.com") / "graphql"
|
||||
graphUser* = "Gb-d6r0vxPOADdG62OEBpQ/UserByScreenName"
|
||||
graphUserV2* = "-ZzAG_Bckx16LMbEvHC3lg/UserResultByScreenNameQuery"
|
||||
graphUserById* = "xvmVfRLmnr1alc5f2dib0Q/UserByRestId"
|
||||
graphUserTweetsV2* = "LE3eTyeqhBh2g-fX85O2eQ/UserWithProfileTweetsQueryV2"
|
||||
graphUserTweetsAndRepliesV2* = "AcYHjc_YAx-9_rKWdMsKvA/UserWithProfileTweetsAndRepliesQueryV2"
|
||||
graphUserTweets* = "SXVCYB8XHSS25nzIljNtZA/UserTweets"
|
||||
graphUserTweetsAndReplies* = "qUpkZU6eN8MbtQb7rC_pYg/UserTweetsAndReplies"
|
||||
graphUserMedia* = "VyudDWQnr9vJNw7GasFz2g/UserMedia"
|
||||
graphUserMediaV2* = "WK111rbR0vM0ZX4lyZCYjw/MediaTimelineV2"
|
||||
graphUserArticles* = "ZmMjUyrTpwYfTGAdylEyMw/UserArticlesTweets"
|
||||
graphUserArticlesV2* = "PsGixN38UZz2RheyayNB5Q/UserProfileArticlesTimelineQuery"
|
||||
graphTweet* = "OZMbEnEa96AN8Pq6HyTWdw/ConversationTimeline"
|
||||
graphTweetDetail* = "XMOz5h24KAZ86qKffKTLdQ/TweetDetail"
|
||||
graphTweetResult* = "xYOrBQoTlfKJJPsX76MZEw/TweetResultByIdQuery"
|
||||
graphTweetEditHistory* = "1izbuOcH_QpuMcyCxOXkAg/TweetEditHistory"
|
||||
graphSearchTimeline* = "hyPfJYJ_XAtDYoslQc-Rgg/SearchTimeline"
|
||||
|
||||
graphUser* = gql / "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery"
|
||||
graphUserById* = gql / "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery"
|
||||
graphUserTweetsV2* = gql / "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2"
|
||||
graphUserTweetsAndRepliesV2* = gql / "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2"
|
||||
graphUserTweets* = gql / "oRJs8SLCRNRbQzuZG93_oA/UserTweets"
|
||||
graphUserTweetsAndReplies* = gql / "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
|
||||
graphUserMedia* = gql / "36oKqyQ7E_9CmtONGjJRsA/UserMedia"
|
||||
graphUserMediaV2* = gql / "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2"
|
||||
graphTweet* = gql / "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline"
|
||||
graphTweetDetail* = gql / "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail"
|
||||
graphTweetResult* = gql / "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery"
|
||||
graphSearchTimeline* = gql / "bshMIjqDk8LTXTq4w91WKw/SearchTimeline"
|
||||
graphListById* = gql / "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId"
|
||||
graphListBySlug* = gql / "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
|
||||
graphListMembers* = gql / "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
|
||||
graphListTweets* = gql / "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
|
||||
graphListById* = "niz0TtOxL2zIcbq6_NQiNw/ListByRestId"
|
||||
graphListBySlug* = "RqkWNDQpOntlxNtJa4RIoQ/ListBySlug"
|
||||
graphListMembers* = "8rYmkvWQe9jRRZdy_-vkGA/ListMembers"
|
||||
graphListTweets* = "0QJtcuMzVywHGAWD6Dtjlw/ListTimeline"
|
||||
graphAboutAccount* = "TzOG2twZEfhr9KmClvVVqA/AboutAccountQuery"
|
||||
|
||||
graphCommunity* = "-ElI1vg3dYbttVMhBhGdLw/CommunityQuery"
|
||||
graphCommunityTweets* = "EwftYyqQemkckQ0wzGM6uw/CommunityTweetsTimeline"
|
||||
graphCommunityMedia* = "ESJtwnI_apuGesbJncpc0Q/CommunityMediaTimeline"
|
||||
graphCommunityMembers* = "woAp_YdzAdqnWDrqLTNpAw/membersSliceTimeline_Query"
|
||||
graphCommunityModerators* = "0oYT9GRiWUhrz5xoqFE9uw/moderatorsSliceTimeline_Query"
|
||||
graphCommunityHashtags* = "D5EqomOIWeJnSkMhL-FLew/CommunityHashtagsTimeline"
|
||||
|
||||
graphTweetResultByRestId* = "GZsN2Pc4knAoit6pXa4HSA/TweetResultByRestId"
|
||||
graphTweetResultsByRestIds* = "Pho4sg8jLcrVlMeclMayrg/TweetResultsByRestIds"
|
||||
|
||||
graphBroadcast* = "RG6wSogandh6WPIzxW9aag/BroadcastQuery"
|
||||
graphAudioSpace* = "Bh0L6azTQoMs9rJKeCF4wQ/AudioSpaceById"
|
||||
restLiveStream* = "1.1/live_video_stream/status/"
|
||||
|
||||
graphFollowers* = "JNyQdTISpzCkj_1fqxDvFg/Followers"
|
||||
graphFollowing* = "qGZZDF3mp91q7X22s3HxpA/Following"
|
||||
|
||||
gqlFeatures* = """{
|
||||
"android_ad_formats_media_component_render_overlay_enabled": false,
|
||||
"android_graphql_skip_api_media_color_palette": false,
|
||||
"android_professional_link_spotlight_display_enabled": false,
|
||||
"blue_business_profile_image_shape_enabled": false,
|
||||
"commerce_android_shop_module_enabled": false,
|
||||
"creator_subscriptions_subscription_count_enabled": false,
|
||||
"creator_subscriptions_tweet_preview_api_enabled": true,
|
||||
"freedom_of_speech_not_reach_fetch_enabled": true,
|
||||
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
|
||||
"hidden_profile_likes_enabled": false,
|
||||
"highlights_tweets_tab_ui_enabled": false,
|
||||
"interactive_text_enabled": false,
|
||||
"longform_notetweets_consumption_enabled": true,
|
||||
"longform_notetweets_inline_media_enabled": true,
|
||||
"longform_notetweets_rich_text_read_enabled": true,
|
||||
"longform_notetweets_richtext_consumption_enabled": true,
|
||||
"mobile_app_spotlight_module_enabled": false,
|
||||
"responsive_web_edit_tweet_api_enabled": true,
|
||||
"responsive_web_enhance_cards_enabled": false,
|
||||
"responsive_web_graphql_exclude_directive_enabled": true,
|
||||
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
|
||||
"responsive_web_graphql_timeline_navigation_enabled": true,
|
||||
"responsive_web_media_download_video_enabled": false,
|
||||
"responsive_web_text_conversations_enabled": false,
|
||||
"responsive_web_twitter_article_tweet_consumption_enabled": true,
|
||||
"unified_cards_destination_url_params_enabled": false,
|
||||
"responsive_web_twitter_blue_verified_badge_is_enabled": true,
|
||||
"rweb_lists_timeline_redesign_enabled": true,
|
||||
"spaces_2022_h2_clipping": true,
|
||||
"spaces_2022_h2_spaces_communities": true,
|
||||
"standardized_nudges_misinfo": true,
|
||||
"subscriptions_verification_info_enabled": true,
|
||||
"subscriptions_verification_info_reason_enabled": true,
|
||||
"subscriptions_verification_info_verified_since_enabled": true,
|
||||
"super_follow_badge_privacy_enabled": false,
|
||||
"super_follow_exclusive_tweet_notifications_enabled": false,
|
||||
"super_follow_tweet_api_enabled": false,
|
||||
"super_follow_user_api_enabled": false,
|
||||
"tweet_awards_web_tipping_enabled": false,
|
||||
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
|
||||
"tweetypie_unmention_optimization_enabled": false,
|
||||
"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
|
||||
"rweb_video_screen_enabled": false,
|
||||
"rweb_cashtags_enabled": true,
|
||||
"profile_label_improvements_pcf_label_in_post_enabled": true,
|
||||
"responsive_web_profile_redirect_enabled": false,
|
||||
"rweb_tipjar_consumption_enabled": false,
|
||||
"verified_phone_label_enabled": false,
|
||||
"vibe_api_enabled": false,
|
||||
"view_counts_everywhere_api_enabled": true,
|
||||
"creator_subscriptions_tweet_preview_api_enabled": true,
|
||||
"responsive_web_graphql_timeline_navigation_enabled": true,
|
||||
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
|
||||
"premium_content_api_read_enabled": false,
|
||||
"communities_web_enable_tweet_community_results_fetch": true,
|
||||
"responsive_web_jetfuel_frame": true,
|
||||
"c9s_tweet_anatomy_moderator_badge_enabled": true,
|
||||
"c9s_list_members_action_api_enabled": false,
|
||||
"c9s_superc9s_indication_enabled": false,
|
||||
"responsive_web_grok_analyze_button_fetch_trends_enabled": false,
|
||||
"responsive_web_grok_analyze_post_followups_enabled": true,
|
||||
"rweb_cashtags_composer_attachment_enabled": true,
|
||||
"responsive_web_jetfuel_frame": true,
|
||||
"responsive_web_grok_share_attachment_enabled": true,
|
||||
"responsive_web_grok_annotations_enabled": true,
|
||||
"articles_preview_enabled": true,
|
||||
"responsive_web_edit_tweet_api_enabled": true,
|
||||
"rweb_conversational_replies_downvote_enabled": false,
|
||||
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
|
||||
"view_counts_everywhere_api_enabled": true,
|
||||
"longform_notetweets_consumption_enabled": true,
|
||||
"responsive_web_twitter_article_tweet_consumption_enabled": true,
|
||||
"content_disclosure_indicator_enabled": true,
|
||||
"content_disclosure_ai_generated_indicator_enabled": true,
|
||||
"responsive_web_grok_show_grok_translated_post": true,
|
||||
"responsive_web_grok_analysis_button_from_backend": true,
|
||||
"post_ctas_fetch_enabled": true,
|
||||
"freedom_of_speech_not_reach_fetch_enabled": true,
|
||||
"standardized_nudges_misinfo": true,
|
||||
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
|
||||
"longform_notetweets_rich_text_read_enabled": true,
|
||||
"longform_notetweets_inline_media_enabled": false,
|
||||
"responsive_web_grok_image_annotation_enabled": true,
|
||||
"responsive_web_grok_imagine_annotation_enabled": true,
|
||||
"rweb_tipjar_consumption_enabled": true,
|
||||
"profile_label_improvements_pcf_label_in_post_enabled": true,
|
||||
"creator_subscriptions_quote_tweet_preview_enabled": false,
|
||||
"c9s_tweet_anatomy_moderator_badge_enabled": true,
|
||||
"responsive_web_grok_analyze_post_followups_enabled": true,
|
||||
"rweb_video_timestamps_enabled": false,
|
||||
"responsive_web_grok_share_attachment_enabled": true,
|
||||
"articles_preview_enabled": true,
|
||||
"immersive_video_status_linkable_timestamps": false,
|
||||
"articles_api_enabled": false,
|
||||
"responsive_web_grok_analysis_button_from_backend": true,
|
||||
"rweb_video_screen_enabled": false,
|
||||
"payments_enabled": false,
|
||||
"responsive_web_profile_redirect_enabled": false,
|
||||
"responsive_web_grok_show_grok_translated_post": false,
|
||||
"responsive_web_grok_community_note_auto_translation_is_enabled": false,
|
||||
"profile_label_improvements_pcf_label_in_profile_enabled": false,
|
||||
"grok_android_analyze_trend_fetch_enabled": false,
|
||||
"grok_translations_community_note_auto_translation_is_enabled": false,
|
||||
"grok_translations_post_auto_translation_is_enabled": false,
|
||||
"grok_translations_community_note_translation_is_enabled": false,
|
||||
"grok_translations_timeline_user_bio_auto_translation_is_enabled": false
|
||||
"responsive_web_grok_community_note_auto_translation_is_enabled": true,
|
||||
"responsive_web_enhance_cards_enabled": false
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
tweetVariables* = """{
|
||||
tweetVars* = """{
|
||||
"postId": "$1",
|
||||
$2
|
||||
"ranking_mode": "$3",
|
||||
"includeHasBirdwatchNotes": false,
|
||||
"includePromotedContent": false,
|
||||
"withBirdwatchNotes": false,
|
||||
"withBirdwatchNotes": true,
|
||||
"withVoice": false,
|
||||
"withV2Timeline": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
tweetDetailVariables* = """{
|
||||
tweetDetailVars* = """{
|
||||
"focalTweetId": "$1",
|
||||
$2
|
||||
"referrer": "profile",
|
||||
|
|
@ -123,21 +115,26 @@ const
|
|||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
restIdVariables* = """{
|
||||
"rest_id": "$1", $2
|
||||
"count": 20
|
||||
}"""
|
||||
tweetEditHistoryVars* = """{
|
||||
"tweetId": "$1",
|
||||
"withQuickPromoteEligibilityTweetFields": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
userMediaVariables* = """{
|
||||
restIdVars* = """{
|
||||
"rest_id": "$1", $2
|
||||
"count": $3
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
userMediaVars* = """{
|
||||
"userId": "$1", $2
|
||||
"count": 20,
|
||||
"count": $3,
|
||||
"includePromotedContent": false,
|
||||
"withClientEventToken": false,
|
||||
"withBirdwatchNotes": false,
|
||||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
userTweetsVariables* = """{
|
||||
userTweetsVars* = """{
|
||||
"userId": "$1", $2
|
||||
"count": 20,
|
||||
"includePromotedContent": false,
|
||||
|
|
@ -145,7 +142,7 @@ const
|
|||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
userTweetsAndRepliesVariables* = """{
|
||||
userTweetsAndRepliesVars* = """{
|
||||
"userId": "$1", $2
|
||||
"count": 20,
|
||||
"includePromotedContent": false,
|
||||
|
|
@ -153,5 +150,70 @@ const
|
|||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
fieldToggles* = """{"withArticlePlainText":false}"""
|
||||
userArticlesVars* = """{
|
||||
"userId": "$1", $2
|
||||
"count": 20,
|
||||
"includePromotedContent": false,
|
||||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
articleVars* = """{
|
||||
"tweetId": "$1",
|
||||
"includePromotedContent": false,
|
||||
"withBirdwatchNotes": true,
|
||||
"withVoice": true,
|
||||
"withCommunity": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
articleBatchVars* = """{
|
||||
"tweetIds": $1,
|
||||
"includePromotedContent": false,
|
||||
"withBirdwatchNotes": true,
|
||||
"withVoice": true,
|
||||
"withCommunity": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
articleFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withArticleSummaryText":true,"withArticleVoiceOver":true}"""
|
||||
|
||||
tweetByRestIdVars* = """{
|
||||
"tweetId": "$1",
|
||||
"includePromotedContent": false,
|
||||
"withBirdwatchNotes": false,
|
||||
"withVoice": false,
|
||||
"withCommunity": false
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
userByRestIdVars* = """{
|
||||
"userId": "$1",
|
||||
"withSafetyModeUserFields": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
communityTweetsVars* = """{
|
||||
"communityId": "$1", $2
|
||||
"count": 20,
|
||||
"displayLocation": "Community",
|
||||
"rankingMode": "$3",
|
||||
"withCommunity": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
communityMediaVars* = """{
|
||||
"communityId": "$1", $2
|
||||
"count": 20,
|
||||
"withCommunity": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
communityMembersVars* = """{
|
||||
"communityId": "$1",
|
||||
"cursor": $2
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
communityHashtagsVars* = """{
|
||||
"communityId": "$1", $2
|
||||
"count": 20,
|
||||
"hashtags": ["$3"],
|
||||
"withCommunity": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
|
||||
userTweetsFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false}"""
|
||||
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
import parser/[user, graphql]
|
||||
export user, graphql
|
||||
import parser/[user, graphql, article]
|
||||
export user, graphql, article
|
||||
|
|
|
|||
88
src/experimental/parser/article.nim
Normal file
88
src/experimental/parser/article.nim
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import std/[strutils, tables, times, options]
|
||||
import jsony
|
||||
import utils, graphql, ../types/article
|
||||
from ../../types import Article, ArticleParagraph, ArticleEntity, ArticleMedia,
|
||||
User, TweetStats
|
||||
|
||||
proc parseGraphArticle*(json: string): Article =
|
||||
if json.len == 0 or json[0] != '{':
|
||||
return
|
||||
|
||||
var raw: GraphArticle
|
||||
try:
|
||||
raw = json.fromJson(GraphArticle)
|
||||
except CatchableError:
|
||||
return
|
||||
|
||||
let
|
||||
tweet = raw.data.tweetResult.result
|
||||
article = tweet.article.articleResults.result
|
||||
|
||||
if article.title.len == 0:
|
||||
return
|
||||
|
||||
let publishedAt = article.metadata.firstPublishedAtSecs
|
||||
var articleTime: DateTime
|
||||
if publishedAt > 0:
|
||||
articleTime = publishedAt.int64.fromUnix.utc
|
||||
elif tweet.legacy.createdAt.len > 0:
|
||||
articleTime = parseTwitterDate(tweet.legacy.createdAt)
|
||||
|
||||
result = Article(
|
||||
title: article.title,
|
||||
coverImage: getImageUrl(article.coverMedia.mediaInfo.originalImgUrl),
|
||||
time: articleTime,
|
||||
user: parseUserResult(tweet.core.userResults.result),
|
||||
)
|
||||
|
||||
result.stats = TweetStats(
|
||||
replies: tweet.legacy.replyCount,
|
||||
retweets: tweet.legacy.retweetCount,
|
||||
likes: tweet.legacy.favoriteCount,
|
||||
)
|
||||
if tweet.views.count.len > 0:
|
||||
try: result.stats.views = parseInt(tweet.views.count)
|
||||
except ValueError: discard
|
||||
|
||||
for blk in article.contentState.blocks:
|
||||
result.paragraphs.add ArticleParagraph(
|
||||
text: blk.text,
|
||||
kind: blk.blockKind,
|
||||
inlineStyles: blk.inlineStyleRanges,
|
||||
entityRanges: blk.entityRanges,
|
||||
)
|
||||
|
||||
for entry in article.contentState.entityMap:
|
||||
let key = try: parseInt(entry.key) except ValueError: continue
|
||||
var entity = ArticleEntity(kind: entry.value.entityKind)
|
||||
case entity.kind
|
||||
of "LINK": entity.url = entry.value.data.url
|
||||
of "MEDIA":
|
||||
for mi in entry.value.data.mediaItems:
|
||||
entity.mediaIds.add mi.mediaId
|
||||
entity.caption = entry.value.data.caption
|
||||
of "TWEET": entity.tweetId = entry.value.data.tweetId
|
||||
of "MARKDOWN": entity.markdown = entry.value.data.markdown
|
||||
else: discard
|
||||
result.entities[key] = entity
|
||||
|
||||
for me in article.mediaEntities:
|
||||
let typeName = me.mediaInfo.typeName
|
||||
var media = ArticleMedia(kind: typeName)
|
||||
if me.mediaInfo.videoInfo.isSome:
|
||||
let variants = me.mediaInfo.videoInfo.get.variants
|
||||
case typeName
|
||||
of "ApiGif":
|
||||
if variants.len > 0:
|
||||
media.url = variants[0].url
|
||||
of "ApiVideo":
|
||||
var bestBitrate = -1
|
||||
for v in variants:
|
||||
if v.bitrate > bestBitrate:
|
||||
bestBitrate = v.bitrate
|
||||
media.url = v.url
|
||||
else: discard
|
||||
elif typeName == "ApiImage":
|
||||
media.url = getImageUrl(me.mediaInfo.originalImgUrl)
|
||||
result.media[me.mediaId] = media
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import options, strutils
|
||||
import jsony
|
||||
import user, ../types/[graphuser, graphlistmembers]
|
||||
import user, utils, ../types/[graphuser, graphlistmembers, graphfollowers]
|
||||
from ../../types import User, VerifiedType, Result, Query, QueryKind
|
||||
|
||||
proc parseUserResult*(userResult: UserResult): User =
|
||||
|
|
@ -10,27 +10,61 @@ proc parseUserResult*(userResult: UserResult): User =
|
|||
result.verifiedType = blue
|
||||
|
||||
if result.username.len == 0 and userResult.core.screenName.len > 0:
|
||||
# Modern UserByRestId/UserByScreenName shape: `legacy` is empty and the data
|
||||
# lives in typed sub-objects (core/relationship_counts/tweet_counts/etc.).
|
||||
result.id = userResult.restId
|
||||
result.username = userResult.core.screenName
|
||||
result.fullname = userResult.core.name
|
||||
result.userPic = userResult.avatar.imageUrl.replace("_normal", "")
|
||||
|
||||
if userResult.banner.imageUrl.len > 0:
|
||||
result.banner = userResult.banner.imageUrl & "/1500x500"
|
||||
|
||||
if userResult.privacy.isSome:
|
||||
result.protected = userResult.privacy.get.protected
|
||||
|
||||
if userResult.location.isSome:
|
||||
result.location = userResult.location.get.location
|
||||
|
||||
if userResult.core.createdAt.len > 0:
|
||||
result.joinDate = parseTwitterDate(userResult.core.createdAt)
|
||||
|
||||
if userResult.verification.isSome:
|
||||
let v = userResult.verification.get
|
||||
if v.verifiedType != VerifiedType.none:
|
||||
result.verifiedType = v.verifiedType
|
||||
|
||||
if userResult.relationshipCounts.isSome:
|
||||
let rc = userResult.relationshipCounts.get
|
||||
result.followers = rc.followers
|
||||
result.following = rc.following
|
||||
|
||||
if userResult.tweetCounts.isSome:
|
||||
let tc = userResult.tweetCounts.get
|
||||
result.tweets = tc.tweets
|
||||
result.media = tc.mediaTweets
|
||||
|
||||
if userResult.actionCounts.isSome:
|
||||
result.likes = userResult.actionCounts.get.favoritesCount
|
||||
|
||||
if userResult.profileBio.isSome:
|
||||
result.bio = userResult.profileBio.get.description
|
||||
let bio = userResult.profileBio.get
|
||||
result.bio = bio.description
|
||||
result.expandUserEntities(bio.entities)
|
||||
|
||||
proc parseGraphUser*(json: string): User =
|
||||
if json.len == 0 or json[0] != '{':
|
||||
return
|
||||
|
||||
let raw = json.fromJson(GraphUser)
|
||||
let userResult = raw.data.userResult.result
|
||||
let
|
||||
raw = json.fromJson(GraphUser)
|
||||
userResult =
|
||||
if raw.data.userResult.isSome: raw.data.userResult.get.result
|
||||
elif raw.data.user.isSome: raw.data.user.get.result
|
||||
else: UserResult()
|
||||
|
||||
if userResult.unavailableReason.get("") == "Suspended":
|
||||
if userResult.unavailableReason.get("") == "Suspended" or
|
||||
userResult.reason.get("") == "Suspended":
|
||||
return User(suspended: true)
|
||||
|
||||
result = parseUserResult(userResult)
|
||||
|
|
@ -53,3 +87,25 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
|||
of TimelineTimelineCursor:
|
||||
if entry.content.cursorType == "Bottom":
|
||||
result.bottom = entry.content.value
|
||||
|
||||
proc parseGraphFollowers*(json, cursor: string; kind: QueryKind): Result[User] =
|
||||
result = Result[User](
|
||||
beginning: cursor.len == 0,
|
||||
query: Query(kind: kind)
|
||||
)
|
||||
|
||||
if json.len == 0 or json[0] != '{':
|
||||
return
|
||||
|
||||
let raw = json.fromJson(GraphFollowers)
|
||||
for instruction in raw.data.user.result.timeline.timeline.instructions:
|
||||
if instruction.kind == "TimelineAddEntries":
|
||||
for entry in instruction.entries:
|
||||
case entry.content.entryType
|
||||
of TimelineTimelineItem:
|
||||
let userResult = entry.content.itemContent.userResults.result
|
||||
if userResult.restId.len > 0:
|
||||
result.content.add parseUserResult(userResult)
|
||||
of TimelineTimelineCursor:
|
||||
if entry.content.cursorType == "Bottom":
|
||||
result.bottom = entry.content.value
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ proc replacedWith*(runes: seq[Rune]; repls: openArray[ReplaceSlice];
|
|||
let
|
||||
name = $runes[rep.slice.a.succ .. rep.slice.b]
|
||||
symbol = $runes[rep.slice.a]
|
||||
result.add a(symbol & name, href = "/search?q=%23" & name)
|
||||
result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name)
|
||||
of rkMention:
|
||||
result.add a($runes[rep.slice], href = rep.url, title = rep.display)
|
||||
of rkUrl:
|
||||
|
|
|
|||
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)
|
||||
|
|
@ -9,12 +9,10 @@ let
|
|||
unReplace = "$1<a href=\"/$2\">@$2</a>"
|
||||
|
||||
htRegex = nre.re"""(*U)(^|[^\w-_.?])([##$])([\w_]*+)(?!</a>|">|#)"""
|
||||
htReplace = "$1<a href=\"/search?q=%23$3\">$2$3</a>"
|
||||
htReplace = "$1<a href=\"/search?f=tweets&q=%23$3\">$2$3</a>"
|
||||
|
||||
proc expandUserEntities(user: var User; raw: RawUser) =
|
||||
let
|
||||
orig = user.bio.toRunes
|
||||
ent = raw.entities
|
||||
proc expandUserEntities*(user: var User; ent: Entities) =
|
||||
let orig = user.bio.toRunes
|
||||
|
||||
if ent.url.urls.len > 0:
|
||||
user.website = ent.url.urls[0].expandedUrl
|
||||
|
|
@ -58,15 +56,17 @@ proc toUser*(raw: RawUser): User =
|
|||
media: raw.mediaCount,
|
||||
verifiedType: raw.verifiedType,
|
||||
protected: raw.protected,
|
||||
joinDate: parseTwitterDate(raw.createdAt),
|
||||
banner: getBanner(raw),
|
||||
userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "")
|
||||
)
|
||||
|
||||
if raw.createdAt.len > 0:
|
||||
result.joinDate = parseTwitterDate(raw.createdAt)
|
||||
|
||||
if raw.pinnedTweetIdsStr.len > 0:
|
||||
result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0])
|
||||
|
||||
result.expandUserEntities(raw)
|
||||
result.expandUserEntities(raw.entities)
|
||||
|
||||
proc parseHook*(s: string; i: var int; v: var User) =
|
||||
var u: RawUser
|
||||
|
|
|
|||
79
src/experimental/types/article.nim
Normal file
79
src/experimental/types/article.nim
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import std/options
|
||||
import graphuser
|
||||
from ../../types import ArticleStyle, ArticleEntityRange
|
||||
|
||||
type
|
||||
GraphArticle* = object
|
||||
data*: tuple[tweetResult: tuple[result: TweetResultNode]]
|
||||
|
||||
TweetResultNode* = object
|
||||
article*: tuple[articleResults: tuple[result: ArticleResultNode]]
|
||||
legacy*: TweetLegacy
|
||||
core*: tuple[userResults: UserData]
|
||||
views*: tuple[count: string]
|
||||
|
||||
TweetLegacy* = object
|
||||
createdAt*: string
|
||||
replyCount*: int
|
||||
retweetCount*: int
|
||||
favoriteCount*: int
|
||||
|
||||
ArticleResultNode* = object
|
||||
title*: string
|
||||
coverMedia*: tuple[mediaInfo: MediaInfoNode]
|
||||
contentState*: ContentState
|
||||
metadata*: tuple[firstPublishedAtSecs: int]
|
||||
mediaEntities*: seq[RawMediaEntity]
|
||||
|
||||
ContentState* = object
|
||||
blocks*: seq[ContentBlock]
|
||||
entityMap*: seq[EntityMapEntry]
|
||||
|
||||
ContentBlock* = object
|
||||
text*: string
|
||||
blockKind*: string
|
||||
inlineStyleRanges*: seq[ArticleStyle]
|
||||
entityRanges*: seq[ArticleEntityRange]
|
||||
|
||||
EntityMapEntry* = object
|
||||
key*: string
|
||||
value*: EntityMapValue
|
||||
|
||||
EntityMapValue* = object
|
||||
entityKind*: string
|
||||
data*: EntityDataNode
|
||||
|
||||
EntityDataNode* = object
|
||||
url*: string
|
||||
mediaItems*: seq[tuple[mediaId: string]]
|
||||
tweetId*: string
|
||||
markdown*: string
|
||||
caption*: string
|
||||
|
||||
RawMediaEntity* = object
|
||||
mediaId*: string
|
||||
mediaInfo*: MediaInfoNode
|
||||
|
||||
MediaInfoNode* = object
|
||||
typeName*: string
|
||||
originalImgUrl*: string
|
||||
videoInfo*: Option[VideoInfoNode]
|
||||
|
||||
VideoInfoNode* = object
|
||||
variants*: seq[VideoVariant]
|
||||
|
||||
VideoVariant* = object
|
||||
url*: string
|
||||
bitrate*: int
|
||||
|
||||
proc renameHook*(v: var ContentBlock; fieldName: var string) =
|
||||
if fieldName == "type":
|
||||
fieldName = "blockKind"
|
||||
|
||||
proc renameHook*(v: var EntityMapValue; fieldName: var string) =
|
||||
if fieldName == "type":
|
||||
fieldName = "entityKind"
|
||||
|
||||
proc renameHook*(v: var MediaInfoNode; fieldName: var string) =
|
||||
if fieldName == "__typename":
|
||||
fieldName = "typeName"
|
||||
17
src/experimental/types/graphfollowers.nim
Normal file
17
src/experimental/types/graphfollowers.nim
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import graphlistmembers
|
||||
|
||||
type
|
||||
GraphFollowers* = object
|
||||
data*: tuple[user: UserWrapper]
|
||||
|
||||
UserWrapper = object
|
||||
result*: UserResultWrapper
|
||||
|
||||
UserResultWrapper = object
|
||||
timeline*: tuple[timeline: graphlistmembers.Timeline]
|
||||
|
||||
# Hook to normalize snake_case field from API to camelCase used by shared types
|
||||
proc renameHook*(v: var Content; fieldName: var string) =
|
||||
if fieldName == "user_results":
|
||||
fieldName = "userResults"
|
||||
|
|
@ -7,10 +7,10 @@ type
|
|||
List = object
|
||||
membersTimeline*: tuple[timeline: Timeline]
|
||||
|
||||
Timeline = object
|
||||
Timeline* = object
|
||||
instructions*: seq[Instruction]
|
||||
|
||||
Instruction = object
|
||||
Instruction* = object
|
||||
kind*: string
|
||||
entries*: seq[tuple[content: Content]]
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ type
|
|||
TimelineTimelineItem
|
||||
TimelineTimelineCursor
|
||||
|
||||
Content = object
|
||||
Content* = object
|
||||
case entryType*: ContentEntryType
|
||||
of TimelineTimelineItem:
|
||||
itemContent*: tuple[userResults: UserData]
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import options, strutils
|
||||
from ../../types import User, VerifiedType
|
||||
import user as userType # Entities, for modern profile_bio parsing
|
||||
|
||||
type
|
||||
GraphUser* = object
|
||||
data*: tuple[userResult: UserData]
|
||||
data*: tuple[userResult: Option[UserData], user: Option[UserData]]
|
||||
|
||||
UserData* = object
|
||||
result*: UserResult
|
||||
|
|
@ -15,6 +16,7 @@ type
|
|||
|
||||
UserBio* = object
|
||||
description*: string
|
||||
entities*: Entities
|
||||
|
||||
UserAvatar* = object
|
||||
imageUrl*: string
|
||||
|
|
@ -22,15 +24,41 @@ type
|
|||
Verification* = object
|
||||
verifiedType*: VerifiedType
|
||||
|
||||
Location* = object
|
||||
location*: string
|
||||
|
||||
Privacy* = object
|
||||
protected*: bool
|
||||
|
||||
# Modern UserByRestId/UserByScreenName shape moves the counts out of `legacy`
|
||||
# into these typed sub-objects.
|
||||
RelationshipCounts* = object
|
||||
followers*: int
|
||||
following*: int
|
||||
|
||||
TweetCounts* = object
|
||||
tweets*: int
|
||||
mediaTweets*: int
|
||||
|
||||
ActionCounts* = object
|
||||
favoritesCount*: int
|
||||
|
||||
UserResult* = object
|
||||
legacy*: User
|
||||
restId*: string
|
||||
isBlueVerified*: bool
|
||||
unavailableReason*: Option[string]
|
||||
core*: UserCore
|
||||
avatar*: UserAvatar
|
||||
banner*: UserAvatar
|
||||
unavailableReason*: Option[string]
|
||||
reason*: Option[string]
|
||||
privacy*: Option[Privacy]
|
||||
profileBio*: Option[UserBio]
|
||||
verification*: Option[Verification]
|
||||
location*: Option[Location]
|
||||
relationshipCounts*: Option[RelationshipCounts]
|
||||
tweetCounts*: Option[TweetCounts]
|
||||
actionCounts*: Option[ActionCounts]
|
||||
|
||||
proc enumHook*(s: string; v: var VerifiedType) =
|
||||
v = try:
|
||||
|
|
|
|||
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,12 +1,12 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen
|
||||
import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen, math
|
||||
import std/[enumerate, re]
|
||||
import types, utils, query
|
||||
|
||||
const
|
||||
cards = "cards.twitter.com/cards"
|
||||
tco = "https://t.co"
|
||||
twitter = parseUri("https://twitter.com")
|
||||
twitter = parseUri("https://x.com")
|
||||
|
||||
let
|
||||
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
|
||||
|
|
@ -59,25 +59,28 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
|
|||
result = body
|
||||
|
||||
if prefs.replaceYouTube.len > 0 and "youtu" in result:
|
||||
result = result.replace(ytRegex, prefs.replaceYouTube)
|
||||
let youtubeHost = strip(prefs.replaceYouTube, chars={'/'})
|
||||
result = result.replace(ytRegex, youtubeHost)
|
||||
|
||||
if prefs.replaceTwitter.len > 0:
|
||||
let twitterHost = strip(prefs.replaceTwitter, chars={'/'})
|
||||
if tco in result:
|
||||
result = result.replace(tco, https & prefs.replaceTwitter & "/t.co")
|
||||
result = result.replace(tco, https & twitterHost & "/t.co")
|
||||
if "x.com" in result:
|
||||
result = result.replace(xRegex, prefs.replaceTwitter)
|
||||
result = result.replace(xRegex, twitterHost)
|
||||
result = result.replacef(xLinkRegex, a(
|
||||
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
|
||||
twitterHost & "$2", href = https & twitterHost & "$1"))
|
||||
if "twitter.com" in result:
|
||||
result = result.replace(cards, prefs.replaceTwitter & "/cards")
|
||||
result = result.replace(twRegex, prefs.replaceTwitter)
|
||||
result = result.replace(cards, twitterHost & "/cards")
|
||||
result = result.replace(twRegex, twitterHost)
|
||||
result = result.replacef(twLinkRegex, a(
|
||||
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
|
||||
twitterHost & "$2", href = https & twitterHost & "$1"))
|
||||
|
||||
if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result):
|
||||
result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/")
|
||||
result = result.replace(rdRegex, prefs.replaceReddit)
|
||||
if prefs.replaceReddit in result and "/gallery/" in result:
|
||||
let redditHost = strip(prefs.replaceReddit, chars={'/'})
|
||||
result = result.replace(rdShortRegex, redditHost & "/comments/")
|
||||
result = result.replace(rdRegex, redditHost)
|
||||
if redditHost in result and "/gallery/" in result:
|
||||
result = result.replace("/gallery/", "/comments/")
|
||||
|
||||
if absolute.len > 0 and "href" in result:
|
||||
|
|
@ -88,7 +91,17 @@ proc getM3u8Url*(content: string): string =
|
|||
if re.find(content, m3u8Regex, matches) != -1:
|
||||
result = matches[0]
|
||||
|
||||
proc proxifyVideo*(manifest: string; proxy: bool): string =
|
||||
proc proxifyVideo*(manifest: string; proxy: bool; manifestUrl = ""): string =
|
||||
let (baseUrl, basePath) =
|
||||
if manifestUrl.len > 0:
|
||||
let
|
||||
u = parseUri(manifestUrl)
|
||||
origin = u.scheme & "://" & u.hostname
|
||||
idx = manifestUrl.rfind('/')
|
||||
dirPath = if idx > 8: manifestUrl[0 .. idx] else: ""
|
||||
(origin, dirPath)
|
||||
else:
|
||||
("https://video.twimg.com", "")
|
||||
var replacements: seq[(string, string)]
|
||||
for line in manifest.splitLines:
|
||||
let url =
|
||||
|
|
@ -96,9 +109,13 @@ proc proxifyVideo*(manifest: string; proxy: bool): string =
|
|||
elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line:
|
||||
line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))]
|
||||
else: line
|
||||
if url.startsWith('/'):
|
||||
let path = "https://video.twimg.com" & url
|
||||
replacements.add (url, if proxy: path.getVidUrl else: path)
|
||||
let resolved =
|
||||
if url.startsWith('/'): baseUrl & url
|
||||
elif basePath.len > 0 and url.len > 0 and not url.startsWith('#') and
|
||||
not url.startsWith("http") and ('.' in url): basePath & url
|
||||
else: ""
|
||||
if resolved.len > 0:
|
||||
replacements.add (url, if proxy: resolved.getVidUrl else: resolved)
|
||||
return manifest.multiReplace(replacements)
|
||||
|
||||
proc getUserPic*(userPic: string; style=""): string =
|
||||
|
|
@ -123,25 +140,30 @@ proc pageDesc*(user: User): string =
|
|||
"The latest tweets from " & user.fullname
|
||||
|
||||
proc getJoinDate*(user: User): string =
|
||||
if user.joinDate.year == 0: return ""
|
||||
user.joinDate.format("'Joined' MMMM YYYY")
|
||||
|
||||
proc getJoinDateFull*(user: User): string =
|
||||
if user.joinDate.year == 0: return ""
|
||||
user.joinDate.format("h:mm tt - d MMM YYYY")
|
||||
|
||||
proc getTime*(tweet: Tweet): string =
|
||||
if tweet.time.year == 0: return ""
|
||||
tweet.time.format("MMM d', 'YYYY' · 'h:mm tt' UTC'")
|
||||
|
||||
proc getRfc822Time*(tweet: Tweet): string =
|
||||
if tweet.time.year == 0: return ""
|
||||
tweet.time.format("ddd', 'dd MMM yyyy HH:mm:ss 'GMT'")
|
||||
|
||||
proc getShortTime*(tweet: Tweet): string =
|
||||
proc getShortTime*(time: DateTime): string =
|
||||
if time.year == 0: return ""
|
||||
let now = now()
|
||||
let since = now - tweet.time
|
||||
let since = now - time
|
||||
|
||||
if now.year != tweet.time.year:
|
||||
result = tweet.time.format("d MMM yyyy")
|
||||
if now.year != time.year:
|
||||
result = time.format("d MMM yyyy")
|
||||
elif since.inDays >= 1:
|
||||
result = tweet.time.format("MMM d")
|
||||
result = time.format("MMM d")
|
||||
elif since.inHours >= 1:
|
||||
result = $since.inHours & "h"
|
||||
elif since.inMinutes >= 1:
|
||||
|
|
@ -151,13 +173,33 @@ proc getShortTime*(tweet: Tweet): string =
|
|||
else:
|
||||
result = "now"
|
||||
|
||||
proc getShortTime*(tweet: Tweet): string =
|
||||
getShortTime(tweet.time)
|
||||
|
||||
proc getDuration*(ms: int): string =
|
||||
let
|
||||
sec = int(round(ms / 1000))
|
||||
min = floorDiv(sec, 60)
|
||||
hour = floorDiv(min, 60)
|
||||
if hour > 0:
|
||||
&"{hour}:{min mod 60:02}:{sec mod 60:02}"
|
||||
else:
|
||||
&"{min mod 60}:{sec mod 60:02}"
|
||||
|
||||
proc getDuration*(video: Video): string =
|
||||
getDuration(video.durationMs)
|
||||
|
||||
proc getLink*(id: int64; username="i"; focus=true): string =
|
||||
var username = username
|
||||
if username.len == 0:
|
||||
username = "i"
|
||||
result = &"/{username}/status/{id}"
|
||||
if focus: result &= "#m"
|
||||
|
||||
proc getLink*(tweet: Tweet; focus=true): string =
|
||||
if tweet.id == 0: return
|
||||
var username = tweet.user.username
|
||||
if username.len == 0:
|
||||
username = "i"
|
||||
result = &"/{username}/status/{tweet.id}"
|
||||
if focus: result &= "#m"
|
||||
return getLink(tweet.id, username, focus)
|
||||
|
||||
proc getTwitterLink*(path: string; params: Table[string, string]): string =
|
||||
var
|
||||
|
|
@ -185,7 +227,7 @@ proc getTwitterLink*(path: string; params: Table[string, string]): string =
|
|||
proc getLocation*(u: User | Tweet): (string, string) =
|
||||
if "://" in u.location: return (u.location, "")
|
||||
let loc = u.location.split(":")
|
||||
let url = if loc.len > 1: "/search?q=place:" & loc[1] else: ""
|
||||
let url = if loc.len > 1: "/search?f=tweets&q=place:" & loc[1] else: ""
|
||||
(loc[0], url)
|
||||
|
||||
proc getSuspended*(username: string): string =
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ proc release*(pool: HttpPool; client: AsyncHttpClient; badClient=false) =
|
|||
|
||||
proc acquire*(pool: HttpPool; heads: HttpHeaders): AsyncHttpClient =
|
||||
if pool.conns.len == 0:
|
||||
result = newAsyncHttpClient(headers=heads, proxy=proxy)
|
||||
result = newAsyncHttpClient(userAgent="", headers=heads, proxy=proxy)
|
||||
else:
|
||||
result = pool.conns.pop()
|
||||
result.headers = heads
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@
|
|||
import asyncdispatch, strformat, logging
|
||||
from net import Port
|
||||
from htmlgen import a
|
||||
from os import getEnv
|
||||
from os import getEnv, normalizedPath
|
||||
|
||||
import jester
|
||||
|
||||
import types, config, prefs, formatters, redis_cache, http_pool, auth
|
||||
import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
|
||||
import views/[general, about]
|
||||
import routes/[
|
||||
preferences, timeline, status, media, search, rss, list, debug,
|
||||
unsupported, embed, resolver, router_utils]
|
||||
preferences, timeline, status, media, search, rss, list, community, debug,
|
||||
unsupported, embed, resolver, broadcast, space, article, router_utils]
|
||||
|
||||
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
||||
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
||||
|
|
@ -34,39 +34,62 @@ stdout.flushFile
|
|||
updateDefaultPrefs(fullCfg)
|
||||
setCacheTimes(cfg)
|
||||
setHmacKey(cfg.hmacKey)
|
||||
if cfg.hmacKey.len == 0 or cfg.hmacKey == "secretkey":
|
||||
stderr.write "WARNING: insecure default 'hmacKey' in nitter.conf; " &
|
||||
"set a unique random value to stop media URL signatures being forgeable.\n"
|
||||
stderr.flushFile
|
||||
setProxyEncoding(cfg.base64Media)
|
||||
setMaxHttpConns(cfg.httpMaxConns)
|
||||
setHttpProxy(cfg.proxy, cfg.proxyAuth)
|
||||
setApiProxy(cfg.apiProxy)
|
||||
setDisableTid(cfg.disableTid)
|
||||
setMaxConcurrentReqs(cfg.maxConcurrentReqs)
|
||||
setMaxRetries(cfg.maxRetries)
|
||||
setRetryDelayMs(cfg.retryDelayMs)
|
||||
initAboutPage(cfg.staticDir)
|
||||
|
||||
waitFor initRedisPool(cfg)
|
||||
stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
|
||||
stdout.flushFile
|
||||
|
||||
createArticleRouter(cfg)
|
||||
createUnsupportedRouter(cfg)
|
||||
createResolverRouter(cfg)
|
||||
createPrefRouter(cfg)
|
||||
createTimelineRouter(cfg)
|
||||
createListRouter(cfg)
|
||||
createCommunityRouter(cfg)
|
||||
createStatusRouter(cfg)
|
||||
createSearchRouter(cfg)
|
||||
createMediaRouter(cfg)
|
||||
createEmbedRouter(cfg)
|
||||
createRssRouter(cfg)
|
||||
createBroadcastRouter(cfg)
|
||||
createSpaceRouter(cfg)
|
||||
createDebugRouter(cfg)
|
||||
|
||||
settings:
|
||||
port = Port(cfg.port)
|
||||
staticDir = cfg.staticDir
|
||||
staticDir = normalizedPath(cfg.staticDir)
|
||||
bindAddr = cfg.address
|
||||
reusePort = true
|
||||
maxBody = 64 * 1024
|
||||
|
||||
routes:
|
||||
before:
|
||||
# Reject malformed paths
|
||||
if request.path.len == 0 or request.path[0] != '/':
|
||||
halt Http400
|
||||
|
||||
# skip all file URLs (except Twitter widget compatibility)
|
||||
cond "." notin request.path or request.path == "/embed/Tweet.html"
|
||||
applyUrlPrefs()
|
||||
|
||||
get "/":
|
||||
resp renderMain(renderSearch(), request, cfg, themePrefs())
|
||||
resp renderMain(renderSearch(), request, cfg, requestPrefs())
|
||||
|
||||
get "/about":
|
||||
resp renderMain(renderAbout(), request, cfg, themePrefs())
|
||||
resp renderMain(renderAbout(), request, cfg, requestPrefs())
|
||||
|
||||
get "/explore":
|
||||
redirect("/about")
|
||||
|
|
@ -77,7 +100,7 @@ routes:
|
|||
get "/i/redirect":
|
||||
let url = decodeUrl(@"url")
|
||||
if url.len == 0: resp Http404
|
||||
redirect(replaceUrls(url, cookiePrefs()))
|
||||
redirect(replaceUrls(url, requestPrefs()))
|
||||
|
||||
error Http404:
|
||||
resp Http404, showError("Page not found", cfg)
|
||||
|
|
@ -102,14 +125,18 @@ routes:
|
|||
resp Http429, showError(
|
||||
&"Instance has no auth tokens, or is fully rate limited.<br>Use {link} or try again later.", cfg)
|
||||
|
||||
extend articleRoute, ""
|
||||
extend rss, ""
|
||||
extend status, ""
|
||||
extend search, ""
|
||||
extend timeline, ""
|
||||
extend media, ""
|
||||
extend list, ""
|
||||
extend community, ""
|
||||
extend preferences, ""
|
||||
extend resolver, ""
|
||||
extend embed, ""
|
||||
extend broadcastRoute, ""
|
||||
extend spaceRoute, ""
|
||||
extend debug, ""
|
||||
extend unsupported, ""
|
||||
|
|
|
|||
615
src/parser.nim
615
src/parser.nim
|
|
@ -1,10 +1,20 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, options, times, math, tables
|
||||
import strutils, options, times, math, tables, uri
|
||||
import packedjson, packedjson/deserialiser
|
||||
import types, parserutils, utils
|
||||
import experimental/parser/unifiedcard
|
||||
|
||||
proc parseGraphTweet(js: JsonNode): Tweet
|
||||
proc parseGraphTweet*(js: JsonNode): Tweet
|
||||
|
||||
proc parseVerifiedType(s: string; current: VerifiedType): VerifiedType =
|
||||
try: parseEnum[VerifiedType](s)
|
||||
except ValueError: current
|
||||
|
||||
proc parseCommunityNote(js: JsonNode): string =
|
||||
let subtitle = js{"subtitle"}
|
||||
result = subtitle{"text"}.getStr
|
||||
with entities, subtitle{"entities"}:
|
||||
result = expandBirdwatchEntities(result, entities)
|
||||
|
||||
proc parseUser(js: JsonNode; id=""): User =
|
||||
if js.isNull: return
|
||||
|
|
@ -21,7 +31,7 @@ proc parseUser(js: JsonNode; id=""): User =
|
|||
tweets: js{"statuses_count"}.getInt,
|
||||
likes: js{"favourites_count"}.getInt,
|
||||
media: js{"media_count"}.getInt,
|
||||
protected: js{"protected"}.getBool,
|
||||
protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool),
|
||||
joinDate: js{"created_at"}.getTime
|
||||
)
|
||||
|
||||
|
|
@ -29,17 +39,17 @@ proc parseUser(js: JsonNode; id=""): User =
|
|||
result.verifiedType = blue
|
||||
|
||||
with verifiedType, js{"verified_type"}:
|
||||
result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
|
||||
result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType)
|
||||
|
||||
result.expandUserEntities(js)
|
||||
|
||||
proc parseGraphUser(js: JsonNode): User =
|
||||
var user = js{"user_result", "result"}
|
||||
if user.isNull:
|
||||
user = ? js{"user_results", "result"}
|
||||
user = js{"user_results", "result"}
|
||||
|
||||
if user.isNull:
|
||||
if js{"core"}.notNull and js{"legacy"}.notNull:
|
||||
if js{"core"}.notNull:
|
||||
user = js
|
||||
else:
|
||||
return
|
||||
|
|
@ -51,15 +61,164 @@ proc parseGraphUser(js: JsonNode): User =
|
|||
|
||||
# fallback to support UserMedia/recent GraphQL updates
|
||||
if result.username.len == 0:
|
||||
result.id = user{"rest_id"}.getStr
|
||||
result.username = user{"core", "screen_name"}.getStr
|
||||
result.fullname = user{"core", "name"}.getStr
|
||||
result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "")
|
||||
|
||||
if user{"is_blue_verified"}.getBool(false):
|
||||
if user{"is_blue_verified"}.getBool(
|
||||
user{"verification", "is_blue_verified"}.getBool(false)):
|
||||
result.verifiedType = blue
|
||||
|
||||
with verifiedType, user{"verification", "verified_type"}:
|
||||
result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
|
||||
result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType)
|
||||
|
||||
proc parseAboutAccount*(js: JsonNode): AccountInfo =
|
||||
if js.isNull: return
|
||||
|
||||
let user = ? js{"data", "user_result_by_screen_name", "result"}
|
||||
|
||||
if user{"unavailable_reason"}.getStr == "Suspended":
|
||||
result.suspended = true
|
||||
return
|
||||
|
||||
result = AccountInfo(
|
||||
username: user{"core", "screen_name"}.getStr,
|
||||
fullname: user{"core", "name"}.getStr,
|
||||
joinDate: user{"core", "created_at"}.getTime,
|
||||
userPic: user{"avatar", "image_url"}.getImageStr.replace("_normal", ""),
|
||||
affiliateLabel: user{"identity_profile_labels_highlighted_label", "label", "description"}.getStr,
|
||||
)
|
||||
|
||||
if user{"is_blue_verified"}.getBool(false):
|
||||
result.verifiedType = blue
|
||||
with verifiedType, user{"verification", "verified_type"}:
|
||||
result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType)
|
||||
|
||||
with about, user{"about_profile"}:
|
||||
result.basedIn = about{"account_based_in"}.getStr
|
||||
result.source = about{"source"}.getStr
|
||||
result.affiliateUsername = about{"affiliate_username"}.getStr
|
||||
|
||||
try:
|
||||
result.usernameChanges = about{"username_changes", "count"}.getStr("0").parseInt
|
||||
except ValueError:
|
||||
discard
|
||||
|
||||
with lastChange, about{"username_changes", "last_changed_at_msec"}:
|
||||
result.lastUsernameChange = lastChange.getTimeFromMsStr
|
||||
|
||||
with info, user{"verification_info"}:
|
||||
result.isIdentityVerified = info{"is_identity_verified"}.getBool
|
||||
with reason, info{"reason"}:
|
||||
result.overrideVerifiedYear = reason{"override_verified_year"}.getInt
|
||||
with since, reason{"verified_since_msec"}:
|
||||
result.verifiedSince = since.getTimeFromMsStr
|
||||
|
||||
proc parseBroadcastInfo*(js: JsonNode): Broadcast =
|
||||
let bc = ? js{"data", "broadcast"}
|
||||
result = Broadcast(
|
||||
id: bc{"broadcast_id"}.getStr,
|
||||
title: bc{"status"}.getStr,
|
||||
state: bc{"state"}.getStr.toUpperAscii,
|
||||
thumb: bc{"image_url"}.getStr,
|
||||
mediaKey: bc{"media_key"}.getStr,
|
||||
totalWatched: bc{"total_watched"}.getInt,
|
||||
startTime: bc{"start_time"}.getTimeFromMs,
|
||||
endTime: bc{"end_time"}.getTimeFromMs,
|
||||
replayStart: bc{"edited_replay", "start_time"}.getInt,
|
||||
availableForReplay: bc{"available_for_replay"}.getBool,
|
||||
user: parseGraphUser(bc)
|
||||
)
|
||||
|
||||
proc parseSpaceParticipant(js: JsonNode): SpaceParticipant =
|
||||
result = SpaceParticipant(
|
||||
userId: js{"user_results", "rest_id"}.getStr,
|
||||
username: js{"twitter_screen_name"}.getStr,
|
||||
displayName: js{"display_name"}.getStr,
|
||||
avatarUrl: js{"avatar_url"}.getStr,
|
||||
isVerified: js{"is_verified"}.getBool or
|
||||
js{"user_results", "result", "is_blue_verified"}.getBool
|
||||
)
|
||||
|
||||
proc parseAudioSpace*(js: JsonNode): AudioSpace =
|
||||
let space = ? js{"data", "audioSpace"}
|
||||
let meta = space{"metadata"}
|
||||
|
||||
result = AudioSpace(
|
||||
id: meta{"rest_id"}.getStr,
|
||||
title: meta{"title"}.getStr,
|
||||
state: meta{"state"}.getStr.toUpperAscii,
|
||||
mediaKey: meta{"media_key"}.getStr,
|
||||
totalLiveListeners: meta{"total_live_listeners"}.getInt,
|
||||
totalReplayWatched: meta{"total_replay_watched"}.getInt,
|
||||
availableForReplay: meta{"is_space_available_for_replay"}.getBool
|
||||
)
|
||||
|
||||
let startedAt = meta{"started_at"}.getInt(0)
|
||||
if startedAt > 0:
|
||||
result.startTime = fromUnix(startedAt div 1000).utc()
|
||||
|
||||
let endedAtStr = meta{"ended_at"}.getStr
|
||||
if endedAtStr.len > 0:
|
||||
try:
|
||||
let endedAt = parseBiggestInt(endedAtStr)
|
||||
if endedAt > 0:
|
||||
result.endTime = fromUnix(endedAt div 1000).utc()
|
||||
except ValueError:
|
||||
discard
|
||||
|
||||
result.creator = parseGraphUser(meta{"creator_results", "result"})
|
||||
|
||||
for admin in space{"participants", "admins"}:
|
||||
result.admins.add parseSpaceParticipant(admin)
|
||||
|
||||
for speaker in space{"participants", "speakers"}:
|
||||
result.speakers.add parseSpaceParticipant(speaker)
|
||||
|
||||
proc parseGraphCommunity*(js: JsonNode): Community =
|
||||
if js.isNull: return
|
||||
let c = ? js{"data", "communityResults", "result"}
|
||||
|
||||
result = Community(
|
||||
id: c{"rest_id"}.getStr(c{"id_str"}.getStr),
|
||||
name: c{"name"}.getStr,
|
||||
description: c{"description"}.getStr,
|
||||
memberCount: c{"member_count"}.getInt,
|
||||
joinPolicy: c{"join_policy"}.getStr,
|
||||
category: c{"primary_community_topic", "topic_name"}.getStr,
|
||||
banner: c{"custom_banner_media", "media_info", "original_img_url"}.getImageStr,
|
||||
creator: parseGraphUser(c{"creator_results", "result"}),
|
||||
)
|
||||
|
||||
let createdMs = c{"created_at"}.getInt(0)
|
||||
if createdMs > 0:
|
||||
result.createdAt = fromUnix(createdMs div 1000).utc()
|
||||
|
||||
for rule in c{"rules"}:
|
||||
result.rules.add CommunityRule(
|
||||
name: rule{"name"}.getStr,
|
||||
description: rule{"description"}.getStr
|
||||
)
|
||||
|
||||
for item in c{"trending_hashtags_slice", "items"}:
|
||||
let tag = item{"hashtag"}.getStr
|
||||
if tag.len > 0:
|
||||
result.hashtags.add tag
|
||||
|
||||
proc parseListObject(js: JsonNode; owner: User): List =
|
||||
List(
|
||||
id: js{"id_str"}.getStr,
|
||||
name: js{"name"}.getStr,
|
||||
username: owner.username,
|
||||
userId: owner.id,
|
||||
description: js{"description"}.getStr,
|
||||
members: js{"member_count"}.getInt,
|
||||
banner: select(
|
||||
js{"custom_banner_media", "media_info", "original_img_url"},
|
||||
js{"default_banner_media", "media_info", "original_img_url"}
|
||||
).getImageStr
|
||||
)
|
||||
|
||||
proc parseGraphList*(js: JsonNode): List =
|
||||
if js.isNull: return
|
||||
|
|
@ -70,15 +229,17 @@ proc parseGraphList*(js: JsonNode): List =
|
|||
if list.isNull:
|
||||
return
|
||||
|
||||
result = List(
|
||||
id: list{"id_str"}.getStr,
|
||||
name: list{"name"}.getStr,
|
||||
username: list{"user_results", "result", "legacy", "screen_name"}.getStr,
|
||||
userId: list{"user_results", "result", "rest_id"}.getStr,
|
||||
description: list{"description"}.getStr,
|
||||
members: list{"member_count"}.getInt,
|
||||
banner: list{"custom_banner_media", "media_info", "original_img_url"}.getImageStr
|
||||
result = parseListObject(list, parseGraphUser(list))
|
||||
|
||||
proc parseGraphSearchList(js: JsonNode): ListSearchResult =
|
||||
let owner = parseGraphUser(js)
|
||||
result = ListSearchResult(
|
||||
list: parseListObject(js, owner),
|
||||
owner: owner,
|
||||
followersContext: js{"followers_context"}.getStr
|
||||
)
|
||||
for url in js{"facepile_urls"}:
|
||||
result.facepiles.add url.getStr
|
||||
|
||||
proc parsePoll(js: JsonNode): Poll =
|
||||
let vals = js{"binding_values"}
|
||||
|
|
@ -134,60 +295,85 @@ proc parseVideo(js: JsonNode): Video =
|
|||
|
||||
result.variants = parseVideoVariants(js{"video_info", "variants"})
|
||||
|
||||
proc addMedia(media: var MediaEntities; photo: Photo) =
|
||||
media.add Media(kind: photoMedia, photo: photo)
|
||||
|
||||
proc addMedia(media: var MediaEntities; video: Video) =
|
||||
media.add Media(kind: videoMedia, video: video)
|
||||
|
||||
proc addMedia(media: var MediaEntities; gif: Gif) =
|
||||
media.add Media(kind: gifMedia, gif: gif)
|
||||
|
||||
proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
|
||||
with jsMedia, js{"extended_entities", "media"}:
|
||||
for m in jsMedia:
|
||||
case m.getTypeName:
|
||||
of "photo":
|
||||
result.photos.add m{"media_url_https"}.getImageStr
|
||||
result.media.addMedia(Photo(
|
||||
url: m{"media_url_https"}.getImageStr,
|
||||
altText: m{"ext_alt_text"}.getStr
|
||||
))
|
||||
of "video":
|
||||
result.video = some(parseVideo(m))
|
||||
result.media.addMedia(parseVideo(m))
|
||||
with user, m{"additional_media_info", "source_user"}:
|
||||
if user{"id"}.getInt > 0:
|
||||
result.attribution = some(parseUser(user))
|
||||
else:
|
||||
result.attribution = some(parseGraphUser(user))
|
||||
# Set attribution link from expanded_url (strip /video/N suffix)
|
||||
let expanded = m{"expanded_url"}.getStr
|
||||
if expanded.len > 0:
|
||||
result.attributionLink = expanded.parseUri.path.replace("/video/1", "")
|
||||
of "animated_gif":
|
||||
result.gif = some Gif(
|
||||
result.media.addMedia(Gif(
|
||||
url: m{"video_info", "variants"}[0]{"url"}.getImageStr,
|
||||
thumb: m{"media_url_https"}.getImageStr
|
||||
)
|
||||
thumb: m{"media_url_https"}.getImageStr,
|
||||
altText: m{"ext_alt_text"}.getStr
|
||||
))
|
||||
else: discard
|
||||
|
||||
with url, m{"url"}:
|
||||
if result.text.endsWith(url.getStr):
|
||||
result.text.removeSuffix(url.getStr)
|
||||
result.text = result.text.strip()
|
||||
|
||||
proc parseMediaEntities(js: JsonNode; result: var Tweet) =
|
||||
with mediaEntities, js{"media_entities"}:
|
||||
var parsedMedia: MediaEntities
|
||||
for mediaEntity in mediaEntities:
|
||||
with mediaInfo, mediaEntity{"media_results", "result", "media_info"}:
|
||||
case mediaInfo.getTypeName
|
||||
of "ApiImage":
|
||||
result.photos.add mediaInfo{"original_img_url"}.getImageStr
|
||||
parsedMedia.addMedia(Photo(
|
||||
url: mediaInfo{"original_img_url"}.getImageStr,
|
||||
altText: mediaInfo{"alt_text"}.getStr
|
||||
))
|
||||
of "ApiVideo":
|
||||
let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"}
|
||||
result.video = some Video(
|
||||
parsedMedia.addMedia(Video(
|
||||
available: status.getStr == "Available",
|
||||
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
|
||||
title: mediaInfo{"alt_text"}.getStr,
|
||||
durationMs: mediaInfo{"duration_millis"}.getInt,
|
||||
variants: parseVideoVariants(mediaInfo{"variants"})
|
||||
)
|
||||
))
|
||||
|
||||
# Parse source user for video attribution
|
||||
with sourceUser, mediaEntity{"source_user_results", "result"}:
|
||||
if result.attribution.isNone:
|
||||
let expanded = mediaEntity{"expanded_url"}.getStr
|
||||
if expanded.len > 0:
|
||||
result.attributionLink = expanded.parseUri.path.replace("/video/1", "")
|
||||
result.attribution = some(User(
|
||||
id: sourceUser{"rest_id"}.getStr,
|
||||
fullname: sourceUser{"core", "name"}.getStr,
|
||||
userPic: sourceUser{"avatar", "image_url"}.getImageStr.replace("_normal", "")
|
||||
))
|
||||
of "ApiGif":
|
||||
result.gif = some Gif(
|
||||
parsedMedia.addMedia(Gif(
|
||||
url: mediaInfo{"variants"}[0]{"url"}.getImageStr,
|
||||
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr
|
||||
)
|
||||
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
|
||||
altText: mediaInfo{"alt_text"}.getStr
|
||||
))
|
||||
else: discard
|
||||
|
||||
# Remove media URLs from text
|
||||
with mediaList, js{"legacy", "entities", "media"}:
|
||||
for url in mediaList:
|
||||
let expandedUrl = url{"expanded_url"}.getStr
|
||||
if result.text.endsWith(expandedUrl):
|
||||
result.text.removeSuffix(expandedUrl)
|
||||
result.text = result.text.strip()
|
||||
if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len:
|
||||
result.media = parsedMedia
|
||||
|
||||
proc parsePromoVideo(js: JsonNode): Video =
|
||||
result = Video(
|
||||
|
|
@ -210,14 +396,23 @@ proc parsePromoVideo(js: JsonNode): Video =
|
|||
result.variants.add variant
|
||||
|
||||
proc parseBroadcast(js: JsonNode): Card =
|
||||
let image = js{"broadcast_thumbnail_large"}.getImageVal
|
||||
let
|
||||
image = js{"broadcast_thumbnail_large"}.getImageVal
|
||||
broadcastUrl = js{"broadcast_url"}.getStrVal
|
||||
broadcastId = broadcastUrl.rsplit('/', maxsplit=1)[^1]
|
||||
streamUrl = "/i/broadcasts/" & broadcastId & "/stream"
|
||||
result = Card(
|
||||
kind: broadcast,
|
||||
url: js{"broadcast_url"}.getStrVal,
|
||||
url: "/i/broadcasts/" & broadcastId,
|
||||
title: js{"broadcaster_display_name"}.getStrVal,
|
||||
text: js{"broadcast_title"}.getStrVal,
|
||||
image: image,
|
||||
video: some Video(thumb: image)
|
||||
video: some Video(
|
||||
thumb: image,
|
||||
available: true,
|
||||
playbackType: m3u8,
|
||||
variants: @[VideoVariant(contentType: m3u8, url: streamUrl)]
|
||||
)
|
||||
)
|
||||
|
||||
proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
||||
|
|
@ -256,7 +451,13 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
|||
result.url = vals{"player_url"}.getStrVal
|
||||
if "youtube.com" in result.url:
|
||||
result.url = result.url.replace("/embed/", "/watch?v=")
|
||||
of audiospace, unknown:
|
||||
of audiospace:
|
||||
let spaceId = vals{"id"}.getStrVal
|
||||
if spaceId.len > 0:
|
||||
result.url = "/i/spaces/" & spaceId
|
||||
result.title = "Twitter Space"
|
||||
result.text = "Click to view Space"
|
||||
of unknown:
|
||||
result.title = "This card type is not supported."
|
||||
else: discard
|
||||
|
||||
|
|
@ -267,7 +468,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
|||
|
||||
for u in ? urls:
|
||||
if u{"url"}.getStr == result.url:
|
||||
result.url = u{"expanded_url"}.getStr
|
||||
result.url = u.getExpandedUrl(result.url)
|
||||
break
|
||||
|
||||
if kind in {videoDirectMessage, imageDirectMessage}:
|
||||
|
|
@ -277,8 +478,9 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
|||
result.url.len == 0 or result.url.startsWith("card://"):
|
||||
result.url = getPicUrl(result.image)
|
||||
|
||||
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
||||
if js.isNull: return
|
||||
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
|
||||
replyId: int64 = 0; hasArticle = false): Tweet =
|
||||
if js.isNull: return Tweet()
|
||||
|
||||
let time =
|
||||
if js{"created_at"}.notNull: js{"created_at"}.getTime
|
||||
|
|
@ -301,6 +503,9 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||
)
|
||||
)
|
||||
|
||||
if result.replyId == 0:
|
||||
result.replyId = replyId
|
||||
|
||||
# fix for pinned threads
|
||||
if result.hasThread and result.threadId == 0:
|
||||
result.threadId = js{"self_thread", "id_str"}.getId
|
||||
|
|
@ -318,13 +523,13 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||
# graphql
|
||||
with rt, js{"retweeted_status_result", "result"}:
|
||||
# needed due to weird edgecase where the actual tweet data isn't included
|
||||
if "legacy" in rt:
|
||||
if "legacy" in rt or "rest_id" in rt:
|
||||
result.retweet = some parseGraphTweet(rt)
|
||||
return
|
||||
|
||||
with reposts, js{"repostedStatusResults"}:
|
||||
with rt, reposts{"result"}:
|
||||
if "legacy" in rt:
|
||||
if "legacy" in rt or "rest_id" in rt:
|
||||
result.retweet = some parseGraphTweet(rt)
|
||||
return
|
||||
|
||||
|
|
@ -332,15 +537,17 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||
let name = jsCard{"name"}.getStr
|
||||
if "poll" in name:
|
||||
if "image" in name:
|
||||
result.photos.add jsCard{"binding_values", "image_large"}.getImageVal
|
||||
result.media.addMedia(Photo(
|
||||
url: jsCard{"binding_values", "image_large"}.getImageVal
|
||||
))
|
||||
|
||||
result.poll = some parsePoll(jsCard)
|
||||
elif name == "amplify":
|
||||
result.video = some(parsePromoVideo(jsCard{"binding_values"}))
|
||||
else:
|
||||
result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
|
||||
elif name.len > 0 and jsCard{"binding_values"}.notNull:
|
||||
result.card = some parseCard(jsCard, js{"entities", "urls"})
|
||||
|
||||
result.expandTweetEntities(js)
|
||||
result.expandTweetEntities(js, hasArticle)
|
||||
parseLegacyMediaEntities(js, result)
|
||||
|
||||
with jsWithheld, js{"withheld_in_countries"}:
|
||||
|
|
@ -357,7 +564,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||
result.text.removeSuffix(" Learn more.")
|
||||
result.available = false
|
||||
|
||||
proc parseGraphTweet(js: JsonNode): Tweet =
|
||||
proc parseGraphTweet*(js: JsonNode): Tweet =
|
||||
if js.kind == JNull:
|
||||
return Tweet()
|
||||
|
||||
|
|
@ -375,7 +582,7 @@ proc parseGraphTweet(js: JsonNode): Tweet =
|
|||
else:
|
||||
discard
|
||||
|
||||
if not js.hasKey("legacy"):
|
||||
if "legacy" notin js and "rest_id" notin js:
|
||||
return Tweet()
|
||||
|
||||
var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"})
|
||||
|
|
@ -394,12 +601,80 @@ proc parseGraphTweet(js: JsonNode): Tweet =
|
|||
"binding_values": %bindingObj
|
||||
}
|
||||
|
||||
result = parseTweet(js{"legacy"}, jsCard)
|
||||
result.id = js{"rest_id"}.getId
|
||||
var replyId: int64 = 0
|
||||
with restId, js{"reply_to_results", "rest_id"}:
|
||||
replyId = restId.getId
|
||||
|
||||
let hasArticle = js{"article", "article_results", "result", "title"}.getStr.len > 0
|
||||
|
||||
if "details" in js:
|
||||
result = Tweet(
|
||||
id: js{"rest_id"}.getId,
|
||||
available: true,
|
||||
text: js{"details", "full_text"}.getStr,
|
||||
time: js{"details", "created_at_ms"}.getTimeFromMs,
|
||||
replyId: js{"reply_to_results", "rest_id"}.getId,
|
||||
isAd: js{"content_disclosure", "advertising_disclosure", "is_paid_promotion"}.getBool,
|
||||
isAI: js{"content_disclosure", "ai_generated_disclosure", "has_ai_generated_media"}.getBool,
|
||||
stats: TweetStats(
|
||||
replies: js{"counts", "reply_count"}.getInt,
|
||||
retweets: js{"counts", "retweet_count"}.getInt,
|
||||
likes: js{"counts", "favorite_count"}.getInt,
|
||||
)
|
||||
)
|
||||
|
||||
if jsCard.kind != JNull:
|
||||
let name = jsCard{"name"}.getStr
|
||||
if "poll" in name:
|
||||
if "image" in name:
|
||||
result.media.addMedia(Photo(
|
||||
url: jsCard{"binding_values", "image_large"}.getImageVal
|
||||
))
|
||||
|
||||
result.poll = some parsePoll(jsCard)
|
||||
elif name == "amplify":
|
||||
result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
|
||||
elif name.len > 0 and jsCard{"binding_values"}.notNull:
|
||||
result.card = some parseCard(jsCard, js{"url_entities"})
|
||||
|
||||
parseMediaEntities(js, result)
|
||||
if result.attribution.isNone:
|
||||
parseLegacyMediaEntities(js{"legacy"}, result)
|
||||
|
||||
result.expandTweetEntitiesV2(js, hasArticle)
|
||||
|
||||
# Strip video source URL from text (for videos from other tweets)
|
||||
with mediaEntities, js{"media_entities"}:
|
||||
for m in mediaEntities:
|
||||
if "source_status_id_str" in m:
|
||||
let mediaUrl = m{"url"}.getStr
|
||||
if mediaUrl.len > 0:
|
||||
let idx = result.text.rfind(mediaUrl)
|
||||
if idx >= 0:
|
||||
result.text = result.text[0 ..< idx].strip()
|
||||
break
|
||||
else:
|
||||
result = parseTweet(js{"legacy"}, jsCard, replyId, hasArticle)
|
||||
result.id = js{"rest_id"}.getId
|
||||
|
||||
with artNode, js{"article", "article_results", "result"}:
|
||||
let artTitle = artNode{"title"}.getStr
|
||||
if artTitle.len > 0:
|
||||
result.articlePreview = some ArticlePreview(
|
||||
title: artTitle,
|
||||
previewText: artNode{"preview_text"}.getStr,
|
||||
coverImage: select(
|
||||
artNode{"cover_media_results", "result", "media_info", "original_img_url"},
|
||||
artNode{"cover_media", "media_info", "original_img_url"}
|
||||
).getImageStr,
|
||||
tweetId: result.id
|
||||
)
|
||||
|
||||
result.user = parseGraphUser(js{"core"})
|
||||
|
||||
if result.replyId == 0:
|
||||
result.replyId = js{"reply_to_results", "rest_id"}.getId
|
||||
if result.reply.len == 0:
|
||||
with replyTo, js{"reply_to_user_results", "result", "core", "screen_name"}:
|
||||
result.reply = @[replyTo.getStr]
|
||||
|
||||
with count, js{"views", "count"}:
|
||||
result.stats.views = count.getStr("0").parseInt
|
||||
|
|
@ -409,21 +684,58 @@ proc parseGraphTweet(js: JsonNode): Tweet =
|
|||
|
||||
parseMediaEntities(js, result)
|
||||
|
||||
if result.quote.isSome:
|
||||
result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}))
|
||||
# Hide card if it's redundant with attribution (same video shown via embed)
|
||||
if result.attribution.isSome and result.card.isSome:
|
||||
let cardUri = get(result.card).url.parseUri
|
||||
if cardUri.isTwitterUrl:
|
||||
let cardPath = cardUri.path.replace("/video/1", "")
|
||||
if cardPath.len > 0 and cardPath == result.attributionLink:
|
||||
get(result.card).kind = hidden
|
||||
|
||||
with quoted, js{"quotedPostResults", "result"}:
|
||||
# Handle retweets - check both legacy and top-level paths
|
||||
with reposts, js{"legacy", "repostedStatusResults"}:
|
||||
with rt, reposts{"result"}:
|
||||
if "legacy" in rt or "rest_id" in rt:
|
||||
result.retweet = some parseGraphTweet(rt)
|
||||
|
||||
with quoted, js{"quoted_status_result", "result"}:
|
||||
result.quote = some(parseGraphTweet(quoted))
|
||||
|
||||
with quoted, js{"quotedPostResults"}:
|
||||
if "result" in quoted:
|
||||
result.quote = some(parseGraphTweet(quoted{"result"}))
|
||||
else:
|
||||
result.quote = some Tweet(id: js{"legacy", "quoted_status_id_str"}.getId)
|
||||
|
||||
with ids, js{"edit_control", "edit_control_initial", "edit_tweet_ids"}:
|
||||
for id in ids:
|
||||
result.history.add parseBiggestInt(id.getStr)
|
||||
|
||||
with birdwatch, js{"birdwatch_pivot"}:
|
||||
result.note = parseCommunityNote(birdwatch)
|
||||
|
||||
proc getConvSection(js: JsonNode): string =
|
||||
let details = select(
|
||||
js{"item", "client_event_info", "details"},
|
||||
js{"item", "clientEventInfo", "details"}
|
||||
)
|
||||
select(
|
||||
details{"conversation_details", "conversation_section"},
|
||||
details{"conversationDetails", "conversationSection"}
|
||||
).getStr
|
||||
|
||||
proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
|
||||
var checkedSection = false
|
||||
for t in ? js{"content", "items"}:
|
||||
let entryId = t.getEntryId
|
||||
if "cursor-showmore" in entryId:
|
||||
let cursor = t{"item", "content", "value"}
|
||||
result.thread.cursor = cursor.getStr
|
||||
result.thread.hasMore = true
|
||||
elif "tweet" in entryId and "promoted" notin entryId:
|
||||
with tweet, t.getTweetResult("item"):
|
||||
if "tweet-" in entryId and "promoted" notin entryId:
|
||||
if not checkedSection:
|
||||
checkedSection = true
|
||||
if getConvSection(t) == "RelatedTweet":
|
||||
result.thread.related = true
|
||||
|
||||
let tweet = t.getTweetResult("item")
|
||||
if tweet.notNull:
|
||||
result.thread.content.add parseGraphTweet(tweet)
|
||||
|
||||
let tweetDisplayType = select(
|
||||
|
|
@ -432,11 +744,31 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
|
|||
)
|
||||
if tweetDisplayType.getStr == "SelfThread":
|
||||
result.self = true
|
||||
else:
|
||||
result.thread.content.add Tweet(id: entryId.getId)
|
||||
elif "cursor-showmore" in entryId:
|
||||
let cursor = t{"item", "content", "value"}
|
||||
result.thread.cursor = cursor.getStr
|
||||
result.thread.hasMore = true
|
||||
|
||||
proc parseGraphTweetResult*(js: JsonNode): Tweet =
|
||||
with tweet, js{"data", "tweet_result", "result"}:
|
||||
result = parseGraphTweet(tweet)
|
||||
|
||||
proc parseTweetByRestId*(js: JsonNode): Tweet =
|
||||
with tweet, js{"data", "tweetResult", "result"}:
|
||||
result = parseGraphTweet(tweet)
|
||||
|
||||
proc parseGraphTweetResults*(js: JsonNode): seq[Tweet] =
|
||||
let results = js{"data", "tweetResult"}
|
||||
if results.kind != JArray: return
|
||||
for item in results:
|
||||
let tweet = item{"result"}
|
||||
if tweet.isNull: continue
|
||||
let t = parseGraphTweet(tweet)
|
||||
if t != nil:
|
||||
result.add t
|
||||
|
||||
proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
||||
result = Conversation(replies: Result[Chain](beginning: true))
|
||||
|
||||
|
|
@ -452,7 +784,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
|||
if i.getTypeName == "TimelineAddEntries":
|
||||
for e in i{"entries"}:
|
||||
let entryId = e.getEntryId
|
||||
if entryId.startsWith("tweet"):
|
||||
if entryId.startsWith("tweet-"):
|
||||
let tweetResult = getTweetResult(e)
|
||||
if tweetResult.notNull:
|
||||
let tweet = parseGraphTweet(tweetResult)
|
||||
|
|
@ -460,11 +792,14 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
|||
if not tweet.available:
|
||||
tweet.id = entryId.getId
|
||||
|
||||
if $tweet.id == tweetId:
|
||||
if entryId.endsWith(tweetId):
|
||||
result.tweet = tweet
|
||||
else:
|
||||
result.before.content.add tweet
|
||||
elif entryId.startsWith("conversationthread"):
|
||||
elif not entryId.endsWith(tweetId):
|
||||
result.before.content.add Tweet(id: entryId.getId)
|
||||
elif entryId.startsWith("conversationthread") or
|
||||
entryId.startsWith("tweetdetailrelatedtweets"):
|
||||
let (thread, self) = parseGraphThread(e)
|
||||
if self:
|
||||
result.after = thread
|
||||
|
|
@ -485,25 +820,60 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
|||
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 parseGraphEditHistory*(js: JsonNode; tweetId: string): EditHistory =
|
||||
let instructions = ? js{
|
||||
"data", "tweet_result_by_rest_id", "result",
|
||||
"edit_history_timeline", "timeline", "instructions"
|
||||
}
|
||||
if instructions.len == 0:
|
||||
return
|
||||
|
||||
for i in instructions:
|
||||
if i.getTypeName == "TimelineAddEntries":
|
||||
for e in i{"entries"}:
|
||||
let entryId = e.getEntryId
|
||||
if entryId == "latestTweet":
|
||||
with item, e{"content", "items"}[0]:
|
||||
let tweetResult = item.getTweetResult("item")
|
||||
if tweetResult.notNull:
|
||||
result.latest = parseGraphTweet(tweetResult)
|
||||
elif entryId == "staleTweets":
|
||||
for item in e{"content", "items"}:
|
||||
let tweetResult = item.getTweetResult("item")
|
||||
if tweetResult.notNull:
|
||||
result.history.add parseGraphTweet(tweetResult)
|
||||
|
||||
iterator extractTweetsFromModuleItems(items: JsonNode): Tweet =
|
||||
for item in items:
|
||||
with tweetResult, item.getTweetResult("item"):
|
||||
let tweet = parseGraphTweet(tweetResult)
|
||||
if not tweet.available:
|
||||
tweet.id = item.getEntryId.getId
|
||||
yield tweet
|
||||
|
||||
iterator extractListsFromItems(items: JsonNode): ListSearchResult =
|
||||
for item in items:
|
||||
with listJs, item{"item", "itemContent", "list"}:
|
||||
let r = parseGraphSearchList(listJs)
|
||||
if r.list.id.len > 0:
|
||||
yield r
|
||||
|
||||
proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] =
|
||||
with tweetResult, getTweetResult(e):
|
||||
var tweet = parseGraphTweet(tweetResult)
|
||||
let tweet = parseGraphTweet(tweetResult)
|
||||
if not tweet.available:
|
||||
tweet.id = e.getEntryId.getId
|
||||
result.add tweet
|
||||
return
|
||||
|
||||
for item in e{"content", "items"}:
|
||||
with tweetResult, item.getTweetResult("item"):
|
||||
var tweet = parseGraphTweet(tweetResult)
|
||||
if not tweet.available:
|
||||
tweet.id = item.getEntryId.getId
|
||||
result.add tweet
|
||||
for tweet in extractTweetsFromModuleItems(e{"content", "items"}):
|
||||
result.add tweet
|
||||
|
||||
proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
|
||||
result = Profile(tweets: Timeline(beginning: after.len == 0))
|
||||
|
|
@ -518,12 +888,8 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
|
|||
|
||||
for i in instructions:
|
||||
if i{"moduleItems"}.notNull:
|
||||
for item in i{"moduleItems"}:
|
||||
with tweetResult, item.getTweetResult("item"):
|
||||
let tweet = parseGraphTweet(tweetResult)
|
||||
if not tweet.available:
|
||||
tweet.id = item.getEntryId.getId
|
||||
result.tweets.content.add tweet
|
||||
for tweet in extractTweetsFromModuleItems(i{"moduleItems"}):
|
||||
result.tweets.content.add tweet
|
||||
continue
|
||||
|
||||
if i{"entries"}.notNull:
|
||||
|
|
@ -558,18 +924,13 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
|
|||
|
||||
for i in instructions:
|
||||
if i{"moduleItems"}.notNull:
|
||||
for item in i{"moduleItems"}:
|
||||
with tweetResult, item.getTweetResult("item"):
|
||||
let t = parseGraphTweet(tweetResult)
|
||||
if not t.available:
|
||||
t.id = item.getEntryId.getId
|
||||
for t in extractTweetsFromModuleItems(i{"moduleItems"}):
|
||||
let photo = extractGalleryPhoto(t)
|
||||
if photo.url.len > 0:
|
||||
result.add photo
|
||||
|
||||
let photo = extractGalleryPhoto(t)
|
||||
if photo.url.len > 0:
|
||||
result.add photo
|
||||
|
||||
if result.len == 16:
|
||||
return
|
||||
if result.len == 16:
|
||||
return
|
||||
continue
|
||||
|
||||
if i.getTypeName != "TimelineAddEntries":
|
||||
|
|
@ -586,7 +947,7 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
|
|||
if result.len == 16:
|
||||
return
|
||||
|
||||
proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
|
||||
proc parseGraphSearch*[T: User | Tweets | ListSearchResult](js: JsonNode; after=""): Result[T] =
|
||||
result = Result[T](beginning: after.len == 0)
|
||||
|
||||
let instructions = select(
|
||||
|
|
@ -602,19 +963,73 @@ proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
|
|||
for e in instruction{"entries"}:
|
||||
let entryId = e.getEntryId
|
||||
when T is Tweets:
|
||||
if entryId.startsWith("tweet"):
|
||||
with tweetRes, getTweetResult(e):
|
||||
let tweet = parseGraphTweet(tweetRes)
|
||||
if not tweet.available:
|
||||
tweet.id = entryId.getId
|
||||
if entryId.startsWith("tweet") or entryId.startsWith("search-grid"):
|
||||
for tweet in extractTweetsFromEntry(e):
|
||||
result.content.add tweet
|
||||
elif T is User:
|
||||
if entryId.startsWith("user"):
|
||||
with userRes, e{"content", "itemContent"}:
|
||||
result.content.add parseGraphUser(userRes)
|
||||
elif T is ListSearchResult:
|
||||
if entryId.startsWith("list-search"):
|
||||
for list in extractListsFromItems(e{"content", "items"}):
|
||||
result.content.add list
|
||||
|
||||
if entryId.startsWith("cursor-bottom"):
|
||||
result.bottom = e{"content", "value"}.getStr
|
||||
elif typ == "TimelineAddToModule":
|
||||
when T is Tweets:
|
||||
for tweet in extractTweetsFromModuleItems(instruction{"moduleItems"}):
|
||||
result.content.add tweet
|
||||
elif T is ListSearchResult:
|
||||
for list in extractListsFromItems(instruction{"moduleItems"}):
|
||||
result.content.add list
|
||||
elif typ == "TimelineReplaceEntry":
|
||||
if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"):
|
||||
result.bottom = instruction{"entry", "content", "value"}.getStr
|
||||
|
||||
proc parseGraphCommunityTimeline*(js: JsonNode; after=""): Timeline =
|
||||
result = Timeline(beginning: after.len == 0)
|
||||
|
||||
let communityResult = js{"data", "communityResults", "result"}
|
||||
let instructions = ? select(
|
||||
communityResult{"ranked_community_timeline", "timeline", "instructions"},
|
||||
communityResult{"community_media_timeline", "timeline", "instructions"},
|
||||
communityResult{"community_filtered_timeline", "timeline", "instructions"}
|
||||
)
|
||||
if instructions.len == 0:
|
||||
return
|
||||
|
||||
for i in instructions:
|
||||
if i{"entries"}.notNull:
|
||||
for e in i{"entries"}:
|
||||
let entryId = e.getEntryId
|
||||
if entryId.startsWith("tweet") or entryId.startsWith("profile-grid") or
|
||||
entryId.startsWith("communities-grid"):
|
||||
for tweet in extractTweetsFromEntry(e):
|
||||
result.content.add tweet
|
||||
elif entryId.startsWith("cursor-bottom"):
|
||||
result.bottom = e{"content", "value"}.getStr
|
||||
|
||||
if after.len == 0 and i.getTypeName == "TimelinePinEntry":
|
||||
var tweets = extractTweetsFromEntry(i{"entry"})
|
||||
for tweet in tweets.mitems:
|
||||
tweet.pinned = true
|
||||
if tweets.len > 0:
|
||||
result.content.insert(tweets, 0)
|
||||
|
||||
proc parseGraphCommunityMembers*(js: JsonNode; after=""): Result[User] =
|
||||
result = Result[User](beginning: after.len == 0)
|
||||
|
||||
let r = js{"data", "communityResults", "result"}
|
||||
let slice = if not r{"members_slice"}.isNull: r{"members_slice"}
|
||||
else: r{"moderators_slice"}
|
||||
for item in slice{"items_results"}:
|
||||
let user = parseGraphUser(item{"result"})
|
||||
if user.username.len > 0:
|
||||
result.content.add user
|
||||
|
||||
let cursor = slice{"slice_info", "next_cursor"}.getStr
|
||||
if cursor.len > 0:
|
||||
result.bottom = cursor
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ let
|
|||
unReplace = "$1<a href=\"/$2\">@$2</a>"
|
||||
|
||||
htRegex = re"(^|[^\w-_./?])([#$]|#)([\w_]+)"
|
||||
htReplace = "$1<a href=\"/search?q=%23$3\">$2$3</a>"
|
||||
htReplace = "$1<a href=\"/search?f=tweets&q=%23$3\">$2$3</a>"
|
||||
|
||||
type
|
||||
ReplaceSliceKind = enum
|
||||
|
|
@ -72,7 +72,6 @@ template getTypeName*(js: JsonNode): string =
|
|||
template getEntryId*(e: JsonNode): string =
|
||||
e{"entryId"}.getStr(e{"entry_id"}.getStr)
|
||||
|
||||
|
||||
template parseTime(time: string; f: static string; flen: int): DateTime =
|
||||
if time.len != flen: return
|
||||
parse(time, f, utc())
|
||||
|
|
@ -89,11 +88,19 @@ proc getTimeFromMs*(js: JsonNode): DateTime =
|
|||
let seconds = ms div 1000
|
||||
return fromUnix(seconds).utc()
|
||||
|
||||
proc getTimeFromMsStr*(js: JsonNode): DateTime =
|
||||
var ms: int64
|
||||
try: ms = parseBiggestInt(js.getStr("0"))
|
||||
except ValueError: return
|
||||
if ms == 0: return
|
||||
let seconds = ms div 1000
|
||||
return fromUnix(seconds).utc()
|
||||
|
||||
proc getId*(id: string): int64 {.inline.} =
|
||||
let start = id.rfind("-")
|
||||
if start < 0:
|
||||
return parseBiggestInt(id)
|
||||
return parseBiggestInt(id[start + 1 ..< id.len])
|
||||
try:
|
||||
parseBiggestInt(if start < 0: id else: id[start + 1 ..< id.len])
|
||||
except ValueError: 0'i64
|
||||
|
||||
proc getId*(js: JsonNode): int64 {.inline.} =
|
||||
case js.kind
|
||||
|
|
@ -112,6 +119,9 @@ proc getImageStr*(js: JsonNode): string =
|
|||
template getImageVal*(js: JsonNode): string =
|
||||
js{"image_value", "url"}.getImageStr
|
||||
|
||||
template getExpandedUrl*(js: JsonNode; fallback=""): string =
|
||||
js{"expanded_url"}.getStr(js{"url"}.getStr(fallback))
|
||||
|
||||
proc getCardUrl*(js: JsonNode; kind: CardKind): string =
|
||||
result = js{"website_url"}.getStrVal
|
||||
if kind == promoVideoConvo:
|
||||
|
|
@ -175,12 +185,16 @@ proc extractSlice(js: JsonNode): Slice[int] =
|
|||
result = js["indices"][0].getInt ..< js["indices"][1].getInt
|
||||
|
||||
proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
|
||||
textLen: int; hideTwitter = false) =
|
||||
textLen: int; hideTwitter = false;
|
||||
hideArticle = false) =
|
||||
let
|
||||
url = js["expanded_url"].getStr
|
||||
url = js.getExpandedUrl
|
||||
slice = js.extractSlice
|
||||
|
||||
if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
|
||||
if hideArticle and url.isTwitterUrl and "/article/" in url:
|
||||
if slice.a < textLen:
|
||||
result.add ReplaceSlice(kind: rkRemove, slice: slice)
|
||||
elif hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
|
||||
if slice.a < textLen:
|
||||
result.add ReplaceSlice(kind: rkRemove, slice: slice)
|
||||
else:
|
||||
|
|
@ -192,28 +206,41 @@ proc extractHashtags(result: var seq[ReplaceSlice]; js: JsonNode) =
|
|||
|
||||
proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice];
|
||||
textSlice: Slice[int]): string =
|
||||
let
|
||||
runeLen = runes.len
|
||||
safeStart = max(0, textSlice.a)
|
||||
safeEnd = min(runeLen, textSlice.b)
|
||||
|
||||
var validRepls: seq[ReplaceSlice]
|
||||
for rep in repls:
|
||||
if rep.slice.a >= 0 and rep.slice.b >= 0 and rep.slice.b < runeLen and rep.slice.a <= rep.slice.b:
|
||||
validRepls.add rep
|
||||
|
||||
template extractLowerBound(i: int; idx): int =
|
||||
if i > 0: repls[idx].slice.b.succ else: textSlice.a
|
||||
if i > 0: min(validRepls[idx].slice.b.succ, runeLen) else: safeStart
|
||||
|
||||
result = newStringOfCap(runes.len)
|
||||
|
||||
for i, rep in repls:
|
||||
result.add $runes[extractLowerBound(i, i - 1) ..< rep.slice.a]
|
||||
for i, rep in validRepls:
|
||||
let lower = extractLowerBound(i, i - 1)
|
||||
if lower < rep.slice.a:
|
||||
result.add $runes[lower ..< rep.slice.a]
|
||||
case rep.kind
|
||||
of rkHashtag:
|
||||
let
|
||||
name = $runes[rep.slice.a.succ .. rep.slice.b]
|
||||
symbol = $runes[rep.slice.a]
|
||||
result.add a(symbol & name, href = "/search?q=%23" & name)
|
||||
if rep.slice.a.succ <= rep.slice.b:
|
||||
let
|
||||
name = $runes[rep.slice.a.succ .. rep.slice.b]
|
||||
symbol = $runes[rep.slice.a]
|
||||
result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name)
|
||||
of rkMention:
|
||||
result.add a($runes[rep.slice], href = rep.url, title = rep.display)
|
||||
result.add a($runes[rep.slice], href = rep.url, title = escape(rep.display))
|
||||
of rkUrl:
|
||||
result.add a(rep.display, href = rep.url)
|
||||
result.add a(escape(rep.display), href = rep.url)
|
||||
of rkRemove:
|
||||
discard
|
||||
|
||||
let rest = extractLowerBound(repls.len, ^1) ..< textSlice.b
|
||||
if rest.a <= rest.b:
|
||||
let rest = extractLowerBound(validRepls.len, ^1) ..< safeEnd
|
||||
if rest.a >= 0 and rest.a <= rest.b and rest.b <= runeLen:
|
||||
result.add $runes[rest]
|
||||
|
||||
proc deduplicate(s: var seq[ReplaceSlice]) =
|
||||
|
|
@ -238,7 +265,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
|
|||
ent = ? js{"entities"}
|
||||
|
||||
with urls, ent{"url", "urls"}:
|
||||
user.website = urls[0]{"expanded_url"}.getStr
|
||||
user.website = urls[0].getExpandedUrl
|
||||
|
||||
var replacements = newSeq[ReplaceSlice]()
|
||||
|
||||
|
|
@ -254,7 +281,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
|
|||
.replacef(htRegex, htReplace)
|
||||
|
||||
proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int];
|
||||
replyTo=""; hasRedundantLink=false) =
|
||||
replyTo=""; hasRedundantLink=false; hasArticle=false) =
|
||||
let hasCard = tweet.card.isSome
|
||||
|
||||
var replacements = newSeq[ReplaceSlice]()
|
||||
|
|
@ -265,10 +292,11 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
|
|||
if urlStr.len == 0 or urlStr notin text:
|
||||
continue
|
||||
|
||||
replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink)
|
||||
replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink,
|
||||
hideArticle = hasArticle)
|
||||
|
||||
if hasCard and u{"url"}.getStr == get(tweet.card).url:
|
||||
get(tweet.card).url = u{"expanded_url"}.getStr
|
||||
get(tweet.card).url = u.getExpandedUrl
|
||||
|
||||
with media, entities{"media"}:
|
||||
for m in media:
|
||||
|
|
@ -302,7 +330,7 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
|
|||
|
||||
tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false)
|
||||
|
||||
proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
|
||||
proc expandTweetEntities*(tweet: Tweet; js: JsonNode; hasArticle=false) =
|
||||
let
|
||||
entities = ? js{"entities"}
|
||||
textRange = js{"display_text_range"}
|
||||
|
|
@ -316,23 +344,99 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
|
|||
replyTo = reply.getStr
|
||||
tweet.reply.add replyTo
|
||||
|
||||
tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote or hasJobCard)
|
||||
tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo,
|
||||
hasQuote or hasJobCard, hasArticle)
|
||||
|
||||
proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int];
|
||||
hasRedundantLink=false; hasArticle=false) =
|
||||
let hasCard = tweet.card.isSome
|
||||
|
||||
var replacements = newSeq[ReplaceSlice]()
|
||||
|
||||
with urls, js{"url_entities"}:
|
||||
for u in urls:
|
||||
let urlStr = u["url"].getStr
|
||||
if urlStr.len == 0 or urlStr notin text:
|
||||
continue
|
||||
|
||||
replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink,
|
||||
hideArticle = hasArticle)
|
||||
|
||||
if hasCard and u{"url"}.getStr == get(tweet.card).url:
|
||||
get(tweet.card).url = u.getExpandedUrl
|
||||
|
||||
with hashtags, js{"details", "hashtag_entities"}:
|
||||
for hashtag in hashtags:
|
||||
replacements.extractHashtags(hashtag)
|
||||
|
||||
with cashtags, js{"details", "cashtag_entities"}:
|
||||
for cashtag in cashtags:
|
||||
replacements.extractHashtags(cashtag)
|
||||
|
||||
with mentions, js{"mention_entities"}:
|
||||
for mention in mentions:
|
||||
let
|
||||
name = mention{"screen_name"}.getStr
|
||||
slice = mention.extractSlice
|
||||
idx = tweet.reply.find(name)
|
||||
|
||||
if slice.a >= textSlice.a:
|
||||
replacements.add ReplaceSlice(kind: rkMention, slice: slice,
|
||||
url: "/" & name, display: mention["name"].getStr)
|
||||
elif idx == -1 and tweet.replyId != 0:
|
||||
tweet.reply.add name
|
||||
|
||||
replacements.deduplicate
|
||||
replacements.sort(cmp)
|
||||
|
||||
tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false)
|
||||
|
||||
proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode; hasArticle=false) =
|
||||
let
|
||||
textRange = js{"details", "display_text_range"}
|
||||
textSlice = textRange{0}.getInt .. textRange{1}.getInt
|
||||
hasQuote = "quoted_tweet_results" in js
|
||||
hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
|
||||
hasAttribution = tweet.attribution.isSome
|
||||
|
||||
tweet.expandTextEntitiesV2(js, tweet.text, textSlice,
|
||||
hasQuote or hasJobCard or hasAttribution,
|
||||
hasArticle)
|
||||
|
||||
proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
|
||||
let
|
||||
entities = ? js{"entity_set"}
|
||||
text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose))
|
||||
textSlice = 0..text.runeLen
|
||||
hasAttribution = tweet.attribution.isSome
|
||||
|
||||
tweet.expandTextEntities(entities, text, textSlice)
|
||||
tweet.expandTextEntities(entities, text, textSlice, hasRedundantLink=hasAttribution)
|
||||
|
||||
tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose))
|
||||
|
||||
proc expandBirdwatchEntities*(text: string; entities: JsonNode): string =
|
||||
let runes = text.toRunes
|
||||
var replacements: seq[ReplaceSlice]
|
||||
|
||||
for entity in entities:
|
||||
let
|
||||
fromIdx = entity{"from_index"}.getInt
|
||||
toIdx = entity{"to_index"}.getInt
|
||||
url = entity{"ref", "url"}.getStr
|
||||
if url.len > 0:
|
||||
replacements.add ReplaceSlice(
|
||||
kind: rkUrl,
|
||||
slice: fromIdx ..< toIdx,
|
||||
url: url,
|
||||
display: $runes[fromIdx ..< min(toIdx, runes.len)]
|
||||
)
|
||||
|
||||
replacements.sort(cmp)
|
||||
result = runes.replacedWith(replacements, 0 ..< runes.len)
|
||||
|
||||
proc extractGalleryPhoto*(t: Tweet): GalleryPhoto =
|
||||
let url =
|
||||
if t.photos.len > 0: t.photos[0]
|
||||
elif t.video.isSome: get(t.video).thumb
|
||||
elif t.gif.isSome: get(t.gif).thumb
|
||||
if t.media.len > 0: t.media[0].getThumb
|
||||
elif t.card.isSome: get(t.card).image
|
||||
else: ""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import tables
|
||||
import tables, strutils
|
||||
import types, prefs_impl
|
||||
from config import get
|
||||
from parsecfg import nil
|
||||
|
||||
export genUpdatePrefs, genResetPrefs
|
||||
export genUpdatePrefs, genResetPrefs, genApplyPrefs
|
||||
|
||||
var defaultPrefs*: Prefs
|
||||
|
||||
proc updateDefaultPrefs*(cfg: parsecfg.Config) =
|
||||
genDefaultPrefs()
|
||||
|
||||
proc getPrefs*(cookies: Table[string, string]): Prefs =
|
||||
proc getPrefs*(cookies, params: Table[string, string]): Prefs =
|
||||
result = defaultPrefs
|
||||
genCookiePrefs(cookies)
|
||||
genParsePrefs(cookies)
|
||||
genParsePrefs(params)
|
||||
|
||||
template getPref*(cookies: Table[string, string], pref): untyped =
|
||||
bind genCookiePref
|
||||
var res = defaultPrefs.`pref`
|
||||
genCookiePref(cookies, pref, res)
|
||||
res
|
||||
proc encodePrefs*(prefs: Prefs): string =
|
||||
var encPairs: seq[string]
|
||||
genEncodePrefs(prefs)
|
||||
encPairs.join(",")
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ genPrefs:
|
|||
stickyProfile(checkbox, true):
|
||||
"Make profile sidebar stick to top"
|
||||
|
||||
stickyNav(checkbox, true):
|
||||
"Keep navbar fixed to top"
|
||||
|
||||
bidiSupport(checkbox, false):
|
||||
"Support bidirectional text (makes clicking on tweets harder)"
|
||||
|
||||
|
|
@ -75,6 +78,12 @@ genPrefs:
|
|||
hideReplies(checkbox, false):
|
||||
"Hide tweet replies"
|
||||
|
||||
hideRelated(checkbox, true):
|
||||
"Hide related tweets under replies"
|
||||
|
||||
hideCommunityNotes(checkbox, false):
|
||||
"Hide community notes"
|
||||
|
||||
squareAvatars(checkbox, false):
|
||||
"Square profile pictures"
|
||||
|
||||
|
|
@ -94,6 +103,17 @@ genPrefs:
|
|||
autoplayGifs(checkbox, true):
|
||||
"Autoplay gifs"
|
||||
|
||||
compactGallery(checkbox, false):
|
||||
"Compact media gallery (no profile info or text)"
|
||||
|
||||
gallerySize(select, "Medium"):
|
||||
"Gallery column size"
|
||||
options: @["Small", "Medium", "Large"]
|
||||
|
||||
mediaView(select, "Timeline"):
|
||||
"Default media view"
|
||||
options: @["Timeline", "Grid", "Gallery"]
|
||||
|
||||
"Link replacements (blank to disable)":
|
||||
replaceTwitter(input, ""):
|
||||
"Twitter -> Nitter"
|
||||
|
|
@ -127,7 +147,7 @@ macro genDefaultPrefs*(): untyped =
|
|||
result.add quote do:
|
||||
defaultPrefs.`ident` = cfg.get("Preferences", `name`, `default`)
|
||||
|
||||
macro genCookiePrefs*(cookies): untyped =
|
||||
macro genParsePrefs*(prefs): untyped =
|
||||
result = nnkStmtList.newTree()
|
||||
for pref in allPrefs():
|
||||
let
|
||||
|
|
@ -137,37 +157,17 @@ macro genCookiePrefs*(cookies): untyped =
|
|||
options = pref.options
|
||||
|
||||
result.add quote do:
|
||||
if `name` in `cookies`:
|
||||
if `name` in `prefs`:
|
||||
when `kind` == input or `name` == "theme":
|
||||
result.`ident` = `cookies`[`name`]
|
||||
result.`ident` = `prefs`[`name`]
|
||||
elif `kind` == checkbox:
|
||||
result.`ident` = `cookies`[`name`] == "on"
|
||||
result.`ident` = `prefs`[`name`] == "on" or
|
||||
`prefs`[`name`] == "true" or
|
||||
`prefs`[`name`] == "1"
|
||||
else:
|
||||
let value = `cookies`[`name`]
|
||||
let value = `prefs`[`name`]
|
||||
if value in `options`: result.`ident` = value
|
||||
|
||||
macro genCookiePref*(cookies, prefName, res): untyped =
|
||||
result = nnkStmtList.newTree()
|
||||
for pref in allPrefs():
|
||||
let ident = ident(pref.name)
|
||||
if ident != prefName:
|
||||
continue
|
||||
|
||||
let
|
||||
name = pref.name
|
||||
kind = newLit(pref.kind)
|
||||
options = pref.options
|
||||
|
||||
result.add quote do:
|
||||
if `name` in `cookies`:
|
||||
when `kind` == input or `name` == "theme":
|
||||
`res` = `cookies`[`name`]
|
||||
elif `kind` == checkbox:
|
||||
`res` = `cookies`[`name`] == "on"
|
||||
else:
|
||||
let value = `cookies`[`name`]
|
||||
if value in `options`: `res` = value
|
||||
|
||||
macro genUpdatePrefs*(): untyped =
|
||||
result = nnkStmtList.newTree()
|
||||
let req = ident("request")
|
||||
|
|
@ -202,6 +202,36 @@ macro genResetPrefs*(): untyped =
|
|||
result.add quote do:
|
||||
savePref(`name`, "", `req`, expire=true)
|
||||
|
||||
macro genEncodePrefs*(prefs): untyped =
|
||||
result = nnkStmtList.newTree()
|
||||
for pref in allPrefs():
|
||||
let
|
||||
name = newLit(pref.name)
|
||||
ident = ident(pref.name)
|
||||
kind = newLit(pref.kind)
|
||||
defaultIdent = nnkDotExpr.newTree(ident("defaultPrefs"), ident(pref.name))
|
||||
|
||||
result.add quote do:
|
||||
when `kind` == checkbox:
|
||||
if `prefs`.`ident` != `defaultIdent`:
|
||||
if `prefs`.`ident`:
|
||||
encPairs.add `name` & "=on"
|
||||
else:
|
||||
encPairs.add `name` & "="
|
||||
else:
|
||||
if `prefs`.`ident` != `defaultIdent`:
|
||||
encPairs.add `name` & "=" & `prefs`.`ident`
|
||||
|
||||
macro genApplyPrefs*(params, req): untyped =
|
||||
result = nnkStmtList.newTree()
|
||||
for pref in allPrefs():
|
||||
let name = newLit(pref.name)
|
||||
result.add quote do:
|
||||
if `name` in `params`:
|
||||
savePref(`name`, `params`[`name`], `req`)
|
||||
else:
|
||||
savePref(`name`, "", `req`, expire=true)
|
||||
|
||||
macro genPrefsType*(): untyped =
|
||||
let name = nnkPostfix.newTree(ident("*"), ident("Prefs"))
|
||||
result = quote do:
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat, sequtils, tables, uri
|
||||
|
||||
import types
|
||||
import types, utils
|
||||
|
||||
const
|
||||
validFilters* = @[
|
||||
"media", "images", "twimg", "videos",
|
||||
"native_video", "consumer_video", "pro_video",
|
||||
"native_video", "consumer_video", "spaces",
|
||||
"links", "news", "quote", "mentions",
|
||||
"replies", "retweets", "nativeretweets",
|
||||
"verified", "safe"
|
||||
"replies", "retweets", "nativeretweets"
|
||||
]
|
||||
|
||||
emptyQuery* = "include:nativeretweets"
|
||||
|
|
@ -21,32 +20,43 @@ template `@`(param: string): untyped =
|
|||
proc initQuery*(pms: Table[string, string]; name=""): Query =
|
||||
result = Query(
|
||||
kind: parseEnum[QueryKind](@"f", tweets),
|
||||
view: @"view",
|
||||
text: @"q",
|
||||
filters: validFilters.filterIt("f-" & it in pms),
|
||||
excludes: validFilters.filterIt("e-" & it in pms),
|
||||
since: @"since",
|
||||
until: @"until",
|
||||
near: @"near"
|
||||
minLikes: validateNumber(@"min_faves")
|
||||
)
|
||||
|
||||
# articles is an internal tab kind, not a valid search filter
|
||||
if result.kind == QueryKind.articles:
|
||||
result.kind = tweets
|
||||
|
||||
if name.len > 0:
|
||||
result.fromUser = name.split(",")
|
||||
|
||||
proc getMediaQuery*(name: string): Query =
|
||||
Query(
|
||||
kind: media,
|
||||
kind: QueryKind.media,
|
||||
filters: @["twimg", "native_video"],
|
||||
fromUser: @[name],
|
||||
sep: "OR"
|
||||
)
|
||||
|
||||
proc getArticlesQuery*(name: string): Query =
|
||||
Query(
|
||||
kind: QueryKind.articles,
|
||||
fromUser: @[name]
|
||||
)
|
||||
|
||||
proc getReplyQuery*(name: string): Query =
|
||||
Query(
|
||||
kind: replies,
|
||||
fromUser: @[name]
|
||||
)
|
||||
|
||||
proc genQueryParam*(query: Query): string =
|
||||
proc genQueryParam*(query: Query; maxId=""): string =
|
||||
var
|
||||
filters: seq[string]
|
||||
param: string
|
||||
|
|
@ -55,15 +65,20 @@ proc genQueryParam*(query: Query): string =
|
|||
return query.text
|
||||
|
||||
for i, user in query.fromUser:
|
||||
param &= &"from:{user} "
|
||||
if i < query.fromUser.high:
|
||||
param &= "OR "
|
||||
if i == 0:
|
||||
param = "("
|
||||
|
||||
if query.fromUser.len > 0 and query.kind in {posts, media}:
|
||||
param &= "filter:self_threads OR -filter:replies "
|
||||
param &= &"from:{user}"
|
||||
if i < query.fromUser.high:
|
||||
param &= " OR "
|
||||
else:
|
||||
param &= ")"
|
||||
|
||||
if query.fromUser.len > 0 and query.kind in {posts, QueryKind.media}:
|
||||
param &= " (filter:self_threads OR -filter:replies)"
|
||||
|
||||
if "nativeretweets" notin query.excludes:
|
||||
param &= "include:nativeretweets "
|
||||
param &= " include:nativeretweets"
|
||||
|
||||
for f in query.filters:
|
||||
filters.add "filter:" & f
|
||||
|
|
@ -73,38 +88,51 @@ proc genQueryParam*(query: Query): string =
|
|||
for i in query.includes:
|
||||
filters.add "include:" & i
|
||||
|
||||
result = strip(param & filters.join(&" {query.sep} "))
|
||||
if filters.len > 0:
|
||||
result = strip(param & " (" & filters.join(&" {query.sep} ") & ")")
|
||||
else:
|
||||
result = strip(param)
|
||||
|
||||
if query.since.len > 0:
|
||||
result &= " since:" & query.since
|
||||
if query.until.len > 0:
|
||||
if query.until.len > 0 and maxId.len == 0:
|
||||
result &= " until:" & query.until
|
||||
if query.near.len > 0:
|
||||
result &= &" near:\"{query.near}\" within:15mi"
|
||||
if query.minLikes.len > 0:
|
||||
result &= " min_faves:" & query.minLikes
|
||||
if query.text.len > 0:
|
||||
if result.len > 0:
|
||||
result &= " " & query.text
|
||||
else:
|
||||
result = query.text
|
||||
|
||||
if result.len > 0 and maxId.len > 0:
|
||||
result &= " max_id:" & maxId
|
||||
|
||||
proc genQueryUrl*(query: Query): string =
|
||||
if query.kind notin {tweets, users}: return
|
||||
var params: seq[string]
|
||||
|
||||
var params = @[&"f={query.kind}"]
|
||||
if query.text.len > 0:
|
||||
params.add "q=" & encodeUrl(query.text)
|
||||
for f in query.filters:
|
||||
params.add &"f-{f}=on"
|
||||
for e in query.excludes:
|
||||
params.add &"e-{e}=on"
|
||||
for i in query.includes.filterIt(it != "nativeretweets"):
|
||||
params.add &"i-{i}=on"
|
||||
if query.view.len > 0:
|
||||
params.add "view=" & encodeUrl(query.view)
|
||||
|
||||
if query.since.len > 0:
|
||||
params.add "since=" & query.since
|
||||
if query.until.len > 0:
|
||||
params.add "until=" & query.until
|
||||
if query.near.len > 0:
|
||||
params.add "near=" & query.near
|
||||
# media doubles as the profile media tab, where f isn't part of the URL scheme
|
||||
if query.kind in {tweets, users, lists, top} or
|
||||
(query.kind == QueryKind.media and query.fromUser.len == 0):
|
||||
params.add &"f={query.kind}"
|
||||
if query.text.len > 0:
|
||||
params.add "q=" & encodeUrl(query.text)
|
||||
for f in query.filters:
|
||||
params.add &"f-{f}=on"
|
||||
for e in query.excludes:
|
||||
params.add &"e-{e}=on"
|
||||
for i in query.includes.filterIt(it != "nativeretweets"):
|
||||
params.add &"i-{i}=on"
|
||||
|
||||
if query.since.len > 0:
|
||||
params.add "since=" & query.since
|
||||
if query.until.len > 0:
|
||||
params.add "until=" & query.until
|
||||
if query.minLikes.len > 0:
|
||||
params.add "min_faves=" & query.minLikes
|
||||
|
||||
if params.len > 0:
|
||||
result &= params.join("&")
|
||||
|
|
|
|||
|
|
@ -144,9 +144,10 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
|
|||
else:
|
||||
let user = await getGraphUserById(userId)
|
||||
result = user.username
|
||||
await setEx(key, baseCacheTime, result)
|
||||
if result.len > 0 and user.id.len > 0:
|
||||
await all(cacheUserId(result, user.id), cache(user))
|
||||
if result.len > 0:
|
||||
await setEx(key, baseCacheTime, result)
|
||||
if user.id.len > 0:
|
||||
await all(cacheUserId(result, user.id), cache(user))
|
||||
|
||||
# proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
|
||||
# if id == 0: return
|
||||
|
|
@ -158,6 +159,48 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
|
|||
# if not result.isNil:
|
||||
# await cache(result)
|
||||
|
||||
proc cache*(data: Broadcast) {.async.} =
|
||||
if data.id.len == 0: return
|
||||
await setEx("bc:" & data.id, baseCacheTime, compress(toFlatty(data)))
|
||||
|
||||
proc getCachedBroadcast*(id: string): Future[Broadcast] {.async.} =
|
||||
if id.len == 0: return
|
||||
let cached = await get("bc:" & id)
|
||||
if cached != redisNil:
|
||||
cached.deserialize(Broadcast)
|
||||
else:
|
||||
result = await getBroadcastInfo(id)
|
||||
await cache(result)
|
||||
result.m3u8Url = await fetchBroadcastStream(result.mediaKey)
|
||||
|
||||
proc cache*(data: AudioSpace) {.async.} =
|
||||
if data.id.len == 0: return
|
||||
let ttl = if data.state == "RUNNING": baseCacheTime div 6 else: baseCacheTime
|
||||
await setEx("sp:" & data.id, ttl, compress(toFlatty(data)))
|
||||
|
||||
proc getCachedAudioSpace*(id: string): Future[AudioSpace] {.async.} =
|
||||
if id.len == 0: return
|
||||
let cached = await get("sp:" & id)
|
||||
if cached != redisNil:
|
||||
cached.deserialize(AudioSpace)
|
||||
else:
|
||||
result = await getAudioSpace(id)
|
||||
await cache(result)
|
||||
result.m3u8Url = await fetchBroadcastStream(result.mediaKey)
|
||||
|
||||
proc cache*(data: AccountInfo; name: string) {.async.} =
|
||||
await setEx("ai:" & toLower(name), baseCacheTime * 24, compress(toFlatty(data)))
|
||||
|
||||
proc getCachedAccountInfo*(username: string; fetch=true): Future[AccountInfo] {.async.} =
|
||||
if username.len == 0: return
|
||||
let name = toLower(username)
|
||||
let cached = await get("ai:" & name)
|
||||
if cached != redisNil:
|
||||
cached.deserialize(AccountInfo)
|
||||
elif fetch:
|
||||
result = await getAboutAccount(username)
|
||||
await cache(result, name)
|
||||
|
||||
proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} =
|
||||
if id.len == 0: return
|
||||
let rail = await get("pr2:" & toLower(id))
|
||||
|
|
@ -167,6 +210,29 @@ proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} =
|
|||
result = await getPhotoRail(id)
|
||||
await cache(result, id)
|
||||
|
||||
proc cache*(data: Community) {.async.} =
|
||||
if data.id.len == 0: return
|
||||
await setEx("cm:" & data.id, listCacheTime, compress(toFlatty(data)))
|
||||
|
||||
proc getCachedCommunity*(id: string): Future[Community] {.async.} =
|
||||
if id.len == 0: return
|
||||
let cached = await get("cm:" & id)
|
||||
if cached != redisNil:
|
||||
cached.deserialize(Community)
|
||||
else:
|
||||
result = await getGraphCommunity(id)
|
||||
await cache(result)
|
||||
|
||||
proc getCachedCommunityModerators*(id: string): Future[seq[User]] {.async.} =
|
||||
if id.len == 0: return
|
||||
let cached = await get("cmm:" & id)
|
||||
if cached != redisNil:
|
||||
cached.deserialize(seq[User])
|
||||
else:
|
||||
let mods = await getGraphCommunityModerators(id)
|
||||
result = mods.content
|
||||
await setEx("cmm:" & id, listCacheTime, compress(toFlatty(result)))
|
||||
|
||||
proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
|
||||
let list = if id.len == 0: redisNil
|
||||
else: await get("l:" & id)
|
||||
|
|
|
|||
48
src/routes/article.nim
Normal file
48
src/routes/article.nim
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import asyncdispatch, tables, strutils
|
||||
import jester, karax/vdom
|
||||
import ".."/[types, api]
|
||||
import ../views/[article, general]
|
||||
import router_utils
|
||||
|
||||
export api, article, vdom, general, router_utils
|
||||
|
||||
proc createArticleRouter*(cfg: Config) =
|
||||
router articleRoute:
|
||||
get "/i/article/@id":
|
||||
cond @"id".allCharsInSet(Digits)
|
||||
|
||||
let article = await getGraphArticle(@"id")
|
||||
if article == nil:
|
||||
resp Http404, showError("Article not found", cfg)
|
||||
|
||||
var tweetIds: seq[string]
|
||||
for e in article.entities.values:
|
||||
if e.kind == "TWEET":
|
||||
tweetIds.add e.tweetId
|
||||
|
||||
var tweets = initTable[int64, Tweet]()
|
||||
if tweetIds.len > 0:
|
||||
try:
|
||||
for t in await getGraphTweetResults(tweetIds):
|
||||
tweets[t.id] = t
|
||||
except CatchableError:
|
||||
discard
|
||||
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
path = getPath()
|
||||
html = renderArticle(article, tweets, path, prefs, @"id")
|
||||
twitterUrl = "https://x.com/" & article.user.username & "/article/" & @"id"
|
||||
resp renderMain(html, request, cfg, prefs, titleText=article.title,
|
||||
twitterLink=twitterUrl)
|
||||
|
||||
get "/@name/article/@id/?":
|
||||
cond '.' notin @"name"
|
||||
cond @"id".allCharsInSet(Digits)
|
||||
redirect("/i/article/" & @"id")
|
||||
|
||||
get "/@name/status/@id/article":
|
||||
cond '.' notin @"name"
|
||||
cond @"id".allCharsInSet(Digits)
|
||||
redirect("/i/article/" & @"id")
|
||||
44
src/routes/broadcast.nim
Normal file
44
src/routes/broadcast.nim
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import asyncdispatch, strutils
|
||||
import jester
|
||||
|
||||
import router_utils
|
||||
import ".."/[types, formatters, redis_cache]
|
||||
import ../views/[general, broadcast]
|
||||
import media
|
||||
|
||||
export broadcast
|
||||
|
||||
proc createBroadcastRouter*(cfg: Config) =
|
||||
router broadcastRoute:
|
||||
get "/i/broadcasts/@id":
|
||||
cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
|
||||
var bc: Broadcast
|
||||
try:
|
||||
bc = await getCachedBroadcast(@"id")
|
||||
except:
|
||||
discard
|
||||
|
||||
if bc.id.len == 0:
|
||||
resp Http404, showError("Broadcast not found", cfg)
|
||||
|
||||
let prefs = requestPrefs()
|
||||
resp renderMain(renderBroadcast(bc, prefs, request.path), request, cfg, prefs,
|
||||
bc.title, ogTitle=bc.title)
|
||||
|
||||
get "/i/broadcasts/@id/stream":
|
||||
cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
|
||||
var bc: Broadcast
|
||||
try:
|
||||
bc = await getCachedBroadcast(@"id")
|
||||
except:
|
||||
discard
|
||||
|
||||
if bc.m3u8Url.len == 0:
|
||||
resp Http404
|
||||
|
||||
let manifest = await safeFetch(bc.m3u8Url)
|
||||
if manifest.len == 0:
|
||||
resp Http502
|
||||
|
||||
resp proxifyVideo(manifest, requestPrefs().proxyVideos, bc.m3u8Url), m3u8Mime
|
||||
89
src/routes/community.nim
Normal file
89
src/routes/community.nim
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strformat
|
||||
|
||||
import jester
|
||||
|
||||
import router_utils
|
||||
import ".."/[types, redis_cache, api]
|
||||
import ../views/[general, timeline, community]
|
||||
|
||||
export community
|
||||
|
||||
template respCommunity*(cmty: Community; title: string; nav, vnode: typed) =
|
||||
if cmty.id.len == 0 or cmty.name.len == 0:
|
||||
resp Http404, showError(&"""Community "{@"id"}" not found""", cfg)
|
||||
|
||||
let html = renderCommunity(vnode, nav, cmty)
|
||||
resp renderMain(html, request, cfg, prefs, titleText=title, banner=cmty.banner)
|
||||
|
||||
proc createCommunityRouter*(cfg: Config) =
|
||||
router community:
|
||||
get "/i/communities/@id/?":
|
||||
cond '.' notin @"id"
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
cmty = await getCachedCommunity(@"id")
|
||||
tl = await getGraphCommunityTweets(cmty.id, "Relevance", getCursor())
|
||||
respCommunity(cmty, cmty.name,
|
||||
renderCommunityTabs(QueryKind.posts, cmty),
|
||||
renderTimelineTweets(tl, prefs, request.path))
|
||||
|
||||
get "/i/communities/@id/latest":
|
||||
cond '.' notin @"id"
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
cmty = await getCachedCommunity(@"id")
|
||||
tl = await getGraphCommunityTweets(cmty.id, "Recency", getCursor())
|
||||
respCommunity(cmty, cmty.name & " - Latest",
|
||||
renderCommunityTabs(QueryKind.replies, cmty),
|
||||
renderTimelineTweets(tl, prefs, request.path))
|
||||
|
||||
get "/i/communities/@id/media":
|
||||
cond '.' notin @"id"
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
cmty = await getCachedCommunity(@"id")
|
||||
tl = await getGraphCommunityMedia(cmty.id, getCursor())
|
||||
respCommunity(cmty, cmty.name & " - Media",
|
||||
renderCommunityTabs(QueryKind.media, cmty),
|
||||
renderTimelineTweets(tl, prefs, request.path))
|
||||
|
||||
get "/i/communities/@id/about":
|
||||
cond '.' notin @"id"
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
cmty = await getCachedCommunity(@"id")
|
||||
mods = await getCachedCommunityModerators(cmty.id)
|
||||
respCommunity(cmty, cmty.name & " - About",
|
||||
renderCommunityTabs(QueryKind.userList, cmty),
|
||||
renderCommunityAbout(cmty, mods))
|
||||
|
||||
get "/i/communities/@id/members":
|
||||
cond '.' notin @"id"
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
cmty = await getCachedCommunity(@"id")
|
||||
members = await getGraphCommunityMembers(cmty.id, getCursor())
|
||||
respCommunity(cmty, cmty.name & " - Members",
|
||||
renderMemberTabs(cmty, false),
|
||||
renderTimelineUsers(members, prefs, request.path))
|
||||
|
||||
get "/i/communities/@id/moderators":
|
||||
cond '.' notin @"id"
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
cmty = await getCachedCommunity(@"id")
|
||||
mods = await getCachedCommunityModerators(cmty.id)
|
||||
respCommunity(cmty, cmty.name & " - Moderators",
|
||||
renderMemberTabs(cmty, true),
|
||||
renderTimelineUsers(Result[User](content: mods), prefs, request.path))
|
||||
|
||||
get "/i/communities/@id/hashtag/@tag":
|
||||
cond '.' notin @"id"
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
cmty = await getCachedCommunity(@"id")
|
||||
tl = await getGraphCommunityHashtags(cmty.id, @"tag", getCursor())
|
||||
respCommunity(cmty, cmty.name & " - #" & @"tag",
|
||||
renderHashtagHeader(cmty, @"tag"),
|
||||
renderTimelineTweets(tl, prefs, request.path))
|
||||
|
|
@ -1,29 +1,79 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import asyncdispatch, strutils, strformat, options
|
||||
import asyncdispatch, strutils, strformat, json
|
||||
import jester, karax/vdom
|
||||
import ".."/[types, api]
|
||||
import ".."/[types, api, formatters]
|
||||
import ../views/[embed, tweet, general]
|
||||
include "../views/oembed.nimf"
|
||||
import router_utils
|
||||
|
||||
export api, embed, vdom, tweet, general, router_utils
|
||||
|
||||
proc parseTweetPath(path: string): tuple[username, id: string] =
|
||||
let parts = path.split('/')
|
||||
if parts.len >= 3 and parts[1] in ["status", "statuses"]:
|
||||
let tweetId = parts[2].split('?')[0].split('#')[0]
|
||||
if tweetId.len > 0 and tweetId.allCharsInSet(Digits):
|
||||
return (parts[0], tweetId)
|
||||
return ("", "")
|
||||
|
||||
proc parseTweetUrl*(url: string; cfg: Config): tuple[username, id: string] =
|
||||
var path = url
|
||||
if path.startsWith("https://"):
|
||||
path = path[8..^1]
|
||||
elif path.startsWith("http://"):
|
||||
path = path[7..^1]
|
||||
|
||||
const twitterPrefixes = ["twitter.com/", "x.com/", "mobile.twitter.com/",
|
||||
"www.twitter.com/", "www.x.com/"]
|
||||
|
||||
for prefix in twitterPrefixes:
|
||||
if path.startsWith(prefix):
|
||||
return parseTweetPath(path[prefix.len..^1])
|
||||
|
||||
let nitterPrefix = cfg.hostname & "/"
|
||||
if path.startsWith(nitterPrefix):
|
||||
return parseTweetPath(path[nitterPrefix.len..^1])
|
||||
|
||||
# Fall back: strip any hostname and try to parse as a tweet path.
|
||||
# Handles requests where the URL's host differs from cfg.hostname
|
||||
# (e.g. localhost in dev/CI, or a reverse proxy with a different domain).
|
||||
let slashPos = path.find('/')
|
||||
if slashPos > 0:
|
||||
let afterHost = path[slashPos + 1..^1]
|
||||
let parsed = parseTweetPath(afterHost)
|
||||
if parsed.username.len > 0:
|
||||
return parsed
|
||||
|
||||
return ("", "")
|
||||
|
||||
proc createEmbedRouter*(cfg: Config) =
|
||||
router embed:
|
||||
get "/i/videos/tweet/@id":
|
||||
let tweet = await getGraphTweetResult(@"id")
|
||||
if tweet == nil or tweet.video.isNone:
|
||||
resp Http404
|
||||
let
|
||||
id = @"id"
|
||||
tweet = await getTweetByRestId(id)
|
||||
prefs = requestPrefs()
|
||||
|
||||
if tweet == nil:
|
||||
resp renderErrorEmbed("Tweet not found", prefs, cfg, request, tweetId=id)
|
||||
|
||||
if not tweet.hasVideos:
|
||||
resp renderErrorEmbed("No video in tweet", prefs, cfg, request,
|
||||
tweetId=id, username=tweet.user.username)
|
||||
|
||||
resp renderVideoEmbed(tweet, cfg, request)
|
||||
|
||||
get "/@user/status/@id/embed":
|
||||
let
|
||||
tweet = await getGraphTweetResult(@"id")
|
||||
prefs = cookiePrefs()
|
||||
id = @"id"
|
||||
user = @"user"
|
||||
tweet = await getTweetByRestId(id)
|
||||
prefs = requestPrefs()
|
||||
path = getPath()
|
||||
|
||||
if tweet == nil:
|
||||
resp Http404
|
||||
resp renderErrorEmbed("Tweet not found", prefs, cfg, request,
|
||||
tweetId=id, username=user)
|
||||
|
||||
resp renderTweetEmbed(tweet, path, prefs, cfg, request)
|
||||
|
||||
|
|
@ -34,3 +84,57 @@ proc createEmbedRouter*(cfg: Config) =
|
|||
redirect(&"/i/status/{id}/embed")
|
||||
else:
|
||||
resp Http404
|
||||
|
||||
get "/api/oembed":
|
||||
responseHeaders().get.add(("Access-Control-Allow-Origin", "*"))
|
||||
|
||||
let
|
||||
url = @"url"
|
||||
format = @"format"
|
||||
|
||||
if format.len > 0 and format != "json":
|
||||
resp Http501, "Only JSON format is supported"
|
||||
|
||||
if url.len == 0:
|
||||
resp Http400, "Missing url parameter"
|
||||
|
||||
let (username, tweetId) = parseTweetUrl(url, cfg)
|
||||
if username.len == 0 or tweetId.len == 0:
|
||||
resp Http400, "Invalid tweet URL"
|
||||
|
||||
let tweet = await getTweetByRestId(tweetId)
|
||||
if tweet == nil:
|
||||
resp Http404
|
||||
|
||||
let
|
||||
maxwidthParam = @"maxwidth"
|
||||
maxwidth = if maxwidthParam.len > 0:
|
||||
try: clamp(parseInt(maxwidthParam), 220, 550)
|
||||
except ValueError: 550
|
||||
else: 550
|
||||
embedUrl = getUrlPrefix(cfg) & "/" & tweet.user.username & "/status/" & tweetId & "/embed"
|
||||
authorUrl = getUrlPrefix(cfg) & "/" & tweet.user.username
|
||||
title = stripHtml(tweet.text)
|
||||
|
||||
var response = %*{
|
||||
"version": "1.0",
|
||||
"type": "rich",
|
||||
"provider_name": cfg.title,
|
||||
"provider_url": getUrlPrefix(cfg),
|
||||
"title": title,
|
||||
"author_name": tweet.user.fullname,
|
||||
"author_url": authorUrl,
|
||||
"url": embedUrl,
|
||||
"width": maxwidth,
|
||||
"height": newJNull(),
|
||||
"cache_age": "3153600000",
|
||||
"html": renderOembedIframe(embedUrl, maxwidth)
|
||||
}
|
||||
|
||||
if tweet.media.len > 0:
|
||||
let thumbUrl = getUrlPrefix(cfg) & getPicUrl(tweet.media[0].getThumb)
|
||||
response["thumbnail_url"] = %thumbUrl
|
||||
response["thumbnail_width"] = %maxwidth
|
||||
response["thumbnail_height"] = %maxwidth
|
||||
|
||||
respJson response
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ template respList*(list, timeline, title, vnode: typed) =
|
|||
|
||||
let
|
||||
html = renderList(vnode, timeline.query, list)
|
||||
rss = &"""/i/lists/{@"id"}/rss"""
|
||||
rss = if cfg.enableRSSList: &"""/i/lists/{@"id"}/rss""" else: ""
|
||||
|
||||
resp renderMain(html, request, cfg, prefs, titleText=title, rss=rss, banner=list.banner)
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ proc createListRouter*(cfg: Config) =
|
|||
get "/i/lists/@id/?":
|
||||
cond '.' notin @"id"
|
||||
let
|
||||
prefs = cookiePrefs()
|
||||
prefs = requestPrefs()
|
||||
list = await getCachedList(id=(@"id"))
|
||||
timeline = await getGraphListTweets(list.id, getCursor())
|
||||
vnode = renderTimelineTweets(timeline, prefs, request.path)
|
||||
|
|
@ -45,7 +45,7 @@ proc createListRouter*(cfg: Config) =
|
|||
get "/i/lists/@id/members":
|
||||
cond '.' notin @"id"
|
||||
let
|
||||
prefs = cookiePrefs()
|
||||
prefs = requestPrefs()
|
||||
list = await getCachedList(id=(@"id"))
|
||||
members = await getGraphListMembers(list, getCursor())
|
||||
respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import uri, strutils, httpclient, os, hashes, base64, re
|
||||
import asynchttpserver, asyncstreams, asyncfile, asyncnet
|
||||
import asyncdispatch
|
||||
|
||||
import jester
|
||||
|
||||
|
|
@ -15,7 +16,9 @@ const
|
|||
maxAge* = "max-age=604800"
|
||||
|
||||
proc safeFetch*(url: string): Future[string] {.async.} =
|
||||
let client = newAsyncHttpClient()
|
||||
# maxRedirects=0: the caller already validated the host, so never follow a
|
||||
# redirect off the allowlisted host (would re-open the #1411 SSRF).
|
||||
let client = newAsyncHttpClient(maxRedirects = 0)
|
||||
try: result = await client.getContent(url)
|
||||
except: discard
|
||||
finally: client.close()
|
||||
|
|
@ -30,47 +33,79 @@ template respond*(req: asynchttpserver.Request; headers) =
|
|||
|
||||
proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
|
||||
result = Http200
|
||||
let
|
||||
request = req.getNativeReq()
|
||||
client = newAsyncHttpClient()
|
||||
let request = req.getNativeReq()
|
||||
var fetchUrl = url
|
||||
|
||||
try:
|
||||
let res = await client.get(url)
|
||||
if res.status != "200 OK":
|
||||
if res.status != "404 Not Found":
|
||||
echo "[media] Proxying failed, status: $1, url: $2" % [res.status, url]
|
||||
return Http404
|
||||
|
||||
let hashed = $hash(url)
|
||||
if request.headers.getOrDefault("If-None-Match") == hashed:
|
||||
return Http304
|
||||
|
||||
let contentLength =
|
||||
if res.headers.hasKey("content-length"):
|
||||
res.headers["content-length", 0]
|
||||
for attempt in 0 .. 2:
|
||||
let client = newAsyncHttpClient(maxRedirects = 0)
|
||||
var shouldRetry = false
|
||||
try:
|
||||
let resFut = client.get(fetchUrl)
|
||||
let completed = await withTimeout(resFut, 5000)
|
||||
if not completed:
|
||||
if attempt < 2:
|
||||
echo "[media] Retry $1/2, timeout after 5s, url: $2" % [$(attempt + 1), fetchUrl]
|
||||
shouldRetry = true
|
||||
else:
|
||||
echo "[media] Proxying timeout after 5s, url: $1" % [fetchUrl]
|
||||
return Http504
|
||||
else:
|
||||
""
|
||||
let res = resFut.read()
|
||||
if res.status != "200 OK":
|
||||
if res.status == "404 Not Found":
|
||||
return Http404
|
||||
if res.status.startsWith("30") and res.headers.hasKey("location"):
|
||||
let location = res.headers["location", 0]
|
||||
if isTwitterUrl(location):
|
||||
fetchUrl = location
|
||||
shouldRetry = true
|
||||
continue
|
||||
else:
|
||||
return Http403
|
||||
if attempt < 2:
|
||||
echo "[media] Retry $1/2, status: $2, url: $3" % [$(attempt + 1), res.status, fetchUrl]
|
||||
shouldRetry = true
|
||||
else:
|
||||
echo "[media] Proxying failed, status: $1, url: $2" % [res.status, fetchUrl]
|
||||
return Http404
|
||||
else:
|
||||
let hashed = $hash(url)
|
||||
if request.headers.getOrDefault("If-None-Match") == hashed:
|
||||
return Http304
|
||||
|
||||
let headers = newHttpHeaders({
|
||||
"Content-Type": res.headers["content-type", 0],
|
||||
"Content-Length": contentLength,
|
||||
"Cache-Control": maxAge,
|
||||
"ETag": hashed
|
||||
})
|
||||
let contentLength =
|
||||
if res.headers.hasKey("content-length"):
|
||||
res.headers["content-length", 0]
|
||||
else:
|
||||
""
|
||||
|
||||
respond(request, headers)
|
||||
let headers = newHttpHeaders({
|
||||
"content-type": res.headers["content-type", 0],
|
||||
"content-length": contentLength,
|
||||
"cache-control": maxAge,
|
||||
"etag": hashed
|
||||
})
|
||||
|
||||
var (hasValue, data) = (true, "")
|
||||
while hasValue:
|
||||
(hasValue, data) = await res.bodyStream.read()
|
||||
if hasValue:
|
||||
await request.client.send(data)
|
||||
data.setLen 0
|
||||
except HttpRequestError, ProtocolError, OSError:
|
||||
echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url]
|
||||
result = Http404
|
||||
finally:
|
||||
client.close()
|
||||
respond(request, headers)
|
||||
|
||||
var (hasValue, data) = (true, "")
|
||||
while hasValue:
|
||||
(hasValue, data) = await res.bodyStream.read()
|
||||
if hasValue:
|
||||
await request.client.send(data)
|
||||
data.setLen 0
|
||||
return Http200
|
||||
except CatchableError:
|
||||
if attempt < 2:
|
||||
echo "[media] Retry $1/2, error: $2, url: $3" % [$(attempt + 1), getCurrentExceptionMsg(), fetchUrl]
|
||||
shouldRetry = true
|
||||
else:
|
||||
echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), fetchUrl]
|
||||
result = Http404
|
||||
finally:
|
||||
client.close()
|
||||
if not shouldRetry:
|
||||
break
|
||||
|
||||
template check*(code): untyped =
|
||||
if code != Http200:
|
||||
|
|
@ -86,6 +121,12 @@ proc decoded*(req: jester.Request; index: int): string =
|
|||
if based: decode(encoded)
|
||||
else: decodeUrl(encoded)
|
||||
|
||||
proc normalizeImgUrl*(url: var string) =
|
||||
if not url.startsWith("http"):
|
||||
if "twimg.com" notin url:
|
||||
url.insert(twimg)
|
||||
url.insert(https)
|
||||
|
||||
proc createMediaRouter*(cfg: Config) =
|
||||
router media:
|
||||
get "/pic/?":
|
||||
|
|
@ -93,10 +134,8 @@ proc createMediaRouter*(cfg: Config) =
|
|||
|
||||
get re"^\/pic\/orig\/(enc)?\/?(.+)":
|
||||
var url = decoded(request, 1)
|
||||
if "twimg.com" notin url:
|
||||
url.insert(twimg)
|
||||
if not url.startsWith(https):
|
||||
url.insert(https)
|
||||
cond "/amplify_video/" notin url
|
||||
normalizeImgUrl(url)
|
||||
url.add("?name=orig")
|
||||
|
||||
let uri = parseUri(url)
|
||||
|
|
@ -107,10 +146,8 @@ proc createMediaRouter*(cfg: Config) =
|
|||
|
||||
get re"^\/pic\/(enc)?\/?(.+)":
|
||||
var url = decoded(request, 1)
|
||||
if "twimg.com" notin url:
|
||||
url.insert(twimg)
|
||||
if not url.startsWith(https):
|
||||
url.insert(https)
|
||||
cond "/amplify_video/" notin url
|
||||
normalizeImgUrl(url)
|
||||
|
||||
let uri = parseUri(url)
|
||||
cond isTwitterUrl(uri) == true
|
||||
|
|
@ -120,12 +157,12 @@ proc createMediaRouter*(cfg: Config) =
|
|||
|
||||
get re"^\/video\/(enc)?\/?(.+)\/(.+)$":
|
||||
let url = decoded(request, 2)
|
||||
cond "http" in url
|
||||
cond isTwitterUrl(url)
|
||||
|
||||
if getHmac(url) != request.matches[1]:
|
||||
resp Http403, showError("Failed to verify signature", cfg)
|
||||
|
||||
if ".mp4" in url or ".ts" in url or ".m4s" in url:
|
||||
if ".mp4" in url or ".ts" in url or ".m4s" in url or ".aac" in url:
|
||||
let code = await proxyMedia(request, url)
|
||||
check code
|
||||
|
||||
|
|
@ -139,6 +176,6 @@ proc createMediaRouter*(cfg: Config) =
|
|||
|
||||
if ".m3u8" in url:
|
||||
let vid = await safeFetch(url)
|
||||
content = proxifyVideo(vid, cookiePref(proxyVideos))
|
||||
content = proxifyVideo(vid, requestPrefs().proxyVideos, url)
|
||||
|
||||
resp content, m3u8Mime
|
||||
|
|
|
|||
|
|
@ -19,8 +19,10 @@ proc createPrefRouter*(cfg: Config) =
|
|||
router preferences:
|
||||
get "/settings":
|
||||
let
|
||||
prefs = cookiePrefs()
|
||||
html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir))
|
||||
prefs = requestPrefs()
|
||||
prefsCode = encodePrefs(prefs)
|
||||
prefsUrl = getUrlPrefix(cfg) & "/?prefs=" & prefsCode
|
||||
html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir), prefsUrl)
|
||||
resp renderMain(html, request, cfg, prefs, "Preferences")
|
||||
|
||||
get "/settings/@i?":
|
||||
|
|
@ -38,3 +40,6 @@ proc createPrefRouter*(cfg: Config) =
|
|||
savePref("hlsPlayback", "on", request)
|
||||
redirect(refPath())
|
||||
|
||||
post "/enablemp4":
|
||||
savePref("mp4Playback", "on", request)
|
||||
redirect(refPath())
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ proc createResolverRouter*(cfg: Config) =
|
|||
router resolver:
|
||||
get "/cards/@card/@id":
|
||||
let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"]
|
||||
respResolved(await resolve(url, cookiePrefs()), "card")
|
||||
respResolved(await resolve(url, requestPrefs()), "card")
|
||||
|
||||
get "/t.co/@url":
|
||||
let url = "https://t.co/" & @"url"
|
||||
respResolved(await resolve(url, cookiePrefs()), "t.co")
|
||||
respResolved(await resolve(url, requestPrefs()), "t.co")
|
||||
|
|
|
|||
|
|
@ -4,26 +4,19 @@ from jester import Request, cookies
|
|||
|
||||
import ../views/general
|
||||
import ".."/[utils, prefs, types]
|
||||
export utils, prefs, types, uri
|
||||
export utils, prefs, types, uri, json
|
||||
|
||||
template savePref*(pref, value: string; req: Request; expire=false) =
|
||||
if not expire or pref in cookies(req):
|
||||
let sameSite = if cfg.useHttps: None else: Lax
|
||||
setCookie(pref, value, daysForward(when expire: -10 else: 360),
|
||||
httpOnly=true, secure=cfg.useHttps, sameSite=None)
|
||||
httpOnly=true, secure=cfg.useHttps, sameSite=sameSite, path="/")
|
||||
|
||||
template cookiePrefs*(): untyped {.dirty.} =
|
||||
getPrefs(cookies(request))
|
||||
|
||||
template cookiePref*(pref): untyped {.dirty.} =
|
||||
getPref(cookies(request), pref)
|
||||
|
||||
template themePrefs*(): Prefs =
|
||||
var res = defaultPrefs
|
||||
res.theme = cookiePref(theme)
|
||||
res
|
||||
template requestPrefs*(): untyped {.dirty.} =
|
||||
getPrefs(cookies(request), params(request))
|
||||
|
||||
template showError*(error: string; cfg: Config): string =
|
||||
renderMain(renderError(error), request, cfg, themePrefs(), "Error")
|
||||
renderMain(renderError(error), request, cfg, requestPrefs(), "Error")
|
||||
|
||||
template getPath*(): untyped {.dirty.} =
|
||||
$(parseUri(request.path) ? filterParams(request.params))
|
||||
|
|
@ -43,5 +36,28 @@ template getCursor*(req: Request): string =
|
|||
proc getNames*(name: string): seq[string] =
|
||||
name.strip(chars={'/'}).split(",").filterIt(it.len > 0)
|
||||
|
||||
template applyUrlPrefs*() {.dirty.} =
|
||||
if @"prefs".len > 0:
|
||||
var prefParams = initTable[string, string]()
|
||||
for pair in @"prefs".split(','):
|
||||
let kv = pair.split('=', maxsplit=1)
|
||||
if kv.len == 2:
|
||||
prefParams[kv[0]] = kv[1]
|
||||
elif kv.len == 1 and kv[0].len > 0:
|
||||
prefParams[kv[0]] = ""
|
||||
genApplyPrefs(prefParams, request)
|
||||
|
||||
# Rebuild URL without prefs param
|
||||
var params: seq[(string, string)]
|
||||
for k, v in request.params:
|
||||
if k != "prefs":
|
||||
params.add (k, v)
|
||||
|
||||
if params.len > 0:
|
||||
let cleanUrl = request.getNativeReq.url ? params
|
||||
redirect($cleanUrl)
|
||||
else:
|
||||
redirect(request.path)
|
||||
|
||||
template respJson*(node: JsonNode) =
|
||||
resp $node, "application/json"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ proc redisKey*(page, name, cursor: string): string =
|
|||
if cursor.len > 0:
|
||||
result &= ":" & cursor
|
||||
|
||||
proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.} =
|
||||
proc timelineRss*(req: Request; cfg: Config; query: Query; prefs: Prefs): Future[Rss] {.async.} =
|
||||
var profile: Profile
|
||||
let
|
||||
name = req.params.getOrDefault("name")
|
||||
|
|
@ -39,7 +39,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.
|
|||
return Rss(feed: profile.user.username, cursor: "suspended")
|
||||
|
||||
if profile.user.fullname.len > 0:
|
||||
let rss = renderTimelineRss(profile, cfg, multi=(names.len > 1))
|
||||
let rss = renderTimelineRss(profile, cfg, prefs, multi=(names.len > 1))
|
||||
return Rss(feed: rss, cursor: profile.tweets.bottom)
|
||||
|
||||
template respRss*(rss, page) =
|
||||
|
|
@ -60,12 +60,15 @@ template respRss*(rss, page) =
|
|||
proc createRssRouter*(cfg: Config) =
|
||||
router rss:
|
||||
get "/search/rss":
|
||||
cond cfg.enableRss
|
||||
if not cfg.enableRSSSearch:
|
||||
resp Http403, showError("RSS feed is disabled", cfg)
|
||||
if @"q".len > 200:
|
||||
resp Http400, showError("Search input too long.", cfg)
|
||||
|
||||
let query = initQuery(params(request))
|
||||
if query.kind != tweets:
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
query = initQuery(params(request))
|
||||
if query.kind notin {QueryKind.tweets, QueryKind.top, QueryKind.media}:
|
||||
resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg)
|
||||
|
||||
let
|
||||
|
|
@ -78,15 +81,17 @@ proc createRssRouter*(cfg: Config) =
|
|||
|
||||
let tweets = await getGraphTweetSearch(query, cursor)
|
||||
rss.cursor = tweets.bottom
|
||||
rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg)
|
||||
rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg, prefs)
|
||||
|
||||
await cacheRss(key, rss)
|
||||
respRss(rss, "Search")
|
||||
|
||||
get "/@name/rss":
|
||||
cond cfg.enableRss
|
||||
cond '.' notin @"name"
|
||||
if not cfg.enableRSSUserTweets:
|
||||
resp Http403, showError("RSS feed is disabled", cfg)
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
name = @"name"
|
||||
key = redisKey("twitter", name, getCursor())
|
||||
|
||||
|
|
@ -94,24 +99,23 @@ proc createRssRouter*(cfg: Config) =
|
|||
if rss.cursor.len > 0:
|
||||
respRss(rss, "User")
|
||||
|
||||
rss = await timelineRss(request, cfg, Query(fromUser: @[name]))
|
||||
rss = await timelineRss(request, cfg, Query(fromUser: @[name]), prefs)
|
||||
|
||||
await cacheRss(key, rss)
|
||||
respRss(rss, "User")
|
||||
|
||||
get "/@name/@tab/rss":
|
||||
cond cfg.enableRss
|
||||
cond '.' notin @"name"
|
||||
cond @"tab" in ["with_replies", "media", "search"]
|
||||
cond @"tab" in ["with_replies", "media", "search", "articles"]
|
||||
# articles can't be approximated by search, so multi-user is unsupported
|
||||
cond not (@"tab" == "articles" and ',' in @"name")
|
||||
if not cfg.tabRssEnabled(@"tab"):
|
||||
resp Http403, showError("RSS feed is disabled", cfg)
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
name = @"name"
|
||||
tab = @"tab"
|
||||
query =
|
||||
case tab
|
||||
of "with_replies": getReplyQuery(name)
|
||||
of "media": getMediaQuery(name)
|
||||
of "search": initQuery(params(request), name=name)
|
||||
else: Query(fromUser: @[name])
|
||||
query = request.getQuery(tab, name, prefs)
|
||||
|
||||
let searchKey = if tab != "search": ""
|
||||
else: ":" & $hash(genQueryUrl(query))
|
||||
|
|
@ -122,14 +126,15 @@ proc createRssRouter*(cfg: Config) =
|
|||
if rss.cursor.len > 0:
|
||||
respRss(rss, "User")
|
||||
|
||||
rss = await timelineRss(request, cfg, query)
|
||||
rss = await timelineRss(request, cfg, query, prefs)
|
||||
|
||||
await cacheRss(key, rss)
|
||||
respRss(rss, "User")
|
||||
|
||||
get "/@name/lists/@slug/rss":
|
||||
cond cfg.enableRss
|
||||
cond @"name" != "i"
|
||||
if not cfg.enableRSSList:
|
||||
resp Http403, showError("RSS feed is disabled", cfg)
|
||||
let
|
||||
slug = decodeUrl(@"slug")
|
||||
list = await getCachedList(@"name", slug)
|
||||
|
|
@ -145,8 +150,10 @@ proc createRssRouter*(cfg: Config) =
|
|||
redirect(url)
|
||||
|
||||
get "/i/lists/@id/rss":
|
||||
cond cfg.enableRss
|
||||
if not cfg.enableRSSList:
|
||||
resp Http403, showError("RSS feed is disabled", cfg)
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
id = @"id"
|
||||
cursor = getCursor()
|
||||
key = redisKey("lists", id, cursor)
|
||||
|
|
@ -159,7 +166,7 @@ proc createRssRouter*(cfg: Config) =
|
|||
list = await getCachedList(id=id)
|
||||
timeline = await getGraphListTweets(list.id, cursor)
|
||||
rss.cursor = timeline.bottom
|
||||
rss.feed = renderListRss(timeline.content, list, cfg)
|
||||
rss.feed = renderListRss(timeline.content, list, cfg, prefs)
|
||||
|
||||
await cacheRss(key, rss)
|
||||
respRss(rss, "List")
|
||||
|
|
|
|||
|
|
@ -19,10 +19,22 @@ proc createSearchRouter*(cfg: Config) =
|
|||
resp Http400, showError("Search input too long.", cfg)
|
||||
|
||||
let
|
||||
prefs = cookiePrefs()
|
||||
query = initQuery(params(request))
|
||||
prefs = requestPrefs()
|
||||
title = "Search" & (if q.len > 0: " (" & q & ")" else: "")
|
||||
|
||||
var query = initQuery(params(request))
|
||||
# x.com URL compat: f=user and f=list map to our kind names
|
||||
# (f=live already falls back to tweets/Latest; f=media matches natively)
|
||||
if @"f" == "user":
|
||||
query.kind = users
|
||||
elif @"f" == "list":
|
||||
query.kind = lists
|
||||
|
||||
# media searches support view modes, defaulting like /user/media
|
||||
if query.kind == QueryKind.media and
|
||||
query.view notin ["timeline", "grid", "gallery"]:
|
||||
query.view = prefs.mediaView.toLowerAscii
|
||||
|
||||
case query.kind
|
||||
of users:
|
||||
if "," in q:
|
||||
|
|
@ -33,19 +45,24 @@ proc createSearchRouter*(cfg: Config) =
|
|||
except InternalError:
|
||||
users = Result[User](beginning: true, query: query)
|
||||
resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title)
|
||||
of tweets:
|
||||
of tweets, top, QueryKind.media:
|
||||
let
|
||||
tweets = await getGraphTweetSearch(query, getCursor())
|
||||
rss = "/search/rss?" & genQueryUrl(query)
|
||||
rss = if cfg.enableRSSSearch: "/search/rss?" & genQueryUrl(query) else: ""
|
||||
resp renderMain(renderTweetSearch(tweets, prefs, getPath()),
|
||||
request, cfg, prefs, title, rss=rss)
|
||||
of lists:
|
||||
let listResults = await getGraphListSearch(query, getCursor())
|
||||
resp renderMain(renderListSearch(listResults, prefs, getPath()),
|
||||
request, cfg, prefs, title)
|
||||
else:
|
||||
resp Http404, showError("Invalid search", cfg)
|
||||
|
||||
get "/hashtag/@hash":
|
||||
redirect("/search?q=" & encodeUrl("#" & @"hash"))
|
||||
redirect("/search?f=tweets&q=" & encodeUrl("#" & @"hash"))
|
||||
|
||||
get "/opensearch":
|
||||
let url = getUrlPrefix(cfg) & "/search?q="
|
||||
resp Http200, {"Content-Type": "application/opensearchdescription+xml"},
|
||||
generateOpenSearchXML(cfg.title, cfg.hostname, url)
|
||||
let
|
||||
url = getUrlPrefix(cfg) & "/search?f=tweets&q="
|
||||
headers = {"Content-Type": "application/opensearchdescription+xml"}
|
||||
resp Http200, headers, generateOpenSearchXML(cfg.title, cfg.hostname, url)
|
||||
|
|
|
|||
36
src/routes/space.nim
Normal file
36
src/routes/space.nim
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import asyncdispatch, strutils
|
||||
import jester
|
||||
|
||||
import router_utils
|
||||
import ".."/[types, formatters, redis_cache]
|
||||
import ../views/[general, space]
|
||||
import media
|
||||
|
||||
export space
|
||||
|
||||
proc createSpaceRouter*(cfg: Config) =
|
||||
router spaceRoute:
|
||||
get "/i/spaces/@id":
|
||||
cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
|
||||
let sp = await getCachedAudioSpace(@"id")
|
||||
|
||||
if sp.id.len == 0:
|
||||
resp Http404, showError("Space not found", cfg)
|
||||
|
||||
let prefs = requestPrefs()
|
||||
resp renderMain(renderSpace(sp, prefs, request.path), request, cfg, prefs,
|
||||
sp.title, ogTitle=sp.title)
|
||||
|
||||
get "/i/spaces/@id/stream":
|
||||
cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
|
||||
let sp = await getCachedAudioSpace(@"id")
|
||||
|
||||
if sp.m3u8Url.len == 0:
|
||||
resp Http404
|
||||
|
||||
let manifest = await safeFetch(sp.m3u8Url)
|
||||
if manifest.len == 0:
|
||||
resp Http502
|
||||
|
||||
resp proxifyVideo(manifest, requestPrefs().proxyVideos, sp.m3u8Url), m3u8Mime
|
||||
|
|
@ -21,18 +21,18 @@ proc createStatusRouter*(cfg: Config) =
|
|||
if id.len > 19 or id.any(c => not c.isDigit):
|
||||
resp Http404, showError("Invalid tweet ID", cfg)
|
||||
|
||||
let prefs = cookiePrefs()
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
sort = parseEnum[RankingMode](@"sort".toLowerAscii.capitalizeAscii, Relevance)
|
||||
|
||||
# used for the infinite scroll feature
|
||||
if @"scroll".len > 0:
|
||||
let replies = await getReplies(id, getCursor())
|
||||
let replies = await getReplies(id, getCursor(), sort)
|
||||
if replies.content.len == 0:
|
||||
resp Http404, ""
|
||||
resp $renderReplies(replies, prefs, getPath())
|
||||
resp Http204
|
||||
resp $renderReplies(replies, prefs, getPath(), sort=sort)
|
||||
|
||||
let conv = await getTweet(id, getCursor())
|
||||
if conv == nil:
|
||||
echo "nil conv"
|
||||
let conv = await getTweet(id, getCursor(), sort)
|
||||
|
||||
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
|
||||
var error = "Tweet not found"
|
||||
|
|
@ -46,15 +46,19 @@ proc createStatusRouter*(cfg: Config) =
|
|||
desc = conv.tweet.text
|
||||
|
||||
var
|
||||
images = conv.tweet.photos
|
||||
images = conv.tweet.getPhotos.mapIt(it.url)
|
||||
video = ""
|
||||
|
||||
if conv.tweet.video.isSome():
|
||||
images = @[get(conv.tweet.video).thumb]
|
||||
let
|
||||
firstMediaKind = if conv.tweet.media.len > 0: conv.tweet.media[0].kind
|
||||
else: photoMedia
|
||||
|
||||
if firstMediaKind == videoMedia:
|
||||
images = @[conv.tweet.media[0].getThumb]
|
||||
video = getVideoEmbed(cfg, conv.tweet.id)
|
||||
elif conv.tweet.gif.isSome():
|
||||
images = @[get(conv.tweet.gif).thumb]
|
||||
video = getPicUrl(get(conv.tweet.gif).url)
|
||||
elif firstMediaKind == gifMedia:
|
||||
images = @[conv.tweet.media[0].getThumb]
|
||||
video = getPicUrl(conv.tweet.media[0].gif.url)
|
||||
elif conv.tweet.card.isSome():
|
||||
let card = conv.tweet.card.get()
|
||||
if card.image.len > 0:
|
||||
|
|
@ -62,9 +66,33 @@ proc createStatusRouter*(cfg: Config) =
|
|||
elif card.video.isSome():
|
||||
images = @[card.video.get().thumb]
|
||||
|
||||
let html = renderConversation(conv, prefs, getPath() & "#m")
|
||||
let
|
||||
tweetUrl = getUrlPrefix(cfg) & "/" & conv.tweet.user.username & "/status/" & $conv.tweet.id
|
||||
oembedUrl = getUrlPrefix(cfg) & "/api/oembed?url=" & encodeUrl(tweetUrl)
|
||||
|
||||
let html = renderConversation(conv, prefs, getPath() & "#m", sort)
|
||||
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
|
||||
images=images, video=video)
|
||||
images=images, video=video, oembed=oembedUrl)
|
||||
|
||||
get "/@name/status/@id/history/?":
|
||||
cond '.' notin @"name"
|
||||
let id = @"id"
|
||||
|
||||
if id.len > 19 or id.any(c => not c.isDigit):
|
||||
resp Http404, showError("Invalid tweet ID", cfg)
|
||||
|
||||
let edits = await getGraphEditHistory(id)
|
||||
if edits.latest == nil or edits.latest.id == 0:
|
||||
resp Http404, showError("Tweet history not found", cfg)
|
||||
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
title = "History for " & pageTitle(edits.latest)
|
||||
ogTitle = "Edit History for " & pageTitle(edits.latest.user)
|
||||
desc = edits.latest.text
|
||||
|
||||
let html = renderEditHistory(edits, prefs, getPath())
|
||||
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle)
|
||||
|
||||
get "/@name/@s/@id/@m/?@i?":
|
||||
cond @"s" in ["status", "statuses"]
|
||||
|
|
@ -76,6 +104,6 @@ proc createStatusRouter*(cfg: Config) =
|
|||
|
||||
get "/i/web/status/@id":
|
||||
redirect("/i/status/" & @"id")
|
||||
|
||||
|
||||
get "/@name/thread/@id/?":
|
||||
redirect("/$1/status/$2" % [@"name", @"id"])
|
||||
|
|
|
|||
|
|
@ -4,20 +4,39 @@ import jester, karax/vdom
|
|||
|
||||
import router_utils
|
||||
import ".."/[types, redis_cache, formatters, query, api]
|
||||
import ../views/[general, profile, timeline, status, search]
|
||||
import ../views/[general, profile, timeline, status, search, about_account]
|
||||
|
||||
export vdom
|
||||
export uri, sequtils
|
||||
export router_utils
|
||||
export redis_cache, formatters, query, api
|
||||
export profile, timeline, status
|
||||
export profile, timeline, status, about_account
|
||||
|
||||
proc getQuery*(request: Request; tab, name: string): Query =
|
||||
proc tabRssEnabled*(cfg: Config; tab: string): bool =
|
||||
case tab
|
||||
of "with_replies": getReplyQuery(name)
|
||||
of "media": getMediaQuery(name)
|
||||
of "search": initQuery(params(request), name=name)
|
||||
else: Query(fromUser: @[name])
|
||||
of "": cfg.enableRSSUserTweets
|
||||
of "with_replies": cfg.enableRSSUserReplies
|
||||
of "media": cfg.enableRSSUserMedia
|
||||
of "articles": cfg.enableRSSUserArticles
|
||||
of "search": cfg.enableRSSSearch
|
||||
else: false
|
||||
|
||||
proc getQuery*(request: Request; tab, name: string; prefs: Prefs): Query =
|
||||
let view = request.params.getOrDefault("view")
|
||||
case tab
|
||||
of "with_replies":
|
||||
result = getReplyQuery(name)
|
||||
of "articles":
|
||||
result = getArticlesQuery(name)
|
||||
of "media":
|
||||
result = getMediaQuery(name)
|
||||
result.view =
|
||||
if view in ["timeline", "grid", "gallery"]: view
|
||||
else: prefs.mediaView.toLowerAscii
|
||||
of "search":
|
||||
result = initQuery(params(request), name=name)
|
||||
else:
|
||||
result = Query(fromUser: @[name])
|
||||
|
||||
template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
|
||||
if cond:
|
||||
|
|
@ -45,20 +64,23 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile]
|
|||
|
||||
let
|
||||
rail =
|
||||
skipIf(skipRail or query.kind == media, @[]):
|
||||
skipIf(skipRail or query.kind == QueryKind.media, @[]):
|
||||
getCachedPhotoRail(userId)
|
||||
|
||||
user = getCachedUser(name)
|
||||
info = getCachedAccountInfo(name, fetch=false)
|
||||
|
||||
result =
|
||||
case query.kind
|
||||
of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after)
|
||||
of replies: await getGraphUserTweets(userId, TimelineKind.replies, after)
|
||||
of media: await getGraphUserTweets(userId, TimelineKind.media, after)
|
||||
of QueryKind.articles: await getGraphUserTweets(userId, TimelineKind.articles, after)
|
||||
else: Profile(tweets: await getGraphTweetSearch(query, after))
|
||||
|
||||
result.user = await user
|
||||
result.photoRail = await rail
|
||||
result.accountInfo = await info
|
||||
|
||||
result.tweets.query = query
|
||||
|
||||
|
|
@ -105,16 +127,72 @@ proc createTimelineRouter*(cfg: Config) =
|
|||
get "/intent/user":
|
||||
respUserId()
|
||||
|
||||
get "/intent/follow/?":
|
||||
let username = request.params.getOrDefault("screen_name")
|
||||
if username.len == 0:
|
||||
resp Http400, showError("Missing screen_name parameter", cfg)
|
||||
redirect("/" & username)
|
||||
|
||||
get "/@name/about/?":
|
||||
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'})
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
name = @"name"
|
||||
info = await getCachedAccountInfo(name)
|
||||
if info.suspended:
|
||||
resp showError(getSuspended(name), cfg)
|
||||
if info.username.len == 0:
|
||||
resp Http404, showError("User \"" & name & "\" not found", cfg)
|
||||
let aboutHtml = renderAboutAccount(info)
|
||||
resp renderMain(aboutHtml, request, cfg, prefs,
|
||||
"About @" & info.username)
|
||||
|
||||
get "/@name/@kind/?":
|
||||
cond @"kind" in ["followers", "following"]
|
||||
cond '.' notin @"name"
|
||||
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'})
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
name = @"name"
|
||||
userId = await getUserId(name)
|
||||
|
||||
if userId.len == 0:
|
||||
resp Http404, showError("User \"" & name & "\" not found", cfg)
|
||||
if userId == "suspended":
|
||||
resp showError(getSuspended(name), cfg)
|
||||
|
||||
let
|
||||
cursor = getCursor()
|
||||
isFollowers = @"kind" == "followers"
|
||||
user = await getCachedUser(name)
|
||||
results = if isFollowers: await getGraphFollowers(userId, cursor)
|
||||
else: await getGraphFollowing(userId, cursor)
|
||||
|
||||
if user.protected:
|
||||
resp renderMain(renderProtected(user.username), request, cfg, prefs,
|
||||
"Protected account @" & name)
|
||||
|
||||
let
|
||||
tab = if isFollowers: "Followers" else: "Following"
|
||||
title = (if isFollowers: "People following @" else: "People followed by @") & name
|
||||
html = renderUserList(user, results, prefs, request.path, tab)
|
||||
|
||||
resp renderMain(html, request, cfg, prefs, title,
|
||||
images = @[user.getUserPic("_400x400")])
|
||||
|
||||
get "/@name/?@tab?/?":
|
||||
cond '.' notin @"name"
|
||||
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
|
||||
cond @"tab" in ["with_replies", "media", "search", ""]
|
||||
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','})
|
||||
cond @"tab" in ["with_replies", "media", "search", "articles", ""]
|
||||
# articles can't be approximated by search, so multi-user is unsupported
|
||||
cond not (@"tab" == "articles" and ',' in @"name")
|
||||
let
|
||||
prefs = cookiePrefs()
|
||||
prefs = requestPrefs()
|
||||
after = getCursor()
|
||||
names = getNames(@"name")
|
||||
|
||||
var query = request.getQuery(@"tab", @"name")
|
||||
var query = request.getQuery(@"tab", @"name", prefs)
|
||||
if names.len != 1:
|
||||
query.fromUser = names
|
||||
|
||||
|
|
@ -122,7 +200,8 @@ proc createTimelineRouter*(cfg: Config) =
|
|||
if @"scroll".len > 0:
|
||||
if query.fromUser.len != 1:
|
||||
var timeline = await getGraphTweetSearch(query, after)
|
||||
if timeline.content.len == 0: resp Http404
|
||||
if timeline.content.len == 0:
|
||||
resp Http204
|
||||
timeline.beginning = true
|
||||
resp $renderTweetSearch(timeline, prefs, getPath())
|
||||
else:
|
||||
|
|
@ -132,7 +211,9 @@ proc createTimelineRouter*(cfg: Config) =
|
|||
resp $renderTimelineTweets(profile.tweets, prefs, getPath())
|
||||
|
||||
let rss =
|
||||
if @"tab".len == 0:
|
||||
if not cfg.tabRssEnabled(@"tab"):
|
||||
""
|
||||
elif @"tab".len == 0:
|
||||
"/$1/rss" % @"name"
|
||||
elif @"tab" == "search":
|
||||
"/$1/search/rss?$2" % [@"name", genQueryUrl(query)]
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@ export feature
|
|||
proc createUnsupportedRouter*(cfg: Config) =
|
||||
router unsupported:
|
||||
template feature {.dirty.} =
|
||||
resp renderMain(renderFeature(), request, cfg, themePrefs())
|
||||
resp renderMain(renderFeature(), request, cfg, requestPrefs())
|
||||
|
||||
get "/about/feature": feature()
|
||||
get "/login/?@i?": feature()
|
||||
get "/@name/lists/?": feature()
|
||||
|
||||
get "/intent/?@i?":
|
||||
cond @"i" notin ["user"]
|
||||
cond @"i" notin ["user", "follow"]
|
||||
feature()
|
||||
|
||||
get "/i/@i?/?@j?":
|
||||
|
|
|
|||
278
src/sass/_article.scss
Normal file
278
src/sass/_article.scss
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
.article-page {
|
||||
max-width: 700px;
|
||||
margin: 0 auto 20px;
|
||||
background-color: var(--bg_panel);
|
||||
|
||||
> .top-ref {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.article-cover {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.article-body {
|
||||
padding: 20px;
|
||||
|
||||
> :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
display: block;
|
||||
font-size: 2rem;
|
||||
line-height: 1.3;
|
||||
margin: 0 0 10px;
|
||||
color: var(--fg_color);
|
||||
}
|
||||
|
||||
.article-author {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border_grey);
|
||||
font-size: 14px;
|
||||
|
||||
.article-author-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.article-avatar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.article-author-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 2px;
|
||||
|
||||
.fullname {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.verified-icon {
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.article-author-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fullname {
|
||||
font-weight: 700;
|
||||
color: var(--fg_color);
|
||||
max-width: unset;
|
||||
text-overflow: unset;
|
||||
overflow: visible;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.username,
|
||||
.article-date-sep,
|
||||
.article-date {
|
||||
color: var(--fg_dark);
|
||||
}
|
||||
|
||||
.username {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.article-date-sep {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.article-date {
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.tweet-stats {
|
||||
margin-top: 6px;
|
||||
|
||||
.tweet-stat {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> h1 {
|
||||
display: block;
|
||||
font-size: 1.8rem;
|
||||
margin: 25px 0 15px;
|
||||
}
|
||||
|
||||
> h2 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: bold;
|
||||
margin: 20px 0 12px;
|
||||
}
|
||||
|
||||
> h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
margin: 18px 0 10px;
|
||||
}
|
||||
|
||||
> p {
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
margin: 16px 0;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.blockquote-attribution {
|
||||
display: block;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
> blockquote {
|
||||
border-left: 3px solid var(--accent);
|
||||
padding-left: 16px;
|
||||
margin: 16px 0;
|
||||
color: var(--fg_faded);
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
> pre {
|
||||
background-color: var(--bg_elements);
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 16px 0;
|
||||
|
||||
code {
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
color: var(--fg_color);
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--bg_elements);
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
> ul,
|
||||
> ol {
|
||||
margin: 16px 0;
|
||||
padding-left: 2em;
|
||||
|
||||
li {
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
margin: 6px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.article-media {
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
|
||||
img,
|
||||
video {
|
||||
max-width: 100%;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.article-media-caption {
|
||||
color: var(--fg_faded);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
> a,
|
||||
> p a,
|
||||
> h1 a,
|
||||
> h2 a,
|
||||
> h3 a,
|
||||
> blockquote a,
|
||||
> ul a,
|
||||
> ol a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.article-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border_grey);
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
margin: 20px 0;
|
||||
border: 1px solid var(--border_grey);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.conversation .article-page {
|
||||
max-width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.article-card {
|
||||
.card-image-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-image img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.article-card-badge {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.quote .article-card {
|
||||
margin: 0;
|
||||
|
||||
.card-container {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
border-top: solid 1px var(--dark_grey);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.article-page {
|
||||
.article-body {
|
||||
padding: 12px 15px 25px;
|
||||
|
||||
.article-title {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
src/sass/_broadcast.scss
Normal file
75
src/sass/_broadcast.scss
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
.broadcast-page {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
margin: 20px auto 0;
|
||||
}
|
||||
|
||||
.broadcast-panel {
|
||||
background-color: var(--bg_panel);
|
||||
border: 1px solid var(--border_grey);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.broadcast-player {
|
||||
position: relative;
|
||||
background: black;
|
||||
|
||||
video,
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.broadcast-info {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.broadcast-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.broadcast-user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.broadcast-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--fg_color);
|
||||
|
||||
img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.broadcast-username {
|
||||
color: var(--fg_dark);
|
||||
}
|
||||
|
||||
.broadcast-meta {
|
||||
color: var(--fg_faded);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
.broadcast-live {
|
||||
background: #e0245e;
|
||||
color: white;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
}
|
||||
149
src/sass/_space.scss
Normal file
149
src/sass/_space.scss
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
.space-page {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
margin: 20px auto 0;
|
||||
}
|
||||
|
||||
.space-panel {
|
||||
background-color: var(--bg_panel);
|
||||
border: 1px solid var(--border_grey);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.space-player {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #7b2a8c 0%, #9b3ab1 100%);
|
||||
min-height: 140px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
audio {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:not([controls]) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.video-overlay {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.space-live {
|
||||
background: #e0245e;
|
||||
color: white;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.space-info {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.space-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.space-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.space-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
color: var(--fg_faded);
|
||||
}
|
||||
|
||||
.listener-count {
|
||||
color: var(--fg_color);
|
||||
}
|
||||
|
||||
.space-state {
|
||||
color: var(--fg_dark);
|
||||
}
|
||||
|
||||
.space-participants {
|
||||
border-top: 1px solid var(--border_grey);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.space-participant {
|
||||
margin-bottom: 10px;
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--fg_color);
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.participant-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.participant-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
strong {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.verified-icon {
|
||||
margin-bottom: 0;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.host-badge {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
.participant-username {
|
||||
color: var(--fg_dark);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
|
@ -1,39 +1,40 @@
|
|||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
.panel-container {
|
||||
margin: auto;
|
||||
font-size: 130%;
|
||||
margin: auto;
|
||||
font-size: 130%;
|
||||
}
|
||||
|
||||
.error-panel {
|
||||
@include center-panel(var(--error_red));
|
||||
text-align: center;
|
||||
@include center-panel(var(--error_red));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search-bar > form {
|
||||
@include center-panel(var(--darkest_grey));
|
||||
@include center-panel(var(--darkest_grey));
|
||||
|
||||
button {
|
||||
background: var(--bg_elements);
|
||||
color: var(--fg_color);
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
button {
|
||||
background: var(--bg_elements);
|
||||
color: var(--fg_color);
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0px 5px 1px 8px;
|
||||
}
|
||||
|
||||
input {
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
background: var(--bg_elements);
|
||||
color: var(--fg_color);
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
margin-right: 8px;
|
||||
height: unset;
|
||||
}
|
||||
input {
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
background: var(--bg_elements);
|
||||
color: var(--fg_color);
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
margin-right: 8px;
|
||||
height: unset;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,18 +66,7 @@
|
|||
}
|
||||
|
||||
#search-panel-toggle:checked ~ .search-panel {
|
||||
@if $rows == 6 {
|
||||
max-height: 200px !important;
|
||||
}
|
||||
@if $rows == 5 {
|
||||
max-height: 300px !important;
|
||||
}
|
||||
@if $rows == 4 {
|
||||
max-height: 300px !important;
|
||||
}
|
||||
@if $rows == 3 {
|
||||
max-height: 365px !important;
|
||||
}
|
||||
max-height: 380px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,46 +1,43 @@
|
|||
// colors
|
||||
$bg_color: #0F0F0F;
|
||||
$fg_color: #F8F8F2;
|
||||
$fg_faded: #F8F8F2CF;
|
||||
$fg_dark: #FF6C60;
|
||||
$fg_nav: #FF6C60;
|
||||
$bg_color: #0f0f0f;
|
||||
$fg_color: #f8f8f2;
|
||||
$fg_faded: #f8f8f2cf;
|
||||
$fg_dark: #ff6c60;
|
||||
$fg_nav: #ff6c60;
|
||||
|
||||
$bg_panel: #161616;
|
||||
$bg_elements: #121212;
|
||||
$bg_overlays: #1F1F1F;
|
||||
$bg_hover: #1A1A1A;
|
||||
$bg_overlays: #1f1f1f;
|
||||
$bg_hover: #1a1a1a;
|
||||
|
||||
$grey: #888889;
|
||||
$dark_grey: #404040;
|
||||
$darker_grey: #282828;
|
||||
$darkest_grey: #222222;
|
||||
$border_grey: #3E3E35;
|
||||
$border_grey: #3e3e35;
|
||||
|
||||
$accent: #FF6C60;
|
||||
$accent_light: #FFACA0;
|
||||
$accent_dark: #8A3731;
|
||||
$accent_border: #FF6C6091;
|
||||
$accent: #ff6c60;
|
||||
$accent_light: #ffaca0;
|
||||
$accent_dark: #8a3731;
|
||||
$accent_border: #ff6c6091;
|
||||
|
||||
$play_button: #D8574D;
|
||||
$play_button_hover: #FF6C60;
|
||||
$play_button: #d8574d;
|
||||
$play_button_hover: #ff6c60;
|
||||
|
||||
$more_replies_dots: #AD433B;
|
||||
$error_red: #420A05;
|
||||
$more_replies_dots: #ad433b;
|
||||
$error_red: #420a05;
|
||||
|
||||
$verified_blue: #1DA1F2;
|
||||
$verified_business: #FAC82B;
|
||||
$verified_government: #C1B6A4;
|
||||
$verified_blue: #1da1f2;
|
||||
$verified_business: #fac82b;
|
||||
$verified_government: #c1b6a4;
|
||||
$icon_text: $fg_color;
|
||||
|
||||
$tab: $fg_color;
|
||||
$tab_selected: $accent;
|
||||
|
||||
$shadow: rgba(0,0,0,.6);
|
||||
$shadow_dark: rgba(0,0,0,.2);
|
||||
$shadow: rgba(0, 0, 0, 0.6);
|
||||
$shadow_dark: rgba(0, 0, 0, 0.2);
|
||||
|
||||
//fonts
|
||||
$font_0: Helvetica Neue;
|
||||
$font_1: Helvetica;
|
||||
$font_2: Arial;
|
||||
$font_3: sans-serif;
|
||||
$font_4: fontello;
|
||||
$font_0: sans-serif;
|
||||
$font_1: fontello;
|
||||
|
|
|
|||
|
|
@ -1,180 +1,220 @@
|
|||
@import '_variables';
|
||||
@import "_variables";
|
||||
|
||||
@import 'tweet/_base';
|
||||
@import 'profile/_base';
|
||||
@import 'general';
|
||||
@import 'navbar';
|
||||
@import 'inputs';
|
||||
@import 'timeline';
|
||||
@import 'search';
|
||||
@import "tweet/_base";
|
||||
@import "profile/_base";
|
||||
@import "general";
|
||||
@import "navbar";
|
||||
@import "inputs";
|
||||
@import "timeline";
|
||||
@import "search";
|
||||
@import "broadcast";
|
||||
@import "space";
|
||||
@import "_article";
|
||||
|
||||
body {
|
||||
// colors
|
||||
--bg_color: #{$bg_color};
|
||||
--fg_color: #{$fg_color};
|
||||
--fg_faded: #{$fg_faded};
|
||||
--fg_dark: #{$fg_dark};
|
||||
--fg_nav: #{$fg_nav};
|
||||
// colors
|
||||
--bg_color: #{$bg_color};
|
||||
--fg_color: #{$fg_color};
|
||||
--fg_faded: #{$fg_faded};
|
||||
--fg_dark: #{$fg_dark};
|
||||
--fg_nav: #{$fg_nav};
|
||||
|
||||
--bg_panel: #{$bg_panel};
|
||||
--bg_elements: #{$bg_elements};
|
||||
--bg_overlays: #{$bg_overlays};
|
||||
--bg_hover: #{$bg_hover};
|
||||
--bg_panel: #{$bg_panel};
|
||||
--bg_elements: #{$bg_elements};
|
||||
--bg_overlays: #{$bg_overlays};
|
||||
--bg_hover: #{$bg_hover};
|
||||
|
||||
--grey: #{$grey};
|
||||
--dark_grey: #{$dark_grey};
|
||||
--darker_grey: #{$darker_grey};
|
||||
--darkest_grey: #{$darkest_grey};
|
||||
--border_grey: #{$border_grey};
|
||||
--grey: #{$grey};
|
||||
--dark_grey: #{$dark_grey};
|
||||
--darker_grey: #{$darker_grey};
|
||||
--darkest_grey: #{$darkest_grey};
|
||||
--border_grey: #{$border_grey};
|
||||
|
||||
--accent: #{$accent};
|
||||
--accent_light: #{$accent_light};
|
||||
--accent_dark: #{$accent_dark};
|
||||
--accent_border: #{$accent_border};
|
||||
--accent: #{$accent};
|
||||
--accent_light: #{$accent_light};
|
||||
--accent_dark: #{$accent_dark};
|
||||
--accent_border: #{$accent_border};
|
||||
|
||||
--play_button: #{$play_button};
|
||||
--play_button_hover: #{$play_button_hover};
|
||||
--play_button: #{$play_button};
|
||||
--play_button_hover: #{$play_button_hover};
|
||||
|
||||
--more_replies_dots: #{$more_replies_dots};
|
||||
--error_red: #{$error_red};
|
||||
--more_replies_dots: #{$more_replies_dots};
|
||||
--error_red: #{$error_red};
|
||||
|
||||
--verified_blue: #{$verified_blue};
|
||||
--verified_business: #{$verified_business};
|
||||
--verified_government: #{$verified_government};
|
||||
--icon_text: #{$icon_text};
|
||||
--verified_blue: #{$verified_blue};
|
||||
--verified_business: #{$verified_business};
|
||||
--verified_government: #{$verified_government};
|
||||
--icon_text: #{$icon_text};
|
||||
|
||||
--tab: #{$fg_color};
|
||||
--tab_selected: #{$accent};
|
||||
--tab: #{$fg_color};
|
||||
--tab_selected: #{$accent};
|
||||
|
||||
--profile_stat: #{$fg_color};
|
||||
--profile_stat: #{$fg_color};
|
||||
|
||||
background-color: var(--bg_color);
|
||||
color: var(--fg_color);
|
||||
font-family: $font_0, $font_1, $font_2, $font_3;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
background-color: var(--bg_color);
|
||||
color: var(--fg_color);
|
||||
font-family: $font_0, $font_1;
|
||||
font-size: 15px;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
outline: unset;
|
||||
margin: 0;
|
||||
text-decoration: none;
|
||||
outline: unset;
|
||||
margin: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
img {
|
||||
dynamic-range-limit: standard;
|
||||
}
|
||||
|
||||
h1 {
|
||||
display: inline;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
h2, h3 {
|
||||
font-weight: normal;
|
||||
h2,
|
||||
h3 {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 14px 0;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
color: var(--accent);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin-top: -0.6em;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin-top: -0.6em;
|
||||
}
|
||||
|
||||
legend {
|
||||
width: 100%;
|
||||
padding: .6em 0 .3em 0;
|
||||
border: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--border_grey);
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
padding: 0.6em 0 0.3em 0;
|
||||
border: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--border_grey);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.preferences .note {
|
||||
.preferences {
|
||||
.note {
|
||||
border-top: 1px solid var(--border_grey);
|
||||
border-bottom: 1px solid var(--border_grey);
|
||||
padding: 6px 0 8px 0;
|
||||
margin-bottom: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.bookmark-note {
|
||||
margin: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 1.3em;
|
||||
padding-left: 1.3em;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
box-sizing: border-box;
|
||||
padding-top: 50px;
|
||||
margin: auto;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
box-sizing: border-box;
|
||||
margin: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body.fixed-nav .container {
|
||||
padding-top: 50px;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
display: inline;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.overlay-panel {
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
margin-top: 10px;
|
||||
background-color: var(--bg_overlays);
|
||||
padding: 10px 15px;
|
||||
align-self: start;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
margin-top: 10px;
|
||||
background-color: var(--bg_overlays);
|
||||
padding: 10px 15px;
|
||||
align-self: start;
|
||||
|
||||
ul {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
ul {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
p {
|
||||
word-break: break-word;
|
||||
}
|
||||
p {
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.verified-icon {
|
||||
color: var(--icon_text);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin: 2px 0 3px 3px;
|
||||
padding-top: 3px;
|
||||
height: 11px;
|
||||
width: 14px;
|
||||
font-size: 8px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-bottom: 2px;
|
||||
|
||||
&.blue {
|
||||
background-color: var(--verified_blue);
|
||||
.verified-icon-circle {
|
||||
position: absolute;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.verified-icon-check {
|
||||
position: absolute;
|
||||
font-size: 9px;
|
||||
margin: 5px 3px;
|
||||
}
|
||||
|
||||
&.blue {
|
||||
.verified-icon-circle {
|
||||
color: var(--verified_blue);
|
||||
}
|
||||
|
||||
&.business {
|
||||
color: var(--bg_panel);
|
||||
background-color: var(--verified_business);
|
||||
.verified-icon-check {
|
||||
color: var(--icon_text);
|
||||
}
|
||||
}
|
||||
|
||||
&.business {
|
||||
.verified-icon-circle {
|
||||
color: var(--verified_business);
|
||||
}
|
||||
|
||||
&.government {
|
||||
color: var(--bg_panel);
|
||||
background-color: var(--verified_government);
|
||||
.verified-icon-check {
|
||||
color: var(--bg_panel);
|
||||
}
|
||||
}
|
||||
|
||||
&.government {
|
||||
.verified-icon-circle {
|
||||
color: var(--verified_government);
|
||||
}
|
||||
|
||||
.verified-icon-check {
|
||||
color: var(--bg_panel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width: 600px) {
|
||||
.preferences-container {
|
||||
max-width: 95vw;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.preferences-container {
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
.nav-item, .nav-item .icon-container {
|
||||
font-size: 16px;
|
||||
}
|
||||
.nav-item,
|
||||
.nav-item .icon-container {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,185 +1,216 @@
|
|||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
button {
|
||||
@include input-colors;
|
||||
background-color: var(--bg_elements);
|
||||
color: var(--fg_color);
|
||||
border: 1px solid var(--accent_border);
|
||||
padding: 3px 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
float: right;
|
||||
@include input-colors;
|
||||
background-color: var(--bg_elements);
|
||||
color: var(--fg_color);
|
||||
border: 1px solid var(--accent_border);
|
||||
padding: 3px 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
float: right;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="date"],
|
||||
input[type="number"],
|
||||
select {
|
||||
@include input-colors;
|
||||
background-color: var(--bg_elements);
|
||||
padding: 1px 4px;
|
||||
color: var(--fg_color);
|
||||
border: 1px solid var(--accent_border);
|
||||
border-radius: 0;
|
||||
font-size: 14px;
|
||||
@include input-colors;
|
||||
background-color: var(--bg_elements);
|
||||
padding: 1px 4px;
|
||||
color: var(--fg_color);
|
||||
border: 1px solid var(--accent_border);
|
||||
border-radius: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
height: 16px;
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"] {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
select {
|
||||
height: 20px;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
height: 20px;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
input[type="date"]::-webkit-inner-spin-button {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"]::-webkit-outer-spin-button {
|
||||
display: none;
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type="date"]::-webkit-clear-button {
|
||||
margin-left: 17px;
|
||||
filter: grayscale(100%);
|
||||
filter: hue-rotate(120deg);
|
||||
margin-left: 17px;
|
||||
filter: grayscale(100%);
|
||||
filter: hue-rotate(120deg);
|
||||
}
|
||||
|
||||
input::-webkit-calendar-picker-indicator {
|
||||
opacity: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
input::-webkit-datetime-edit-day-field:focus,
|
||||
input::-webkit-datetime-edit-month-field:focus,
|
||||
input::-webkit-datetime-edit-year-field:focus {
|
||||
background-color: var(--accent);
|
||||
color: var(--fg_color);
|
||||
outline: none;
|
||||
background-color: var(--accent);
|
||||
color: var(--fg_color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.date-range {
|
||||
.date-input {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
.date-input {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 5px;
|
||||
}
|
||||
.icon-container {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
.search-title {
|
||||
margin: 0 2px;
|
||||
}
|
||||
.search-title {
|
||||
margin: 0 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-button button {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: none;
|
||||
float: none;
|
||||
padding: unset;
|
||||
padding-left: 4px;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: none;
|
||||
float: none;
|
||||
padding: unset;
|
||||
padding-left: 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent_light);
|
||||
}
|
||||
&:hover {
|
||||
color: var(--accent_light);
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 0;
|
||||
height: 17px;
|
||||
width: 17px;
|
||||
background-color: var(--bg_elements);
|
||||
border: 1px solid var(--accent_border);
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 0;
|
||||
height: 17px;
|
||||
width: 17px;
|
||||
background-color: var(--bg_elements);
|
||||
border: 1px solid var(--accent_border);
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-container {
|
||||
display: block;
|
||||
position: relative;
|
||||
margin-bottom: 5px;
|
||||
display: block;
|
||||
position: relative;
|
||||
margin-bottom: 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding-right: 22px;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding-right: 22px;
|
||||
height: 0;
|
||||
width: 0;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
height: 0;
|
||||
width: 0;
|
||||
|
||||
&:checked ~ .checkbox:after {
|
||||
display: block;
|
||||
}
|
||||
&:checked ~ .checkbox:after {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover input ~ .checkbox {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
&:hover input ~ .checkbox {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
&:active input ~ .checkbox {
|
||||
border-color: var(--accent_light);
|
||||
}
|
||||
&:active input ~ .checkbox {
|
||||
border-color: var(--accent_light);
|
||||
}
|
||||
|
||||
.checkbox:after {
|
||||
left: 2px;
|
||||
bottom: 0;
|
||||
font-size: 13px;
|
||||
font-family: $font_4;
|
||||
content: '\e803';
|
||||
}
|
||||
.checkbox:after {
|
||||
left: 2px;
|
||||
bottom: 0;
|
||||
font-size: 13px;
|
||||
font-family: $font_1;
|
||||
content: "\e811";
|
||||
}
|
||||
}
|
||||
|
||||
.pref-group {
|
||||
display: inline;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.preferences {
|
||||
button {
|
||||
margin: 6px 0 3px 0;
|
||||
}
|
||||
button {
|
||||
margin: 6px 0 3px 0;
|
||||
}
|
||||
|
||||
label {
|
||||
padding-right: 150px;
|
||||
}
|
||||
label {
|
||||
padding-right: 150px;
|
||||
}
|
||||
|
||||
select {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: block;
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
select {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: block;
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
max-width: 140px;
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="number"] {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.pref-group {
|
||||
display: block;
|
||||
}
|
||||
.pref-group {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pref-input {
|
||||
position: relative;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.pref-input {
|
||||
position: relative;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.pref-reset {
|
||||
float: left;
|
||||
}
|
||||
.pref-reset {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.prefs-code {
|
||||
background-color: var(--bg_elements);
|
||||
border: 1px solid var(--accent_border);
|
||||
color: var(--fg_color);
|
||||
font-size: 13px;
|
||||
padding: 6px 8px;
|
||||
margin: 4px 0;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
user-select: all;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,89 +1,90 @@
|
|||
@import '_variables';
|
||||
@import "_variables";
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
background-color: var(--bg_overlays);
|
||||
box-shadow: 0 0 4px $shadow;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
z-index: 1000;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--bg_overlays);
|
||||
box-shadow: 0 0 4px $shadow;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
z-index: 1000;
|
||||
font-size: 16px;
|
||||
|
||||
a, .icon-button button {
|
||||
color: var(--fg_nav);
|
||||
}
|
||||
a,
|
||||
.icon-button button {
|
||||
color: var(--fg_nav);
|
||||
}
|
||||
|
||||
body.fixed-nav & {
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
.inner-nav {
|
||||
margin: auto;
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-basis: 920px;
|
||||
height: 50px;
|
||||
margin: auto;
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-basis: 920px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent_light);
|
||||
text-decoration: unset;
|
||||
}
|
||||
&:hover {
|
||||
color: var(--accent_light);
|
||||
text-decoration: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.site-logo {
|
||||
display: block;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
display: block;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
line-height: 50px;
|
||||
height: 50px;
|
||||
overflow: hidden;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
line-height: 50px;
|
||||
height: 50px;
|
||||
overflow: hidden;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
|
||||
&.right {
|
||||
text-align: right;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
&.right {
|
||||
text-align: right;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&.right a {
|
||||
padding-left: 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent_light);
|
||||
text-decoration: unset;
|
||||
}
|
||||
}
|
||||
&.right a:hover {
|
||||
color: var(--accent_light);
|
||||
text-decoration: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.lp {
|
||||
height: 14px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
fill: var(--fg_nav);
|
||||
height: 14px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
fill: var(--fg_nav);
|
||||
|
||||
&:hover {
|
||||
fill: var(--accent_light);
|
||||
}
|
||||
&:hover {
|
||||
fill: var(--accent_light);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-info:before {
|
||||
margin: 0 -3px;
|
||||
.icon-info {
|
||||
margin: 0 -3px;
|
||||
}
|
||||
|
||||
.icon-cog {
|
||||
font-size: 15px;
|
||||
font-size: 15px;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,83 +1,118 @@
|
|||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
@import 'card';
|
||||
@import 'photo-rail';
|
||||
@import "card";
|
||||
@import "about-account";
|
||||
@import "photo-rail";
|
||||
@import "community";
|
||||
|
||||
.profile-tabs {
|
||||
@include panel(auto, 900px);
|
||||
@include panel(auto, 900px);
|
||||
|
||||
.timeline-container {
|
||||
float: right;
|
||||
width: 68% !important;
|
||||
max-width: unset;
|
||||
}
|
||||
.timeline-container {
|
||||
float: right;
|
||||
width: 68% !important;
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-banner {
|
||||
margin-bottom: 4px;
|
||||
background-color: var(--bg_panel);
|
||||
margin-bottom: 4px;
|
||||
background-color: var(--bg_panel);
|
||||
|
||||
a {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding: 33.34% 0 0 0;
|
||||
}
|
||||
a {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding: 33.34% 0 0 0;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-tab {
|
||||
padding: 0 4px 0 0;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
max-width: 32%;
|
||||
padding: 0 4px 0 0;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
max-width: 32%;
|
||||
top: 0;
|
||||
|
||||
body.fixed-nav & {
|
||||
top: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-result {
|
||||
min-height: 54px;
|
||||
min-height: 54px;
|
||||
|
||||
.username {
|
||||
margin: 0 !important;
|
||||
}
|
||||
.username {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.tweet-header {
|
||||
margin-bottom: unset;
|
||||
}
|
||||
.tweet-header {
|
||||
margin-bottom: unset;
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width: 700px) {
|
||||
.profile-tabs {
|
||||
width: 100vw;
|
||||
max-width: 600px;
|
||||
.profile-tabs.media-only {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
|
||||
.timeline-container {
|
||||
width: 100% !important;
|
||||
.timeline-container {
|
||||
float: none;
|
||||
width: 100% !important;
|
||||
max-width: none;
|
||||
padding: 0 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tab-item wide {
|
||||
flex-grow: 1.4;
|
||||
}
|
||||
}
|
||||
.timeline-container > .tab {
|
||||
max-width: 900px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.profile-tabs {
|
||||
width: 100vw;
|
||||
max-width: 600px;
|
||||
|
||||
.timeline-container {
|
||||
width: 100% !important;
|
||||
|
||||
.tab-item wide {
|
||||
flex-grow: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profile-tab {
|
||||
width: 100%;
|
||||
max-width: unset;
|
||||
position: initial !important;
|
||||
padding: 0;
|
||||
.profile-tabs.media-only {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
|
||||
.timeline-container {
|
||||
width: 100vw !important;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-tab {
|
||||
width: 100%;
|
||||
max-width: unset;
|
||||
position: initial !important;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-height: 900px) {
|
||||
.profile-tab.sticky {
|
||||
position: sticky;
|
||||
}
|
||||
.profile-tab.sticky {
|
||||
position: sticky;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
203
src/sass/profile/_community.scss
Normal file
203
src/sass/profile/_community.scss
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
.community-header {
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid var(--border_grey);
|
||||
background-color: var(--bg_panel);
|
||||
|
||||
.community-name {
|
||||
font-size: 22px;
|
||||
margin-bottom: 6px;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.community-category {
|
||||
display: inline-block;
|
||||
background-color: var(--bg_elements);
|
||||
border: 1px solid var(--border_grey);
|
||||
border-radius: 16px;
|
||||
padding: 2px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--fg_faded);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.community-description {
|
||||
color: var(--fg_faded);
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.community-member-count {
|
||||
font-weight: bold;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.community-stats {
|
||||
color: var(--grey);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.community-about {
|
||||
padding: 16px 15px 15px;
|
||||
background-color: var(--bg_panel);
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.community-info {
|
||||
border-bottom: 1px solid var(--border_grey);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.community-info-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
align-items: center;
|
||||
|
||||
.verified-icon {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
> .icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: var(--grey);
|
||||
flex-shrink: 0;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--fg_color);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.community-rules {
|
||||
border-bottom: 1px solid var(--border_grey);
|
||||
padding: 16px 0 12px;
|
||||
}
|
||||
|
||||
.community-rules-intro {
|
||||
color: var(--fg_faded);
|
||||
font-size: 14px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.community-rule {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
align-items: flex-start;
|
||||
|
||||
.community-rule-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--accent);
|
||||
color: var(--fg_color);
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.community-rule-content p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--fg_faded);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.community-moderators {
|
||||
padding-top: 16px;
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.community-mods-link {
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.community-moderator {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
align-items: center;
|
||||
|
||||
.community-mod-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.community-mod-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.community-mod-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
color: var(--fg_color);
|
||||
|
||||
.verified-icon {
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.community-mod-username {
|
||||
color: var(--fg_faded);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.community-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 10px 15px;
|
||||
border-bottom: 1px solid var(--border_grey);
|
||||
|
||||
.community-tag {
|
||||
display: inline-block;
|
||||
background-color: var(--bg_elements);
|
||||
border: 1px solid var(--border_grey);
|
||||
border-radius: 16px;
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.community-hashtag-header {
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid var(--border_grey);
|
||||
|
||||
.community-hashtag-title {
|
||||
font-size: 20px;
|
||||
color: var(--accent);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
71
src/sass/profile/about-account.scss
Normal file
71
src/sass/profile/about-account.scss
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
@import '_variables';
|
||||
|
||||
.about-account {
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
margin: 20px auto 0;
|
||||
align-self: flex-start;
|
||||
background: var(--bg_panel);
|
||||
border-radius: 4px;
|
||||
padding: 12px 20px 20px;
|
||||
}
|
||||
|
||||
.about-account-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--border_grey);
|
||||
}
|
||||
|
||||
.about-account-avatar img {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.about-account-name {
|
||||
@include breakable;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.about-account-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.about-account-at {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.about-account-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
> span:first-child {
|
||||
color: var(--fg_faded);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.about-account-label {
|
||||
color: var(--fg_faded);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media(max-width: 700px) {
|
||||
.about-account {
|
||||
max-width: none;
|
||||
margin: 10px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,122 +1,194 @@
|
|||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
.search-title {
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
button {
|
||||
margin: 0 2px 0 0;
|
||||
padding: 0px 1px 1px 4px;
|
||||
height: 23px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 0 2px 0 0;
|
||||
height: 23px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.pref-input {
|
||||
margin: 0 4px 0 0;
|
||||
flex-grow: 1;
|
||||
height: 23px;
|
||||
}
|
||||
|
||||
.pref-input {
|
||||
margin: 0 4px 0 0;
|
||||
flex-grow: 1;
|
||||
height: 23px;
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="number"] {
|
||||
height: calc(100% - 4px);
|
||||
width: calc(100% - 8px);
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
height: calc(100% - 4px);
|
||||
width: calc(100% - 8px);
|
||||
}
|
||||
> label {
|
||||
display: inline;
|
||||
background-color: var(--bg_elements);
|
||||
color: var(--fg_color);
|
||||
border: 1px solid var(--accent_border);
|
||||
padding: 1px 1px 2px 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 2px;
|
||||
|
||||
> label {
|
||||
display: inline;
|
||||
background-color: var(--bg_elements);
|
||||
color: var(--fg_color);
|
||||
border: 1px solid var(--accent_border);
|
||||
padding: 1px 6px 2px 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 2px;
|
||||
@include input-colors;
|
||||
}
|
||||
|
||||
@include input-colors;
|
||||
}
|
||||
|
||||
@include create-toggle(search-panel, 200px);
|
||||
@include create-toggle(search-panel, 380px);
|
||||
}
|
||||
|
||||
.search-panel {
|
||||
width: 100%;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.4s;
|
||||
width: 100%;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.4s;
|
||||
|
||||
flex-grow: 1;
|
||||
font-weight: initial;
|
||||
text-align: left;
|
||||
flex-grow: 1;
|
||||
font-weight: initial;
|
||||
text-align: left;
|
||||
|
||||
> div {
|
||||
line-height: 1.7em;
|
||||
}
|
||||
.checkbox-container {
|
||||
display: inline;
|
||||
padding-right: unset;
|
||||
margin-bottom: 5px;
|
||||
margin-left: 23px;
|
||||
}
|
||||
|
||||
.checkbox-container {
|
||||
display: inline;
|
||||
padding-right: unset;
|
||||
margin-bottom: unset;
|
||||
margin-left: 23px;
|
||||
}
|
||||
.checkbox {
|
||||
right: unset;
|
||||
left: -22px;
|
||||
line-height: 1.6em;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
right: unset;
|
||||
left: -22px;
|
||||
}
|
||||
|
||||
.checkbox-container .checkbox:after {
|
||||
top: -4px;
|
||||
}
|
||||
.checkbox-container .checkbox:after {
|
||||
top: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
line-height: unset;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
line-height: unset;
|
||||
|
||||
> div {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
> div {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
input {
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.pref-input {
|
||||
display: block;
|
||||
padding-bottom: 5px;
|
||||
|
||||
input {
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.pref-input {
|
||||
display: block;
|
||||
padding-bottom: 5px;
|
||||
|
||||
input {
|
||||
height: 21px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
height: 21px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-toggles {
|
||||
flex-grow: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, auto);
|
||||
grid-column-gap: 10px;
|
||||
flex-grow: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, auto);
|
||||
grid-column-gap: 10px;
|
||||
}
|
||||
|
||||
.list-result {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
.list-result-banner {
|
||||
flex-shrink: 0;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin-right: 10px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: var(--darker_grey);
|
||||
// stay above the tweet-link overlay's hover background
|
||||
z-index: 1;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.list-result-body {
|
||||
min-width: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
|
||||
a {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
.list-result-title {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.list-members {
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.3em;
|
||||
color: var(--fg_faded);
|
||||
}
|
||||
|
||||
.list-result-context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2px;
|
||||
color: var(--fg_faded);
|
||||
|
||||
a {
|
||||
color: var(--fg_dark);
|
||||
}
|
||||
|
||||
a.fullname {
|
||||
color: var(--fg_color);
|
||||
}
|
||||
|
||||
.list-facepile {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-result-description {
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-tabs {
|
||||
@include search-resize(820px, 5);
|
||||
@include search-resize(725px, 4);
|
||||
@include search-resize(600px, 6);
|
||||
@include search-resize(560px, 5);
|
||||
@include search-resize(480px, 4);
|
||||
@include search-resize(410px, 3);
|
||||
@include search-resize(820px, 5);
|
||||
@include search-resize(715px, 4);
|
||||
@include search-resize(700px, 5);
|
||||
@include search-resize(485px, 4);
|
||||
@include search-resize(410px, 3);
|
||||
}
|
||||
|
||||
@include search-resize(560px, 5);
|
||||
@include search-resize(480px, 4);
|
||||
@include search-resize(700px, 5);
|
||||
@include search-resize(485px, 4);
|
||||
@include search-resize(410px, 3);
|
||||
|
|
|
|||
|
|
@ -1,162 +1,505 @@
|
|||
@import '_variables';
|
||||
@import "_variables";
|
||||
|
||||
.timeline-container {
|
||||
@include panel(100%, 600px);
|
||||
@include panel(100%, 600px);
|
||||
}
|
||||
|
||||
.timeline {
|
||||
background-color: var(--bg_panel);
|
||||
.timeline-container.media-only {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
box-sizing: border-box;
|
||||
|
||||
> div:not(:first-child) {
|
||||
border-top: 1px solid var(--border_grey);
|
||||
}
|
||||
> .tab,
|
||||
> .timeline-header {
|
||||
max-width: 900px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.timeline-container.media-only {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline > div:not(:first-child) {
|
||||
border-top: 1px solid var(--border_grey);
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
width: 100%;
|
||||
background-color: var(--bg_panel);
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
background-color: var(--bg_panel);
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
box-sizing: border-box;
|
||||
|
||||
button {
|
||||
float: unset;
|
||||
}
|
||||
button {
|
||||
float: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-banner img {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.timeline-description {
|
||||
font-weight: normal;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.tab {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
list-style: none;
|
||||
margin: 0 0 5px 0;
|
||||
background-color: var(--bg_panel);
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
list-style: none;
|
||||
margin: 0 0 4px 0;
|
||||
background-color: var(--bg_panel);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1 1 0;
|
||||
text-align: center;
|
||||
margin-top: 0;
|
||||
flex: 1 1 0;
|
||||
text-align: center;
|
||||
margin-top: 0;
|
||||
|
||||
a {
|
||||
border-bottom: .1rem solid transparent;
|
||||
color: var(--tab);
|
||||
display: block;
|
||||
padding: 8px 0;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
a {
|
||||
border-bottom: 0.1rem solid transparent;
|
||||
color: var(--tab);
|
||||
display: block;
|
||||
padding: 8px 0;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-bottom-color: var(--tab_selected);
|
||||
color: var(--tab_selected);
|
||||
}
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&.active a {
|
||||
border-bottom-color: var(--tab_selected);
|
||||
color: var(--tab_selected);
|
||||
&.active {
|
||||
border-bottom-color: var(--tab_selected);
|
||||
color: var(--tab_selected);
|
||||
}
|
||||
}
|
||||
|
||||
&.wide {
|
||||
flex-grow: 1.2;
|
||||
flex-basis: 50px;
|
||||
}
|
||||
&.active a {
|
||||
border-bottom-color: var(--tab_selected);
|
||||
color: var(--tab_selected);
|
||||
}
|
||||
|
||||
&.wide {
|
||||
flex-grow: 1.2;
|
||||
flex-basis: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-footer {
|
||||
background-color: var(--bg_panel);
|
||||
padding: 6px 0;
|
||||
background-color: var(--bg_panel);
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.timeline-protected {
|
||||
text-align: center;
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: var(--accent);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-none {
|
||||
h2 {
|
||||
color: var(--accent);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-none {
|
||||
color: var(--accent);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.timeline-end {
|
||||
background-color: var(--bg_panel);
|
||||
color: var(--accent);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
background-color: var(--bg_panel);
|
||||
color: var(--accent);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.show-more {
|
||||
background-color: var(--bg_panel);
|
||||
text-align: center;
|
||||
padding: .75em 0;
|
||||
display: block !important;
|
||||
background-color: var(--bg_panel);
|
||||
text-align: center;
|
||||
padding: 0.75em 0;
|
||||
display: block !important;
|
||||
|
||||
a {
|
||||
background-color: var(--darkest_grey);
|
||||
display: inline-block;
|
||||
height: 2em;
|
||||
padding: 0 2em;
|
||||
line-height: 2em;
|
||||
a {
|
||||
background-color: var(--darkest_grey);
|
||||
display: inline-block;
|
||||
height: 2em;
|
||||
padding: 0 2em;
|
||||
line-height: 2em;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--darker_grey);
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--darker_grey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.top-ref {
|
||||
background-color: var(--bg_color);
|
||||
border-top: none !important;
|
||||
background-color: var(--bg_color);
|
||||
border-top: none !important;
|
||||
|
||||
.icon-down {
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
.icon-down {
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent_light);
|
||||
}
|
||||
|
||||
&::before {
|
||||
transform: rotate(180deg) translateY(-1px);
|
||||
}
|
||||
&:hover {
|
||||
color: var(--accent_light);
|
||||
}
|
||||
|
||||
&::before {
|
||||
transform: rotate(180deg) translateY(-1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
overflow-wrap: break-word;
|
||||
border-left-width: 0;
|
||||
min-width: 0;
|
||||
padding: .75em;
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow-wrap: break-word;
|
||||
border-left-width: 0;
|
||||
min-width: 0;
|
||||
padding: 0.75em;
|
||||
display: flex;
|
||||
position: relative;
|
||||
background-color: var(--bg_panel);
|
||||
}
|
||||
|
||||
.timeline.media-grid-view,
|
||||
.timeline.media-gallery-view {
|
||||
> div:not(:first-child) {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.timeline-item::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline.media-grid-view,
|
||||
.timeline.media-gallery-view .gallery-masonry.compact {
|
||||
.tweet-header,
|
||||
.replying-to,
|
||||
.retweet-header,
|
||||
.pinned,
|
||||
.tweet-stats,
|
||||
.attribution,
|
||||
.poll,
|
||||
.quote,
|
||||
.community-note,
|
||||
.media-tag-block,
|
||||
.tweet-content,
|
||||
.card-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: unset;
|
||||
|
||||
.card-container {
|
||||
border: unset;
|
||||
border-radius: unset;
|
||||
|
||||
.card-image-container {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.card-content-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline.media-grid-view {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
> div:not(:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tweet-link {
|
||||
z-index: 1000;
|
||||
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
> .show-more,
|
||||
> .top-ref,
|
||||
> .timeline-footer,
|
||||
> .timeline-header {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.tweet-body {
|
||||
height: 100%;
|
||||
margin-left: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
aspect-ratio: 1/1;
|
||||
}
|
||||
|
||||
.gallery-row + .gallery-row {
|
||||
margin-top: 0.25em !important;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
background-color: var(--darkest_grey);
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.attachments,
|
||||
.gallery-row,
|
||||
.still-image {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.still-image img,
|
||||
.attachment > video,
|
||||
.attachment > img {
|
||||
object-fit: cover;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.attachment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.gallery-video {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.media-gif {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.timeline-item:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.alt-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline.media-gallery-view {
|
||||
.gallery-masonry {
|
||||
margin: 10px 0;
|
||||
column-gap: 10px;
|
||||
column-width: unquote("clamp(190px, 22vw, 350px)");
|
||||
|
||||
&[data-col-size="small"] {
|
||||
column-width: unquote("max(130px, 11vw)");
|
||||
}
|
||||
|
||||
&[data-col-size="large"] {
|
||||
column-width: unquote("clamp(350px, 22vw, 480px)");
|
||||
}
|
||||
|
||||
&.masonry-active {
|
||||
column-width: unset;
|
||||
column-gap: unset;
|
||||
position: relative;
|
||||
|
||||
.timeline-item {
|
||||
animation: none;
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.compact {
|
||||
.tweet-body {
|
||||
padding: 0;
|
||||
|
||||
> .attachments {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-image-container img {
|
||||
max-height: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes masonry-init {
|
||||
to {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Start hidden. CSS animation reveals after a delay as a no-JS fallback.
|
||||
// With JS, masonry-active cancels the animation and masonry-visible reveals.
|
||||
.gallery-masonry .timeline-item,
|
||||
> .show-more,
|
||||
> .top-ref,
|
||||
> .timeline-footer {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
animation: masonry-init 0.2s 0.3s forwards;
|
||||
}
|
||||
|
||||
.gallery-masonry.masonry-active .timeline-item.masonry-visible,
|
||||
> .show-more.masonry-visible,
|
||||
> .top-ref.masonry-visible,
|
||||
> .timeline-footer.masonry-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transition: opacity 0.15s ease;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
margin-bottom: 10px;
|
||||
break-inside: avoid;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
> .show-more,
|
||||
> .top-ref,
|
||||
> .timeline-footer,
|
||||
> .timeline-header {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
> .show-more {
|
||||
padding: 0;
|
||||
margin-top: 8px;
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.tweet-content {
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.tweet-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
margin-left: 0;
|
||||
padding: 10px;
|
||||
|
||||
> .attachments {
|
||||
align-self: stretch;
|
||||
border-radius: 0;
|
||||
margin: -10px -10px 10px;
|
||||
max-height: none;
|
||||
order: -1;
|
||||
width: auto;
|
||||
background-color: var(--bg_elements);
|
||||
|
||||
.gallery-row {
|
||||
max-height: none;
|
||||
max-width: none;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.still-image img,
|
||||
.attachment > video,
|
||||
.attachment > img {
|
||||
max-height: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.attachment:last-child {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.card-container {
|
||||
border: unset;
|
||||
border-radius: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.tweet-stat {
|
||||
padding-top: unset;
|
||||
}
|
||||
|
||||
.quote {
|
||||
margin-bottom: 5px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.replying-to {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tweet-header {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 0.75em;
|
||||
margin-bottom: 0;
|
||||
|
||||
.tweet-avatar {
|
||||
img {
|
||||
float: none;
|
||||
height: 42px;
|
||||
margin: 0;
|
||||
width: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
.tweet-name-row {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fullname-and-username {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fullname {
|
||||
max-width: calc(100% - 18px);
|
||||
}
|
||||
|
||||
.verified-icon {
|
||||
margin-left: 4px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.username {
|
||||
display: block;
|
||||
flex-basis: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.timeline.media-gallery-view {
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,240 +1,284 @@
|
|||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import 'thread';
|
||||
@import 'media';
|
||||
@import 'video';
|
||||
@import 'embed';
|
||||
@import 'card';
|
||||
@import 'poll';
|
||||
@import 'quote';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
@import "thread";
|
||||
@import "media";
|
||||
@import "video";
|
||||
@import "embed";
|
||||
@import "card";
|
||||
@import "poll";
|
||||
@import "quote";
|
||||
|
||||
.tweet-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 58px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 58px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tweet-content {
|
||||
font-family: $font_3;
|
||||
line-height: 1.3em;
|
||||
pointer-events: all;
|
||||
display: inline;
|
||||
line-height: 1.3em;
|
||||
pointer-events: all;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.tweet-bidi {
|
||||
display: block !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.tweet-header {
|
||||
padding: 0;
|
||||
vertical-align: bottom;
|
||||
flex-basis: 100%;
|
||||
margin-bottom: .2em;
|
||||
padding: 0;
|
||||
vertical-align: bottom;
|
||||
flex-basis: 100%;
|
||||
margin-bottom: 0.2em;
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
pointer-events: all;
|
||||
}
|
||||
a {
|
||||
display: inline-block;
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
.tweet-name-row {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.verified-icon {
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.fullname-and-username {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fullname {
|
||||
@include ellipsis;
|
||||
flex-shrink: 2;
|
||||
max-width: 80%;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--fg_color);
|
||||
@include ellipsis;
|
||||
flex-shrink: 2;
|
||||
max-width: 80%;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--fg_color);
|
||||
}
|
||||
|
||||
.username {
|
||||
@include ellipsis;
|
||||
min-width: 1.6em;
|
||||
margin-left: .4em;
|
||||
word-wrap: normal;
|
||||
@include ellipsis;
|
||||
min-width: 1.6em;
|
||||
margin-left: 0.4em;
|
||||
word-wrap: normal;
|
||||
}
|
||||
|
||||
.tweet-date {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.tweet-date a, .username, .show-more a {
|
||||
color: var(--fg_dark);
|
||||
.tweet-date a,
|
||||
.username,
|
||||
.show-more a {
|
||||
color: var(--fg_dark);
|
||||
}
|
||||
|
||||
.tweet-published {
|
||||
margin: 0;
|
||||
margin-top: 5px;
|
||||
color: var(--grey);
|
||||
pointer-events: all;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 0px;
|
||||
color: var(--grey);
|
||||
}
|
||||
|
||||
.tweet-avatar {
|
||||
display: contents !important;
|
||||
display: contents !important;
|
||||
|
||||
img {
|
||||
float: left;
|
||||
margin-top: 3px;
|
||||
margin-left: -58px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
img {
|
||||
float: left;
|
||||
margin-top: 3px;
|
||||
margin-left: -58px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
&.round {
|
||||
border-radius: 50%;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
&.mini {
|
||||
position: unset;
|
||||
margin-right: 5px;
|
||||
margin-top: -1px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
&.round {
|
||||
border-radius: 50%;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.tweet-embed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background-color: var(--bg_panel);
|
||||
|
||||
.tweet-content {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.tweet-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 0.75em * 2);
|
||||
}
|
||||
|
||||
.card-image img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: absolute;
|
||||
}
|
||||
&.mini {
|
||||
position: unset;
|
||||
margin-right: 5px;
|
||||
margin-top: -1px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.attribution {
|
||||
display: flex;
|
||||
pointer-events: all;
|
||||
margin: 5px 0;
|
||||
display: flex;
|
||||
pointer-events: all;
|
||||
margin: 5px 0;
|
||||
|
||||
strong {
|
||||
color: var(--fg_color);
|
||||
}
|
||||
strong {
|
||||
color: var(--fg_color);
|
||||
}
|
||||
}
|
||||
|
||||
.media-tag-block {
|
||||
padding-top: 5px;
|
||||
pointer-events: all;
|
||||
padding-top: 5px;
|
||||
pointer-events: all;
|
||||
color: var(--fg_faded);
|
||||
|
||||
.icon-container {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.media-tag,
|
||||
.icon-container {
|
||||
color: var(--fg_faded);
|
||||
|
||||
.icon-container {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.media-tag, .icon-container {
|
||||
color: var(--fg_faded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-container .media-tag-block {
|
||||
font-size: 13px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tweet-geo {
|
||||
color: var(--fg_faded);
|
||||
color: var(--fg_faded);
|
||||
}
|
||||
|
||||
.replying-to {
|
||||
color: var(--fg_faded);
|
||||
margin: -2px 0 4px;
|
||||
color: var(--fg_faded);
|
||||
margin: -2px 0 4px;
|
||||
|
||||
a {
|
||||
pointer-events: all;
|
||||
}
|
||||
a {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
.retweet-header, .pinned, .tweet-stats {
|
||||
align-content: center;
|
||||
color: var(--grey);
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
.retweet-header,
|
||||
.pinned,
|
||||
.tweet-stats {
|
||||
align-content: center;
|
||||
color: var(--grey);
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
|
||||
span {
|
||||
@include ellipsis;
|
||||
}
|
||||
span {
|
||||
@include ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.retweet-header {
|
||||
margin-top: -5px !important;
|
||||
margin-top: -5px !important;
|
||||
}
|
||||
|
||||
.tweet-stats {
|
||||
margin-bottom: -3px;
|
||||
-webkit-user-select: none;
|
||||
margin-bottom: -3px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.tweet-stat {
|
||||
padding-top: 5px;
|
||||
min-width: 1em;
|
||||
margin-right: 0.8em;
|
||||
padding-top: 5px;
|
||||
min-width: 1em;
|
||||
margin-right: 0.8em;
|
||||
}
|
||||
|
||||
.show-thread {
|
||||
display: block;
|
||||
pointer-events: all;
|
||||
padding-top: 2px;
|
||||
display: block;
|
||||
pointer-events: all;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.unavailable-box {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
border: solid 1px var(--dark_grey);
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
background-color: var(--bg_color);
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
border: solid 1px var(--dark_grey);
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
background-color: var(--bg_color);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tweet-link {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
-webkit-user-select: none;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg_hover);
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--bg_hover);
|
||||
}
|
||||
}
|
||||
|
||||
.latest-post-version {
|
||||
border-bottom: 1px solid var(--dark_grey);
|
||||
border-top: 1px solid var(--dark_grey);
|
||||
padding: 01ch 0px;
|
||||
margin: 1ch 0px;
|
||||
color: var(--grey);
|
||||
|
||||
a {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
.community-note {
|
||||
background-color: var(--bg_elements);
|
||||
margin-top: 10px;
|
||||
border: solid 1px var(--dark_grey);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
pointer-events: all;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg_panel);
|
||||
border-color: var(--grey);
|
||||
}
|
||||
}
|
||||
|
||||
.community-note-header {
|
||||
background-color: var(--bg_hover);
|
||||
font-weight: 700;
|
||||
padding: 8px 10px;
|
||||
padding-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
.icon-container {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.community-note-text {
|
||||
white-space: pre-line;
|
||||
padding: 10px 10px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.disclosures {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--grey);
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
margin-bottom: -2px;
|
||||
|
||||
.icon-attention-circled {
|
||||
margin-right: -3px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,119 +1,119 @@
|
|||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
.card {
|
||||
margin: 5px 0;
|
||||
pointer-events: all;
|
||||
max-height: unset;
|
||||
margin: 5px 0;
|
||||
pointer-events: all;
|
||||
max-height: unset;
|
||||
}
|
||||
|
||||
.card-container {
|
||||
border-radius: 10px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: var(--dark_grey);
|
||||
background-color: var(--bg_elements);
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
text-decoration: none !important;
|
||||
border: solid 1px var(--dark_grey);
|
||||
border-radius: 10px;
|
||||
background-color: var(--bg_elements);
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
text-decoration: none !important;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--grey);
|
||||
}
|
||||
&:hover {
|
||||
border-color: var(--grey);
|
||||
}
|
||||
|
||||
.attachments {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.attachments {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 0.5em;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@include ellipsis;
|
||||
white-space: unset;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
@include ellipsis;
|
||||
white-space: unset;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
margin: 0.3em 0;
|
||||
white-space: pre-wrap;
|
||||
margin: 0.3em 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.card-destination {
|
||||
@include ellipsis;
|
||||
color: var(--grey);
|
||||
display: block;
|
||||
@include ellipsis;
|
||||
color: var(--grey);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-content-container {
|
||||
color: unset;
|
||||
overflow: auto;
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
color: unset;
|
||||
overflow: auto;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.card-image-container {
|
||||
width: 98px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
padding-top: 100%;
|
||||
}
|
||||
width: 98px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
padding-top: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.card-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background-color: var(--bg_overlays);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background-color: var(--bg_overlays);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 400px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 400px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.card-overlay {
|
||||
@include play-button;
|
||||
opacity: 0.8;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@include play-button;
|
||||
opacity: 0.8;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.large {
|
||||
.card-container {
|
||||
display: block;
|
||||
}
|
||||
.card-container {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-image-container {
|
||||
width: unset;
|
||||
.card-image-container {
|
||||
width: unset;
|
||||
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.card-image {
|
||||
position: unset;
|
||||
border-style: solid;
|
||||
border-color: var(--dark_grey);
|
||||
border-width: 0;
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
.card-image {
|
||||
position: unset;
|
||||
border-style: solid;
|
||||
border-color: var(--dark_grey);
|
||||
border-width: 0;
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,159 @@
|
|||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
.embed-video {
|
||||
.gallery-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background-color: black;
|
||||
top: 0%;
|
||||
left: 0%;
|
||||
}
|
||||
// Embed page: transparent background, no scrollbars
|
||||
html:has(body > .embed-wrapper),
|
||||
html:has(body > .embed-video) {
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
|
||||
.video-container {
|
||||
max-height: unset;
|
||||
}
|
||||
body {
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
// Tweet embed wrapper
|
||||
.embed-wrapper {
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--border_grey);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
.embed-footer {
|
||||
display: block;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border_grey);
|
||||
background: var(--bg_panel);
|
||||
color: var(--accent);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg_hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tweet embed content
|
||||
.tweet-embed {
|
||||
position: relative;
|
||||
background-color: var(--bg_panel);
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg_hover);
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
pointer-events: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.tweet-link:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.tweet-content {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.avatar:not(.mini) {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
// Cap media height in embeds
|
||||
.still-image img,
|
||||
.quote-media-container img,
|
||||
.quote-media-container video {
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
&.error-embed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
padding: 20px;
|
||||
|
||||
.error-panel {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Video-only embed
|
||||
.embed-video {
|
||||
position: relative;
|
||||
min-height: 300px;
|
||||
background-color: black;
|
||||
border: 1px solid var(--border_grey);
|
||||
|
||||
.attachments {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
max-height: 560px;
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gallery-video {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gallery-video>.attachment {
|
||||
max-height: 560px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 560px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.video-download {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.video-overlay-link {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(30, 30, 30, 0.75);
|
||||
backdrop-filter: blur(4px);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid transparent;
|
||||
transition:
|
||||
background 0.15s,
|
||||
opacity 0.15s;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
background: rgba(60, 60, 60, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
// Hide button while playing, show on hover or when paused
|
||||
&.video-playing .video-overlay-link {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.video-playing:hover .video-overlay-link {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,119 +1,168 @@
|
|||
@import '_variables';
|
||||
@import "_variables";
|
||||
|
||||
.gallery-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
max-height: 379.5px;
|
||||
max-width: 533px;
|
||||
pointer-events: all;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
max-height: 379.5px;
|
||||
max-width: 533px;
|
||||
pointer-events: all;
|
||||
|
||||
&.mixed-row {
|
||||
.attachment {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1 1 0;
|
||||
max-height: 379.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #101010;
|
||||
}
|
||||
|
||||
.still-image,
|
||||
.still-image img,
|
||||
.attachment > video,
|
||||
.attachment > img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.still-image {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
display: flex;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.still-image img {
|
||||
flex-basis: auto;
|
||||
flex-grow: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.attachment > video,
|
||||
.attachment > img {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.attachment > video {
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attachments {
|
||||
margin-top: .35em;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
max-height: 600px;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
flex-flow: column;
|
||||
background-color: var(--bg_color);
|
||||
align-items: center;
|
||||
pointer-events: all;
|
||||
|
||||
.image-attachment {
|
||||
width: 100%;
|
||||
}
|
||||
margin-top: 0.35em;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
max-height: 600px;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
flex-flow: column;
|
||||
background-color: var(--bg_color);
|
||||
align-items: center;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.attachment {
|
||||
position: relative;
|
||||
line-height: 0;
|
||||
overflow: hidden;
|
||||
margin: 0 .25em 0 0;
|
||||
flex-grow: 1;
|
||||
box-sizing: border-box;
|
||||
min-width: 2em;
|
||||
position: relative;
|
||||
line-height: 0;
|
||||
overflow: hidden;
|
||||
margin: 0 0.25em 0 0;
|
||||
flex-grow: 1;
|
||||
box-sizing: border-box;
|
||||
min-width: 2em;
|
||||
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
max-height: 530px;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-gif video {
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
max-height: 530px;
|
||||
background-color: #101010;
|
||||
}
|
||||
|
||||
.still-image {
|
||||
max-height: 379.5px;
|
||||
max-width: 533px;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
max-width: 100%;
|
||||
max-height: 379.5px;
|
||||
flex-basis: 300px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
// .single-image {
|
||||
// display: inline-block;
|
||||
// width: 100%;
|
||||
// max-height: 600px;
|
||||
|
||||
// .attachments {
|
||||
// width: unset;
|
||||
// max-height: unset;
|
||||
// display: inherit;
|
||||
// }
|
||||
// }
|
||||
|
||||
.overlay-circle {
|
||||
border-radius: 50%;
|
||||
background-color: var(--dark_grey);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
border-width: 5px;
|
||||
border-color: var(--play_button);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.overlay-triangle {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 12px 0 12px 17px;
|
||||
border-color: transparent transparent transparent var(--play_button);
|
||||
margin-left: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.media-gif {
|
||||
display: table;
|
||||
background-color: unset;
|
||||
width: unset;
|
||||
display: table;
|
||||
background-color: unset;
|
||||
width: unset;
|
||||
max-height: unset;
|
||||
}
|
||||
|
||||
.media-gif video,
|
||||
.media-gif img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 530px;
|
||||
background-color: #101010;
|
||||
}
|
||||
|
||||
.still-image {
|
||||
max-height: 379.5px;
|
||||
max-width: 533px;
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
max-width: 100%;
|
||||
max-height: 379.5px;
|
||||
flex-basis: 300px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.alt-text {
|
||||
margin: 0px;
|
||||
padding: 11px 7px;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
width: 2.98em;
|
||||
max-height: 25px;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
color: var(--fg_color);
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.alt-text:hover {
|
||||
padding: 7px;
|
||||
width: Min(230px, calc(100% - 10px * 2));
|
||||
max-height: calc(100% - 10px);
|
||||
line-height: 1.2em;
|
||||
white-space: pre-wrap;
|
||||
transition-duration: 0.4s;
|
||||
transition-property: max-height;
|
||||
}
|
||||
|
||||
.overlay-circle {
|
||||
border-radius: 50%;
|
||||
background-color: var(--dark_grey);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
border-width: 5px;
|
||||
border-color: var(--play_button);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.overlay-triangle {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 12px 0 12px 17px;
|
||||
border-color: transparent transparent transparent var(--play_button);
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.media-body {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
white-space: pre-wrap;
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,42 @@
|
|||
@import '_variables';
|
||||
@import "_variables";
|
||||
|
||||
.poll-meter {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
margin: 6px 0;
|
||||
height: 26px;
|
||||
background: var(--bg_color);
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
margin: 6px 0;
|
||||
height: 26px;
|
||||
background: var(--bg_color);
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.poll-choice-bar {
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background: var(--dark_grey);
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background: var(--dark_grey);
|
||||
}
|
||||
|
||||
.poll-choice-value {
|
||||
position: relative;
|
||||
font-weight: bold;
|
||||
margin-left: 5px;
|
||||
margin-right: 6px;
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
pointer-events: all;
|
||||
position: relative;
|
||||
font-weight: bold;
|
||||
margin-left: 5px;
|
||||
margin-right: 6px;
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.poll-choice-option {
|
||||
position: relative;
|
||||
pointer-events: all;
|
||||
position: relative;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.poll-info {
|
||||
color: var(--grey);
|
||||
pointer-events: all;
|
||||
color: var(--grey);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.leader .poll-choice-bar {
|
||||
background: var(--accent_dark);
|
||||
background: var(--accent_dark);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,94 +1,121 @@
|
|||
@import '_variables';
|
||||
@import "_variables";
|
||||
|
||||
.quote {
|
||||
margin-top: 10px;
|
||||
border: solid 1px var(--dark_grey);
|
||||
border-radius: 10px;
|
||||
background-color: var(--bg_elements);
|
||||
margin-top: 10px;
|
||||
border: solid 1px var(--dark_grey);
|
||||
border-radius: 10px;
|
||||
background-color: var(--bg_elements);
|
||||
overflow: hidden;
|
||||
pointer-events: all;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--grey);
|
||||
}
|
||||
|
||||
&.unavailable:hover {
|
||||
border-color: var(--dark_grey);
|
||||
}
|
||||
|
||||
.tweet-name-row {
|
||||
padding: 8px 10px 6px 10px;
|
||||
}
|
||||
|
||||
.quote-text {
|
||||
overflow: hidden;
|
||||
pointer-events: all;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
padding: 10px;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.show-thread {
|
||||
padding: 0px 10px 6px 10px;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.quote-latest {
|
||||
padding: 0px 10px 6px 10px;
|
||||
color: var(--grey);
|
||||
}
|
||||
|
||||
.replying-to {
|
||||
padding: 0px 10px;
|
||||
padding-bottom: 4px;
|
||||
margin: unset;
|
||||
}
|
||||
|
||||
.community-note {
|
||||
background-color: var(--bg_panel);
|
||||
border: unset;
|
||||
border-top: solid 1px var(--dark_grey);
|
||||
border-radius: unset;
|
||||
margin-top: 0;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--grey);
|
||||
border-top-color: var(--grey);
|
||||
}
|
||||
|
||||
&.unavailable:hover {
|
||||
border-color: var(--dark_grey);
|
||||
}
|
||||
|
||||
.tweet-name-row {
|
||||
padding: 6px 8px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.quote-text {
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
padding: 0px 8px 8px 8px;
|
||||
}
|
||||
|
||||
.show-thread {
|
||||
padding: 0px 8px 6px 8px;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.replying-to {
|
||||
padding: 0px 8px;
|
||||
margin: unset;
|
||||
.community-note-header {
|
||||
background-color: var(--bg_panel);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.unavailable-quote {
|
||||
padding: 12px;
|
||||
padding: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.quote-link {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.quote-media-container {
|
||||
max-height: 300px;
|
||||
max-height: 300px;
|
||||
display: flex;
|
||||
|
||||
.card {
|
||||
margin: unset;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.media-gif {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: unset;
|
||||
.media-gif > .attachment {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: var(--bg_color);
|
||||
|
||||
video,
|
||||
img {
|
||||
height: unset;
|
||||
width: unset;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.attachments {
|
||||
border-radius: 0;
|
||||
}
|
||||
.gallery-row .attachment,
|
||||
.gallery-row .attachment > video,
|
||||
.gallery-row .attachment > img {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.media-gif {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gallery-gif .attachment {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: var(--bg_color);
|
||||
|
||||
video {
|
||||
height: unset;
|
||||
width: unset;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-video, .gallery-gif {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.still-image img {
|
||||
max-height: 250px
|
||||
}
|
||||
.still-image img {
|
||||
max-height: 250px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,138 +1,196 @@
|
|||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
.conversation {
|
||||
@include panel(100%, 600px);
|
||||
.conversation,
|
||||
.edit-history {
|
||||
@include panel(100%, 600px);
|
||||
|
||||
.show-more {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.show-more {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.main-thread {
|
||||
margin-bottom: 20px;
|
||||
background-color: var(--bg_panel);
|
||||
}
|
||||
|
||||
.main-tweet, .replies {
|
||||
padding-top: 50px;
|
||||
margin-top: -50px;
|
||||
}
|
||||
|
||||
.main-tweet .tweet-content {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@media(max-width: 600px) {
|
||||
.main-tweet .tweet-content {
|
||||
font-size: 16px;
|
||||
}
|
||||
.main-thread,
|
||||
.latest-edit {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.reply {
|
||||
background-color: var(--bg_panel);
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.reply-sort {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 14px;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--bg_panel);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.reply-sort-label {
|
||||
color: var(--fg_faded);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.reply-sort-option {
|
||||
color: var(--tab);
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
border-bottom: 0.1rem solid transparent;
|
||||
|
||||
&:hover {
|
||||
color: var(--fg_color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--tab_selected);
|
||||
border-bottom-color: var(--tab_selected);
|
||||
}
|
||||
}
|
||||
|
||||
.main-tweet,
|
||||
.replies,
|
||||
.edit-history > div {
|
||||
body.fixed-nav & {
|
||||
padding-top: 50px;
|
||||
margin-top: -50px;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-history-header {
|
||||
padding: 10px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
background-color: var(--bg_panel);
|
||||
}
|
||||
|
||||
.tweet-edit {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.main-tweet .tweet-content {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.main-tweet .tweet-content {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.thread-line {
|
||||
.timeline-item::before,
|
||||
&.timeline-item::before {
|
||||
background: var(--accent_dark);
|
||||
content: '';
|
||||
position: relative;
|
||||
min-width: 3px;
|
||||
width: 3px;
|
||||
left: 26px;
|
||||
border-radius: 2px;
|
||||
margin-left: -3px;
|
||||
margin-bottom: 37px;
|
||||
top: 56px;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.timeline-item::before,
|
||||
&.timeline-item::before {
|
||||
background: var(--accent_dark);
|
||||
content: "";
|
||||
position: relative;
|
||||
min-width: 3px;
|
||||
width: 3px;
|
||||
left: 26px;
|
||||
border-radius: 2px;
|
||||
margin-left: -3px;
|
||||
margin-bottom: 37px;
|
||||
top: 56px;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.with-header:not(:first-child)::after {
|
||||
background: var(--accent_dark);
|
||||
content: '';
|
||||
position: relative;
|
||||
float: left;
|
||||
min-width: 3px;
|
||||
width: 3px;
|
||||
right: calc(100% - 26px);
|
||||
border-radius: 2px;
|
||||
margin-left: -3px;
|
||||
margin-bottom: 37px;
|
||||
bottom: 10px;
|
||||
height: 30px;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.with-header:not(:first-child)::after {
|
||||
background: var(--accent_dark);
|
||||
content: "";
|
||||
position: relative;
|
||||
float: left;
|
||||
min-width: 3px;
|
||||
width: 3px;
|
||||
right: calc(100% - 26px);
|
||||
border-radius: 2px;
|
||||
margin-left: -3px;
|
||||
margin-bottom: 37px;
|
||||
bottom: 10px;
|
||||
height: 30px;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.unavailable::before {
|
||||
top: 48px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.unavailable::before {
|
||||
top: 48px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.more-replies::before {
|
||||
content: '...';
|
||||
background: unset;
|
||||
color: var(--more_replies_dots);
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
line-height: 0.25em;
|
||||
left: 1.2em;
|
||||
width: 5px;
|
||||
top: 2px;
|
||||
margin-bottom: 0;
|
||||
margin-left: -2.5px;
|
||||
}
|
||||
.more-replies::before {
|
||||
content: "...";
|
||||
background: unset;
|
||||
color: var(--more_replies_dots);
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
line-height: 0.25em;
|
||||
left: 1.2em;
|
||||
width: 5px;
|
||||
top: 2px;
|
||||
margin-bottom: 0;
|
||||
margin-left: -2.5px;
|
||||
}
|
||||
|
||||
.earlier-replies {
|
||||
padding-bottom: 0;
|
||||
margin-bottom: -5px;
|
||||
}
|
||||
.earlier-replies {
|
||||
padding-bottom: 0;
|
||||
margin-bottom: -5px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-item.thread-last::before {
|
||||
background: unset;
|
||||
min-width: unset;
|
||||
width: 0;
|
||||
margin: 0;
|
||||
background: unset;
|
||||
min-width: unset;
|
||||
width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.more-replies {
|
||||
padding-top: 0.3em !important;
|
||||
padding-top: 0.3em !important;
|
||||
}
|
||||
|
||||
.more-replies-text {
|
||||
@include ellipsis;
|
||||
display: block;
|
||||
margin-left: 58px;
|
||||
padding: 7px 0;
|
||||
@include ellipsis;
|
||||
display: block;
|
||||
margin-left: 58px;
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.timeline-item.thread.more-replies-thread {
|
||||
padding: 0 0.75em;
|
||||
padding: 0 0.75em;
|
||||
|
||||
&::before {
|
||||
top: 40px;
|
||||
margin-bottom: 31px;
|
||||
}
|
||||
|
||||
.more-replies {
|
||||
display: flex;
|
||||
padding-top: unset !important;
|
||||
margin-top: 8px;
|
||||
|
||||
&::before {
|
||||
top: 40px;
|
||||
margin-bottom: 31px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
line-height: 0.4em;
|
||||
}
|
||||
|
||||
.more-replies {
|
||||
display: flex;
|
||||
padding-top: unset !important;
|
||||
margin-top: 8px;
|
||||
|
||||
&::before {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
line-height: 0.4em;
|
||||
}
|
||||
|
||||
.more-replies-text {
|
||||
display: inline;
|
||||
}
|
||||
.more-replies-text {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.related-header {
|
||||
padding: 8px 12px;
|
||||
margin-top: 10px;
|
||||
background-color: var(--bg_panel);
|
||||
color: var(--fg_faded);
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid var(--border_grey);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,68 +1,111 @@
|
|||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
video {
|
||||
max-height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gallery-video {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gallery-video.card-container {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
&.card-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
> .attachment {
|
||||
min-height: 80px;
|
||||
min-width: 200px;
|
||||
max-height: 530px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-download {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.7));
|
||||
|
||||
.icon-container {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-download-alt {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment:hover .video-download {
|
||||
opacity: 1;
|
||||
|
||||
&:hover {
|
||||
color: var(--fg_color);
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.video-download {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.video-overlay {
|
||||
@include play-button;
|
||||
background-color: $shadow;
|
||||
@include play-button;
|
||||
background-color: $shadow;
|
||||
|
||||
p {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
text-align: center;
|
||||
top: calc(50% - 20px);
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
margin: 0 20px;
|
||||
}
|
||||
p {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
text-align: center;
|
||||
top: calc(50% - 20px);
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
margin: 0 20px;
|
||||
}
|
||||
|
||||
div {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
top: calc(50% - 20px);
|
||||
margin: 0 auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.overlay-circle {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
top: calc(50% - 20px);
|
||||
margin: 0 auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
form {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
}
|
||||
.overlay-duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
background-color: #0000007a;
|
||||
line-height: 1em;
|
||||
padding: 4px 6px 4px 6px;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 5px 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
form {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 5px 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
64
src/tid.nim
Normal file
64
src/tid.nim
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import std/[asyncdispatch, base64, httpclient, random, strutils, sequtils, times]
|
||||
import nimcrypto
|
||||
import experimental/parser/tid
|
||||
|
||||
randomize()
|
||||
|
||||
const defaultKeyword = "obfiowerehiring";
|
||||
const pairsUrl =
|
||||
"https://raw.githubusercontent.com/fa0311/x-client-transaction-id-pair-dict/refs/heads/main/pair.json";
|
||||
|
||||
var
|
||||
cachedPairs: seq[TidPair] = @[]
|
||||
lastCached = 0
|
||||
# refresh every hour
|
||||
ttlSec = 60 * 60
|
||||
|
||||
proc getPair(): Future[TidPair] {.async.} =
|
||||
if cachedPairs.len == 0 or int(epochTime()) - lastCached > ttlSec:
|
||||
let client = newAsyncHttpClient()
|
||||
defer: client.close()
|
||||
|
||||
let resp = await client.get(pairsUrl)
|
||||
if resp.status == $Http200:
|
||||
cachedPairs = parseTidPairs(await resp.body)
|
||||
lastCached = int(epochTime())
|
||||
|
||||
if cachedPairs.len == 0:
|
||||
raise newException(ValueError, "Failed to fetch x-client-transaction-id pairs")
|
||||
|
||||
return sample(cachedPairs)
|
||||
|
||||
proc encodeSha256(text: string): array[32, byte] =
|
||||
let
|
||||
data = cast[ptr byte](addr text[0])
|
||||
dataLen = uint(len(text))
|
||||
digest = sha256.digest(data, dataLen)
|
||||
return digest.data
|
||||
|
||||
proc encodeBase64[T](data: T): string =
|
||||
return encode(data).replace("=", "")
|
||||
|
||||
proc decodeBase64(data: string): seq[byte] =
|
||||
return cast[seq[byte]](decode(data))
|
||||
|
||||
proc genTid*(path: string): Future[string] {.async.} =
|
||||
let
|
||||
pair = await getPair()
|
||||
|
||||
timeNow = int(epochTime() - 1682924400)
|
||||
timeNowBytes = @[
|
||||
byte(timeNow and 0xff),
|
||||
byte((timeNow shr 8) and 0xff),
|
||||
byte((timeNow shr 16) and 0xff),
|
||||
byte((timeNow shr 24) and 0xff)
|
||||
]
|
||||
|
||||
data = "GET!" & path & "!" & $timeNow & defaultKeyword & pair.animationKey
|
||||
hashBytes = encodeSha256(data)
|
||||
keyBytes = decodeBase64(pair.verification)
|
||||
bytesArr = keyBytes & timeNowBytes & hashBytes[0 ..< 16] & @[3'u8]
|
||||
randomNum = byte(rand(256))
|
||||
tid = @[randomNum] & bytesArr.mapIt(it xor randomNum)
|
||||
|
||||
return encodeBase64(tid)
|
||||
231
src/types.nim
231
src/types.nim
|
|
@ -1,5 +1,5 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import times, sequtils, options, tables, uri
|
||||
import times, sequtils, options, tables
|
||||
import prefs_impl
|
||||
|
||||
genPrefsType()
|
||||
|
|
@ -11,21 +11,16 @@ type
|
|||
BadClientError* = object of CatchableError
|
||||
|
||||
TimelineKind* {.pure.} = enum
|
||||
tweets, replies, media
|
||||
tweets, replies, media, articles
|
||||
|
||||
Api* {.pure.} = enum
|
||||
tweetDetail
|
||||
tweetResult
|
||||
search
|
||||
list
|
||||
listBySlug
|
||||
listMembers
|
||||
listTweets
|
||||
userRestId
|
||||
userScreenName
|
||||
userTweets
|
||||
userTweetsAndReplies
|
||||
userMedia
|
||||
ApiUrl* = object
|
||||
endpoint*: string
|
||||
params*: seq[(string, string)]
|
||||
skipTid*: bool
|
||||
|
||||
ApiReq* = object
|
||||
oauth*: ApiUrl
|
||||
cookie*: ApiUrl
|
||||
|
||||
RateLimit* = object
|
||||
limit*: int
|
||||
|
|
@ -42,7 +37,7 @@ type
|
|||
pending*: int
|
||||
limited*: bool
|
||||
limitedAt*: int
|
||||
apis*: Table[Api, RateLimit]
|
||||
apis*: Table[string, RateLimit]
|
||||
case kind*: SessionKind
|
||||
of oauth:
|
||||
oauthToken*: string
|
||||
|
|
@ -51,10 +46,6 @@ type
|
|||
authToken*: string
|
||||
ct0*: string
|
||||
|
||||
SessionAwareUrl* = object
|
||||
oauthUrl*: Uri
|
||||
cookieUrl*: Uri
|
||||
|
||||
Error* = enum
|
||||
null = 0
|
||||
noUserMatches = 17
|
||||
|
|
@ -70,6 +61,7 @@ type
|
|||
rateLimited = 88
|
||||
expiredToken = 89
|
||||
listIdOrSlug = 112
|
||||
timelineUnavailable = 131
|
||||
tweetNotFound = 144
|
||||
tweetNotAuthorized = 179
|
||||
forbidden = 200
|
||||
|
|
@ -106,6 +98,59 @@ type
|
|||
suspended*: bool
|
||||
joinDate*: DateTime
|
||||
|
||||
AccountInfo* = object
|
||||
username*: string
|
||||
fullname*: string
|
||||
userPic*: string
|
||||
joinDate*: DateTime
|
||||
verifiedType*: VerifiedType
|
||||
suspended*: bool
|
||||
basedIn*: string
|
||||
source*: string
|
||||
usernameChanges*: int
|
||||
lastUsernameChange*: DateTime
|
||||
affiliateUsername*: string
|
||||
affiliateLabel*: string
|
||||
isIdentityVerified*: bool
|
||||
verifiedSince*: DateTime
|
||||
overrideVerifiedYear*: int
|
||||
|
||||
Broadcast* = object
|
||||
id*: string
|
||||
title*: string
|
||||
state*: string
|
||||
thumb*: string
|
||||
mediaKey*: string
|
||||
m3u8Url*: string
|
||||
totalWatched*: int
|
||||
startTime*: DateTime
|
||||
endTime*: DateTime
|
||||
replayStart*: int
|
||||
availableForReplay*: bool
|
||||
user*: User
|
||||
|
||||
SpaceParticipant* = object
|
||||
userId*: string
|
||||
username*: string
|
||||
displayName*: string
|
||||
avatarUrl*: string
|
||||
isVerified*: bool
|
||||
|
||||
AudioSpace* = object
|
||||
id*: string
|
||||
title*: string
|
||||
state*: string
|
||||
mediaKey*: string
|
||||
m3u8Url*: string
|
||||
totalLiveListeners*: int
|
||||
totalReplayWatched*: int
|
||||
startTime*: DateTime
|
||||
endTime*: DateTime
|
||||
availableForReplay*: bool
|
||||
creator*: User
|
||||
admins*: seq[SpaceParticipant]
|
||||
speakers*: seq[SpaceParticipant]
|
||||
|
||||
VideoType* = enum
|
||||
m3u8 = "application/x-mpegURL"
|
||||
mp4 = "video/mp4"
|
||||
|
|
@ -129,10 +174,15 @@ type
|
|||
variants*: seq[VideoVariant]
|
||||
|
||||
QueryKind* = enum
|
||||
posts, replies, media, users, tweets, userList
|
||||
posts, replies, media, users, tweets, userList, followers, following, lists, top,
|
||||
articles
|
||||
|
||||
RankingMode* = enum
|
||||
Relevance, Recency, Likes
|
||||
|
||||
Query* = object
|
||||
kind*: QueryKind
|
||||
view*: string
|
||||
text*: string
|
||||
filters*: seq[string]
|
||||
includes*: seq[string]
|
||||
|
|
@ -140,12 +190,33 @@ type
|
|||
fromUser*: seq[string]
|
||||
since*: string
|
||||
until*: string
|
||||
near*: string
|
||||
minLikes*: string
|
||||
sep*: string
|
||||
|
||||
Gif* = object
|
||||
url*: string
|
||||
thumb*: string
|
||||
altText*: string
|
||||
|
||||
Photo* = object
|
||||
url*: string
|
||||
altText*: string
|
||||
|
||||
MediaKind* = enum
|
||||
photoMedia
|
||||
videoMedia
|
||||
gifMedia
|
||||
|
||||
Media* = object
|
||||
case kind*: MediaKind
|
||||
of photoMedia:
|
||||
photo*: Photo
|
||||
of videoMedia:
|
||||
video*: Video
|
||||
of gifMedia:
|
||||
gif*: Gif
|
||||
|
||||
MediaEntities* = seq[Media]
|
||||
|
||||
GalleryPhoto* = object
|
||||
url*: string
|
||||
|
|
@ -154,6 +225,44 @@ type
|
|||
|
||||
PhotoRail* = seq[GalleryPhoto]
|
||||
|
||||
Article* = ref object
|
||||
title*: string
|
||||
coverImage*: string
|
||||
user*: User
|
||||
time*: DateTime
|
||||
stats*: TweetStats
|
||||
paragraphs*: seq[ArticleParagraph]
|
||||
entities*: Table[int, ArticleEntity]
|
||||
media*: Table[string, ArticleMedia]
|
||||
|
||||
ArticleParagraph* = object
|
||||
text*: string
|
||||
kind*: string
|
||||
inlineStyles*: seq[ArticleStyle]
|
||||
entityRanges*: seq[ArticleEntityRange]
|
||||
|
||||
ArticleStyle* = object
|
||||
offset*: int
|
||||
length*: int
|
||||
style*: string
|
||||
|
||||
ArticleEntityRange* = object
|
||||
offset*: int
|
||||
length*: int
|
||||
key*: int
|
||||
|
||||
ArticleEntity* = object
|
||||
kind*: string
|
||||
url*: string
|
||||
mediaIds*: seq[string]
|
||||
tweetId*: string
|
||||
markdown*: string
|
||||
caption*: string
|
||||
|
||||
ArticleMedia* = object
|
||||
kind*: string
|
||||
url*: string
|
||||
|
||||
Poll* = object
|
||||
options*: seq[string]
|
||||
values*: seq[int]
|
||||
|
|
@ -203,6 +312,12 @@ type
|
|||
likes*: int
|
||||
views*: int
|
||||
|
||||
ArticlePreview* = object
|
||||
title*: string
|
||||
previewText*: string
|
||||
coverImage*: string
|
||||
tweetId*: int64
|
||||
|
||||
Tweet* = ref object
|
||||
id*: int64
|
||||
threadId*: int64
|
||||
|
|
@ -221,13 +336,17 @@ type
|
|||
stats*: TweetStats
|
||||
retweet*: Option[Tweet]
|
||||
attribution*: Option[User]
|
||||
attributionLink*: string
|
||||
mediaTags*: seq[User]
|
||||
quote*: Option[Tweet]
|
||||
card*: Option[Card]
|
||||
poll*: Option[Poll]
|
||||
gif*: Option[Gif]
|
||||
video*: Option[Video]
|
||||
photos*: seq[string]
|
||||
media*: MediaEntities
|
||||
history*: seq[int64]
|
||||
note*: string
|
||||
isAd*: bool
|
||||
isAI*: bool
|
||||
articlePreview*: Option[ArticlePreview]
|
||||
|
||||
Tweets* = seq[Tweet]
|
||||
|
||||
|
|
@ -241,6 +360,7 @@ type
|
|||
content*: Tweets
|
||||
hasMore*: bool
|
||||
cursor*: string
|
||||
related*: bool
|
||||
|
||||
Conversation* = ref object
|
||||
tweet*: Tweet
|
||||
|
|
@ -248,6 +368,10 @@ type
|
|||
after*: Chain
|
||||
replies*: Result[Chain]
|
||||
|
||||
EditHistory* = object
|
||||
latest*: Tweet
|
||||
history*: Tweets
|
||||
|
||||
Timeline* = Result[Tweets]
|
||||
|
||||
Profile* = object
|
||||
|
|
@ -255,6 +379,7 @@ type
|
|||
photoRail*: PhotoRail
|
||||
pinned*: Option[Tweet]
|
||||
tweets*: Timeline
|
||||
accountInfo*: AccountInfo
|
||||
|
||||
List* = object
|
||||
id*: string
|
||||
|
|
@ -265,6 +390,29 @@ type
|
|||
members*: int
|
||||
banner*: string
|
||||
|
||||
ListSearchResult* = object
|
||||
list*: List
|
||||
owner*: User
|
||||
followersContext*: string
|
||||
facepiles*: seq[string]
|
||||
|
||||
CommunityRule* = object
|
||||
name*: string
|
||||
description*: string
|
||||
|
||||
Community* = object
|
||||
id*: string
|
||||
name*: string
|
||||
description*: string
|
||||
memberCount*: int
|
||||
banner*: string
|
||||
creator*: User
|
||||
category*: string
|
||||
joinPolicy*: string
|
||||
createdAt*: DateTime
|
||||
rules*: seq[CommunityRule]
|
||||
hashtags*: seq[string]
|
||||
|
||||
GlobalObjects* = ref object
|
||||
tweets*: Table[string, Tweet]
|
||||
users*: Table[string, User]
|
||||
|
|
@ -281,10 +429,20 @@ type
|
|||
hmacKey*: string
|
||||
base64Media*: bool
|
||||
minTokens*: int
|
||||
enableRss*: bool
|
||||
enableRSSUserTweets*: bool
|
||||
enableRSSUserReplies*: bool
|
||||
enableRSSUserMedia*: bool
|
||||
enableRSSUserArticles*: bool
|
||||
enableRSSSearch*: bool
|
||||
enableRSSList*: bool
|
||||
enableDebug*: bool
|
||||
proxy*: string
|
||||
proxyAuth*: string
|
||||
apiProxy*: string
|
||||
disableTid*: bool
|
||||
maxConcurrentReqs*: int
|
||||
maxRetries*: int
|
||||
retryDelayMs*: int
|
||||
|
||||
rssCacheTime*: int
|
||||
listCacheTime*: int
|
||||
|
|
@ -303,3 +461,24 @@ proc contains*(thread: Chain; tweet: Tweet): bool =
|
|||
|
||||
proc add*(timeline: var seq[Tweets]; tweet: Tweet) =
|
||||
timeline.add @[tweet]
|
||||
|
||||
proc getPhotos*(tweet: Tweet): seq[Photo] =
|
||||
tweet.media.filterIt(it.kind == photoMedia).mapIt(it.photo)
|
||||
|
||||
proc getVideos*(tweet: Tweet): seq[Video] =
|
||||
tweet.media.filterIt(it.kind == videoMedia).mapIt(it.video)
|
||||
|
||||
proc hasPhotos*(tweet: Tweet): bool =
|
||||
tweet.media.anyIt(it.kind == photoMedia)
|
||||
|
||||
proc hasVideos*(tweet: Tweet): bool =
|
||||
tweet.media.anyIt(it.kind == videoMedia)
|
||||
|
||||
proc hasGifs*(tweet: Tweet): bool =
|
||||
tweet.media.anyIt(it.kind == gifMedia)
|
||||
|
||||
proc getThumb*(media: Media): string =
|
||||
case media.kind
|
||||
of photoMedia: media.photo.url
|
||||
of videoMedia: media.video.thumb
|
||||
of gifMedia: media.gif.thumb
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat, uri, tables, base64
|
||||
import sequtils, strutils, strformat, uri, tables, base64
|
||||
import nimcrypto
|
||||
|
||||
var
|
||||
|
|
@ -9,7 +9,7 @@ var
|
|||
const
|
||||
https* = "https://"
|
||||
twimg* = "pbs.twimg.com/"
|
||||
nitterParams = ["name", "tab", "id", "list", "referer", "scroll"]
|
||||
nitterParams* = ["name", "tab", "id", "list", "referer", "scroll", "prefs"]
|
||||
twitterDomains = @[
|
||||
"twitter.com",
|
||||
"pic.twitter.com",
|
||||
|
|
@ -17,7 +17,9 @@ const
|
|||
"abs.twimg.com",
|
||||
"pbs.twimg.com",
|
||||
"video.twimg.com",
|
||||
"x.com"
|
||||
"x.com",
|
||||
"pscp.tv",
|
||||
"video.pscp.tv"
|
||||
]
|
||||
|
||||
proc setHmacKey*(key: string) =
|
||||
|
|
@ -38,12 +40,14 @@ proc getVidUrl*(link: string): string =
|
|||
&"/video/{sig}/{encodeUrl(link)}"
|
||||
|
||||
proc getPicUrl*(link: string): string =
|
||||
if link.len == 0: return
|
||||
if base64Media:
|
||||
&"/pic/enc/{encode(link, safe=true)}"
|
||||
else:
|
||||
&"/pic/{encodeUrl(link)}"
|
||||
|
||||
proc getOrigPicUrl*(link: string): string =
|
||||
if link.len == 0: return
|
||||
if base64Media:
|
||||
&"/pic/orig/enc/{encode(link, safe=true)}"
|
||||
else:
|
||||
|
|
@ -55,7 +59,13 @@ proc filterParams*(params: Table): seq[(string, string)] =
|
|||
result.add p
|
||||
|
||||
proc isTwitterUrl*(uri: Uri): bool =
|
||||
uri.hostname in twitterDomains
|
||||
uri.scheme in ["http", "https"] and
|
||||
(uri.hostname in twitterDomains or uri.hostname.endsWith(".video.pscp.tv"))
|
||||
|
||||
proc isTwitterUrl*(url: string): bool =
|
||||
isTwitterUrl(parseUri(url))
|
||||
|
||||
proc validateNumber*(value: string): string =
|
||||
if value.anyIt(not it.isDigit):
|
||||
return ""
|
||||
return value
|
||||
|
|
|
|||
93
src/views/about_account.nim
Normal file
93
src/views/about_account.nim
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat, times
|
||||
import karax/[karaxdsl, vdom]
|
||||
|
||||
import renderutils
|
||||
import ".."/[types, formatters]
|
||||
|
||||
proc renderAboutAccount*(info: AccountInfo): VNode =
|
||||
let user = User(
|
||||
username: info.username,
|
||||
fullname: info.fullname,
|
||||
userPic: info.userPic,
|
||||
verifiedType: info.verifiedType
|
||||
)
|
||||
|
||||
buildHtml(tdiv(class="about-account")):
|
||||
tdiv(class="about-account-header"):
|
||||
a(class="about-account-avatar", href=(&"/{info.username}")):
|
||||
genImg(getUserPic(info.userPic, "_200x200"))
|
||||
tdiv(class="about-account-name"):
|
||||
linkUser(user, class="profile-card-fullname")
|
||||
verifiedIcon(user)
|
||||
linkUser(user, class="profile-card-username")
|
||||
|
||||
tdiv(class="about-account-body"):
|
||||
tdiv(class="about-account-row"):
|
||||
span: icon "calendar"
|
||||
tdiv:
|
||||
span(class="about-account-label"): text "Date joined"
|
||||
span(class="about-account-value"):
|
||||
text info.joinDate.format("MMMM YYYY")
|
||||
|
||||
if info.basedIn.len > 0:
|
||||
tdiv(class="about-account-row"):
|
||||
span: icon "location"
|
||||
tdiv:
|
||||
span(class="about-account-label"): text "Account based in"
|
||||
span(class="about-account-value"): text info.basedIn
|
||||
|
||||
if info.verifiedType != VerifiedType.none:
|
||||
if info.overrideVerifiedYear != 0:
|
||||
tdiv(class="about-account-row"):
|
||||
span: icon "ok"
|
||||
tdiv:
|
||||
span(class="about-account-label"): text "Verified"
|
||||
span(class="about-account-value"):
|
||||
let year = abs(info.overrideVerifiedYear)
|
||||
let era = if info.overrideVerifiedYear < 0: " BCE" else: ""
|
||||
text "Since " & $year & era
|
||||
elif info.verifiedSince.year > 0:
|
||||
tdiv(class="about-account-row"):
|
||||
span: icon "ok"
|
||||
tdiv:
|
||||
span(class="about-account-label"): text "Verified"
|
||||
span(class="about-account-value"):
|
||||
text "Since " & info.verifiedSince.format("MMMM YYYY")
|
||||
|
||||
if info.isIdentityVerified:
|
||||
tdiv(class="about-account-row"):
|
||||
span: icon "ok"
|
||||
tdiv:
|
||||
span(class="about-account-label"): text "ID Verified"
|
||||
span(class="about-account-value"): text "Yes"
|
||||
|
||||
if info.affiliateUsername.len > 0:
|
||||
tdiv(class="about-account-row"):
|
||||
span: icon "group"
|
||||
tdiv:
|
||||
span(class="about-account-label"): text "An affiliate of"
|
||||
span(class="about-account-value"):
|
||||
a(href=(&"/{info.affiliateUsername}")):
|
||||
if info.affiliateLabel.len > 0:
|
||||
text info.affiliateLabel & " (@" & info.affiliateUsername & ")"
|
||||
else:
|
||||
text "@" & info.affiliateUsername
|
||||
|
||||
if info.usernameChanges > 0:
|
||||
tdiv(class="about-account-row"):
|
||||
span(class="about-account-at"): text "@"
|
||||
tdiv:
|
||||
span(class="about-account-label"):
|
||||
text $info.usernameChanges & " username change"
|
||||
if info.usernameChanges > 1: text "s"
|
||||
if info.lastUsernameChange.year > 0:
|
||||
span(class="about-account-value"):
|
||||
text "Last on " & info.lastUsernameChange.format("MMMM YYYY")
|
||||
|
||||
if info.source.len > 0:
|
||||
tdiv(class="about-account-row"):
|
||||
span: icon "link"
|
||||
tdiv:
|
||||
span(class="about-account-label"): text "Connected via"
|
||||
span(class="about-account-value"): text info.source
|
||||
248
src/views/article.nim
Normal file
248
src/views/article.nim
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat, tables, unicode, bitops, uri
|
||||
import karax/[karaxdsl, vdom]
|
||||
|
||||
import renderutils, tweet, timeline
|
||||
import ".."/[types, utils, formatters]
|
||||
|
||||
proc renderAtomicParagraph(paragraph: ArticleParagraph; article: Article;
|
||||
tweets: Table[int64, Tweet]; path: string;
|
||||
prefs: Prefs): VNode =
|
||||
if paragraph.entityRanges.len == 0:
|
||||
return text ""
|
||||
|
||||
let er = paragraph.entityRanges[0]
|
||||
if er.key notin article.entities:
|
||||
return text ""
|
||||
|
||||
let entity = article.entities[er.key]
|
||||
|
||||
case entity.kind
|
||||
of "MEDIA":
|
||||
buildHtml(tdiv(class="article-media")):
|
||||
for id in entity.mediaIds:
|
||||
let media = article.media.getOrDefault(id)
|
||||
if media.url.len == 0:
|
||||
continue
|
||||
case media.kind
|
||||
of "ApiGif":
|
||||
video(src=getVidUrl(media.url), controls="", autoplay="", loop="",
|
||||
muted="")
|
||||
of "ApiVideo":
|
||||
video(src=getVidUrl(media.url), controls="")
|
||||
else:
|
||||
a(href=getOrigPicUrl(media.url), target="_blank"):
|
||||
img(src=getSmallPic(media.url), alt=entity.caption, loading="lazy")
|
||||
if entity.caption.len > 0:
|
||||
p(class="article-media-caption"): text entity.caption
|
||||
of "TWEET":
|
||||
let tweet = tweets.getOrDefault(
|
||||
try: parseBiggestInt(entity.tweetId)
|
||||
except ValueError: 0, nil)
|
||||
if tweet != nil:
|
||||
renderTweet(tweet, prefs, path)
|
||||
else:
|
||||
text ""
|
||||
of "MARKDOWN":
|
||||
var content = entity.markdown
|
||||
if content.startsWith("```"):
|
||||
let firstNl = content.find('\n')
|
||||
if firstNl >= 0: content = content[firstNl + 1 .. ^1]
|
||||
if content.endsWith("```"): content = content[0 .. ^4]
|
||||
content = content.strip
|
||||
buildHtml(pre()):
|
||||
code(): text content
|
||||
of "DIVIDER":
|
||||
buildHtml(hr(class="article-divider"))
|
||||
else:
|
||||
text ""
|
||||
|
||||
proc wrapStyle(node: VNode; style: int): VNode =
|
||||
result = node
|
||||
if style.testBit(4): result = buildHtml(code()): result
|
||||
if style.testBit(0): result = buildHtml(strong()): result
|
||||
if style.testBit(1): result = buildHtml(em()): result
|
||||
if style.testBit(2): result = buildHtml(del()): result
|
||||
if style.testBit(3): result = buildHtml(underlined()): result
|
||||
|
||||
proc addContent(target: VNode; content: string; style = 0) =
|
||||
var first = true
|
||||
for line in content.split('\n'):
|
||||
if not first:
|
||||
target.add VNode(kind: VNodeKind.br)
|
||||
first = false
|
||||
var pos = 0
|
||||
while pos < line.len:
|
||||
let atPos = line.find('@', pos)
|
||||
if atPos == -1:
|
||||
target.add wrapStyle(text line[pos .. ^1], style)
|
||||
break
|
||||
if atPos > 0 and line[atPos - 1] in Letters + Digits + {'_'}:
|
||||
target.add wrapStyle(text line[pos .. atPos], style)
|
||||
pos = atPos + 1
|
||||
continue
|
||||
var j = atPos + 1
|
||||
while j < line.len and j - atPos - 1 < 15 and
|
||||
line[j] in Letters + Digits + {'_'}:
|
||||
inc j
|
||||
if j == atPos + 1:
|
||||
target.add wrapStyle(text line[pos .. atPos], style)
|
||||
pos = atPos + 1
|
||||
continue
|
||||
if atPos > pos:
|
||||
target.add wrapStyle(text line[pos ..< atPos], style)
|
||||
let username = line[atPos + 1 ..< j]
|
||||
let link = a.newVNode()
|
||||
link.setAttr("href", "/" & username)
|
||||
link.add wrapStyle(text ("@" & username), style)
|
||||
target.add link
|
||||
pos = j
|
||||
|
||||
proc applyInlineStyles(target: VNode; runes: seq[Rune]; start, length: int;
|
||||
styles: seq[ArticleStyle]) =
|
||||
if styles.len == 0:
|
||||
target.addContent($runes[start ..< start + length])
|
||||
return
|
||||
|
||||
var
|
||||
lastStyle = 0
|
||||
lastStart = start
|
||||
let endPos = start + length
|
||||
|
||||
for i in start ..< endPos:
|
||||
var style = 0
|
||||
for sr in styles:
|
||||
let
|
||||
sStart = sr.offset
|
||||
sEnd = sStart + sr.length
|
||||
if sStart <= i and sEnd > i:
|
||||
case sr.style
|
||||
of "Bold": style.setBit(0)
|
||||
of "Italic": style.setBit(1)
|
||||
of "Strikethrough": style.setBit(2)
|
||||
of "Underline": style.setBit(3)
|
||||
of "Code": style.setBit(4)
|
||||
else: discard
|
||||
|
||||
if style != lastStyle:
|
||||
if i > lastStart:
|
||||
addContent(target, $runes[lastStart ..< i], lastStyle)
|
||||
lastStyle = style
|
||||
lastStart = i
|
||||
|
||||
if lastStart < endPos:
|
||||
addContent(target, $runes[lastStart ..< endPos], lastStyle)
|
||||
|
||||
proc renderTextParagraph(paragraph: ArticleParagraph; article: Article): VNode =
|
||||
let text = paragraph.text
|
||||
|
||||
result = case paragraph.kind
|
||||
of "header-one": h1.newVNode()
|
||||
of "header-two": h2.newVNode()
|
||||
of "header-three": h3.newVNode()
|
||||
of "ordered-list-item", "unordered-list-item": li.newVNode()
|
||||
of "blockquote": VNode(kind: VNodeKind.blockquote)
|
||||
of "code-block":
|
||||
let pre = pre.newVNode()
|
||||
let code = code.newVNode()
|
||||
code.add text text
|
||||
pre.add code
|
||||
return pre
|
||||
else: p.newVNode()
|
||||
|
||||
let
|
||||
runes = text.toRunes
|
||||
textLen = runes.len
|
||||
var last = 0
|
||||
for er in paragraph.entityRanges:
|
||||
if er.offset > last:
|
||||
applyInlineStyles(result, runes, last, er.offset - last,
|
||||
paragraph.inlineStyles)
|
||||
|
||||
last = er.offset + er.length
|
||||
|
||||
var target = result
|
||||
if er.key in article.entities:
|
||||
let entity = article.entities[er.key]
|
||||
if entity.kind == "LINK":
|
||||
let parsed = parseUri(entity.url)
|
||||
if parsed.scheme in ["http", "https"]:
|
||||
target = a.newVNode()
|
||||
if parsed.isTwitterUrl:
|
||||
target.setAttr("href", parsed.path)
|
||||
else:
|
||||
target.setAttr("href", entity.url)
|
||||
|
||||
applyInlineStyles(target, runes, er.offset, er.length,
|
||||
paragraph.inlineStyles)
|
||||
if target != result:
|
||||
result.add target
|
||||
|
||||
if last < textLen:
|
||||
applyInlineStyles(result, runes, last, textLen - last,
|
||||
paragraph.inlineStyles)
|
||||
|
||||
if paragraph.kind == "blockquote" and result.len > 0:
|
||||
let lastChild = result[result.len - 1]
|
||||
if lastChild.kind == VNodeKind.strong and lastChild.len > 0 and
|
||||
lastChild[0].kind == VNodeKind.text:
|
||||
lastChild.setAttr("class", "blockquote-attribution")
|
||||
|
||||
proc renderArticle*(article: Article; tweets: Table[int64, Tweet];
|
||||
path: string; prefs: Prefs; tweetId=""): VNode =
|
||||
let author = article.user
|
||||
|
||||
let main = buildHtml(article(class="article-body")):
|
||||
h1(class="article-title"): text article.title
|
||||
|
||||
tdiv(class="article-author"):
|
||||
tdiv(class="article-author-row"):
|
||||
a(class="article-avatar", href=("/" & author.username)):
|
||||
genImg(author.getUserPic("_bigger"), class=prefs.getAvatarClass)
|
||||
tdiv(class="article-author-info"):
|
||||
tdiv(class="article-author-name"):
|
||||
linkUser(author, class="fullname")
|
||||
verifiedIcon(author)
|
||||
tdiv(class="article-author-meta"):
|
||||
linkUser(author, class="username")
|
||||
span(class="article-date-sep"): text " · "
|
||||
a(class="article-date",
|
||||
href=("/" & author.username & "/status/" & tweetId)):
|
||||
text article.time.getShortTime
|
||||
if not prefs.hideTweetStats:
|
||||
renderStats(article.stats)
|
||||
|
||||
var listKind = ""
|
||||
var list: VNode = nil
|
||||
|
||||
for paragraph in article.paragraphs:
|
||||
let isListItem = paragraph.kind in [
|
||||
"ordered-list-item", "unordered-list-item"]
|
||||
|
||||
if not isListItem and list != nil:
|
||||
main.add list
|
||||
list = nil
|
||||
listKind = ""
|
||||
|
||||
if paragraph.kind == "atomic":
|
||||
main.add renderAtomicParagraph(paragraph, article, tweets, path, prefs)
|
||||
elif isListItem:
|
||||
if paragraph.kind != listKind:
|
||||
if list != nil:
|
||||
main.add list
|
||||
list = if paragraph.kind == "ordered-list-item": ol.newVNode()
|
||||
else: ul.newVNode()
|
||||
listKind = paragraph.kind
|
||||
list.add renderTextParagraph(paragraph, article)
|
||||
else:
|
||||
main.add renderTextParagraph(paragraph, article)
|
||||
|
||||
if list != nil:
|
||||
main.add list
|
||||
|
||||
buildHtml(tdiv(class="article-page")):
|
||||
if article.coverImage.len > 0:
|
||||
a(href=getOrigPicUrl(article.coverImage), target="_blank"):
|
||||
img(class="article-cover", src=getSmallPic(article.coverImage), alt="")
|
||||
main
|
||||
renderToTop()
|
||||
75
src/views/broadcast.nim
Normal file
75
src/views/broadcast.nim
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat, times
|
||||
import karax/[karaxdsl, vdom]
|
||||
|
||||
import renderutils
|
||||
import ".."/[types, utils, formatters]
|
||||
|
||||
proc renderBroadcast*(bc: Broadcast; prefs: Prefs; path: string): VNode =
|
||||
let
|
||||
isLive = bc.state == "RUNNING"
|
||||
thumb = getPicUrl(bc.thumb)
|
||||
source = if prefs.proxyVideos and bc.m3u8Url.startsWith("http"):
|
||||
getVidUrl(bc.m3u8Url) else: bc.m3u8Url
|
||||
stateText =
|
||||
if isLive: "LIVE"
|
||||
elif bc.endTime.year > 1: "Ended " & bc.endTime.format("MMM d, YYYY")
|
||||
elif bc.state.len > 0: bc.state
|
||||
else: "Ended"
|
||||
durationMs =
|
||||
if bc.startTime.year > 1 and bc.endTime.year > 1:
|
||||
int((bc.endTime - bc.startTime).inMilliseconds) - bc.replayStart * 1000
|
||||
else: 0
|
||||
duration = if durationMs > 0: getDuration(durationMs) else: ""
|
||||
|
||||
buildHtml(tdiv(class="broadcast-page")):
|
||||
tdiv(class="broadcast-panel"):
|
||||
tdiv(class="broadcast-player"):
|
||||
if bc.m3u8Url.len > 0 and prefs.hlsPlayback:
|
||||
video(poster=thumb, data-url=source, data-autoload="false",
|
||||
data-start=($bc.replayStart), muted=prefs.muteVideos)
|
||||
verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
|
||||
tdiv(class="overlay-circle"): span(class="overlay-triangle")
|
||||
if isLive:
|
||||
tdiv(class="broadcast-live"): text "LIVE"
|
||||
elif duration.len > 0:
|
||||
tdiv(class="overlay-duration"): text duration
|
||||
verbatim "</div>"
|
||||
elif bc.m3u8Url.len > 0:
|
||||
img(src=thumb, alt=bc.title)
|
||||
tdiv(class="video-overlay"):
|
||||
buttonReferer "/enablehls", "Enable hls playback", path
|
||||
if isLive:
|
||||
tdiv(class="broadcast-live"): text "LIVE"
|
||||
elif duration.len > 0:
|
||||
tdiv(class="overlay-duration"): text duration
|
||||
elif bc.thumb.len > 0:
|
||||
img(src=thumb, alt=bc.title)
|
||||
tdiv(class="video-overlay"):
|
||||
if bc.availableForReplay:
|
||||
p: text "Stream unavailable"
|
||||
else:
|
||||
p: text "Replay is not available"
|
||||
else:
|
||||
tdiv(class="video-overlay"):
|
||||
p: text "Broadcast not found"
|
||||
|
||||
tdiv(class="broadcast-info"):
|
||||
h2(class="broadcast-title"): text bc.title
|
||||
|
||||
tdiv(class="broadcast-user-row"):
|
||||
a(class="broadcast-user", href=("/" & bc.user.username)):
|
||||
genImg(getUserPic(bc.user.userPic, "_bigger"))
|
||||
tdiv:
|
||||
tdiv:
|
||||
strong: text bc.user.fullname
|
||||
verifiedIcon(bc.user)
|
||||
span(class="broadcast-username"): text "@" & bc.user.username
|
||||
|
||||
tdiv(class="broadcast-meta"):
|
||||
if bc.totalWatched > 0:
|
||||
span: text insertSep($bc.totalWatched, ',') & " views"
|
||||
if isLive:
|
||||
span(class="broadcast-live"): text stateText
|
||||
else:
|
||||
span: text stateText
|
||||
128
src/views/community.nim
Normal file
128
src/views/community.nim
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat, times
|
||||
import karax/[karaxdsl, vdom]
|
||||
|
||||
import renderutils
|
||||
import ".."/[types, utils, formatters]
|
||||
|
||||
proc renderCommunityTabs*(kind: QueryKind; community: Community): VNode =
|
||||
let
|
||||
path = &"/i/communities/{community.id}"
|
||||
q = Query(kind: kind)
|
||||
buildHtml(tdiv):
|
||||
ul(class="tab"):
|
||||
li(class=q.getTabClass(posts)):
|
||||
a(href=path): text "Top"
|
||||
li(class=q.getTabClass(replies)):
|
||||
a(href=(path & "/latest")): text "Latest"
|
||||
li(class=q.getTabClass(media)):
|
||||
a(href=(path & "/media")): text "Media"
|
||||
li(class=q.getTabClass(userList)):
|
||||
a(href=(path & "/about")): text "About"
|
||||
if community.hashtags.len > 0:
|
||||
tdiv(class="community-tags"):
|
||||
for tag in community.hashtags:
|
||||
let bare = tag.strip(chars={'#'})
|
||||
a(class="community-tag",
|
||||
href=(&"/i/communities/{community.id}/hashtag/{bare}")):
|
||||
text tag
|
||||
|
||||
proc renderMemberTabs*(community: Community; isModerators: bool): VNode =
|
||||
let path = &"/i/communities/{community.id}"
|
||||
buildHtml(ul(class="tab")):
|
||||
li(class=(if not isModerators: "tab-item active" else: "tab-item")):
|
||||
a(href=(path & "/members")): text "All"
|
||||
li(class=(if isModerators: "tab-item active" else: "tab-item")):
|
||||
a(href=(path & "/moderators")): text "Moderators"
|
||||
|
||||
proc renderHashtagHeader*(community: Community; tag: string): VNode =
|
||||
buildHtml(tdiv(class="community-hashtag-header")):
|
||||
h2(class="community-hashtag-title"): text "#" & tag
|
||||
|
||||
proc renderCommunityAbout*(community: Community; moderators: seq[User]): VNode =
|
||||
buildHtml(tdiv(class="community-about")):
|
||||
tdiv(class="community-info"):
|
||||
h2: text "Community Info"
|
||||
tdiv(class="community-info-item"):
|
||||
icon "group"
|
||||
if community.joinPolicy == "Open":
|
||||
text "Anyone can join this Community."
|
||||
else:
|
||||
text "Membership is by approval only."
|
||||
|
||||
tdiv(class="community-info-item"):
|
||||
icon "info"
|
||||
text "All Communities are publicly visible."
|
||||
|
||||
tdiv(class="community-info-item"):
|
||||
icon "calendar"
|
||||
let
|
||||
date = community.createdAt.format("MMMM d, yyyy")
|
||||
creator = community.creator.username
|
||||
span:
|
||||
text &"Created {date} by "
|
||||
a(href=(&"/{creator}")): text &"@{creator}"
|
||||
if community.creator.verifiedType != none:
|
||||
verifiedIcon(community.creator)
|
||||
|
||||
if community.rules.len > 0:
|
||||
tdiv(class="community-rules"):
|
||||
h2: text "Rules"
|
||||
p(class="community-rules-intro"):
|
||||
text "These are set and enforced by Community admins and are in addition to "
|
||||
a(href="https://help.x.com/rules-and-policies/x-rules"): text "X's rules"
|
||||
text "."
|
||||
|
||||
for i, rule in community.rules:
|
||||
tdiv(class="community-rule"):
|
||||
span(class="community-rule-number"): text $(i + 1)
|
||||
tdiv(class="community-rule-content"):
|
||||
strong: text rule.name
|
||||
if rule.description.len > 0:
|
||||
p: text rule.description
|
||||
|
||||
if moderators.len > 0:
|
||||
tdiv(class="community-moderators"):
|
||||
h2:
|
||||
text "Moderators"
|
||||
a(class="community-mods-link",
|
||||
href=(&"/i/communities/{community.id}/moderators")):
|
||||
text "See all"
|
||||
for user in moderators:
|
||||
tdiv(class="community-moderator"):
|
||||
a(href=(&"/{user.username}")):
|
||||
genImg(user.getUserPic("_bigger"), class="community-mod-avatar")
|
||||
tdiv(class="community-mod-info"):
|
||||
a(href=(&"/{user.username}"), class="community-mod-name"):
|
||||
text user.fullname
|
||||
if user.verifiedType != none:
|
||||
verifiedIcon(user)
|
||||
a(href=(&"/{user.username}"), class="community-mod-username"):
|
||||
text &"@{user.username}"
|
||||
|
||||
proc renderCommunity*(body, nav: VNode; community: Community): VNode =
|
||||
buildHtml(tdiv(class="timeline-container")):
|
||||
if community.banner.len > 0:
|
||||
tdiv(class="timeline-banner"):
|
||||
a(href=getPicUrl(community.banner), target="_blank"):
|
||||
genImg(community.banner)
|
||||
|
||||
tdiv(class="community-header"):
|
||||
h1(class="community-name"):
|
||||
a(href=(&"/i/communities/{community.id}")): text community.name
|
||||
|
||||
if community.category.len > 0:
|
||||
span(class="community-category"): text community.category
|
||||
|
||||
if community.description.len > 0:
|
||||
tdiv(class="community-description"):
|
||||
text community.description
|
||||
|
||||
tdiv(class="community-stats"):
|
||||
a(class="community-member-count",
|
||||
href=(&"/i/communities/{community.id}/members")):
|
||||
text insertSep($community.memberCount, ',')
|
||||
text " Members"
|
||||
|
||||
nav
|
||||
body
|
||||
|
|
@ -1,22 +1,76 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import options
|
||||
import karax/[karaxdsl, vdom]
|
||||
from jester import Request
|
||||
|
||||
import ".."/[types, formatters]
|
||||
import ".."/[types, formatters, prefs]
|
||||
import general, tweet
|
||||
|
||||
const doctype = "<!DOCTYPE html>\n"
|
||||
const
|
||||
doctype = "<!DOCTYPE html>\n"
|
||||
embedResizeJs = staticRead("../../public/js/embedResize.js")
|
||||
|
||||
proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
|
||||
let thumb = get(tweet.video).thumb
|
||||
let vidUrl = getVideoEmbed(cfg, tweet.id)
|
||||
let prefs = Prefs(hlsPlayback: true, mp4Playback: true)
|
||||
let
|
||||
video = tweet.getVideos()[0]
|
||||
thumb = video.thumb
|
||||
vidUrl = getVideoEmbed(cfg, tweet.id)
|
||||
prefs = Prefs(hlsPlayback: true, mp4Playback: true, proxyVideos: defaultPrefs.proxyVideos)
|
||||
tweetUrl = getLink(tweet)
|
||||
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb]))
|
||||
base(target="_blank")
|
||||
|
||||
body:
|
||||
tdiv(class="embed-video"):
|
||||
renderVideo(get(tweet.video), prefs, "")
|
||||
renderVideo(video, prefs, "")
|
||||
a(class="video-overlay-link", href=tweetUrl):
|
||||
text "Watch on " & cfg.hostname
|
||||
|
||||
script:
|
||||
verbatim embedResizeJs
|
||||
|
||||
result = doctype & $node
|
||||
|
||||
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req)
|
||||
base(target="_blank")
|
||||
|
||||
body:
|
||||
tdiv(class="embed-wrapper"):
|
||||
tdiv(class="tweet-embed"):
|
||||
a(class="tweet-link", href=getLink(tweet))
|
||||
renderTweet(tweet, prefs, path, mainTweet=true)
|
||||
a(class="embed-footer", href=getLink(tweet)):
|
||||
text "Read more on " & cfg.hostname
|
||||
|
||||
script:
|
||||
verbatim embedResizeJs
|
||||
|
||||
result = doctype & $node
|
||||
|
||||
proc renderErrorEmbed*(error: string; prefs: Prefs; cfg: Config; req: Request;
|
||||
tweetId = ""; username = ""): string =
|
||||
let link = if tweetId.len > 0:
|
||||
if username.len > 0: "/" & username & "/status/" & tweetId
|
||||
else: "/i/status/" & tweetId
|
||||
else: "/"
|
||||
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req)
|
||||
base(target="_blank")
|
||||
|
||||
body:
|
||||
tdiv(class="embed-wrapper"):
|
||||
tdiv(class="tweet-embed error-embed"):
|
||||
a(class="tweet-link", href=link)
|
||||
tdiv(class="error-panel"):
|
||||
span: text error
|
||||
a(class="embed-footer", href=link):
|
||||
text "Read more on " & cfg.hostname
|
||||
|
||||
script:
|
||||
verbatim embedResizeJs
|
||||
|
||||
result = doctype & $node
|
||||
|
|
|
|||
|
|
@ -29,19 +29,17 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
|
|||
|
||||
tdiv(class="nav-item right"):
|
||||
icon "search", title="Search", href="/search"
|
||||
if cfg.enableRss and rss.len > 0:
|
||||
if rss.len > 0:
|
||||
icon "rss", title="RSS Feed", href=rss
|
||||
icon "bird", title="Open in Twitter", href=canonical
|
||||
icon "bird", title="Open in X", href=canonical
|
||||
a(href="https://liberapay.com/zedeus"): verbatim lp
|
||||
icon "info", title="About", href="/about"
|
||||
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
|
||||
|
||||
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
|
||||
rss=""; canonical=""): VNode =
|
||||
var theme = prefs.theme.toTheme
|
||||
if "theme" in req.params:
|
||||
theme = req.params["theme"].toTheme
|
||||
rss=""; alternate=""; oembed=""): VNode =
|
||||
let theme = prefs.theme.toTheme
|
||||
|
||||
let ogType =
|
||||
if video.len > 0: "video"
|
||||
|
|
@ -52,8 +50,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
||||
|
||||
buildHtml(head):
|
||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=19")
|
||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3")
|
||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=106")
|
||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7")
|
||||
|
||||
if theme.len > 0:
|
||||
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
|
||||
|
|
@ -66,15 +64,19 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
|
||||
href=opensearchUrl)
|
||||
|
||||
if canonical.len > 0:
|
||||
link(rel="canonical", href=canonical)
|
||||
if alternate.len > 0:
|
||||
link(rel="alternate", href=alternate, title="View on X")
|
||||
|
||||
if cfg.enableRss and rss.len > 0:
|
||||
if rss.len > 0:
|
||||
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
|
||||
|
||||
if oembed.len > 0:
|
||||
let oembedTitle = if titleText.len > 0: titleText else: "oEmbed"
|
||||
link(rel="alternate", type="application/json+oembed", href=oembed, title=oembedTitle)
|
||||
|
||||
if prefs.hlsPlayback:
|
||||
script(src="/js/hls.min.js", `defer`="")
|
||||
script(src="/js/hlsPlayback.js", `defer`="")
|
||||
script(src="/js/hlsPlayback.js?v=1", `defer`="")
|
||||
|
||||
if prefs.infiniteScroll:
|
||||
script(src="/js/infiniteScroll.js", `defer`="")
|
||||
|
|
@ -86,6 +88,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||
text cfg.title
|
||||
|
||||
meta(name="viewport", content="width=device-width, initial-scale=1.0")
|
||||
meta(name="referrer", content="same-origin")
|
||||
meta(name="theme-color", content="#1F1F1F")
|
||||
meta(property="og:type", content=ogType)
|
||||
meta(property="og:title", content=(if ogTitle.len > 0: ogTitle else: titleText))
|
||||
|
|
@ -98,6 +101,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||
link(rel="preload", type="image/png", href=bannerUrl, `as`="image")
|
||||
|
||||
for url in images:
|
||||
if url.len == 0: continue
|
||||
let preloadUrl = if "400x400" in url: getPicUrl(url)
|
||||
else: getSmallPic(url)
|
||||
link(rel="preload", type="image/png", href=preloadUrl, `as`="image")
|
||||
|
|
@ -119,20 +123,24 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||
# this is last so images are also preloaded
|
||||
# if this is done earlier, Chrome only preloads one image for some reason
|
||||
link(rel="preload", type="font/woff2", `as`="font",
|
||||
href="/fonts/fontello.woff2?61663884", crossorigin="anonymous")
|
||||
href="/fonts/fontello.woff2?59696369", crossorigin="anonymous")
|
||||
|
||||
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
|
||||
titleText=""; desc=""; ogTitle=""; rss=""; video="";
|
||||
images: seq[string] = @[]; banner=""): string =
|
||||
images: seq[string] = @[]; banner="";
|
||||
twitterLink=""; oembed=""): string =
|
||||
|
||||
let canonical = getTwitterLink(req.path, req.params)
|
||||
let twitterLink =
|
||||
if twitterLink.len > 0: twitterLink
|
||||
else: getTwitterLink(req.path, req.params)
|
||||
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
|
||||
rss, canonical)
|
||||
rss, twitterLink, oembed)
|
||||
|
||||
body:
|
||||
renderNavbar(cfg, req, rss, canonical)
|
||||
let bodyClass = if prefs.stickyNav: "fixed-nav" else: ""
|
||||
body(class=bodyClass):
|
||||
renderNavbar(cfg, req, rss, twitterLink)
|
||||
|
||||
tdiv(class="container"):
|
||||
body
|
||||
|
|
|
|||
7
src/views/oembed.nimf
Normal file
7
src/views/oembed.nimf
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#? stdtmpl(subsChar = '$', metaChar = '#')
|
||||
## SPDX-License-Identifier: AGPL-3.0-only
|
||||
#proc renderOembedIframe*(embedUrl: string; maxwidth = 550): string =
|
||||
# result = ""
|
||||
<iframe src="$embedUrl" style="width:100%;max-width:${maxwidth}px;height:250px;border:none" scrolling="no" loading="lazy" onload="let c=new MessageChannel;c.port1.onmessage=e=>this.style.height=e.data+'px';this.contentWindow.postMessage('','*',[c.port2])"></iframe>
|
||||
# result = result.strip()
|
||||
#end proc
|
||||
|
|
@ -32,7 +32,8 @@ macro renderPrefs*(): untyped =
|
|||
|
||||
result[2].add stmt
|
||||
|
||||
proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode =
|
||||
proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string];
|
||||
prefsUrl: string): VNode =
|
||||
buildHtml(tdiv(class="overlay-panel")):
|
||||
fieldset(class="preferences"):
|
||||
form(`method`="post", action="/saveprefs", autocomplete="off"):
|
||||
|
|
@ -40,6 +41,14 @@ proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode
|
|||
|
||||
renderPrefs()
|
||||
|
||||
legend: text "Bookmark"
|
||||
p(class="bookmark-note"):
|
||||
text "Save this URL to restore your preferences (?prefs works on all pages)"
|
||||
pre(class="prefs-code"):
|
||||
text prefsUrl
|
||||
p(class="bookmark-note"):
|
||||
verbatim "You can override preferences with query parameters (e.g. <code>?hlsPlayback=on</code>). These overrides aren't saved to cookies, and links won't retain the parameters. Intended for configuring RSS feeds and other cookieless environments. Hover over a preference to see its name."
|
||||
|
||||
h4(class="note"):
|
||||
text "Preferences are stored client-side using cookies without any personal information."
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import strutils, strformat
|
||||
import karax/[karaxdsl, vdom, vstyles]
|
||||
|
||||
import renderutils, search
|
||||
import renderutils, search, timeline
|
||||
import ".."/[types, utils, formatters]
|
||||
|
||||
proc renderStat(num: int; class: string; text=""): VNode =
|
||||
|
|
@ -12,7 +12,14 @@ proc renderStat(num: int; class: string; text=""): VNode =
|
|||
span(class="profile-stat-num"):
|
||||
text insertSep($num, ',')
|
||||
|
||||
proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
||||
proc renderStatLink(num: int; class, href: string): VNode =
|
||||
buildHtml(li(class=class)):
|
||||
a(href=href):
|
||||
span(class="profile-stat-header"): text capitalizeAscii(class)
|
||||
span(class="profile-stat-num"):
|
||||
text insertSep($num, ',')
|
||||
|
||||
proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
|
||||
buildHtml(tdiv(class="profile-card")):
|
||||
tdiv(class="profile-card-info"):
|
||||
let
|
||||
|
|
@ -26,6 +33,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
|||
|
||||
tdiv(class="profile-card-tabs-name"):
|
||||
linkUser(user, class="profile-card-fullname")
|
||||
verifiedIcon(user)
|
||||
linkUser(user, class="profile-card-username")
|
||||
|
||||
tdiv(class="profile-card-extra"):
|
||||
|
|
@ -45,6 +53,11 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
|||
else:
|
||||
span: text place
|
||||
|
||||
if info.basedIn.len > 0:
|
||||
tdiv(class="profile-location"):
|
||||
span: icon "location"
|
||||
span: text "Based in " & info.basedIn
|
||||
|
||||
if user.website.len > 0:
|
||||
tdiv(class="profile-website"):
|
||||
span:
|
||||
|
|
@ -53,14 +66,14 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
|||
a(href=url): text url.shortLink
|
||||
|
||||
tdiv(class="profile-joindate"):
|
||||
span(title=getJoinDateFull(user)):
|
||||
a(href=(&"/{user.username}/about"), title=getJoinDateFull(user)):
|
||||
icon "calendar", getJoinDate(user)
|
||||
|
||||
tdiv(class="profile-card-extra-links"):
|
||||
ul(class="profile-statlist"):
|
||||
renderStat(user.tweets, "posts", text="Tweets")
|
||||
renderStat(user.following, "following")
|
||||
renderStat(user.followers, "followers")
|
||||
renderStatLink(user.following, "following", &"/{user.username}/following")
|
||||
renderStatLink(user.followers, "followers", &"/{user.username}/followers")
|
||||
renderStat(user.likes, "likes")
|
||||
|
||||
proc renderPhotoRail(profile: Profile): VNode =
|
||||
|
|
@ -93,7 +106,7 @@ proc renderBanner(banner: string): VNode =
|
|||
else:
|
||||
a(href=getPicUrl(banner), target="_blank"): genImg(banner)
|
||||
|
||||
proc renderProtected(username: string): VNode =
|
||||
proc renderProtected*(username: string): VNode =
|
||||
buildHtml(tdiv(class="timeline-container")):
|
||||
tdiv(class="timeline-header timeline-protected"):
|
||||
h2: text "This account's tweets are protected."
|
||||
|
|
@ -101,19 +114,52 @@ proc renderProtected(username: string): VNode =
|
|||
|
||||
proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
|
||||
profile.tweets.query.fromUser = @[profile.user.username]
|
||||
let
|
||||
isGalleryView = profile.tweets.query.kind == QueryKind.media and
|
||||
profile.tweets.query.view == "gallery"
|
||||
viewClass = if isGalleryView: " media-only" else: ""
|
||||
|
||||
buildHtml(tdiv(class="profile-tabs")):
|
||||
if not prefs.hideBanner:
|
||||
buildHtml(tdiv(class=("profile-tabs" & viewClass))):
|
||||
if not isGalleryView and not prefs.hideBanner:
|
||||
tdiv(class="profile-banner"):
|
||||
renderBanner(profile.user.banner)
|
||||
|
||||
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
||||
tdiv(class=("profile-tab" & sticky)):
|
||||
renderUserCard(profile.user, prefs)
|
||||
if profile.photoRail.len > 0:
|
||||
renderPhotoRail(profile)
|
||||
if not isGalleryView:
|
||||
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
||||
tdiv(class=("profile-tab" & sticky)):
|
||||
renderUserCard(profile.user, prefs, profile.accountInfo)
|
||||
if profile.photoRail.len > 0:
|
||||
renderPhotoRail(profile)
|
||||
|
||||
if profile.user.protected:
|
||||
renderProtected(profile.user.username)
|
||||
else:
|
||||
renderTweetSearch(profile.tweets, prefs, path, profile.pinned)
|
||||
|
||||
proc renderFollowTabs(user: User; activeTab: string): VNode =
|
||||
buildHtml(ul(class="tab")):
|
||||
for tab in ["Following", "Followers"]:
|
||||
li(class=(if activeTab == tab: "tab-item active" else: "tab-item")):
|
||||
a(href=(&"/{user.username}/{tab.toLowerAscii()}")): text tab
|
||||
|
||||
proc renderUserList*(user: User; results: Result[User]; prefs: Prefs;
|
||||
path, activeTab: string): VNode =
|
||||
# Check if we've loaded all results (for page 1)
|
||||
var displayResults = results
|
||||
if results.beginning:
|
||||
let expectedCount = if activeTab == "Followers": user.followers else: user.following
|
||||
if results.content.len >= expectedCount:
|
||||
displayResults.bottom = ""
|
||||
|
||||
buildHtml(tdiv(class="profile-tabs")):
|
||||
if not prefs.hideBanner:
|
||||
tdiv(class="profile-banner"):
|
||||
renderBanner(user.banner)
|
||||
|
||||
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
||||
tdiv(class=("profile-tab" & sticky)):
|
||||
renderUserCard(user, prefs, AccountInfo())
|
||||
|
||||
tdiv(class="timeline-container"):
|
||||
renderFollowTabs(user, activeTab)
|
||||
renderTimelineUsers(displayResults, prefs, path)
|
||||
|
|
|
|||
|
|
@ -4,14 +4,23 @@ import karax/[karaxdsl, vdom, vstyles]
|
|||
import ".."/[types, utils]
|
||||
|
||||
const smallWebp* = "?name=small&format=webp"
|
||||
const mediumWebp* = "?name=medium&format=webp"
|
||||
|
||||
proc getSmallPic*(url: string): string =
|
||||
if url.len == 0: return
|
||||
result = url
|
||||
if "?" notin url and not url.endsWith("placeholder.png"):
|
||||
result &= smallWebp
|
||||
result = getPicUrl(result)
|
||||
|
||||
proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
|
||||
proc getMediumPic*(url: string): string =
|
||||
if url.len == 0: return
|
||||
result = url
|
||||
if "?" notin url and not url.endsWith("placeholder.png"):
|
||||
result &= mediumWebp
|
||||
result = getPicUrl(result)
|
||||
|
||||
proc icon*(icon: string; label=""; title=""; class=""; href=""): VNode =
|
||||
var c = "icon-" & icon
|
||||
if class.len > 0: c = &"{c} {class}"
|
||||
buildHtml(tdiv(class="icon-container")):
|
||||
|
|
@ -20,13 +29,15 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
|
|||
else:
|
||||
span(class=c, title=title)
|
||||
|
||||
if text.len > 0:
|
||||
text " " & text
|
||||
if label.len > 0:
|
||||
text " " & label
|
||||
|
||||
template verifiedIcon*(user: User): untyped {.dirty.} =
|
||||
if user.verifiedType != VerifiedType.none:
|
||||
let lower = ($user.verifiedType).toLowerAscii()
|
||||
icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account")
|
||||
buildHtml(tdiv(class=(&"verified-icon {lower}"))):
|
||||
icon "circle", class="verified-icon-circle", title=(&"Verified {lower} account")
|
||||
icon "ok", class="verified-icon-check", title=(&"Verified {lower} account")
|
||||
else:
|
||||
text ""
|
||||
|
||||
|
|
@ -40,7 +51,6 @@ proc linkUser*(user: User, class=""): VNode =
|
|||
buildHtml(a(href=href, class=class, title=nameText)):
|
||||
text nameText
|
||||
if isName:
|
||||
verifiedIcon(user)
|
||||
if user.protected:
|
||||
text " "
|
||||
icon "lock", title="Protected account"
|
||||
|
|
@ -64,20 +74,20 @@ proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNod
|
|||
text text
|
||||
|
||||
proc genCheckbox*(pref, label: string; state: bool): VNode =
|
||||
buildHtml(label(class="pref-group checkbox-container")):
|
||||
buildHtml(label(class="pref-group checkbox-container", title=pref)):
|
||||
text label
|
||||
input(name=pref, `type`="checkbox", checked=state)
|
||||
span(class="checkbox")
|
||||
|
||||
proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode =
|
||||
let p = placeholder
|
||||
buildHtml(tdiv(class=("pref-group pref-input " & class))):
|
||||
buildHtml(tdiv(class=("pref-group pref-input " & class), title=pref)):
|
||||
if label.len > 0:
|
||||
label(`for`=pref): text label
|
||||
input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0))
|
||||
|
||||
proc genSelect*(pref, label, state: string; options: seq[string]): VNode =
|
||||
buildHtml(tdiv(class="pref-group pref-input")):
|
||||
buildHtml(tdiv(class="pref-group pref-input", title=pref)):
|
||||
label(`for`=pref): text label
|
||||
select(name=pref):
|
||||
for opt in options:
|
||||
|
|
@ -89,9 +99,16 @@ proc genDate*(pref, state: string): VNode =
|
|||
input(name=pref, `type`="date", value=state)
|
||||
icon "calendar"
|
||||
|
||||
proc genImg*(url: string; class=""): VNode =
|
||||
proc genNumberInput*(pref, label, state, placeholder: string; class=""; autofocus=true; min="0"): VNode =
|
||||
let p = placeholder
|
||||
buildHtml(tdiv(class=("pref-group pref-input " & class))):
|
||||
if label.len > 0:
|
||||
label(`for`=pref): text label
|
||||
input(name=pref, `type`="number", placeholder=p, value=state, autofocus=(autofocus and state.len == 0), min=min, step="1")
|
||||
|
||||
proc genImg*(url: string; class=""; alt=""): VNode =
|
||||
buildHtml():
|
||||
img(src=getPicUrl(url), class=class, alt="", loading="lazy")
|
||||
img(src=getPicUrl(url), class=class, alt=alt, loading="lazy")
|
||||
|
||||
proc getTabClass*(query: Query; tab: QueryKind): string =
|
||||
if query.kind == tab: "tab-item active"
|
||||
|
|
|
|||
|
|
@ -1,31 +1,140 @@
|
|||
#? stdtmpl(subsChar = '$', metaChar = '#')
|
||||
## SPDX-License-Identifier: AGPL-3.0-only
|
||||
#import strutils, xmltree, strformat, options, unicode
|
||||
#import strutils, sequtils, xmltree, strformat, options, unicode
|
||||
#import ../types, ../utils, ../formatters, ../prefs
|
||||
## Snowflake ID cutoff for RSS GUID format transition
|
||||
## Corresponds to approximately December 14, 2025 UTC
|
||||
#const guidCutoff = 2000000000000000000'i64
|
||||
#
|
||||
#proc getTitle(tweet: Tweet; retweet: string): string =
|
||||
#if tweet.pinned: result = "Pinned: "
|
||||
#elif retweet.len > 0: result = &"RT by @{retweet}: "
|
||||
#elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: "
|
||||
#var prefix = ""
|
||||
#if tweet.pinned: prefix = "Pinned: "
|
||||
#elif retweet.len > 0: prefix = &"RT by @{retweet}: "
|
||||
#elif tweet.reply.len > 0: prefix = &"R to @{tweet.reply[0]}: "
|
||||
#end if
|
||||
#var text = stripHtml(tweet.text)
|
||||
#var text = strutils.splitWhitespace(stripHtml(tweet.text)).join(" ")
|
||||
##if unicode.runeLen(text) > 32:
|
||||
## text = unicode.runeSubStr(text, 0, 32) & "..."
|
||||
##end if
|
||||
#result &= xmltree.escape(text)
|
||||
#if result.len > 0: return
|
||||
#text = xmltree.escape(text)
|
||||
## article tweets' text is just the article link; the title says more
|
||||
#if tweet.articlePreview.isSome and tweet.articlePreview.get().title.len > 0:
|
||||
# result = prefix & xmltree.escape(tweet.articlePreview.get().title)
|
||||
# return
|
||||
#end if
|
||||
#if tweet.photos.len > 0:
|
||||
# result &= "Image"
|
||||
#elif tweet.video.isSome:
|
||||
# result &= "Video"
|
||||
#elif tweet.gif.isSome:
|
||||
# result &= "Gif"
|
||||
#if text.len > 0:
|
||||
# result = prefix & text
|
||||
# return
|
||||
#end if
|
||||
#if tweet.media.len > 0:
|
||||
# result = prefix
|
||||
# let firstKind = tweet.media[0].kind
|
||||
# if tweet.media.anyIt(it.kind != firstKind):
|
||||
# result &= "Media"
|
||||
# else:
|
||||
# case firstKind
|
||||
# of photoMedia: result &= "Image"
|
||||
# of videoMedia: result &= "Video"
|
||||
# of gifMedia: result &= "Gif"
|
||||
# end case
|
||||
# end if
|
||||
#end if
|
||||
#if result.len == 0 and tweet.card.isSome:
|
||||
# let card = tweet.card.get()
|
||||
# if card.kind notin {hidden, unknown} and card.title.len > 0:
|
||||
# result = prefix & xmltree.escape(card.title)
|
||||
# end if
|
||||
#end if
|
||||
#end proc
|
||||
#
|
||||
#proc getDescription(desc: string; cfg: Config): string =
|
||||
Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
||||
Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
|
||||
#end proc
|
||||
#
|
||||
#proc renderRssMedia(media: Media; tweet: Tweet; urlPrefix: string): string =
|
||||
#case media.kind
|
||||
#of photoMedia:
|
||||
# let photo = media.photo
|
||||
<img src="${urlPrefix}${getPicUrl(photo.url)}" style="max-width:250px;" />
|
||||
#of videoMedia:
|
||||
# let video = media.video
|
||||
<a href="${urlPrefix}${tweet.getLink}">
|
||||
<br>Video<br>
|
||||
<img src="${urlPrefix}${getPicUrl(video.thumb)}" style="max-width:250px;" />
|
||||
</a>
|
||||
#of gifMedia:
|
||||
# let gif = media.gif
|
||||
# let thumb = &"{urlPrefix}{getPicUrl(gif.thumb)}"
|
||||
# let url = &"{urlPrefix}{getPicUrl(gif.url)}"
|
||||
<video poster="${thumb}" autoplay muted loop style="max-width:250px;">
|
||||
<source src="${url}" type="video/mp4"></video>
|
||||
#end case
|
||||
#end proc
|
||||
#
|
||||
#proc renderRssCard(card: Card; prefs: Prefs; urlPrefix: string): string =
|
||||
#let cardLink = if card.url.startsWith("/"): urlPrefix & card.url else: replaceUrls(card.url, prefs)
|
||||
#let title = xmltree.escape(card.title)
|
||||
<hr/>
|
||||
<b>Link</b><br>
|
||||
#if cardLink.len > 0:
|
||||
<a href="${xmltree.escape(cardLink)}">
|
||||
#end if
|
||||
#if card.image.len > 0:
|
||||
<img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" />
|
||||
#end if
|
||||
#if title.len > 0:
|
||||
# if card.image.len > 0:
|
||||
<br>
|
||||
# end if
|
||||
<b>${title}</b>
|
||||
#end if
|
||||
#if cardLink.len > 0:
|
||||
</a>
|
||||
#end if
|
||||
#if card.text.len > 0:
|
||||
<p>${xmltree.escape(card.text)}</p>
|
||||
#end if
|
||||
#if cardLink.len > 0:
|
||||
# let destText = if card.dest.len > 0: xmltree.escape(card.dest) else: xmltree.escape(cardLink)
|
||||
<small><a href="${xmltree.escape(cardLink)}">${destText}</a></small>
|
||||
#elif card.dest.len > 0:
|
||||
<small>${xmltree.escape(card.dest)}</small>
|
||||
#end if
|
||||
#end proc
|
||||
#
|
||||
#proc renderRssArticle(article: ArticlePreview; urlPrefix: string): string =
|
||||
#let link = urlPrefix & "/i/article/" & $article.tweetId
|
||||
<hr/>
|
||||
<b>Article</b><br>
|
||||
<a href="${link}">
|
||||
#if article.coverImage.len > 0:
|
||||
<img src="${urlPrefix}${getPicUrl(article.coverImage)}" style="max-width:250px;" />
|
||||
#end if
|
||||
#if article.title.len > 0:
|
||||
# if article.coverImage.len > 0:
|
||||
<br>
|
||||
# end if
|
||||
<b>${xmltree.escape(article.title)}</b>
|
||||
#end if
|
||||
</a>
|
||||
#if article.previewText.len > 0:
|
||||
<p>${xmltree.escape(article.previewText)}</p>
|
||||
#end if
|
||||
#end proc
|
||||
#
|
||||
#proc renderRssPoll(poll: Poll): string =
|
||||
<hr/>
|
||||
<b>Poll</b>
|
||||
<p>
|
||||
#for i in 0 ..< poll.options.len:
|
||||
# let perc = if poll.votes > 0: poll.values[i] / poll.votes * 100 else: 0.0
|
||||
# let pct = (&"{perc:.0f}").strip(chars={'.'})
|
||||
# let line = pct & "% — " & xmltree.escape(poll.options[i])
|
||||
${line}<br>
|
||||
#end for
|
||||
#let votesStr = insertSep($poll.votes, ',')
|
||||
<i>${votesStr} votes • ${xmltree.escape(poll.status)}</i>
|
||||
</p>
|
||||
#end proc
|
||||
#
|
||||
#proc getTweetsWithPinned(profile: Profile): seq[Tweets] =
|
||||
|
|
@ -46,35 +155,46 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
|||
#end if
|
||||
#end proc
|
||||
#
|
||||
#proc renderRssTweet(tweet: Tweet; cfg: Config): string =
|
||||
#proc renderRssTweet(tweet: Tweet; cfg: Config; prefs: Prefs): string =
|
||||
#let tweet = tweet.retweet.get(tweet)
|
||||
#let urlPrefix = getUrlPrefix(cfg)
|
||||
#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix)
|
||||
#let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix)
|
||||
#if text.len > 0:
|
||||
<p>${text.replace("\n", "<br>\n")}</p>
|
||||
#if tweet.quote.isSome and get(tweet.quote).available:
|
||||
# let quoteLink = getLink(get(tweet.quote))
|
||||
<p><a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p>
|
||||
#end if
|
||||
#if tweet.photos.len > 0:
|
||||
# for photo in tweet.photos:
|
||||
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" />
|
||||
#if tweet.media.len > 0:
|
||||
# for media in tweet.media:
|
||||
${renderRssMedia(media, tweet, urlPrefix)}
|
||||
# end for
|
||||
#elif tweet.video.isSome:
|
||||
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
|
||||
#elif tweet.gif.isSome:
|
||||
# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
|
||||
# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
|
||||
<video poster="${thumb}" autoplay muted loop style="max-width:250px;">
|
||||
<source src="${url}" type="video/mp4"></video>
|
||||
#elif tweet.card.isSome:
|
||||
# let card = tweet.card.get()
|
||||
# if card.image.len > 0:
|
||||
<img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" />
|
||||
# end if
|
||||
#elif tweet.card.isSome and tweet.card.get().kind notin {hidden, unknown}:
|
||||
${renderRssCard(tweet.card.get(), prefs, urlPrefix)}
|
||||
#end if
|
||||
#if tweet.articlePreview.isSome:
|
||||
${renderRssArticle(tweet.articlePreview.get(), urlPrefix)}
|
||||
#end if
|
||||
#if tweet.poll.isSome:
|
||||
${renderRssPoll(tweet.poll.get())}
|
||||
#end if
|
||||
#if tweet.note.len > 0 and not prefs.hideCommunityNotes:
|
||||
<p><b>Community note:</b> ${replaceUrls(tweet.note, prefs, absolute=urlPrefix)}</p>
|
||||
#end if
|
||||
#if tweet.quote.isSome and get(tweet.quote).available:
|
||||
# let quoteTweet = get(tweet.quote)
|
||||
# let quoteLink = urlPrefix & getLink(quoteTweet)
|
||||
<hr/>
|
||||
<blockquote>
|
||||
<b>${quoteTweet.user.fullname} (@${quoteTweet.user.username})</b>
|
||||
<p>
|
||||
${renderRssTweet(quoteTweet, cfg, prefs)}
|
||||
</p>
|
||||
<footer>
|
||||
— <cite><a href="${quoteLink}">${quoteLink}</a>
|
||||
</footer>
|
||||
</blockquote>
|
||||
#end if
|
||||
#end proc
|
||||
#
|
||||
#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string =
|
||||
#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; prefs: Prefs; userId=""): string =
|
||||
#let urlPrefix = getUrlPrefix(cfg)
|
||||
#var links: seq[string]
|
||||
#for thread in tweets:
|
||||
|
|
@ -88,19 +208,24 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
|||
# if link in links: continue
|
||||
# end if
|
||||
# links.add link
|
||||
# let useGlobalGuid = tweet.id >= guidCutoff
|
||||
<item>
|
||||
<title>${getTitle(tweet, retweet)}</title>
|
||||
<dc:creator>@${tweet.user.username}</dc:creator>
|
||||
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
|
||||
<description><![CDATA[${renderRssTweet(tweet, cfg, prefs).strip(chars={'\n'})}]]></description>
|
||||
<pubDate>${getRfc822Time(tweet)}</pubDate>
|
||||
#if useGlobalGuid:
|
||||
<guid isPermaLink="false">${tweet.id}</guid>
|
||||
#else:
|
||||
<guid>${urlPrefix & link}</guid>
|
||||
#end if
|
||||
<link>${urlPrefix & link}</link>
|
||||
</item>
|
||||
# end for
|
||||
#end for
|
||||
#end proc
|
||||
#
|
||||
#proc renderTimelineRss*(profile: Profile; cfg: Config; multi=false): string =
|
||||
#proc renderTimelineRss*(profile: Profile; cfg: Config; prefs: Prefs; multi=false): string =
|
||||
#let urlPrefix = getUrlPrefix(cfg)
|
||||
#result = ""
|
||||
#let handle = (if multi: "" else: "@") & profile.user.username
|
||||
|
|
@ -126,13 +251,13 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
|||
</image>
|
||||
#let tweetsList = getTweetsWithPinned(profile)
|
||||
#if tweetsList.len > 0:
|
||||
${renderRssTweets(tweetsList, cfg, userId=profile.user.id)}
|
||||
${renderRssTweets(tweetsList, cfg, prefs, userId=profile.user.id)}
|
||||
#end if
|
||||
</channel>
|
||||
</rss>
|
||||
#end proc
|
||||
#
|
||||
#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config): string =
|
||||
#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config; prefs: Prefs): string =
|
||||
#let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}"
|
||||
#result = ""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
|
@ -144,12 +269,12 @@ ${renderRssTweets(tweetsList, cfg, userId=profile.user.id)}
|
|||
<description>${getDescription(&"{list.name} by @{list.username}", cfg)}</description>
|
||||
<language>en-us</language>
|
||||
<ttl>40</ttl>
|
||||
${renderRssTweets(tweets, cfg)}
|
||||
${renderRssTweets(tweets, cfg, prefs)}
|
||||
</channel>
|
||||
</rss>
|
||||
#end proc
|
||||
#
|
||||
#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config): string =
|
||||
#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config; prefs: Prefs): string =
|
||||
#let link = &"{getUrlPrefix(cfg)}/search"
|
||||
#let escName = xmltree.escape(name)
|
||||
#result = ""
|
||||
|
|
@ -162,7 +287,7 @@ ${renderRssTweets(tweets, cfg)}
|
|||
<description>${getDescription(&"Search \"{escName}\"", cfg)}</description>
|
||||
<language>en-us</language>
|
||||
<ttl>40</ttl>
|
||||
${renderRssTweets(tweets, cfg)}
|
||||
${renderRssTweets(tweets, cfg, prefs)}
|
||||
</channel>
|
||||
</rss>
|
||||
#end proc
|
||||
|
|
|
|||
|
|
@ -10,14 +10,12 @@ const toggles = {
|
|||
"media": "Media",
|
||||
"videos": "Videos",
|
||||
"news": "News",
|
||||
"verified": "Verified",
|
||||
"native_video": "Native videos",
|
||||
"replies": "Replies",
|
||||
"links": "Links",
|
||||
"images": "Images",
|
||||
"safe": "Safe",
|
||||
"quote": "Quotes",
|
||||
"pro_video": "Pro videos"
|
||||
"spaces": "Spaces"
|
||||
}.toOrderedTable
|
||||
|
||||
proc renderSearch*(): VNode =
|
||||
|
|
@ -38,29 +36,62 @@ proc renderProfileTabs*(query: Query; username: string): VNode =
|
|||
a(href=(link & "/with_replies")): text "Tweets & Replies"
|
||||
li(class=query.getTabClass(media)):
|
||||
a(href=(link & "/media")): text "Media"
|
||||
if query.fromUser.len == 1:
|
||||
li(class=query.getTabClass(QueryKind.articles)):
|
||||
a(href=(link & "/articles")): text "Articles"
|
||||
li(class=query.getTabClass(tweets)):
|
||||
a(href=(link & "/search")): text "Search"
|
||||
|
||||
proc mediaViewUrl(query: Query; view: string): string =
|
||||
var q = query
|
||||
q.view = view
|
||||
"?" & genQueryUrl(q)
|
||||
|
||||
proc renderMediaViewTabs*(query: Query): VNode =
|
||||
let currentView = if query.view.len > 0: query.view else: "timeline"
|
||||
func cls(view: string): string =
|
||||
if currentView == view: "tab-item active" else: "tab-item"
|
||||
buildHtml(ul(class="tab media-view-tabs")):
|
||||
li(class=cls("timeline")):
|
||||
a(href=query.mediaViewUrl("timeline")): text "Timeline"
|
||||
li(class=cls("grid")):
|
||||
a(href=query.mediaViewUrl("grid")): text "Grid"
|
||||
li(class=cls("gallery")):
|
||||
a(href=query.mediaViewUrl("gallery")): text "Gallery"
|
||||
|
||||
proc renderSearchTabs*(query: Query): VNode =
|
||||
var q = query
|
||||
# the media view mode only applies to the Media tab
|
||||
q.view = ""
|
||||
buildHtml(ul(class="tab")):
|
||||
li(class=query.getTabClass(top)):
|
||||
q.kind = top
|
||||
a(href=("?" & genQueryUrl(q))): text "Top"
|
||||
li(class=query.getTabClass(tweets)):
|
||||
q.kind = tweets
|
||||
a(href=("?" & genQueryUrl(q))): text "Tweets"
|
||||
a(href=("?" & genQueryUrl(q))): text "Latest"
|
||||
li(class=query.getTabClass(media)):
|
||||
q.kind = media
|
||||
q.view = query.view
|
||||
a(href=("?" & genQueryUrl(q))): text "Media"
|
||||
li(class=query.getTabClass(users)):
|
||||
q.kind = users
|
||||
q.view = ""
|
||||
a(href=("?" & genQueryUrl(q))): text "Users"
|
||||
li(class=query.getTabClass(lists)):
|
||||
q.kind = lists
|
||||
a(href=("?" & genQueryUrl(q))): text "Lists"
|
||||
|
||||
proc isPanelOpen(q: Query): bool =
|
||||
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or
|
||||
@[q.near, q.until, q.since].anyIt(it.len > 0))
|
||||
@[q.minLikes, q.until, q.since].anyIt(it.len > 0))
|
||||
|
||||
proc renderSearchPanel*(query: Query): VNode =
|
||||
let user = query.fromUser.join(",")
|
||||
let action = if user.len > 0: &"/{user}/search" else: "/search"
|
||||
buildHtml(form(`method`="get", action=action,
|
||||
class="search-field", autocomplete="off")):
|
||||
hiddenField("f", "tweets")
|
||||
hiddenField("f", $query.kind)
|
||||
genInput("q", "", query.text, "Enter search...", class="pref-inline")
|
||||
button(`type`="submit"): icon "search"
|
||||
|
||||
|
|
@ -85,36 +116,58 @@ proc renderSearchPanel*(query: Query): VNode =
|
|||
span(class="search-title"): text "-"
|
||||
genDate("until", query.until)
|
||||
tdiv:
|
||||
span(class="search-title"): text "Near"
|
||||
genInput("near", "", query.near, "Location...", autofocus=false)
|
||||
span(class="search-title"): text "Minimum likes"
|
||||
genNumberInput("min_faves", "", query.minLikes, "Number...", autofocus=false)
|
||||
|
||||
proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
|
||||
pinned=none(Tweet)): VNode =
|
||||
let query = results.query
|
||||
buildHtml(tdiv(class="timeline-container")):
|
||||
let containerClass =
|
||||
if query.fromUser.len == 0 and query.kind == QueryKind.media and
|
||||
query.view == "gallery": "timeline-container media-only"
|
||||
else: "timeline-container"
|
||||
buildHtml(tdiv(class=containerClass)):
|
||||
if query.fromUser.len > 1:
|
||||
tdiv(class="timeline-header"):
|
||||
text query.fromUser.join(" | ")
|
||||
|
||||
if query.fromUser.len > 0:
|
||||
renderProfileTabs(query, query.fromUser.join(","))
|
||||
if query.kind != QueryKind.media or query.view != "gallery":
|
||||
renderProfileTabs(query, query.fromUser.join(","))
|
||||
if query.kind == QueryKind.media and query.fromUser.len == 1:
|
||||
renderMediaViewTabs(query)
|
||||
|
||||
if query.fromUser.len == 0 or query.kind == tweets:
|
||||
if query.fromUser.len == 0 or query.kind == QueryKind.tweets:
|
||||
tdiv(class="timeline-header"):
|
||||
renderSearchPanel(query)
|
||||
|
||||
if query.fromUser.len == 0:
|
||||
renderSearchTabs(query)
|
||||
if query.kind == QueryKind.media:
|
||||
renderMediaViewTabs(query)
|
||||
|
||||
renderTimelineTweets(results, prefs, path, pinned)
|
||||
|
||||
proc renderSearchForm(kind, placeholder, value: string): VNode =
|
||||
buildHtml(form(`method`="get", action="/search",
|
||||
class="search-field", autocomplete="off")):
|
||||
hiddenField("f", kind)
|
||||
genInput("q", "", value, placeholder, class="pref-inline")
|
||||
button(`type`="submit"): icon "search"
|
||||
|
||||
proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv(class="timeline-container")):
|
||||
tdiv(class="timeline-header"):
|
||||
form(`method`="get", action="/search", class="search-field", autocomplete="off"):
|
||||
hiddenField("f", "users")
|
||||
genInput("q", "", results.query.text, "Enter username...", class="pref-inline")
|
||||
button(`type`="submit"): icon "search"
|
||||
renderSearchForm("users", "Enter username...", results.query.text)
|
||||
|
||||
renderSearchTabs(results.query)
|
||||
renderTimelineUsers(results, prefs)
|
||||
|
||||
proc renderListSearch*(results: Result[ListSearchResult]; prefs: Prefs;
|
||||
path: string): VNode =
|
||||
buildHtml(tdiv(class="timeline-container")):
|
||||
tdiv(class="timeline-header"):
|
||||
renderSearchForm("lists", "Enter search...", results.query.text)
|
||||
|
||||
renderSearchTabs(results.query)
|
||||
renderTimelineLists(results, prefs, path)
|
||||
|
|
|
|||
86
src/views/space.nim
Normal file
86
src/views/space.nim
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, times
|
||||
import karax/[karaxdsl, vdom]
|
||||
|
||||
import renderutils
|
||||
import ".."/[types, utils, formatters]
|
||||
|
||||
proc renderParticipant(p: SpaceParticipant; role: string): VNode =
|
||||
buildHtml(tdiv(class="space-participant")):
|
||||
a(href=("/" & p.username)):
|
||||
genImg(p.avatarUrl.replace("_normal", "_bigger"))
|
||||
tdiv(class="participant-info"):
|
||||
tdiv(class="participant-name"):
|
||||
strong: text p.displayName
|
||||
if p.isVerified:
|
||||
tdiv(class="verified-icon blue"):
|
||||
icon "circle", class="verified-icon-circle", title="Verified account"
|
||||
icon "ok", class="verified-icon-check", title="Verified account"
|
||||
if role.len > 0:
|
||||
span(class="host-badge"): text role
|
||||
span(class="participant-username"): text "@" & p.username
|
||||
|
||||
proc renderSpace*(sp: AudioSpace; prefs: Prefs; path: string): VNode =
|
||||
let
|
||||
isLive = sp.state == "RUNNING"
|
||||
source = if prefs.proxyVideos and sp.m3u8Url.startsWith("http"):
|
||||
getVidUrl(sp.m3u8Url) else: sp.m3u8Url
|
||||
stateText =
|
||||
if isLive: "LIVE"
|
||||
elif sp.endTime.year > 1: "Ended " & sp.endTime.format("MMM d, YYYY")
|
||||
elif sp.state.len > 0: sp.state
|
||||
else: "Ended"
|
||||
durationMs =
|
||||
if sp.startTime.year > 1 and sp.endTime.year > 1:
|
||||
int((sp.endTime - sp.startTime).inMilliseconds)
|
||||
else: 0
|
||||
duration = if durationMs > 0: getDuration(durationMs) else: ""
|
||||
totalListeners =
|
||||
if sp.totalReplayWatched > 0: sp.totalReplayWatched
|
||||
else: sp.totalLiveListeners
|
||||
|
||||
buildHtml(tdiv(class="space-page")):
|
||||
tdiv(class="space-panel"):
|
||||
tdiv(class="space-player"):
|
||||
if sp.m3u8Url.len > 0 and prefs.hlsPlayback:
|
||||
audio(data-url=source, data-autoload="false")
|
||||
verbatim "<div class=\"video-overlay\" onclick=\"playAudio(this)\">"
|
||||
tdiv(class="overlay-circle"): span(class="overlay-triangle")
|
||||
if isLive:
|
||||
tdiv(class="space-live"): text "LIVE"
|
||||
elif duration.len > 0:
|
||||
tdiv(class="overlay-duration"): text duration
|
||||
verbatim "</div>"
|
||||
elif sp.m3u8Url.len > 0:
|
||||
tdiv(class="video-overlay"):
|
||||
buttonReferer "/enablehls", "Enable hls playback", path
|
||||
if isLive:
|
||||
tdiv(class="space-live"): text "LIVE"
|
||||
elif duration.len > 0:
|
||||
tdiv(class="overlay-duration"): text duration
|
||||
elif sp.availableForReplay:
|
||||
tdiv(class="video-overlay"):
|
||||
p: text "Audio stream unavailable"
|
||||
else:
|
||||
tdiv(class="video-overlay"):
|
||||
p: text "Replay is not available"
|
||||
|
||||
tdiv(class="space-info"):
|
||||
tdiv(class="space-header"):
|
||||
h2(class="space-title"): text sp.title
|
||||
tdiv(class="space-meta"):
|
||||
if totalListeners > 0:
|
||||
span(class="listener-count"): text insertSep($totalListeners, ',') & " listeners"
|
||||
if isLive:
|
||||
span(class="space-live"): text stateText
|
||||
else:
|
||||
span(class="space-state"): text stateText
|
||||
|
||||
if sp.admins.len > 0 or sp.speakers.len > 0:
|
||||
tdiv(class="space-participants"):
|
||||
for admin in sp.admins:
|
||||
let role = if admin.username == sp.creator.username: "Host"
|
||||
else: "Co-host"
|
||||
renderParticipant(admin, role)
|
||||
for speaker in sp.speakers:
|
||||
renderParticipant(speaker, "")
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import sequtils
|
||||
import karax/[karaxdsl, vdom]
|
||||
|
||||
import ".."/[types, formatters]
|
||||
|
|
@ -28,16 +29,46 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode =
|
|||
if thread.hasMore:
|
||||
renderMoreReplies(thread)
|
||||
|
||||
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode =
|
||||
proc renderReplySort(sort: RankingMode): VNode =
|
||||
buildHtml(tdiv(class="reply-sort")):
|
||||
span(class="reply-sort-label"): text "Sort replies:"
|
||||
for mode in RankingMode:
|
||||
let
|
||||
cls = if mode == sort: "reply-sort-option active"
|
||||
else: "reply-sort-option"
|
||||
label = case mode
|
||||
of Relevance: "Relevant"
|
||||
of Recency: "Recent"
|
||||
of Likes: "Liked"
|
||||
a(class=cls, href=("?sort=" & $mode & "#r")):
|
||||
text label
|
||||
|
||||
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string;
|
||||
tweet: Tweet = nil; sort = Relevance): VNode =
|
||||
buildHtml(tdiv(class="replies", id="r")):
|
||||
var hasReplies = false
|
||||
var replyCount = 0
|
||||
for thread in replies.content:
|
||||
if thread.content.len == 0: continue
|
||||
if thread.content.len == 0 or thread.related: continue
|
||||
hasReplies = true
|
||||
replyCount += thread.content.len
|
||||
renderReplyThread(thread, prefs, path)
|
||||
|
||||
if replies.bottom.len > 0:
|
||||
renderMore(Query(), replies.bottom, focus="#r")
|
||||
if hasReplies and replies.bottom.len > 0:
|
||||
if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies:
|
||||
let extra = if sort == Relevance: "" else: "sort=" & $sort & "&"
|
||||
renderMore(Query(), replies.bottom, focus="#r", extra=extra)
|
||||
|
||||
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode =
|
||||
proc renderRelated(replies: Result[Chain]; prefs: Prefs; path: string): VNode =
|
||||
buildHtml(tdiv(class="related-tweets")):
|
||||
tdiv(class="related-header"):
|
||||
text "Related tweets"
|
||||
for thread in replies.content:
|
||||
if thread.content.len == 0 or not thread.related: continue
|
||||
renderReplyThread(thread, prefs, path)
|
||||
|
||||
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string;
|
||||
sort = Relevance): VNode =
|
||||
let hasAfter = conv.after.content.len > 0
|
||||
let threadId = conv.tweet.threadId
|
||||
buildHtml(tdiv(class="conversation")):
|
||||
|
|
@ -70,6 +101,25 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
|
|||
if not conv.replies.beginning:
|
||||
renderNewer(Query(), getLink(conv.tweet), focus="#r")
|
||||
if conv.replies.content.len > 0 or conv.replies.bottom.len > 0:
|
||||
renderReplies(conv.replies, prefs, path)
|
||||
renderReplySort(sort)
|
||||
renderReplies(conv.replies, prefs, path, conv.tweet, sort)
|
||||
|
||||
if not prefs.hideRelated:
|
||||
if conv.replies.content.anyIt(it.related and it.content.len > 0):
|
||||
renderRelated(conv.replies, prefs, path)
|
||||
|
||||
renderToTop(focus="#m")
|
||||
|
||||
proc renderEditHistory*(edits: EditHistory; prefs: Prefs; path: string): VNode =
|
||||
buildHtml(tdiv(class="edit-history")):
|
||||
tdiv(class="latest-edit"):
|
||||
tdiv(class="edit-history-header"):
|
||||
text "Latest post"
|
||||
renderTweet(edits.latest, prefs, path)
|
||||
|
||||
tdiv(class="previous-edits"):
|
||||
tdiv(class="edit-history-header"):
|
||||
text "Version history"
|
||||
for tweet in edits.history:
|
||||
tdiv(class="tweet-edit"):
|
||||
renderTweet(tweet, prefs, path)
|
||||
|
|
|
|||
|
|
@ -5,12 +5,38 @@ import karax/[karaxdsl, vdom]
|
|||
import ".."/[types, query, formatters]
|
||||
import tweet, renderutils
|
||||
|
||||
proc timelineViewClass(query: Query): string =
|
||||
if query.kind != QueryKind.media:
|
||||
return "timeline"
|
||||
|
||||
case query.view
|
||||
of "grid": "timeline media-grid-view"
|
||||
of "gallery": "timeline media-gallery-view"
|
||||
else: "timeline"
|
||||
|
||||
proc getQuery(query: Query): string =
|
||||
if query.kind != posts:
|
||||
result = genQueryUrl(query)
|
||||
if result.len > 0:
|
||||
result &= "&"
|
||||
|
||||
proc getSearchMaxId(results: Timeline; path: string): string =
|
||||
if results.query.kind != tweets or results.content.len == 0 or
|
||||
results.query.until.len == 0:
|
||||
return
|
||||
|
||||
let lastThread = results.content[^1]
|
||||
if lastThread.len == 0 or lastThread[^1].id == 0:
|
||||
return
|
||||
|
||||
# 2000000 is the minimum decrement to guarantee no result overlap
|
||||
var maxId = lastThread[^1].id - 2_000_000'i64
|
||||
if maxId <= 0:
|
||||
maxId = lastThread[^1].id - 1
|
||||
|
||||
if maxId > 0:
|
||||
return "maxid:" & $maxId
|
||||
|
||||
proc renderToTop*(focus="#"): VNode =
|
||||
buildHtml(tdiv(class="top-ref")):
|
||||
icon "down", href=focus
|
||||
|
|
@ -24,9 +50,9 @@ proc renderNewer*(query: Query; path: string; focus=""): VNode =
|
|||
a(href=(p & url)):
|
||||
text "Load newest"
|
||||
|
||||
proc renderMore*(query: Query; cursor: string; focus=""): VNode =
|
||||
proc renderMore*(query: Query; cursor: string; focus=""; extra=""): VNode =
|
||||
buildHtml(tdiv(class="show-more")):
|
||||
a(href=(&"?{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")):
|
||||
a(href=(&"?{extra}{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")):
|
||||
text "Load more"
|
||||
|
||||
proc renderNoMore(): VNode =
|
||||
|
|
@ -39,7 +65,7 @@ proc renderNoneFound(): VNode =
|
|||
h2(class="timeline-none"):
|
||||
text "No items found"
|
||||
|
||||
proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
|
||||
proc renderThread(thread: Tweets; prefs: Prefs; path: string; bigThumb=false): VNode =
|
||||
buildHtml(tdiv(class="thread-line")):
|
||||
let sortedThread = thread.sortedByIt(it.id)
|
||||
for i, tweet in sortedThread:
|
||||
|
|
@ -53,10 +79,10 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
|
|||
let show = i == thread.high and sortedThread[0].id != tweet.threadId
|
||||
let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: ""
|
||||
renderTweet(tweet, prefs, path, class=(header & "thread"),
|
||||
index=i, last=(i == thread.high), showThread=show)
|
||||
index=i, last=(i == thread.high), bigThumb=bigThumb)
|
||||
|
||||
proc renderUser(user: User; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv(class="timeline-item")):
|
||||
buildHtml(tdiv(class="timeline-item", data-username=user.username)):
|
||||
a(class="tweet-link", href=("/" & user.username))
|
||||
tdiv(class="tweet-body profile-result"):
|
||||
tdiv(class="tweet-header"):
|
||||
|
|
@ -66,6 +92,7 @@ proc renderUser(user: User; prefs: Prefs): VNode =
|
|||
tdiv(class="tweet-name-row"):
|
||||
tdiv(class="fullname-and-username"):
|
||||
linkUser(user, class="fullname")
|
||||
verifiedIcon(user)
|
||||
linkUser(user, class="username")
|
||||
|
||||
tdiv(class="tweet-content media-body", dir="auto"):
|
||||
|
|
@ -87,15 +114,106 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode =
|
|||
else:
|
||||
renderNoMore()
|
||||
|
||||
proc mentionUsername(word: string): string =
|
||||
# "@user" -> "user" for well-formed mentions, "" otherwise
|
||||
if word.len > 1 and word[0] == '@' and
|
||||
word[1 .. ^1].allCharsInSet({'A'..'Z', 'a'..'z', '0'..'9', '_'}):
|
||||
word[1 .. ^1]
|
||||
else: ""
|
||||
|
||||
proc mentionedUser(s: string): string =
|
||||
# last @mention in strings like "65 followers including @user"
|
||||
let words = s.split(' ')
|
||||
for i in countdown(words.high, 0):
|
||||
result = mentionUsername(words[i])
|
||||
if result.len > 0: return
|
||||
|
||||
proc renderMentionedText(s: string): VNode =
|
||||
# linkify @mentions in plain API strings like "65 followers including @user"
|
||||
let words = s.split(' ')
|
||||
buildHtml(span):
|
||||
for i in 0 ..< words.len:
|
||||
if i > 0: text " "
|
||||
let username = mentionUsername(words[i])
|
||||
if username.len > 0:
|
||||
a(href=("/" & username)): text words[i]
|
||||
else:
|
||||
text words[i]
|
||||
|
||||
proc renderListCard(r: ListSearchResult): VNode =
|
||||
let listUrl = "/i/lists/" & r.list.id
|
||||
buildHtml(tdiv(class="timeline-item list-result")):
|
||||
a(class="tweet-link", href=listUrl)
|
||||
a(class="list-result-banner", href=listUrl):
|
||||
if r.list.banner.len > 0:
|
||||
genImg(r.list.banner)
|
||||
tdiv(class="list-result-body"):
|
||||
tdiv(class="list-result-title fullname-and-username"):
|
||||
a(class="list-name fullname", href=listUrl): text r.list.name
|
||||
span(class="list-members"):
|
||||
text &"· {insertSep($r.list.members, ',')} members"
|
||||
tdiv(class="list-result-context"):
|
||||
if r.followersContext.len > 0:
|
||||
# the first facepile belongs to the "including @user" account
|
||||
let mentioned = mentionedUser(r.followersContext)
|
||||
for i in 0 ..< r.facepiles.len:
|
||||
if i == 0 and mentioned.len > 0:
|
||||
a(class="facepile-link", href=("/" & mentioned)):
|
||||
genImg(r.facepiles[i], class="list-facepile")
|
||||
else:
|
||||
genImg(r.facepiles[i], class="list-facepile")
|
||||
renderMentionedText(r.followersContext)
|
||||
else:
|
||||
if r.owner.username.len > 0:
|
||||
a(class="facepile-link", href=("/" & r.owner.username)):
|
||||
genImg(r.owner.getUserPic("_mini"), class="list-facepile")
|
||||
else:
|
||||
genImg(r.owner.getUserPic("_mini"), class="list-facepile")
|
||||
linkUser(r.owner, class="fullname")
|
||||
linkUser(r.owner, class="username")
|
||||
if r.list.description.len > 0:
|
||||
tdiv(class="list-result-description"):
|
||||
text r.list.description
|
||||
|
||||
proc renderTimelineLists*(results: Result[ListSearchResult]; prefs: Prefs;
|
||||
path=""): VNode =
|
||||
buildHtml(tdiv(class="timeline")):
|
||||
if not results.beginning:
|
||||
renderNewer(results.query, path)
|
||||
|
||||
if results.content.len > 0:
|
||||
for list in results.content:
|
||||
renderListCard(list)
|
||||
if results.bottom.len > 0:
|
||||
renderMore(results.query, results.bottom)
|
||||
renderToTop()
|
||||
elif results.beginning:
|
||||
renderNoneFound()
|
||||
else:
|
||||
renderNoMore()
|
||||
|
||||
proc filterThreads(threads: seq[Tweets]; prefs: Prefs): seq[Tweets] =
|
||||
var retweets: seq[int64]
|
||||
for thread in threads:
|
||||
if thread.len == 1:
|
||||
let tweet = thread[0]
|
||||
let retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
|
||||
if retweetId in retweets or tweet.id in retweets or
|
||||
tweet.pinned and prefs.hidePins:
|
||||
continue
|
||||
if retweetId != 0 and tweet.retweet.isSome:
|
||||
retweets &= retweetId
|
||||
result.add(thread)
|
||||
|
||||
proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
|
||||
pinned=none(Tweet)): VNode =
|
||||
buildHtml(tdiv(class="timeline")):
|
||||
buildHtml(tdiv(class=results.query.timelineViewClass)):
|
||||
if not results.beginning:
|
||||
renderNewer(results.query, parseUri(path).path)
|
||||
|
||||
if not prefs.hidePins and pinned.isSome:
|
||||
let tweet = get pinned
|
||||
renderTweet(tweet, prefs, path, showThread=tweet.hasThread)
|
||||
renderTweet(tweet, prefs, path)
|
||||
|
||||
if results.content.len == 0:
|
||||
if not results.beginning:
|
||||
|
|
@ -103,26 +221,24 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
|
|||
else:
|
||||
renderNoneFound()
|
||||
else:
|
||||
var retweets: seq[int64]
|
||||
let filtered = filterThreads(results.content, prefs)
|
||||
|
||||
for thread in results.content:
|
||||
if thread.len == 1:
|
||||
let
|
||||
tweet = thread[0]
|
||||
retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
|
||||
if results.query.view == "gallery":
|
||||
let bigThumb = prefs.gallerySize == "Large"
|
||||
let galClass = if prefs.compactGallery: "gallery-masonry compact" else: "gallery-masonry"
|
||||
tdiv(class=galClass, `data-col-size`=prefs.gallerySize.toLowerAscii):
|
||||
for thread in filtered:
|
||||
if thread.len == 1: renderTweet(thread[0], prefs, path, bigThumb=bigThumb)
|
||||
else: renderThread(thread, prefs, path, bigThumb)
|
||||
else:
|
||||
for thread in filtered:
|
||||
if thread.len == 1:
|
||||
renderTweet(thread[0], prefs, path)
|
||||
else: renderThread(thread, prefs, path)
|
||||
|
||||
if retweetId in retweets or tweet.id in retweets or
|
||||
tweet.pinned and prefs.hidePins:
|
||||
continue
|
||||
|
||||
var hasThread = tweet.hasThread
|
||||
if retweetId != 0 and tweet.retweet.isSome:
|
||||
retweets &= retweetId
|
||||
hasThread = get(tweet.retweet).hasThread
|
||||
renderTweet(tweet, prefs, path, showThread=hasThread)
|
||||
else:
|
||||
renderThread(thread, prefs, path)
|
||||
|
||||
if results.bottom.len > 0:
|
||||
var cursor = getSearchMaxId(results, path)
|
||||
if cursor.len > 0:
|
||||
renderMore(results.query, cursor)
|
||||
elif results.bottom.len > 0:
|
||||
renderMore(results.query, results.bottom)
|
||||
renderToTop()
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue