mirror of
https://github.com/zedeus/nitter.git
synced 2026-05-02 02:22:09 -04:00
Compare commits
8 Commits
8114eefa19
...
feature/mp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
401aa26464 | ||
|
|
93208908e6 | ||
|
|
6e490c2dd9 | ||
|
|
00daab1f15 | ||
|
|
608c3ca8df | ||
|
|
0610f7b890 | ||
|
|
1dab9c9c61 | ||
|
|
651941acd1 |
23
.github/workflows/build-docker.yml
vendored
23
.github/workflows/build-docker.yml
vendored
@@ -10,20 +10,20 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
uses: ./.github/workflows/run-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
build-docker-amd64:
|
||||
needs: [tests]
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
version: latest
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -35,19 +35,20 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }}
|
||||
|
||||
build-docker-arm64:
|
||||
needs: [tests]
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
version: latest
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
148
.github/workflows/run-tests.yml
vendored
148
.github/workflows/run-tests.yml
vendored
@@ -8,138 +8,38 @@ on:
|
||||
- master
|
||||
workflow_call:
|
||||
|
||||
# Ensure that multiple runs on the same branch do not overlap.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
build-test:
|
||||
name: Build and test
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
nim: ["2.0.x", "2.2.x", "devel"]
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Cache Nimble Dependencies
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Cache nimble
|
||||
id: cache-nimble
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.nimble
|
||||
key: ${{ matrix.nim }}-nimble-v2-${{ hashFiles('*.nimble') }}
|
||||
restore-keys: |
|
||||
${{ matrix.nim }}-nimble-v2-
|
||||
|
||||
- name: Setup Nim
|
||||
uses: jiro4989/setup-nim-action@v2
|
||||
key: nimble-${{ hashFiles('*.nimble') }}
|
||||
restore-keys: "nimble-"
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
nim-version: ${{ matrix.nim }}
|
||||
use-nightlies: true
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Project
|
||||
run: nimble build -Y
|
||||
|
||||
- name: Upload 2.2.x build artifact
|
||||
if: matrix.nim == '2.2.x'
|
||||
uses: actions/upload-artifact@v6
|
||||
python-version: "3.10"
|
||||
cache: "pip"
|
||||
- uses: jiro4989/setup-nim-action@v1
|
||||
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: ubuntu-24.04
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
steps:
|
||||
- name: Install runtime deps
|
||||
run: |
|
||||
sudo apt-get install -y --no-install-recommends libsass-dev libpcre3
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Cache pipx (poetry)
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.local/pipx
|
||||
~/.local/bin
|
||||
key: pipx-poetry-${{ runner.os }}
|
||||
|
||||
- name: Install poetry
|
||||
env:
|
||||
PIPX_HOME: ~/.local/pipx
|
||||
PIPX_BIN_DIR: ~/.local/bin
|
||||
run: command -v poetry >/dev/null 2>&1 || pipx install poetry
|
||||
|
||||
- name: Setup Python (3.14) with Poetry cache
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
cache: poetry
|
||||
cache-dependency-path: tests/poetry.lock
|
||||
|
||||
- name: Install Python deps
|
||||
working-directory: tests
|
||||
run: poetry sync
|
||||
|
||||
- name: Cache Nimble Dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.nimble
|
||||
key: 2.2.x-nimble-v2-${{ hashFiles('*.nimble') }}
|
||||
restore-keys: |
|
||||
2.2.x-nimble-v2-
|
||||
|
||||
- name: Setup Nim
|
||||
uses: jiro4989/setup-nim-action@v2
|
||||
with:
|
||||
nim-version: 2.2.x
|
||||
use-nightlies: true
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download 2.2.x build artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: nitter-linux-nim-2.2.x-${{ github.sha }}
|
||||
path: .
|
||||
|
||||
- name: Make nitter binary executable
|
||||
run: chmod +x ./nitter
|
||||
|
||||
- name: Prepare Nitter Environment
|
||||
nim-version: "1.x"
|
||||
- run: nimble build -d:release -Y
|
||||
- run: pip install seleniumbase
|
||||
- run: seleniumbase install chromedriver
|
||||
- uses: supercharge/redis-github-action@1.5.0
|
||||
- name: Prepare Nitter
|
||||
run: |
|
||||
sudo apt install libsass-dev -y
|
||||
cp nitter.example.conf nitter.conf
|
||||
sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf
|
||||
sed -i 's/maxRetries = 1/maxRetries = 10/g' nitter.conf
|
||||
|
||||
# Run both Nimble tasks concurrently
|
||||
nim r tools/rendermd.nim &
|
||||
nim r tools/gencss.nim &
|
||||
wait
|
||||
|
||||
echo '${{ secrets.SESSIONS }}' | head -n1
|
||||
echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl
|
||||
|
||||
- name: Run Tests
|
||||
nimble md
|
||||
nimble scss
|
||||
- name: Run tests
|
||||
run: |
|
||||
./nitter &
|
||||
cd tests
|
||||
poetry run pytest -n3 --reruns=5 --rs .
|
||||
pytest -n4 tests
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -10,8 +10,4 @@ nitter
|
||||
/public/css/style.css
|
||||
/public/md/*.html
|
||||
nitter.conf
|
||||
guest_accounts.json*
|
||||
sessions.json*
|
||||
dump.rdb
|
||||
*.bak
|
||||
/tools/*.json*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM nimlang/nim:2.2.0-alpine-regular as nim
|
||||
FROM nimlang/nim:1.6.10-alpine-regular as nim
|
||||
LABEL maintainer="setenforce@protonmail.com"
|
||||
|
||||
RUN apk --no-cache add libsass-dev pcre
|
||||
@@ -9,7 +9,7 @@ COPY nitter.nimble .
|
||||
RUN nimble install -y --depsOnly
|
||||
|
||||
COPY . .
|
||||
RUN nimble build -d:danger -d:lto -d:strip --mm:refc \
|
||||
RUN nimble build -d:danger -d:lto -d:strip \
|
||||
&& nimble scss \
|
||||
&& nimble md
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM alpine:3.20.6 as nim
|
||||
FROM alpine:3.17 as nim
|
||||
LABEL maintainer="setenforce@protonmail.com"
|
||||
|
||||
RUN apk --no-cache add libsass-dev pcre gcc git libc-dev nim nimble
|
||||
RUN apk --no-cache add gcc git libc-dev libsass-dev "nim=1.6.8-r0" nimble pcre
|
||||
|
||||
WORKDIR /src/nitter
|
||||
|
||||
@@ -9,17 +9,15 @@ COPY nitter.nimble .
|
||||
RUN nimble install -y --depsOnly
|
||||
|
||||
COPY . .
|
||||
RUN nimble build -d:danger -d:lto -d:strip --mm:refc \
|
||||
RUN nimble build -d:danger -d:lto -d:strip \
|
||||
&& nimble scss \
|
||||
&& nimble md
|
||||
|
||||
FROM alpine:3.20.6
|
||||
FROM alpine:3.17
|
||||
WORKDIR /src/
|
||||
RUN apk --no-cache add pcre ca-certificates openssl
|
||||
RUN apk --no-cache add ca-certificates pcre openssl1.1-compat
|
||||
COPY --from=nim /src/nitter/nitter ./
|
||||
COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf
|
||||
COPY --from=nim /src/nitter/public ./public
|
||||
EXPOSE 8080
|
||||
RUN adduser -h /src/ -D -s /bin/sh nitter
|
||||
USER nitter
|
||||
CMD ./nitter
|
||||
|
||||
57
README.md
57
README.md
@@ -4,35 +4,27 @@
|
||||
[](https://github.com/zedeus/nitter/actions/workflows/build-docker.yml)
|
||||
[](#license)
|
||||
|
||||
> [!NOTE]
|
||||
> Running a Nitter instance now requires real accounts, since Twitter removed the previous methods. \
|
||||
> This does not affect users. \
|
||||
> For instructions on how to obtain session tokens, see [Creating session tokens](https://github.com/zedeus/nitter/wiki/Creating-session-tokens).
|
||||
|
||||
A free and open source alternative Twitter front-end focused on privacy and
|
||||
performance. \
|
||||
Inspired by the [Invidious](https://github.com/iv-org/invidious) project.
|
||||
Inspired by the [Invidious](https://github.com/iv-org/invidious)
|
||||
project.
|
||||
|
||||
- No JavaScript or ads
|
||||
- All requests go through the backend, client never talks to Twitter
|
||||
- Prevents Twitter from tracking your IP or JavaScript fingerprint
|
||||
- Uses Twitter's unofficial API (no developer account required)
|
||||
- Uses Twitter's unofficial API (no rate limits or developer account required)
|
||||
- Lightweight (for [@nim_lang](https://nitter.net/nim_lang), 60KB vs 784KB from twitter.com)
|
||||
- RSS feeds
|
||||
- Themes
|
||||
- Mobile support (responsive design)
|
||||
- AGPLv3 licensed, no proprietary instances permitted
|
||||
|
||||
<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>
|
||||
Liberapay: https://liberapay.com/zedeus \
|
||||
Patreon: https://patreon.com/nitter \
|
||||
BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \
|
||||
ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \
|
||||
LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \
|
||||
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -50,13 +42,12 @@ maintained by the community.
|
||||
|
||||
## Why?
|
||||
|
||||
It's impossible to use Twitter without JavaScript enabled, and as of 2024 you
|
||||
need to sign up. For privacy-minded folks, preventing JavaScript analytics and
|
||||
IP-based tracking is important, but apart from using a VPN and uBlock/uMatrix,
|
||||
it's impossible. Despite being behind a VPN and using heavy-duty adblockers,
|
||||
you can get accurately tracked with your [browser's
|
||||
fingerprint](https://restoreprivacy.com/browser-fingerprinting/), [no
|
||||
JavaScript required](https://noscriptfingerprint.com/). This all became
|
||||
It's impossible to use Twitter without JavaScript enabled. For privacy-minded
|
||||
folks, preventing JavaScript analytics and IP-based tracking is important, but
|
||||
apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind
|
||||
a VPN and using heavy-duty adblockers, you can get accurately tracked with your
|
||||
[browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/),
|
||||
[no JavaScript required](https://noscriptfingerprint.com/). This all became
|
||||
particularly important after Twitter [removed the
|
||||
ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws)
|
||||
for users to control whether their data gets sent to advertisers.
|
||||
@@ -80,21 +71,19 @@ Twitter account.
|
||||
|
||||
- libpcre
|
||||
- libsass
|
||||
- redis/valkey
|
||||
- redis
|
||||
|
||||
To compile Nitter you need a Nim installation, see
|
||||
[nim-lang.org](https://nim-lang.org/install.html) for details. It is possible
|
||||
to install it system-wide or in the user directory you create below.
|
||||
[nim-lang.org](https://nim-lang.org/install.html) for details. It is possible to
|
||||
install it system-wide or in the user directory you create below.
|
||||
|
||||
To compile the scss files, you need to install `libsass`. On Ubuntu and Debian,
|
||||
you can use `libsass-dev`.
|
||||
|
||||
Redis is required for caching and in the future for account info. As of 2024
|
||||
Redis is no longer open source, so using the fork Valkey is recommended. It
|
||||
should be available on most distros as `redis` or `redis-server`
|
||||
(Ubuntu/Debian), or `valkey`/`valkey-server`. Running it with the default
|
||||
config is fine, Nitter's default config is set to use the default port and
|
||||
localhost.
|
||||
Redis is required for caching and in the future for account info. It should be
|
||||
available on most distros as `redis` or `redis-server` (Ubuntu/Debian).
|
||||
Running it with the default config is fine, Nitter's default config is set to
|
||||
use the default Redis port and localhost.
|
||||
|
||||
Here's how to create a `nitter` user, clone the repo, and build the project
|
||||
along with the scss and md files.
|
||||
@@ -104,7 +93,7 @@ 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 build -d:release
|
||||
$ nimble scss
|
||||
$ nimble md
|
||||
$ cp nitter.example.conf nitter.conf
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
|
||||
# disable annoying warnings
|
||||
warning("GcUnsafe2", off)
|
||||
warning("HoleEnumConv", off)
|
||||
hint("XDeclaredButNotUsed", off)
|
||||
hint("XCannotRaiseY", off)
|
||||
hint("User", off)
|
||||
|
||||
const
|
||||
nimVersion = (major: NimMajor, minor: NimMinor, patch: NimPatch)
|
||||
|
||||
when nimVersion >= (1, 6, 0):
|
||||
warning("HoleEnumConv", off)
|
||||
|
||||
@@ -9,7 +9,6 @@ services:
|
||||
- "127.0.0.1:8080:8080" # Replace with "8080:8080" if you don't use a reverse proxy
|
||||
volumes:
|
||||
- ./nitter.conf:/src/nitter.conf:Z,ro
|
||||
- ./sessions.jsonl:/src/sessions.jsonl:Z,ro # Run get_sessions.py to get the credentials
|
||||
depends_on:
|
||||
- nitter-redis
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,41 +1,37 @@
|
||||
[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 # master switch, set to false to disable all RSS feeds
|
||||
enableRSSUserTweets = true # /@user/rss
|
||||
enableRSSUserReplies = true # /@user/with_replies/rss
|
||||
enableRSSUserMedia = true # /@user/media/rss
|
||||
enableRSSSearch = true # /search/rss and /@user/search/rss
|
||||
enableRSSList = true # list RSS feeds
|
||||
enableDebug = false # enable request logs and debug endpoints (/.sessions)
|
||||
proxy = "" # http/https url, SOCKS proxies are not supported
|
||||
hmacKey = "secretkey" # random key for cryptographic signing of video urls
|
||||
base64Media = false # use base64 encoding for proxied media urls
|
||||
enableRSS = true # set this to false to disable RSS feeds
|
||||
enableDebug = false # enable request logs and debug endpoints (/.tokens)
|
||||
proxy = "" # http/https url, SOCKS proxies are not supported
|
||||
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
|
||||
tokenCount = 10
|
||||
# minimum amount of usable tokens. tokens are used to authorize API requests,
|
||||
# but they expire after ~1 hour, and have a limit of 500 requests per endpoint.
|
||||
# the limits reset every 15 minutes, and the pool is filled up so there's
|
||||
# always at least `tokenCount` usable tokens. only increase this if you receive
|
||||
# major bursts all the time and don't have a rate limiting setup via e.g. nginx
|
||||
|
||||
# Change default preferences here, see src/prefs_impl.nim for a complete list
|
||||
[Preferences]
|
||||
|
||||
@@ -10,11 +10,11 @@ bin = @["nitter"]
|
||||
|
||||
# Dependencies
|
||||
|
||||
requires "nim >= 2.0.0"
|
||||
requires "nim >= 1.4.8"
|
||||
requires "jester#baca3f"
|
||||
requires "karax#5cf360c"
|
||||
requires "sass#7dfdd03"
|
||||
requires "nimcrypto#a079df9"
|
||||
requires "nimcrypto#4014ef9"
|
||||
requires "markdown#158efe3"
|
||||
requires "packedjson#9e6fbb6"
|
||||
requires "supersnappy#6c94198"
|
||||
@@ -22,13 +22,13 @@ requires "redpool#8b7c1db"
|
||||
requires "https://github.com/zedeus/redis#d0a0e6f"
|
||||
requires "zippy#ca5989a"
|
||||
requires "flatty#e668085"
|
||||
requires "jsony#1de1f08"
|
||||
requires "oauth#b8c163b"
|
||||
requires "jsony#ea811be"
|
||||
|
||||
|
||||
# Tasks
|
||||
|
||||
task scss, "Generate css":
|
||||
exec "nim r --hint[Processing]:off tools/gencss"
|
||||
exec "nimble c --hint[Processing]:off -d:danger -r tools/gencss"
|
||||
|
||||
task md, "Render md":
|
||||
exec "nim r --hint[Processing]:off tools/rendermd"
|
||||
exec "nimble c --hint[Processing]:off -d:danger -r tools/rendermd"
|
||||
|
||||
155
public/css/fontello.css
vendored
155
public/css/fontello.css
vendored
@@ -1,148 +1,53 @@
|
||||
@font-face {
|
||||
font-family: "fontello";
|
||||
src: url("/fonts/fontello.eot?49059696");
|
||||
src:
|
||||
url("/fonts/fontello.eot?49059696#iefix") format("embedded-opentype"),
|
||||
url("/fonts/fontello.woff2?49059696") format("woff2"),
|
||||
url("/fonts/fontello.woff?49059696") format("woff"),
|
||||
url("/fonts/fontello.ttf?49059696") format("truetype"),
|
||||
url("/fonts/fontello.svg?49059696#fontello") format("svg");
|
||||
font-family: 'fontello';
|
||||
src: url('/fonts/fontello.eot?21002321');
|
||||
src: url('/fonts/fontello.eot?21002321#iefix') format('embedded-opentype'),
|
||||
url('/fonts/fontello.woff2?21002321') format('woff2'),
|
||||
url('/fonts/fontello.woff?21002321') format('woff'),
|
||||
url('/fonts/fontello.ttf?21002321') format('truetype'),
|
||||
url('/fonts/fontello.svg?21002321#fontello') format('svg');
|
||||
font-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-group:before {
|
||||
content: "\e804";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-play:before {
|
||||
content: "\e805";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-link:before {
|
||||
content: "\e806";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-calendar:before {
|
||||
content: "\e807";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-location:before {
|
||||
content: "\e808";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-picture:before {
|
||||
content: "\e809";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-lock:before {
|
||||
content: "\e80a";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-down:before {
|
||||
content: "\e80b";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-retweet:before {
|
||||
content: "\e80c";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-search:before {
|
||||
content: "\e80d";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-pin:before {
|
||||
content: "\e80e";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-cog:before {
|
||||
content: "\e80f";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-rss:before {
|
||||
content: "\e810";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-ok:before {
|
||||
content: "\e811";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-attention:before {
|
||||
content: "\e812";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-circle:before {
|
||||
content: "\f111";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-info:before {
|
||||
content: "\f128";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
.icon-bird:before {
|
||||
content: "\f309";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
|
||||
.icon-heart:before { content: '\2665'; } /* '♥' */
|
||||
.icon-quote:before { content: '\275e'; } /* '❞' */
|
||||
.icon-comment:before { content: '\e802'; } /* '' */
|
||||
.icon-ok:before { content: '\e803'; } /* '' */
|
||||
.icon-play:before { content: '\e804'; } /* '' */
|
||||
.icon-link:before { content: '\e805'; } /* '' */
|
||||
.icon-calendar:before { content: '\e806'; } /* '' */
|
||||
.icon-location:before { content: '\e807'; } /* '' */
|
||||
.icon-picture:before { content: '\e809'; } /* '' */
|
||||
.icon-lock:before { content: '\e80a'; } /* '' */
|
||||
.icon-down:before { content: '\e80b'; } /* '' */
|
||||
.icon-retweet:before { content: '\e80d'; } /* '' */
|
||||
.icon-search:before { content: '\e80e'; } /* '' */
|
||||
.icon-pin:before { content: '\e80f'; } /* '' */
|
||||
.icon-cog:before { content: '\e812'; } /* '' */
|
||||
.icon-rss-feed:before { content: '\e813'; } /* '' */
|
||||
.icon-info:before { content: '\f128'; } /* '' */
|
||||
.icon-bird:before { content: '\f309'; } /* '' */
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
Font license info
|
||||
|
||||
|
||||
## Modern Pictograms
|
||||
|
||||
Copyright (c) 2012 by John Caserta. All rights reserved.
|
||||
|
||||
Author: John Caserta
|
||||
License: SIL (http://scripts.sil.org/OFL)
|
||||
Homepage: http://thedesignoffice.org/project/modern-pictograms/
|
||||
|
||||
|
||||
## Entypo
|
||||
|
||||
Copyright (C) 2012 by Daniel Bruce
|
||||
@@ -46,3 +37,12 @@ Font license info
|
||||
Homepage: http://aristeides.com/
|
||||
|
||||
|
||||
## Modern Pictograms
|
||||
|
||||
Copyright (c) 2012 by John Caserta. All rights reserved.
|
||||
|
||||
Author: John Caserta
|
||||
License: SIL (http://scripts.sil.org/OFL)
|
||||
Homepage: http://thedesignoffice.org/project/modern-pictograms/
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,28 +1,26 @@
|
||||
<?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) 2026 by original authors @ fontello.com</metadata>
|
||||
<metadata>Copyright (C) 2020 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="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" />
|
||||
|
||||
<glyph glyph-name="heart" unicode="" d="M790 644q70-64 70-156t-70-158l-360-330-360 330q-70 66-70 158t70 156q62 58 151 58t153-58l56-52 58 52q62 58 150 58t152-58z" horiz-adv-x="860" />
|
||||
<glyph glyph-name="quote" unicode="❞" d="M18 685l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z m558 0l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z" horiz-adv-x="928" />
|
||||
|
||||
<glyph glyph-name="quote" unicode="" d="M18 685l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z m558 0l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z" horiz-adv-x="928" />
|
||||
<glyph glyph-name="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="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="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="play" unicode="" d="M772 333l-741-412q-13-7-22-2t-9 20v822q0 14 9 20t22-2l741-412q13-7 13-17t-13-17z" horiz-adv-x="785.7" />
|
||||
|
||||
<glyph glyph-name="play" unicode="" d="M772 333l-741-412q-13-7-22-2t-9 20v822q0 14 9 20t22-2l741-412q13-7 13-17t-13-17z" horiz-adv-x="785.7" />
|
||||
<glyph glyph-name="link" unicode="" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" />
|
||||
|
||||
<glyph glyph-name="link" unicode="" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" />
|
||||
<glyph glyph-name="calendar" unicode="" d="M800 700q42 0 71-29t29-71l0-600q0-40-29-70t-71-30l-700 0q-40 0-70 30t-30 70l0 600q0 42 30 71t70 29l46 0 0-100 160 0 0 100 290 0 0-100 160 0 0 100 44 0z m0-700l0 400-700 0 0-400 700 0z m-540 800l0-170-70 0 0 170 70 0z m450 0l0-170-70 0 0 170 70 0z" horiz-adv-x="900" />
|
||||
|
||||
<glyph glyph-name="calendar" unicode="" d="M800 700q42 0 71-29t29-71l0-600q0-40-29-70t-71-30l-700 0q-40 0-70 30t-30 70l0 600q0 42 30 71t70 29l46 0 0-100 160 0 0 100 290 0 0-100 160 0 0 100 44 0z m0-700l0 400-700 0 0-400 700 0z m-540 800l0-170-70 0 0 170 70 0z m450 0l0-170-70 0 0 170 70 0z" horiz-adv-x="900" />
|
||||
|
||||
<glyph glyph-name="location" unicode="" d="M250 750q104 0 177-73t73-177q0-106-62-243t-126-223l-62-84q-10 12-27 35t-60 89-76 130-60 147-27 149q0 104 73 177t177 73z m0-388q56 0 96 40t40 96-40 95-96 39-95-39-39-95 39-96 95-40z" horiz-adv-x="500" />
|
||||
<glyph glyph-name="location" unicode="" d="M250 750q104 0 177-73t73-177q0-106-62-243t-126-223l-62-84q-10 12-27 35t-60 89-76 130-60 147-27 149q0 104 73 177t177 73z m0-388q56 0 96 40t40 96-40 95-96 39-95-39-39-95 39-96 95-40z" horiz-adv-x="500" />
|
||||
|
||||
<glyph glyph-name="picture" unicode="" d="M357 529q0-45-31-76t-76-32-76 32-31 76 31 76 76 31 76-31 31-76z m572-215v-250h-786v107l178 179 90-89 285 285z m53 393h-893q-7 0-12-5t-6-13v-678q0-7 6-13t12-5h893q7 0 13 5t5 13v678q0 8-5 13t-13 5z m89-18v-678q0-37-26-63t-63-27h-893q-36 0-63 27t-26 63v678q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" />
|
||||
|
||||
@@ -30,25 +28,19 @@
|
||||
|
||||
<glyph glyph-name="down" unicode="" d="M939 399l-414-413q-10-11-25-11t-25 11l-414 413q-11 11-11 26t11 25l93 92q10 11 25 11t25-11l296-296 296 296q11 11 25 11t26-11l92-92q11-11 11-25t-11-26z" horiz-adv-x="1000" />
|
||||
|
||||
<glyph glyph-name="retweet" unicode="" d="M714 11q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" />
|
||||
<glyph glyph-name="retweet" unicode="" d="M714 11q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" />
|
||||
|
||||
<glyph glyph-name="search" unicode="" d="M772 78q30-34 6-62l-46-46q-36-32-68 0l-190 190q-74-42-156-42-128 0-223 95t-95 223 90 219 218 91 224-95 96-223q0-88-46-162z m-678 358q0-88 68-156t156-68 151 63 63 153q0 88-68 155t-156 67-151-63-63-151z" horiz-adv-x="789" />
|
||||
<glyph glyph-name="search" unicode="" d="M772 78q30-34 6-62l-46-46q-36-32-68 0l-190 190q-74-42-156-42-128 0-223 95t-95 223 90 219 218 91 224-95 96-223q0-88-46-162z m-678 358q0-88 68-156t156-68 151 63 63 153q0 88-68 155t-156 67-151-63-63-151z" horiz-adv-x="789" />
|
||||
|
||||
<glyph glyph-name="pin" unicode="" d="M268 368v250q0 8-5 13t-13 5-13-5-5-13v-250q0-8 5-13t13-5 13 5 5 13z m375-197q0-14-11-25t-25-10h-239l-29-270q-1-7-6-11t-11-5h-1q-15 0-17 15l-43 271h-225q-15 0-25 10t-11 25q0 69 44 124t99 55v286q-29 0-50 21t-22 50 22 50 50 22h357q29 0 50-22t21-50-21-50-50-21v-286q55 0 99-55t44-124z" horiz-adv-x="642.9" />
|
||||
<glyph glyph-name="pin" unicode="" d="M268 368v250q0 8-5 13t-13 5-13-5-5-13v-250q0-8 5-13t13-5 13 5 5 13z m375-197q0-14-11-25t-25-10h-239l-29-270q-1-7-6-11t-11-5h-1q-15 0-17 15l-43 271h-225q-15 0-25 10t-11 25q0 69 44 124t99 55v286q-29 0-50 21t-22 50 22 50 50 22h357q29 0 50-22t21-50-21-50-50-21v-286q55 0 99-55t44-124z" horiz-adv-x="642.9" />
|
||||
|
||||
<glyph glyph-name="cog" unicode="" d="M911 295l-133-56q-8-22-12-31l55-133-79-79-135 53q-9-4-31-12l-55-134-112 0-56 133q-11 4-33 13l-132-55-78 79 53 134q-1 3-4 9t-6 12-4 11l-131 55 0 112 131 56 14 33-54 132 78 79 133-54q22 9 33 13l55 132 112 0 56-132q14-5 31-13l133 55 80-79-54-135q6-12 12-30l133-56 0-112z m-447-111q69 0 118 48t49 118-49 119-118 50-119-50-49-119 49-118 119-48z" horiz-adv-x="928" />
|
||||
<glyph glyph-name="cog" unicode="" d="M911 295l-133-56q-8-22-12-31l55-133-79-79-135 53q-9-4-31-12l-55-134-112 0-56 133q-11 4-33 13l-132-55-78 79 53 134q-1 3-4 9t-6 12-4 11l-131 55 0 112 131 56 14 33-54 132 78 79 133-54q22 9 33 13l55 132 112 0 56-132q14-5 31-13l133 55 80-79-54-135q6-12 12-30l133-56 0-112z m-447-111q69 0 118 48t49 118-49 119-118 50-119-50-49-119 49-118 119-48z" horiz-adv-x="928" />
|
||||
|
||||
<glyph glyph-name="rss" unicode="" d="M184 93c0-51-43-91-93-91s-91 40-91 91c0 50 41 91 91 91s93-41 93-91z m261-85l-125 0c0 174-140 323-315 323l0 118c231 0 440-163 440-441z m259 0l-136 0c0 300-262 561-563 561l0 129c370 0 699-281 699-690z" horiz-adv-x="704" />
|
||||
|
||||
<glyph glyph-name="ok" unicode="" d="M933 534q0-22-16-38l-404-404-76-76q-16-15-38-15t-38 15l-76 76-202 202q-15 16-15 38t15 38l76 76q16 16 38 16t38-16l164-165 366 367q16 16 38 16t38-16l76-76q16-15 16-38z" horiz-adv-x="1000" />
|
||||
|
||||
<glyph glyph-name="attention-circled" unicode="" d="M429 779q116 0 215-58t156-156 57-215-57-215-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58z m71-696v106q0 8-5 13t-12 5h-107q-8 0-13-5t-6-13v-106q0-8 6-13t13-6h107q7 0 12 6t5 13z m-1 192l10 346q0 7-6 10-5 5-13 5h-123q-8 0-13-5-6-3-6-10l10-346q0-6 5-10t14-4h103q8 0 13 4t6 10z" horiz-adv-x="857.1" />
|
||||
|
||||
<glyph glyph-name="circle" unicode="" d="M857 350q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" />
|
||||
<glyph glyph-name="rss-feed" unicode="" d="M184 93c0-51-43-91-93-91s-91 40-91 91c0 50 41 91 91 91s93-41 93-91z m261-85l-125 0c0 174-140 323-315 323l0 118c231 0 440-163 440-441z m259 0l-136 0c0 300-262 561-563 561l0 129c370 0 699-281 699-690z" horiz-adv-x="704" />
|
||||
|
||||
<glyph glyph-name="info" unicode="" d="M393 149v-134q0-9-7-15t-15-7h-134q-9 0-16 7t-7 15v134q0 9 7 16t16 6h134q9 0 15-6t7-16z m176 335q0-30-8-56t-20-43-31-33-32-25-34-19q-23-13-38-37t-15-37q0-10-7-18t-16-9h-134q-8 0-14 11t-6 20v26q0 46 37 87t79 60q33 16 47 32t14 42q0 24-26 41t-60 18q-36 0-60-16-20-14-60-64-7-9-17-9-7 0-14 4l-91 70q-8 6-9 14t3 16q89 148 259 148 45 0 90-17t81-46 59-72 23-88z" horiz-adv-x="571.4" />
|
||||
|
||||
<glyph glyph-name="bird" unicode="" d="M920 636q-36-54-94-98l0-24q0-130-60-250t-186-203-290-83q-160 0-290 84 14-2 46-2 132 0 234 80-62 2-110 38t-66 94q10-4 34-4 26 0 50 6-66 14-108 66t-42 120l0 2q36-20 84-24-84 58-84 158 0 48 26 94 154-188 390-196-6 18-6 42 0 78 55 133t135 55q82 0 136-58 60 12 120 44-20-66-82-104 56 8 108 30z" horiz-adv-x="920" />
|
||||
</font>
|
||||
</defs>
|
||||
</svg>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 5.9 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
5
public/js/hls.light.min.js
vendored
Normal file
5
public/js/hls.light.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
5
public/js/hls.min.js
vendored
5
public/js/hls.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -3,7 +3,6 @@
|
||||
function playVideo(overlay) {
|
||||
const video = overlay.parentElement.querySelector('video');
|
||||
const url = video.getAttribute("data-url");
|
||||
const startTime = parseFloat(video.getAttribute("data-start") || "0");
|
||||
video.setAttribute("controls", "");
|
||||
overlay.style.display = "none";
|
||||
|
||||
@@ -13,13 +12,12 @@ function playVideo(overlay) {
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, function () {
|
||||
hls.loadLevel = hls.levels.length - 1;
|
||||
hls.startLoad(startTime);
|
||||
hls.startLoad();
|
||||
video.play();
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = url;
|
||||
video.addEventListener('canplay', function() {
|
||||
if (startTime > 0) video.currentTime = startTime;
|
||||
video.play();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,225 +1,66 @@
|
||||
// @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 getHrefs(selector) {
|
||||
return new Set([...document.querySelectorAll(selector)].map(el => el.getAttribute("href")));
|
||||
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 getTweetId(item) {
|
||||
const m = item.querySelector(".tweet-link")?.getAttribute("href")?.match(/\/status\/(\d+)/);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
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 isDuplicate(item, hrefs) {
|
||||
return hrefs.has(item.querySelector(".tweet-link")?.getAttribute("href"));
|
||||
}
|
||||
var html = document.querySelector("html");
|
||||
var container = document.querySelector(containerClass);
|
||||
var loading = false;
|
||||
|
||||
const GAP = 10;
|
||||
window.addEventListener('scroll', function() {
|
||||
if (loading) return;
|
||||
if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) {
|
||||
loading = true;
|
||||
var loadMore = getLoadMore(document);
|
||||
if (loadMore == null) return;
|
||||
|
||||
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) {
|
||||
return response.text();
|
||||
}).then(function (html) {
|
||||
var parser = new DOMParser();
|
||||
var doc = parser.parseFromString(html, 'text/html');
|
||||
loadMore.remove();
|
||||
|
||||
this._rebuild();
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
// Reveal all items and gallery siblings (show-more, top-ref). Idempotent.
|
||||
_revealAll() {
|
||||
clearTimeout(this._revealTimer);
|
||||
for (const item of this._items) item.classList.add("masonry-visible");
|
||||
for (const el of this.container.parentElement.querySelectorAll(":scope > .show-more, :scope > .top-ref, :scope > .timeline-footer"))
|
||||
el.classList.add("masonry-visible");
|
||||
}
|
||||
|
||||
// Height-primary, count-as-tiebreaker: handles both tall tweets and unloaded images.
|
||||
_pickCol() {
|
||||
return this.colHeights.reduce((min, h, i) => {
|
||||
const m = this.colHeights[min];
|
||||
return (h < m || (h === m && this.colCounts[i] < this.colCounts[min])) ? i : min;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Position items using current column state. Updates colHeights, colCounts, container height.
|
||||
_position(items, heights, colWidth) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const col = this._pickCol();
|
||||
items[i].style.left = `${col * (colWidth + GAP)}px`;
|
||||
items[i].style.top = `${this.colHeights[col]}px`;
|
||||
this.colHeights[col] += heights[i] + GAP;
|
||||
this.colCounts[col]++;
|
||||
}
|
||||
this.container.style.height = `${Math.max(0, ...this.colHeights)}px`;
|
||||
}
|
||||
|
||||
// Full reset and re-place all items.
|
||||
_place(items, heights, n, colWidth) {
|
||||
this.colHeights = new Array(n).fill(0);
|
||||
this.colCounts = new Array(n).fill(0);
|
||||
this.colCount = n;
|
||||
this._position(items, heights, colWidth);
|
||||
}
|
||||
|
||||
_rebuild() {
|
||||
const w = this.container.clientWidth;
|
||||
const n = Math.max(1, Math.floor(w / this._targetWidth(w)));
|
||||
if (n === this.colCount && w === this._lastWidth) return;
|
||||
|
||||
const isFirst = this.colCount === 0;
|
||||
|
||||
if (isFirst) {
|
||||
this._items = [...this.container.querySelectorAll(".timeline-item")];
|
||||
}
|
||||
|
||||
// Sort newest-first by tweet ID (snowflake IDs exceed Number precision, compare as strings).
|
||||
this._items.sort((a, b) => {
|
||||
const idA = getTweetId(a), idB = getTweetId(b);
|
||||
if (idA.length !== idB.length) return idB.length - idA.length;
|
||||
return idB < idA ? -1 : idB > idA ? 1 : 0;
|
||||
});
|
||||
|
||||
// Pre-set widths BEFORE reading heights so measurements reflect the new column width.
|
||||
const colWidth = this._colWidthCache = Math.floor((w - GAP * (n - 1)) / n);
|
||||
for (const item of this._items) item.style.width = `${colWidth}px`;
|
||||
|
||||
this._place(this._items, this._items.map(item => item.offsetHeight), n, colWidth);
|
||||
this._lastWidth = w;
|
||||
|
||||
if (isFirst) {
|
||||
if (this._observer) this._items.forEach(item => this._observer.observe(item));
|
||||
// Reveal immediately if all images are cached, else wait for syncHeights.
|
||||
const hasUnloaded = this._items.some(item =>
|
||||
[...item.querySelectorAll("img")].some(img => !img.complete));
|
||||
if (hasUnloaded) {
|
||||
this._revealTimer = setTimeout(() => this._revealAll(), 1000);
|
||||
} else {
|
||||
this._revealAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read actual heights and re-place all items. Fixes drift after images load.
|
||||
syncHeights() {
|
||||
this._place(this._items, this._items.map(item => item.offsetHeight), this.colCount, this._colWidthCache);
|
||||
this._revealAll();
|
||||
}
|
||||
|
||||
// Batch-add items in three phases to avoid O(N) reflows:
|
||||
// 1. writes: set widths, append all — no reads, no reflows
|
||||
// 2. one read: batch offsetHeight
|
||||
// 3. writes: assign columns, set left/top
|
||||
addAll(newItems) {
|
||||
if (!newItems.length) return;
|
||||
const colWidth = this._colWidthCache;
|
||||
|
||||
for (const item of newItems) {
|
||||
item.style.width = `${colWidth}px`;
|
||||
this.container.appendChild(item);
|
||||
}
|
||||
|
||||
this._position(newItems, newItems.map(item => item.offsetHeight), colWidth);
|
||||
this._items.push(...newItems);
|
||||
|
||||
if (this._observer) newItems.forEach(item => this._observer.observe(item));
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const isTweet = location.pathname.includes("/status/");
|
||||
const containerClass = isTweet ? ".replies" : ".timeline";
|
||||
const itemClass = containerClass + " > div:not(.top-ref)";
|
||||
const html = document.documentElement;
|
||||
const container = document.querySelector(containerClass);
|
||||
const masonryEl = container?.querySelector(".gallery-masonry");
|
||||
const masonry = masonryEl ? new Masonry(masonryEl) : null;
|
||||
let loading = false;
|
||||
|
||||
function handleScroll(failed) {
|
||||
if (loading || html.scrollTop + html.clientHeight < html.scrollHeight - 3000) return;
|
||||
|
||||
const loadMore = getLoadMore(document);
|
||||
if (!loadMore) return;
|
||||
loading = true;
|
||||
loadMore.children[0].text = "Loading...";
|
||||
|
||||
const url = new URL(loadMore.children[0].href);
|
||||
url.searchParams.append("scroll", "true");
|
||||
|
||||
fetch(url)
|
||||
.then(r => {
|
||||
if (r.status > 299) throw new Error("error");
|
||||
return r.text();
|
||||
})
|
||||
.then(responseText => {
|
||||
const doc = new DOMParser().parseFromString(responseText, "text/html");
|
||||
loadMore.remove();
|
||||
|
||||
if (masonry) {
|
||||
masonry.syncHeights();
|
||||
const newMasonry = doc.querySelector(".gallery-masonry");
|
||||
if (newMasonry) {
|
||||
const knownHrefs = getHrefs(".gallery-masonry .tweet-link");
|
||||
masonry.addAll([...newMasonry.querySelectorAll(".timeline-item")].filter(item => !isDuplicate(item, knownHrefs)));
|
||||
}
|
||||
} else {
|
||||
const knownHrefs = getHrefs(`${itemClass} .tweet-link`);
|
||||
for (const item of doc.querySelectorAll(itemClass)) {
|
||||
if (item.className === "timeline-item show-more" || isDuplicate(item, knownHrefs)) continue;
|
||||
isTweet ? container.appendChild(item) : insertBeforeLast(container, item);
|
||||
}
|
||||
loading = false;
|
||||
const newLoadMore = getLoadMore(doc);
|
||||
if (newLoadMore == null) return;
|
||||
if (isTweet) container.appendChild(newLoadMore);
|
||||
else insertBeforeLast(container, newLoadMore);
|
||||
}).catch(function (err) {
|
||||
console.warn('Something went wrong.', err);
|
||||
loading = true;
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -4,15 +4,15 @@ Nitter is a free and open source alternative Twitter front-end focused on
|
||||
privacy and performance. The source is available on GitHub at
|
||||
<https://github.com/zedeus/nitter>
|
||||
|
||||
- No JavaScript or ads
|
||||
- All requests go through the backend, client never talks to Twitter
|
||||
- Prevents Twitter from tracking your IP or JavaScript fingerprint
|
||||
- Uses Twitter's unofficial API (no developer account required)
|
||||
- Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com)
|
||||
- RSS feeds
|
||||
- Themes
|
||||
- Mobile support (responsive design)
|
||||
- AGPLv3 licensed, no proprietary instances permitted
|
||||
* No JavaScript or ads
|
||||
* All requests go through the backend, client never talks to Twitter
|
||||
* Prevents Twitter from tracking your IP or JavaScript fingerprint
|
||||
* Uses Twitter's unofficial API (no rate limits or developer account required)
|
||||
* Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com)
|
||||
* RSS feeds
|
||||
* Themes
|
||||
* Mobile support (responsive design)
|
||||
* AGPLv3 licensed, no proprietary instances permitted
|
||||
|
||||
Nitter's GitHub wiki contains
|
||||
[instances](https://github.com/zedeus/nitter/wiki/Instances) and
|
||||
@@ -21,13 +21,12 @@ maintained by the community.
|
||||
|
||||
## Why use Nitter?
|
||||
|
||||
It's impossible to use Twitter without JavaScript enabled, and as of 2024 you
|
||||
need to sign up. For privacy-minded folks, preventing JavaScript analytics and
|
||||
IP-based tracking is important, but apart from using a VPN and uBlock/uMatrix,
|
||||
it's impossible. Despite being behind a VPN and using heavy-duty adblockers,
|
||||
you can get accurately tracked with your [browser's
|
||||
fingerprint](https://restoreprivacy.com/browser-fingerprinting/), [no
|
||||
JavaScript required](https://noscriptfingerprint.com/). This all became
|
||||
It's impossible to use Twitter without JavaScript enabled. For privacy-minded
|
||||
folks, preventing JavaScript analytics and IP-based tracking is important, but
|
||||
apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind
|
||||
a VPN and using heavy-duty adblockers, you can get accurately tracked with your
|
||||
[browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/),
|
||||
[no JavaScript required](https://noscriptfingerprint.com/). This all became
|
||||
particularly important after Twitter [removed the
|
||||
ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws)
|
||||
for users to control whether their data gets sent to advertisers.
|
||||
@@ -43,13 +42,12 @@ Twitter account.
|
||||
|
||||
## Donating
|
||||
|
||||
Liberapay: https://liberapay.com/zedeus \
|
||||
Patreon: https://patreon.com/nitter \
|
||||
BTC: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55 \
|
||||
ETH: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460 \
|
||||
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL \
|
||||
SOL: ANsyGNXFo6osuFwr1YnUqif2RdoYRhc27WdyQNmmETSW \
|
||||
ZEC: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt
|
||||
Liberapay: <https://liberapay.com/zedeus> \
|
||||
Patreon: <https://patreon.com/nitter> \
|
||||
BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \
|
||||
ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \
|
||||
LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \
|
||||
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
|
||||
|
||||
## Contact
|
||||
|
||||
|
||||
215
src/api.nim
215
src/api.nim
@@ -1,128 +1,58 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import asyncdispatch, httpclient, strutils, sequtils, sugar
|
||||
import asyncdispatch, httpclient, uri, strutils, sequtils, sugar
|
||||
import packedjson
|
||||
import types, query, formatters, consts, apiutils, parser, utils
|
||||
import types, query, formatters, consts, apiutils, parser
|
||||
import experimental/parser as newParser
|
||||
|
||||
# Helper to generate params object for GraphQL requests
|
||||
proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
|
||||
result.add ("variables", variables)
|
||||
result.add ("features", gqlFeatures)
|
||||
if fieldToggles.len > 0:
|
||||
result.add ("fieldToggles", fieldToggles)
|
||||
|
||||
proc apiUrl(endpoint, variables: string; fieldToggles = ""): ApiUrl =
|
||||
return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles))
|
||||
|
||||
proc apiReq(endpoint, variables: string; fieldToggles = ""): ApiReq =
|
||||
let url = apiUrl(endpoint, variables, fieldToggles)
|
||||
return ApiReq(cookie: url, oauth: url)
|
||||
|
||||
proc mediaUrl(id, cursor: string; count=20): ApiReq =
|
||||
result = ApiReq(
|
||||
cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor, $count]),
|
||||
oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor, $count])
|
||||
)
|
||||
|
||||
proc userTweetsUrl(id: string; cursor: string): ApiReq =
|
||||
result = ApiReq(
|
||||
# cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles),
|
||||
oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor, "20"])
|
||||
)
|
||||
# might change this in the future pending testing
|
||||
result.cookie = result.oauth
|
||||
|
||||
proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
|
||||
let cookieVars = userTweetsAndRepliesVars % [id, cursor]
|
||||
result = ApiReq(
|
||||
cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles),
|
||||
oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"])
|
||||
)
|
||||
|
||||
proc tweetDetailUrl(id: string; cursor: string): ApiReq =
|
||||
let cookieVars = tweetDetailVars % [id, cursor]
|
||||
result = ApiReq(
|
||||
# cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles),
|
||||
cookie: apiUrl(graphTweet, tweetVars % [id, cursor]),
|
||||
oauth: apiUrl(graphTweet, tweetVars % [id, cursor])
|
||||
)
|
||||
|
||||
proc userUrl(username: string): ApiReq =
|
||||
let cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username
|
||||
result = ApiReq(
|
||||
cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles),
|
||||
oauth: apiUrl(graphUserV2, """{"screen_name": "$1"}""" % username)
|
||||
)
|
||||
|
||||
proc getGraphUser*(username: string): Future[User] {.async.} =
|
||||
if username.len == 0: return
|
||||
let js = await fetchRaw(userUrl(username))
|
||||
let
|
||||
variables = %*{"screen_name": username}
|
||||
params = {"variables": $variables, "features": gqlFeatures}
|
||||
js = await fetchRaw(graphUser ? params, Api.userScreenName)
|
||||
result = parseGraphUser(js)
|
||||
|
||||
proc getGraphUserById*(id: string): Future[User] {.async.} =
|
||||
if id.len == 0 or id.any(c => not c.isDigit): return
|
||||
let
|
||||
url = apiReq(graphUserById, """{"rest_id": "$1"}""" % id)
|
||||
js = await fetchRaw(url)
|
||||
variables = %*{"userId": id}
|
||||
params = {"variables": $variables, "features": gqlFeatures}
|
||||
js = await fetchRaw(graphUserById ? params, Api.userRestId)
|
||||
result = parseGraphUser(js)
|
||||
|
||||
proc getAboutAccount*(username: string): Future[AccountInfo] {.async.} =
|
||||
if username.len == 0: return
|
||||
let
|
||||
url = apiReq(graphAboutAccount, """{"screenName":"$1"}""" % username)
|
||||
js = await fetch(url)
|
||||
result = parseAboutAccount(js)
|
||||
|
||||
proc restReq(endpoint: string; params: seq[(string, string)] = @[]): ApiReq =
|
||||
let url = ApiUrl(endpoint: endpoint, params: params)
|
||||
ApiReq(cookie: url, oauth: url)
|
||||
|
||||
proc getBroadcastInfo*(id: string): Future[Broadcast] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
req = apiReq(graphBroadcast, """{"id":"$1"}""" % id)
|
||||
js = await fetch(req)
|
||||
result = parseBroadcastInfo(js)
|
||||
|
||||
proc fetchBroadcastStream*(mediaKey: string): Future[string] {.async.} =
|
||||
if mediaKey.len == 0: return
|
||||
let
|
||||
streamReq = restReq(restLiveStream & mediaKey)
|
||||
streamJs = await fetch(streamReq)
|
||||
result = streamJs{"source", "noRedirectPlaybackUrl"}.getStr(
|
||||
streamJs{"source", "location"}.getStr)
|
||||
|
||||
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
|
||||
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Timeline] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
url = case kind
|
||||
of TimelineKind.tweets: userTweetsUrl(id, cursor)
|
||||
of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
|
||||
of TimelineKind.media: mediaUrl(id, cursor, 100)
|
||||
js = await fetch(url)
|
||||
result = parseGraphTimeline(js, after)
|
||||
variables = userTweetsVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
(url, apiId) = case kind
|
||||
of TimelineKind.tweets: (graphUserTweets, Api.userTweets)
|
||||
of TimelineKind.replies: (graphUserTweetsAndReplies, Api.userTweetsAndReplies)
|
||||
of TimelineKind.media: (graphUserMedia, Api.userMedia)
|
||||
js = await fetch(url ? params, apiId)
|
||||
result = parseGraphTimeline(js, "user", after)
|
||||
|
||||
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
url = apiReq(graphListTweets, restIdVars % [id, cursor, "20"])
|
||||
js = await fetch(url)
|
||||
result = parseGraphTimeline(js, after).tweets
|
||||
variables = listTweetsVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphListTweets ? params, Api.listTweets)
|
||||
result = parseGraphTimeline(js, "list", after)
|
||||
|
||||
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
|
||||
let
|
||||
variables = %*{"screenName": name, "listSlug": list}
|
||||
url = apiReq(graphListBySlug, $variables)
|
||||
js = await fetch(url)
|
||||
result = parseGraphList(js)
|
||||
params = {"variables": $variables, "features": gqlFeatures}
|
||||
result = parseGraphList(await fetch(graphListBySlug ? params, Api.listBySlug))
|
||||
|
||||
proc getGraphList*(id: string): Future[List] {.async.} =
|
||||
let
|
||||
url = apiReq(graphListById, """{"listId": "$1"}""" % id)
|
||||
js = await fetch(url)
|
||||
result = parseGraphList(js)
|
||||
let
|
||||
variables = %*{"listId": id}
|
||||
params = {"variables": $variables, "features": gqlFeatures}
|
||||
result = parseGraphList(await fetch(graphListById ? params, Api.list))
|
||||
|
||||
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
|
||||
if list.id.len == 0: return
|
||||
@@ -136,23 +66,24 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
|
||||
}
|
||||
if after.len > 0:
|
||||
variables["cursor"] = % after
|
||||
let
|
||||
url = apiReq(graphListMembers, $variables)
|
||||
js = await fetchRaw(url)
|
||||
result = parseGraphListMembers(js, after)
|
||||
let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures}
|
||||
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after)
|
||||
|
||||
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
url = apiReq(graphTweetResult, """{"rest_id": "$1"}""" % id)
|
||||
js = await fetch(url)
|
||||
variables = tweetResultVariables % id
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphTweetResult ? params, Api.tweetResult)
|
||||
result = parseGraphTweetResult(js)
|
||||
|
||||
proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
js = await fetch(tweetDetailUrl(id, cursor))
|
||||
variables = tweetVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphTweet ? params, Api.tweetDetail)
|
||||
result = parseGraphConversation(js, id)
|
||||
|
||||
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
|
||||
@@ -164,75 +95,51 @@ proc getTweet*(id: string; after=""): Future[Conversation] {.async.} =
|
||||
if after.len > 0:
|
||||
result.replies = await getReplies(id, after)
|
||||
|
||||
proc getGraphEditHistory*(id: string): Future[EditHistory] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
url = apiReq(graphTweetEditHistory, tweetEditHistoryVars % id)
|
||||
js = await fetch(url)
|
||||
result = parseGraphEditHistory(js, id)
|
||||
|
||||
proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
|
||||
# workaround for #1372
|
||||
let maxId =
|
||||
if not after.startsWith("maxid:"): ""
|
||||
else: validateNumber(after[6..^1])
|
||||
|
||||
let q = genQueryParam(query, maxId)
|
||||
proc getGraphSearch*(query: Query; after=""): Future[Result[Tweet]] {.async.} =
|
||||
let q = genQueryParam(query)
|
||||
if q.len == 0 or q == emptyQuery:
|
||||
return Timeline(query: query, beginning: true)
|
||||
return Result[Tweet](query: query, beginning: true)
|
||||
|
||||
var
|
||||
variables = %*{
|
||||
"rawQuery": q,
|
||||
"query_source": "typedQuery",
|
||||
"count": 20,
|
||||
"product": "Latest",
|
||||
"withDownvotePerspective": false,
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false
|
||||
}
|
||||
if after.len > 0 and maxId.len == 0:
|
||||
if after.len > 0:
|
||||
variables["cursor"] = % after
|
||||
let
|
||||
url = apiReq(graphSearchTimeline, $variables)
|
||||
js = await fetch(url)
|
||||
result = parseGraphSearch[Tweets](js, after)
|
||||
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
|
||||
result = parseGraphSearch(await fetch(url, Api.search), after)
|
||||
result.query = query
|
||||
|
||||
# when no more items are available the API just returns the last page in
|
||||
# full. this detects that and clears the page instead.
|
||||
if after.len > 0 and result.bottom.len > 0 and maxId.len == 0 and
|
||||
after[0..<64] == result.bottom[0..<64]:
|
||||
result.content.setLen(0)
|
||||
|
||||
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
|
||||
proc getUserSearch*(query: Query; page="1"): Future[Result[User]] {.async.} =
|
||||
if query.text.len == 0:
|
||||
return Result[User](query: query, beginning: true)
|
||||
|
||||
var
|
||||
variables = %*{
|
||||
"rawQuery": query.text,
|
||||
"query_source": "typedQuery",
|
||||
"count": 20,
|
||||
"product": "People",
|
||||
"withDownvotePerspective": false,
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false
|
||||
}
|
||||
if after.len > 0:
|
||||
variables["cursor"] = % after
|
||||
result.beginning = false
|
||||
var url = userSearch ? {
|
||||
"q": query.text,
|
||||
"skip_status": "1",
|
||||
"count": "20",
|
||||
"page": page
|
||||
}
|
||||
|
||||
let
|
||||
url = apiReq(graphSearchTimeline, $variables)
|
||||
js = await fetch(url)
|
||||
result = parseGraphSearch[User](js, after)
|
||||
result = parseUsers(await fetchRaw(url, Api.userSearch))
|
||||
result.query = query
|
||||
if page.len == 0:
|
||||
result.bottom = "2"
|
||||
elif page.allCharsInSet(Digits):
|
||||
result.bottom = $(parseInt(page) + 1)
|
||||
|
||||
proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
|
||||
if id.len == 0: return
|
||||
let js = await fetch(mediaUrl(id, "", 30))
|
||||
result = parseGraphPhotoRail(js)
|
||||
proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} =
|
||||
if name.len == 0: return
|
||||
let
|
||||
ps = genParams({"screen_name": name, "trim_user": "true"},
|
||||
count="18", ext=false)
|
||||
url = photoRail ? ps
|
||||
result = parsePhotoRail(await fetch(url, Api.timeline))
|
||||
|
||||
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
|
||||
let client = newAsyncHttpClient(maxRedirects=0)
|
||||
|
||||
248
src/apiutils.nim
248
src/apiutils.nim
@@ -1,126 +1,68 @@
|
||||
# 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, tid
|
||||
import httpclient, asyncdispatch, options, strutils, uri
|
||||
import jsony, packedjson, zippy
|
||||
import types, tokens, consts, parserutils, http_pool
|
||||
import experimental/types/common
|
||||
|
||||
const
|
||||
rlRemaining = "x-rate-limit-remaining"
|
||||
rlReset = "x-rate-limit-reset"
|
||||
rlLimit = "x-rate-limit-limit"
|
||||
errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest}
|
||||
|
||||
var
|
||||
pool: HttpPool
|
||||
disableTid: bool
|
||||
apiProxy: string
|
||||
maxRetries: int
|
||||
retryDelayMs: int
|
||||
var pool: HttpPool
|
||||
|
||||
proc setDisableTid*(disable: bool) =
|
||||
disableTid = disable
|
||||
proc genParams*(pars: openArray[(string, string)] = @[]; cursor="";
|
||||
count="20"; ext=true): seq[(string, string)] =
|
||||
result = timelineParams
|
||||
for p in pars:
|
||||
result &= p
|
||||
if ext:
|
||||
result &= ("ext", "mediaStats")
|
||||
result &= ("include_ext_alt_text", "1")
|
||||
result &= ("include_ext_media_availability", "1")
|
||||
if count.len > 0:
|
||||
result &= ("count", count)
|
||||
if cursor.len > 0:
|
||||
# The raw cursor often has plus signs, which sometimes get turned into spaces,
|
||||
# so we need to turn them back into a plus
|
||||
if " " in cursor:
|
||||
result &= ("cursor", cursor.replace(" ", "+"))
|
||||
else:
|
||||
result &= ("cursor", cursor)
|
||||
|
||||
proc setMaxRetries*(n: int) =
|
||||
maxRetries = n
|
||||
|
||||
proc setRetryDelayMs*(ms: int) =
|
||||
retryDelayMs = ms
|
||||
|
||||
proc setApiProxy*(url: string) =
|
||||
apiProxy = ""
|
||||
if url.len > 0:
|
||||
apiProxy = url.strip(chars={'/'}) & "/"
|
||||
if "http" notin apiProxy:
|
||||
apiProxy = "http://" & apiProxy
|
||||
|
||||
proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri =
|
||||
let url = case sessionKind
|
||||
of oauth: req.oauth
|
||||
of cookie: req.cookie
|
||||
let base = case sessionKind
|
||||
of oauth: "https://api.x.com"
|
||||
of cookie: "https://x.com/i/api"
|
||||
let prefix = if url.endpoint.startsWith("1.1/"): "" else: "graphql/"
|
||||
parseUri(base) / (prefix & url.endpoint) ? url.params
|
||||
|
||||
proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
|
||||
let
|
||||
encodedUrl = url.replace(",", "%2C").replace("+", "%20")
|
||||
params = OAuth1Parameters(
|
||||
consumerKey: consumerKey,
|
||||
signatureMethod: "HMAC-SHA1",
|
||||
timestamp: $int(round(epochTime())),
|
||||
nonce: "0",
|
||||
isIncludeVersionToHeader: true,
|
||||
token: oauthToken
|
||||
)
|
||||
signature = getSignature(HttpGet, encodedUrl, "", params, consumerSecret, oauthTokenSecret)
|
||||
|
||||
params.signature = percentEncode(signature)
|
||||
|
||||
return getOauth1RequestHeader(params)["authorization"]
|
||||
|
||||
proc getCookieHeader(authToken, ct0: string): string =
|
||||
"auth_token=" & authToken & "; ct0=" & ct0
|
||||
|
||||
proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} =
|
||||
proc genHeaders*(token: Token = nil): HttpHeaders =
|
||||
result = newHttpHeaders({
|
||||
"accept": "*/*",
|
||||
"connection": "keep-alive",
|
||||
"authorization": auth,
|
||||
"content-type": "application/json",
|
||||
"x-guest-token": if token == nil: "" else: token.tok,
|
||||
"x-twitter-active-user": "yes",
|
||||
"authority": "api.twitter.com",
|
||||
"accept-encoding": "gzip",
|
||||
"accept-language": "en-US,en;q=0.9",
|
||||
"connection": "keep-alive",
|
||||
"content-type": "application/json",
|
||||
"origin": "https://x.com",
|
||||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
|
||||
"x-twitter-active-user": "yes",
|
||||
"x-twitter-client-language": "en",
|
||||
"priority": "u=1, i"
|
||||
"accept": "*/*",
|
||||
"DNT": "1"
|
||||
})
|
||||
|
||||
case session.kind
|
||||
of SessionKind.oauth:
|
||||
result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret)
|
||||
of SessionKind.cookie:
|
||||
result["x-twitter-auth-type"] = "OAuth2Session"
|
||||
result["x-csrf-token"] = session.ct0
|
||||
result["cookie"] = getCookieHeader(session.authToken, session.ct0)
|
||||
result["sec-ch-ua"] = """"Google Chrome";v="142", "Chromium";v="142", "Not A(Brand";v="24""""
|
||||
result["sec-ch-ua-mobile"] = "?0"
|
||||
result["sec-ch-ua-platform"] = "Windows"
|
||||
result["sec-fetch-dest"] = "empty"
|
||||
result["sec-fetch-mode"] = "cors"
|
||||
result["sec-fetch-site"] = "same-site"
|
||||
if disableTid or "/1.1/" in url.path:
|
||||
result["authorization"] = bearerToken2
|
||||
else:
|
||||
result["authorization"] = bearerToken
|
||||
result["x-client-transaction-id"] = await genTid(url.path)
|
||||
|
||||
proc getAndValidateSession*(req: ApiReq): Future[Session] {.async.} =
|
||||
result = await getSession(req)
|
||||
case result.kind
|
||||
of SessionKind.oauth:
|
||||
if result.oauthToken.len == 0:
|
||||
echo "[sessions] Empty oauth token, session: ", result.pretty
|
||||
raise rateLimitError()
|
||||
of SessionKind.cookie:
|
||||
if result.authToken.len == 0 or result.ct0.len == 0:
|
||||
echo "[sessions] Empty cookie credentials, session: ", result.pretty
|
||||
raise rateLimitError()
|
||||
template updateToken() =
|
||||
if resp.headers.hasKey(rlRemaining):
|
||||
let
|
||||
remaining = parseInt(resp.headers[rlRemaining])
|
||||
reset = parseInt(resp.headers[rlReset])
|
||||
token.setRateLimit(api, remaining, reset)
|
||||
|
||||
template fetchImpl(result, fetchBody) {.dirty.} =
|
||||
once:
|
||||
pool = HttpPool()
|
||||
|
||||
var token = await getToken(api)
|
||||
if token.tok.len == 0:
|
||||
raise rateLimitError()
|
||||
|
||||
try:
|
||||
var resp: AsyncResponse
|
||||
pool.use(await genHeaders(session, url)):
|
||||
pool.use(genHeaders(token)):
|
||||
template getContent =
|
||||
# TODO: this is a temporary simple implementation
|
||||
if apiProxy.len > 0 and "/1.1/" notin url.path:
|
||||
resp = await c.get(($url).replace("https://", apiProxy))
|
||||
else:
|
||||
resp = await c.get($url)
|
||||
resp = await c.get($url)
|
||||
result = await resp.body
|
||||
|
||||
getContent()
|
||||
@@ -129,93 +71,57 @@ template fetchImpl(result, fetchBody) {.dirty.} =
|
||||
badClient = true
|
||||
raise newException(BadClientError, "Bad client")
|
||||
|
||||
if resp.status == $Http404 and result.len == 0:
|
||||
echo "[sessions] transient 404 (empty body), retrying: ", url.path
|
||||
raise rateLimitError()
|
||||
|
||||
if resp.headers.hasKey(rlRemaining):
|
||||
let
|
||||
remaining = parseInt(resp.headers[rlRemaining])
|
||||
reset = parseInt(resp.headers[rlReset])
|
||||
limit = parseInt(resp.headers[rlLimit])
|
||||
session.setRateLimit(req, remaining, reset, limit)
|
||||
|
||||
if result.len > 0:
|
||||
if resp.headers.getOrDefault("content-encoding") == "gzip":
|
||||
result = uncompress(result, dfGzip)
|
||||
|
||||
if result.startsWith("{\"errors"):
|
||||
let errors = result.fromJson(Errors)
|
||||
if errors notin errorsToSkip:
|
||||
echo "Fetch error, API: ", url.path, ", errors: ", errors
|
||||
if errors in {expiredToken, badToken, locked}:
|
||||
invalidate(session)
|
||||
raise rateLimitError()
|
||||
elif errors in {rateLimited}:
|
||||
# rate limit hit, resets after 24 hours
|
||||
setLimited(session, req)
|
||||
raise rateLimitError()
|
||||
elif result.startsWith("429 Too Many Requests"):
|
||||
echo "[sessions] 429 error, API: ", url.path, ", session: ", session.pretty
|
||||
raise rateLimitError()
|
||||
else:
|
||||
echo "non-gzip body, url: ", url, ", body: ", result
|
||||
|
||||
fetchBody
|
||||
|
||||
release(token, used=true)
|
||||
|
||||
if resp.status == $Http400:
|
||||
echo "ERROR 400, ", url.path, ": ", result
|
||||
raise newException(InternalError, $url)
|
||||
except InternalError as e:
|
||||
raise e
|
||||
except BadClientError as e:
|
||||
raise e
|
||||
except OSError as e:
|
||||
release(token, used=true)
|
||||
raise e
|
||||
except Exception as e:
|
||||
let s = session.pretty
|
||||
echo "error: ", e.name, ", msg: ", e.msg, ", session: ", s, ", url: ", url
|
||||
echo "error: ", e.name, ", msg: ", e.msg, ", token: ", token[], ", url: ", url
|
||||
if "length" notin e.msg and "descriptor" notin e.msg:
|
||||
release(token, invalid=true)
|
||||
raise rateLimitError()
|
||||
finally:
|
||||
release(session)
|
||||
|
||||
template retry(bod) =
|
||||
for i in 0 ..< maxRetries:
|
||||
try:
|
||||
bod
|
||||
break
|
||||
except RateLimitError:
|
||||
echo "[sessions] Rate limited, retrying ", req.cookie.endpoint,
|
||||
" request (", i, "/", maxRetries, ")..."
|
||||
if retryDelayMs > 0:
|
||||
await sleepAsync(retryDelayMs)
|
||||
proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} =
|
||||
var body: string
|
||||
fetchImpl body:
|
||||
if body.startsWith('{') or body.startsWith('['):
|
||||
result = parseJson(body)
|
||||
else:
|
||||
echo resp.status, ": ", body, " --- url: ", url
|
||||
result = newJNull()
|
||||
|
||||
proc fetch*(req: ApiReq): Future[JsonNode] {.async.} =
|
||||
retry:
|
||||
var
|
||||
body: string
|
||||
session = await getAndValidateSession(req)
|
||||
updateToken()
|
||||
|
||||
let url = req.toUrl(session.kind)
|
||||
let error = result.getError
|
||||
if error in {invalidToken, badToken}:
|
||||
echo "fetch error: ", result.getError
|
||||
release(token, invalid=true)
|
||||
raise rateLimitError()
|
||||
|
||||
fetchImpl body:
|
||||
if body.startsWith('{') or body.startsWith('['):
|
||||
result = parseJson(body)
|
||||
else:
|
||||
echo resp.status, ": ", body, " --- url: ", url
|
||||
result = newJNull()
|
||||
proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} =
|
||||
fetchImpl result:
|
||||
if not (result.startsWith('{') or result.startsWith('[')):
|
||||
echo resp.status, ": ", result, " --- url: ", url
|
||||
result.setLen(0)
|
||||
|
||||
let error = result.getError
|
||||
if error != null and error notin errorsToSkip:
|
||||
echo "Fetch error, API: ", url.path, ", error: ", error
|
||||
if error in {expiredToken, badToken, locked}:
|
||||
invalidate(session)
|
||||
raise rateLimitError()
|
||||
updateToken()
|
||||
|
||||
proc fetchRaw*(req: ApiReq): Future[string] {.async.} =
|
||||
retry:
|
||||
var 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
|
||||
result.setLen(0)
|
||||
if result.startsWith("{\"errors"):
|
||||
let errors = result.fromJson(Errors)
|
||||
if errors in {invalidToken, badToken}:
|
||||
echo "fetch error: ", errors
|
||||
release(token, invalid=true)
|
||||
raise rateLimitError()
|
||||
|
||||
213
src/auth.nim
213
src/auth.nim
@@ -1,213 +0,0 @@
|
||||
#SPDX-License-Identifier: AGPL-3.0-only
|
||||
import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os]
|
||||
import types, consts
|
||||
import experimental/parser/session
|
||||
|
||||
const hourInSeconds = 60 * 60
|
||||
|
||||
var
|
||||
sessionPool: seq[Session]
|
||||
enableLogging = false
|
||||
# max requests at a time per session to avoid race conditions
|
||||
maxConcurrentReqs = 2
|
||||
|
||||
proc setMaxConcurrentReqs*(reqs: int) =
|
||||
if reqs > 0:
|
||||
maxConcurrentReqs = reqs
|
||||
|
||||
template log(str: varargs[string, `$`]) =
|
||||
echo "[sessions] ", str.join("")
|
||||
|
||||
proc endpoint(req: ApiReq; session: Session): string =
|
||||
case session.kind
|
||||
of oauth: req.oauth.endpoint
|
||||
of cookie: req.cookie.endpoint
|
||||
|
||||
proc pretty*(session: Session): string =
|
||||
if session.isNil:
|
||||
return "<null>"
|
||||
|
||||
if session.id > 0 and session.username.len > 0:
|
||||
result = $session.id & " (" & session.username & ")"
|
||||
elif session.username.len > 0:
|
||||
result = session.username
|
||||
elif session.id > 0:
|
||||
result = $session.id
|
||||
else:
|
||||
result = "<unknown>"
|
||||
result = $session.kind & " " & result
|
||||
|
||||
proc snowflakeToEpoch(flake: int64): int64 =
|
||||
int64(((flake shr 22) + 1288834974657) div 1000)
|
||||
|
||||
proc getSessionPoolHealth*(): JsonNode =
|
||||
let now = epochTime().int
|
||||
|
||||
var
|
||||
totalReqs = 0
|
||||
limited: PackedSet[int64]
|
||||
reqsPerApi: Table[string, int]
|
||||
oldest = now.int64
|
||||
newest = 0'i64
|
||||
average = 0'i64
|
||||
|
||||
for session in sessionPool:
|
||||
let created = snowflakeToEpoch(session.id)
|
||||
if created > newest:
|
||||
newest = created
|
||||
if created < oldest:
|
||||
oldest = created
|
||||
average += created
|
||||
|
||||
if session.limited:
|
||||
limited.incl session.id
|
||||
|
||||
for api in session.apis.keys:
|
||||
let
|
||||
apiStatus = session.apis[api]
|
||||
reqs = apiStatus.limit - apiStatus.remaining
|
||||
|
||||
# no requests made with this session and endpoint since the limit reset
|
||||
if apiStatus.reset < now:
|
||||
continue
|
||||
|
||||
reqsPerApi.mgetOrPut($api, 0).inc reqs
|
||||
totalReqs.inc reqs
|
||||
|
||||
if sessionPool.len > 0:
|
||||
average = average div sessionPool.len
|
||||
else:
|
||||
oldest = 0
|
||||
average = 0
|
||||
|
||||
return %*{
|
||||
"sessions": %*{
|
||||
"total": sessionPool.len,
|
||||
"limited": limited.card,
|
||||
"oldest": $fromUnix(oldest),
|
||||
"newest": $fromUnix(newest),
|
||||
"average": $fromUnix(average)
|
||||
},
|
||||
"requests": %*{
|
||||
"total": totalReqs,
|
||||
"apis": reqsPerApi
|
||||
}
|
||||
}
|
||||
|
||||
proc getSessionPoolDebug*(): JsonNode =
|
||||
let now = epochTime().int
|
||||
var list = newJObject()
|
||||
|
||||
for session in sessionPool:
|
||||
let sessionJson = %*{
|
||||
"apis": newJObject(),
|
||||
"pending": session.pending,
|
||||
}
|
||||
|
||||
if session.limited:
|
||||
sessionJson["limited"] = %true
|
||||
|
||||
for api in session.apis.keys:
|
||||
let
|
||||
apiStatus = session.apis[api]
|
||||
obj = %*{}
|
||||
|
||||
if apiStatus.reset > now.int:
|
||||
obj["remaining"] = %apiStatus.remaining
|
||||
obj["reset"] = %apiStatus.reset
|
||||
|
||||
if "remaining" notin obj:
|
||||
continue
|
||||
|
||||
sessionJson{"apis", $api} = obj
|
||||
list[$session.id] = sessionJson
|
||||
|
||||
return %list
|
||||
|
||||
proc rateLimitError*(): ref RateLimitError =
|
||||
newException(RateLimitError, "rate limited")
|
||||
|
||||
proc noSessionsError*(): ref NoSessionsError =
|
||||
newException(NoSessionsError, "no sessions available")
|
||||
|
||||
proc isLimited(session: Session; req: ApiReq): bool =
|
||||
if session.isNil:
|
||||
return true
|
||||
|
||||
let api = req.endpoint(session)
|
||||
if session.limited and api != graphUserTweetsV2:
|
||||
if (epochTime().int - session.limitedAt) > hourInSeconds:
|
||||
session.limited = false
|
||||
log "resetting limit: ", session.pretty
|
||||
return false
|
||||
else:
|
||||
return true
|
||||
|
||||
if api in session.apis:
|
||||
let limit = session.apis[api]
|
||||
return limit.remaining <= 10 and limit.reset > epochTime().int
|
||||
else:
|
||||
return false
|
||||
|
||||
proc isReady(session: Session; req: ApiReq): bool =
|
||||
not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(req))
|
||||
|
||||
proc invalidate*(session: var Session) =
|
||||
if session.isNil: return
|
||||
log "invalidating: ", session.pretty
|
||||
|
||||
# TODO: This isn't sufficient, but it works for now
|
||||
let idx = sessionPool.find(session)
|
||||
if idx > -1: sessionPool.delete(idx)
|
||||
session = nil
|
||||
|
||||
proc release*(session: Session) =
|
||||
if session.isNil: return
|
||||
dec session.pending
|
||||
|
||||
proc getSession*(req: ApiReq): Future[Session] {.async.} =
|
||||
for i in 0 ..< sessionPool.len:
|
||||
if result.isReady(req): break
|
||||
result = sessionPool.sample()
|
||||
|
||||
if not result.isNil and result.isReady(req):
|
||||
inc result.pending
|
||||
else:
|
||||
log "no sessions available for API: ", req.cookie.endpoint
|
||||
raise noSessionsError()
|
||||
|
||||
proc setLimited*(session: Session; req: ApiReq) =
|
||||
let api = req.endpoint(session)
|
||||
session.limited = true
|
||||
session.limitedAt = epochTime().int
|
||||
log "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", ", session.pretty
|
||||
|
||||
proc setRateLimit*(session: Session; req: ApiReq; remaining, reset, limit: int) =
|
||||
# avoid undefined behavior in race conditions
|
||||
let api = req.endpoint(session)
|
||||
if api in session.apis:
|
||||
let rateLimit = session.apis[api]
|
||||
if rateLimit.reset >= reset and rateLimit.remaining < remaining:
|
||||
return
|
||||
if rateLimit.reset == reset and rateLimit.remaining >= remaining:
|
||||
session.apis[api].remaining = remaining
|
||||
return
|
||||
|
||||
session.apis[api] = RateLimit(limit: limit, remaining: remaining, reset: reset)
|
||||
|
||||
proc initSessionPool*(cfg: Config; path: string) =
|
||||
enableLogging = cfg.enableDebug
|
||||
|
||||
if path.endsWith(".json"):
|
||||
log "ERROR: .json is not supported, the file must be a valid JSONL file ending in .jsonl"
|
||||
quit 1
|
||||
|
||||
if not fileExists(path):
|
||||
log "ERROR: ", path, " not found. This file is required to authenticate API requests."
|
||||
quit 1
|
||||
|
||||
log "parsing JSONL account sessions file: ", path
|
||||
for line in path.lines:
|
||||
sessionPool.add parseSession(line)
|
||||
|
||||
log "successfully added ", sessionPool.len, " valid account sessions"
|
||||
@@ -13,8 +13,6 @@ 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"),
|
||||
@@ -39,19 +37,10 @@ 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),
|
||||
enableRSSUserTweets: masterRss and cfg.get("Config", "enableRSSUserTweets", true),
|
||||
enableRSSUserReplies: masterRss and cfg.get("Config", "enableRSSUserReplies", true),
|
||||
enableRSSUserMedia: masterRss and cfg.get("Config", "enableRSSUserMedia", true),
|
||||
enableRSSSearch: masterRss and cfg.get("Config", "enableRSSSearch", true),
|
||||
enableRSSList: masterRss and cfg.get("Config", "enableRSSList", true),
|
||||
enableRss: cfg.get("Config", "enableRSS", true),
|
||||
enableDebug: cfg.get("Config", "enableDebug", false),
|
||||
proxy: cfg.get("Config", "proxy", ""),
|
||||
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)
|
||||
proxyAuth: cfg.get("Config", "proxyAuth", "")
|
||||
)
|
||||
|
||||
return (conf, cfg)
|
||||
|
||||
218
src/consts.nim
218
src/consts.nim
@@ -1,175 +1,121 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils
|
||||
import uri, sequtils, strutils
|
||||
|
||||
const
|
||||
consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
|
||||
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
|
||||
bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
|
||||
bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F"
|
||||
auth* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
|
||||
|
||||
graphUser* = "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName"
|
||||
graphUserV2* = "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery"
|
||||
graphUserById* = "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery"
|
||||
graphUserTweetsV2* = "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2"
|
||||
graphUserTweetsAndRepliesV2* = "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2"
|
||||
graphUserTweets* = "oRJs8SLCRNRbQzuZG93_oA/UserTweets"
|
||||
graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
|
||||
graphUserMedia* = "36oKqyQ7E_9CmtONGjJRsA/UserMedia"
|
||||
graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2"
|
||||
graphTweet* = "b4pV7sWOe97RncwHcGESUA/ConversationTimeline"
|
||||
graphTweetDetail* = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail"
|
||||
graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery"
|
||||
graphTweetEditHistory* = "upS9teTSG45aljmP9oTuXA/TweetEditHistory"
|
||||
graphSearchTimeline* = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline"
|
||||
graphListById* = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId"
|
||||
graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
|
||||
graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
|
||||
graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
|
||||
graphAboutAccount* = "zs_jFPFT78rBpXv9Z3U2YQ/AboutAccountQuery"
|
||||
api = parseUri("https://api.twitter.com")
|
||||
activate* = $(api / "1.1/guest/activate.json")
|
||||
|
||||
graphBroadcast* = "0nMmbMh-_JwwRRFNXkyH3Q/BroadcastQuery"
|
||||
restLiveStream* = "1.1/live_video_stream/status/"
|
||||
photoRail* = api / "1.1/statuses/media_timeline.json"
|
||||
userSearch* = api / "1.1/users/search.json"
|
||||
|
||||
graphql = api / "graphql"
|
||||
graphUser* = graphql / "pVrmNaXcxPjisIvKtLDMEA/UserByScreenName"
|
||||
graphUserById* = graphql / "1YAM811Q8Ry4XyPpJclURQ/UserByRestId"
|
||||
graphUserTweets* = graphql / "WzJjibAcDa-oCjCcLOotcg/UserTweets"
|
||||
graphUserTweetsAndReplies* = graphql / "fn9oRltM1N4thkh5CVusPg/UserTweetsAndReplies"
|
||||
graphUserMedia* = graphql / "qQoeS7szGavsi8-ehD2AWg/UserMedia"
|
||||
graphTweet* = graphql / "miKSMGb2R1SewIJv2-ablQ/TweetDetail"
|
||||
graphTweetResult* = graphql / "0kc0a_7TTr3dvweZlMslsQ/TweetResultByRestId"
|
||||
graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline"
|
||||
graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId"
|
||||
graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug"
|
||||
graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers"
|
||||
graphListTweets* = graphql / "jZntL0oVJSdjhmPcdbw_eA/ListLatestTweetsTimeline"
|
||||
|
||||
timelineParams* = {
|
||||
"include_profile_interstitial_type": "0",
|
||||
"include_blocking": "0",
|
||||
"include_blocked_by": "0",
|
||||
"include_followed_by": "0",
|
||||
"include_want_retweets": "0",
|
||||
"include_mute_edge": "0",
|
||||
"include_can_dm": "0",
|
||||
"include_can_media_tag": "1",
|
||||
"include_ext_is_blue_verified": "1",
|
||||
"skip_status": "1",
|
||||
"cards_platform": "Web-12",
|
||||
"include_cards": "1",
|
||||
"include_composer_source": "0",
|
||||
"include_reply_count": "1",
|
||||
"tweet_mode": "extended",
|
||||
"include_entities": "1",
|
||||
"include_user_entities": "1",
|
||||
"include_ext_media_color": "0",
|
||||
"send_error_codes": "1",
|
||||
"simple_quoted_tweet": "1",
|
||||
"include_quote_count": "1"
|
||||
}.toSeq
|
||||
|
||||
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,
|
||||
"articles_api_enabled": false,
|
||||
"articles_preview_enabled": true,
|
||||
"blue_business_profile_image_shape_enabled": false,
|
||||
"c9s_tweet_anatomy_moderator_badge_enabled": true,
|
||||
"commerce_android_shop_module_enabled": false,
|
||||
"communities_web_enable_tweet_community_results_fetch": true,
|
||||
"creator_subscriptions_quote_tweet_preview_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,
|
||||
"grok_android_analyze_trend_fetch_enabled": false,
|
||||
"grok_translations_community_note_auto_translation_is_enabled": false,
|
||||
"grok_translations_community_note_translation_is_enabled": false,
|
||||
"grok_translations_post_auto_translation_is_enabled": false,
|
||||
"grok_translations_timeline_user_bio_auto_translation_is_enabled": false,
|
||||
"hidden_profile_likes_enabled": false,
|
||||
"freedom_of_speech_not_reach_fetch_enabled": false,
|
||||
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": false,
|
||||
"highlights_tweets_tab_ui_enabled": false,
|
||||
"immersive_video_status_linkable_timestamps": false,
|
||||
"interactive_text_enabled": false,
|
||||
"longform_notetweets_consumption_enabled": true,
|
||||
"longform_notetweets_inline_media_enabled": true,
|
||||
"longform_notetweets_inline_media_enabled": false,
|
||||
"longform_notetweets_richtext_consumption_enabled": true,
|
||||
"longform_notetweets_rich_text_read_enabled": true,
|
||||
"mobile_app_spotlight_module_enabled": false,
|
||||
"payments_enabled": false,
|
||||
"post_ctas_fetch_enabled": true,
|
||||
"premium_content_api_read_enabled": false,
|
||||
"profile_label_improvements_pcf_label_in_post_enabled": true,
|
||||
"profile_label_improvements_pcf_label_in_profile_enabled": false,
|
||||
"responsive_web_edit_tweet_api_enabled": true,
|
||||
"longform_notetweets_rich_text_read_enabled": false,
|
||||
"responsive_web_edit_tweet_api_enabled": false,
|
||||
"responsive_web_enhance_cards_enabled": false,
|
||||
"responsive_web_graphql_exclude_directive_enabled": true,
|
||||
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
|
||||
"responsive_web_graphql_timeline_navigation_enabled": true,
|
||||
"responsive_web_grok_analysis_button_from_backend": true,
|
||||
"responsive_web_grok_analyze_button_fetch_trends_enabled": false,
|
||||
"responsive_web_grok_analyze_post_followups_enabled": true,
|
||||
"responsive_web_grok_annotations_enabled": true,
|
||||
"responsive_web_grok_community_note_auto_translation_is_enabled": false,
|
||||
"responsive_web_grok_image_annotation_enabled": true,
|
||||
"responsive_web_grok_imagine_annotation_enabled": true,
|
||||
"responsive_web_grok_share_attachment_enabled": true,
|
||||
"responsive_web_grok_show_grok_translated_post": false,
|
||||
"responsive_web_jetfuel_frame": true,
|
||||
"responsive_web_media_download_video_enabled": false,
|
||||
"responsive_web_profile_redirect_enabled": false,
|
||||
"responsive_web_graphql_timeline_navigation_enabled": false,
|
||||
"responsive_web_text_conversations_enabled": false,
|
||||
"responsive_web_twitter_article_notes_tab_enabled": false,
|
||||
"responsive_web_twitter_article_tweet_consumption_enabled": true,
|
||||
"responsive_web_twitter_blue_verified_badge_is_enabled": true,
|
||||
"rweb_lists_timeline_redesign_enabled": true,
|
||||
"rweb_tipjar_consumption_enabled": true,
|
||||
"rweb_video_screen_enabled": false,
|
||||
"rweb_video_timestamps_enabled": false,
|
||||
"spaces_2022_h2_clipping": true,
|
||||
"spaces_2022_h2_spaces_communities": true,
|
||||
"standardized_nudges_misinfo": true,
|
||||
"subscriptions_feature_can_gift_premium": false,
|
||||
"subscriptions_verification_info_enabled": true,
|
||||
"subscriptions_verification_info_is_identity_verified_enabled": false,
|
||||
"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,
|
||||
"standardized_nudges_misinfo": false,
|
||||
"tweet_awards_web_tipping_enabled": false,
|
||||
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
|
||||
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false,
|
||||
"tweetypie_unmention_optimization_enabled": false,
|
||||
"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
|
||||
"unified_cards_destination_url_params_enabled": false,
|
||||
"verified_phone_label_enabled": false,
|
||||
"vibe_api_enabled": false,
|
||||
"view_counts_everywhere_api_enabled": true,
|
||||
"hidden_profile_subscriptions_enabled": false
|
||||
"view_counts_everywhere_api_enabled": false
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
tweetVars* = """{
|
||||
"postId": "$1",
|
||||
$2
|
||||
"includeHasBirdwatchNotes": false,
|
||||
"includePromotedContent": false,
|
||||
"withBirdwatchNotes": true,
|
||||
"withVoice": false,
|
||||
"withV2Timeline": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
tweetDetailVars* = """{
|
||||
tweetVariables* = """{
|
||||
"focalTweetId": "$1",
|
||||
$2
|
||||
"referrer": "profile",
|
||||
"with_rux_injections": false,
|
||||
"rankingMode": "Relevance",
|
||||
"includePromotedContent": true,
|
||||
"withCommunity": true,
|
||||
"withQuickPromoteEligibilityTweetFields": true,
|
||||
"withBirdwatchNotes": true,
|
||||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
tweetEditHistoryVars* = """{
|
||||
"tweetId": "$1",
|
||||
"withQuickPromoteEligibilityTweetFields": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
restIdVars* = """{
|
||||
"rest_id": "$1", $2
|
||||
"count": $3
|
||||
"withBirdwatchNotes": false,
|
||||
"includePromotedContent": false,
|
||||
"withDownvotePerspective": false,
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false,
|
||||
"withVoice": false
|
||||
}"""
|
||||
|
||||
userMediaVars* = """{
|
||||
"userId": "$1", $2
|
||||
"count": $3,
|
||||
tweetResultVariables* = """{
|
||||
"tweetId": "$1",
|
||||
"includePromotedContent": false,
|
||||
"withClientEventToken": false,
|
||||
"withBirdwatchNotes": false,
|
||||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
"withDownvotePerspective": false,
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false,
|
||||
"withVoice": false,
|
||||
"withCommunity": false
|
||||
}"""
|
||||
|
||||
userTweetsVars* = """{
|
||||
userTweetsVariables* = """{
|
||||
"userId": "$1", $2
|
||||
"count": 20,
|
||||
"includePromotedContent": false,
|
||||
"withQuickPromoteEligibilityTweetFields": true,
|
||||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
"withDownvotePerspective": false,
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false,
|
||||
"withVoice": false,
|
||||
"withV2Timeline": true
|
||||
}"""
|
||||
|
||||
userTweetsAndRepliesVars* = """{
|
||||
"userId": "$1", $2
|
||||
listTweetsVariables* = """{
|
||||
"listId": "$1", $2
|
||||
"count": 20,
|
||||
"includePromotedContent": false,
|
||||
"withCommunity": true,
|
||||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
|
||||
userTweetsFieldToggles* = """{"withArticlePlainText":false}"""
|
||||
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""
|
||||
"withDownvotePerspective": false,
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false,
|
||||
"withVoice": false
|
||||
}"""
|
||||
|
||||
@@ -1,53 +1,17 @@
|
||||
import options, strutils
|
||||
import options
|
||||
import jsony
|
||||
import user, utils, ../types/[graphuser, graphlistmembers]
|
||||
from ../../types import User, VerifiedType, Result, Query, QueryKind
|
||||
|
||||
proc parseUserResult*(userResult: UserResult): User =
|
||||
result = userResult.legacy
|
||||
|
||||
if result.verifiedType == none and userResult.isBlueVerified:
|
||||
result.verifiedType = blue
|
||||
|
||||
if result.username.len == 0 and userResult.core.screenName.len > 0:
|
||||
result.id = userResult.restId
|
||||
result.username = userResult.core.screenName
|
||||
result.fullname = userResult.core.name
|
||||
result.userPic = userResult.avatar.imageUrl.replace("_normal", "")
|
||||
|
||||
if userResult.privacy.isSome:
|
||||
result.protected = userResult.privacy.get.protected
|
||||
|
||||
if userResult.location.isSome:
|
||||
result.location = userResult.location.get.location
|
||||
|
||||
if userResult.core.createdAt.len > 0:
|
||||
result.joinDate = parseTwitterDate(userResult.core.createdAt)
|
||||
|
||||
if userResult.verification.isSome:
|
||||
let v = userResult.verification.get
|
||||
if v.verifiedType != VerifiedType.none:
|
||||
result.verifiedType = v.verifiedType
|
||||
|
||||
if userResult.profileBio.isSome and result.bio.len == 0:
|
||||
result.bio = userResult.profileBio.get.description
|
||||
import user, ../types/[graphuser, graphlistmembers]
|
||||
from ../../types import User, Result, Query, QueryKind
|
||||
|
||||
proc parseGraphUser*(json: string): User =
|
||||
if json.len == 0 or json[0] != '{':
|
||||
return
|
||||
let raw = json.fromJson(GraphUser)
|
||||
|
||||
let
|
||||
raw = json.fromJson(GraphUser)
|
||||
userResult =
|
||||
if raw.data.userResult.isSome: raw.data.userResult.get.result
|
||||
elif raw.data.user.isSome: raw.data.user.get.result
|
||||
else: UserResult()
|
||||
|
||||
if userResult.unavailableReason.get("") == "Suspended" or
|
||||
userResult.reason.get("") == "Suspended":
|
||||
if raw.data.user.result.reason.get("") == "Suspended":
|
||||
return User(suspended: true)
|
||||
|
||||
result = parseUserResult(userResult)
|
||||
result = toUser raw.data.user.result.legacy
|
||||
result.id = raw.data.user.result.restId
|
||||
result.verified = result.verified or raw.data.user.result.isBlueVerified
|
||||
|
||||
proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
||||
result = Result[User](
|
||||
@@ -63,7 +27,7 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
||||
of TimelineTimelineItem:
|
||||
let userResult = entry.content.itemContent.userResults.result
|
||||
if userResult.restId.len > 0:
|
||||
result.content.add parseUserResult(userResult)
|
||||
result.content.add toUser userResult.legacy
|
||||
of TimelineTimelineCursor:
|
||||
if entry.content.cursorType == "Bottom":
|
||||
result.bottom = entry.content.value
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import std/strutils
|
||||
import jsony
|
||||
import ../types/session
|
||||
from ../../types import Session, SessionKind
|
||||
|
||||
proc parseSession*(raw: string): Session =
|
||||
let session = raw.fromJson(RawSession)
|
||||
let kind = if session.kind == "": "oauth" else: session.kind
|
||||
|
||||
case kind
|
||||
of "oauth":
|
||||
let id = session.oauthToken[0 ..< session.oauthToken.find('-')]
|
||||
result = Session(
|
||||
kind: SessionKind.oauth,
|
||||
id: parseBiggestInt(id),
|
||||
username: session.username,
|
||||
oauthToken: session.oauthToken,
|
||||
oauthSecret: session.oauthTokenSecret
|
||||
)
|
||||
of "cookie":
|
||||
let id = if session.id.len > 0: parseBiggestInt(session.id) else: 0
|
||||
result = Session(
|
||||
kind: SessionKind.cookie,
|
||||
id: id,
|
||||
username: session.username,
|
||||
authToken: session.authToken,
|
||||
ct0: session.ct0
|
||||
)
|
||||
else:
|
||||
raise newException(ValueError, "Unknown session kind: " & kind)
|
||||
@@ -54,7 +54,7 @@ proc replacedWith*(runes: seq[Rune]; repls: openArray[ReplaceSlice];
|
||||
let
|
||||
name = $runes[rep.slice.a.succ .. rep.slice.b]
|
||||
symbol = $runes[rep.slice.a]
|
||||
result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name)
|
||||
result.add a(symbol & name, href = "/search?q=%23" & name)
|
||||
of rkMention:
|
||||
result.add a($runes[rep.slice], href = rep.url, title = rep.display)
|
||||
of rkUrl:
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import jsony
|
||||
import ../types/tid
|
||||
export TidPair
|
||||
|
||||
proc parseTidPairs*(raw: string): seq[TidPair] =
|
||||
result = raw.fromJson(seq[TidPair])
|
||||
if result.len == 0:
|
||||
raise newException(ValueError, "Parsing pairs failed: " & raw)
|
||||
@@ -1,7 +1,6 @@
|
||||
import std/[options, tables, strutils, strformat, sugar]
|
||||
import jsony
|
||||
import user, ../types/unifiedcard
|
||||
import ../../formatters
|
||||
import ../types/unifiedcard
|
||||
from ../../types import Card, CardKind, Video
|
||||
from ../../utils import twimg, https
|
||||
|
||||
@@ -28,14 +27,6 @@ proc parseMediaDetails(data: ComponentData; card: UnifiedCard; result: var Card)
|
||||
result.text = data.topicDetail.title
|
||||
result.dest = "Topic"
|
||||
|
||||
proc parseJobDetails(data: ComponentData; card: UnifiedCard; result: var Card) =
|
||||
data.destination.parseDestination(card, result)
|
||||
|
||||
result.kind = CardKind.jobDetails
|
||||
result.title = data.title
|
||||
result.text = data.shortDescriptionText
|
||||
result.dest = &"@{data.profileUser.username} · {data.location}"
|
||||
|
||||
proc parseAppDetails(data: ComponentData; card: UnifiedCard; result: var Card) =
|
||||
let app = card.appStoreData[data.appId][0]
|
||||
|
||||
@@ -78,18 +69,6 @@ proc parseMedia(component: Component; card: UnifiedCard; result: var Card) =
|
||||
of model3d:
|
||||
result.title = "Unsupported 3D model ad"
|
||||
|
||||
proc parseGrokShare(data: ComponentData; card: UnifiedCard; result: var Card) =
|
||||
result.kind = summaryLarge
|
||||
|
||||
data.destination.parseDestination(card, result)
|
||||
result.dest = "Answer by Grok"
|
||||
|
||||
for msg in data.conversationPreview:
|
||||
if msg.sender == "USER":
|
||||
result.title = msg.message.shorten(70)
|
||||
elif msg.sender == "AGENT":
|
||||
result.text = msg.message.shorten(500)
|
||||
|
||||
proc parseUnifiedCard*(json: string): Card =
|
||||
let card = json.fromJson(UnifiedCard)
|
||||
|
||||
@@ -105,10 +84,6 @@ proc parseUnifiedCard*(json: string): Card =
|
||||
component.parseMedia(card, result)
|
||||
of buttonGroup:
|
||||
discard
|
||||
of grokShare:
|
||||
component.data.parseGrokShare(card, result)
|
||||
of ComponentType.jobDetails:
|
||||
component.data.parseJobDetails(card, result)
|
||||
of ComponentType.hidden:
|
||||
result.kind = CardKind.hidden
|
||||
of ComponentType.unknown:
|
||||
|
||||
@@ -9,7 +9,7 @@ let
|
||||
unReplace = "$1<a href=\"/$2\">@$2</a>"
|
||||
|
||||
htRegex = nre.re"""(*U)(^|[^\w-_.?])([##$])([\w_]*+)(?!</a>|">|#)"""
|
||||
htReplace = "$1<a href=\"/search?f=tweets&q=%23$3\">$2$3</a>"
|
||||
htReplace = "$1<a href=\"/search?q=%23$3\">$2$3</a>"
|
||||
|
||||
proc expandUserEntities(user: var User; raw: RawUser) =
|
||||
let
|
||||
@@ -56,21 +56,32 @@ proc toUser*(raw: RawUser): User =
|
||||
tweets: raw.statusesCount,
|
||||
likes: raw.favouritesCount,
|
||||
media: raw.mediaCount,
|
||||
verifiedType: raw.verifiedType,
|
||||
verified: raw.verified,
|
||||
protected: raw.protected,
|
||||
joinDate: parseTwitterDate(raw.createdAt),
|
||||
banner: getBanner(raw),
|
||||
userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "")
|
||||
)
|
||||
|
||||
if raw.createdAt.len > 0:
|
||||
result.joinDate = parseTwitterDate(raw.createdAt)
|
||||
|
||||
if raw.pinnedTweetIdsStr.len > 0:
|
||||
result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0])
|
||||
|
||||
result.expandUserEntities(raw)
|
||||
|
||||
proc parseHook*(s: string; i: var int; v: var User) =
|
||||
var u: RawUser
|
||||
parseHook(s, i, u)
|
||||
v = toUser u
|
||||
proc parseUser*(json: string; username=""): User =
|
||||
handleErrors:
|
||||
case error.code
|
||||
of suspended: return User(username: username, suspended: true)
|
||||
of userNotFound: return
|
||||
else: echo "[error - parseUser]: ", error
|
||||
|
||||
result = toUser json.fromJson(RawUser)
|
||||
|
||||
proc parseUsers*(json: string; after=""): Result[User] =
|
||||
result = Result[User](beginning: after.len == 0)
|
||||
|
||||
# starting with '{' means it's an error
|
||||
if json[0] == '[':
|
||||
let raw = json.fromJson(seq[RawUser])
|
||||
for user in raw:
|
||||
result.content.add user.toUser
|
||||
|
||||
@@ -1,48 +1,15 @@
|
||||
import options, strutils
|
||||
from ../../types import User, VerifiedType
|
||||
import options
|
||||
import user
|
||||
|
||||
type
|
||||
GraphUser* = object
|
||||
data*: tuple[userResult: Option[UserData], user: Option[UserData]]
|
||||
data*: tuple[user: UserData]
|
||||
|
||||
UserData* = object
|
||||
result*: UserResult
|
||||
|
||||
UserCore* = object
|
||||
name*: string
|
||||
screenName*: string
|
||||
createdAt*: string
|
||||
|
||||
UserBio* = object
|
||||
description*: string
|
||||
|
||||
UserAvatar* = object
|
||||
imageUrl*: string
|
||||
|
||||
Verification* = object
|
||||
verifiedType*: VerifiedType
|
||||
|
||||
Location* = object
|
||||
location*: string
|
||||
|
||||
Privacy* = object
|
||||
protected*: bool
|
||||
|
||||
UserResult* = object
|
||||
legacy*: User
|
||||
UserResult = object
|
||||
legacy*: RawUser
|
||||
restId*: string
|
||||
isBlueVerified*: bool
|
||||
core*: UserCore
|
||||
avatar*: UserAvatar
|
||||
unavailableReason*: Option[string]
|
||||
reason*: Option[string]
|
||||
privacy*: Option[Privacy]
|
||||
profileBio*: Option[UserBio]
|
||||
verification*: Option[Verification]
|
||||
location*: Option[Location]
|
||||
|
||||
proc enumHook*(s: string; v: var VerifiedType) =
|
||||
v = try:
|
||||
parseEnum[VerifiedType](s)
|
||||
except:
|
||||
VerifiedType.none
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
type
|
||||
RawSession* = object
|
||||
kind*: string
|
||||
id*: string
|
||||
username*: string
|
||||
oauthToken*: string
|
||||
oauthTokenSecret*: string
|
||||
authToken*: string
|
||||
ct0*: string
|
||||
@@ -1,4 +0,0 @@
|
||||
type
|
||||
TidPair* = object
|
||||
animationKey*: string
|
||||
verification*: string
|
||||
23
src/experimental/types/timeline.nim
Normal file
23
src/experimental/types/timeline.nim
Normal file
@@ -0,0 +1,23 @@
|
||||
import std/tables
|
||||
import user
|
||||
|
||||
type
|
||||
Search* = object
|
||||
globalObjects*: GlobalObjects
|
||||
timeline*: Timeline
|
||||
|
||||
GlobalObjects = object
|
||||
users*: Table[string, RawUser]
|
||||
|
||||
Timeline = object
|
||||
instructions*: seq[Instructions]
|
||||
|
||||
Instructions = object
|
||||
addEntries*: tuple[entries: seq[Entry]]
|
||||
|
||||
Entry = object
|
||||
entryId*: string
|
||||
content*: tuple[operation: Operation]
|
||||
|
||||
Operation = object
|
||||
cursor*: tuple[value, cursorType: string]
|
||||
@@ -1,10 +1,7 @@
|
||||
import std/[options, tables, times]
|
||||
import jsony
|
||||
from ../../types import VideoType, VideoVariant, User
|
||||
import options, tables
|
||||
from ../../types import VideoType, VideoVariant
|
||||
|
||||
type
|
||||
Text* = distinct string
|
||||
|
||||
UnifiedCard* = object
|
||||
componentObjects*: Table[string, Component]
|
||||
destinationObjects*: Table[string, Destination]
|
||||
@@ -16,13 +13,11 @@ type
|
||||
media
|
||||
swipeableMedia
|
||||
buttonGroup
|
||||
jobDetails
|
||||
appStoreDetails
|
||||
twitterListDetails
|
||||
communityDetails
|
||||
mediaWithDetailsHorizontal
|
||||
hidden
|
||||
grokShare
|
||||
unknown
|
||||
|
||||
Component* = object
|
||||
@@ -34,16 +29,12 @@ type
|
||||
appId*: string
|
||||
mediaId*: string
|
||||
destination*: string
|
||||
location*: string
|
||||
title*: Text
|
||||
subtitle*: Text
|
||||
name*: Text
|
||||
memberCount*: int
|
||||
mediaList*: seq[MediaItem]
|
||||
topicDetail*: tuple[title: Text]
|
||||
profileUser*: User
|
||||
shortDescriptionText*: string
|
||||
conversationPreview*: seq[GrokConversation]
|
||||
|
||||
MediaItem* = object
|
||||
id*: string
|
||||
@@ -78,13 +69,12 @@ type
|
||||
title*: Text
|
||||
category*: Text
|
||||
|
||||
GrokConversation* = object
|
||||
message*: string
|
||||
sender*: string
|
||||
Text = object
|
||||
content: string
|
||||
|
||||
TypeField = Component | Destination | MediaEntity | AppStoreData
|
||||
|
||||
converter fromText*(text: Text): string = string(text)
|
||||
converter fromText*(text: Text): string = text.content
|
||||
|
||||
proc renameHook*(v: var TypeField; fieldName: var string) =
|
||||
if fieldName == "type":
|
||||
@@ -96,13 +86,11 @@ proc enumHook*(s: string; v: var ComponentType) =
|
||||
of "media": media
|
||||
of "swipeable_media": swipeableMedia
|
||||
of "button_group": buttonGroup
|
||||
of "job_details": jobDetails
|
||||
of "app_store_details": appStoreDetails
|
||||
of "twitter_list_details": twitterListDetails
|
||||
of "community_details": communityDetails
|
||||
of "media_with_details_horizontal": mediaWithDetailsHorizontal
|
||||
of "commerce_drop_details": hidden
|
||||
of "grok_share": grokShare
|
||||
else: echo "ERROR: Unknown enum value (ComponentType): ", s; unknown
|
||||
|
||||
proc enumHook*(s: string; v: var AppType) =
|
||||
@@ -118,18 +106,3 @@ proc enumHook*(s: string; v: var MediaType) =
|
||||
of "photo": photo
|
||||
of "model3d": model3d
|
||||
else: echo "ERROR: Unknown enum value (MediaType): ", s; photo
|
||||
|
||||
proc parseHook*(s: string; i: var int; v: var DateTime) =
|
||||
var str: string
|
||||
parseHook(s, i, str)
|
||||
v = parse(str, "yyyy-MM-dd hh:mm:ss")
|
||||
|
||||
proc parseHook*(s: string; i: var int; v: var Text) =
|
||||
if s[i] == '"':
|
||||
var str: string
|
||||
parseHook(s, i, str)
|
||||
v = Text(str)
|
||||
else:
|
||||
var t: tuple[content: string]
|
||||
parseHook(s, i, t)
|
||||
v = Text(t.content)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import options
|
||||
import common
|
||||
from ../../types import VerifiedType
|
||||
|
||||
type
|
||||
RawUser* = object
|
||||
@@ -16,7 +15,7 @@ type
|
||||
favouritesCount*: int
|
||||
statusesCount*: int
|
||||
mediaCount*: int
|
||||
verifiedType*: VerifiedType
|
||||
verified*: bool
|
||||
protected*: bool
|
||||
profileLinkColor*: string
|
||||
profileBannerUrl*: string
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen, math
|
||||
import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen
|
||||
import std/[enumerate, re]
|
||||
import types, utils, query
|
||||
|
||||
const
|
||||
cards = "cards.twitter.com/cards"
|
||||
tco = "https://t.co"
|
||||
twitter = parseUri("https://x.com")
|
||||
twitter = parseUri("https://twitter.com")
|
||||
|
||||
let
|
||||
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
|
||||
twLinkRegex = re"""<a href="https:\/\/twitter.com([^"]+)">twitter\.com(\S+)</a>"""
|
||||
xRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?x\.com"
|
||||
xLinkRegex = re"""<a href="https:\/\/x.com([^"]+)">x\.com(\S+)</a>"""
|
||||
|
||||
ytRegex = re(r"([A-z.]+\.)?youtu(be\.com|\.be)", {reStudy, reIgnoreCase})
|
||||
|
||||
@@ -33,13 +31,10 @@ proc getUrlPrefix*(cfg: Config): string =
|
||||
if cfg.useHttps: https & cfg.hostname
|
||||
else: "http://" & cfg.hostname
|
||||
|
||||
proc shorten*(text: string; length=28): string =
|
||||
result = text
|
||||
proc shortLink*(text: string; length=28): string =
|
||||
result = text.replace(wwwRegex, "")
|
||||
if result.len > length:
|
||||
result = result[0 ..< length] & "…"
|
||||
|
||||
proc shortLink*(text: string; length=28): string =
|
||||
result = text.replace(wwwRegex, "").shorten(length)
|
||||
|
||||
proc stripHtml*(text: string; shorten=false): string =
|
||||
var html = parseHtml(text)
|
||||
@@ -59,28 +54,19 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
|
||||
result = body
|
||||
|
||||
if prefs.replaceYouTube.len > 0 and "youtu" in result:
|
||||
let youtubeHost = strip(prefs.replaceYouTube, chars={'/'})
|
||||
result = result.replace(ytRegex, youtubeHost)
|
||||
result = result.replace(ytRegex, prefs.replaceYouTube)
|
||||
|
||||
if prefs.replaceTwitter.len > 0:
|
||||
let twitterHost = strip(prefs.replaceTwitter, chars={'/'})
|
||||
if tco in result:
|
||||
result = result.replace(tco, https & twitterHost & "/t.co")
|
||||
if "x.com" in result:
|
||||
result = result.replace(xRegex, twitterHost)
|
||||
result = result.replacef(xLinkRegex, a(
|
||||
twitterHost & "$2", href = https & twitterHost & "$1"))
|
||||
if "twitter.com" in result:
|
||||
result = result.replace(cards, twitterHost & "/cards")
|
||||
result = result.replace(twRegex, twitterHost)
|
||||
result = result.replacef(twLinkRegex, a(
|
||||
twitterHost & "$2", href = https & twitterHost & "$1"))
|
||||
if prefs.replaceTwitter.len > 0 and ("twitter.com" in body or tco in body):
|
||||
result = result.replace(tco, https & prefs.replaceTwitter & "/t.co")
|
||||
result = result.replace(cards, prefs.replaceTwitter & "/cards")
|
||||
result = result.replace(twRegex, prefs.replaceTwitter)
|
||||
result = result.replacef(twLinkRegex, a(
|
||||
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
|
||||
|
||||
if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" 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(rdShortRegex, prefs.replaceReddit & "/comments/")
|
||||
result = result.replace(rdRegex, prefs.replaceReddit)
|
||||
if prefs.replaceReddit in result and "/gallery/" in result:
|
||||
result = result.replace("/gallery/", "/comments/")
|
||||
|
||||
if absolute.len > 0 and "href" in result:
|
||||
@@ -91,31 +77,15 @@ proc getM3u8Url*(content: string): string =
|
||||
if re.find(content, m3u8Regex, matches) != -1:
|
||||
result = matches[0]
|
||||
|
||||
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", "")
|
||||
proc proxifyVideo*(manifest: string; proxy: bool): string =
|
||||
var replacements: seq[(string, string)]
|
||||
for line in manifest.splitLines:
|
||||
let url =
|
||||
if line.startsWith("#EXT-X-MAP:URI"): line[16 .. ^2]
|
||||
elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line:
|
||||
line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))]
|
||||
else: line
|
||||
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)
|
||||
if url.startsWith('/'):
|
||||
let path = "https://video.twimg.com" & url
|
||||
replacements.add (url, if proxy: path.getVidUrl else: path)
|
||||
return manifest.multiReplace(replacements)
|
||||
|
||||
proc getUserPic*(userPic: string; style=""): string =
|
||||
@@ -168,30 +138,13 @@ proc getShortTime*(tweet: Tweet): string =
|
||||
else:
|
||||
result = "now"
|
||||
|
||||
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
|
||||
return getLink(tweet.id, username, focus)
|
||||
if username.len == 0:
|
||||
username = "i"
|
||||
result = &"/{username}/status/{tweet.id}"
|
||||
if focus: result &= "#m"
|
||||
|
||||
proc getTwitterLink*(path: string; params: Table[string, string]): string =
|
||||
var
|
||||
@@ -219,7 +172,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?f=tweets&q=place:" & loc[1] else: ""
|
||||
let url = if loc.len > 1: "/search?q=place:" & loc[1] else: ""
|
||||
(loc[0], url)
|
||||
|
||||
proc getSuspended*(username: string): string =
|
||||
|
||||
@@ -39,8 +39,11 @@ template use*(pool: HttpPool; heads: HttpHeaders; body: untyped): untyped =
|
||||
|
||||
try:
|
||||
body
|
||||
except BadClientError, ProtocolError:
|
||||
# Twitter returned 503 or closed the connection, we need a new client
|
||||
except ProtocolError:
|
||||
# Twitter closed the connection, retry
|
||||
body
|
||||
except BadClientError:
|
||||
# Twitter returned 503, we need a new client
|
||||
pool.release(c, true)
|
||||
badClient = false
|
||||
c = pool.acquire(heads)
|
||||
|
||||
@@ -6,22 +6,17 @@ from os import getEnv
|
||||
|
||||
import jester
|
||||
|
||||
import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
|
||||
import types, config, prefs, formatters, redis_cache, http_pool, tokens
|
||||
import views/[general, about]
|
||||
import routes/[
|
||||
preferences, timeline, status, media, search, rss, list, debug,
|
||||
unsupported, embed, resolver, broadcast, router_utils]
|
||||
unsupported, embed, resolver, router_utils]
|
||||
|
||||
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
||||
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
||||
|
||||
let
|
||||
configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf")
|
||||
(cfg, fullCfg) = getConfig(configPath)
|
||||
|
||||
sessionsPath = getEnv("NITTER_SESSIONS_FILE", "./sessions.jsonl")
|
||||
|
||||
initSessionPool(cfg, sessionsPath)
|
||||
let configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf")
|
||||
let (cfg, fullCfg) = getConfig(configPath)
|
||||
|
||||
if not cfg.enableDebug:
|
||||
# Silence Jester's query warning
|
||||
@@ -37,17 +32,14 @@ setHmacKey(cfg.hmacKey)
|
||||
setProxyEncoding(cfg.base64Media)
|
||||
setMaxHttpConns(cfg.httpMaxConns)
|
||||
setHttpProxy(cfg.proxy, cfg.proxyAuth)
|
||||
setApiProxy(cfg.apiProxy)
|
||||
setDisableTid(cfg.disableTid)
|
||||
setMaxConcurrentReqs(cfg.maxConcurrentReqs)
|
||||
setMaxRetries(cfg.maxRetries)
|
||||
setRetryDelayMs(cfg.retryDelayMs)
|
||||
initAboutPage(cfg.staticDir)
|
||||
|
||||
waitFor initRedisPool(cfg)
|
||||
stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
|
||||
stdout.flushFile
|
||||
|
||||
asyncCheck initTokenPool(cfg)
|
||||
|
||||
createUnsupportedRouter(cfg)
|
||||
createResolverRouter(cfg)
|
||||
createPrefRouter(cfg)
|
||||
@@ -58,7 +50,6 @@ createSearchRouter(cfg)
|
||||
createMediaRouter(cfg)
|
||||
createEmbedRouter(cfg)
|
||||
createRssRouter(cfg)
|
||||
createBroadcastRouter(cfg)
|
||||
createDebugRouter(cfg)
|
||||
|
||||
settings:
|
||||
@@ -68,16 +59,11 @@ settings:
|
||||
reusePort = true
|
||||
|
||||
routes:
|
||||
before:
|
||||
# skip all file URLs
|
||||
cond "." notin request.path
|
||||
applyUrlPrefs()
|
||||
|
||||
get "/":
|
||||
resp renderMain(renderSearch(), request, cfg, requestPrefs())
|
||||
resp renderMain(renderSearch(), request, cfg, themePrefs())
|
||||
|
||||
get "/about":
|
||||
resp renderMain(renderAbout(), request, cfg, requestPrefs())
|
||||
resp renderMain(renderAbout(), request, cfg, themePrefs())
|
||||
|
||||
get "/explore":
|
||||
redirect("/about")
|
||||
@@ -88,7 +74,7 @@ routes:
|
||||
get "/i/redirect":
|
||||
let url = decodeUrl(@"url")
|
||||
if url.len == 0: resp Http404
|
||||
redirect(replaceUrls(url, requestPrefs()))
|
||||
redirect(replaceUrls(url, cookiePrefs()))
|
||||
|
||||
error Http404:
|
||||
resp Http404, showError("Page not found", cfg)
|
||||
@@ -101,18 +87,13 @@ routes:
|
||||
|
||||
error BadClientError:
|
||||
echo error.exc.name, ": ", error.exc.msg
|
||||
resp Http500, showError("Network error occurred, please try again.", cfg)
|
||||
resp Http500, showError("Network error occured, please try again.", cfg)
|
||||
|
||||
error RateLimitError:
|
||||
const link = a("another instance", href = instancesUrl)
|
||||
resp Http429, showError(
|
||||
&"Instance has been rate limited.<br>Use {link} or try again later.", cfg)
|
||||
|
||||
error NoSessionsError:
|
||||
const link = a("another instance", href = instancesUrl)
|
||||
resp Http429, showError(
|
||||
&"Instance has no auth tokens, or is fully rate limited.<br>Use {link} or try again later.", cfg)
|
||||
|
||||
extend rss, ""
|
||||
extend status, ""
|
||||
extend search, ""
|
||||
@@ -122,6 +103,5 @@ routes:
|
||||
extend preferences, ""
|
||||
extend resolver, ""
|
||||
extend embed, ""
|
||||
extend broadcastRoute, ""
|
||||
extend debug, ""
|
||||
extend unsupported, ""
|
||||
|
||||
776
src/parser.nim
776
src/parser.nim
@@ -1,17 +1,11 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, options, times, math, tables
|
||||
import strutils, options, tables, times, math
|
||||
import packedjson, packedjson/deserialiser
|
||||
import types, parserutils, utils
|
||||
import experimental/parser/unifiedcard
|
||||
|
||||
proc parseGraphTweet(js: JsonNode): Tweet
|
||||
|
||||
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
|
||||
result = User(
|
||||
@@ -27,104 +21,19 @@ 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(js{"privacy", "protected"}.getBool),
|
||||
verified: js{"verified"}.getBool or js{"ext_is_blue_verified"}.getBool,
|
||||
protected: js{"protected"}.getBool,
|
||||
joinDate: js{"created_at"}.getTime
|
||||
)
|
||||
|
||||
if js{"is_blue_verified"}.getBool(false):
|
||||
result.verifiedType = blue
|
||||
|
||||
with verifiedType, js{"verified_type"}:
|
||||
result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
|
||||
|
||||
result.expandUserEntities(js)
|
||||
|
||||
proc parseGraphUser(js: JsonNode): User =
|
||||
var user = js{"user_result", "result"}
|
||||
if user.isNull:
|
||||
user = ? js{"user_results", "result"}
|
||||
let user = ? js{"user_results", "result"}
|
||||
result = parseUser(user{"legacy"})
|
||||
|
||||
if user.isNull:
|
||||
if js{"core"}.notNull and js{"legacy"}.notNull:
|
||||
user = js
|
||||
else:
|
||||
return
|
||||
|
||||
result = parseUser(user{"legacy"}, user{"rest_id"}.getStr)
|
||||
|
||||
if result.verifiedType == none and user{"is_blue_verified"}.getBool(false):
|
||||
result.verifiedType = blue
|
||||
|
||||
# fallback to support UserMedia/recent GraphQL updates
|
||||
if result.username.len == 0:
|
||||
result.username = user{"core", "screen_name"}.getStr
|
||||
result.fullname = user{"core", "name"}.getStr
|
||||
result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "")
|
||||
|
||||
if user{"is_blue_verified"}.getBool(
|
||||
user{"verification", "is_blue_verified"}.getBool(false)):
|
||||
result.verifiedType = blue
|
||||
|
||||
with verifiedType, user{"verification", "verified_type"}:
|
||||
result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
|
||||
|
||||
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 = parseEnum[VerifiedType](verifiedType.getStr)
|
||||
|
||||
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)
|
||||
)
|
||||
if "is_blue_verified" in user:
|
||||
result.verified = true
|
||||
|
||||
proc parseGraphList*(js: JsonNode): List =
|
||||
if js.isNull: return
|
||||
@@ -163,124 +72,42 @@ proc parsePoll(js: JsonNode): Poll =
|
||||
result.leader = result.values.find(max(result.values))
|
||||
result.votes = result.values.sum
|
||||
|
||||
proc parseVideoVariants(variants: JsonNode): seq[VideoVariant] =
|
||||
result = @[]
|
||||
for v in variants:
|
||||
let
|
||||
url = v{"url"}.getStr
|
||||
contentType = parseEnum[VideoType](v{"content_type"}.getStr("video/mp4"))
|
||||
bitrate = v{"bit_rate"}.getInt(v{"bitrate"}.getInt(0))
|
||||
|
||||
result.add VideoVariant(
|
||||
contentType: contentType,
|
||||
bitrate: bitrate,
|
||||
url: url,
|
||||
resolution: if contentType == mp4: getMp4Resolution(url) else: 0
|
||||
)
|
||||
proc parseGif(js: JsonNode): Gif =
|
||||
result = Gif(
|
||||
url: js{"video_info", "variants"}[0]{"url"}.getImageStr,
|
||||
thumb: js{"media_url_https"}.getImageStr
|
||||
)
|
||||
|
||||
proc parseVideo(js: JsonNode): Video =
|
||||
result = Video(
|
||||
thumb: js{"media_url_https"}.getImageStr,
|
||||
available: true,
|
||||
views: js{"ext", "mediaStats", "r", "ok", "viewCount"}.getStr($js{"mediaStats", "viewCount"}.getInt),
|
||||
available: js{"ext_media_availability", "status"}.getStr.toLowerAscii == "available",
|
||||
title: js{"ext_alt_text"}.getStr,
|
||||
durationMs: js{"video_info", "duration_millis"}.getInt
|
||||
# playbackType: mp4
|
||||
durationMs: js{"video_info", "duration_millis"}.getInt,
|
||||
playbackType: m3u8
|
||||
)
|
||||
|
||||
with status, js{"ext_media_availability", "status"}:
|
||||
if status.getStr.len > 0 and status.getStr.toLowerAscii != "available":
|
||||
result.available = false
|
||||
|
||||
with title, js{"additional_media_info", "title"}:
|
||||
result.title = title.getStr
|
||||
|
||||
with description, js{"additional_media_info", "description"}:
|
||||
result.description = description.getStr
|
||||
|
||||
result.variants = parseVideoVariants(js{"video_info", "variants"})
|
||||
for v in js{"video_info", "variants"}:
|
||||
let
|
||||
contentType = parseEnum[VideoType](v{"content_type"}.getStr("summary"))
|
||||
url = v{"url"}.getStr
|
||||
|
||||
proc addMedia(media: var MediaEntities; photo: Photo) =
|
||||
media.add Media(kind: photoMedia, photo: photo)
|
||||
if contentType == mp4:
|
||||
result.playbackType = mp4
|
||||
|
||||
proc addMedia(media: var MediaEntities; video: Video) =
|
||||
media.add Media(kind: videoMedia, video: video)
|
||||
|
||||
proc addMedia(media: var MediaEntities; gif: Gif) =
|
||||
media.add Media(kind: gifMedia, gif: gif)
|
||||
|
||||
proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
|
||||
with jsMedia, js{"extended_entities", "media"}:
|
||||
for m in jsMedia:
|
||||
case m.getTypeName:
|
||||
of "photo":
|
||||
result.media.addMedia(Photo(
|
||||
url: m{"media_url_https"}.getImageStr,
|
||||
altText: m{"ext_alt_text"}.getStr
|
||||
))
|
||||
of "video":
|
||||
result.media.addMedia(parseVideo(m))
|
||||
with user, m{"additional_media_info", "source_user"}:
|
||||
if user{"id"}.getInt > 0:
|
||||
result.attribution = some(parseUser(user))
|
||||
else:
|
||||
result.attribution = some(parseGraphUser(user))
|
||||
of "animated_gif":
|
||||
result.media.addMedia(Gif(
|
||||
url: m{"video_info", "variants"}[0]{"url"}.getImageStr,
|
||||
thumb: m{"media_url_https"}.getImageStr,
|
||||
altText: m{"ext_alt_text"}.getStr
|
||||
))
|
||||
else: discard
|
||||
|
||||
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":
|
||||
parsedMedia.addMedia(Photo(
|
||||
url: mediaInfo{"original_img_url"}.getImageStr,
|
||||
altText: mediaInfo{"alt_text"}.getStr
|
||||
))
|
||||
of "ApiVideo":
|
||||
let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"}
|
||||
parsedMedia.addMedia(Video(
|
||||
available: status.getStr == "Available",
|
||||
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
|
||||
title: mediaInfo{"alt_text"}.getStr,
|
||||
durationMs: mediaInfo{"duration_millis"}.getInt,
|
||||
variants: parseVideoVariants(mediaInfo{"variants"})
|
||||
))
|
||||
of "ApiGif":
|
||||
parsedMedia.addMedia(Gif(
|
||||
url: mediaInfo{"variants"}[0]{"url"}.getImageStr,
|
||||
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
|
||||
altText: mediaInfo{"alt_text"}.getStr
|
||||
))
|
||||
else: discard
|
||||
|
||||
if "expanded_url" in mediaEntity:
|
||||
let expandedUrl = js.getExpandedUrl
|
||||
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
|
||||
|
||||
# Remove media URLs from text
|
||||
with mediaList, js{"legacy", "entities", "media"}:
|
||||
for url in mediaList:
|
||||
let expandedUrl = url.getExpandedUrl
|
||||
if result.text.endsWith(expandedUrl):
|
||||
result.text.removeSuffix(expandedUrl)
|
||||
result.text = result.text.strip()
|
||||
result.variants.add VideoVariant(
|
||||
contentType: contentType,
|
||||
bitrate: v{"bitrate"}.getInt,
|
||||
url: url,
|
||||
resolution: if contentType == mp4: getMp4Resolution(url) else: 0
|
||||
)
|
||||
|
||||
proc parsePromoVideo(js: JsonNode): Video =
|
||||
result = Video(
|
||||
@@ -303,23 +130,14 @@ proc parsePromoVideo(js: JsonNode): Video =
|
||||
result.variants.add variant
|
||||
|
||||
proc parseBroadcast(js: JsonNode): Card =
|
||||
let
|
||||
image = js{"broadcast_thumbnail_large"}.getImageVal
|
||||
broadcastUrl = js{"broadcast_url"}.getStrVal
|
||||
broadcastId = broadcastUrl.rsplit('/', maxsplit=1)[^1]
|
||||
streamUrl = "/i/broadcasts/" & broadcastId & "/stream"
|
||||
let image = js{"broadcast_thumbnail_large"}.getImageVal
|
||||
result = Card(
|
||||
kind: broadcast,
|
||||
url: "/i/broadcasts/" & broadcastId,
|
||||
url: js{"broadcast_url"}.getStrVal,
|
||||
title: js{"broadcaster_display_name"}.getStrVal,
|
||||
text: js{"broadcast_title"}.getStrVal,
|
||||
image: image,
|
||||
video: some Video(
|
||||
thumb: image,
|
||||
available: true,
|
||||
playbackType: m3u8,
|
||||
variants: @[VideoVariant(contentType: m3u8, url: streamUrl)]
|
||||
)
|
||||
video: some Video(thumb: image)
|
||||
)
|
||||
|
||||
proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
||||
@@ -369,7 +187,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
||||
|
||||
for u in ? urls:
|
||||
if u{"url"}.getStr == result.url:
|
||||
result.url = u.getExpandedUrl(result.url)
|
||||
result.url = u{"expanded_url"}.getStr
|
||||
break
|
||||
|
||||
if kind in {videoDirectMessage, imageDirectMessage}:
|
||||
@@ -379,20 +197,14 @@ 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();
|
||||
replyId: int64 = 0): Tweet =
|
||||
if js.isNull: return Tweet()
|
||||
|
||||
let time =
|
||||
if js{"created_at"}.notNull: js{"created_at"}.getTime
|
||||
else: js{"created_at_ms"}.getTimeFromMs
|
||||
|
||||
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
||||
if js.isNull: return
|
||||
result = Tweet(
|
||||
id: js{"id_str"}.getId,
|
||||
threadId: js{"conversation_id_str"}.getId,
|
||||
replyId: js{"in_reply_to_status_id_str"}.getId,
|
||||
text: js{"full_text"}.getStr,
|
||||
time: time,
|
||||
time: js{"created_at"}.getTime,
|
||||
hasThread: js{"self_thread"}.notNull,
|
||||
available: true,
|
||||
user: User(id: js{"user_id_str"}.getStr),
|
||||
@@ -400,20 +212,17 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
|
||||
replies: js{"reply_count"}.getInt,
|
||||
retweets: js{"retweet_count"}.getInt,
|
||||
likes: js{"favorite_count"}.getInt,
|
||||
views: js{"views_count"}.getInt
|
||||
quotes: js{"quote_count"}.getInt
|
||||
)
|
||||
)
|
||||
|
||||
if result.replyId == 0:
|
||||
result.replyId = replyId
|
||||
result.expandTweetEntities(js)
|
||||
|
||||
# fix for pinned threads
|
||||
if result.hasThread and result.threadId == 0:
|
||||
result.threadId = js{"self_thread", "id_str"}.getId
|
||||
|
||||
if "retweeted_status" in js:
|
||||
result.retweet = some Tweet()
|
||||
elif js{"is_quote_status"}.getBool:
|
||||
if js{"is_quote_status"}.getBool:
|
||||
result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId)
|
||||
|
||||
# legacy
|
||||
@@ -428,28 +237,33 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
|
||||
result.retweet = some parseGraphTweet(rt)
|
||||
return
|
||||
|
||||
with reposts, js{"repostedStatusResults"}:
|
||||
with rt, reposts{"result"}:
|
||||
if "legacy" in rt:
|
||||
result.retweet = some parseGraphTweet(rt)
|
||||
return
|
||||
|
||||
if jsCard.kind != JNull:
|
||||
let name = jsCard{"name"}.getStr
|
||||
if "poll" in name:
|
||||
if "image" in name:
|
||||
result.media.addMedia(Photo(
|
||||
url: jsCard{"binding_values", "image_large"}.getImageVal
|
||||
))
|
||||
result.photos.add jsCard{"binding_values", "image_large"}.getImageVal
|
||||
|
||||
result.poll = some parsePoll(jsCard)
|
||||
elif name == "amplify":
|
||||
result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
|
||||
result.video = some(parsePromoVideo(jsCard{"binding_values"}))
|
||||
else:
|
||||
result.card = some parseCard(jsCard, js{"entities", "urls"})
|
||||
|
||||
result.expandTweetEntities(js)
|
||||
parseLegacyMediaEntities(js, result)
|
||||
with jsMedia, js{"extended_entities", "media"}:
|
||||
for m in jsMedia:
|
||||
case m{"type"}.getStr
|
||||
of "photo":
|
||||
result.photos.add m{"media_url_https"}.getImageStr
|
||||
of "video":
|
||||
result.video = some(parseVideo(m))
|
||||
with user, m{"additional_media_info", "source_user"}:
|
||||
if user{"id"}.getInt > 0:
|
||||
result.attribution = some(parseUser(user))
|
||||
else:
|
||||
result.attribution = some(parseGraphUser(user))
|
||||
of "animated_gif":
|
||||
result.gif = some(parseGif(m))
|
||||
else: discard
|
||||
|
||||
with jsWithheld, js{"withheld_in_countries"}:
|
||||
let withheldInCountries: seq[string] =
|
||||
@@ -465,340 +279,242 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
|
||||
result.text.removeSuffix(" Learn more.")
|
||||
result.available = false
|
||||
|
||||
proc finalizeTweet(global: GlobalObjects; id: string): Tweet =
|
||||
let intId = if id.len > 0: parseBiggestInt(id) else: 0
|
||||
result = global.tweets.getOrDefault(id, Tweet(id: intId))
|
||||
|
||||
if result.quote.isSome:
|
||||
let quote = get(result.quote).id
|
||||
if $quote in global.tweets:
|
||||
result.quote = some global.tweets[$quote]
|
||||
else:
|
||||
result.quote = some Tweet()
|
||||
|
||||
if result.retweet.isSome:
|
||||
let rt = get(result.retweet).id
|
||||
if $rt in global.tweets:
|
||||
result.retweet = some finalizeTweet(global, $rt)
|
||||
else:
|
||||
result.retweet = some Tweet()
|
||||
|
||||
proc parsePin(js: JsonNode; global: GlobalObjects): Tweet =
|
||||
let pin = js{"pinEntry", "entry", "entryId"}.getStr
|
||||
if pin.len == 0: return
|
||||
|
||||
let id = pin.getId
|
||||
if id notin global.tweets: return
|
||||
|
||||
global.tweets[id].pinned = true
|
||||
return finalizeTweet(global, id)
|
||||
|
||||
proc parseGlobalObjects(js: JsonNode): GlobalObjects =
|
||||
result = GlobalObjects()
|
||||
let
|
||||
tweets = ? js{"globalObjects", "tweets"}
|
||||
users = ? js{"globalObjects", "users"}
|
||||
|
||||
for k, v in users:
|
||||
result.users[k] = parseUser(v, k)
|
||||
|
||||
for k, v in tweets:
|
||||
var tweet = parseTweet(v, v{"card"})
|
||||
if tweet.user.id in result.users:
|
||||
tweet.user = result.users[tweet.user.id]
|
||||
result.tweets[k] = tweet
|
||||
|
||||
proc parseInstructions[T](res: var Result[T]; global: GlobalObjects; js: JsonNode) =
|
||||
if js.kind != JArray or js.len == 0:
|
||||
return
|
||||
|
||||
for i in js:
|
||||
when T is Tweet:
|
||||
if res.beginning and i{"pinEntry"}.notNull:
|
||||
with pin, parsePin(i, global):
|
||||
res.content.add pin
|
||||
|
||||
with r, i{"replaceEntry", "entry"}:
|
||||
if "top" in r{"entryId"}.getStr:
|
||||
res.top = r.getCursor
|
||||
elif "bottom" in r{"entryId"}.getStr:
|
||||
res.bottom = r.getCursor
|
||||
|
||||
proc parseTimeline*(js: JsonNode; after=""): Timeline =
|
||||
result = Timeline(beginning: after.len == 0)
|
||||
let global = parseGlobalObjects(? js)
|
||||
|
||||
let instructions = ? js{"timeline", "instructions"}
|
||||
if instructions.len == 0: return
|
||||
|
||||
result.parseInstructions(global, instructions)
|
||||
|
||||
var entries: JsonNode
|
||||
for i in instructions:
|
||||
if "addEntries" in i:
|
||||
entries = i{"addEntries", "entries"}
|
||||
|
||||
for e in ? entries:
|
||||
let entry = e{"entryId"}.getStr
|
||||
if "tweet" in entry or entry.startsWith("sq-I-t") or "tombstone" in entry:
|
||||
let tweet = finalizeTweet(global, e.getEntryId)
|
||||
if not tweet.available: continue
|
||||
result.content.add tweet
|
||||
elif "cursor-top" in entry:
|
||||
result.top = e.getCursor
|
||||
elif "cursor-bottom" in entry:
|
||||
result.bottom = e.getCursor
|
||||
elif entry.startsWith("sq-cursor"):
|
||||
with cursor, e{"content", "operation", "cursor"}:
|
||||
if cursor{"cursorType"}.getStr == "Bottom":
|
||||
result.bottom = cursor{"value"}.getStr
|
||||
else:
|
||||
result.top = cursor{"value"}.getStr
|
||||
|
||||
proc parsePhotoRail*(js: JsonNode): PhotoRail =
|
||||
for tweet in js:
|
||||
let
|
||||
t = parseTweet(tweet, js{"card"})
|
||||
url = if t.photos.len > 0: t.photos[0]
|
||||
elif t.video.isSome: get(t.video).thumb
|
||||
elif t.gif.isSome: get(t.gif).thumb
|
||||
elif t.card.isSome: get(t.card).image
|
||||
else: ""
|
||||
|
||||
if url.len == 0: continue
|
||||
result.add GalleryPhoto(url: url, tweetId: $t.id)
|
||||
|
||||
proc parseGraphTweet(js: JsonNode): Tweet =
|
||||
if js.kind == JNull:
|
||||
return Tweet()
|
||||
|
||||
case js.getTypeName:
|
||||
case js{"__typename"}.getStr
|
||||
of "TweetUnavailable":
|
||||
return Tweet()
|
||||
of "TweetTombstone":
|
||||
with text, select(js{"tombstone", "richText"}, js{"tombstone", "text"}):
|
||||
return Tweet(text: text.getTombstone)
|
||||
return Tweet()
|
||||
return Tweet(text: js{"tombstone", "text"}.getTombstone)
|
||||
of "TweetPreviewDisplay":
|
||||
return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
|
||||
of "TweetWithVisibilityResults":
|
||||
return parseGraphTweet(js{"tweet"})
|
||||
else:
|
||||
discard
|
||||
|
||||
if "legacy" notin js and "rest_id" notin js:
|
||||
return Tweet()
|
||||
|
||||
var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"})
|
||||
var jsCard = copy(js{"card", "legacy"})
|
||||
if jsCard.kind != JNull:
|
||||
let legacyCard = jsCard{"legacy"}
|
||||
if legacyCard.kind != JNull:
|
||||
let bindingArray = legacyCard{"binding_values"}
|
||||
if bindingArray.kind == JArray:
|
||||
var bindingObj: seq[(string, JsonNode)]
|
||||
for item in bindingArray:
|
||||
bindingObj.add((item{"key"}.getStr, item{"value"}))
|
||||
# Create a new card object with flattened structure
|
||||
jsCard = %*{
|
||||
"name": legacyCard{"name"},
|
||||
"url": legacyCard{"url"},
|
||||
"binding_values": %bindingObj
|
||||
}
|
||||
|
||||
var replyId = 0
|
||||
with restId, js{"reply_to_results", "rest_id"}:
|
||||
replyId = restId.getId
|
||||
|
||||
if "details" in js:
|
||||
result = Tweet(
|
||||
id: js{"rest_id"}.getId,
|
||||
available: true,
|
||||
text: js{"details", "full_text"}.getStr,
|
||||
time: js{"details", "created_at_ms"}.getTimeFromMs,
|
||||
replyId: js{"reply_to_results", "rest_id"}.getId,
|
||||
isAd: js{"content_disclosure", "advertising_disclosure", "is_paid_promotion"}.getBool,
|
||||
isAI: js{"content_disclosure", "ai_generated_disclosure", "has_ai_generated_media"}.getBool,
|
||||
stats: TweetStats(
|
||||
replies: js{"counts", "reply_count"}.getInt,
|
||||
retweets: js{"counts", "retweet_count"}.getInt,
|
||||
likes: js{"counts", "favorite_count"}.getInt,
|
||||
)
|
||||
)
|
||||
|
||||
if jsCard.kind != JNull:
|
||||
let name = jsCard{"name"}.getStr
|
||||
if "poll" in name:
|
||||
if "image" in name:
|
||||
result.media.addMedia(Photo(
|
||||
url: jsCard{"binding_values", "image_large"}.getImageVal
|
||||
))
|
||||
|
||||
result.poll = some parsePoll(jsCard)
|
||||
elif name == "amplify":
|
||||
result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
|
||||
else:
|
||||
result.card = some parseCard(jsCard, js{"url_entities"})
|
||||
|
||||
result.expandTweetEntitiesV2(js)
|
||||
else:
|
||||
result = parseTweet(js{"legacy"}, jsCard, replyId)
|
||||
result.id = js{"rest_id"}.getId
|
||||
var values = newJObject()
|
||||
for val in jsCard["binding_values"]:
|
||||
values[val["key"].getStr] = val["value"]
|
||||
jsCard["binding_values"] = values
|
||||
|
||||
result = parseTweet(js{"legacy"}, jsCard)
|
||||
result.user = parseGraphUser(js{"core"})
|
||||
|
||||
if result.reply.len == 0:
|
||||
with replyTo, js{"reply_to_user_results", "result", "core", "screen_name"}:
|
||||
result.reply = @[replyTo.getStr]
|
||||
|
||||
with count, js{"views", "count"}:
|
||||
result.stats.views = count.getStr("0").parseInt
|
||||
|
||||
with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
|
||||
result.expandNoteTweetEntities(noteTweet)
|
||||
|
||||
parseMediaEntities(js, result)
|
||||
|
||||
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)
|
||||
if result.quote.isSome:
|
||||
result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}))
|
||||
|
||||
proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
|
||||
for t in ? js{"content", "items"}:
|
||||
let entryId = t.getEntryId
|
||||
if "tweet-" in entryId and "promoted" notin entryId:
|
||||
let tweet = t.getTweetResult("item")
|
||||
if tweet.notNull:
|
||||
result.thread.content.add parseGraphTweet(tweet)
|
||||
|
||||
let tweetDisplayType = select(
|
||||
t{"item", "content", "tweet_display_type"},
|
||||
t{"item", "itemContent", "tweetDisplayType"}
|
||||
)
|
||||
if tweetDisplayType.getStr == "SelfThread":
|
||||
result.self = true
|
||||
else:
|
||||
result.thread.content.add Tweet(id: entryId.getId)
|
||||
elif "cursor-showmore" in entryId:
|
||||
let cursor = t{"item", "content", "value"}
|
||||
let thread = js{"content", "items"}
|
||||
for t in js{"content", "items"}:
|
||||
let entryId = t{"entryId"}.getStr
|
||||
if "cursor-showmore" in entryId:
|
||||
let cursor = t{"item", "itemContent", "value"}
|
||||
result.thread.cursor = cursor.getStr
|
||||
result.thread.hasMore = true
|
||||
elif "tweet" in entryId:
|
||||
let tweet = parseGraphTweet(t{"item", "itemContent", "tweet_results", "result"})
|
||||
result.thread.content.add tweet
|
||||
|
||||
if t{"item", "itemContent", "tweetDisplayType"}.getStr == "SelfThread":
|
||||
result.self = true
|
||||
|
||||
proc parseGraphTweetResult*(js: JsonNode): Tweet =
|
||||
with tweet, js{"data", "tweet_result", "result"}:
|
||||
with tweet, js{"data", "tweetResult", "result"}:
|
||||
result = parseGraphTweet(tweet)
|
||||
|
||||
proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
||||
result = Conversation(replies: Result[Chain](beginning: true))
|
||||
|
||||
let instructions = ? select(
|
||||
js{"data", "timelineResponse", "instructions"},
|
||||
js{"data", "timeline_response", "instructions"},
|
||||
js{"data", "threaded_conversation_with_injections_v2", "instructions"}
|
||||
)
|
||||
let instructions = ? js{"data", "threaded_conversation_with_injections", "instructions"}
|
||||
if instructions.len == 0:
|
||||
return
|
||||
|
||||
for e in instructions[0]{"entries"}:
|
||||
let entryId = e{"entryId"}.getStr
|
||||
# echo entryId
|
||||
if entryId.startsWith("tweet"):
|
||||
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
|
||||
let tweet = parseGraphTweet(tweetResult)
|
||||
|
||||
if not tweet.available:
|
||||
tweet.id = parseBiggestInt(entryId.getId())
|
||||
|
||||
if $tweet.id == tweetId:
|
||||
result.tweet = tweet
|
||||
else:
|
||||
result.before.content.add tweet
|
||||
elif entryId.startsWith("tombstone"):
|
||||
let id = entryId.getId()
|
||||
let tweet = Tweet(
|
||||
id: parseBiggestInt(id),
|
||||
available: false,
|
||||
text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone
|
||||
)
|
||||
|
||||
if id == tweetId:
|
||||
result.tweet = tweet
|
||||
else:
|
||||
result.before.content.add tweet
|
||||
elif entryId.startsWith("conversationthread"):
|
||||
let (thread, self) = parseGraphThread(e)
|
||||
if self:
|
||||
result.after = thread
|
||||
else:
|
||||
result.replies.content.add thread
|
||||
elif entryId.startsWith("cursor-bottom"):
|
||||
result.replies.bottom = e{"content", "itemContent", "value"}.getStr
|
||||
|
||||
proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Timeline =
|
||||
result = Timeline(beginning: after.len == 0)
|
||||
|
||||
let instructions =
|
||||
if root == "list": ? js{"data", "list", "tweets_timeline", "timeline", "instructions"}
|
||||
else: ? js{"data", "user", "result", "timeline_v2", "timeline", "instructions"}
|
||||
|
||||
if instructions.len == 0:
|
||||
return
|
||||
|
||||
for i in instructions:
|
||||
if i.getTypeName == "TimelineAddEntries":
|
||||
if i{"type"}.getStr == "TimelineAddEntries":
|
||||
for e in i{"entries"}:
|
||||
let entryId = e.getEntryId
|
||||
if entryId.startsWith("tweet-"):
|
||||
let tweetResult = getTweetResult(e)
|
||||
if tweetResult.notNull:
|
||||
let entryId = e{"entryId"}.getStr
|
||||
if entryId.startsWith("tweet"):
|
||||
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
|
||||
let tweet = parseGraphTweet(tweetResult)
|
||||
|
||||
if not tweet.available:
|
||||
tweet.id = entryId.getId
|
||||
|
||||
if entryId.endsWith(tweetId):
|
||||
result.tweet = tweet
|
||||
else:
|
||||
result.before.content.add tweet
|
||||
elif not entryId.endsWith(tweetId):
|
||||
result.before.content.add Tweet(id: entryId.getId)
|
||||
elif entryId.startsWith("conversationthread"):
|
||||
let (thread, self) = parseGraphThread(e)
|
||||
if self:
|
||||
result.after = thread
|
||||
elif thread.content.len > 0:
|
||||
result.replies.content.add thread
|
||||
elif entryId.startsWith("tombstone"):
|
||||
let
|
||||
content = select(e{"content", "content"}, e{"content", "itemContent"})
|
||||
tweet = Tweet(
|
||||
id: entryId.getId,
|
||||
available: false,
|
||||
text: content{"tombstoneInfo", "richText"}.getTombstone
|
||||
)
|
||||
|
||||
if $tweet.id == tweetId:
|
||||
result.tweet = tweet
|
||||
else:
|
||||
result.before.content.add tweet
|
||||
tweet.id = parseBiggestInt(entryId.getId())
|
||||
result.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
|
||||
result.bottom = e{"content", "value"}.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
|
||||
proc parseGraphSearch*(js: JsonNode; after=""): Timeline =
|
||||
result = Timeline(beginning: after.len == 0)
|
||||
|
||||
for i in instructions:
|
||||
if i.getTypeName == "TimelineAddEntries":
|
||||
for e in i{"entries"}:
|
||||
let entryId = e.getEntryId
|
||||
if entryId == "latestTweet":
|
||||
with item, e{"content", "items"}[0]:
|
||||
let tweetResult = item.getTweetResult("item")
|
||||
if tweetResult.notNull:
|
||||
result.latest = parseGraphTweet(tweetResult)
|
||||
elif entryId == "staleTweets":
|
||||
for item in e{"content", "items"}:
|
||||
let tweetResult = item.getTweetResult("item")
|
||||
if tweetResult.notNull:
|
||||
result.history.add parseGraphTweet(tweetResult)
|
||||
|
||||
proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] =
|
||||
with tweetResult, getTweetResult(e):
|
||||
var tweet = parseGraphTweet(tweetResult)
|
||||
if not tweet.available:
|
||||
tweet.id = e.getEntryId.getId
|
||||
result.add tweet
|
||||
return
|
||||
|
||||
for item in e{"content", "items"}:
|
||||
with tweetResult, item.getTweetResult("item"):
|
||||
var tweet = parseGraphTweet(tweetResult)
|
||||
if not tweet.available:
|
||||
tweet.id = item.getEntryId.getId
|
||||
result.add tweet
|
||||
|
||||
proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
|
||||
result = Profile(tweets: Timeline(beginning: after.len == 0))
|
||||
|
||||
let instructions = ? select(
|
||||
js{"data", "list", "timeline_response", "timeline", "instructions"},
|
||||
js{"data", "user", "result", "timeline", "timeline", "instructions"},
|
||||
js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
|
||||
)
|
||||
if instructions.len == 0:
|
||||
return
|
||||
|
||||
for i in instructions:
|
||||
if i{"moduleItems"}.notNull:
|
||||
for item in i{"moduleItems"}:
|
||||
with tweetResult, item.getTweetResult("item"):
|
||||
let tweet = parseGraphTweet(tweetResult)
|
||||
if not tweet.available:
|
||||
tweet.id = item.getEntryId.getId
|
||||
result.tweets.content.add tweet
|
||||
continue
|
||||
|
||||
if i{"entries"}.notNull:
|
||||
for e in i{"entries"}:
|
||||
let entryId = e.getEntryId
|
||||
if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"):
|
||||
for tweet in extractTweetsFromEntry(e):
|
||||
result.tweets.content.add tweet
|
||||
elif "-conversation-" in entryId or entryId.startsWith("homeConversation"):
|
||||
let (thread, self) = parseGraphThread(e)
|
||||
result.tweets.content.add thread.content
|
||||
elif entryId.startsWith("cursor-bottom"):
|
||||
result.tweets.bottom = e{"content", "value"}.getStr
|
||||
|
||||
if after.len == 0:
|
||||
if i.getTypeName == "TimelinePinEntry":
|
||||
let tweets = extractTweetsFromEntry(i{"entry"})
|
||||
if tweets.len > 0:
|
||||
var tweet = tweets[0]
|
||||
tweet.pinned = true
|
||||
result.pinned = some tweet
|
||||
|
||||
proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
|
||||
result = @[]
|
||||
|
||||
let instructions = select(
|
||||
js{"data", "user", "result", "timeline", "timeline", "instructions"},
|
||||
js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
|
||||
)
|
||||
if instructions.len == 0:
|
||||
return
|
||||
|
||||
for i in instructions:
|
||||
if i{"moduleItems"}.notNull:
|
||||
for item in i{"moduleItems"}:
|
||||
with tweetResult, item.getTweetResult("item"):
|
||||
let t = parseGraphTweet(tweetResult)
|
||||
if not t.available:
|
||||
t.id = item.getEntryId.getId
|
||||
|
||||
let photo = extractGalleryPhoto(t)
|
||||
if photo.url.len > 0:
|
||||
result.add photo
|
||||
|
||||
if result.len == 16:
|
||||
return
|
||||
continue
|
||||
|
||||
if i.getTypeName != "TimelineAddEntries":
|
||||
continue
|
||||
|
||||
for e in i{"entries"}:
|
||||
let entryId = e.getEntryId
|
||||
if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"):
|
||||
for t in extractTweetsFromEntry(e):
|
||||
let photo = extractGalleryPhoto(t)
|
||||
if photo.url.len > 0:
|
||||
result.add photo
|
||||
|
||||
if result.len == 16:
|
||||
return
|
||||
|
||||
proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
|
||||
result = Result[T](beginning: after.len == 0)
|
||||
|
||||
let instructions = select(
|
||||
js{"data", "search", "timeline_response", "timeline", "instructions"},
|
||||
js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"}
|
||||
)
|
||||
let instructions = js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"}
|
||||
if instructions.len == 0:
|
||||
return
|
||||
|
||||
for instruction in instructions:
|
||||
let typ = getTypeName(instruction)
|
||||
let typ = instruction{"type"}.getStr
|
||||
if typ == "TimelineAddEntries":
|
||||
for e in instruction{"entries"}:
|
||||
let entryId = e.getEntryId
|
||||
when T is Tweets:
|
||||
if entryId.startsWith("tweet"):
|
||||
with tweetRes, getTweetResult(e):
|
||||
let tweet = parseGraphTweet(tweetRes)
|
||||
if not tweet.available:
|
||||
tweet.id = entryId.getId
|
||||
result.content.add tweet
|
||||
elif T is User:
|
||||
if entryId.startsWith("user"):
|
||||
with userRes, e{"content", "itemContent"}:
|
||||
result.content.add parseGraphUser(userRes)
|
||||
|
||||
if entryId.startsWith("cursor-bottom"):
|
||||
for e in instructions[0]{"entries"}:
|
||||
let entryId = e{"entryId"}.getStr
|
||||
if entryId.startsWith("tweet"):
|
||||
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
|
||||
let tweet = parseGraphTweet(tweetResult)
|
||||
if not tweet.available:
|
||||
tweet.id = parseBiggestInt(entryId.getId())
|
||||
result.content.add tweet
|
||||
elif entryId.startsWith("cursor-bottom"):
|
||||
result.bottom = e{"content", "value"}.getStr
|
||||
elif typ == "TimelineReplaceEntry":
|
||||
if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"):
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import std/[times, macros, htmlgen, options, algorithm, re]
|
||||
import std/strutils except escape
|
||||
import std/[strutils, times, macros, htmlgen, options, algorithm, re]
|
||||
import std/unicode except strip
|
||||
from xmltree import escape
|
||||
import packedjson
|
||||
import types, utils, formatters
|
||||
|
||||
const
|
||||
unicodeOpen = "\uFFFA"
|
||||
unicodeClose = "\uFFFB"
|
||||
xmlOpen = escape("<")
|
||||
xmlClose = escape(">")
|
||||
|
||||
let
|
||||
unRegex = re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})"
|
||||
unReplace = "$1<a href=\"/$2\">@$2</a>"
|
||||
|
||||
htRegex = re"(^|[^\w-_./?])([#$]|#)([\w_]+)"
|
||||
htReplace = "$1<a href=\"/search?f=tweets&q=%23$3\">$2$3</a>"
|
||||
htReplace = "$1<a href=\"/search?q=%23$3\">$2$3</a>"
|
||||
|
||||
type
|
||||
ReplaceSliceKind = enum
|
||||
@@ -36,12 +28,6 @@ template `?`*(js: JsonNode): untyped =
|
||||
if j.isNull: return
|
||||
j
|
||||
|
||||
template select*(a, b: JsonNode): untyped =
|
||||
if a.notNull: a else: b
|
||||
|
||||
template select*(a, b, c: JsonNode): untyped =
|
||||
if a.notNull: a elif b.notNull: b else: c
|
||||
|
||||
template with*(ident, value, body): untyped =
|
||||
if true:
|
||||
let ident {.inject.} = value
|
||||
@@ -59,19 +45,6 @@ template getError*(js: JsonNode): Error =
|
||||
if js.kind != JArray or js.len == 0: null
|
||||
else: Error(js[0]{"code"}.getInt)
|
||||
|
||||
proc getTweetResult*(js: JsonNode; root="content"): JsonNode =
|
||||
select(
|
||||
js{root, "content", "tweet_results", "result"},
|
||||
js{root, "itemContent", "tweet_results", "result"},
|
||||
js{root, "content", "tweetResult", "result"}
|
||||
)
|
||||
|
||||
template getTypeName*(js: JsonNode): string =
|
||||
js{"__typename"}.getStr(js{"type"}.getStr)
|
||||
|
||||
template getEntryId*(e: JsonNode): string =
|
||||
e{"entryId"}.getStr(e{"entry_id"}.getStr)
|
||||
|
||||
template parseTime(time: string; f: static string; flen: int): DateTime =
|
||||
if time.len != flen: return
|
||||
parse(time, f, utc())
|
||||
@@ -82,32 +55,29 @@ proc getDateTime*(js: JsonNode): DateTime =
|
||||
proc getTime*(js: JsonNode): DateTime =
|
||||
parseTime(js.getStr, "ddd MMM dd hh:mm:ss \'+0000\' yyyy", 30)
|
||||
|
||||
proc getTimeFromMs*(js: JsonNode): DateTime =
|
||||
let ms = js.getInt(0)
|
||||
if ms == 0: return
|
||||
let seconds = ms div 1000
|
||||
return fromUnix(seconds).utc()
|
||||
|
||||
proc getTimeFromMsStr*(js: JsonNode): DateTime =
|
||||
var ms: int64
|
||||
try: ms = parseBiggestInt(js.getStr("0"))
|
||||
except ValueError: return
|
||||
if ms == 0: return
|
||||
let seconds = ms div 1000
|
||||
return fromUnix(seconds).utc()
|
||||
|
||||
proc getId*(id: string): int64 {.inline.} =
|
||||
proc getId*(id: string): string {.inline.} =
|
||||
let start = id.rfind("-")
|
||||
if start < 0:
|
||||
return parseBiggestInt(id)
|
||||
return parseBiggestInt(id[start + 1 ..< id.len])
|
||||
if start < 0: return id
|
||||
id[start + 1 ..< id.len]
|
||||
|
||||
proc getId*(js: JsonNode): int64 {.inline.} =
|
||||
case js.kind
|
||||
of JString: return js.getStr("0").getId
|
||||
of JString: return parseBiggestInt(js.getStr("0"))
|
||||
of JInt: return js.getBiggestInt()
|
||||
else: return 0
|
||||
|
||||
proc getEntryId*(js: JsonNode): string {.inline.} =
|
||||
let entry = js{"entryId"}.getStr
|
||||
if entry.len == 0: return
|
||||
|
||||
if "tweet" in entry or "sq-I-t" in entry:
|
||||
return entry.getId
|
||||
elif "tombstone" in entry:
|
||||
return js{"content", "item", "content", "tombstone", "tweet", "id"}.getStr
|
||||
else:
|
||||
echo "unknown entry: ", entry
|
||||
return
|
||||
|
||||
template getStrVal*(js: JsonNode; default=""): string =
|
||||
js{"string_value"}.getStr(default)
|
||||
|
||||
@@ -119,9 +89,6 @@ 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:
|
||||
@@ -187,7 +154,7 @@ proc extractSlice(js: JsonNode): Slice[int] =
|
||||
proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
|
||||
textLen: int; hideTwitter = false) =
|
||||
let
|
||||
url = js.getExpandedUrl
|
||||
url = js["expanded_url"].getStr
|
||||
slice = js.extractSlice
|
||||
|
||||
if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
|
||||
@@ -214,7 +181,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?f=tweets&q=%23" & name)
|
||||
result.add a(symbol & name, href = "/search?q=%23" & name)
|
||||
of rkMention:
|
||||
result.add a($runes[rep.slice], href = rep.url, title = rep.display)
|
||||
of rkUrl:
|
||||
@@ -248,7 +215,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
|
||||
ent = ? js{"entities"}
|
||||
|
||||
with urls, ent{"url", "urls"}:
|
||||
user.website = urls[0].getExpandedUrl
|
||||
user.website = urls[0]{"expanded_url"}.getStr
|
||||
|
||||
var replacements = newSeq[ReplaceSlice]()
|
||||
|
||||
@@ -264,7 +231,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=""; hasQuote=false) =
|
||||
let hasCard = tweet.card.isSome
|
||||
|
||||
var replacements = newSeq[ReplaceSlice]()
|
||||
@@ -275,10 +242,10 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
|
||||
if urlStr.len == 0 or urlStr notin text:
|
||||
continue
|
||||
|
||||
replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink)
|
||||
replacements.extractUrls(u, textSlice.b, hideTwitter = hasQuote)
|
||||
|
||||
if hasCard and u{"url"}.getStr == get(tweet.card).url:
|
||||
get(tweet.card).url = u.getExpandedUrl
|
||||
get(tweet.card).url = u{"expanded_url"}.getStr
|
||||
|
||||
with media, entities{"media"}:
|
||||
for m in media:
|
||||
@@ -315,10 +282,9 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
|
||||
proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
|
||||
let
|
||||
entities = ? js{"entities"}
|
||||
hasQuote = js{"is_quote_status"}.getBool
|
||||
textRange = js{"display_text_range"}
|
||||
textSlice = textRange{0}.getInt .. textRange{1}.getInt
|
||||
hasQuote = js{"is_quote_status"}.getBool
|
||||
hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
|
||||
|
||||
var replyTo = ""
|
||||
if tweet.replyId != 0:
|
||||
@@ -326,94 +292,12 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
|
||||
replyTo = reply.getStr
|
||||
tweet.reply.add replyTo
|
||||
|
||||
tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote or hasJobCard)
|
||||
|
||||
proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int];
|
||||
hasRedundantLink=false) =
|
||||
let hasCard = tweet.card.isSome
|
||||
|
||||
var replacements = newSeq[ReplaceSlice]()
|
||||
|
||||
with urls, js{"url_entities"}:
|
||||
for u in urls:
|
||||
let urlStr = u["url"].getStr
|
||||
if urlStr.len == 0 or urlStr notin text:
|
||||
continue
|
||||
|
||||
replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink)
|
||||
|
||||
if hasCard and u{"url"}.getStr == get(tweet.card).url:
|
||||
get(tweet.card).url = u.getExpandedUrl
|
||||
|
||||
with hashtags, js{"details", "hashtag_entities"}:
|
||||
for hashtag in hashtags:
|
||||
replacements.extractHashtags(hashtag)
|
||||
|
||||
with cashtags, js{"details", "cashtag_entities"}:
|
||||
for cashtag in cashtags:
|
||||
replacements.extractHashtags(cashtag)
|
||||
|
||||
with mentions, js{"mention_entities"}:
|
||||
for mention in mentions:
|
||||
let
|
||||
name = mention{"screen_name"}.getStr
|
||||
slice = mention.extractSlice
|
||||
idx = tweet.reply.find(name)
|
||||
|
||||
if slice.a >= textSlice.a:
|
||||
replacements.add ReplaceSlice(kind: rkMention, slice: slice,
|
||||
url: "/" & name, display: mention["name"].getStr)
|
||||
elif idx == -1 and tweet.replyId != 0:
|
||||
tweet.reply.add name
|
||||
|
||||
replacements.deduplicate
|
||||
replacements.sort(cmp)
|
||||
|
||||
tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false)
|
||||
|
||||
proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode) =
|
||||
let
|
||||
textRange = js{"details", "display_text_range"}
|
||||
textSlice = textRange{0}.getInt .. textRange{1}.getInt
|
||||
hasQuote = "quoted_tweet_results" in js
|
||||
hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
|
||||
|
||||
tweet.expandTextEntitiesV2(js, tweet.text, textSlice, hasQuote or hasJobCard)
|
||||
tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote)
|
||||
|
||||
proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
|
||||
let
|
||||
entities = ? js{"entity_set"}
|
||||
text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose))
|
||||
text = js{"text"}.getStr
|
||||
textSlice = 0..text.runeLen
|
||||
|
||||
tweet.expandTextEntities(entities, text, textSlice)
|
||||
|
||||
tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose))
|
||||
|
||||
proc expandBirdwatchEntities*(text: string; entities: JsonNode): string =
|
||||
let runes = text.toRunes
|
||||
var replacements: seq[ReplaceSlice]
|
||||
|
||||
for entity in entities:
|
||||
let
|
||||
fromIdx = entity{"from_index"}.getInt
|
||||
toIdx = entity{"to_index"}.getInt
|
||||
url = entity{"ref", "url"}.getStr
|
||||
if url.len > 0:
|
||||
replacements.add ReplaceSlice(
|
||||
kind: rkUrl,
|
||||
slice: fromIdx ..< toIdx,
|
||||
url: url,
|
||||
display: $runes[fromIdx ..< min(toIdx, runes.len)]
|
||||
)
|
||||
|
||||
replacements.sort(cmp)
|
||||
result = runes.replacedWith(replacements, 0 ..< runes.len)
|
||||
|
||||
proc extractGalleryPhoto*(t: Tweet): GalleryPhoto =
|
||||
let url =
|
||||
if t.media.len > 0: t.media[0].getThumb
|
||||
elif t.card.isSome: get(t.card).image
|
||||
else: ""
|
||||
|
||||
result = GalleryPhoto(url: url, tweetId: $t.id)
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import tables, strutils
|
||||
import tables
|
||||
import types, prefs_impl
|
||||
from config import get
|
||||
from parsecfg import nil
|
||||
|
||||
export genUpdatePrefs, genResetPrefs, genApplyPrefs
|
||||
export genUpdatePrefs, genResetPrefs
|
||||
|
||||
var defaultPrefs*: Prefs
|
||||
|
||||
proc updateDefaultPrefs*(cfg: parsecfg.Config) =
|
||||
genDefaultPrefs()
|
||||
|
||||
proc getPrefs*(cookies, params: Table[string, string]): Prefs =
|
||||
proc getPrefs*(cookies: Table[string, string]): Prefs =
|
||||
result = defaultPrefs
|
||||
genParsePrefs(cookies)
|
||||
genParsePrefs(params)
|
||||
genCookiePrefs(cookies)
|
||||
|
||||
proc encodePrefs*(prefs: Prefs): string =
|
||||
var encPairs: seq[string]
|
||||
genEncodePrefs(prefs)
|
||||
encPairs.join(",")
|
||||
template getPref*(cookies: Table[string, string], pref): untyped =
|
||||
bind genCookiePref
|
||||
var res = defaultPrefs.`pref`
|
||||
genCookiePref(cookies, pref, res)
|
||||
res
|
||||
|
||||
@@ -60,9 +60,6 @@ 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)"
|
||||
|
||||
@@ -78,15 +75,12 @@ genPrefs:
|
||||
hideReplies(checkbox, false):
|
||||
"Hide tweet replies"
|
||||
|
||||
hideCommunityNotes(checkbox, false):
|
||||
"Hide community notes"
|
||||
|
||||
squareAvatars(checkbox, false):
|
||||
"Square profile pictures"
|
||||
|
||||
Media:
|
||||
mp4Playback(checkbox, true):
|
||||
"Enable mp4 video playback (only for gifs)"
|
||||
"Enable mp4 video playback"
|
||||
|
||||
hlsPlayback(checkbox, false):
|
||||
"Enable HLS video streaming (requires JavaScript)"
|
||||
@@ -100,17 +94,6 @@ 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"
|
||||
@@ -144,7 +127,7 @@ macro genDefaultPrefs*(): untyped =
|
||||
result.add quote do:
|
||||
defaultPrefs.`ident` = cfg.get("Preferences", `name`, `default`)
|
||||
|
||||
macro genParsePrefs*(prefs): untyped =
|
||||
macro genCookiePrefs*(cookies): untyped =
|
||||
result = nnkStmtList.newTree()
|
||||
for pref in allPrefs():
|
||||
let
|
||||
@@ -154,17 +137,37 @@ macro genParsePrefs*(prefs): untyped =
|
||||
options = pref.options
|
||||
|
||||
result.add quote do:
|
||||
if `name` in `prefs`:
|
||||
if `name` in `cookies`:
|
||||
when `kind` == input or `name` == "theme":
|
||||
result.`ident` = `prefs`[`name`]
|
||||
result.`ident` = `cookies`[`name`]
|
||||
elif `kind` == checkbox:
|
||||
result.`ident` = `prefs`[`name`] == "on" or
|
||||
`prefs`[`name`] == "true" or
|
||||
`prefs`[`name`] == "1"
|
||||
result.`ident` = `cookies`[`name`] == "on"
|
||||
else:
|
||||
let value = `prefs`[`name`]
|
||||
let value = `cookies`[`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")
|
||||
@@ -199,36 +202,6 @@ 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,14 +1,15 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat, sequtils, tables, uri
|
||||
|
||||
import types, utils
|
||||
import types
|
||||
|
||||
const
|
||||
validFilters* = @[
|
||||
"media", "images", "twimg", "videos",
|
||||
"native_video", "consumer_video", "spaces",
|
||||
"native_video", "consumer_video", "pro_video",
|
||||
"links", "news", "quote", "mentions",
|
||||
"replies", "retweets", "nativeretweets"
|
||||
"replies", "retweets", "nativeretweets",
|
||||
"verified", "safe"
|
||||
]
|
||||
|
||||
emptyQuery* = "include:nativeretweets"
|
||||
@@ -20,13 +21,12 @@ 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",
|
||||
minLikes: validateNumber(@"min_faves")
|
||||
near: @"near"
|
||||
)
|
||||
|
||||
if name.len > 0:
|
||||
@@ -46,7 +46,7 @@ proc getReplyQuery*(name: string): Query =
|
||||
fromUser: @[name]
|
||||
)
|
||||
|
||||
proc genQueryParam*(query: Query; maxId=""): string =
|
||||
proc genQueryParam*(query: Query): string =
|
||||
var
|
||||
filters: seq[string]
|
||||
param: string
|
||||
@@ -55,20 +55,15 @@ proc genQueryParam*(query: Query; maxId=""): string =
|
||||
return query.text
|
||||
|
||||
for i, user in query.fromUser:
|
||||
if i == 0:
|
||||
param = "("
|
||||
|
||||
param &= &"from:{user}"
|
||||
param &= &"from:{user} "
|
||||
if i < query.fromUser.high:
|
||||
param &= " OR "
|
||||
else:
|
||||
param &= ")"
|
||||
param &= "OR "
|
||||
|
||||
if query.fromUser.len > 0 and query.kind in {posts, media}:
|
||||
param &= " (filter:self_threads OR -filter:replies)"
|
||||
param &= "filter:self_threads OR-filter:replies "
|
||||
|
||||
if "nativeretweets" notin query.excludes:
|
||||
param &= " include:nativeretweets"
|
||||
param &= "include:nativeretweets "
|
||||
|
||||
for f in query.filters:
|
||||
filters.add "filter:" & f
|
||||
@@ -78,49 +73,38 @@ proc genQueryParam*(query: Query; maxId=""): string =
|
||||
for i in query.includes:
|
||||
filters.add "include:" & i
|
||||
|
||||
if filters.len > 0:
|
||||
result = strip(param & " (" & filters.join(&" {query.sep} ") & ")")
|
||||
else:
|
||||
result = strip(param)
|
||||
|
||||
result = strip(param & filters.join(&" {query.sep} "))
|
||||
if query.since.len > 0:
|
||||
result &= " since:" & query.since
|
||||
if query.until.len > 0 and maxId.len == 0:
|
||||
if query.until.len > 0:
|
||||
result &= " until:" & query.until
|
||||
if query.minLikes.len > 0:
|
||||
result &= " min_faves:" & query.minLikes
|
||||
if query.near.len > 0:
|
||||
result &= &" near:\"{query.near}\" within:15mi"
|
||||
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 =
|
||||
var params: seq[string]
|
||||
if query.kind notin {tweets, users}: return
|
||||
|
||||
if query.view.len > 0:
|
||||
params.add "view=" & encodeUrl(query.view)
|
||||
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.kind in {tweets, users}:
|
||||
params.add &"f={query.kind}"
|
||||
if query.text.len > 0:
|
||||
params.add "q=" & encodeUrl(query.text)
|
||||
for f in query.filters:
|
||||
params.add &"f-{f}=on"
|
||||
for e in query.excludes:
|
||||
params.add &"e-{e}=on"
|
||||
for i in query.includes.filterIt(it != "nativeretweets"):
|
||||
params.add &"i-{i}=on"
|
||||
|
||||
if query.since.len > 0:
|
||||
params.add "since=" & query.since
|
||||
if query.until.len > 0:
|
||||
params.add "until=" & query.until
|
||||
if query.minLikes.len > 0:
|
||||
params.add "min_faves=" & query.minLikes
|
||||
if query.since.len > 0:
|
||||
params.add "since=" & query.since
|
||||
if query.until.len > 0:
|
||||
params.add "until=" & query.until
|
||||
if query.near.len > 0:
|
||||
params.add "near=" & query.near
|
||||
|
||||
if params.len > 0:
|
||||
result &= params.join("&")
|
||||
|
||||
@@ -52,7 +52,6 @@ proc initRedisPool*(cfg: Config) {.async.} =
|
||||
await migrate("profileDates", "p:*")
|
||||
await migrate("profileStats", "p:*")
|
||||
await migrate("userType", "p:*")
|
||||
await migrate("verifiedType", "p:*")
|
||||
|
||||
pool.withAcquire(r):
|
||||
# optimize memory usage for user ID buckets
|
||||
@@ -86,7 +85,7 @@ proc cache*(data: List) {.async.} =
|
||||
await setEx(data.listKey, listCacheTime, compress(toFlatty(data)))
|
||||
|
||||
proc cache*(data: PhotoRail; name: string) {.async.} =
|
||||
await setEx("pr2:" & toLower(name), baseCacheTime * 2, compress(toFlatty(data)))
|
||||
await setEx("pr:" & toLower(name), baseCacheTime, compress(toFlatty(data)))
|
||||
|
||||
proc cache*(data: User) {.async.} =
|
||||
if data.username.len == 0: return
|
||||
@@ -148,51 +147,24 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
|
||||
if result.len > 0 and user.id.len > 0:
|
||||
await all(cacheUserId(result, user.id), cache(user))
|
||||
|
||||
# proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
|
||||
# if id == 0: return
|
||||
# let tweet = await get(id.tweetKey)
|
||||
# if tweet != redisNil:
|
||||
# tweet.deserialize(Tweet)
|
||||
# else:
|
||||
# result = await getGraphTweetResult($id)
|
||||
# if not result.isNil:
|
||||
# await cache(result)
|
||||
|
||||
proc cache*(data: Broadcast) {.async.} =
|
||||
if data.id.len == 0: return
|
||||
await setEx("bc:" & data.id, baseCacheTime, compress(toFlatty(data)))
|
||||
|
||||
proc getCachedBroadcast*(id: string): Future[Broadcast] {.async.} =
|
||||
if id.len == 0: return
|
||||
let cached = await get("bc:" & id)
|
||||
if cached != redisNil:
|
||||
cached.deserialize(Broadcast)
|
||||
proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
|
||||
if id == 0: return
|
||||
let tweet = await get(id.tweetKey)
|
||||
if tweet != redisNil:
|
||||
tweet.deserialize(Tweet)
|
||||
else:
|
||||
result = await getBroadcastInfo(id)
|
||||
await cache(result)
|
||||
result.m3u8Url = await fetchBroadcastStream(result.mediaKey)
|
||||
result = await getGraphTweetResult($id)
|
||||
if not result.isNil:
|
||||
await cache(result)
|
||||
|
||||
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))
|
||||
proc getCachedPhotoRail*(name: string): Future[PhotoRail] {.async.} =
|
||||
if name.len == 0: return
|
||||
let rail = await get("pr:" & toLower(name))
|
||||
if rail != redisNil:
|
||||
rail.deserialize(PhotoRail)
|
||||
else:
|
||||
result = await getPhotoRail(id)
|
||||
await cache(result, id)
|
||||
result = await getPhotoRail(name)
|
||||
await cache(result, name)
|
||||
|
||||
proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
|
||||
let list = if id.len == 0: redisNil
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import asyncdispatch, strutils
|
||||
import jester
|
||||
|
||||
import router_utils
|
||||
import ".."/[types, formatters, redis_cache]
|
||||
import ../views/[general, broadcast]
|
||||
import media
|
||||
|
||||
export broadcast
|
||||
|
||||
proc createBroadcastRouter*(cfg: Config) =
|
||||
router broadcastRoute:
|
||||
get "/i/broadcasts/@id":
|
||||
cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
|
||||
var bc: Broadcast
|
||||
try:
|
||||
bc = await getCachedBroadcast(@"id")
|
||||
except:
|
||||
discard
|
||||
|
||||
if bc.id.len == 0:
|
||||
resp Http404, showError("Broadcast not found", cfg)
|
||||
|
||||
let prefs = requestPrefs()
|
||||
resp renderMain(renderBroadcast(bc, prefs, request.path), request, cfg, prefs,
|
||||
bc.title, ogTitle=bc.title)
|
||||
|
||||
get "/i/broadcasts/@id/stream":
|
||||
cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
|
||||
var bc: Broadcast
|
||||
try:
|
||||
bc = await getCachedBroadcast(@"id")
|
||||
except:
|
||||
discard
|
||||
|
||||
if bc.m3u8Url.len == 0:
|
||||
resp Http404
|
||||
|
||||
let manifest = await safeFetch(bc.m3u8Url)
|
||||
if manifest.len == 0:
|
||||
resp Http502
|
||||
|
||||
resp proxifyVideo(manifest, requestPrefs().proxyVideos, bc.m3u8Url), m3u8Mime
|
||||
@@ -1,13 +1,10 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import jester
|
||||
import router_utils
|
||||
import ".."/[auth, types]
|
||||
import ".."/[tokens, types]
|
||||
|
||||
proc createDebugRouter*(cfg: Config) =
|
||||
router debug:
|
||||
get "/.health":
|
||||
respJson getSessionPoolHealth()
|
||||
|
||||
get "/.sessions":
|
||||
get "/.tokens":
|
||||
cond cfg.enableDebug
|
||||
respJson getSessionPoolDebug()
|
||||
respJson getPoolJson()
|
||||
|
||||
@@ -10,22 +10,22 @@ export api, embed, vdom, tweet, general, router_utils
|
||||
proc createEmbedRouter*(cfg: Config) =
|
||||
router embed:
|
||||
get "/i/videos/tweet/@id":
|
||||
let tweet = await getGraphTweetResult(@"id")
|
||||
if tweet == nil or not tweet.hasVideos:
|
||||
let convo = await getTweet(@"id")
|
||||
if convo == nil or convo.tweet == nil or convo.tweet.video.isNone:
|
||||
resp Http404
|
||||
|
||||
resp renderVideoEmbed(tweet, cfg, request)
|
||||
resp renderVideoEmbed(convo.tweet, cfg, request)
|
||||
|
||||
get "/@user/status/@id/embed":
|
||||
let
|
||||
tweet = await getGraphTweetResult(@"id")
|
||||
prefs = requestPrefs()
|
||||
convo = await getTweet(@"id")
|
||||
prefs = cookiePrefs()
|
||||
path = getPath()
|
||||
|
||||
if tweet == nil:
|
||||
if convo == nil or convo.tweet == nil:
|
||||
resp Http404
|
||||
|
||||
resp renderTweetEmbed(tweet, path, prefs, cfg, request)
|
||||
resp renderTweetEmbed(convo.tweet, path, prefs, cfg, request)
|
||||
|
||||
get "/embed/Tweet.html":
|
||||
let id = @"id"
|
||||
|
||||
@@ -13,7 +13,7 @@ template respList*(list, timeline, title, vnode: typed) =
|
||||
|
||||
let
|
||||
html = renderList(vnode, timeline.query, list)
|
||||
rss = if cfg.enableRSSList: &"""/i/lists/{@"id"}/rss""" else: ""
|
||||
rss = &"""/i/lists/{@"id"}/rss"""
|
||||
|
||||
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 = requestPrefs()
|
||||
prefs = cookiePrefs()
|
||||
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 = requestPrefs()
|
||||
prefs = cookiePrefs()
|
||||
list = await getCachedList(id=(@"id"))
|
||||
members = await getGraphListMembers(list, getCursor())
|
||||
respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path))
|
||||
|
||||
@@ -12,7 +12,8 @@ export httpclient, os, strutils, asyncstreams, base64, re
|
||||
|
||||
const
|
||||
m3u8Mime* = "application/vnd.apple.mpegurl"
|
||||
maxAge* = "max-age=604800"
|
||||
mp4Mime* = "video/mp4"
|
||||
maxAge* = "public, max-age=604800, must-revalidate"
|
||||
|
||||
proc safeFetch*(url: string): Future[string] {.async.} =
|
||||
let client = newAsyncHttpClient()
|
||||
@@ -20,59 +21,84 @@ proc safeFetch*(url: string): Future[string] {.async.} =
|
||||
except: discard
|
||||
finally: client.close()
|
||||
|
||||
template respond*(req: asynchttpserver.Request; headers) =
|
||||
var msg = "HTTP/1.1 200 OK\c\L"
|
||||
for k, v in headers:
|
||||
template respond*(req: asynchttpserver.Request; code: HttpCode;
|
||||
headers: seq[(string, string)]) =
|
||||
var msg = "HTTP/1.1 " & $code & "\c\L"
|
||||
for (k, v) in headers:
|
||||
msg.add(k & ": " & v & "\c\L")
|
||||
|
||||
msg.add "\c\L"
|
||||
yield req.client.send(msg)
|
||||
yield req.client.send(msg, flags={})
|
||||
|
||||
proc getContentLength(res: AsyncResponse): string =
|
||||
result = "0"
|
||||
if res.headers.hasKey("content-length"):
|
||||
result = $res.contentLength
|
||||
elif res.headers.hasKey("content-range"):
|
||||
result = res.headers["content-range"]
|
||||
result = result[result.find('/') + 1 .. ^1]
|
||||
if result == "*":
|
||||
result.setLen(0)
|
||||
|
||||
proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
|
||||
result = Http200
|
||||
|
||||
let
|
||||
request = req.getNativeReq()
|
||||
client = newAsyncHttpClient()
|
||||
hashed = $hash(url)
|
||||
|
||||
if request.headers.getOrDefault("If-None-Match") == hashed:
|
||||
return Http304
|
||||
|
||||
let c = newAsyncHttpClient(headers=newHttpHeaders({
|
||||
"accept": "*/*",
|
||||
"range": $req.headers.getOrDefault("range")
|
||||
}))
|
||||
|
||||
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]
|
||||
var res = await c.get(url)
|
||||
if not res.status.startsWith("20"):
|
||||
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]
|
||||
else:
|
||||
""
|
||||
|
||||
let headers = newHttpHeaders({
|
||||
"content-type": res.headers["content-type", 0],
|
||||
"content-length": contentLength,
|
||||
var headers = @{
|
||||
"accept-ranges": "bytes",
|
||||
"content-type": $res.headers.getOrDefault("content-type"),
|
||||
"cache-control": maxAge,
|
||||
"etag": hashed
|
||||
})
|
||||
"age": $res.headers.getOrDefault("age"),
|
||||
"date": $res.headers.getOrDefault("date"),
|
||||
"last-modified": $res.headers.getOrDefault("last-modified")
|
||||
}
|
||||
|
||||
respond(request, headers)
|
||||
var tries = 0
|
||||
while tries <= 10 and res.headers.hasKey("transfer-encoding"):
|
||||
await sleepAsync(100 + tries * 200)
|
||||
res = await c.get(url)
|
||||
tries.inc
|
||||
|
||||
let contentLength = res.getContentLength
|
||||
if contentLength.len > 0:
|
||||
headers.add ("content-length", contentLength)
|
||||
|
||||
if res.headers.hasKey("content-range"):
|
||||
headers.add ("content-range", $res.headers.getOrDefault("content-range"))
|
||||
respond(request, Http206, headers)
|
||||
else:
|
||||
respond(request, Http200, headers)
|
||||
|
||||
var (hasValue, data) = (true, "")
|
||||
while hasValue:
|
||||
(hasValue, data) = await res.bodyStream.read()
|
||||
if hasValue:
|
||||
await request.client.send(data)
|
||||
await request.client.send(data, flags={})
|
||||
data.setLen 0
|
||||
except HttpRequestError, ProtocolError, OSError:
|
||||
echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url]
|
||||
except OSError: discard
|
||||
except ProtocolError, HttpRequestError:
|
||||
result = Http404
|
||||
finally:
|
||||
client.close()
|
||||
c.close()
|
||||
|
||||
template check*(code): untyped =
|
||||
template check*(c): untyped =
|
||||
let code = c
|
||||
if code != Http200:
|
||||
resp code
|
||||
else:
|
||||
@@ -86,11 +112,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 getPicUrl*(req: jester.Request): string =
|
||||
result = decoded(req, 1)
|
||||
if "twimg.com" notin result:
|
||||
result.insert(twimg)
|
||||
if not result.startsWith(https):
|
||||
result.insert(https)
|
||||
|
||||
proc createMediaRouter*(cfg: Config) =
|
||||
router media:
|
||||
@@ -98,38 +125,24 @@ proc createMediaRouter*(cfg: Config) =
|
||||
resp Http404
|
||||
|
||||
get re"^\/pic\/orig\/(enc)?\/?(.+)":
|
||||
var url = decoded(request, 1)
|
||||
cond "/amplify_video/" notin url
|
||||
normalizeImgUrl(url)
|
||||
url.add("?name=orig")
|
||||
|
||||
let uri = parseUri(url)
|
||||
cond isTwitterUrl(uri) == true
|
||||
|
||||
let code = await proxyMedia(request, url)
|
||||
check code
|
||||
let url = getPicUrl(request)
|
||||
cond isTwitterUrl(parseUri(url)) == true
|
||||
check await proxyMedia(request, url & "?name=orig")
|
||||
|
||||
get re"^\/pic\/(enc)?\/?(.+)":
|
||||
var url = decoded(request, 1)
|
||||
cond "/amplify_video/" notin url
|
||||
normalizeImgUrl(url)
|
||||
|
||||
let uri = parseUri(url)
|
||||
cond isTwitterUrl(uri) == true
|
||||
|
||||
let code = await proxyMedia(request, url)
|
||||
check code
|
||||
let url = getPicUrl(request)
|
||||
cond isTwitterUrl(parseUri(url)) == true
|
||||
check await proxyMedia(request, url)
|
||||
|
||||
get re"^\/video\/(enc)?\/?(.+)\/(.+)$":
|
||||
let url = decoded(request, 2)
|
||||
cond "http" in url
|
||||
|
||||
if getHmac(url) != request.matches[1]:
|
||||
resp Http403, showError("Failed to verify signature", cfg)
|
||||
resp showError("Failed to verify signature", cfg)
|
||||
|
||||
if ".mp4" in url or ".ts" in url or ".m4s" in url:
|
||||
let code = await proxyMedia(request, url)
|
||||
check code
|
||||
check await proxyMedia(request, url)
|
||||
|
||||
var content: string
|
||||
if ".vmap" in url:
|
||||
@@ -141,6 +154,6 @@ proc createMediaRouter*(cfg: Config) =
|
||||
|
||||
if ".m3u8" in url:
|
||||
let vid = await safeFetch(url)
|
||||
content = proxifyVideo(vid, requestPrefs().proxyVideos, url)
|
||||
content = proxifyVideo(vid, cookiePref(proxyVideos))
|
||||
|
||||
resp content, m3u8Mime
|
||||
|
||||
@@ -19,10 +19,8 @@ proc createPrefRouter*(cfg: Config) =
|
||||
router preferences:
|
||||
get "/settings":
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
prefsCode = encodePrefs(prefs)
|
||||
prefsUrl = getUrlPrefix(cfg) & "/?prefs=" & prefsCode
|
||||
html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir), prefsUrl)
|
||||
prefs = cookiePrefs()
|
||||
html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir))
|
||||
resp renderMain(html, request, cfg, prefs, "Preferences")
|
||||
|
||||
get "/settings/@i?":
|
||||
|
||||
@@ -18,8 +18,8 @@ proc createResolverRouter*(cfg: Config) =
|
||||
router resolver:
|
||||
get "/cards/@card/@id":
|
||||
let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"]
|
||||
respResolved(await resolve(url, requestPrefs()), "card")
|
||||
respResolved(await resolve(url, cookiePrefs()), "card")
|
||||
|
||||
get "/t.co/@url":
|
||||
let url = "https://t.co/" & @"url"
|
||||
respResolved(await resolve(url, requestPrefs()), "t.co")
|
||||
respResolved(await resolve(url, cookiePrefs()), "t.co")
|
||||
|
||||
@@ -9,13 +9,21 @@ export utils, prefs, types, uri
|
||||
template savePref*(pref, value: string; req: Request; expire=false) =
|
||||
if not expire or pref in cookies(req):
|
||||
setCookie(pref, value, daysForward(when expire: -10 else: 360),
|
||||
httpOnly=true, secure=cfg.useHttps, sameSite=None, path="/")
|
||||
httpOnly=true, secure=cfg.useHttps, sameSite=None)
|
||||
|
||||
template requestPrefs*(): untyped {.dirty.} =
|
||||
getPrefs(cookies(request), params(request))
|
||||
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 showError*(error: string; cfg: Config): string =
|
||||
renderMain(renderError(error), request, cfg, requestPrefs(), "Error")
|
||||
renderMain(renderError(error), request, cfg, themePrefs(), "Error")
|
||||
|
||||
template getPath*(): untyped {.dirty.} =
|
||||
$(parseUri(request.path) ? filterParams(request.params))
|
||||
@@ -35,28 +43,5 @@ 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; prefs: Prefs): Future[Rss] {.async.} =
|
||||
proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.} =
|
||||
var profile: Profile
|
||||
let
|
||||
name = req.params.getOrDefault("name")
|
||||
@@ -23,23 +23,25 @@ proc timelineRss*(req: Request; cfg: Config; query: Query; prefs: Prefs): Future
|
||||
names = getNames(name)
|
||||
|
||||
if names.len == 1:
|
||||
profile = await fetchProfile(after, query, skipRail=true)
|
||||
profile = await fetchProfile(after, query, skipRail=true, skipPinned=true)
|
||||
else:
|
||||
var q = query
|
||||
q.fromUser = names
|
||||
profile.tweets = await getGraphTweetSearch(q, after)
|
||||
# this is kinda dumb
|
||||
profile.user = User(
|
||||
username: name,
|
||||
fullname: names.join(" | "),
|
||||
userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png"
|
||||
profile = Profile(
|
||||
tweets: await getGraphSearch(q, after),
|
||||
# this is kinda dumb
|
||||
user: User(
|
||||
username: name,
|
||||
fullname: names.join(" | "),
|
||||
userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png"
|
||||
)
|
||||
)
|
||||
|
||||
if profile.user.suspended:
|
||||
return Rss(feed: profile.user.username, cursor: "suspended")
|
||||
|
||||
if profile.user.fullname.len > 0:
|
||||
let rss = renderTimelineRss(profile, cfg, prefs, multi=(names.len > 1))
|
||||
let rss = renderTimelineRss(profile, cfg, multi=(names.len > 1))
|
||||
return Rss(feed: rss, cursor: profile.tweets.bottom)
|
||||
|
||||
template respRss*(rss, page) =
|
||||
@@ -60,14 +62,11 @@ template respRss*(rss, page) =
|
||||
proc createRssRouter*(cfg: Config) =
|
||||
router rss:
|
||||
get "/search/rss":
|
||||
if not cfg.enableRSSSearch:
|
||||
resp Http403, showError("RSS feed is disabled", cfg)
|
||||
cond cfg.enableRss
|
||||
if @"q".len > 200:
|
||||
resp Http400, showError("Search input too long.", cfg)
|
||||
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
query = initQuery(params(request))
|
||||
let query = initQuery(params(request))
|
||||
if query.kind != tweets:
|
||||
resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg)
|
||||
|
||||
@@ -79,19 +78,17 @@ proc createRssRouter*(cfg: Config) =
|
||||
if rss.cursor.len > 0:
|
||||
respRss(rss, "Search")
|
||||
|
||||
let tweets = await getGraphTweetSearch(query, cursor)
|
||||
let tweets = await getGraphSearch(query, cursor)
|
||||
rss.cursor = tweets.bottom
|
||||
rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg, prefs)
|
||||
rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg)
|
||||
|
||||
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())
|
||||
|
||||
@@ -99,23 +96,16 @@ proc createRssRouter*(cfg: Config) =
|
||||
if rss.cursor.len > 0:
|
||||
respRss(rss, "User")
|
||||
|
||||
rss = await timelineRss(request, cfg, Query(fromUser: @[name]), prefs)
|
||||
rss = await timelineRss(request, cfg, Query(fromUser: @[name]))
|
||||
|
||||
await cacheRss(key, rss)
|
||||
respRss(rss, "User")
|
||||
|
||||
get "/@name/@tab/rss":
|
||||
cond cfg.enableRss
|
||||
cond '.' notin @"name"
|
||||
cond @"tab" in ["with_replies", "media", "search"]
|
||||
let rssEnabled = case @"tab"
|
||||
of "with_replies": cfg.enableRSSUserReplies
|
||||
of "media": cfg.enableRSSUserMedia
|
||||
of "search": cfg.enableRSSSearch
|
||||
else: false
|
||||
if not rssEnabled:
|
||||
resp Http403, showError("RSS feed is disabled", cfg)
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
name = @"name"
|
||||
tab = @"tab"
|
||||
query =
|
||||
@@ -134,15 +124,14 @@ proc createRssRouter*(cfg: Config) =
|
||||
if rss.cursor.len > 0:
|
||||
respRss(rss, "User")
|
||||
|
||||
rss = await timelineRss(request, cfg, query, prefs)
|
||||
rss = await timelineRss(request, cfg, query)
|
||||
|
||||
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)
|
||||
@@ -158,10 +147,8 @@ proc createRssRouter*(cfg: Config) =
|
||||
redirect(url)
|
||||
|
||||
get "/i/lists/@id/rss":
|
||||
if not cfg.enableRSSList:
|
||||
resp Http403, showError("RSS feed is disabled", cfg)
|
||||
cond cfg.enableRss
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
id = @"id"
|
||||
cursor = getCursor()
|
||||
key = redisKey("lists", id, cursor)
|
||||
@@ -174,7 +161,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, prefs)
|
||||
rss.feed = renderListRss(timeline.content, list, cfg)
|
||||
|
||||
await cacheRss(key, rss)
|
||||
respRss(rss, "List")
|
||||
|
||||
@@ -19,7 +19,7 @@ proc createSearchRouter*(cfg: Config) =
|
||||
resp Http400, showError("Search input too long.", cfg)
|
||||
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
prefs = cookiePrefs()
|
||||
query = initQuery(params(request))
|
||||
title = "Search" & (if q.len > 0: " (" & q & ")" else: "")
|
||||
|
||||
@@ -29,23 +29,23 @@ proc createSearchRouter*(cfg: Config) =
|
||||
redirect("/" & q)
|
||||
var users: Result[User]
|
||||
try:
|
||||
users = await getGraphUserSearch(query, getCursor())
|
||||
users = await getUserSearch(query, getCursor())
|
||||
except InternalError:
|
||||
users = Result[User](beginning: true, query: query)
|
||||
resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title)
|
||||
of tweets:
|
||||
let
|
||||
tweets = await getGraphTweetSearch(query, getCursor())
|
||||
rss = if cfg.enableRSSSearch: "/search/rss?" & genQueryUrl(query) else: ""
|
||||
tweets = await getGraphSearch(query, getCursor())
|
||||
rss = "/search/rss?" & genQueryUrl(query)
|
||||
resp renderMain(renderTweetSearch(tweets, prefs, getPath()),
|
||||
request, cfg, prefs, title, rss=rss)
|
||||
else:
|
||||
resp Http404, showError("Invalid search", cfg)
|
||||
|
||||
get "/hashtag/@hash":
|
||||
redirect("/search?f=tweets&q=" & encodeUrl("#" & @"hash"))
|
||||
redirect("/search?q=" & encodeUrl("#" & @"hash"))
|
||||
|
||||
get "/opensearch":
|
||||
let url = getUrlPrefix(cfg) & "/search?f=tweets&q="
|
||||
let url = getUrlPrefix(cfg) & "/search?q="
|
||||
resp Http200, {"Content-Type": "application/opensearchdescription+xml"},
|
||||
generateOpenSearchXML(cfg.title, cfg.hostname, url)
|
||||
|
||||
@@ -21,16 +21,18 @@ proc createStatusRouter*(cfg: Config) =
|
||||
if id.len > 19 or id.any(c => not c.isDigit):
|
||||
resp Http404, showError("Invalid tweet ID", cfg)
|
||||
|
||||
let prefs = requestPrefs()
|
||||
let prefs = cookiePrefs()
|
||||
|
||||
# used for the infinite scroll feature
|
||||
if @"scroll".len > 0:
|
||||
let replies = await getReplies(id, getCursor())
|
||||
if replies.content.len == 0:
|
||||
resp Http204
|
||||
resp Http404, ""
|
||||
resp $renderReplies(replies, prefs, getPath())
|
||||
|
||||
let conv = await getTweet(id, getCursor())
|
||||
if conv == nil:
|
||||
echo "nil conv"
|
||||
|
||||
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
|
||||
var error = "Tweet not found"
|
||||
@@ -44,19 +46,15 @@ proc createStatusRouter*(cfg: Config) =
|
||||
desc = conv.tweet.text
|
||||
|
||||
var
|
||||
images = conv.tweet.getPhotos.mapIt(it.url)
|
||||
images = conv.tweet.photos
|
||||
video = ""
|
||||
|
||||
let
|
||||
firstMediaKind = if conv.tweet.media.len > 0: conv.tweet.media[0].kind
|
||||
else: photoMedia
|
||||
|
||||
if firstMediaKind == videoMedia:
|
||||
images = @[conv.tweet.media[0].getThumb]
|
||||
if conv.tweet.video.isSome():
|
||||
images = @[get(conv.tweet.video).thumb]
|
||||
video = getVideoEmbed(cfg, conv.tweet.id)
|
||||
elif firstMediaKind == gifMedia:
|
||||
images = @[conv.tweet.media[0].getThumb]
|
||||
video = getPicUrl(conv.tweet.media[0].gif.url)
|
||||
elif conv.tweet.gif.isSome():
|
||||
images = @[get(conv.tweet.gif).thumb]
|
||||
video = getPicUrl(get(conv.tweet.gif).url)
|
||||
elif conv.tweet.card.isSome():
|
||||
let card = conv.tweet.card.get()
|
||||
if card.image.len > 0:
|
||||
@@ -68,26 +66,6 @@ proc createStatusRouter*(cfg: Config) =
|
||||
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
|
||||
images=images, video=video)
|
||||
|
||||
get "/@name/status/@id/history/?":
|
||||
cond '.' notin @"name"
|
||||
let id = @"id"
|
||||
|
||||
if id.len > 19 or id.any(c => not c.isDigit):
|
||||
resp Http404, showError("Invalid tweet ID", cfg)
|
||||
|
||||
let edits = await getGraphEditHistory(id)
|
||||
if edits.latest == nil or edits.latest.id == 0:
|
||||
resp Http404, showError("Tweet history not found", cfg)
|
||||
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
title = "History for " & pageTitle(edits.latest)
|
||||
ogTitle = "Edit History for " & pageTitle(edits.latest.user)
|
||||
desc = edits.latest.text
|
||||
|
||||
let html = renderEditHistory(edits, prefs, getPath())
|
||||
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle)
|
||||
|
||||
get "/@name/@s/@id/@m/?@i?":
|
||||
cond @"s" in ["status", "statuses"]
|
||||
cond @"m" in ["video", "photo"]
|
||||
@@ -98,6 +76,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,28 +4,20 @@ import jester, karax/vdom
|
||||
|
||||
import router_utils
|
||||
import ".."/[types, redis_cache, formatters, query, api]
|
||||
import ../views/[general, profile, timeline, status, search, about_account]
|
||||
import ../views/[general, profile, timeline, status, search]
|
||||
|
||||
export vdom
|
||||
export uri, sequtils
|
||||
export router_utils
|
||||
export redis_cache, formatters, query, api
|
||||
export profile, timeline, status, about_account
|
||||
export profile, timeline, status
|
||||
|
||||
proc getQuery*(request: Request; tab, name: string; prefs: Prefs): Query =
|
||||
let view = request.params.getOrDefault("view")
|
||||
proc getQuery*(request: Request; tab, name: string): Query =
|
||||
case tab
|
||||
of "with_replies":
|
||||
result = getReplyQuery(name)
|
||||
of "media":
|
||||
result = getMediaQuery(name)
|
||||
result.view =
|
||||
if view in ["timeline", "grid", "gallery"]: view
|
||||
else: prefs.mediaView.toLowerAscii
|
||||
of "search":
|
||||
result = initQuery(params(request), name=name)
|
||||
else:
|
||||
result = Query(fromUser: @[name])
|
||||
of "with_replies": getReplyQuery(name)
|
||||
of "media": getMediaQuery(name)
|
||||
of "search": initQuery(params(request), name=name)
|
||||
else: Query(fromUser: @[name])
|
||||
|
||||
template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
|
||||
if cond:
|
||||
@@ -35,7 +27,8 @@ template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
|
||||
else:
|
||||
body
|
||||
|
||||
proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile] {.async.} =
|
||||
proc fetchProfile*(after: string; query: Query; skipRail=false;
|
||||
skipPinned=false): Future[Profile] {.async.} =
|
||||
let
|
||||
name = query.fromUser[0]
|
||||
userId = await getUserId(name)
|
||||
@@ -52,23 +45,37 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile]
|
||||
after.setLen 0
|
||||
|
||||
let
|
||||
timeline =
|
||||
case query.kind
|
||||
of posts: getGraphUserTweets(userId, TimelineKind.tweets, after)
|
||||
of replies: getGraphUserTweets(userId, TimelineKind.replies, after)
|
||||
of media: getGraphUserTweets(userId, TimelineKind.media, after)
|
||||
else: getGraphSearch(query, after)
|
||||
|
||||
rail =
|
||||
skipIf(skipRail or query.kind == media, @[]):
|
||||
getCachedPhotoRail(userId)
|
||||
getCachedPhotoRail(name)
|
||||
|
||||
user = getCachedUser(name)
|
||||
info = getCachedAccountInfo(name, fetch=false)
|
||||
user = await getCachedUser(name)
|
||||
|
||||
result =
|
||||
case query.kind
|
||||
of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after)
|
||||
of replies: await getGraphUserTweets(userId, TimelineKind.replies, after)
|
||||
of media: await getGraphUserTweets(userId, TimelineKind.media, after)
|
||||
else: Profile(tweets: await getGraphTweetSearch(query, after))
|
||||
var pinned: Option[Tweet]
|
||||
if not skipPinned and user.pinnedTweet > 0 and
|
||||
after.len == 0 and query.kind in {posts, replies}:
|
||||
let tweet = await getCachedTweet(user.pinnedTweet)
|
||||
if not tweet.isNil:
|
||||
tweet.pinned = true
|
||||
tweet.user = user
|
||||
pinned = some tweet
|
||||
|
||||
result.user = await user
|
||||
result.photoRail = await rail
|
||||
result.accountInfo = await info
|
||||
result = Profile(
|
||||
user: user,
|
||||
pinned: pinned,
|
||||
tweets: await timeline,
|
||||
photoRail: await rail
|
||||
)
|
||||
|
||||
if result.user.protected or result.user.suspended:
|
||||
return
|
||||
|
||||
result.tweets.query = query
|
||||
|
||||
@@ -76,11 +83,11 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
|
||||
rss, after: string): Future[string] {.async.} =
|
||||
if query.fromUser.len != 1:
|
||||
let
|
||||
timeline = await getGraphTweetSearch(query, after)
|
||||
timeline = await getGraphSearch(query, after)
|
||||
html = renderTweetSearch(timeline, prefs, getPath())
|
||||
return renderMain(html, request, cfg, prefs, "Multi", rss=rss)
|
||||
|
||||
var profile = await fetchProfile(after, query)
|
||||
var profile = await fetchProfile(after, query, skipPinned=prefs.hidePins)
|
||||
template u: untyped = profile.user
|
||||
|
||||
if u.suspended:
|
||||
@@ -115,46 +122,24 @@ proc createTimelineRouter*(cfg: Config) =
|
||||
get "/intent/user":
|
||||
respUserId()
|
||||
|
||||
get "/intent/follow/?":
|
||||
let username = request.params.getOrDefault("screen_name")
|
||||
if username.len == 0:
|
||||
resp Http400, showError("Missing screen_name parameter", cfg)
|
||||
redirect("/" & username)
|
||||
|
||||
get "/@name/about/?":
|
||||
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'})
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
name = @"name"
|
||||
info = await getCachedAccountInfo(name)
|
||||
if info.suspended:
|
||||
resp showError(getSuspended(name), cfg)
|
||||
if info.username.len == 0:
|
||||
resp Http404, showError("User \"" & name & "\" not found", cfg)
|
||||
let aboutHtml = renderAboutAccount(info)
|
||||
resp renderMain(aboutHtml, request, cfg, prefs,
|
||||
"About @" & info.username)
|
||||
|
||||
get "/@name/?@tab?/?":
|
||||
cond '.' notin @"name"
|
||||
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
|
||||
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','})
|
||||
cond @"tab" in ["with_replies", "media", "search", ""]
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
prefs = cookiePrefs()
|
||||
after = getCursor()
|
||||
names = getNames(@"name")
|
||||
|
||||
var query = request.getQuery(@"tab", @"name", prefs)
|
||||
var query = request.getQuery(@"tab", @"name")
|
||||
if names.len != 1:
|
||||
query.fromUser = names
|
||||
|
||||
# used for the infinite scroll feature
|
||||
if @"scroll".len > 0:
|
||||
if query.fromUser.len != 1:
|
||||
var timeline = await getGraphTweetSearch(query, after)
|
||||
if timeline.content.len == 0:
|
||||
resp Http204
|
||||
var timeline = await getGraphSearch(query, after)
|
||||
if timeline.content.len == 0: resp Http404
|
||||
timeline.beginning = true
|
||||
resp $renderTweetSearch(timeline, prefs, getPath())
|
||||
else:
|
||||
@@ -163,17 +148,8 @@ proc createTimelineRouter*(cfg: Config) =
|
||||
profile.tweets.beginning = true
|
||||
resp $renderTimelineTweets(profile.tweets, prefs, getPath())
|
||||
|
||||
let rssEnabled =
|
||||
if @"tab".len == 0: cfg.enableRSSUserTweets
|
||||
elif @"tab" == "with_replies": cfg.enableRSSUserReplies
|
||||
elif @"tab" == "media": cfg.enableRSSUserMedia
|
||||
elif @"tab" == "search": cfg.enableRSSSearch
|
||||
else: false
|
||||
|
||||
let rss =
|
||||
if not rssEnabled:
|
||||
""
|
||||
elif @"tab".len == 0:
|
||||
if @"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, requestPrefs())
|
||||
resp renderMain(renderFeature(), request, cfg, themePrefs())
|
||||
|
||||
get "/about/feature": feature()
|
||||
get "/login/?@i?": feature()
|
||||
get "/@name/lists/?": feature()
|
||||
|
||||
get "/intent/?@i?":
|
||||
cond @"i" notin ["user", "follow"]
|
||||
cond @"i" notin ["user"]
|
||||
feature()
|
||||
|
||||
get "/i/@i?/?@j?":
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
.broadcast-page {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
margin: 20px auto 0;
|
||||
}
|
||||
|
||||
.broadcast-panel {
|
||||
background-color: var(--bg_panel);
|
||||
border: 1px solid var(--border_grey);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.broadcast-player {
|
||||
position: relative;
|
||||
background: black;
|
||||
|
||||
video,
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.broadcast-info {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.broadcast-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.broadcast-user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.broadcast-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--fg_color);
|
||||
|
||||
img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.broadcast-username {
|
||||
color: var(--fg_dark);
|
||||
}
|
||||
|
||||
.broadcast-meta {
|
||||
color: var(--fg_faded);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
.broadcast-live {
|
||||
background: #e0245e;
|
||||
color: white;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -1,40 +1,39 @@
|
||||
@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;
|
||||
padding: 0px 5px 1px 8px;
|
||||
}
|
||||
button {
|
||||
background: var(--bg_elements);
|
||||
color: var(--fg_color);
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
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,7 +66,18 @@
|
||||
}
|
||||
|
||||
#search-panel-toggle:checked ~ .search-panel {
|
||||
max-height: 380px !important;
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,44 @@
|
||||
// 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;
|
||||
$icon_text: $fg_color;
|
||||
|
||||
$tab: $fg_color;
|
||||
$tab_selected: $accent;
|
||||
|
||||
$shadow: rgba(0, 0, 0, 0.6);
|
||||
$shadow_dark: rgba(0, 0, 0, 0.2);
|
||||
$shadow: rgba(0,0,0,.6);
|
||||
$shadow_dark: rgba(0,0,0,.2);
|
||||
|
||||
//fonts
|
||||
$font_0: sans-serif;
|
||||
$font_1: fontello;
|
||||
$font_0: Helvetica Neue;
|
||||
$font_1: Helvetica;
|
||||
$font_2: Arial;
|
||||
$font_3: sans-serif;
|
||||
$font_4: fontello;
|
||||
|
||||
@@ -1,217 +1,165 @@
|
||||
@import "_variables";
|
||||
@import '_variables';
|
||||
|
||||
@import "tweet/_base";
|
||||
@import "profile/_base";
|
||||
@import "general";
|
||||
@import "navbar";
|
||||
@import "inputs";
|
||||
@import "timeline";
|
||||
@import "search";
|
||||
@import "broadcast";
|
||||
@import 'tweet/_base';
|
||||
@import 'profile/_base';
|
||||
@import 'general';
|
||||
@import 'navbar';
|
||||
@import 'inputs';
|
||||
@import 'timeline';
|
||||
@import 'search';
|
||||
|
||||
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};
|
||||
--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-size: 15px;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
* {
|
||||
outline: unset;
|
||||
margin: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
img {
|
||||
dynamic-range-limit: standard;
|
||||
outline: unset;
|
||||
margin: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
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: 0.6em 0 0.3em 0;
|
||||
border: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--border_grey);
|
||||
margin-bottom: 8px;
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
margin: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body.fixed-nav .container {
|
||||
padding-top: 50px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
box-sizing: border-box;
|
||||
padding-top: 50px;
|
||||
margin: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-bottom: 2px;
|
||||
|
||||
.verified-icon-circle {
|
||||
position: absolute;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.verified-icon-check {
|
||||
position: absolute;
|
||||
font-size: 9px;
|
||||
margin: 5px 3px;
|
||||
}
|
||||
|
||||
&.blue {
|
||||
.verified-icon-circle {
|
||||
color: var(--verified_blue);
|
||||
}
|
||||
|
||||
.verified-icon-check {
|
||||
color: var(--icon_text);
|
||||
}
|
||||
}
|
||||
|
||||
&.business {
|
||||
.verified-icon-circle {
|
||||
color: var(--verified_business);
|
||||
}
|
||||
|
||||
.verified-icon-check {
|
||||
color: var(--bg_panel);
|
||||
}
|
||||
}
|
||||
|
||||
&.government {
|
||||
.verified-icon-circle {
|
||||
color: var(--verified_government);
|
||||
}
|
||||
|
||||
.verified-icon-check {
|
||||
color: var(--bg_panel);
|
||||
}
|
||||
}
|
||||
color: var(--icon_text);
|
||||
background-color: var(--verified_blue);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin: 2px 0 3px 3px;
|
||||
padding-top: 2px;
|
||||
height: 12px;
|
||||
width: 14px;
|
||||
font-size: 8px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@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,216 +1,185 @@
|
||||
@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="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"] {
|
||||
height: 16px;
|
||||
input[type="text"] {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
display: none;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-container {
|
||||
display: block;
|
||||
position: relative;
|
||||
margin-bottom: 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding-right: 22px;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
display: block;
|
||||
position: relative;
|
||||
margin-bottom: 5px;
|
||||
cursor: pointer;
|
||||
height: 0;
|
||||
width: 0;
|
||||
user-select: none;
|
||||
padding-right: 22px;
|
||||
|
||||
&:checked ~ .checkbox:after {
|
||||
display: block;
|
||||
input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
height: 0;
|
||||
width: 0;
|
||||
|
||||
&: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_1;
|
||||
content: "\e811";
|
||||
}
|
||||
.checkbox:after {
|
||||
left: 2px;
|
||||
bottom: 0;
|
||||
font-size: 13px;
|
||||
font-family: $font_4;
|
||||
content: '\e803';
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
min-width: 100px;
|
||||
}
|
||||
select {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: block;
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"] {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
max-width: 140px;
|
||||
}
|
||||
input[type="text"] {
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.pref-reset {
|
||||
float: left;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +1,88 @@
|
||||
@import "_variables";
|
||||
@import '_variables';
|
||||
|
||||
nav {
|
||||
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);
|
||||
}
|
||||
|
||||
body.fixed-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;
|
||||
|
||||
a, .icon-button button {
|
||||
color: var(--fg_nav);
|
||||
}
|
||||
}
|
||||
|
||||
.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:hover {
|
||||
color: var(--accent_light);
|
||||
text-decoration: unset;
|
||||
}
|
||||
&.right a {
|
||||
padding-left: 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent_light);
|
||||
text-decoration: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lp {
|
||||
height: 14px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
fill: var(--fg_nav);
|
||||
height: 14px;
|
||||
margin-top: 2px;
|
||||
display: block;
|
||||
fill: var(--fg_nav);
|
||||
|
||||
&:hover {
|
||||
fill: var(--accent_light);
|
||||
}
|
||||
&:hover {
|
||||
fill: var(--accent_light);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-info {
|
||||
margin: 0 -3px;
|
||||
.icon-info:before {
|
||||
margin: 0 -3px;
|
||||
}
|
||||
|
||||
.icon-cog {
|
||||
font-size: 15px;
|
||||
padding-left: 0 !important;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@@ -1,117 +1,83 @@
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
|
||||
@import "card";
|
||||
@import "about-account";
|
||||
@import "photo-rail";
|
||||
@import 'card';
|
||||
@import 'photo-rail';
|
||||
|
||||
.profile-tabs {
|
||||
@include panel(auto, 900px);
|
||||
@include panel(auto, 900px);
|
||||
|
||||
.timeline-container {
|
||||
float: right;
|
||||
width: 68% !important;
|
||||
max-width: unset;
|
||||
}
|
||||
.timeline-container {
|
||||
float: right;
|
||||
width: 68% !important;
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-banner {
|
||||
margin-bottom: 4px;
|
||||
background-color: var(--bg_panel);
|
||||
margin-bottom: 4px;
|
||||
background-color: var(--bg_panel);
|
||||
|
||||
a {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding: 33.34% 0 0 0;
|
||||
}
|
||||
a {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding: 33.34% 0 0 0;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-tab {
|
||||
padding: 0 4px 0 0;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
max-width: 32%;
|
||||
top: 0;
|
||||
|
||||
body.fixed-nav & {
|
||||
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: 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;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-tabs.media-only {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
@media(max-width: 700px) {
|
||||
.profile-tabs {
|
||||
width: 100vw;
|
||||
max-width: 600px;
|
||||
|
||||
.timeline-container {
|
||||
float: none;
|
||||
width: 100% !important;
|
||||
max-width: none;
|
||||
padding: 0 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.timeline-container {
|
||||
width: 100% !important;
|
||||
|
||||
.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;
|
||||
}
|
||||
.tab-item wide {
|
||||
flex-grow: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
@import '_variables';
|
||||
|
||||
.about-account {
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
margin: 20px auto 0;
|
||||
align-self: flex-start;
|
||||
background: var(--bg_panel);
|
||||
border-radius: 4px;
|
||||
padding: 12px 20px 20px;
|
||||
}
|
||||
|
||||
.about-account-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--border_grey);
|
||||
}
|
||||
|
||||
.about-account-avatar img {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.about-account-name {
|
||||
@include breakable;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.about-account-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.about-account-at {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.about-account-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
> span:first-child {
|
||||
color: var(--fg_faded);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.about-account-label {
|
||||
color: var(--fg_faded);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media(max-width: 700px) {
|
||||
.about-account {
|
||||
max-width: none;
|
||||
margin: 10px;
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,7 @@
|
||||
}
|
||||
|
||||
.profile-card-tabs-name {
|
||||
flex-shrink: 100;
|
||||
@include breakable;
|
||||
}
|
||||
|
||||
.profile-card-avatar {
|
||||
|
||||
@@ -1,120 +1,120 @@
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
|
||||
.search-title {
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
button {
|
||||
margin: 0 2px 0 0;
|
||||
padding: 0px 1px 1px 4px;
|
||||
height: 23px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
flex-wrap: wrap;
|
||||
|
||||
.pref-input {
|
||||
margin: 0 4px 0 0;
|
||||
flex-grow: 1;
|
||||
height: 23px;
|
||||
}
|
||||
button {
|
||||
margin: 0 2px 0 0;
|
||||
height: 23px;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"] {
|
||||
height: calc(100% - 4px);
|
||||
width: calc(100% - 8px);
|
||||
}
|
||||
.pref-input {
|
||||
margin: 0 4px 0 0;
|
||||
flex-grow: 1;
|
||||
height: 23px;
|
||||
}
|
||||
|
||||
> 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;
|
||||
input[type="text"] {
|
||||
height: calc(100% - 4px);
|
||||
width: calc(100% - 8px);
|
||||
}
|
||||
|
||||
@include input-colors;
|
||||
}
|
||||
> 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 create-toggle(search-panel, 380px);
|
||||
@include input-colors;
|
||||
}
|
||||
|
||||
@include create-toggle(search-panel, 200px);
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
.checkbox-container {
|
||||
display: inline;
|
||||
padding-right: unset;
|
||||
margin-bottom: 5px;
|
||||
margin-left: 23px;
|
||||
}
|
||||
> div {
|
||||
line-height: 1.7em;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
right: unset;
|
||||
left: -22px;
|
||||
line-height: 1.6em;
|
||||
}
|
||||
.checkbox-container {
|
||||
display: inline;
|
||||
padding-right: unset;
|
||||
margin-bottom: unset;
|
||||
margin-left: 23px;
|
||||
}
|
||||
|
||||
.checkbox-container .checkbox:after {
|
||||
top: -4px;
|
||||
}
|
||||
.checkbox {
|
||||
right: unset;
|
||||
left: -22px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
input {
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.pref-input {
|
||||
display: block;
|
||||
padding-bottom: 5px;
|
||||
> div {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
input {
|
||||
height: 21px;
|
||||
margin-top: 1px;
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.pref-input {
|
||||
display: block;
|
||||
padding-bottom: 5px;
|
||||
|
||||
input {
|
||||
height: 21px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-toggles {
|
||||
flex-grow: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, auto);
|
||||
grid-column-gap: 10px;
|
||||
flex-grow: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, auto);
|
||||
grid-column-gap: 10px;
|
||||
}
|
||||
|
||||
.profile-tabs {
|
||||
@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(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(700px, 5);
|
||||
@include search-resize(485px, 4);
|
||||
@include search-resize(560px, 5);
|
||||
@include search-resize(480px, 4);
|
||||
@include search-resize(410px, 3);
|
||||
|
||||
@@ -1,485 +1,162 @@
|
||||
@import "_variables";
|
||||
@import '_variables';
|
||||
|
||||
.timeline-container {
|
||||
@include panel(100%, 600px);
|
||||
@include panel(100%, 600px);
|
||||
}
|
||||
|
||||
.timeline > div:not(:first-child) {
|
||||
border-top: 1px solid var(--border_grey);
|
||||
.timeline {
|
||||
background-color: var(--bg_panel);
|
||||
|
||||
> 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: 4px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
background-color: var(--bg_panel);
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
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 4px 0;
|
||||
background-color: var(--bg_panel);
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
list-style: none;
|
||||
margin: 0 0 5px 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: 0.1rem solid transparent;
|
||||
color: var(--tab);
|
||||
display: block;
|
||||
padding: 8px 0;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
a {
|
||||
border-bottom: .1rem solid transparent;
|
||||
color: var(--tab);
|
||||
display: block;
|
||||
padding: 8px 0;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-bottom-color: var(--tab_selected);
|
||||
color: var(--tab_selected);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-bottom-color: var(--tab_selected);
|
||||
color: var(--tab_selected);
|
||||
&.active a {
|
||||
border-bottom-color: var(--tab_selected);
|
||||
color: var(--tab_selected);
|
||||
}
|
||||
}
|
||||
|
||||
&.active a {
|
||||
border-bottom-color: var(--tab_selected);
|
||||
color: var(--tab_selected);
|
||||
}
|
||||
|
||||
&.wide {
|
||||
flex-grow: 1.2;
|
||||
flex-basis: 50px;
|
||||
}
|
||||
&.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;
|
||||
}
|
||||
h2 {
|
||||
color: var(--accent);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-none {
|
||||
color: var(--accent);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
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: 0.75em 0;
|
||||
display: block !important;
|
||||
background-color: var(--bg_panel);
|
||||
text-align: center;
|
||||
padding: .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);
|
||||
&:hover {
|
||||
color: var(--accent_light);
|
||||
}
|
||||
|
||||
&::before {
|
||||
transform: rotate(180deg) translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
transform: rotate(180deg) translateY(-1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
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;
|
||||
overflow-wrap: break-word;
|
||||
border-left-width: 0;
|
||||
min-width: 0;
|
||||
padding: .75em;
|
||||
display: flex;
|
||||
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,311 +1,240 @@
|
||||
@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 {
|
||||
line-height: 1.3em;
|
||||
pointer-events: all;
|
||||
display: inline;
|
||||
font-family: $font_3;
|
||||
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: 0.2em;
|
||||
padding: 0;
|
||||
vertical-align: bottom;
|
||||
flex-basis: 100%;
|
||||
margin-bottom: .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;
|
||||
|
||||
.verified-icon {
|
||||
margin-left: 2px;
|
||||
}
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.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: 0.4em;
|
||||
word-wrap: normal;
|
||||
@include ellipsis;
|
||||
min-width: 1.6em;
|
||||
margin-left: .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-top: 6px;
|
||||
margin-bottom: 0px;
|
||||
color: var(--grey);
|
||||
pointer-events: all;
|
||||
margin: 0;
|
||||
margin-top: 5px;
|
||||
color: var(--grey);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.tweet-avatar {
|
||||
display: contents !important;
|
||||
display: contents !important;
|
||||
|
||||
img {
|
||||
float: left;
|
||||
margin-top: 3px;
|
||||
margin-left: -58px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
img {
|
||||
float: left;
|
||||
margin-top: 3px;
|
||||
margin-left: -58px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
&.round {
|
||||
border-radius: 50%;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
&.mini {
|
||||
position: unset;
|
||||
margin-right: 5px;
|
||||
margin-top: -1px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
&.round {
|
||||
border-radius: 50%;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
&.mini {
|
||||
position: unset;
|
||||
margin-right: 5px;
|
||||
margin-top: -1px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.tweet-embed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background-color: var(--bg_panel);
|
||||
|
||||
.tweet-content {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.tweet-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 0.75em * 2);
|
||||
}
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background-color: var(--bg_panel);
|
||||
|
||||
.card-image img {
|
||||
height: auto;
|
||||
}
|
||||
.tweet-content {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.tweet-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 0.75em * 2);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: absolute;
|
||||
}
|
||||
.card-image img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
.attribution {
|
||||
display: flex;
|
||||
pointer-events: all;
|
||||
margin: 5px 0;
|
||||
display: flex;
|
||||
pointer-events: all;
|
||||
margin: 5px 0;
|
||||
|
||||
strong {
|
||||
color: var(--fg_color);
|
||||
}
|
||||
strong {
|
||||
color: var(--fg_color);
|
||||
}
|
||||
}
|
||||
|
||||
.media-tag-block {
|
||||
padding-top: 5px;
|
||||
pointer-events: all;
|
||||
color: var(--fg_faded);
|
||||
|
||||
.icon-container {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.media-tag,
|
||||
.icon-container {
|
||||
padding-top: 5px;
|
||||
pointer-events: all;
|
||||
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;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
margin-bottom: -3px;
|
||||
-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;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
-webkit-user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg_hover);
|
||||
}
|
||||
}
|
||||
|
||||
.latest-post-version {
|
||||
border-bottom: 1px solid var(--dark_grey);
|
||||
border-top: 1px solid var(--dark_grey);
|
||||
padding: 01ch 0px;
|
||||
margin: 1ch 0px;
|
||||
color: var(--grey);
|
||||
|
||||
a {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
.community-note {
|
||||
background-color: var(--bg_elements);
|
||||
margin-top: 10px;
|
||||
border: solid 1px var(--dark_grey);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
pointer-events: all;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg_panel);
|
||||
border-color: var(--grey);
|
||||
}
|
||||
}
|
||||
|
||||
.community-note-header {
|
||||
background-color: var(--bg_hover);
|
||||
font-weight: 700;
|
||||
padding: 8px 10px;
|
||||
padding-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
.icon-container {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.community-note-text {
|
||||
white-space: pre-line;
|
||||
padding: 10px 10px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.disclosures {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--grey);
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
margin-bottom: -2px;
|
||||
|
||||
.icon-attention {
|
||||
margin-right: -3px;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--bg_hover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,119 +1,118 @@
|
||||
@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: 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;
|
||||
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;
|
||||
|
||||
&: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;
|
||||
}
|
||||
|
||||
.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-image-container {
|
||||
width: unset;
|
||||
|
||||
&:before {
|
||||
display: none;
|
||||
.card-container {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.card-image {
|
||||
position: unset;
|
||||
border-style: solid;
|
||||
border-color: var(--dark_grey);
|
||||
border-width: 0;
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
.card-image-container {
|
||||
width: unset;
|
||||
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.card-image {
|
||||
position: unset;
|
||||
border-style: solid;
|
||||
border-color: var(--dark_grey);
|
||||
border-width: 0;
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
|
||||
.embed-video {
|
||||
.gallery-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background-color: black;
|
||||
top: 0%;
|
||||
left: 0%;
|
||||
}
|
||||
.gallery-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background-color: black;
|
||||
top: 0%;
|
||||
left: 0%;
|
||||
}
|
||||
|
||||
.gallery-video > .attachment {
|
||||
max-height: unset;
|
||||
}
|
||||
.video-container {
|
||||
max-height: unset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,165 +1,119 @@
|
||||
@import "_variables";
|
||||
@import '_variables';
|
||||
|
||||
.gallery-row {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
|
||||
.still-image {
|
||||
display: flex;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.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: 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;
|
||||
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%;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment {
|
||||
position: relative;
|
||||
line-height: 0;
|
||||
overflow: hidden;
|
||||
margin: 0 0.25em 0 0;
|
||||
flex-grow: 1;
|
||||
box-sizing: border-box;
|
||||
min-width: 2em;
|
||||
position: relative;
|
||||
line-height: 0;
|
||||
overflow: hidden;
|
||||
margin: 0 .25em 0 0;
|
||||
flex-grow: 1;
|
||||
box-sizing: border-box;
|
||||
min-width: 2em;
|
||||
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
max-height: 530px;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-gif video {
|
||||
max-height: 530px;
|
||||
}
|
||||
}
|
||||
|
||||
.media-gif {
|
||||
display: table;
|
||||
background-color: unset;
|
||||
width: unset;
|
||||
max-height: unset;
|
||||
}
|
||||
|
||||
.media-gif video {
|
||||
max-height: 530px;
|
||||
background-color: #101010;
|
||||
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;
|
||||
}
|
||||
max-width: 533px;
|
||||
justify-content: center;
|
||||
|
||||
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);
|
||||
.image {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
// .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;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
.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,120 +1,94 @@
|
||||
@import "_variables";
|
||||
@import '_variables';
|
||||
|
||||
.quote {
|
||||
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 {
|
||||
margin-top: 10px;
|
||||
border: solid 1px var(--dark_grey);
|
||||
border-radius: 10px;
|
||||
background-color: var(--bg_elements);
|
||||
overflow: hidden;
|
||||
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;
|
||||
pointer-events: all;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
border-top-color: var(--grey);
|
||||
border-color: var(--grey);
|
||||
}
|
||||
|
||||
.community-note-header {
|
||||
background-color: var(--bg_panel);
|
||||
padding-bottom: 0;
|
||||
&.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.unavailable-quote {
|
||||
padding: 12px;
|
||||
display: block;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.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;
|
||||
display: flex;
|
||||
|
||||
.card {
|
||||
margin: unset;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.media-gif {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.media-gif > .attachment {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: var(--bg_color);
|
||||
|
||||
video {
|
||||
height: unset;
|
||||
width: unset;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-row .attachment,
|
||||
.gallery-row .attachment > video,
|
||||
.gallery-row .attachment > img {
|
||||
max-height: 300px;
|
||||
}
|
||||
display: flex;
|
||||
|
||||
.still-image img {
|
||||
max-height: 250px;
|
||||
}
|
||||
.card {
|
||||
margin: unset;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,154 +1,112 @@
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
|
||||
.conversation,
|
||||
.edit-history {
|
||||
@include panel(100%, 600px);
|
||||
.conversation {
|
||||
@include panel(100%, 600px);
|
||||
|
||||
.show-more {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.show-more {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.main-thread,
|
||||
.latest-edit {
|
||||
margin-bottom: 20px;
|
||||
.main-thread {
|
||||
margin-bottom: 20px;
|
||||
background-color: var(--bg_panel);
|
||||
}
|
||||
|
||||
.reply {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.main-tweet,
|
||||
.replies,
|
||||
.edit-history > div {
|
||||
body.fixed-nav & {
|
||||
.main-tweet, .replies {
|
||||
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;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.main-tweet .tweet-content {
|
||||
font-size: 16px;
|
||||
}
|
||||
@media(max-width: 600px) {
|
||||
.main-tweet .tweet-content {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.reply {
|
||||
background-color: var(--bg_panel);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.timeline-item.thread.more-replies-thread {
|
||||
padding: 0 0.75em;
|
||||
|
||||
&::before {
|
||||
top: 40px;
|
||||
margin-bottom: 31px;
|
||||
}
|
||||
|
||||
.more-replies {
|
||||
display: flex;
|
||||
padding-top: unset !important;
|
||||
margin-top: 8px;
|
||||
|
||||
&::before {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
line-height: 0.4em;
|
||||
}
|
||||
|
||||
.more-replies-text {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
@include ellipsis;
|
||||
display: block;
|
||||
margin-left: 58px;
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
@@ -1,77 +1,66 @@
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
|
||||
video {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gallery-video {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
&.card-container {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
> .attachment {
|
||||
min-height: 80px;
|
||||
min-width: 200px;
|
||||
.gallery-video.card-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
max-height: 530px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-overlay {
|
||||
@include play-button;
|
||||
background-color: $shadow;
|
||||
@include play-button;
|
||||
background-color: $shadow;
|
||||
|
||||
p {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
text-align: center;
|
||||
top: calc(50% - 20px);
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
margin: 0 20px;
|
||||
}
|
||||
p {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
text-align: center;
|
||||
top: calc(50% - 20px);
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
margin: 0 20px;
|
||||
}
|
||||
|
||||
.overlay-circle {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
top: calc(50% - 20px);
|
||||
margin: 0 auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
div {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
top: calc(50% - 20px);
|
||||
margin: 0 auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.overlay-duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
background-color: #0000007a;
|
||||
line-height: 1em;
|
||||
padding: 4px 6px 4px 6px;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
form {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
form {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 5px 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
button {
|
||||
padding: 5px 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
62
src/tid.nim
62
src/tid.nim
@@ -1,62 +0,0 @@
|
||||
import std/[asyncdispatch, base64, httpclient, random, strutils, sequtils, times]
|
||||
import nimcrypto
|
||||
import experimental/parser/tid
|
||||
|
||||
randomize()
|
||||
|
||||
const defaultKeyword = "obfiowerehiring";
|
||||
const pairsUrl =
|
||||
"https://raw.githubusercontent.com/fa0311/x-client-transaction-id-pair-dict/refs/heads/main/pair.json";
|
||||
|
||||
var
|
||||
cachedPairs: seq[TidPair] = @[]
|
||||
lastCached = 0
|
||||
# refresh every hour
|
||||
ttlSec = 60 * 60
|
||||
|
||||
proc getPair(): Future[TidPair] {.async.} =
|
||||
if cachedPairs.len == 0 or int(epochTime()) - lastCached > ttlSec:
|
||||
lastCached = int(epochTime())
|
||||
|
||||
let client = newAsyncHttpClient()
|
||||
defer: client.close()
|
||||
|
||||
let resp = await client.get(pairsUrl)
|
||||
if resp.status == $Http200:
|
||||
cachedPairs = parseTidPairs(await resp.body)
|
||||
|
||||
return sample(cachedPairs)
|
||||
|
||||
proc encodeSha256(text: string): array[32, byte] =
|
||||
let
|
||||
data = cast[ptr byte](addr text[0])
|
||||
dataLen = uint(len(text))
|
||||
digest = sha256.digest(data, dataLen)
|
||||
return digest.data
|
||||
|
||||
proc encodeBase64[T](data: T): string =
|
||||
return encode(data).replace("=", "")
|
||||
|
||||
proc decodeBase64(data: string): seq[byte] =
|
||||
return cast[seq[byte]](decode(data))
|
||||
|
||||
proc genTid*(path: string): Future[string] {.async.} =
|
||||
let
|
||||
pair = await getPair()
|
||||
|
||||
timeNow = int(epochTime() - 1682924400)
|
||||
timeNowBytes = @[
|
||||
byte(timeNow and 0xff),
|
||||
byte((timeNow shr 8) and 0xff),
|
||||
byte((timeNow shr 16) and 0xff),
|
||||
byte((timeNow shr 24) and 0xff)
|
||||
]
|
||||
|
||||
data = "GET!" & path & "!" & $timeNow & defaultKeyword & pair.animationKey
|
||||
hashBytes = encodeSha256(data)
|
||||
keyBytes = decodeBase64(pair.verification)
|
||||
bytesArr = keyBytes & timeNowBytes & hashBytes[0 ..< 16] & @[3'u8]
|
||||
randomNum = byte(rand(256))
|
||||
tid = @[randomNum] & bytesArr.mapIt(it xor randomNum)
|
||||
|
||||
return encodeBase64(tid)
|
||||
164
src/tokens.nim
Normal file
164
src/tokens.nim
Normal file
@@ -0,0 +1,164 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import asyncdispatch, httpclient, times, sequtils, json, random
|
||||
import strutils, tables
|
||||
import types, consts
|
||||
|
||||
const
|
||||
maxConcurrentReqs = 5 # max requests at a time per token, to avoid race conditions
|
||||
maxLastUse = 1.hours # if a token is unused for 60 minutes, it expires
|
||||
maxAge = 2.hours + 55.minutes # tokens expire after 3 hours
|
||||
failDelay = initDuration(minutes=30)
|
||||
|
||||
var
|
||||
tokenPool: seq[Token]
|
||||
lastFailed: Time
|
||||
enableLogging = false
|
||||
|
||||
let headers = newHttpHeaders({"authorization": auth})
|
||||
|
||||
template log(str) =
|
||||
if enableLogging: echo "[tokens] ", str
|
||||
|
||||
proc getPoolJson*(): JsonNode =
|
||||
var
|
||||
list = newJObject()
|
||||
totalReqs = 0
|
||||
totalPending = 0
|
||||
reqsPerApi: Table[string, int]
|
||||
|
||||
for token in tokenPool:
|
||||
totalPending.inc(token.pending)
|
||||
list[token.tok] = %*{
|
||||
"apis": newJObject(),
|
||||
"pending": token.pending,
|
||||
"init": $token.init,
|
||||
"lastUse": $token.lastUse
|
||||
}
|
||||
|
||||
for api in token.apis.keys:
|
||||
list[token.tok]["apis"][$api] = %token.apis[api]
|
||||
|
||||
let
|
||||
maxReqs =
|
||||
case api
|
||||
of Api.timeline: 187
|
||||
of Api.listMembers, Api.listBySlug, Api.list, Api.listTweets,
|
||||
Api.userTweets, Api.userTweetsAndReplies, Api.userMedia,
|
||||
Api.userRestId, Api.userScreenName,
|
||||
Api.tweetDetail, Api.tweetResult, Api.search: 500
|
||||
of Api.userSearch: 900
|
||||
reqs = maxReqs - token.apis[api].remaining
|
||||
|
||||
reqsPerApi[$api] = reqsPerApi.getOrDefault($api, 0) + reqs
|
||||
totalReqs.inc(reqs)
|
||||
|
||||
return %*{
|
||||
"amount": tokenPool.len,
|
||||
"requests": totalReqs,
|
||||
"pending": totalPending,
|
||||
"apis": reqsPerApi,
|
||||
"tokens": list
|
||||
}
|
||||
|
||||
proc rateLimitError*(): ref RateLimitError =
|
||||
newException(RateLimitError, "rate limited")
|
||||
|
||||
proc fetchToken(): Future[Token] {.async.} =
|
||||
if getTime() - lastFailed < failDelay:
|
||||
raise rateLimitError()
|
||||
|
||||
let client = newAsyncHttpClient(headers=headers)
|
||||
|
||||
try:
|
||||
let
|
||||
resp = await client.postContent(activate)
|
||||
tokNode = parseJson(resp)["guest_token"]
|
||||
tok = tokNode.getStr($(tokNode.getInt))
|
||||
time = getTime()
|
||||
|
||||
return Token(tok: tok, init: time, lastUse: time)
|
||||
except Exception as e:
|
||||
echo "[tokens] fetching token failed: ", e.msg
|
||||
if "Try again" notin e.msg:
|
||||
echo "[tokens] fetching tokens paused, resuming in 30 minutes"
|
||||
lastFailed = getTime()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
proc expired(token: Token): bool =
|
||||
let time = getTime()
|
||||
token.init < time - maxAge or token.lastUse < time - maxLastUse
|
||||
|
||||
proc isLimited(token: Token; api: Api): bool =
|
||||
if token.isNil or token.expired:
|
||||
return true
|
||||
|
||||
if api in token.apis:
|
||||
let limit = token.apis[api]
|
||||
return (limit.remaining <= 10 and limit.reset > epochTime().int)
|
||||
else:
|
||||
return false
|
||||
|
||||
proc isReady(token: Token; api: Api): bool =
|
||||
not (token.isNil or token.pending > maxConcurrentReqs or token.isLimited(api))
|
||||
|
||||
proc release*(token: Token; used=false; invalid=false) =
|
||||
if token.isNil: return
|
||||
if invalid or token.expired:
|
||||
if invalid: log "discarding invalid token"
|
||||
elif token.expired: log "discarding expired token"
|
||||
|
||||
let idx = tokenPool.find(token)
|
||||
if idx > -1: tokenPool.delete(idx)
|
||||
elif used:
|
||||
dec token.pending
|
||||
token.lastUse = getTime()
|
||||
|
||||
proc getToken*(api: Api): Future[Token] {.async.} =
|
||||
for i in 0 ..< tokenPool.len:
|
||||
if result.isReady(api): break
|
||||
release(result)
|
||||
result = tokenPool.sample()
|
||||
|
||||
if not result.isReady(api):
|
||||
release(result)
|
||||
result = await fetchToken()
|
||||
log "added new token to pool"
|
||||
tokenPool.add result
|
||||
|
||||
if not result.isNil:
|
||||
inc result.pending
|
||||
else:
|
||||
raise rateLimitError()
|
||||
|
||||
proc setRateLimit*(token: Token; api: Api; remaining, reset: int) =
|
||||
# avoid undefined behavior in race conditions
|
||||
if api in token.apis:
|
||||
let limit = token.apis[api]
|
||||
if limit.reset >= reset and limit.remaining < remaining:
|
||||
return
|
||||
|
||||
token.apis[api] = RateLimit(remaining: remaining, reset: reset)
|
||||
|
||||
proc poolTokens*(amount: int) {.async.} =
|
||||
var futs: seq[Future[Token]]
|
||||
for i in 0 ..< amount:
|
||||
futs.add fetchToken()
|
||||
|
||||
for token in futs:
|
||||
var newToken: Token
|
||||
|
||||
try: newToken = await token
|
||||
except: discard
|
||||
|
||||
if not newToken.isNil:
|
||||
log "added new token to pool"
|
||||
tokenPool.add newToken
|
||||
|
||||
proc initTokenPool*(cfg: Config) {.async.} =
|
||||
enableLogging = cfg.enableDebug
|
||||
|
||||
while true:
|
||||
if tokenPool.countIt(not it.isLimited(Api.timeline)) < cfg.minTokens:
|
||||
await poolTokens(min(4, cfg.minTokens - tokenPool.len))
|
||||
await sleepAsync(2000)
|
||||
179
src/types.nim
179
src/types.nim
@@ -6,76 +6,62 @@ genPrefsType()
|
||||
|
||||
type
|
||||
RateLimitError* = object of CatchableError
|
||||
NoSessionsError* = object of CatchableError
|
||||
InternalError* = object of CatchableError
|
||||
BadClientError* = object of CatchableError
|
||||
|
||||
TimelineKind* {.pure.} = enum
|
||||
tweets, replies, media
|
||||
tweets
|
||||
replies
|
||||
media
|
||||
|
||||
ApiUrl* = object
|
||||
endpoint*: string
|
||||
params*: seq[(string, string)]
|
||||
|
||||
ApiReq* = object
|
||||
oauth*: ApiUrl
|
||||
cookie*: ApiUrl
|
||||
Api* {.pure.} = enum
|
||||
tweetDetail
|
||||
tweetResult
|
||||
timeline
|
||||
search
|
||||
userSearch
|
||||
list
|
||||
listBySlug
|
||||
listMembers
|
||||
listTweets
|
||||
userRestId
|
||||
userScreenName
|
||||
userTweets
|
||||
userTweetsAndReplies
|
||||
userMedia
|
||||
|
||||
RateLimit* = object
|
||||
limit*: int
|
||||
remaining*: int
|
||||
reset*: int
|
||||
|
||||
SessionKind* = enum
|
||||
oauth
|
||||
cookie
|
||||
|
||||
Session* = ref object
|
||||
id*: int64
|
||||
username*: string
|
||||
Token* = ref object
|
||||
tok*: string
|
||||
init*: Time
|
||||
lastUse*: Time
|
||||
pending*: int
|
||||
limited*: bool
|
||||
limitedAt*: int
|
||||
apis*: Table[string, RateLimit]
|
||||
case kind*: SessionKind
|
||||
of oauth:
|
||||
oauthToken*: string
|
||||
oauthSecret*: string
|
||||
of cookie:
|
||||
authToken*: string
|
||||
ct0*: string
|
||||
apis*: Table[Api, RateLimit]
|
||||
|
||||
Error* = enum
|
||||
null = 0
|
||||
noUserMatches = 17
|
||||
protectedUser = 22
|
||||
missingParams = 25
|
||||
timeout = 29
|
||||
couldntAuth = 32
|
||||
doesntExist = 34
|
||||
unauthorized = 37
|
||||
invalidParam = 47
|
||||
userNotFound = 50
|
||||
suspended = 63
|
||||
rateLimited = 88
|
||||
expiredToken = 89
|
||||
invalidToken = 89
|
||||
listIdOrSlug = 112
|
||||
tweetNotFound = 144
|
||||
tweetNotAuthorized = 179
|
||||
forbidden = 200
|
||||
badRequest = 214
|
||||
badToken = 239
|
||||
locked = 326
|
||||
noCsrf = 353
|
||||
tweetUnavailable = 421
|
||||
tweetCensored = 422
|
||||
|
||||
VerifiedType* = enum
|
||||
none = "None"
|
||||
blue = "Blue"
|
||||
business = "Business"
|
||||
government = "Government"
|
||||
|
||||
User* = object
|
||||
id*: string
|
||||
username*: string
|
||||
@@ -91,42 +77,11 @@ type
|
||||
tweets*: int
|
||||
likes*: int
|
||||
media*: int
|
||||
verifiedType*: VerifiedType
|
||||
verified*: bool
|
||||
protected*: bool
|
||||
suspended*: bool
|
||||
joinDate*: DateTime
|
||||
|
||||
AccountInfo* = object
|
||||
username*: string
|
||||
fullname*: string
|
||||
userPic*: string
|
||||
joinDate*: DateTime
|
||||
verifiedType*: VerifiedType
|
||||
suspended*: bool
|
||||
basedIn*: string
|
||||
source*: string
|
||||
usernameChanges*: int
|
||||
lastUsernameChange*: DateTime
|
||||
affiliateUsername*: string
|
||||
affiliateLabel*: string
|
||||
isIdentityVerified*: bool
|
||||
verifiedSince*: DateTime
|
||||
overrideVerifiedYear*: int
|
||||
|
||||
Broadcast* = object
|
||||
id*: string
|
||||
title*: string
|
||||
state*: string
|
||||
thumb*: string
|
||||
mediaKey*: string
|
||||
m3u8Url*: string
|
||||
totalWatched*: int
|
||||
startTime*: DateTime
|
||||
endTime*: DateTime
|
||||
replayStart*: int
|
||||
availableForReplay*: bool
|
||||
user*: User
|
||||
|
||||
VideoType* = enum
|
||||
m3u8 = "application/x-mpegURL"
|
||||
mp4 = "video/mp4"
|
||||
@@ -142,6 +97,7 @@ type
|
||||
durationMs*: int
|
||||
url*: string
|
||||
thumb*: string
|
||||
views*: string
|
||||
available*: bool
|
||||
reason*: string
|
||||
title*: string
|
||||
@@ -154,7 +110,6 @@ type
|
||||
|
||||
Query* = object
|
||||
kind*: QueryKind
|
||||
view*: string
|
||||
text*: string
|
||||
filters*: seq[string]
|
||||
includes*: seq[string]
|
||||
@@ -162,33 +117,12 @@ type
|
||||
fromUser*: seq[string]
|
||||
since*: string
|
||||
until*: string
|
||||
minLikes*: string
|
||||
near*: 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
|
||||
@@ -227,10 +161,9 @@ type
|
||||
imageDirectMessage = "image_direct_message"
|
||||
audiospace = "audiospace"
|
||||
newsletterPublication = "newsletter_publication"
|
||||
jobDetails = "job_details"
|
||||
hidden
|
||||
unknown
|
||||
|
||||
|
||||
Card* = object
|
||||
kind*: CardKind
|
||||
url*: string
|
||||
@@ -244,7 +177,7 @@ type
|
||||
replies*: int
|
||||
retweets*: int
|
||||
likes*: int
|
||||
views*: int
|
||||
quotes*: int
|
||||
|
||||
Tweet* = ref object
|
||||
id*: int64
|
||||
@@ -268,13 +201,9 @@ type
|
||||
quote*: Option[Tweet]
|
||||
card*: Option[Card]
|
||||
poll*: Option[Poll]
|
||||
media*: MediaEntities
|
||||
history*: seq[int64]
|
||||
note*: string
|
||||
isAd*: bool
|
||||
isAI*: bool
|
||||
|
||||
Tweets* = seq[Tweet]
|
||||
gif*: Option[Gif]
|
||||
video*: Option[Video]
|
||||
photos*: seq[string]
|
||||
|
||||
Result*[T] = object
|
||||
content*: seq[T]
|
||||
@@ -283,7 +212,7 @@ type
|
||||
query*: Query
|
||||
|
||||
Chain* = object
|
||||
content*: Tweets
|
||||
content*: seq[Tweet]
|
||||
hasMore*: bool
|
||||
cursor*: string
|
||||
|
||||
@@ -293,18 +222,13 @@ type
|
||||
after*: Chain
|
||||
replies*: Result[Chain]
|
||||
|
||||
EditHistory* = object
|
||||
latest*: Tweet
|
||||
history*: Tweets
|
||||
|
||||
Timeline* = Result[Tweets]
|
||||
Timeline* = Result[Tweet]
|
||||
|
||||
Profile* = object
|
||||
user*: User
|
||||
photoRail*: PhotoRail
|
||||
pinned*: Option[Tweet]
|
||||
tweets*: Timeline
|
||||
accountInfo*: AccountInfo
|
||||
|
||||
List* = object
|
||||
id*: string
|
||||
@@ -331,19 +255,10 @@ type
|
||||
hmacKey*: string
|
||||
base64Media*: bool
|
||||
minTokens*: int
|
||||
enableRSSUserTweets*: bool
|
||||
enableRSSUserReplies*: bool
|
||||
enableRSSUserMedia*: bool
|
||||
enableRSSSearch*: bool
|
||||
enableRSSList*: bool
|
||||
enableRss*: bool
|
||||
enableDebug*: bool
|
||||
proxy*: string
|
||||
proxyAuth*: string
|
||||
apiProxy*: string
|
||||
disableTid*: bool
|
||||
maxConcurrentReqs*: int
|
||||
maxRetries*: int
|
||||
retryDelayMs*: int
|
||||
|
||||
rssCacheTime*: int
|
||||
listCacheTime*: int
|
||||
@@ -359,27 +274,3 @@ type
|
||||
|
||||
proc contains*(thread: Chain; tweet: Tweet): bool =
|
||||
thread.content.anyIt(it.id == tweet.id)
|
||||
|
||||
proc add*(timeline: var seq[Tweets]; tweet: Tweet) =
|
||||
timeline.add @[tweet]
|
||||
|
||||
proc getPhotos*(tweet: Tweet): seq[Photo] =
|
||||
tweet.media.filterIt(it.kind == photoMedia).mapIt(it.photo)
|
||||
|
||||
proc getVideos*(tweet: Tweet): seq[Video] =
|
||||
tweet.media.filterIt(it.kind == videoMedia).mapIt(it.video)
|
||||
|
||||
proc hasPhotos*(tweet: Tweet): bool =
|
||||
tweet.media.anyIt(it.kind == photoMedia)
|
||||
|
||||
proc hasVideos*(tweet: Tweet): bool =
|
||||
tweet.media.anyIt(it.kind == videoMedia)
|
||||
|
||||
proc hasGifs*(tweet: Tweet): bool =
|
||||
tweet.media.anyIt(it.kind == gifMedia)
|
||||
|
||||
proc getThumb*(media: Media): string =
|
||||
case media.kind
|
||||
of photoMedia: media.photo.url
|
||||
of videoMedia: media.video.thumb
|
||||
of gifMedia: media.gif.thumb
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import sequtils, strutils, strformat, uri, tables, base64
|
||||
import strutils, strformat, uri, tables, base64
|
||||
import nimcrypto
|
||||
import types
|
||||
|
||||
var
|
||||
hmacKey: string
|
||||
@@ -9,17 +10,14 @@ var
|
||||
const
|
||||
https* = "https://"
|
||||
twimg* = "pbs.twimg.com/"
|
||||
nitterParams* = ["name", "tab", "id", "list", "referer", "scroll", "prefs"]
|
||||
nitterParams = ["name", "tab", "id", "list", "referer", "scroll"]
|
||||
twitterDomains = @[
|
||||
"twitter.com",
|
||||
"pic.twitter.com",
|
||||
"twimg.com",
|
||||
"abs.twimg.com",
|
||||
"pbs.twimg.com",
|
||||
"video.twimg.com",
|
||||
"x.com",
|
||||
"pscp.tv",
|
||||
"video.pscp.tv"
|
||||
"video.twimg.com"
|
||||
]
|
||||
|
||||
proc setHmacKey*(key: string) =
|
||||
@@ -31,6 +29,20 @@ proc setProxyEncoding*(state: bool) =
|
||||
proc getHmac*(data: string): string =
|
||||
($hmac(sha256, hmacKey, data))[0 .. 12]
|
||||
|
||||
proc getBestMp4VidVariant(video: Video): VideoVariant =
|
||||
for v in video.variants:
|
||||
if v.bitrate >= result.bitrate:
|
||||
result = v
|
||||
|
||||
proc getVidVariant*(video: Video; playbackType: VideoType): VideoVariant =
|
||||
case playbackType
|
||||
of mp4:
|
||||
return video.getBestMp4VidVariant
|
||||
of m3u8, vmap:
|
||||
for variant in video.variants:
|
||||
if variant.contentType == playbackType:
|
||||
return variant
|
||||
|
||||
proc getVidUrl*(link: string): string =
|
||||
if link.len == 0: return
|
||||
let sig = getHmac(link)
|
||||
@@ -57,13 +69,7 @@ proc filterParams*(params: Table): seq[(string, string)] =
|
||||
result.add p
|
||||
|
||||
proc isTwitterUrl*(uri: Uri): bool =
|
||||
uri.hostname in twitterDomains or
|
||||
uri.hostname.endsWith(".video.pscp.tv")
|
||||
uri.hostname in twitterDomains
|
||||
|
||||
proc isTwitterUrl*(url: string): bool =
|
||||
isTwitterUrl(parseUri(url))
|
||||
|
||||
proc validateNumber*(value: string): string =
|
||||
if value.anyIt(not it.isDigit):
|
||||
return ""
|
||||
return value
|
||||
parseUri(url).hostname in twitterDomains
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
# 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
|
||||
@@ -1,75 +0,0 @@
|
||||
# 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
|
||||
@@ -9,17 +9,14 @@ import general, tweet
|
||||
const doctype = "<!DOCTYPE html>\n"
|
||||
|
||||
proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
|
||||
let
|
||||
video = tweet.getVideos()[0]
|
||||
thumb = video.thumb
|
||||
vidUrl = getVideoEmbed(cfg, tweet.id)
|
||||
prefs = Prefs(hlsPlayback: true, mp4Playback: true)
|
||||
|
||||
let thumb = get(tweet.video).thumb
|
||||
let vidUrl = getVideoEmbed(cfg, tweet.id)
|
||||
let prefs = Prefs(hlsPlayback: true)
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb]))
|
||||
|
||||
body:
|
||||
tdiv(class="embed-video"):
|
||||
renderVideo(video, prefs, "")
|
||||
renderVideo(get(tweet.video), prefs, "")
|
||||
|
||||
result = doctype & $node
|
||||
|
||||
@@ -29,17 +29,19 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
|
||||
|
||||
tdiv(class="nav-item right"):
|
||||
icon "search", title="Search", href="/search"
|
||||
if rss.len > 0:
|
||||
icon "rss", title="RSS Feed", href=rss
|
||||
icon "bird", title="Open in X", href=canonical
|
||||
if cfg.enableRss and rss.len > 0:
|
||||
icon "rss-feed", title="RSS Feed", href=rss
|
||||
icon "bird", title="Open in Twitter", 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=""; alternate=""): VNode =
|
||||
let theme = prefs.theme.toTheme
|
||||
rss=""; canonical=""): VNode =
|
||||
var theme = prefs.theme.toTheme
|
||||
if "theme" in req.params:
|
||||
theme = req.params["theme"].toTheme
|
||||
|
||||
let ogType =
|
||||
if video.len > 0: "video"
|
||||
@@ -50,8 +52,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=35")
|
||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5")
|
||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=18")
|
||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2")
|
||||
|
||||
if theme.len > 0:
|
||||
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
|
||||
@@ -64,14 +66,14 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
|
||||
href=opensearchUrl)
|
||||
|
||||
if alternate.len > 0:
|
||||
link(rel="alternate", href=alternate, title="View on X")
|
||||
if canonical.len > 0:
|
||||
link(rel="canonical", href=canonical)
|
||||
|
||||
if rss.len > 0:
|
||||
if cfg.enableRss and rss.len > 0:
|
||||
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
|
||||
|
||||
if prefs.hlsPlayback:
|
||||
script(src="/js/hls.min.js", `defer`="")
|
||||
script(src="/js/hls.light.min.js", `defer`="")
|
||||
script(src="/js/hlsPlayback.js", `defer`="")
|
||||
|
||||
if prefs.infiniteScroll:
|
||||
@@ -117,21 +119,20 @@ 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?21002321", crossorigin="anonymous")
|
||||
|
||||
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
|
||||
titleText=""; desc=""; ogTitle=""; rss=""; video="";
|
||||
images: seq[string] = @[]; banner=""): string =
|
||||
|
||||
let twitterLink = getTwitterLink(req.path, req.params)
|
||||
let canonical = getTwitterLink(req.path, req.params)
|
||||
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
|
||||
rss, twitterLink)
|
||||
rss, canonical)
|
||||
|
||||
let bodyClass = if prefs.stickyNav: "fixed-nav" else: ""
|
||||
body(class=bodyClass):
|
||||
renderNavbar(cfg, req, rss, twitterLink)
|
||||
body:
|
||||
renderNavbar(cfg, req, rss, canonical)
|
||||
|
||||
tdiv(class="container"):
|
||||
body
|
||||
|
||||
@@ -32,8 +32,7 @@ macro renderPrefs*(): untyped =
|
||||
|
||||
result[2].add stmt
|
||||
|
||||
proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string];
|
||||
prefsUrl: string): VNode =
|
||||
proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode =
|
||||
buildHtml(tdiv(class="overlay-panel")):
|
||||
fieldset(class="preferences"):
|
||||
form(`method`="post", action="/saveprefs", autocomplete="off"):
|
||||
@@ -41,14 +40,6 @@ proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string];
|
||||
|
||||
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."
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ proc renderStat(num: int; class: string; text=""): VNode =
|
||||
span(class="profile-stat-num"):
|
||||
text insertSep($num, ',')
|
||||
|
||||
proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
|
||||
proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv(class="profile-card")):
|
||||
tdiv(class="profile-card-info"):
|
||||
let
|
||||
@@ -26,7 +26,6 @@ proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): 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"):
|
||||
@@ -46,11 +45,6 @@ proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): 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:
|
||||
@@ -59,7 +53,7 @@ proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
|
||||
a(href=url): text url.shortLink
|
||||
|
||||
tdiv(class="profile-joindate"):
|
||||
a(href=(&"/{user.username}/about"), title=getJoinDateFull(user)):
|
||||
span(title=getJoinDateFull(user)):
|
||||
icon "calendar", getJoinDate(user)
|
||||
|
||||
tdiv(class="profile-card-extra-links"):
|
||||
@@ -107,22 +101,17 @@ proc renderProtected(username: string): VNode =
|
||||
|
||||
proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
|
||||
profile.tweets.query.fromUser = @[profile.user.username]
|
||||
let
|
||||
isGalleryView = profile.tweets.query.kind == media and
|
||||
profile.tweets.query.view == "gallery"
|
||||
viewClass = if isGalleryView: " media-only" else: ""
|
||||
|
||||
buildHtml(tdiv(class=("profile-tabs" & viewClass))):
|
||||
if not isGalleryView and not prefs.hideBanner:
|
||||
buildHtml(tdiv(class="profile-tabs")):
|
||||
if not prefs.hideBanner:
|
||||
tdiv(class="profile-banner"):
|
||||
renderBanner(profile.user.banner)
|
||||
|
||||
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)
|
||||
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
||||
tdiv(class=("profile-tab" & sticky)):
|
||||
renderUserCard(profile.user, prefs)
|
||||
if profile.photoRail.len > 0:
|
||||
renderPhotoRail(profile)
|
||||
|
||||
if profile.user.protected:
|
||||
renderProtected(profile.user.username)
|
||||
|
||||
@@ -4,7 +4,6 @@ import karax/[karaxdsl, vdom, vstyles]
|
||||
import ".."/[types, utils]
|
||||
|
||||
const smallWebp* = "?name=small&format=webp"
|
||||
const mediumWebp* = "?name=medium&format=webp"
|
||||
|
||||
proc getSmallPic*(url: string): string =
|
||||
result = url
|
||||
@@ -12,12 +11,6 @@ proc getSmallPic*(url: string): string =
|
||||
result &= smallWebp
|
||||
result = getPicUrl(result)
|
||||
|
||||
proc getMediumPic*(url: string): string =
|
||||
result = url
|
||||
if "?" notin url and not url.endsWith("placeholder.png"):
|
||||
result &= mediumWebp
|
||||
result = getPicUrl(result)
|
||||
|
||||
proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
|
||||
var c = "icon-" & icon
|
||||
if class.len > 0: c = &"{c} {class}"
|
||||
@@ -30,15 +23,6 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
|
||||
if text.len > 0:
|
||||
text " " & text
|
||||
|
||||
template verifiedIcon*(user: User): untyped {.dirty.} =
|
||||
if user.verifiedType != VerifiedType.none:
|
||||
let lower = ($user.verifiedType).toLowerAscii()
|
||||
buildHtml(tdiv(class=(&"verified-icon {lower}"))):
|
||||
icon "circle", class="verified-icon-circle", title=(&"Verified {lower} account")
|
||||
icon "ok", class="verified-icon-check", title=(&"Verified {lower} account")
|
||||
else:
|
||||
text ""
|
||||
|
||||
proc linkUser*(user: User, class=""): VNode =
|
||||
let
|
||||
isName = "username" notin class
|
||||
@@ -48,10 +32,11 @@ proc linkUser*(user: User, class=""): VNode =
|
||||
|
||||
buildHtml(a(href=href, class=class, title=nameText)):
|
||||
text nameText
|
||||
if isName:
|
||||
if user.protected:
|
||||
text " "
|
||||
icon "lock", title="Protected account"
|
||||
if isName and user.verified:
|
||||
icon "ok", class="verified-icon", title="Verified account"
|
||||
if isName and user.protected:
|
||||
text " "
|
||||
icon "lock", title="Protected account"
|
||||
|
||||
proc linkText*(text: string; class=""): VNode =
|
||||
let url = if "http" notin text: https & text else: text
|
||||
@@ -72,20 +57,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", title=pref)):
|
||||
buildHtml(label(class="pref-group checkbox-container")):
|
||||
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), title=pref)):
|
||||
buildHtml(tdiv(class=("pref-group pref-input " & class))):
|
||||
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", title=pref)):
|
||||
buildHtml(tdiv(class="pref-group pref-input")):
|
||||
label(`for`=pref): text label
|
||||
select(name=pref):
|
||||
for opt in options:
|
||||
@@ -97,16 +82,9 @@ proc genDate*(pref, state: string): VNode =
|
||||
input(name=pref, `type`="date", value=state)
|
||||
icon "calendar"
|
||||
|
||||
proc genNumberInput*(pref, label, state, placeholder: string; class=""; autofocus=true; min="0"): VNode =
|
||||
let p = placeholder
|
||||
buildHtml(tdiv(class=("pref-group pref-input " & class))):
|
||||
if label.len > 0:
|
||||
label(`for`=pref): text label
|
||||
input(name=pref, `type`="number", placeholder=p, value=state, autofocus=(autofocus and state.len == 0), min=min, step="1")
|
||||
|
||||
proc genImg*(url: string; class=""; alt=""): VNode =
|
||||
proc genImg*(url: string; class=""): VNode =
|
||||
buildHtml():
|
||||
img(src=getPicUrl(url), class=class, alt=alt, loading="lazy")
|
||||
img(src=getPicUrl(url), class=class, alt="", loading="lazy", decoding="async")
|
||||
|
||||
proc getTabClass*(query: Query; tab: QueryKind): string =
|
||||
if query.kind == tab: "tab-item active"
|
||||
|
||||
@@ -1,149 +1,83 @@
|
||||
#? stdtmpl(subsChar = '$', metaChar = '#')
|
||||
## SPDX-License-Identifier: AGPL-3.0-only
|
||||
#import strutils, sequtils, xmltree, strformat, options, unicode
|
||||
#import strutils, 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 =
|
||||
#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]}: "
|
||||
#if tweet.pinned: result = "Pinned: "
|
||||
#elif retweet.len > 0: result = &"RT by @{retweet}: "
|
||||
#elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: "
|
||||
#end if
|
||||
#var text = stripHtml(tweet.text)
|
||||
##if unicode.runeLen(text) > 32:
|
||||
## text = unicode.runeSubStr(text, 0, 32) & "..."
|
||||
##end if
|
||||
#text = xmltree.escape(text)
|
||||
#if text.len > 0:
|
||||
# result = prefix & text
|
||||
# return
|
||||
#result &= xmltree.escape(text)
|
||||
#if result.len > 0: 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
|
||||
#if tweet.photos.len > 0:
|
||||
# result &= "Image"
|
||||
#elif tweet.video.isSome:
|
||||
# result &= "Video"
|
||||
#elif tweet.gif.isSome:
|
||||
# result &= "Gif"
|
||||
#end if
|
||||
#end proc
|
||||
#
|
||||
#proc getDescription(desc: string; cfg: Config): string =
|
||||
Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
|
||||
Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
||||
#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 getTweetsWithPinned(profile: Profile): seq[Tweets] =
|
||||
#result = profile.tweets.content
|
||||
#if profile.pinned.isSome and result.len > 0:
|
||||
# let pinnedTweet = profile.pinned.get
|
||||
# var inserted = false
|
||||
# for threadIdx in 0 ..< result.len:
|
||||
# if not inserted:
|
||||
# for tweetIdx in 0 ..< result[threadIdx].len:
|
||||
# if result[threadIdx][tweetIdx].id < pinnedTweet.id:
|
||||
# result[threadIdx].insert(pinnedTweet, tweetIdx)
|
||||
# inserted = true
|
||||
# end if
|
||||
# end for
|
||||
# end if
|
||||
# end for
|
||||
#end if
|
||||
#end proc
|
||||
#
|
||||
#proc renderRssTweet(tweet: Tweet; cfg: Config; prefs: Prefs): string =
|
||||
#proc renderRssTweet(tweet: Tweet; cfg: Config): string =
|
||||
#let tweet = tweet.retweet.get(tweet)
|
||||
#let urlPrefix = getUrlPrefix(cfg)
|
||||
#let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix)
|
||||
#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix)
|
||||
<p>${text.replace("\n", "<br>\n")}</p>
|
||||
#if tweet.media.len > 0:
|
||||
# for media in tweet.media:
|
||||
${renderRssMedia(media, tweet, urlPrefix)}
|
||||
#if tweet.quote.isSome and get(tweet.quote).available:
|
||||
# let quoteLink = getLink(get(tweet.quote))
|
||||
<p><a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p>
|
||||
#end if
|
||||
#if tweet.photos.len > 0:
|
||||
# for photo in tweet.photos:
|
||||
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" />
|
||||
# end for
|
||||
#elif tweet.video.isSome:
|
||||
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
|
||||
#elif tweet.gif.isSome:
|
||||
# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
|
||||
# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
|
||||
<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
|
||||
#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; prefs: Prefs; userId=""): string =
|
||||
#proc renderRssTweets(tweets: seq[Tweet]; cfg: Config): string =
|
||||
#let urlPrefix = getUrlPrefix(cfg)
|
||||
#var links: seq[string]
|
||||
#for thread in tweets:
|
||||
# for tweet in thread:
|
||||
# if userId.len > 0 and tweet.user.id != userId: continue
|
||||
# end if
|
||||
#
|
||||
# let retweet = if tweet.retweet.isSome: tweet.user.username else: ""
|
||||
# let tweet = if retweet.len > 0: tweet.retweet.get else: tweet
|
||||
# let link = getLink(tweet)
|
||||
# if link in links: continue
|
||||
# end if
|
||||
# links.add link
|
||||
# let useGlobalGuid = tweet.id >= guidCutoff
|
||||
<item>
|
||||
<title>${getTitle(tweet, retweet)}</title>
|
||||
<dc:creator>@${tweet.user.username}</dc:creator>
|
||||
<description><![CDATA[${renderRssTweet(tweet, cfg, prefs).strip(chars={'\n'})}]]></description>
|
||||
<pubDate>${getRfc822Time(tweet)}</pubDate>
|
||||
#if useGlobalGuid:
|
||||
<guid isPermaLink="false">${tweet.id}</guid>
|
||||
#else:
|
||||
<guid>${urlPrefix & link}</guid>
|
||||
#end if
|
||||
<link>${urlPrefix & link}</link>
|
||||
</item>
|
||||
# end for
|
||||
#for t in tweets:
|
||||
# let retweet = if t.retweet.isSome: t.user.username else: ""
|
||||
# let tweet = if retweet.len > 0: t.retweet.get else: t
|
||||
# let link = getLink(tweet)
|
||||
# if link in links: continue
|
||||
# end if
|
||||
# links.add link
|
||||
<item>
|
||||
<title>${getTitle(tweet, retweet)}</title>
|
||||
<dc:creator>@${tweet.user.username}</dc:creator>
|
||||
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
|
||||
<pubDate>${getRfc822Time(tweet)}</pubDate>
|
||||
<guid>${urlPrefix & link}</guid>
|
||||
<link>${urlPrefix & link}</link>
|
||||
</item>
|
||||
#end for
|
||||
#end proc
|
||||
#
|
||||
#proc renderTimelineRss*(profile: Profile; cfg: Config; prefs: Prefs; multi=false): string =
|
||||
#proc renderTimelineRss*(profile: Profile; cfg: Config; multi=false): string =
|
||||
#let urlPrefix = getUrlPrefix(cfg)
|
||||
#result = ""
|
||||
#let handle = (if multi: "" else: "@") & profile.user.username
|
||||
@@ -167,15 +101,14 @@ ${renderRssTweet(quoteTweet, cfg, prefs)}
|
||||
<width>128</width>
|
||||
<height>128</height>
|
||||
</image>
|
||||
#let tweetsList = getTweetsWithPinned(profile)
|
||||
#if tweetsList.len > 0:
|
||||
${renderRssTweets(tweetsList, cfg, prefs, userId=profile.user.id)}
|
||||
#if profile.tweets.content.len > 0:
|
||||
${renderRssTweets(profile.tweets.content, cfg)}
|
||||
#end if
|
||||
</channel>
|
||||
</rss>
|
||||
#end proc
|
||||
#
|
||||
#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config; prefs: Prefs): string =
|
||||
#proc renderListRss*(tweets: seq[Tweet]; list: List; cfg: Config): string =
|
||||
#let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}"
|
||||
#result = ""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -187,12 +120,12 @@ ${renderRssTweets(tweetsList, cfg, prefs, userId=profile.user.id)}
|
||||
<description>${getDescription(&"{list.name} by @{list.username}", cfg)}</description>
|
||||
<language>en-us</language>
|
||||
<ttl>40</ttl>
|
||||
${renderRssTweets(tweets, cfg, prefs)}
|
||||
${renderRssTweets(tweets, cfg)}
|
||||
</channel>
|
||||
</rss>
|
||||
#end proc
|
||||
#
|
||||
#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config; prefs: Prefs): string =
|
||||
#proc renderSearchRss*(tweets: seq[Tweet]; name, param: string; cfg: Config): string =
|
||||
#let link = &"{getUrlPrefix(cfg)}/search"
|
||||
#let escName = xmltree.escape(name)
|
||||
#result = ""
|
||||
@@ -205,7 +138,7 @@ ${renderRssTweets(tweets, cfg, prefs)}
|
||||
<description>${getDescription(&"Search \"{escName}\"", cfg)}</description>
|
||||
<language>en-us</language>
|
||||
<ttl>40</ttl>
|
||||
${renderRssTweets(tweets, cfg, prefs)}
|
||||
${renderRssTweets(tweets, cfg)}
|
||||
</channel>
|
||||
</rss>
|
||||
#end proc
|
||||
|
||||
@@ -10,21 +10,23 @@ const toggles = {
|
||||
"media": "Media",
|
||||
"videos": "Videos",
|
||||
"news": "News",
|
||||
"verified": "Verified",
|
||||
"native_video": "Native videos",
|
||||
"replies": "Replies",
|
||||
"links": "Links",
|
||||
"images": "Images",
|
||||
"safe": "Safe",
|
||||
"quote": "Quotes",
|
||||
"spaces": "Spaces"
|
||||
"pro_video": "Pro videos"
|
||||
}.toOrderedTable
|
||||
|
||||
proc renderSearch*(): VNode =
|
||||
buildHtml(tdiv(class="panel-container")):
|
||||
tdiv(class="search-bar"):
|
||||
form(`method`="get", action="/search", autocomplete="off"):
|
||||
hiddenField("f", "tweets")
|
||||
hiddenField("f", "users")
|
||||
input(`type`="text", name="q", autofocus="",
|
||||
placeholder="Search...", dir="auto")
|
||||
placeholder="Enter username...", dir="auto")
|
||||
button(`type`="submit"): icon "search"
|
||||
|
||||
proc renderProfileTabs*(query: Query; username: string): VNode =
|
||||
@@ -39,19 +41,6 @@ proc renderProfileTabs*(query: Query; username: string): VNode =
|
||||
li(class=query.getTabClass(tweets)):
|
||||
a(href=(link & "/search")): text "Search"
|
||||
|
||||
proc renderMediaViewTabs*(query: Query; username: string): VNode =
|
||||
let currentView = if query.view.len > 0: query.view else: "timeline"
|
||||
let base = "/" & username & "/media?view="
|
||||
func cls(view: string): string =
|
||||
if currentView == view: "tab-item active" else: "tab-item"
|
||||
buildHtml(ul(class="tab media-view-tabs")):
|
||||
li(class=cls("timeline")):
|
||||
a(href=(base & "timeline")): text "Timeline"
|
||||
li(class=cls("grid")):
|
||||
a(href=(base & "grid")): text "Grid"
|
||||
li(class=cls("gallery")):
|
||||
a(href=(base & "gallery")): text "Gallery"
|
||||
|
||||
proc renderSearchTabs*(query: Query): VNode =
|
||||
var q = query
|
||||
buildHtml(ul(class="tab")):
|
||||
@@ -64,7 +53,7 @@ proc renderSearchTabs*(query: Query): VNode =
|
||||
|
||||
proc isPanelOpen(q: Query): bool =
|
||||
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or
|
||||
@[q.minLikes, q.until, q.since].anyIt(it.len > 0))
|
||||
@[q.near, q.until, q.since].anyIt(it.len > 0))
|
||||
|
||||
proc renderSearchPanel*(query: Query): VNode =
|
||||
let user = query.fromUser.join(",")
|
||||
@@ -96,10 +85,10 @@ proc renderSearchPanel*(query: Query): VNode =
|
||||
span(class="search-title"): text "-"
|
||||
genDate("until", query.until)
|
||||
tdiv:
|
||||
span(class="search-title"): text "Minimum likes"
|
||||
genNumberInput("min_faves", "", query.minLikes, "Number...", autofocus=false)
|
||||
span(class="search-title"): text "Near"
|
||||
genInput("near", "", query.near, "Location...", autofocus=false)
|
||||
|
||||
proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
|
||||
proc renderTweetSearch*(results: Result[Tweet]; prefs: Prefs; path: string;
|
||||
pinned=none(Tweet)): VNode =
|
||||
let query = results.query
|
||||
buildHtml(tdiv(class="timeline-container")):
|
||||
@@ -108,10 +97,7 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
|
||||
text query.fromUser.join(" | ")
|
||||
|
||||
if query.fromUser.len > 0:
|
||||
if query.kind != media or query.view != "gallery":
|
||||
renderProfileTabs(query, query.fromUser.join(","))
|
||||
if query.kind == media and query.fromUser.len == 1:
|
||||
renderMediaViewTabs(query, query.fromUser[0])
|
||||
renderProfileTabs(query, query.fromUser.join(","))
|
||||
|
||||
if query.fromUser.len == 0 or query.kind == tweets:
|
||||
tdiv(class="timeline-header"):
|
||||
|
||||
@@ -28,19 +28,14 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode =
|
||||
if thread.hasMore:
|
||||
renderMoreReplies(thread)
|
||||
|
||||
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: Tweet = nil): VNode =
|
||||
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode =
|
||||
buildHtml(tdiv(class="replies", id="r")):
|
||||
var hasReplies = false
|
||||
var replyCount = 0
|
||||
for thread in replies.content:
|
||||
if thread.content.len == 0: continue
|
||||
hasReplies = true
|
||||
replyCount += thread.content.len
|
||||
renderReplyThread(thread, prefs, path)
|
||||
|
||||
if hasReplies and replies.bottom.len > 0:
|
||||
if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies:
|
||||
renderMore(Query(), replies.bottom, focus="#r")
|
||||
if replies.bottom.len > 0:
|
||||
renderMore(Query(), replies.bottom, focus="#r")
|
||||
|
||||
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode =
|
||||
let hasAfter = conv.after.content.len > 0
|
||||
@@ -75,20 +70,6 @@ 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, conv.tweet)
|
||||
renderReplies(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)
|
||||
|
||||
@@ -1,42 +1,16 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat, algorithm, uri, options
|
||||
import strutils, strformat, sequtils, algorithm, uri, options
|
||||
import karax/[karaxdsl, vdom]
|
||||
|
||||
import ".."/[types, query, formatters]
|
||||
import tweet, renderutils
|
||||
|
||||
proc timelineViewClass(query: Query): string =
|
||||
if query.kind != media:
|
||||
return "timeline"
|
||||
|
||||
case query.view
|
||||
of "grid": "timeline media-grid-view"
|
||||
of "gallery": "timeline media-gallery-view"
|
||||
else: "timeline"
|
||||
|
||||
proc getQuery(query: Query): string =
|
||||
if query.kind != posts:
|
||||
result = genQueryUrl(query)
|
||||
if result.len > 0:
|
||||
result &= "&"
|
||||
|
||||
proc getSearchMaxId(results: Timeline; path: string): string =
|
||||
if results.query.kind != tweets or results.content.len == 0 or
|
||||
results.query.until.len == 0:
|
||||
return
|
||||
|
||||
let lastThread = results.content[^1]
|
||||
if lastThread.len == 0 or lastThread[^1].id == 0:
|
||||
return
|
||||
|
||||
# 2000000 is the minimum decrement to guarantee no result overlap
|
||||
var maxId = lastThread[^1].id - 2_000_000'i64
|
||||
if maxId <= 0:
|
||||
maxId = lastThread[^1].id - 1
|
||||
|
||||
if maxId > 0:
|
||||
return "maxid:" & $maxId
|
||||
|
||||
proc renderToTop*(focus="#"): VNode =
|
||||
buildHtml(tdiv(class="top-ref")):
|
||||
icon "down", href=focus
|
||||
@@ -65,24 +39,26 @@ proc renderNoneFound(): VNode =
|
||||
h2(class="timeline-none"):
|
||||
text "No items found"
|
||||
|
||||
proc renderThread(thread: Tweets; prefs: Prefs; path: string; bigThumb=false): VNode =
|
||||
proc renderThread(thread: seq[Tweet]; prefs: Prefs; path: string): VNode =
|
||||
buildHtml(tdiv(class="thread-line")):
|
||||
let sortedThread = thread.sortedByIt(it.id)
|
||||
for i, tweet in sortedThread:
|
||||
# thread has a gap, display "more replies" link
|
||||
if i > 0 and tweet.replyId != sortedThread[i - 1].id:
|
||||
tdiv(class="timeline-item thread more-replies-thread"):
|
||||
tdiv(class="more-replies"):
|
||||
a(class="more-replies-text", href=getLink(tweet)):
|
||||
text "more replies"
|
||||
|
||||
let show = i == thread.high and sortedThread[0].id != tweet.threadId
|
||||
let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: ""
|
||||
renderTweet(tweet, prefs, path, class=(header & "thread"),
|
||||
index=i, last=(i == thread.high), bigThumb=bigThumb)
|
||||
index=i, last=(i == thread.high), showThread=show)
|
||||
|
||||
proc threadFilter(tweets: openArray[Tweet]; threads: openArray[int64]; it: Tweet): seq[Tweet] =
|
||||
result = @[it]
|
||||
if it.retweet.isSome or it.replyId in threads: return
|
||||
for t in tweets:
|
||||
if t.id == result[0].replyId:
|
||||
result.insert t
|
||||
elif t.replyId == result[0].id:
|
||||
result.add t
|
||||
|
||||
proc renderUser(user: User; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv(class="timeline-item", data-username=user.username)):
|
||||
buildHtml(tdiv(class="timeline-item")):
|
||||
a(class="tweet-link", href=("/" & user.username))
|
||||
tdiv(class="tweet-body profile-result"):
|
||||
tdiv(class="tweet-header"):
|
||||
@@ -92,7 +68,6 @@ 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"):
|
||||
@@ -114,28 +89,15 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode =
|
||||
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;
|
||||
proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string;
|
||||
pinned=none(Tweet)): VNode =
|
||||
buildHtml(tdiv(class=results.query.timelineViewClass)):
|
||||
buildHtml(tdiv(class="timeline")):
|
||||
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)
|
||||
renderTweet(tweet, prefs, path, showThread=tweet.hasThread)
|
||||
|
||||
if results.content.len == 0:
|
||||
if not results.beginning:
|
||||
@@ -143,23 +105,26 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
|
||||
else:
|
||||
renderNoneFound()
|
||||
else:
|
||||
let filtered = filterThreads(results.content, prefs)
|
||||
var
|
||||
threads: seq[int64]
|
||||
retweets: seq[int64]
|
||||
|
||||
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)
|
||||
for tweet in results.content:
|
||||
let rt = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
|
||||
|
||||
var cursor = getSearchMaxId(results, path)
|
||||
if cursor.len > 0:
|
||||
renderMore(results.query, cursor)
|
||||
elif results.bottom.len > 0:
|
||||
renderMore(results.query, results.bottom)
|
||||
if tweet.id in threads or rt in retweets or tweet.id in retweets or
|
||||
tweet.pinned and prefs.hidePins: continue
|
||||
|
||||
let thread = results.content.threadFilter(threads, tweet)
|
||||
if thread.len < 2:
|
||||
var hasThread = tweet.hasThread
|
||||
if rt != 0:
|
||||
retweets &= rt
|
||||
hasThread = get(tweet.retweet).hasThread
|
||||
renderTweet(tweet, prefs, path, showThread=hasThread)
|
||||
else:
|
||||
renderThread(thread, prefs, path)
|
||||
threads &= thread.mapIt(it.id)
|
||||
|
||||
renderMore(results.query, results.bottom)
|
||||
renderToTop()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, sequtils, strformat, options, algorithm
|
||||
import strutils, sequtils, strformat, options
|
||||
import karax/[karaxdsl, vdom, vstyles]
|
||||
from jester import Request
|
||||
|
||||
@@ -10,17 +10,20 @@ import general
|
||||
const doctype = "<!DOCTYPE html>\n"
|
||||
|
||||
proc renderMiniAvatar(user: User; prefs: Prefs): VNode =
|
||||
genImg(user.getUserPic("_mini"), class=(prefs.getAvatarClass & " mini"))
|
||||
let url = getPicUrl(user.getUserPic("_mini"))
|
||||
buildHtml():
|
||||
img(class=(prefs.getAvatarClass & " mini"), src=url, loading="lazy")
|
||||
|
||||
proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode =
|
||||
proc renderHeader(tweet: Tweet; retweet: string; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv):
|
||||
if pinned:
|
||||
tdiv(class="pinned"):
|
||||
span: icon "pin", "Pinned Tweet"
|
||||
elif retweet.len > 0:
|
||||
if retweet.len > 0:
|
||||
tdiv(class="retweet-header"):
|
||||
span: icon "retweet", retweet & " retweeted"
|
||||
|
||||
if tweet.pinned:
|
||||
tdiv(class="pinned"):
|
||||
span: icon "pin", "Pinned Tweet"
|
||||
|
||||
tdiv(class="tweet-header"):
|
||||
a(class="tweet-avatar", href=("/" & tweet.user.username)):
|
||||
var size = "_bigger"
|
||||
@@ -31,28 +34,28 @@ proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VN
|
||||
tdiv(class="tweet-name-row"):
|
||||
tdiv(class="fullname-and-username"):
|
||||
linkUser(tweet.user, class="fullname")
|
||||
verifiedIcon(tweet.user)
|
||||
linkUser(tweet.user, class="username")
|
||||
|
||||
span(class="tweet-date"):
|
||||
a(href=getLink(tweet), title=tweet.getTime):
|
||||
text tweet.getShortTime
|
||||
|
||||
proc renderAltText(altText: string): VNode =
|
||||
buildHtml(p(class="alt-text")):
|
||||
text "ALT " & altText
|
||||
proc renderAlbum(tweet: Tweet): VNode =
|
||||
let
|
||||
groups = if tweet.photos.len < 3: @[tweet.photos]
|
||||
else: tweet.photos.distribute(2)
|
||||
|
||||
proc renderPhotoAttachment(photo: Photo; bigThumb=false): VNode =
|
||||
buildHtml(tdiv(class="attachment")):
|
||||
let
|
||||
named = "name=" in photo.url
|
||||
thumb = if named: photo.url
|
||||
elif bigThumb: photo.url & mediumWebp
|
||||
else: photo.url & smallWebp
|
||||
a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"):
|
||||
genImg(thumb, alt=photo.altText)
|
||||
if photo.altText.len > 0:
|
||||
renderAltText(photo.altText)
|
||||
buildHtml(tdiv(class="attachments")):
|
||||
for i, photos in groups:
|
||||
let margin = if i > 0: ".25em" else: ""
|
||||
tdiv(class="gallery-row", style={marginTop: margin}):
|
||||
for photo in photos:
|
||||
tdiv(class="attachment image"):
|
||||
let
|
||||
named = "name=" in photo
|
||||
small = if named: photo else: photo & smallWebp
|
||||
a(href=getOrigPicUrl(photo), class="still-image", target="_blank"):
|
||||
genImg(small)
|
||||
|
||||
proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
|
||||
case playbackType
|
||||
@@ -62,7 +65,7 @@ proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
|
||||
proc hasMp4Url(video: Video): bool =
|
||||
video.variants.anyIt(it.contentType == mp4)
|
||||
|
||||
proc renderVideoDisabled(playbackType: VideoType; path=""): VNode =
|
||||
proc renderVideoDisabled(playbackType: VideoType; path: string): VNode =
|
||||
buildHtml(tdiv(class="video-overlay")):
|
||||
case playbackType
|
||||
of mp4:
|
||||
@@ -78,98 +81,52 @@ proc renderVideoUnavailable(video: Video): VNode =
|
||||
else:
|
||||
p: text "This media is unavailable"
|
||||
|
||||
proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""; bigThumb=false): VNode =
|
||||
proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode =
|
||||
let
|
||||
playbackType = if not prefs.proxyVideos and videoData.hasMp4Url: mp4
|
||||
else: videoData.playbackType
|
||||
thumb = if bigThumb: getMediumPic(videoData.thumb) else: getSmallPic(videoData.thumb)
|
||||
|
||||
buildHtml(tdiv(class="attachment")):
|
||||
if not videoData.available:
|
||||
img(src=thumb, loading="lazy")
|
||||
renderVideoUnavailable(videoData)
|
||||
elif not prefs.isPlaybackEnabled(playbackType):
|
||||
img(src=thumb, loading="lazy")
|
||||
renderVideoDisabled(playbackType, path)
|
||||
else:
|
||||
let
|
||||
vars = videoData.variants.filterIt(it.contentType == playbackType)
|
||||
vidUrl = vars.sortedByIt(it.resolution)[^1].url
|
||||
source = if prefs.proxyVideos and vidUrl.startsWith("http"):
|
||||
getVidUrl(vidUrl) else: vidUrl
|
||||
case playbackType
|
||||
of mp4:
|
||||
video(poster=thumb, controls="", muted=prefs.muteVideos):
|
||||
source(src=source, `type`="video/mp4")
|
||||
of m3u8, vmap:
|
||||
video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos)
|
||||
verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
|
||||
tdiv(class="overlay-circle"): span(class="overlay-triangle")
|
||||
if videoData.durationMs > 0:
|
||||
tdiv(class="overlay-duration"): text getDuration(videoData)
|
||||
verbatim "</div>"
|
||||
|
||||
proc renderVideo*(video: Video; prefs: Prefs; path: string; bigThumb=false): VNode =
|
||||
let hasCardContent = video.description.len > 0 or video.title.len > 0
|
||||
container = if video.description.len == 0 and video.title.len == 0: ""
|
||||
else: " card-container"
|
||||
playbackType = if prefs.proxyVideos and video.hasMp4Url: mp4
|
||||
else: video.playbackType
|
||||
|
||||
buildHtml(tdiv(class="attachments card")):
|
||||
tdiv(class=("gallery-video" & (if hasCardContent: " card-container" else: ""))):
|
||||
renderVideoAttachment(video, prefs, path, bigThumb)
|
||||
if hasCardContent:
|
||||
tdiv(class="gallery-video" & container):
|
||||
tdiv(class="attachment video-container"):
|
||||
let thumb = getSmallPic(video.thumb)
|
||||
let canPlay = prefs.isPlaybackEnabled(playbackType)
|
||||
|
||||
if video.available and canPlay:
|
||||
let
|
||||
vidUrl = video.getVidVariant(playbackType).url
|
||||
source = if prefs.proxyVideos: getVidUrl(vidUrl)
|
||||
else: vidUrl
|
||||
case playbackType
|
||||
of mp4:
|
||||
video(src=source, poster=thumb, controls="", muted=prefs.muteVideos, preload="metadata")
|
||||
of m3u8, vmap:
|
||||
video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos)
|
||||
verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
|
||||
tdiv(class="overlay-circle"): span(class="overlay-triangle")
|
||||
verbatim "</div>"
|
||||
else:
|
||||
img(src=thumb, loading="lazy", decoding="async")
|
||||
if not canPlay:
|
||||
renderVideoDisabled(playbackType, path)
|
||||
else:
|
||||
renderVideoUnavailable(video)
|
||||
|
||||
if container.len > 0:
|
||||
tdiv(class="card-content"):
|
||||
h2(class="card-title"): text video.title
|
||||
if video.description.len > 0:
|
||||
p(class="card-description"): text video.description
|
||||
|
||||
proc renderGifAttachment(gif: Gif; prefs: Prefs): VNode =
|
||||
let thumb = getSmallPic(gif.thumb)
|
||||
|
||||
buildHtml(tdiv(class="attachment")):
|
||||
if not prefs.mp4Playback:
|
||||
img(src=thumb, loading="lazy")
|
||||
renderVideoDisabled(mp4)
|
||||
elif prefs.autoplayGifs:
|
||||
video(class="gif", poster=thumb, autoplay="", muted="", loop=""):
|
||||
source(src=getPicUrl(gif.url), `type`="video/mp4")
|
||||
else:
|
||||
video(class="gif", poster=thumb, controls="", muted="", loop=""):
|
||||
source(src=getPicUrl(gif.url), `type`="video/mp4")
|
||||
if gif.altText.len > 0:
|
||||
renderAltText(gif.altText)
|
||||
|
||||
proc renderGif(gif: Gif; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv(class="attachments media-gif")):
|
||||
renderGifAttachment(gif, prefs)
|
||||
|
||||
proc renderMedia(media: seq[Media]; prefs: Prefs; path: string; bigThumb=false): VNode =
|
||||
if media.len == 0:
|
||||
return nil
|
||||
|
||||
if media.len == 1:
|
||||
let item = media[0]
|
||||
if item.kind == videoMedia:
|
||||
return renderVideo(item.video, prefs, path, bigThumb)
|
||||
if item.kind == gifMedia:
|
||||
return renderGif(item.gif, prefs)
|
||||
|
||||
let
|
||||
groups = if media.len < 3: @[media]
|
||||
else: media.distribute(2)
|
||||
|
||||
buildHtml(tdiv(class="attachments")):
|
||||
for i, mediaGroup in groups:
|
||||
let margin = if i > 0: ".25em" else: ""
|
||||
let rowClass = "gallery-row" &
|
||||
(if mediaGroup.allIt(it.kind == photoMedia): "" else: " mixed-row")
|
||||
tdiv(class=rowClass, style={marginTop: margin}):
|
||||
for mediaItem in mediaGroup:
|
||||
case mediaItem.kind
|
||||
of photoMedia:
|
||||
renderPhotoAttachment(mediaItem.photo, bigThumb)
|
||||
of videoMedia:
|
||||
renderVideoAttachment(mediaItem.video, prefs, path, bigThumb)
|
||||
of gifMedia:
|
||||
renderGifAttachment(mediaItem.gif, prefs)
|
||||
tdiv(class="gallery-gif", style={maxHeight: "unset"}):
|
||||
tdiv(class="attachment"):
|
||||
video(class="gif", poster=getSmallPic(gif.thumb), autoplay=prefs.autoplayGifs,
|
||||
controls="", muted="", loop=""):
|
||||
source(src=getPicUrl(gif.url), `type`="video/mp4")
|
||||
|
||||
proc renderPoll(poll: Poll): VNode =
|
||||
buildHtml(tdiv(class="poll")):
|
||||
@@ -189,7 +146,7 @@ proc renderPoll(poll: Poll): VNode =
|
||||
proc renderCardImage(card: Card): VNode =
|
||||
buildHtml(tdiv(class="card-image-container")):
|
||||
tdiv(class="card-image"):
|
||||
genImg(card.image)
|
||||
img(src=getPicUrl(card.image), alt="", loading="lazy")
|
||||
if card.kind == player:
|
||||
tdiv(class="card-overlay"):
|
||||
tdiv(class="overlay-circle"):
|
||||
@@ -225,12 +182,14 @@ func formatStat(stat: int): string =
|
||||
if stat > 0: insertSep($stat, ',')
|
||||
else: ""
|
||||
|
||||
proc renderStats(stats: TweetStats): VNode =
|
||||
proc renderStats(stats: TweetStats; views: string): VNode =
|
||||
buildHtml(tdiv(class="tweet-stats")):
|
||||
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
|
||||
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
|
||||
span(class="tweet-stat"): icon "quote", formatStat(stats.quotes)
|
||||
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
|
||||
span(class="tweet-stat"): icon "views", formatStat(stats.views)
|
||||
if views.len > 0:
|
||||
span(class="tweet-stat"): icon "play", insertSep(views, ',')
|
||||
|
||||
proc renderReply(tweet: Tweet): VNode =
|
||||
buildHtml(tdiv(class="replying-to")):
|
||||
@@ -243,7 +202,8 @@ proc renderAttribution(user: User; prefs: Prefs): VNode =
|
||||
buildHtml(a(class="attribution", href=("/" & user.username))):
|
||||
renderMiniAvatar(user, prefs)
|
||||
strong: text user.fullname
|
||||
verifiedIcon(user)
|
||||
if user.verified:
|
||||
icon "ok", class="verified-icon", title="Verified account"
|
||||
|
||||
proc renderMediaTags(tags: seq[User]): VNode =
|
||||
buildHtml(tdiv(class="media-tag-block")):
|
||||
@@ -254,28 +214,19 @@ proc renderMediaTags(tags: seq[User]): VNode =
|
||||
if i < tags.high:
|
||||
text ", "
|
||||
|
||||
proc renderLatestPost(username: string; id: int64): VNode =
|
||||
buildHtml(tdiv(class="latest-post-version")):
|
||||
text "There's a new version of this post. "
|
||||
a(href=getLink(id, username)):
|
||||
text "See the latest post"
|
||||
|
||||
proc renderCommunityNote(note: string; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv(class="community-note")):
|
||||
tdiv(class="community-note-header"):
|
||||
icon "group"
|
||||
span: text "Community note"
|
||||
tdiv(class="community-note-text", dir="auto"):
|
||||
verbatim replaceUrls(note, prefs)
|
||||
|
||||
proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode =
|
||||
buildHtml(tdiv(class="quote-media-container")):
|
||||
renderMedia(quote.media, prefs, path)
|
||||
if quote.photos.len > 0:
|
||||
renderAlbum(quote)
|
||||
elif quote.video.isSome:
|
||||
renderVideo(quote.video.get(), prefs, path)
|
||||
elif quote.gif.isSome:
|
||||
renderGif(quote.gif.get(), prefs)
|
||||
|
||||
proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
|
||||
if not quote.available:
|
||||
return buildHtml(tdiv(class="quote unavailable")):
|
||||
a(class="unavailable-quote", href=getLink(quote, focus=false)):
|
||||
tdiv(class="unavailable-quote"):
|
||||
if quote.tombstone.len > 0:
|
||||
text quote.tombstone
|
||||
elif quote.text.len > 0:
|
||||
@@ -290,7 +241,6 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
|
||||
tdiv(class="fullname-and-username"):
|
||||
renderMiniAvatar(quote.user, prefs)
|
||||
linkUser(quote.user, class="fullname")
|
||||
verifiedIcon(quote.user)
|
||||
linkUser(quote.user, class="username")
|
||||
|
||||
span(class="tweet-date"):
|
||||
@@ -304,28 +254,12 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
|
||||
tdiv(class="quote-text", dir="auto"):
|
||||
verbatim replaceUrls(quote.text, prefs)
|
||||
|
||||
if quote.media.len > 0:
|
||||
renderQuoteMedia(quote, prefs, path)
|
||||
|
||||
if quote.note.len > 0 and not prefs.hideCommunityNotes:
|
||||
renderCommunityNote(quote.note, prefs)
|
||||
|
||||
if quote.hasThread:
|
||||
a(class="show-thread", href=getLink(quote)):
|
||||
text "Show this thread"
|
||||
|
||||
if quote.history.len > 0 and quote.id != max(quote.history):
|
||||
tdiv(class="quote-latest"):
|
||||
text "There's a new version of this post"
|
||||
|
||||
proc renderDisclosures*(tweet: Tweet): VNode =
|
||||
buildHtml(tdiv(class="disclosures")):
|
||||
if tweet.isAI:
|
||||
span(data-disclosure="ai"):
|
||||
icon "attention", "Made with AI"
|
||||
if tweet.isAd:
|
||||
span(data-disclosure="ad"):
|
||||
icon "attention", "Paid partnership (ad)"
|
||||
if quote.photos.len > 0 or quote.video.isSome or quote.gif.isSome:
|
||||
renderQuoteMedia(quote, prefs, path)
|
||||
|
||||
proc renderLocation*(tweet: Tweet): string =
|
||||
let (place, url) = tweet.getLocation()
|
||||
@@ -339,14 +273,14 @@ proc renderLocation*(tweet: Tweet): string =
|
||||
return $node
|
||||
|
||||
proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||
last=false; mainTweet=false; afterTweet=false; bigThumb=false): VNode =
|
||||
last=false; showThread=false; mainTweet=false; afterTweet=false): VNode =
|
||||
var divClass = class
|
||||
if index == -1 or last:
|
||||
divClass = "thread-last " & class
|
||||
|
||||
if not tweet.available:
|
||||
return buildHtml(tdiv(class=divClass & "unavailable timeline-item", data-username=tweet.user.username)):
|
||||
a(class="unavailable-box", href=getLink(tweet)):
|
||||
return buildHtml(tdiv(class=divClass & "unavailable timeline-item")):
|
||||
tdiv(class="unavailable-box"):
|
||||
if tweet.tombstone.len > 0:
|
||||
text tweet.tombstone
|
||||
elif tweet.text.len > 0:
|
||||
@@ -357,25 +291,23 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||
if tweet.quote.isSome:
|
||||
renderQuote(tweet.quote.get(), prefs, path)
|
||||
|
||||
let
|
||||
fullTweet = tweet
|
||||
pinned = tweet.pinned
|
||||
|
||||
let fullTweet = tweet
|
||||
var retweet: string
|
||||
var tweet = fullTweet
|
||||
if tweet.retweet.isSome:
|
||||
tweet = tweet.retweet.get
|
||||
retweet = fullTweet.user.fullname
|
||||
|
||||
buildHtml(tdiv(class=("timeline-item " & divClass), data-username=tweet.user.username)):
|
||||
buildHtml(tdiv(class=("timeline-item " & divClass))):
|
||||
if not mainTweet:
|
||||
a(class="tweet-link", href=getLink(tweet))
|
||||
|
||||
tdiv(class="tweet-body"):
|
||||
renderHeader(tweet, retweet, pinned, prefs)
|
||||
var views = ""
|
||||
renderHeader(tweet, retweet, prefs)
|
||||
|
||||
if not afterTweet and index == 0 and tweet.reply.len > 0 and
|
||||
(tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username or pinned):
|
||||
(tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username):
|
||||
renderReply(tweet)
|
||||
|
||||
var tweetClass = "tweet-content media-body"
|
||||
@@ -391,8 +323,14 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||
if tweet.card.isSome and tweet.card.get().kind != hidden:
|
||||
renderCard(tweet.card.get(), prefs, path)
|
||||
|
||||
if tweet.media.len > 0:
|
||||
renderMedia(tweet.media, prefs, path, bigThumb)
|
||||
if tweet.photos.len > 0:
|
||||
renderAlbum(tweet)
|
||||
elif tweet.video.isSome:
|
||||
renderVideo(tweet.video.get(), prefs, path)
|
||||
views = tweet.video.get().views
|
||||
elif tweet.gif.isSome:
|
||||
renderGif(tweet.gif.get(), prefs)
|
||||
views = "GIF"
|
||||
|
||||
if tweet.poll.isSome:
|
||||
renderPoll(tweet.poll.get())
|
||||
@@ -400,32 +338,18 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||
if tweet.quote.isSome:
|
||||
renderQuote(tweet.quote.get(), prefs, path)
|
||||
|
||||
if tweet.note.len > 0 and not prefs.hideCommunityNotes:
|
||||
renderCommunityNote(tweet.note, prefs)
|
||||
|
||||
if tweet.isAI or tweet.isAd:
|
||||
renderDisclosures(tweet)
|
||||
|
||||
let
|
||||
hasEdits = tweet.history.len > 1
|
||||
isLatest = hasEdits and tweet.id == max(tweet.history)
|
||||
|
||||
if mainTweet:
|
||||
p(class="tweet-published"):
|
||||
if hasEdits and isLatest:
|
||||
a(href=(getLink(tweet, focus=false) & "/history")):
|
||||
text &"Last edited {getTime(tweet)}"
|
||||
else:
|
||||
text &"{getTime(tweet)}"
|
||||
|
||||
if hasEdits and not isLatest:
|
||||
renderLatestPost(tweet.user.username, max(tweet.history))
|
||||
p(class="tweet-published"): text &"{getTime(tweet)}"
|
||||
|
||||
if tweet.mediaTags.len > 0:
|
||||
renderMediaTags(tweet.mediaTags)
|
||||
|
||||
if not prefs.hideTweetStats:
|
||||
renderStats(tweet.stats)
|
||||
renderStats(tweet.stats, views)
|
||||
|
||||
if showThread:
|
||||
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
|
||||
text "Show this thread"
|
||||
|
||||
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
|
||||
let node = buildHtml(html(lang="en")):
|
||||
|
||||
@@ -54,13 +54,6 @@ class Timeline(object):
|
||||
none = '.timeline-none'
|
||||
protected = '.timeline-protected'
|
||||
photo_rail = '.photo-rail-grid'
|
||||
media_view_tabs = '.media-view-tabs'
|
||||
media_view_timeline = '.media-view-tabs a[href$="media?view=timeline"]'
|
||||
media_view_grid = '.media-view-tabs a[href$="media?view=grid"]'
|
||||
media_view_gallery = '.media-view-tabs a[href$="media?view=gallery"]'
|
||||
media_view_active = '.media-view-tabs .tab-item.active a'
|
||||
grid_view = '.timeline.media-grid-view'
|
||||
gallery_view = '.timeline.media-gallery-view'
|
||||
|
||||
|
||||
class Conversation(object):
|
||||
@@ -86,7 +79,7 @@ class Media(object):
|
||||
row = '.gallery-row'
|
||||
image = '.still-image'
|
||||
video = '.gallery-video'
|
||||
gif = '.media-gif'
|
||||
gif = '.gallery-gif'
|
||||
|
||||
|
||||
class BaseTestCase(BaseCase):
|
||||
|
||||
1716
tests/poetry.lock
generated
1716
tests/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
[virtualenvs]
|
||||
in-project = true
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user