├── tests ├── requirements.txt ├── test_search.py ├── test_thread.py ├── test_quote.py ├── test_timeline.py ├── test_profile.py ├── base.py ├── test_card.py ├── test_tweet_media.py └── test_tweet.py ├── .github ├── FUNDING.yml └── workflows │ ├── build-publish-docker.yml │ └── run-tests.yml ├── public ├── css │ ├── themes │ │ ├── nitter.css │ │ ├── auto.css │ │ ├── auto_(twitter).css │ │ ├── twitter.css │ │ ├── pleroma.css │ │ ├── black.css │ │ ├── mastodon.css │ │ ├── twitter_dark.css │ │ └── dracula.css │ └── fontello.css ├── logo.png ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── fonts │ ├── fontello.eot │ ├── fontello.ttf │ ├── fontello.woff │ ├── fontello.woff2 │ ├── LICENSE.txt │ └── fontello.svg ├── apple-touch-icon.png ├── robots.txt ├── android-chrome-192x192.png ├── android-chrome-384x384.png ├── android-chrome-512x512.png ├── browserconfig.xml ├── site.webmanifest ├── safari-pinned-tab.svg ├── lp.svg ├── js │ ├── hlsPlayback.js │ └── infiniteScroll.js └── md │ └── about.md ├── screenshot.png ├── src ├── experimental │ ├── parser.nim │ ├── types │ │ ├── guestaccount.nim │ │ ├── graphuser.nim │ │ ├── common.nim │ │ ├── timeline.nim │ │ ├── graphlistmembers.nim │ │ ├── user.nim │ │ └── unifiedcard.nim │ └── parser │ │ ├── guestaccount.nim │ │ ├── utils.nim │ │ ├── graphql.nim │ │ ├── slices.nim │ │ ├── user.nim │ │ └── unifiedcard.nim ├── routes │ ├── debug.nim │ ├── unsupported.nim │ ├── resolver.nim │ ├── embed.nim │ ├── preferences.nim │ ├── router_utils.nim │ ├── search.nim │ ├── list.nim │ ├── status.nim │ ├── media.nim │ ├── rss.nim │ └── timeline.nim ├── sass │ ├── tweet │ │ ├── embed.scss │ │ ├── poll.scss │ │ ├── video.scss │ │ ├── quote.scss │ │ ├── card.scss │ │ ├── media.scss │ │ ├── thread.scss │ │ └── _base.scss │ ├── general.scss │ ├── include │ │ ├── _variables.scss │ │ └── _mixins.css │ ├── profile │ │ ├── _base.scss │ │ ├── photo-rail.scss │ │ └── card.scss │ ├── navbar.scss │ ├── search.scss │ ├── timeline.scss │ ├── inputs.scss │ └── index.scss ├── prefs.nim ├── views │ ├── feature.nim │ ├── opensearch.nimf │ ├── embed.nim │ ├── about.nim │ ├── list.nim │ ├── preferences.nim │ ├── status.nim │ ├── renderutils.nim │ ├── profile.nim │ ├── timeline.nim │ ├── search.nim │ └── general.nim ├── http_pool.nim ├── utils.nim ├── config.nim ├── nitter.nim ├── query.nim └── consts.nim ├── .dockerignore ├── tools ├── gencss.nim └── rendermd.nim ├── .gitignore ├── config.nims ├── Dockerfile ├── Dockerfile.arm64 ├── nitter.nimble ├── docker-compose.yml ├── .travis.yml ├── nitter.example.conf └── twitter_oauth.sh /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | seleniumbase 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: zedeus 2 | liberapay: zedeus 3 | patreon: nitter 4 | -------------------------------------------------------------------------------- /public/css/themes/nitter.css: -------------------------------------------------------------------------------- 1 | body { 2 | /* uses default values */ 3 | } 4 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrivacyDevel/nitter/HEAD/screenshot.png -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrivacyDevel/nitter/HEAD/public/logo.png -------------------------------------------------------------------------------- /src/experimental/parser.nim: -------------------------------------------------------------------------------- 1 | import parser/[user, graphql] 2 | export user, graphql 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.png 2 | *.md 3 | LICENSE 4 | docker-compose.yml 5 | Dockerfile 6 | tests/ 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrivacyDevel/nitter/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrivacyDevel/nitter/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrivacyDevel/nitter/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/fonts/fontello.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrivacyDevel/nitter/HEAD/public/fonts/fontello.eot -------------------------------------------------------------------------------- /public/fonts/fontello.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrivacyDevel/nitter/HEAD/public/fonts/fontello.ttf -------------------------------------------------------------------------------- /public/fonts/fontello.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrivacyDevel/nitter/HEAD/public/fonts/fontello.woff -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrivacyDevel/nitter/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/fonts/fontello.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrivacyDevel/nitter/HEAD/public/fonts/fontello.woff2 -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | Crawl-delay: 1 4 | User-agent: Twitterbot 5 | Disallow: 6 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrivacyDevel/nitter/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrivacyDevel/nitter/HEAD/public/android-chrome-384x384.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrivacyDevel/nitter/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/css/themes/auto.css: -------------------------------------------------------------------------------- 1 | @import "nitter.css" (prefers-color-scheme: dark); 2 | @import "twitter.css" (prefers-color-scheme: light); 3 | -------------------------------------------------------------------------------- /src/experimental/types/guestaccount.nim: -------------------------------------------------------------------------------- 1 | type 2 | RawAccount* = object 3 | oauthToken*: string 4 | oauthTokenSecret*: string 5 | -------------------------------------------------------------------------------- /public/css/themes/auto_(twitter).css: -------------------------------------------------------------------------------- 1 | @import "twitter_dark.css" (prefers-color-scheme: dark); 2 | @import "twitter.css" (prefers-color-scheme: light); 3 | -------------------------------------------------------------------------------- /tools/gencss.nim: -------------------------------------------------------------------------------- 1 | import sass 2 | 3 | compileFile("src/sass/index.scss", 4 | outputPath = "public/css/style.css", 5 | includePaths = @["src/sass/include"]) 6 | 7 | echo "Compiled to public/css/style.css" 8 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | #2b5797 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /tools/rendermd.nim: -------------------------------------------------------------------------------- 1 | import std/[os, strutils] 2 | import markdown 3 | 4 | for file in walkFiles("public/md/*.md"): 5 | let 6 | html = markdown(readFile(file)) 7 | output = file.replace(".md", ".html") 8 | 9 | output.writeFile(html) 10 | echo "Rendered ", output 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | nitter 2 | *.html 3 | *.db 4 | /tests/__pycache__ 5 | /tests/geckodriver.log 6 | /tests/downloaded_files 7 | /tests/latest_logs 8 | /tools/gencss 9 | /tools/rendermd 10 | /public/css/style.css 11 | /public/md/*.html 12 | nitter.conf 13 | guest_accounts.json* 14 | dump.rdb 15 | -------------------------------------------------------------------------------- /config.nims: -------------------------------------------------------------------------------- 1 | --define:ssl 2 | --define:useStdLib 3 | --threads:off 4 | 5 | # workaround httpbeast file upload bug 6 | --assertions:off 7 | 8 | # disable annoying warnings 9 | warning("GcUnsafe2", off) 10 | warning("HoleEnumConv", off) 11 | hint("XDeclaredButNotUsed", off) 12 | hint("XCannotRaiseY", off) 13 | hint("User", off) 14 | -------------------------------------------------------------------------------- /tests/test_search.py: -------------------------------------------------------------------------------- 1 | from base import BaseTestCase 2 | from parameterized import parameterized 3 | 4 | 5 | #class SearchTest(BaseTestCase): 6 | #@parameterized.expand([['@mobile_test'], ['@mobile_test_2']]) 7 | #def test_username_search(self, username): 8 | #self.search_username(username) 9 | #self.assert_text(f'{username}') 10 | -------------------------------------------------------------------------------- /src/routes/debug.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import jester 3 | import router_utils 4 | import ".."/[auth, types] 5 | 6 | proc createDebugRouter*(cfg: Config) = 7 | router debug: 8 | get "/.health": 9 | respJson getAccountPoolHealth() 10 | 11 | get "/.accounts": 12 | cond cfg.enableDebug 13 | respJson getAccountPoolDebug() 14 | -------------------------------------------------------------------------------- /src/sass/tweet/embed.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | @import '_mixins'; 3 | 4 | .embed-video { 5 | .gallery-video { 6 | width: 100%; 7 | height: 100%; 8 | position: absolute; 9 | background-color: black; 10 | top: 0%; 11 | left: 0%; 12 | } 13 | 14 | .video-container { 15 | max-height: unset; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/experimental/types/graphuser.nim: -------------------------------------------------------------------------------- 1 | import options 2 | from ../../types import User 3 | 4 | type 5 | GraphUser* = object 6 | data*: tuple[userResult: UserData] 7 | 8 | UserData* = object 9 | result*: UserResult 10 | 11 | UserResult = object 12 | legacy*: User 13 | restId*: string 14 | isBlueVerified*: bool 15 | unavailableReason*: Option[string] 16 | -------------------------------------------------------------------------------- /src/experimental/types/common.nim: -------------------------------------------------------------------------------- 1 | from ../../types import Error 2 | 3 | type 4 | Url* = object 5 | url*: string 6 | expandedUrl*: string 7 | displayUrl*: string 8 | indices*: array[2, int] 9 | 10 | ErrorObj* = object 11 | code*: Error 12 | message*: string 13 | 14 | Errors* = object 15 | errors*: seq[ErrorObj] 16 | 17 | proc contains*(codes: set[Error]; errors: Errors): bool = 18 | for e in errors.errors: 19 | if e.code in codes: 20 | return true 21 | -------------------------------------------------------------------------------- /src/experimental/types/timeline.nim: -------------------------------------------------------------------------------- 1 | import std/tables 2 | from ../../types import User 3 | 4 | type 5 | Search* = object 6 | globalObjects*: GlobalObjects 7 | timeline*: Timeline 8 | 9 | GlobalObjects = object 10 | users*: Table[string, User] 11 | 12 | Timeline = object 13 | instructions*: seq[Instructions] 14 | 15 | Instructions = object 16 | addEntries*: tuple[entries: seq[Entry]] 17 | 18 | Entry = object 19 | entryId*: string 20 | content*: tuple[operation: Operation] 21 | 22 | Operation = object 23 | cursor*: tuple[value, cursorType: string] 24 | -------------------------------------------------------------------------------- /src/prefs.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import tables 3 | import types, prefs_impl 4 | from config import get 5 | from parsecfg import nil 6 | 7 | export genUpdatePrefs, genResetPrefs 8 | 9 | var defaultPrefs*: Prefs 10 | 11 | proc updateDefaultPrefs*(cfg: parsecfg.Config) = 12 | genDefaultPrefs() 13 | 14 | proc getPrefs*(cookies: Table[string, string]): Prefs = 15 | result = defaultPrefs 16 | genCookiePrefs(cookies) 17 | 18 | template getPref*(cookies: Table[string, string], pref): untyped = 19 | bind genCookiePref 20 | var res = defaultPrefs.`pref` 21 | genCookiePref(cookies, pref, res) 22 | res 23 | -------------------------------------------------------------------------------- /src/views/feature.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import karax/[karaxdsl, vdom] 3 | 4 | proc renderFeature*(): VNode = 5 | buildHtml(tdiv(class="overlay-panel")): 6 | h1: text "Unsupported feature" 7 | p: 8 | text "Nitter doesn't support this feature yet, but it might in the future. " 9 | text "You can check for an issue and open one if needed here: " 10 | a(href="https://github.com/zedeus/nitter/issues"): 11 | text "https://github.com/zedeus/nitter/issues" 12 | p: 13 | text "To find out more about the Nitter project, see the " 14 | a(href="/about"): text "About page" 15 | -------------------------------------------------------------------------------- /src/views/opensearch.nimf: -------------------------------------------------------------------------------- 1 | #? stdtmpl(subsChar = '$', metaChar = '#') 2 | ## SPDX-License-Identifier: AGPL-3.0-only 3 | #proc generateOpenSearchXML*(name, hostname, url: string): string = 4 | # result = "" 5 | 6 | 8 | ${name} 9 | Twitter search via ${hostname} 10 | UTF-8 11 | 12 | 13 | #end proc 14 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Nitter", 3 | "short_name": "Nitter", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-384x384.png", 12 | "sizes": "384x384", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/android-chrome-512x512.png", 17 | "sizes": "512x512", 18 | "type": "image/png" 19 | } 20 | ], 21 | "theme_color": "#333333", 22 | "background_color": "#333333", 23 | "display": "standalone" 24 | } 25 | -------------------------------------------------------------------------------- /src/routes/unsupported.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import jester 3 | 4 | import router_utils 5 | import ../types 6 | import ../views/[general, feature] 7 | 8 | export feature 9 | 10 | proc createUnsupportedRouter*(cfg: Config) = 11 | router unsupported: 12 | template feature {.dirty.} = 13 | resp renderMain(renderFeature(), request, cfg, themePrefs()) 14 | 15 | get "/about/feature": feature() 16 | get "/login/?@i?": feature() 17 | get "/@name/lists/?": feature() 18 | 19 | get "/intent/?@i?": 20 | cond @"i" notin ["user"] 21 | feature() 22 | 23 | get "/i/@i?/?@j?": 24 | cond @"i" notin ["status", "lists" , "user"] 25 | feature() 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nimlang/nim:2.0.0-alpine-regular as nim 2 | LABEL maintainer="setenforce@protonmail.com" 3 | 4 | RUN apk --no-cache add libsass-dev pcre 5 | 6 | WORKDIR /src/nitter 7 | 8 | COPY nitter.nimble . 9 | RUN nimble install -y --depsOnly 10 | 11 | COPY . . 12 | RUN nimble build -d:danger -d:lto -d:strip \ 13 | && nimble scss \ 14 | && nimble md 15 | 16 | FROM alpine:latest 17 | WORKDIR /src/ 18 | RUN apk --no-cache add pcre ca-certificates 19 | COPY --from=nim /src/nitter/nitter ./ 20 | COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf 21 | COPY --from=nim /src/nitter/public ./public 22 | EXPOSE 8080 23 | RUN adduser -h /src/ -D -s /bin/sh nitter 24 | USER nitter 25 | CMD ./nitter 26 | -------------------------------------------------------------------------------- /src/views/embed.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import options 3 | import karax/[karaxdsl, vdom] 4 | from jester import Request 5 | 6 | import ".."/[types, formatters] 7 | import general, tweet 8 | 9 | const doctype = "\n" 10 | 11 | proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = 12 | let thumb = get(tweet.video).thumb 13 | let vidUrl = getVideoEmbed(cfg, tweet.id) 14 | let prefs = Prefs(hlsPlayback: true, mp4Playback: true) 15 | let node = buildHtml(html(lang="en")): 16 | renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb])) 17 | 18 | body: 19 | tdiv(class="embed-video"): 20 | renderVideo(get(tweet.video), prefs, "") 21 | 22 | result = doctype & $node 23 | -------------------------------------------------------------------------------- /Dockerfile.arm64: -------------------------------------------------------------------------------- 1 | FROM alpine:3.18 as nim 2 | LABEL maintainer="setenforce@protonmail.com" 3 | 4 | RUN apk --no-cache add libsass-dev pcre gcc git libc-dev "nim=1.6.14-r0" "nimble=0.13.1-r2" 5 | 6 | WORKDIR /src/nitter 7 | 8 | COPY nitter.nimble . 9 | RUN nimble install -y --depsOnly 10 | 11 | COPY . . 12 | RUN nimble build -d:danger -d:lto -d:strip \ 13 | && nimble scss \ 14 | && nimble md 15 | 16 | FROM alpine:3.18 17 | WORKDIR /src/ 18 | RUN apk --no-cache add pcre ca-certificates openssl1.1-compat 19 | COPY --from=nim /src/nitter/nitter ./ 20 | COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf 21 | COPY --from=nim /src/nitter/public ./public 22 | EXPOSE 8080 23 | RUN adduser -h /src/ -D -s /bin/sh nitter 24 | USER nitter 25 | CMD ./nitter 26 | -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/routes/resolver.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils 3 | 4 | import jester 5 | 6 | import router_utils 7 | import ".."/[types, api] 8 | import ../views/general 9 | 10 | template respResolved*(url, kind: string): untyped = 11 | let u = url 12 | if u.len == 0: 13 | resp showError("Invalid $1 link" % kind, cfg) 14 | else: 15 | redirect(u) 16 | 17 | proc createResolverRouter*(cfg: Config) = 18 | router resolver: 19 | get "/cards/@card/@id": 20 | let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"] 21 | respResolved(await resolve(url, cookiePrefs()), "card") 22 | 23 | get "/t.co/@url": 24 | let url = "https://t.co/" & @"url" 25 | respResolved(await resolve(url, cookiePrefs()), "t.co") 26 | -------------------------------------------------------------------------------- /src/experimental/parser/guestaccount.nim: -------------------------------------------------------------------------------- 1 | import std/strutils 2 | import jsony 3 | import ../types/guestaccount 4 | from ../../types import GuestAccount 5 | 6 | proc toGuestAccount(account: RawAccount): GuestAccount = 7 | let id = account.oauthToken[0 ..< account.oauthToken.find('-')] 8 | result = GuestAccount( 9 | id: parseBiggestInt(id), 10 | oauthToken: account.oauthToken, 11 | oauthSecret: account.oauthTokenSecret 12 | ) 13 | 14 | proc parseGuestAccount*(raw: string): GuestAccount = 15 | let rawAccount = raw.fromJson(RawAccount) 16 | result = rawAccount.toGuestAccount 17 | 18 | proc parseGuestAccounts*(path: string): seq[GuestAccount] = 19 | let rawAccounts = readFile(path).fromJson(seq[RawAccount]) 20 | for account in rawAccounts: 21 | result.add account.toGuestAccount 22 | -------------------------------------------------------------------------------- /src/experimental/parser/utils.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import std/[sugar, strutils, times] 3 | import ../types/common 4 | import ../../utils as uutils 5 | 6 | template parseTime(time: string; f: static string; flen: int): DateTime = 7 | if time.len != flen: return 8 | parse(time, f, utc()) 9 | 10 | proc parseIsoDate*(date: string): DateTime = 11 | date.parseTime("yyyy-MM-dd\'T\'HH:mm:ss\'Z\'", 20) 12 | 13 | proc parseTwitterDate*(date: string): DateTime = 14 | date.parseTime("ddd MMM dd hh:mm:ss \'+0000\' yyyy", 30) 15 | 16 | proc getImageUrl*(url: string): string = 17 | url.dup(removePrefix(twimg), removePrefix(https)) 18 | 19 | template handleErrors*(body) = 20 | if json.startsWith("{\"errors"): 21 | for error {.inject.} in json.fromJson(Errors).errors: 22 | body 23 | -------------------------------------------------------------------------------- /public/lp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/experimental/types/graphlistmembers.nim: -------------------------------------------------------------------------------- 1 | import graphuser 2 | 3 | type 4 | GraphListMembers* = object 5 | data*: tuple[list: List] 6 | 7 | List = object 8 | membersTimeline*: tuple[timeline: Timeline] 9 | 10 | Timeline = object 11 | instructions*: seq[Instruction] 12 | 13 | Instruction = object 14 | kind*: string 15 | entries*: seq[tuple[content: Content]] 16 | 17 | ContentEntryType* = enum 18 | TimelineTimelineItem 19 | TimelineTimelineCursor 20 | 21 | Content = object 22 | case entryType*: ContentEntryType 23 | of TimelineTimelineItem: 24 | itemContent*: tuple[userResults: UserData] 25 | of TimelineTimelineCursor: 26 | value*: string 27 | cursorType*: string 28 | 29 | proc renameHook*(v: var Instruction; fieldName: var string) = 30 | if fieldName == "type": 31 | fieldName = "kind" 32 | -------------------------------------------------------------------------------- /src/views/about.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import os, strformat 3 | import karax/[karaxdsl, vdom] 4 | 5 | const 6 | date = staticExec("git show -s --format=\"%cd\" --date=format:\"%Y.%m.%d\"") 7 | hash = staticExec("git show -s --format=\"%h\"") 8 | link = "https://github.com/zedeus/nitter/commit/" & hash 9 | version = &"{date}-{hash}" 10 | 11 | var aboutHtml: string 12 | 13 | proc initAboutPage*(dir: string) = 14 | try: 15 | aboutHtml = readFile(dir/"md/about.html") 16 | except IOError: 17 | stderr.write (dir/"md/about.html") & " not found, please run `nimble md`\n" 18 | aboutHtml = "

About page is missing



" 19 | 20 | proc renderAbout*(): VNode = 21 | buildHtml(tdiv(class="overlay-panel")): 22 | verbatim aboutHtml 23 | h2: text "Instance info" 24 | p: 25 | text "Version " 26 | a(href=link): text version 27 | -------------------------------------------------------------------------------- /src/sass/tweet/poll.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | 3 | .poll-meter { 4 | overflow: hidden; 5 | position: relative; 6 | margin: 6px 0; 7 | height: 26px; 8 | background: var(--bg_color); 9 | border-radius: 5px; 10 | display: flex; 11 | align-items: center; 12 | } 13 | 14 | .poll-choice-bar { 15 | height: 100%; 16 | position: absolute; 17 | background: var(--dark_grey); 18 | } 19 | 20 | .poll-choice-value { 21 | position: relative; 22 | font-weight: bold; 23 | margin-left: 5px; 24 | margin-right: 6px; 25 | min-width: 30px; 26 | text-align: right; 27 | pointer-events: all; 28 | } 29 | 30 | .poll-choice-option { 31 | position: relative; 32 | pointer-events: all; 33 | } 34 | 35 | .poll-info { 36 | color: var(--grey); 37 | pointer-events: all; 38 | } 39 | 40 | .leader .poll-choice-bar { 41 | background: var(--accent_dark); 42 | } 43 | -------------------------------------------------------------------------------- /src/sass/general.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | @import '_mixins'; 3 | 4 | .panel-container { 5 | margin: auto; 6 | font-size: 130%; 7 | } 8 | 9 | .error-panel { 10 | @include center-panel(var(--error_red)); 11 | text-align: center; 12 | } 13 | 14 | .search-bar > form { 15 | @include center-panel(var(--darkest_grey)); 16 | 17 | button { 18 | background: var(--bg_elements); 19 | color: var(--fg_color); 20 | border: 0; 21 | border-radius: 3px; 22 | cursor: pointer; 23 | font-weight: bold; 24 | width: 30px; 25 | height: 30px; 26 | } 27 | 28 | input { 29 | font-size: 16px; 30 | width: 100%; 31 | background: var(--bg_elements); 32 | color: var(--fg_color); 33 | border: 0; 34 | border-radius: 4px; 35 | padding: 4px; 36 | margin-right: 8px; 37 | height: unset; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /nitter.nimble: -------------------------------------------------------------------------------- 1 | # Package 2 | 3 | version = "0.1.0" 4 | author = "zedeus" 5 | description = "An alternative front-end for Twitter" 6 | license = "AGPL-3.0" 7 | srcDir = "src" 8 | bin = @["nitter"] 9 | 10 | 11 | # Dependencies 12 | 13 | requires "nim >= 1.6.10" 14 | requires "jester#baca3f" 15 | requires "karax#5cf360c" 16 | requires "sass#7dfdd03" 17 | requires "nimcrypto#a079df9" 18 | requires "markdown#158efe3" 19 | requires "packedjson#9e6fbb6" 20 | requires "supersnappy#6c94198" 21 | requires "redpool#8b7c1db" 22 | requires "https://github.com/zedeus/redis#d0a0e6f" 23 | requires "zippy#ca5989a" 24 | requires "flatty#e668085" 25 | requires "jsony#1de1f08" 26 | requires "oauth#b8c163b" 27 | 28 | # Tasks 29 | 30 | task scss, "Generate css": 31 | exec "nimble c --hint[Processing]:off -d:danger -r tools/gencss" 32 | 33 | task md, "Render md": 34 | exec "nimble c --hint[Processing]:off -d:danger -r tools/rendermd" 35 | -------------------------------------------------------------------------------- /public/js/hlsPlayback.js: -------------------------------------------------------------------------------- 1 | // @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0 2 | // SPDX-License-Identifier: AGPL-3.0-only 3 | function playVideo(overlay) { 4 | const video = overlay.parentElement.querySelector('video'); 5 | const url = video.getAttribute("data-url"); 6 | video.setAttribute("controls", ""); 7 | overlay.style.display = "none"; 8 | 9 | if (Hls.isSupported()) { 10 | var hls = new Hls({autoStartLoad: false}); 11 | hls.loadSource(url); 12 | hls.attachMedia(video); 13 | hls.on(Hls.Events.MANIFEST_PARSED, function () { 14 | hls.loadLevel = hls.levels.length - 1; 15 | hls.startLoad(); 16 | video.play(); 17 | }); 18 | } else if (video.canPlayType('application/vnd.apple.mpegurl')) { 19 | video.src = url; 20 | video.addEventListener('canplay', function() { 21 | video.play(); 22 | }); 23 | } 24 | } 25 | // @license-end 26 | -------------------------------------------------------------------------------- /public/css/themes/twitter.css: -------------------------------------------------------------------------------- 1 | body { 2 | --bg_color: #E6ECF0; 3 | --fg_color: #0F0F0F; 4 | --fg_faded: #657786; 5 | --fg_dark: var(--fg_faded); 6 | --fg_nav: var(--accent); 7 | 8 | --bg_panel: #FFFFFF; 9 | --bg_elements: #FDFDFD; 10 | --bg_overlays: #FFFFFF; 11 | --bg_hover: #F5F8FA; 12 | 13 | --grey: var(--fg_faded); 14 | --dark_grey: #D6D6D6; 15 | --darker_grey: #CECECE; 16 | --darkest_grey: #ECECEC; 17 | --border_grey: #E6ECF0; 18 | 19 | --accent: #1DA1F2; 20 | --accent_light: #A0EDFF; 21 | --accent_dark: var(--accent); 22 | --accent_border: #1DA1F296; 23 | 24 | --play_button: #D84D4D; 25 | --play_button_hover: #FF6C60; 26 | 27 | --more_replies_dots: #0199F7; 28 | --error_red: #FF7266; 29 | 30 | --verified_blue: var(--accent); 31 | --icon_text: #F8F8F2; 32 | 33 | --tab: var(--accent); 34 | --tab_selected: #000000; 35 | 36 | --profile_stat: var(--fg_dark); 37 | } 38 | -------------------------------------------------------------------------------- /public/css/themes/pleroma.css: -------------------------------------------------------------------------------- 1 | body { 2 | --bg_color: #0A1117; 3 | --fg_color: #B9B9BA; 4 | --fg_faded: #B9B9BAFA; 5 | --fg_dark: var(--accent); 6 | --fg_nav: var(--accent); 7 | 8 | --bg_panel: #121A24; 9 | --bg_elements: #182230; 10 | --bg_overlays: var(--bg_elements); 11 | --bg_hover: #1B2735; 12 | 13 | --grey: #666A6F; 14 | --dark_grey: #42413D; 15 | --darker_grey: #293442; 16 | --darkest_grey: #202935; 17 | --border_grey: #1C2737; 18 | 19 | --accent: #D8A070; 20 | --accent_light: #DEB897; 21 | --accent_dark: #6D533C; 22 | --accent_border: #947050; 23 | 24 | --play_button: var(--accent); 25 | --play_button_hover: var(--accent_light); 26 | 27 | --more_replies_dots: #886446; 28 | --error_red: #420A05; 29 | 30 | --verified_blue: #1DA1F2; 31 | --icon_text: #F8F8F8; 32 | 33 | --tab: var(--fg_color); 34 | --tab_selected: var(--accent); 35 | 36 | --profile_stat: var(--fg_color); 37 | } 38 | -------------------------------------------------------------------------------- /public/css/themes/black.css: -------------------------------------------------------------------------------- 1 | body { 2 | --bg_color: #000000; 3 | --fg_color: #FFFFFF; 4 | --fg_faded: #FFFFFFD4; 5 | --fg_dark: var(--accent); 6 | --fg_nav: var(--accent); 7 | 8 | --bg_panel: #0C0C0C; 9 | --bg_elements: #000000; 10 | --bg_overlays: var(--bg_panel); 11 | --bg_hover: #131313; 12 | 13 | --grey: #E2E2E2; 14 | --dark_grey: #4E4E4E; 15 | --darker_grey: #272727; 16 | --darkest_grey: #212121; 17 | --border_grey: #7D7D7D; 18 | 19 | --accent: #FF6C60; 20 | --accent_light: #FFACA0; 21 | --accent_dark: #909090; 22 | --accent_border: #FF6C6091; 23 | 24 | --play_button: var(--accent); 25 | --play_button_hover: var(--accent_light); 26 | 27 | --more_replies_dots: #A7A7A7; 28 | --error_red: #420A05; 29 | 30 | --verified_blue: #1DA1F2; 31 | --icon_text: var(--fg_color); 32 | 33 | --tab: var(--fg_color); 34 | --tab_selected: var(--accent); 35 | 36 | --profile_stat: var(--fg_color); 37 | } 38 | -------------------------------------------------------------------------------- /public/css/themes/mastodon.css: -------------------------------------------------------------------------------- 1 | body { 2 | --bg_color: #191B22; 3 | --fg_color: #FFFFFF; 4 | --fg_faded: #F8F8F2CF; 5 | --fg_dark: var(--accent); 6 | --fg_nav: var(--accent); 7 | 8 | --bg_panel: #282C37; 9 | --bg_elements: #22252F; 10 | --bg_overlays: var(--bg_panel); 11 | --bg_hover: var(--bg_elements); 12 | 13 | --grey: #8595AB; 14 | --dark_grey: #393F4F; 15 | --darker_grey: #1C1E23; 16 | --darkest_grey: #1F2125; 17 | --border_grey: #393F4F; 18 | 19 | --accent: #9BAEC8; 20 | --accent_light: #CDDEF5; 21 | --accent_dark: #748294; 22 | --accent_border: var(--accent_dark); 23 | 24 | --play_button: var(--accent); 25 | --play_button_hover: var(--accent_light); 26 | 27 | --more_replies_dots: #7F8DA0; 28 | --error_red: #420A05; 29 | 30 | --verified_blue: #1DA1F2; 31 | --icon_text: var(--fg_color); 32 | 33 | --tab: var(--accent); 34 | --tab_selected: #D9E1E8; 35 | 36 | --profile_stat: var(--fg_color); 37 | } 38 | -------------------------------------------------------------------------------- /src/views/list.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strformat 3 | import karax/[karaxdsl, vdom] 4 | 5 | import renderutils 6 | import ".."/[types, utils] 7 | 8 | proc renderListTabs*(query: Query; path: string): VNode = 9 | buildHtml(ul(class="tab")): 10 | li(class=query.getTabClass(posts)): 11 | a(href=(path)): text "Tweets" 12 | li(class=query.getTabClass(userList)): 13 | a(href=(path & "/members")): text "Members" 14 | 15 | proc renderList*(body: VNode; query: Query; list: List): VNode = 16 | buildHtml(tdiv(class="timeline-container")): 17 | if list.banner.len > 0: 18 | tdiv(class="timeline-banner"): 19 | a(href=getPicUrl(list.banner), target="_blank"): 20 | genImg(list.banner) 21 | 22 | tdiv(class="timeline-header"): 23 | text &"\"{list.name}\" by @{list.username}" 24 | 25 | tdiv(class="timeline-description"): 26 | text list.description 27 | 28 | renderListTabs(query, &"/i/lists/{list.id}") 29 | body 30 | -------------------------------------------------------------------------------- /public/css/themes/twitter_dark.css: -------------------------------------------------------------------------------- 1 | body { 2 | --bg_color: #101821; 3 | --fg_color: #FFFFFF; 4 | --fg_faded: #8899A6; 5 | --fg_dark: var(--fg_faded); 6 | --fg_nav: var(--accent); 7 | 8 | --bg_panel: #15202B; 9 | --bg_elements: var(--bg_panel); 10 | --bg_overlays: var(--bg_panel); 11 | --bg_hover: #192734; 12 | 13 | --grey: var(--fg_faded); 14 | --dark_grey: #38444D; 15 | --darker_grey: #2A343C; 16 | --darkest_grey:#1B2835; 17 | --border_grey: #38444D; 18 | 19 | --accent: #1B95E0; 20 | --accent_light: #80CEFF; 21 | --accent_dark: #2B608A; 22 | --accent_border: #1B95E096; 23 | 24 | --play_button: var(--accent); 25 | --play_button_hover: var(--accent_light); 26 | 27 | --more_replies_dots: #39719C; 28 | --error_red: #FF7266; 29 | 30 | --verified_blue: var(--accent); 31 | --icon_text: var(--fg_color); 32 | 33 | --tab: var(--grey); 34 | --tab_selected: var(--accent); 35 | 36 | --profile_stat: var(--fg_color); 37 | } 38 | -------------------------------------------------------------------------------- /src/sass/include/_variables.scss: -------------------------------------------------------------------------------- 1 | // colors 2 | $bg_color: #0F0F0F; 3 | $fg_color: #F8F8F2; 4 | $fg_faded: #F8F8F2CF; 5 | $fg_dark: #FF6C60; 6 | $fg_nav: #FF6C60; 7 | 8 | $bg_panel: #161616; 9 | $bg_elements: #121212; 10 | $bg_overlays: #1F1F1F; 11 | $bg_hover: #1A1A1A; 12 | 13 | $grey: #888889; 14 | $dark_grey: #404040; 15 | $darker_grey: #282828; 16 | $darkest_grey: #222222; 17 | $border_grey: #3E3E35; 18 | 19 | $accent: #FF6C60; 20 | $accent_light: #FFACA0; 21 | $accent_dark: #8A3731; 22 | $accent_border: #FF6C6091; 23 | 24 | $play_button: #D8574D; 25 | $play_button_hover: #FF6C60; 26 | 27 | $more_replies_dots: #AD433B; 28 | $error_red: #420A05; 29 | 30 | $verified_blue: #1DA1F2; 31 | $verified_business: #FAC82B; 32 | $verified_government: #C1B6A4; 33 | $icon_text: $fg_color; 34 | 35 | $tab: $fg_color; 36 | $tab_selected: $accent; 37 | 38 | $shadow: rgba(0,0,0,.6); 39 | $shadow_dark: rgba(0,0,0,.2); 40 | 41 | //fonts 42 | $font_0: Helvetica Neue; 43 | $font_1: Helvetica; 44 | $font_2: Arial; 45 | $font_3: sans-serif; 46 | $font_4: fontello; 47 | -------------------------------------------------------------------------------- /public/css/themes/dracula.css: -------------------------------------------------------------------------------- 1 | body { 2 | --bg_color: #282a36; 3 | --fg_color: #f8f8f2; 4 | --fg_faded: #818eb6; 5 | --fg_dark: var(--fg_faded); 6 | --fg_nav: var(--accent); 7 | 8 | --bg_panel: #343746; 9 | --bg_elements: #292b36; 10 | --bg_overlays: #44475a; 11 | --bg_hover: #2f323f; 12 | 13 | --grey: var(--fg_faded); 14 | --dark_grey: #44475a; 15 | --darker_grey: #3d4051; 16 | --darkest_grey: #363948; 17 | --border_grey: #44475a; 18 | 19 | --accent: #bd93f9; 20 | --accent_light: #caa9fa; 21 | --accent_dark: var(--accent); 22 | --accent_border: #ff79c696; 23 | 24 | --play_button: #ffb86c; 25 | --play_button_hover: #ffc689; 26 | 27 | --more_replies_dots: #bd93f9; 28 | --error_red: #ff5555; 29 | 30 | --verified_blue: var(--accent); 31 | --icon_text: ##F8F8F2; 32 | 33 | --tab: #6272a4; 34 | --tab_selected: var(--accent); 35 | 36 | --profile_stat: #919cbf; 37 | } 38 | 39 | .search-bar > form input::placeholder{ 40 | color: var(--fg_faded); 41 | } -------------------------------------------------------------------------------- /src/routes/embed.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import asyncdispatch, strutils, strformat, options 3 | import jester, karax/vdom 4 | import ".."/[types, api] 5 | import ../views/[embed, tweet, general] 6 | import router_utils 7 | 8 | export api, embed, vdom, tweet, general, router_utils 9 | 10 | proc createEmbedRouter*(cfg: Config) = 11 | router embed: 12 | get "/i/videos/tweet/@id": 13 | let tweet = await getGraphTweetResult(@"id") 14 | if tweet == nil or tweet.video.isNone: 15 | resp Http404 16 | 17 | resp renderVideoEmbed(tweet, cfg, request) 18 | 19 | get "/@user/status/@id/embed": 20 | let 21 | tweet = await getGraphTweetResult(@"id") 22 | prefs = cookiePrefs() 23 | path = getPath() 24 | 25 | if tweet == nil: 26 | resp Http404 27 | 28 | resp renderTweetEmbed(tweet, path, prefs, cfg, request) 29 | 30 | get "/embed/Tweet.html": 31 | let id = @"id" 32 | 33 | if id.len > 0: 34 | redirect(&"/i/status/{id}/embed") 35 | else: 36 | resp Http404 37 | -------------------------------------------------------------------------------- /src/experimental/types/user.nim: -------------------------------------------------------------------------------- 1 | import options 2 | import common 3 | from ../../types import VerifiedType 4 | 5 | type 6 | RawUser* = object 7 | idStr*: string 8 | name*: string 9 | screenName*: string 10 | location*: string 11 | description*: string 12 | entities*: Entities 13 | createdAt*: string 14 | followersCount*: int 15 | friendsCount*: int 16 | favouritesCount*: int 17 | statusesCount*: int 18 | mediaCount*: int 19 | verifiedType*: VerifiedType 20 | protected*: bool 21 | profileLinkColor*: string 22 | profileBannerUrl*: string 23 | profileImageUrlHttps*: string 24 | profileImageExtensions*: Option[ImageExtensions] 25 | pinnedTweetIdsStr*: seq[string] 26 | 27 | Entities* = object 28 | url*: Urls 29 | description*: Urls 30 | 31 | Urls* = object 32 | urls*: seq[Url] 33 | 34 | ImageExtensions = object 35 | mediaColor*: tuple[r: Ok] 36 | 37 | Ok = object 38 | ok*: Palette 39 | 40 | Palette = object 41 | palette*: seq[tuple[rgb: Color]] 42 | 43 | Color* = object 44 | red*, green*, blue*: int 45 | -------------------------------------------------------------------------------- /public/fonts/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Font license info 2 | 3 | 4 | ## Entypo 5 | 6 | Copyright (C) 2012 by Daniel Bruce 7 | 8 | Author: Daniel Bruce 9 | License: SIL (http://scripts.sil.org/OFL) 10 | Homepage: http://www.entypo.com 11 | 12 | 13 | ## Iconic 14 | 15 | Copyright (C) 2012 by P.J. Onori 16 | 17 | Author: P.J. Onori 18 | License: SIL (http://scripts.sil.org/OFL) 19 | Homepage: http://somerandomdude.com/work/iconic/ 20 | 21 | 22 | ## Font Awesome 23 | 24 | Copyright (C) 2016 by Dave Gandy 25 | 26 | Author: Dave Gandy 27 | License: SIL () 28 | Homepage: http://fortawesome.github.com/Font-Awesome/ 29 | 30 | 31 | ## Elusive 32 | 33 | Copyright (C) 2013 by Aristeides Stathopoulos 34 | 35 | Author: Aristeides Stathopoulos 36 | License: SIL (http://scripts.sil.org/OFL) 37 | Homepage: http://aristeides.com/ 38 | 39 | 40 | ## Modern Pictograms 41 | 42 | Copyright (c) 2012 by John Caserta. All rights reserved. 43 | 44 | Author: John Caserta 45 | License: SIL (http://scripts.sil.org/OFL) 46 | Homepage: http://thedesignoffice.org/project/modern-pictograms/ 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/routes/preferences.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils, uri, os, algorithm 3 | 4 | import jester 5 | 6 | import router_utils 7 | import ".."/[types, formatters] 8 | import ../views/[general, preferences] 9 | 10 | export preferences 11 | 12 | proc findThemes*(dir: string): seq[string] = 13 | for kind, path in walkDir(dir / "css" / "themes"): 14 | let theme = path.splitFile.name 15 | result.add theme.replace("_", " ").titleize 16 | sort(result) 17 | 18 | proc createPrefRouter*(cfg: Config) = 19 | router preferences: 20 | get "/settings": 21 | let 22 | prefs = cookiePrefs() 23 | html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir)) 24 | resp renderMain(html, request, cfg, prefs, "Preferences") 25 | 26 | get "/settings/@i?": 27 | redirect("/settings") 28 | 29 | post "/saveprefs": 30 | genUpdatePrefs() 31 | redirect(refPath()) 32 | 33 | post "/resetprefs": 34 | genResetPrefs() 35 | redirect("/settings?referer=" & encodeUrl(refPath())) 36 | 37 | post "/enablehls": 38 | savePref("hlsPlayback", "on", request) 39 | redirect(refPath()) 40 | 41 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | 5 | nitter: 6 | image: ghcr.io/privacydevel/nitter:master 7 | container_name: nitter 8 | ports: 9 | - "127.0.0.1:8080:8080" # Replace with "8080:8080" if you don't use a reverse proxy 10 | volumes: 11 | - ./nitter.conf:/src/nitter.conf:Z,ro 12 | depends_on: 13 | - nitter-redis 14 | restart: unless-stopped 15 | healthcheck: 16 | test: wget -nv --tries=1 --spider http://127.0.0.1:8080/Jack/status/20 || exit 1 17 | interval: 30s 18 | timeout: 5s 19 | retries: 2 20 | user: "998:998" 21 | read_only: true 22 | security_opt: 23 | - no-new-privileges:true 24 | cap_drop: 25 | - ALL 26 | 27 | nitter-redis: 28 | image: redis:6-alpine 29 | container_name: nitter-redis 30 | command: redis-server --save 60 1 --loglevel warning 31 | volumes: 32 | - nitter-redis:/data 33 | restart: unless-stopped 34 | healthcheck: 35 | test: redis-cli ping 36 | interval: 30s 37 | timeout: 5s 38 | retries: 2 39 | user: "999:1000" 40 | read_only: true 41 | security_opt: 42 | - no-new-privileges:true 43 | cap_drop: 44 | - ALL 45 | 46 | volumes: 47 | nitter-redis: 48 | -------------------------------------------------------------------------------- /src/sass/tweet/video.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | @import '_mixins'; 3 | 4 | video { 5 | max-height: 100%; 6 | width: 100%; 7 | } 8 | 9 | .gallery-video { 10 | display: flex; 11 | overflow: hidden; 12 | } 13 | 14 | .gallery-video.card-container { 15 | flex-direction: column; 16 | } 17 | 18 | .video-container { 19 | max-height: 530px; 20 | margin: 0; 21 | display: flex; 22 | align-items: center; 23 | justify-content: center; 24 | 25 | img { 26 | max-height: 100%; 27 | max-width: 100%; 28 | } 29 | } 30 | 31 | .video-overlay { 32 | @include play-button; 33 | background-color: $shadow; 34 | 35 | p { 36 | position: relative; 37 | z-index: 0; 38 | text-align: center; 39 | top: calc(50% - 20px); 40 | font-size: 20px; 41 | line-height: 1.3; 42 | margin: 0 20px; 43 | } 44 | 45 | div { 46 | position: relative; 47 | z-index: 0; 48 | top: calc(50% - 20px); 49 | margin: 0 auto; 50 | width: 40px; 51 | height: 40px; 52 | } 53 | 54 | form { 55 | width: 100%; 56 | height: 100%; 57 | align-items: center; 58 | justify-content: center; 59 | display: flex; 60 | } 61 | 62 | button { 63 | padding: 5px 8px; 64 | font-size: 16px; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/http_pool.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import httpclient 3 | 4 | type 5 | HttpPool* = ref object 6 | conns*: seq[AsyncHttpClient] 7 | 8 | var 9 | maxConns: int 10 | proxy: Proxy 11 | 12 | proc setMaxHttpConns*(n: int) = 13 | maxConns = n 14 | 15 | proc setHttpProxy*(url: string; auth: string) = 16 | if url.len > 0: 17 | proxy = newProxy(url, auth) 18 | else: 19 | proxy = nil 20 | 21 | proc release*(pool: HttpPool; client: AsyncHttpClient; badClient=false) = 22 | if pool.conns.len >= maxConns or badClient: 23 | try: client.close() 24 | except: discard 25 | elif client != nil: 26 | pool.conns.insert(client) 27 | 28 | proc acquire*(pool: HttpPool; heads: HttpHeaders): AsyncHttpClient = 29 | if pool.conns.len == 0: 30 | result = newAsyncHttpClient(headers=heads, proxy=proxy) 31 | else: 32 | result = pool.conns.pop() 33 | result.headers = heads 34 | 35 | template use*(pool: HttpPool; heads: HttpHeaders; body: untyped): untyped = 36 | var 37 | c {.inject.} = pool.acquire(heads) 38 | badClient {.inject.} = false 39 | 40 | try: 41 | body 42 | except BadClientError, ProtocolError: 43 | # Twitter returned 503 or closed the connection, we need a new client 44 | pool.release(c, true) 45 | badClient = false 46 | c = pool.acquire(heads) 47 | body 48 | finally: 49 | pool.release(c, badClient) 50 | -------------------------------------------------------------------------------- /src/experimental/parser/graphql.nim: -------------------------------------------------------------------------------- 1 | import options 2 | import jsony 3 | import user, ../types/[graphuser, graphlistmembers] 4 | from ../../types import User, VerifiedType, Result, Query, QueryKind 5 | 6 | proc parseGraphUser*(json: string): User = 7 | if json.len == 0 or json[0] != '{': 8 | return 9 | 10 | let raw = json.fromJson(GraphUser) 11 | 12 | if raw.data.userResult.result.unavailableReason.get("") == "Suspended": 13 | return User(suspended: true) 14 | 15 | result = raw.data.userResult.result.legacy 16 | result.id = raw.data.userResult.result.restId 17 | if result.verifiedType == VerifiedType.none and raw.data.userResult.result.isBlueVerified: 18 | result.verifiedType = blue 19 | 20 | proc parseGraphListMembers*(json, cursor: string): Result[User] = 21 | result = Result[User]( 22 | beginning: cursor.len == 0, 23 | query: Query(kind: userList) 24 | ) 25 | 26 | let raw = json.fromJson(GraphListMembers) 27 | for instruction in raw.data.list.membersTimeline.timeline.instructions: 28 | if instruction.kind == "TimelineAddEntries": 29 | for entry in instruction.entries: 30 | case entry.content.entryType 31 | of TimelineTimelineItem: 32 | let userResult = entry.content.itemContent.userResults.result 33 | if userResult.restId.len > 0: 34 | result.content.add userResult.legacy 35 | of TimelineTimelineCursor: 36 | if entry.content.cursorType == "Bottom": 37 | result.bottom = entry.content.value 38 | -------------------------------------------------------------------------------- /src/utils.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils, strformat, uri, tables, base64 3 | import nimcrypto 4 | 5 | var 6 | hmacKey: string 7 | base64Media = false 8 | 9 | const 10 | https* = "https://" 11 | twimg* = "pbs.twimg.com/" 12 | nitterParams = ["name", "tab", "id", "list", "referer", "scroll"] 13 | twitterDomains = @[ 14 | "twitter.com", 15 | "pic.twitter.com", 16 | "twimg.com", 17 | "abs.twimg.com", 18 | "pbs.twimg.com", 19 | "video.twimg.com", 20 | "x.com" 21 | ] 22 | 23 | proc setHmacKey*(key: string) = 24 | hmacKey = key 25 | 26 | proc setProxyEncoding*(state: bool) = 27 | base64Media = state 28 | 29 | proc getHmac*(data: string): string = 30 | ($hmac(sha256, hmacKey, data))[0 .. 12] 31 | 32 | proc getVidUrl*(link: string): string = 33 | if link.len == 0: return 34 | let sig = getHmac(link) 35 | if base64Media: 36 | &"/video/enc/{sig}/{encode(link, safe=true)}" 37 | else: 38 | &"/video/{sig}/{encodeUrl(link)}" 39 | 40 | proc getPicUrl*(link: string): string = 41 | if base64Media: 42 | &"/pic/enc/{encode(link, safe=true)}" 43 | else: 44 | &"/pic/{encodeUrl(link)}" 45 | 46 | proc getOrigPicUrl*(link: string): string = 47 | if base64Media: 48 | &"/pic/orig/enc/{encode(link, safe=true)}" 49 | else: 50 | &"/pic/orig/{encodeUrl(link)}" 51 | 52 | proc filterParams*(params: Table): seq[(string, string)] = 53 | for p in params.pairs(): 54 | if p[1].len > 0 and p[0] notin nitterParams: 55 | result.add p 56 | 57 | proc isTwitterUrl*(uri: Uri): bool = 58 | uri.hostname in twitterDomains 59 | 60 | proc isTwitterUrl*(url: string): bool = 61 | isTwitterUrl(parseUri(url)) 62 | -------------------------------------------------------------------------------- /.github/workflows/build-publish-docker.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Docker 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | paths-ignore: ["README.md"] 7 | pull_request: 8 | branches: ["master"] 9 | paths-ignore: ["README.md"] 10 | 11 | env: 12 | IMAGE_NAME: ${{ github.repository }} 13 | 14 | permissions: 15 | contents: read 16 | packages: write 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v3 24 | 25 | - name: Set up QEMU 26 | id: qemu 27 | uses: docker/setup-qemu-action@v2 28 | with: 29 | platforms: arm64 30 | 31 | - name: Setup Docker buildx 32 | id: buildx 33 | uses: docker/setup-buildx-action@v2 34 | 35 | - name: Log in to GHCR 36 | uses: docker/login-action@v2 37 | with: 38 | registry: ghcr.io 39 | username: ${{ github.actor }} 40 | password: ${{ secrets.GITHUB_TOKEN }} 41 | 42 | - name: Extract Docker metadata 43 | id: meta 44 | uses: docker/metadata-action@v4 45 | with: 46 | images: | 47 | ghcr.io/${{ env.IMAGE_NAME }} 48 | 49 | 50 | - name: Build and push all platforms Docker image 51 | id: build-and-push 52 | uses: docker/build-push-action@v4 53 | with: 54 | context: . 55 | push: ${{ github.event_name != 'pull_request' }} 56 | tags: ${{ steps.meta.outputs.tags }} 57 | labels: ${{ steps.meta.outputs.labels }} 58 | platforms: linux/amd64,linux/arm64 59 | cache-from: type=gha 60 | cache-to: type=gha,mode=max 61 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - "*.md" 7 | branches-ignore: 8 | - master 9 | workflow_call: 10 | 11 | jobs: 12 | test: 13 | runs-on: buildjet-2vcpu-ubuntu-2204 14 | strategy: 15 | matrix: 16 | nim: 17 | - "1.6.10" 18 | - "1.6.x" 19 | - "2.0.x" 20 | - "devel" 21 | steps: 22 | - uses: actions/checkout@v3 23 | with: 24 | fetch-depth: 0 25 | - name: Cache nimble 26 | id: cache-nimble 27 | uses: buildjet/cache@v3 28 | with: 29 | path: ~/.nimble 30 | key: ${{ matrix.nim }}-nimble-${{ hashFiles('*.nimble') }} 31 | restore-keys: | 32 | ${{ matrix.nim }}-nimble- 33 | - uses: actions/setup-python@v4 34 | with: 35 | python-version: "3.10" 36 | cache: "pip" 37 | - uses: jiro4989/setup-nim-action@v1 38 | with: 39 | nim-version: ${{ matrix.nim }} 40 | repo-token: ${{ secrets.GITHUB_TOKEN }} 41 | - run: nimble build -d:release -Y 42 | - run: pip install seleniumbase 43 | - run: seleniumbase install chromedriver 44 | - uses: supercharge/redis-github-action@1.5.0 45 | - name: Prepare Nitter 46 | run: | 47 | sudo apt install libsass-dev -y 48 | cp nitter.example.conf nitter.conf 49 | sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf 50 | nimble md 51 | nimble scss 52 | echo '${{ secrets.GUEST_ACCOUNTS }}' > ./guest_accounts.jsonl 53 | - name: Run tests 54 | run: | 55 | ./nitter & 56 | pytest -n8 tests 57 | -------------------------------------------------------------------------------- /src/sass/profile/_base.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | @import '_mixins'; 3 | 4 | @import 'card'; 5 | @import 'photo-rail'; 6 | 7 | .profile-tabs { 8 | @include panel(auto, 900px); 9 | 10 | .timeline-container { 11 | float: right; 12 | width: 68% !important; 13 | max-width: unset; 14 | } 15 | } 16 | 17 | .profile-banner { 18 | margin-bottom: 4px; 19 | background-color: var(--bg_panel); 20 | 21 | a { 22 | display: block; 23 | position: relative; 24 | padding: 33.34% 0 0 0; 25 | } 26 | 27 | img { 28 | max-width: 100%; 29 | position: absolute; 30 | top: 0; 31 | } 32 | } 33 | 34 | .profile-tab { 35 | padding: 0 4px 0 0; 36 | box-sizing: border-box; 37 | display: inline-block; 38 | font-size: 14px; 39 | text-align: left; 40 | vertical-align: top; 41 | max-width: 32%; 42 | top: 50px; 43 | } 44 | 45 | .profile-result { 46 | min-height: 54px; 47 | 48 | .username { 49 | margin: 0 !important; 50 | } 51 | 52 | .tweet-header { 53 | margin-bottom: unset; 54 | } 55 | } 56 | 57 | @media(max-width: 700px) { 58 | .profile-tabs { 59 | width: 100vw; 60 | max-width: 600px; 61 | 62 | .timeline-container { 63 | width: 100% !important; 64 | 65 | .tab-item wide { 66 | flex-grow: 1.4; 67 | } 68 | } 69 | } 70 | 71 | .profile-tab { 72 | width: 100%; 73 | max-width: unset; 74 | position: initial !important; 75 | padding: 0; 76 | } 77 | } 78 | 79 | @media (min-height: 900px) { 80 | .profile-tab.sticky { 81 | position: sticky; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/routes/router_utils.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils, sequtils, uri, tables, json 3 | from jester import Request, cookies 4 | 5 | import ../views/general 6 | import ".."/[utils, prefs, types] 7 | export utils, prefs, types, uri 8 | 9 | template savePref*(pref, value: string; req: Request; expire=false) = 10 | if not expire or pref in cookies(req): 11 | setCookie(pref, value, daysForward(when expire: -10 else: 360), 12 | httpOnly=true, secure=cfg.useHttps, sameSite=None) 13 | 14 | template cookiePrefs*(): untyped {.dirty.} = 15 | getPrefs(cookies(request)) 16 | 17 | template cookiePref*(pref): untyped {.dirty.} = 18 | getPref(cookies(request), pref) 19 | 20 | template themePrefs*(): Prefs = 21 | var res = defaultPrefs 22 | res.theme = cookiePref(theme) 23 | res 24 | 25 | template showError*(error: string; cfg: Config): string = 26 | renderMain(renderError(error), request, cfg, themePrefs(), "Error") 27 | 28 | template getPath*(): untyped {.dirty.} = 29 | $(parseUri(request.path) ? filterParams(request.params)) 30 | 31 | template refPath*(): untyped {.dirty.} = 32 | if @"referer".len > 0: @"referer" else: "/" 33 | 34 | template getCursor*(): string = 35 | let cursor = @"cursor" 36 | decodeUrl(if cursor.len > 0: cursor else: @"max_position", false) 37 | 38 | template getCursor*(req: Request): string = 39 | let cursor = req.params.getOrDefault("cursor") 40 | decodeUrl(if cursor.len > 0: cursor 41 | else: req.params.getOrDefault("max_position"), false) 42 | 43 | proc getNames*(name: string): seq[string] = 44 | name.strip(chars={'/'}).split(",").filterIt(it.len > 0) 45 | 46 | template respJson*(node: JsonNode) = 47 | resp $node, "application/json" 48 | -------------------------------------------------------------------------------- /src/sass/navbar.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | 3 | nav { 4 | display: flex; 5 | align-items: center; 6 | position: fixed; 7 | background-color: var(--bg_overlays); 8 | box-shadow: 0 0 4px $shadow; 9 | padding: 0; 10 | width: 100%; 11 | height: 50px; 12 | z-index: 1000; 13 | font-size: 16px; 14 | 15 | a, .icon-button button { 16 | color: var(--fg_nav); 17 | } 18 | } 19 | 20 | .inner-nav { 21 | margin: auto; 22 | box-sizing: border-box; 23 | padding: 0 10px; 24 | display: flex; 25 | align-items: center; 26 | flex-basis: 920px; 27 | height: 50px; 28 | } 29 | 30 | .site-name { 31 | font-size: 15px; 32 | font-weight: 600; 33 | line-height: 1; 34 | 35 | &:hover { 36 | color: var(--accent_light); 37 | text-decoration: unset; 38 | } 39 | } 40 | 41 | .site-logo { 42 | display: block; 43 | width: 35px; 44 | height: 35px; 45 | } 46 | 47 | .nav-item { 48 | display: flex; 49 | flex: 1; 50 | line-height: 50px; 51 | height: 50px; 52 | overflow: hidden; 53 | flex-wrap: wrap; 54 | align-items: center; 55 | 56 | &.right { 57 | text-align: right; 58 | justify-content: flex-end; 59 | } 60 | 61 | &.right a { 62 | padding-left: 4px; 63 | 64 | &:hover { 65 | color: var(--accent_light); 66 | text-decoration: unset; 67 | } 68 | } 69 | } 70 | 71 | .lp { 72 | height: 14px; 73 | display: inline-block; 74 | position: relative; 75 | top: 2px; 76 | fill: var(--fg_nav); 77 | 78 | &:hover { 79 | fill: var(--accent_light); 80 | } 81 | } 82 | 83 | .icon-info:before { 84 | margin: 0 -3px; 85 | } 86 | 87 | .icon-cog { 88 | font-size: 15px; 89 | } 90 | -------------------------------------------------------------------------------- /src/routes/search.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils, uri 3 | 4 | import jester 5 | 6 | import router_utils 7 | import ".."/[query, types, api, formatters] 8 | import ../views/[general, search] 9 | 10 | include "../views/opensearch.nimf" 11 | 12 | export search 13 | 14 | proc createSearchRouter*(cfg: Config) = 15 | router search: 16 | get "/search/?": 17 | let q = @"q" 18 | if q.len > 500: 19 | resp Http400, showError("Search input too long.", cfg) 20 | 21 | let 22 | prefs = cookiePrefs() 23 | query = initQuery(params(request)) 24 | title = "Search" & (if q.len > 0: " (" & q & ")" else: "") 25 | 26 | case query.kind 27 | of users: 28 | if "," in q: 29 | redirect("/" & q) 30 | var users: Result[User] 31 | try: 32 | users = await getGraphUserSearch(query, getCursor()) 33 | except InternalError: 34 | users = Result[User](beginning: true, query: query) 35 | resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) 36 | of tweets: 37 | let 38 | tweets = await getGraphTweetSearch(query, getCursor()) 39 | rss = "/search/rss?" & genQueryUrl(query) 40 | resp renderMain(renderTweetSearch(tweets, prefs, getPath()), 41 | request, cfg, prefs, title, rss=rss) 42 | else: 43 | resp Http404, showError("Invalid search", cfg) 44 | 45 | get "/hashtag/@hash": 46 | redirect("/search?q=" & encodeUrl("#" & @"hash")) 47 | 48 | get "/opensearch": 49 | let url = getUrlPrefix(cfg) & "/search?q=" 50 | resp Http200, {"Content-Type": "application/opensearchdescription+xml"}, 51 | generateOpenSearchXML(cfg.title, cfg.hostname, url) 52 | -------------------------------------------------------------------------------- /src/views/preferences.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import tables, macros, strutils 3 | import karax/[karaxdsl, vdom] 4 | 5 | import renderutils 6 | import ../types, ../prefs_impl 7 | 8 | macro renderPrefs*(): untyped = 9 | result = nnkCall.newTree( 10 | ident("buildHtml"), ident("tdiv"), nnkStmtList.newTree()) 11 | 12 | for header, options in prefList: 13 | result[2].add nnkCall.newTree( 14 | ident("legend"), 15 | nnkStmtList.newTree( 16 | nnkCommand.newTree(ident("text"), newLit(header)))) 17 | 18 | for pref in options: 19 | let procName = ident("gen" & capitalizeAscii($pref.kind)) 20 | let state = nnkDotExpr.newTree(ident("prefs"), ident(pref.name)) 21 | var stmt = nnkStmtList.newTree( 22 | nnkCall.newTree(procName, newLit(pref.name), newLit(pref.label), state)) 23 | 24 | case pref.kind 25 | of checkbox: discard 26 | of input: stmt[0].add newLit(pref.placeholder) 27 | of select: 28 | if pref.name == "theme": 29 | stmt[0].add ident("themes") 30 | else: 31 | stmt[0].add newLit(pref.options) 32 | 33 | result[2].add stmt 34 | 35 | proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode = 36 | buildHtml(tdiv(class="overlay-panel")): 37 | fieldset(class="preferences"): 38 | form(`method`="post", action="/saveprefs", autocomplete="off"): 39 | refererField path 40 | 41 | renderPrefs() 42 | 43 | h4(class="note"): 44 | text "Preferences are stored client-side using cookies without any personal information." 45 | 46 | button(`type`="submit", class="pref-submit"): 47 | text "Save preferences" 48 | 49 | buttonReferer "/resetprefs", "Reset preferences", path, class="pref-reset" 50 | -------------------------------------------------------------------------------- /src/routes/list.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils, strformat, uri 3 | 4 | import jester 5 | 6 | import router_utils 7 | import ".."/[types, redis_cache, api] 8 | import ../views/[general, timeline, list] 9 | 10 | template respList*(list, timeline, title, vnode: typed) = 11 | if list.id.len == 0 or list.name.len == 0: 12 | resp Http404, showError(&"""List "{@"id"}" not found""", cfg) 13 | 14 | let 15 | html = renderList(vnode, timeline.query, list) 16 | rss = &"""/i/lists/{@"id"}/rss""" 17 | 18 | resp renderMain(html, request, cfg, prefs, titleText=title, rss=rss, banner=list.banner) 19 | 20 | proc title*(list: List): string = 21 | &"@{list.username}/{list.name}" 22 | 23 | proc createListRouter*(cfg: Config) = 24 | router list: 25 | get "/@name/lists/@slug/?": 26 | cond '.' notin @"name" 27 | cond @"name" != "i" 28 | cond @"slug" != "memberships" 29 | let 30 | slug = decodeUrl(@"slug") 31 | list = await getCachedList(@"name", slug) 32 | if list.id.len == 0: 33 | resp Http404, showError(&"""List "{@"slug"}" not found""", cfg) 34 | redirect(&"/i/lists/{list.id}") 35 | 36 | get "/i/lists/@id/?": 37 | cond '.' notin @"id" 38 | let 39 | prefs = cookiePrefs() 40 | list = await getCachedList(id=(@"id")) 41 | timeline = await getGraphListTweets(list.id, getCursor()) 42 | vnode = renderTimelineTweets(timeline, prefs, request.path) 43 | respList(list, timeline, list.title, vnode) 44 | 45 | get "/i/lists/@id/members": 46 | cond '.' notin @"id" 47 | let 48 | prefs = cookiePrefs() 49 | list = await getCachedList(id=(@"id")) 50 | members = await getGraphListMembers(list, getCursor()) 51 | respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path)) 52 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | include: 3 | - stage: test 4 | if: (NOT type IN (pull_request)) AND (branch = master) 5 | dist: bionic 6 | language: python 7 | python: 8 | - 3.6 9 | services: 10 | - docker 11 | - xvfb 12 | script: 13 | - sudo apt update 14 | - sudo apt install --force-yes chromium-chromedriver 15 | - wget https://www.dropbox.com/s/ckuoaubd1crrj2k/Linux_x64_737173_chrome-linux.zip -O chrome.zip 16 | - unzip chrome.zip 17 | - cd chrome-linux 18 | - sudo rm /usr/bin/google-chrome 19 | - sudo ln -s chrome /usr/bin/google-chrome 20 | - cd .. 21 | - pip3 install --upgrade pip 22 | - pip3 install -U seleniumbase pytest 23 | - docker run -d -p 127.0.0.1:8080:8080/tcp $IMAGE_NAME:$TRAVIS_COMMIT 24 | - sleep 10 25 | - cd tests 26 | - pytest --headless -n 8 --reruns 10 --reruns-delay 2 27 | - stage: pr 28 | if: type IN (pull_request) 29 | dist: bionic 30 | language: python 31 | python: 32 | - 3.6 33 | services: 34 | - docker 35 | - xvfb 36 | script: 37 | - sudo apt update 38 | - sudo apt install --force-yes chromium-chromedriver 39 | - wget https://www.dropbox.com/s/ckuoaubd1crrj2k/Linux_x64_737173_chrome-linux.zip -O chrome.zip 40 | - unzip chrome.zip 41 | - cd chrome-linux 42 | - sudo rm /usr/bin/google-chrome 43 | - sudo ln -s chrome /usr/bin/google-chrome 44 | - cd .. 45 | - pip3 install --upgrade pip 46 | - pip3 install -U seleniumbase pytest 47 | - docker build -t $IMAGE_NAME:$TRAVIS_COMMIT . 48 | - docker run -d -p 127.0.0.1:8080:8080/tcp $IMAGE_NAME:$TRAVIS_COMMIT 49 | - sleep 10 50 | - cd tests 51 | - pytest --headless -n 8 --reruns 3 --reruns-delay 2 52 | -------------------------------------------------------------------------------- /nitter.example.conf: -------------------------------------------------------------------------------- 1 | [Server] 2 | hostname = "nitter.net" # for generating links, change this to your own domain/ip 3 | title = "nitter" 4 | address = "0.0.0.0" 5 | port = 8080 6 | https = false # disable to enable cookies when not using https 7 | httpMaxConnections = 100 8 | staticDir = "./public" 9 | 10 | [Cache] 11 | listMinutes = 240 # how long to cache list info (not the tweets, so keep it high) 12 | rssMinutes = 10 # how long to cache rss queries 13 | redisHost = "localhost" # Change to "nitter-redis" if using docker-compose 14 | redisPort = 6379 15 | redisPassword = "" 16 | redisConnections = 20 # minimum open connections in pool 17 | redisMaxConnections = 30 18 | # new connections are opened when none are available, but if the pool size 19 | # goes above this, they're closed when released. don't worry about this unless 20 | # you receive tons of requests per second 21 | 22 | [Config] 23 | hmacKey = "secretkey" # random key for cryptographic signing of video urls 24 | base64Media = false # use base64 encoding for proxied media urls 25 | enableRSS = true # set this to false to disable RSS feeds 26 | enableDebug = false # enable request logs and debug endpoints (/.accounts) 27 | proxy = "" # http/https url, SOCKS proxies are not supported 28 | proxyAuth = "" 29 | tokenCount = 10 30 | # minimum amount of usable tokens. tokens are used to authorize API requests, 31 | # but they expire after ~1 hour, and have a limit of 500 requests per endpoint. 32 | # the limits reset every 15 minutes, and the pool is filled up so there's 33 | # always at least `tokenCount` usable tokens. only increase this if you receive 34 | # major bursts all the time and don't have a rate limiting setup via e.g. nginx 35 | 36 | # Change default preferences here, see src/prefs_impl.nim for a complete list 37 | [Preferences] 38 | theme = "Nitter" 39 | replaceTwitter = "nitter.net" 40 | replaceYouTube = "piped.video" 41 | replaceReddit = "teddit.net" 42 | proxyVideos = true 43 | hlsPlayback = false 44 | infiniteScroll = false 45 | -------------------------------------------------------------------------------- /src/config.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import parsecfg except Config 3 | import types, strutils 4 | from os import getEnv 5 | 6 | proc get*[T](config: parseCfg.Config; section, key: string; default: T): T = 7 | let val = config.getSectionValue(section, key) 8 | if val.len == 0: return default 9 | 10 | when T is int: parseInt(val) 11 | elif T is bool: parseBool(val) 12 | elif T is string: val 13 | 14 | proc getConfig*(path: string): (Config, parseCfg.Config) = 15 | var cfg = loadConfig(path) 16 | 17 | let conf = Config( 18 | # Server 19 | address: cfg.get("Server", "address", "0.0.0.0"), 20 | port: cfg.get("Server", "port", 8080), 21 | useHttps: cfg.get("Server", "https", true), 22 | httpMaxConns: cfg.get("Server", "httpMaxConnections", 100), 23 | staticDir: cfg.get("Server", "staticDir", "./public"), 24 | title: cfg.get("Server", "title", "Nitter"), 25 | hostname: cfg.get("Server", "hostname", "nitter.net"), 26 | 27 | # Cache 28 | listCacheTime: cfg.get("Cache", "listMinutes", 120), 29 | rssCacheTime: cfg.get("Cache", "rssMinutes", 10), 30 | 31 | redisHost: cfg.get("Cache", "redisHost", "localhost"), 32 | redisPort: cfg.get("Cache", "redisPort", 6379), 33 | redisConns: cfg.get("Cache", "redisConnections", 20), 34 | redisMaxConns: cfg.get("Cache", "redisMaxConnections", 30), 35 | redisPassword: cfg.get("Cache", "redisPassword", ""), 36 | 37 | # Config 38 | hmacKey: cfg.get("Config", "hmacKey", "secretkey"), 39 | base64Media: cfg.get("Config", "base64Media", false), 40 | minTokens: cfg.get("Config", "tokenCount", 10), 41 | enableRss: cfg.get("Config", "enableRSS", true), 42 | enableDebug: cfg.get("Config", "enableDebug", false), 43 | proxy: cfg.get("Config", "proxy", ""), 44 | proxyAuth: cfg.get("Config", "proxyAuth", "") 45 | ) 46 | 47 | return (conf, cfg) 48 | 49 | 50 | let configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf") 51 | let (cfg*, fullCfg*) = getConfig(configPath) 52 | -------------------------------------------------------------------------------- /src/sass/tweet/quote.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | 3 | .quote { 4 | margin-top: 10px; 5 | border: solid 1px var(--dark_grey); 6 | border-radius: 10px; 7 | background-color: var(--bg_elements); 8 | overflow: hidden; 9 | pointer-events: all; 10 | position: relative; 11 | width: 100%; 12 | 13 | &:hover { 14 | border-color: var(--grey); 15 | } 16 | 17 | &.unavailable:hover { 18 | border-color: var(--dark_grey); 19 | } 20 | 21 | .tweet-name-row { 22 | padding: 6px 8px; 23 | margin-top: 1px; 24 | } 25 | 26 | .quote-text { 27 | overflow: hidden; 28 | white-space: pre-wrap; 29 | word-wrap: break-word; 30 | padding: 0px 8px 8px 8px; 31 | } 32 | 33 | .show-thread { 34 | padding: 0px 8px 6px 8px; 35 | margin-top: -6px; 36 | } 37 | 38 | .replying-to { 39 | padding: 0px 8px; 40 | margin: unset; 41 | } 42 | } 43 | 44 | .unavailable-quote { 45 | padding: 12px; 46 | } 47 | 48 | .quote-link { 49 | width: 100%; 50 | height: 100%; 51 | left: 0; 52 | top: 0; 53 | position: absolute; 54 | } 55 | 56 | .quote-media-container { 57 | max-height: 300px; 58 | display: flex; 59 | 60 | .card { 61 | margin: unset; 62 | } 63 | 64 | .attachments { 65 | border-radius: 0; 66 | } 67 | 68 | .media-gif { 69 | width: 100%; 70 | display: flex; 71 | justify-content: center; 72 | } 73 | 74 | .gallery-gif .attachment { 75 | display: flex; 76 | justify-content: center; 77 | background-color: var(--bg_color); 78 | 79 | video { 80 | height: unset; 81 | width: unset; 82 | max-height: 100%; 83 | max-width: 100%; 84 | } 85 | } 86 | 87 | .gallery-video, .gallery-gif { 88 | max-height: 300px; 89 | } 90 | 91 | .still-image img { 92 | max-height: 250px 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/test_thread.py: -------------------------------------------------------------------------------- 1 | from base import BaseTestCase, Conversation 2 | from parameterized import parameterized 3 | 4 | thread = [ 5 | ['octonion/status/975253897697611777', [], 'Based', ['Crystal', 'Julia'], [ 6 | ['For', 'Then', 'Okay,', 'Python', 'Speed', 'Java', 'Coding', 'I', 'You'], 7 | ['yeah,'] 8 | ]], 9 | 10 | ['octonion/status/975254452625002496', ['Based'], 'Crystal', ['Julia'], []], 11 | 12 | ['octonion/status/975256058384887808', ['Based', 'Crystal'], 'Julia', [], []], 13 | 14 | ['gauravssnl/status/975364889039417344', 15 | ['Based', 'For', 'Then', 'Okay,', 'Python'], 'Speed', [], [ 16 | ['Java', 'Coding', 'I', 'You'], ['JAVA!'] 17 | ]], 18 | 19 | ['d0m96/status/1141811379407425537', [], 'I\'m', 20 | ['The', 'The', 'Today', 'Some', 'If', 'There', 'Above'], 21 | [['Thank', 'Also,']]], 22 | 23 | ['gmpreussner/status/999766552546299904', [], 'A', [], 24 | [['I', 'Especially'], ['I']]] 25 | ] 26 | 27 | 28 | class ThreadTest(BaseTestCase): 29 | def find_tweets(self, selector): 30 | return self.find_elements(f"{selector} {Conversation.tweet_text}") 31 | 32 | def compare_first_word(self, tweets, selector): 33 | if len(tweets) > 0: 34 | self.assert_element_visible(selector) 35 | for i, tweet in enumerate(self.find_tweets(selector)): 36 | text = tweet.text.split(" ")[0] 37 | self.assert_equal(tweets[i], text) 38 | 39 | @parameterized.expand(thread) 40 | def test_thread(self, tweet, before, main, after, replies): 41 | self.open_nitter(tweet) 42 | self.assert_element_visible(Conversation.main) 43 | 44 | self.assert_text(main, Conversation.main) 45 | self.assert_text(main, Conversation.main) 46 | 47 | self.compare_first_word(before, Conversation.before) 48 | self.compare_first_word(after, Conversation.after) 49 | 50 | for i, reply in enumerate(self.find_elements(Conversation.thread)): 51 | selector = Conversation.replies + f" > div:nth-child({i + 1})" 52 | self.compare_first_word(replies[i], selector) 53 | -------------------------------------------------------------------------------- /public/css/fontello.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'fontello'; 3 | src: url('/fonts/fontello.eot?21002321'); 4 | src: url('/fonts/fontello.eot?21002321#iefix') format('embedded-opentype'), 5 | url('/fonts/fontello.woff2?21002321') format('woff2'), 6 | url('/fonts/fontello.woff?21002321') format('woff'), 7 | url('/fonts/fontello.ttf?21002321') format('truetype'), 8 | url('/fonts/fontello.svg?21002321#fontello') format('svg'); 9 | font-weight: normal; 10 | font-style: normal; 11 | } 12 | 13 | [class^="icon-"]:before, [class*=" icon-"]:before { 14 | font-family: "fontello"; 15 | font-style: normal; 16 | font-weight: normal; 17 | speak: never; 18 | 19 | display: inline-block; 20 | text-decoration: inherit; 21 | width: 1em; 22 | text-align: center; 23 | 24 | /* For safety - reset parent styles, that can break glyph codes*/ 25 | font-variant: normal; 26 | text-transform: none; 27 | 28 | /* fix buttons height, for twitter bootstrap */ 29 | line-height: 1em; 30 | 31 | /* Font smoothing. That was taken from TWBS */ 32 | -webkit-font-smoothing: antialiased; 33 | -moz-osx-font-smoothing: grayscale; 34 | } 35 | 36 | .icon-heart:before { content: '\2665'; } /* '♥' */ 37 | .icon-quote:before { content: '\275e'; } /* '❞' */ 38 | .icon-comment:before { content: '\e802'; } /* '' */ 39 | .icon-ok:before { content: '\e803'; } /* '' */ 40 | .icon-play:before { content: '\e804'; } /* '' */ 41 | .icon-link:before { content: '\e805'; } /* '' */ 42 | .icon-calendar:before { content: '\e806'; } /* '' */ 43 | .icon-location:before { content: '\e807'; } /* '' */ 44 | .icon-picture:before { content: '\e809'; } /* '' */ 45 | .icon-lock:before { content: '\e80a'; } /* '' */ 46 | .icon-down:before { content: '\e80b'; } /* '' */ 47 | .icon-retweet:before { content: '\e80d'; } /* '' */ 48 | .icon-search:before { content: '\e80e'; } /* '' */ 49 | .icon-pin:before { content: '\e80f'; } /* '' */ 50 | .icon-cog:before { content: '\e812'; } /* '' */ 51 | .icon-rss-feed:before { content: '\e813'; } /* '' */ 52 | .icon-info:before { content: '\f128'; } /* '' */ 53 | .icon-bird:before { content: '\f309'; } /* '' */ 54 | -------------------------------------------------------------------------------- /src/experimental/parser/slices.nim: -------------------------------------------------------------------------------- 1 | import std/[macros, htmlgen, unicode] 2 | import ../types/common 3 | import ".."/../[formatters, utils] 4 | 5 | type 6 | ReplaceSliceKind = enum 7 | rkRemove, rkUrl, rkHashtag, rkMention 8 | 9 | ReplaceSlice* = object 10 | slice: Slice[int] 11 | kind: ReplaceSliceKind 12 | url, display: string 13 | 14 | proc cmp*(x, y: ReplaceSlice): int = cmp(x.slice.a, y.slice.b) 15 | 16 | proc dedupSlices*(s: var seq[ReplaceSlice]) = 17 | var 18 | len = s.len 19 | i = 0 20 | while i < len: 21 | var j = i + 1 22 | while j < len: 23 | if s[i].slice.a == s[j].slice.a: 24 | s.del j 25 | dec len 26 | else: 27 | inc j 28 | inc i 29 | 30 | proc extractUrls*(result: var seq[ReplaceSlice]; url: Url; 31 | textLen: int; hideTwitter = false) = 32 | let 33 | link = url.expandedUrl 34 | slice = url.indices[0] ..< url.indices[1] 35 | 36 | if hideTwitter and slice.b.succ >= textLen and link.isTwitterUrl: 37 | if slice.a < textLen: 38 | result.add ReplaceSlice(kind: rkRemove, slice: slice) 39 | else: 40 | result.add ReplaceSlice(kind: rkUrl, url: link, 41 | display: link.shortLink, slice: slice) 42 | 43 | proc replacedWith*(runes: seq[Rune]; repls: openArray[ReplaceSlice]; 44 | textSlice: Slice[int]): string = 45 | template extractLowerBound(i: int; idx): int = 46 | if i > 0: repls[idx].slice.b.succ else: textSlice.a 47 | 48 | result = newStringOfCap(runes.len) 49 | 50 | for i, rep in repls: 51 | result.add $runes[extractLowerBound(i, i - 1) ..< rep.slice.a] 52 | case rep.kind 53 | of rkHashtag: 54 | let 55 | name = $runes[rep.slice.a.succ .. rep.slice.b] 56 | symbol = $runes[rep.slice.a] 57 | result.add a(symbol & name, href = "/search?q=%23" & name) 58 | of rkMention: 59 | result.add a($runes[rep.slice], href = rep.url, title = rep.display) 60 | of rkUrl: 61 | result.add a(rep.display, href = rep.url) 62 | of rkRemove: 63 | discard 64 | 65 | let rest = extractLowerBound(repls.len, ^1) ..< textSlice.b 66 | if rest.a <= rest.b: 67 | result.add $runes[rest] 68 | -------------------------------------------------------------------------------- /twitter_oauth.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Grab oauth token for use with Nitter (requires Twitter account). 3 | # results: {"oauth_token":"xxxxxxxxxx-xxxxxxxxx","oauth_token_secret":"xxxxxxxxxxxxxxxxxxxxx"} 4 | 5 | username="" 6 | password="" 7 | 8 | if [[ -z "$username" || -z "$password" ]]; then 9 | echo "needs username and password" 10 | exit 1 11 | fi 12 | 13 | bearer_token='AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F' 14 | guest_token=$(curl -s -XPOST https://api.twitter.com/1.1/guest/activate.json -H "Authorization: Bearer ${bearer_token}" | jq -r '.guest_token') 15 | base_url='https://api.twitter.com/1.1/onboarding/task.json' 16 | header=(-H "Authorization: Bearer ${bearer_token}" -H "User-Agent: TwitterAndroid/10.21.1" -H "Content-Type: application/json" -H "X-Guest-Token: ${guest_token}") 17 | 18 | # start flow 19 | flow_1=$(curl -si -XPOST "${base_url}?flow_name=login" "${header[@]}") 20 | 21 | # get 'att', now needed in headers, and 'flow_token' from flow_1 22 | att=$(sed -En 's/^att: (.*)\r/\1/p' <<< "${flow_1}") 23 | flow_token=$(sed -n '$p' <<< "${flow_1}" | jq -r .flow_token) 24 | 25 | # username 26 | token_2=$(curl -s -XPOST "${base_url}" -H "att: ${att}" "${header[@]}" \ 27 | -d '{"flow_token":"'"${flow_token}"'","subtask_inputs":[{"subtask_id":"LoginEnterUserIdentifierSSO","settings_list":{"setting_responses":[{"key":"user_identifier","response_data":{"text_data":{"result":"'"${username}"'"}}}],"link":"next_link"}}]}' | jq -r .flow_token) 28 | 29 | # password 30 | token_3=$(curl -s -XPOST "${base_url}" -H "att: ${att}" "${header[@]}" \ 31 | -d '{"flow_token":"'"${token_2}"'","subtask_inputs":[{"enter_password":{"password":"'"${password}"'","link":"next_link"},"subtask_id":"LoginEnterPassword"}]}' | jq -r .flow_token) 32 | 33 | # finally print oauth_token and secret 34 | curl -s -XPOST "${base_url}" -H "att: ${att}" "${header[@]}" \ 35 | -d '{"flow_token":"'"${token_3}"'","subtask_inputs":[{"check_logged_in_account":{"link":"AccountDuplicationCheck_false"},"subtask_id":"AccountDuplicationCheck"}]}' | \ 36 | jq -c '.subtasks[0]|if(.open_account) then {oauth_token: .open_account.oauth_token, oauth_token_secret: .open_account.oauth_token_secret} else empty end' -------------------------------------------------------------------------------- /tests/test_quote.py: -------------------------------------------------------------------------------- 1 | from base import BaseTestCase, Quote, Conversation 2 | from parameterized import parameterized 3 | 4 | text = [ 5 | ['elonmusk/status/1138136540096319488', 6 | 'TREV PAGE', '@Model3Owners', 7 | """As of March 58.4% of new car sales in Norway are electric. 8 | 9 | What are we doing wrong? reuters.com/article/us-norwa…"""], 10 | 11 | ['nim_lang/status/1491461266849808397#m', 12 | 'Nim', '@nim_lang', 13 | """What's better than Nim 1.6.0? 14 | 15 | Nim 1.6.2 :) 16 | 17 | nim-lang.org/blog/2021/12/17…"""] 18 | ] 19 | 20 | image = [ 21 | ['elonmusk/status/1138827760107790336', 'D83h6Y8UIAE2Wlz'], 22 | ['SpaceX/status/1067155053461426176', 'Ds9EYfxXoAAPNmx'] 23 | ] 24 | 25 | gif = [ 26 | ['SpaceX/status/747497521593737216', 'Cl-R5yFWkAA_-3X'], 27 | ['nim_lang/status/1068099315074248704', 'DtJSqP9WoAAKdRC'] 28 | ] 29 | 30 | video = [ 31 | ['bkuensting/status/1067316003200217088', 'IyCaQlzF0q8u9vBd'] 32 | ] 33 | 34 | 35 | class QuoteTest(BaseTestCase): 36 | @parameterized.expand(text) 37 | def test_text(self, tweet, fullname, username, text): 38 | self.open_nitter(tweet) 39 | quote = Quote(Conversation.main + " ") 40 | self.assert_text(fullname, quote.fullname) 41 | self.assert_text(username, quote.username) 42 | self.assert_text(text, quote.text) 43 | 44 | @parameterized.expand(image) 45 | def test_image(self, tweet, url): 46 | self.open_nitter(tweet) 47 | quote = Quote(Conversation.main + " ") 48 | self.assert_element_visible(quote.media) 49 | self.assertIn(url, self.get_image_url(quote.media + ' img')) 50 | 51 | @parameterized.expand(gif) 52 | def test_gif(self, tweet, url): 53 | self.open_nitter(tweet) 54 | quote = Quote(Conversation.main + " ") 55 | self.assert_element_visible(quote.media) 56 | self.assertIn(url, self.get_attribute(quote.media + ' source', 'src')) 57 | 58 | @parameterized.expand(video) 59 | def test_video(self, tweet, url): 60 | self.open_nitter(tweet) 61 | quote = Quote(Conversation.main + " ") 62 | self.assert_element_visible(quote.media) 63 | self.assertIn(url, self.get_image_url(quote.media + ' img')) 64 | -------------------------------------------------------------------------------- /src/sass/profile/photo-rail.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | 3 | .photo-rail { 4 | &-card { 5 | float: left; 6 | background: var(--bg_panel); 7 | border-radius: 0 0 4px 4px; 8 | width: 100%; 9 | margin: 5px 0; 10 | } 11 | 12 | &-header { 13 | padding: 5px 12px 0; 14 | } 15 | 16 | &-header-mobile { 17 | display: none; 18 | box-sizing: border-box; 19 | padding: 5px 12px 0; 20 | width: 100%; 21 | float: unset; 22 | color: var(--accent); 23 | justify-content: space-between; 24 | } 25 | 26 | &-grid { 27 | display: grid; 28 | grid-template-columns: repeat(4, 1fr); 29 | grid-gap: 3px 3px; 30 | padding: 5px 12px 12px; 31 | 32 | a { 33 | position: relative; 34 | border-radius: 5px; 35 | background-color: var(--darker_grey); 36 | 37 | &:before { 38 | content: ""; 39 | display: block; 40 | padding-top: 100%; 41 | } 42 | } 43 | 44 | img { 45 | height: 100%; 46 | width: 100%; 47 | border-radius: 4px; 48 | object-fit: cover; 49 | position: absolute; 50 | top: 0; 51 | left: 0; 52 | bottom: 0; 53 | right: 0; 54 | } 55 | } 56 | } 57 | 58 | @include create-toggle(photo-rail-grid, 640px); 59 | #photo-rail-grid-toggle:checked ~ .photo-rail-grid { 60 | padding-bottom: 12px; 61 | } 62 | 63 | @media(max-width: 700px) { 64 | .photo-rail-header { 65 | display: none; 66 | } 67 | 68 | .photo-rail-header-mobile { 69 | display: flex; 70 | } 71 | 72 | .photo-rail-grid { 73 | max-height: 0; 74 | padding-bottom: 0; 75 | overflow: hidden; 76 | transition: max-height 0.4s; 77 | } 78 | 79 | .photo-rail-grid { 80 | grid-template-columns: repeat(6, 1fr); 81 | } 82 | 83 | #photo-rail-grid-toggle:checked ~ .photo-rail-grid { 84 | max-height: 300px; 85 | } 86 | } 87 | 88 | @media(max-width: 450px) { 89 | .photo-rail-grid { 90 | grid-template-columns: repeat(4, 1fr); 91 | } 92 | 93 | #photo-rail-grid-toggle:checked ~ .photo-rail-grid { 94 | max-height: 450px; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/sass/include/_mixins.css: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | 3 | @mixin panel($width, $max-width) { 4 | max-width: $max-width; 5 | margin: 0 auto; 6 | float: none; 7 | border-radius: 0; 8 | position: relative; 9 | width: $width; 10 | } 11 | 12 | @mixin play-button { 13 | position: absolute; 14 | width: 100%; 15 | height: 100%; 16 | top: 0; 17 | left: 0; 18 | z-index: 1; 19 | 20 | &:hover { 21 | .overlay-circle { 22 | border-color: var(--play_button_hover); 23 | } 24 | 25 | .overlay-triangle { 26 | border-color: transparent transparent transparent var(--play_button_hover); 27 | } 28 | } 29 | } 30 | 31 | @mixin breakable { 32 | overflow: hidden; 33 | overflow-wrap: break-word; 34 | } 35 | 36 | @mixin ellipsis { 37 | overflow: hidden; 38 | text-overflow: ellipsis; 39 | white-space: nowrap; 40 | } 41 | 42 | @mixin center-panel($bg) { 43 | padding: 12px; 44 | border-radius: 4px; 45 | display: flex; 46 | background: $bg; 47 | box-shadow: 0 0 15px $shadow_dark; 48 | margin: auto; 49 | margin-top: -50px; 50 | } 51 | 52 | @mixin input-colors { 53 | &:hover { 54 | border-color: var(--accent); 55 | } 56 | 57 | &:active { 58 | border-color: var(--accent_light); 59 | } 60 | } 61 | 62 | @mixin search-resize($width, $rows) { 63 | @media(max-width: $width) { 64 | .search-toggles { 65 | grid-template-columns: repeat($rows, auto); 66 | } 67 | 68 | #search-panel-toggle:checked ~ .search-panel { 69 | @if $rows == 6 { 70 | max-height: 200px !important; 71 | } 72 | @if $rows == 5 { 73 | max-height: 300px !important; 74 | } 75 | @if $rows == 4 { 76 | max-height: 300px !important; 77 | } 78 | @if $rows == 3 { 79 | max-height: 365px !important; 80 | } 81 | } 82 | } 83 | } 84 | 85 | @mixin create-toggle($elem, $height) { 86 | ##{$elem}-toggle { 87 | display: none; 88 | 89 | &:checked ~ .#{$elem} { 90 | max-height: $height; 91 | } 92 | 93 | &:checked ~ label .icon-down:before { 94 | transform: rotate(180deg) translateY(-1px); 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/sass/tweet/card.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | @import '_mixins'; 3 | 4 | .card { 5 | margin: 5px 0; 6 | pointer-events: all; 7 | max-height: unset; 8 | } 9 | 10 | .card-container { 11 | border-radius: 10px; 12 | border-width: 1px; 13 | border-style: solid; 14 | border-color: var(--dark_grey); 15 | background-color: var(--bg_elements); 16 | overflow: hidden; 17 | color: inherit; 18 | display: flex; 19 | flex-direction: row; 20 | text-decoration: none !important; 21 | 22 | &:hover { 23 | border-color: var(--grey); 24 | } 25 | 26 | .attachments { 27 | margin: 0; 28 | border-radius: 0; 29 | } 30 | } 31 | 32 | .card-content { 33 | padding: 0.5em; 34 | } 35 | 36 | .card-title { 37 | @include ellipsis; 38 | white-space: unset; 39 | font-weight: bold; 40 | font-size: 1.1em; 41 | } 42 | 43 | .card-description { 44 | margin: 0.3em 0; 45 | } 46 | 47 | .card-destination { 48 | @include ellipsis; 49 | color: var(--grey); 50 | display: block; 51 | } 52 | 53 | .card-content-container { 54 | color: unset; 55 | overflow: auto; 56 | &:hover { 57 | text-decoration: none; 58 | } 59 | } 60 | 61 | .card-image-container { 62 | width: 98px; 63 | flex-shrink: 0; 64 | position: relative; 65 | overflow: hidden; 66 | &:before { 67 | content: ""; 68 | display: block; 69 | padding-top: 100%; 70 | } 71 | } 72 | 73 | .card-image { 74 | position: absolute; 75 | top: 0; 76 | left: 0; 77 | bottom: 0; 78 | right: 0; 79 | background-color: var(--bg_overlays); 80 | 81 | img { 82 | width: 100%; 83 | height: 100%; 84 | max-height: 400px; 85 | display: block; 86 | object-fit: cover; 87 | } 88 | } 89 | 90 | .card-overlay { 91 | @include play-button; 92 | opacity: 0.8; 93 | display: flex; 94 | justify-content: center; 95 | align-items: center; 96 | } 97 | 98 | .large { 99 | .card-container { 100 | display: block; 101 | } 102 | 103 | .card-image-container { 104 | width: unset; 105 | 106 | &:before { 107 | display: none; 108 | } 109 | } 110 | 111 | .card-image { 112 | position: unset; 113 | border-style: solid; 114 | border-color: var(--dark_grey); 115 | border-width: 0; 116 | border-bottom-width: 1px; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /public/md/about.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | Nitter is a free and open source alternative Twitter front-end focused on 4 | privacy and performance. The source is available on GitHub at 5 | 6 | 7 | * No JavaScript or ads 8 | * All requests go through the backend, client never talks to Twitter 9 | * Prevents Twitter from tracking your IP or JavaScript fingerprint 10 | * Uses Twitter's unofficial API (no rate limits or developer account required) 11 | * Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com) 12 | * RSS feeds 13 | * Themes 14 | * Mobile support (responsive design) 15 | * AGPLv3 licensed, no proprietary instances permitted 16 | 17 | Nitter's GitHub wiki contains 18 | [instances](https://github.com/zedeus/nitter/wiki/Instances) and 19 | [browser extensions](https://github.com/zedeus/nitter/wiki/Extensions) 20 | maintained by the community. 21 | 22 | ## Why use Nitter? 23 | 24 | It's impossible to use Twitter without JavaScript enabled. For privacy-minded 25 | folks, preventing JavaScript analytics and IP-based tracking is important, but 26 | apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind 27 | a VPN and using heavy-duty adblockers, you can get accurately tracked with your 28 | [browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/), 29 | [no JavaScript required](https://noscriptfingerprint.com/). This all became 30 | particularly important after Twitter [removed the 31 | ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws) 32 | for users to control whether their data gets sent to advertisers. 33 | 34 | Using an instance of Nitter (hosted on a VPS for example), you can browse 35 | Twitter without JavaScript while retaining your privacy. In addition to 36 | respecting your privacy, Nitter is on average around 15 times lighter than 37 | Twitter, and in most cases serves pages faster (eg. timelines load 2-4x faster). 38 | 39 | In the future a simple account system will be added that lets you follow Twitter 40 | users, allowing you to have a clean chronological timeline without needing a 41 | Twitter account. 42 | 43 | ## Donating 44 | 45 | Liberapay: \ 46 | Patreon: \ 47 | BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \ 48 | ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \ 49 | LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \ 50 | XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL 51 | 52 | ## Contact 53 | 54 | Feel free to join our [Matrix channel](https://matrix.to/#/#nitter:matrix.org). 55 | -------------------------------------------------------------------------------- /tests/test_timeline.py: -------------------------------------------------------------------------------- 1 | from base import BaseTestCase, Timeline 2 | from parameterized import parameterized 3 | 4 | normal = [['jack'], ['elonmusk']] 5 | 6 | after = [['jack', '1681686036294803456'], 7 | ['elonmusk', '1681686036294803456']] 8 | 9 | no_more = [['mobile_test_8?cursor=DAABCgABF4YVAqN___kKAAICNn_4msIQAAgAAwAAAAIAAA']] 10 | 11 | empty = [['emptyuser'], ['mobile_test_10']] 12 | 13 | protected = [['mobile_test_7'], ['Empty_user']] 14 | 15 | photo_rail = [['mobile_test', ['Bo0nDsYIYAIjqVn', 'BoQbwJAIUAA0QCY', 'BoQbRQxIIAA3FWD', 'Bn8Qh8iIIAABXrG']]] 16 | 17 | 18 | class TweetTest(BaseTestCase): 19 | @parameterized.expand(normal) 20 | def test_timeline(self, username): 21 | self.open_nitter(username) 22 | self.assert_element_present(Timeline.older) 23 | self.assert_element_absent(Timeline.newest) 24 | self.assert_element_absent(Timeline.end) 25 | self.assert_element_absent(Timeline.none) 26 | 27 | @parameterized.expand(after) 28 | def test_after(self, username, cursor): 29 | self.open_nitter(f'{username}?cursor={cursor}') 30 | self.assert_element_present(Timeline.newest) 31 | self.assert_element_present(Timeline.older) 32 | self.assert_element_absent(Timeline.end) 33 | self.assert_element_absent(Timeline.none) 34 | 35 | @parameterized.expand(no_more) 36 | def test_no_more(self, username): 37 | self.open_nitter(username) 38 | self.assert_text('No more items', Timeline.end) 39 | self.assert_element_present(Timeline.newest) 40 | self.assert_element_absent(Timeline.older) 41 | 42 | @parameterized.expand(empty) 43 | def test_empty(self, username): 44 | self.open_nitter(username) 45 | self.assert_text('No items found', Timeline.none) 46 | self.assert_element_absent(Timeline.newest) 47 | self.assert_element_absent(Timeline.older) 48 | self.assert_element_absent(Timeline.end) 49 | 50 | @parameterized.expand(protected) 51 | def test_protected(self, username): 52 | self.open_nitter(username) 53 | self.assert_text('This account\'s tweets are protected.', Timeline.protected) 54 | self.assert_element_absent(Timeline.newest) 55 | self.assert_element_absent(Timeline.older) 56 | self.assert_element_absent(Timeline.end) 57 | 58 | #@parameterized.expand(photo_rail) 59 | #def test_photo_rail(self, username, images): 60 | #self.open_nitter(username) 61 | #self.assert_element_visible(Timeline.photo_rail) 62 | #for i, url in enumerate(images): 63 | #img = self.get_attribute(Timeline.photo_rail + f' a:nth-child({i + 1}) img', 'src') 64 | #self.assertIn(url, img) 65 | -------------------------------------------------------------------------------- /src/sass/tweet/media.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | 3 | .gallery-row { 4 | display: flex; 5 | flex-direction: row; 6 | flex-wrap: nowrap; 7 | align-items: center; 8 | overflow: hidden; 9 | flex-grow: 1; 10 | max-height: 379.5px; 11 | max-width: 533px; 12 | pointer-events: all; 13 | 14 | .still-image { 15 | width: 100%; 16 | display: flex; 17 | } 18 | } 19 | 20 | .attachments { 21 | margin-top: .35em; 22 | display: flex; 23 | flex-direction: row; 24 | width: 100%; 25 | max-height: 600px; 26 | border-radius: 7px; 27 | overflow: hidden; 28 | flex-flow: column; 29 | background-color: var(--bg_color); 30 | align-items: center; 31 | pointer-events: all; 32 | 33 | .image-attachment { 34 | width: 100%; 35 | } 36 | } 37 | 38 | .attachment { 39 | position: relative; 40 | line-height: 0; 41 | overflow: hidden; 42 | margin: 0 .25em 0 0; 43 | flex-grow: 1; 44 | box-sizing: border-box; 45 | min-width: 2em; 46 | 47 | &:last-child { 48 | margin: 0; 49 | max-height: 530px; 50 | } 51 | } 52 | 53 | .gallery-gif video { 54 | max-height: 530px; 55 | background-color: #101010; 56 | } 57 | 58 | .still-image { 59 | max-height: 379.5px; 60 | max-width: 533px; 61 | justify-content: center; 62 | 63 | img { 64 | object-fit: cover; 65 | max-width: 100%; 66 | max-height: 379.5px; 67 | flex-basis: 300px; 68 | flex-grow: 1; 69 | } 70 | } 71 | 72 | .image { 73 | display: inline-block; 74 | } 75 | 76 | // .single-image { 77 | // display: inline-block; 78 | // width: 100%; 79 | // max-height: 600px; 80 | 81 | // .attachments { 82 | // width: unset; 83 | // max-height: unset; 84 | // display: inherit; 85 | // } 86 | // } 87 | 88 | .overlay-circle { 89 | border-radius: 50%; 90 | background-color: var(--dark_grey); 91 | width: 40px; 92 | height: 40px; 93 | align-items: center; 94 | display: flex; 95 | border-width: 5px; 96 | border-color: var(--play_button); 97 | border-style: solid; 98 | } 99 | 100 | .overlay-triangle { 101 | width: 0; 102 | height: 0; 103 | border-style: solid; 104 | border-width: 12px 0 12px 17px; 105 | border-color: transparent transparent transparent var(--play_button); 106 | margin-left: 14px; 107 | } 108 | 109 | .media-gif { 110 | display: table; 111 | background-color: unset; 112 | width: unset; 113 | } 114 | 115 | .media-body { 116 | flex: 1; 117 | padding: 0; 118 | white-space: pre-wrap; 119 | } 120 | -------------------------------------------------------------------------------- /src/sass/search.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | @import '_mixins'; 3 | 4 | .search-title { 5 | font-weight: bold; 6 | display: inline-block; 7 | margin-top: 4px; 8 | } 9 | 10 | .search-field { 11 | display: flex; 12 | flex-wrap: wrap; 13 | 14 | button { 15 | margin: 0 2px 0 0; 16 | height: 23px; 17 | display: flex; 18 | align-items: center; 19 | } 20 | 21 | .pref-input { 22 | margin: 0 4px 0 0; 23 | flex-grow: 1; 24 | height: 23px; 25 | } 26 | 27 | input[type="text"] { 28 | height: calc(100% - 4px); 29 | width: calc(100% - 8px); 30 | } 31 | 32 | > label { 33 | display: inline; 34 | background-color: var(--bg_elements); 35 | color: var(--fg_color); 36 | border: 1px solid var(--accent_border); 37 | padding: 1px 6px 2px 6px; 38 | font-size: 14px; 39 | cursor: pointer; 40 | margin-bottom: 2px; 41 | 42 | @include input-colors; 43 | } 44 | 45 | @include create-toggle(search-panel, 200px); 46 | } 47 | 48 | .search-panel { 49 | width: 100%; 50 | max-height: 0; 51 | overflow: hidden; 52 | transition: max-height 0.4s; 53 | 54 | flex-grow: 1; 55 | font-weight: initial; 56 | text-align: left; 57 | 58 | > div { 59 | line-height: 1.7em; 60 | } 61 | 62 | .checkbox-container { 63 | display: inline; 64 | padding-right: unset; 65 | margin-bottom: unset; 66 | margin-left: 23px; 67 | } 68 | 69 | .checkbox { 70 | right: unset; 71 | left: -22px; 72 | } 73 | 74 | .checkbox-container .checkbox:after { 75 | top: -4px; 76 | } 77 | } 78 | 79 | .search-row { 80 | display: flex; 81 | flex-wrap: wrap; 82 | line-height: unset; 83 | 84 | > div { 85 | flex-grow: 1; 86 | flex-shrink: 1; 87 | } 88 | 89 | input { 90 | height: 21px; 91 | } 92 | 93 | .pref-input { 94 | display: block; 95 | padding-bottom: 5px; 96 | 97 | input { 98 | height: 21px; 99 | margin-top: 1px; 100 | } 101 | } 102 | } 103 | 104 | .search-toggles { 105 | flex-grow: 1; 106 | display: grid; 107 | grid-template-columns: repeat(6, auto); 108 | grid-column-gap: 10px; 109 | } 110 | 111 | .profile-tabs { 112 | @include search-resize(820px, 5); 113 | @include search-resize(725px, 4); 114 | @include search-resize(600px, 6); 115 | @include search-resize(560px, 5); 116 | @include search-resize(480px, 4); 117 | @include search-resize(410px, 3); 118 | } 119 | 120 | @include search-resize(560px, 5); 121 | @include search-resize(480px, 4); 122 | @include search-resize(410px, 3); 123 | -------------------------------------------------------------------------------- /src/sass/profile/card.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | @import '_mixins'; 3 | 4 | .profile-card { 5 | flex-wrap: wrap; 6 | background: var(--bg_panel); 7 | padding: 12px; 8 | display: flex; 9 | } 10 | 11 | .profile-card-info { 12 | @include breakable; 13 | width: 100%; 14 | } 15 | 16 | .profile-card-tabs-name { 17 | @include breakable; 18 | max-width: 100%; 19 | } 20 | 21 | .profile-card-username { 22 | @include breakable; 23 | color: var(--fg_color); 24 | font-size: 14px; 25 | display: block; 26 | } 27 | 28 | .profile-card-fullname { 29 | @include breakable; 30 | color: var(--fg_color); 31 | font-size: 16px; 32 | font-weight: bold; 33 | text-shadow: none; 34 | max-width: 100%; 35 | } 36 | 37 | .profile-card-avatar { 38 | display: inline-block; 39 | position: relative; 40 | width: 100%; 41 | margin-right: 4px; 42 | margin-bottom: 6px; 43 | 44 | &:after { 45 | content: ''; 46 | display: block; 47 | margin-top: 100%; 48 | } 49 | 50 | img { 51 | box-sizing: border-box; 52 | position: absolute; 53 | width: 100%; 54 | height: 100%; 55 | border: 4px solid var(--darker_grey); 56 | background: var(--bg_panel); 57 | } 58 | } 59 | 60 | .profile-card-extra { 61 | display: contents; 62 | flex: 100%; 63 | margin-top: 7px; 64 | 65 | .profile-bio { 66 | @include breakable; 67 | width: 100%; 68 | margin: 4px -6px 6px 0; 69 | white-space: pre-wrap; 70 | 71 | p { 72 | margin: 0; 73 | } 74 | } 75 | 76 | .profile-joindate, .profile-location, .profile-website { 77 | color: var(--fg_faded); 78 | margin: 1px 0; 79 | width: 100%; 80 | } 81 | } 82 | 83 | .profile-card-extra-links { 84 | margin-top: 8px; 85 | font-size: 14px; 86 | width: 100%; 87 | } 88 | 89 | .profile-statlist { 90 | display: flex; 91 | flex-wrap: wrap; 92 | padding: 0; 93 | width: 100%; 94 | justify-content: space-between; 95 | 96 | li { 97 | display: table-cell; 98 | text-align: center; 99 | } 100 | } 101 | 102 | .profile-stat-header { 103 | font-weight: bold; 104 | color: var(--profile_stat); 105 | } 106 | 107 | .profile-stat-num { 108 | display: block; 109 | color: var(--profile_stat); 110 | } 111 | 112 | @media(max-width: 700px) { 113 | .profile-card-info { 114 | display: flex; 115 | } 116 | 117 | .profile-card-tabs-name { 118 | flex-shrink: 100; 119 | } 120 | 121 | .profile-card-avatar { 122 | width: 80px; 123 | height: 80px; 124 | 125 | img { 126 | border-width: 2px; 127 | width: unset; 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /tests/test_profile.py: -------------------------------------------------------------------------------- 1 | from base import BaseTestCase, Profile 2 | from parameterized import parameterized 3 | 4 | profiles = [ 5 | ['mobile_test', 'Test account', 6 | 'Test Account. test test Testing username with @mobile_test_2 and a #hashtag', 7 | 'San Francisco, CA', 'example.com/foobar', 'Joined October 2009', '98'], 8 | ['mobile_test_2', 'mobile test 2', '', '', '', 'Joined January 2011', '13'] 9 | ] 10 | 11 | verified = [['jack'], ['elonmusk']] 12 | 13 | protected = [ 14 | ['mobile_test_7', 'mobile test 7', ''], 15 | ['Poop', 'Randy', 'Social media fanatic.'] 16 | ] 17 | 18 | invalid = [['thisprofiledoesntexist'], ['%']] 19 | 20 | banner_image = [ 21 | ['mobile_test', 'profile_banners%2F82135242%2F1384108037%2F1500x500'] 22 | ] 23 | 24 | 25 | class ProfileTest(BaseTestCase): 26 | @parameterized.expand(profiles) 27 | def test_data(self, username, fullname, bio, location, website, joinDate, mediaCount): 28 | self.open_nitter(username) 29 | self.assert_exact_text(fullname, Profile.fullname) 30 | self.assert_exact_text(f'@{username}', Profile.username) 31 | 32 | tests = [ 33 | (bio, Profile.bio), 34 | (location, Profile.location), 35 | (website, Profile.website), 36 | (joinDate, Profile.joinDate), 37 | (mediaCount + " Photos and videos", Profile.mediaCount) 38 | ] 39 | 40 | for text, selector in tests: 41 | if len(text) > 0: 42 | self.assert_exact_text(text, selector) 43 | else: 44 | self.assert_element_absent(selector) 45 | 46 | @parameterized.expand(verified) 47 | def test_verified(self, username): 48 | self.open_nitter(username) 49 | self.assert_element_visible(Profile.verified) 50 | 51 | @parameterized.expand(protected) 52 | def test_protected(self, username, fullname, bio): 53 | self.open_nitter(username) 54 | self.assert_element_visible(Profile.protected) 55 | self.assert_exact_text(fullname, Profile.fullname) 56 | self.assert_exact_text(f'@{username}', Profile.username) 57 | 58 | if len(bio) > 0: 59 | self.assert_text(bio, Profile.bio) 60 | else: 61 | self.assert_element_absent(Profile.bio) 62 | 63 | @parameterized.expand(invalid) 64 | def test_invalid_username(self, username): 65 | self.open_nitter(username) 66 | self.assert_text(f'User "{username}" not found') 67 | 68 | def test_suspended(self): 69 | self.open_nitter('suspendme') 70 | self.assert_text('User "suspendme" has been suspended') 71 | 72 | @parameterized.expand(banner_image) 73 | def test_banner_image(self, username, url): 74 | self.open_nitter(username) 75 | banner = self.find_element(Profile.banner + ' img') 76 | self.assertIn(url, banner.get_attribute('src')) 77 | -------------------------------------------------------------------------------- /src/views/status.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import karax/[karaxdsl, vdom] 3 | 4 | import ".."/[types, formatters] 5 | import tweet, timeline 6 | 7 | proc renderEarlier(thread: Chain): VNode = 8 | buildHtml(tdiv(class="timeline-item more-replies earlier-replies")): 9 | a(class="more-replies-text", href=getLink(thread.content[0])): 10 | text "earlier replies" 11 | 12 | proc renderMoreReplies(thread: Chain): VNode = 13 | let link = getLink(thread.content[^1]) 14 | buildHtml(tdiv(class="timeline-item more-replies")): 15 | if thread.content[^1].available: 16 | a(class="more-replies-text", href=link): 17 | text "more replies" 18 | else: 19 | a(class="more-replies-text"): 20 | text "more replies" 21 | 22 | proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode = 23 | buildHtml(tdiv(class="reply thread thread-line")): 24 | for i, tweet in thread.content: 25 | let last = (i == thread.content.high and not thread.hasMore) 26 | renderTweet(tweet, prefs, path, index=i, last=last) 27 | 28 | if thread.hasMore: 29 | renderMoreReplies(thread) 30 | 31 | proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode = 32 | buildHtml(tdiv(class="replies", id="r")): 33 | for thread in replies.content: 34 | if thread.content.len == 0: continue 35 | renderReplyThread(thread, prefs, path) 36 | 37 | if replies.bottom.len > 0: 38 | renderMore(Query(), replies.bottom, focus="#r") 39 | 40 | proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode = 41 | let hasAfter = conv.after.content.len > 0 42 | let threadId = conv.tweet.threadId 43 | buildHtml(tdiv(class="conversation")): 44 | tdiv(class="main-thread"): 45 | if conv.before.content.len > 0: 46 | tdiv(class="before-tweet thread-line"): 47 | let first = conv.before.content[0] 48 | if threadId != first.id and (first.replyId > 0 or not first.available): 49 | renderEarlier(conv.before) 50 | for i, tweet in conv.before.content: 51 | renderTweet(tweet, prefs, path, index=i) 52 | 53 | tdiv(class="main-tweet", id="m"): 54 | let afterClass = if hasAfter: "thread thread-line" else: "" 55 | renderTweet(conv.tweet, prefs, path, class=afterClass, mainTweet=true) 56 | 57 | if hasAfter: 58 | tdiv(class="after-tweet thread-line"): 59 | let 60 | total = conv.after.content.high 61 | hasMore = conv.after.hasMore 62 | for i, tweet in conv.after.content: 63 | renderTweet(tweet, prefs, path, index=i, 64 | last=(i == total and not hasMore), afterTweet=true) 65 | 66 | if hasMore: 67 | renderMoreReplies(conv.after) 68 | 69 | if not prefs.hideReplies: 70 | if not conv.replies.beginning: 71 | renderNewer(Query(), getLink(conv.tweet), focus="#r") 72 | if conv.replies.content.len > 0 or conv.replies.bottom.len > 0: 73 | renderReplies(conv.replies, prefs, path) 74 | 75 | renderToTop(focus="#m") 76 | -------------------------------------------------------------------------------- /src/experimental/parser/user.nim: -------------------------------------------------------------------------------- 1 | import std/[algorithm, unicode, re, strutils, strformat, options, nre] 2 | import jsony 3 | import utils, slices 4 | import ../types/user as userType 5 | from ../../types import Result, User, Error 6 | 7 | let 8 | unRegex = re.re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})" 9 | unReplace = "$1@$2" 10 | 11 | htRegex = nre.re"""(*U)(^|[^\w-_.?])([##$])([\w_]*+)(?!|">|#)""" 12 | htReplace = "$1$2$3" 13 | 14 | proc expandUserEntities(user: var User; raw: RawUser) = 15 | let 16 | orig = user.bio.toRunes 17 | ent = raw.entities 18 | 19 | if ent.url.urls.len > 0: 20 | user.website = ent.url.urls[0].expandedUrl 21 | 22 | var replacements = newSeq[ReplaceSlice]() 23 | 24 | for u in ent.description.urls: 25 | replacements.extractUrls(u, orig.high) 26 | 27 | replacements.dedupSlices 28 | replacements.sort(cmp) 29 | 30 | user.bio = orig.replacedWith(replacements, 0 .. orig.len) 31 | .replacef(unRegex, unReplace) 32 | .replace(htRegex, htReplace) 33 | 34 | proc getBanner(user: RawUser): string = 35 | if user.profileBannerUrl.len > 0: 36 | return user.profileBannerUrl & "/1500x500" 37 | 38 | if user.profileLinkColor.len > 0: 39 | return '#' & user.profileLinkColor 40 | 41 | if user.profileImageExtensions.isSome: 42 | let ext = get(user.profileImageExtensions) 43 | if ext.mediaColor.r.ok.palette.len > 0: 44 | let color = ext.mediaColor.r.ok.palette[0].rgb 45 | return &"#{color.red:02x}{color.green:02x}{color.blue:02x}" 46 | 47 | proc toUser*(raw: RawUser): User = 48 | result = User( 49 | id: raw.idStr, 50 | username: raw.screenName, 51 | fullname: raw.name, 52 | location: raw.location, 53 | bio: raw.description, 54 | following: raw.friendsCount, 55 | followers: raw.followersCount, 56 | tweets: raw.statusesCount, 57 | likes: raw.favouritesCount, 58 | media: raw.mediaCount, 59 | verifiedType: raw.verifiedType, 60 | protected: raw.protected, 61 | joinDate: parseTwitterDate(raw.createdAt), 62 | banner: getBanner(raw), 63 | userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "") 64 | ) 65 | 66 | if raw.pinnedTweetIdsStr.len > 0: 67 | result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0]) 68 | 69 | result.expandUserEntities(raw) 70 | 71 | proc parseHook*(s: string; i: var int; v: var User) = 72 | var u: RawUser 73 | parseHook(s, i, u) 74 | v = toUser u 75 | 76 | proc parseUser*(json: string; username=""): User = 77 | handleErrors: 78 | case error.code 79 | of suspended: return User(username: username, suspended: true) 80 | of userNotFound: return 81 | else: echo "[error - parseUser]: ", error 82 | 83 | result = json.fromJson(User) 84 | 85 | proc parseUsers*(json: string; after=""): Result[User] = 86 | result = Result[User](beginning: after.len == 0) 87 | 88 | # starting with '{' means it's an error 89 | if json[0] == '[': 90 | let raw = json.fromJson(seq[RawUser]) 91 | for user in raw: 92 | result.content.add user.toUser 93 | -------------------------------------------------------------------------------- /public/js/infiniteScroll.js: -------------------------------------------------------------------------------- 1 | // @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0 2 | // SPDX-License-Identifier: AGPL-3.0-only 3 | function insertBeforeLast(node, elem) { 4 | node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]); 5 | } 6 | 7 | function getLoadMore(doc) { 8 | return doc.querySelector(".show-more:not(.timeline-item)"); 9 | } 10 | 11 | function isDuplicate(item, itemClass) { 12 | const tweet = item.querySelector(".tweet-link"); 13 | if (tweet == null) return false; 14 | const href = tweet.getAttribute("href"); 15 | return document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != null; 16 | } 17 | 18 | window.onload = function () { 19 | const url = window.location.pathname; 20 | const isTweet = url.indexOf("/status/") !== -1; 21 | const containerClass = isTweet ? ".replies" : ".timeline"; 22 | const itemClass = containerClass + " > div:not(.top-ref)"; 23 | 24 | var html = document.querySelector("html"); 25 | var container = document.querySelector(containerClass); 26 | var loading = false; 27 | 28 | function handleScroll(failed) { 29 | if (loading) return; 30 | 31 | if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) { 32 | loading = true; 33 | var loadMore = getLoadMore(document); 34 | if (loadMore == null) return; 35 | 36 | loadMore.children[0].text = "Loading..."; 37 | 38 | var url = new URL(loadMore.children[0].href); 39 | url.searchParams.append("scroll", "true"); 40 | 41 | fetch(url.toString()).then(function (response) { 42 | if (response.status === 404) throw "error"; 43 | 44 | return response.text(); 45 | }).then(function (html) { 46 | var parser = new DOMParser(); 47 | var doc = parser.parseFromString(html, "text/html"); 48 | loadMore.remove(); 49 | 50 | for (var item of doc.querySelectorAll(itemClass)) { 51 | if (item.className == "timeline-item show-more") continue; 52 | if (isDuplicate(item, itemClass)) continue; 53 | if (isTweet) container.appendChild(item); 54 | else insertBeforeLast(container, item); 55 | } 56 | 57 | loading = false; 58 | const newLoadMore = getLoadMore(doc); 59 | if (newLoadMore == null) return; 60 | if (isTweet) container.appendChild(newLoadMore); 61 | else insertBeforeLast(container, newLoadMore); 62 | }).catch(function (err) { 63 | console.warn("Something went wrong.", err); 64 | if (failed > 3) { 65 | loadMore.children[0].text = "Error"; 66 | return; 67 | } 68 | 69 | loading = false; 70 | handleScroll((failed || 0) + 1); 71 | }); 72 | } 73 | } 74 | 75 | window.addEventListener("scroll", () => handleScroll()); 76 | }; 77 | // @license-end 78 | -------------------------------------------------------------------------------- /src/sass/tweet/thread.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | @import '_mixins'; 3 | 4 | .conversation { 5 | @include panel(100%, 600px); 6 | 7 | .show-more { 8 | margin-bottom: 10px; 9 | } 10 | } 11 | 12 | .main-thread { 13 | margin-bottom: 20px; 14 | background-color: var(--bg_panel); 15 | } 16 | 17 | .main-tweet, .replies { 18 | padding-top: 50px; 19 | margin-top: -50px; 20 | } 21 | 22 | .main-tweet .tweet-content { 23 | font-size: 18px; 24 | } 25 | 26 | @media(max-width: 600px) { 27 | .main-tweet .tweet-content { 28 | font-size: 16px; 29 | } 30 | } 31 | 32 | .reply { 33 | background-color: var(--bg_panel); 34 | margin-bottom: 10px; 35 | } 36 | 37 | .thread-line { 38 | .timeline-item::before, 39 | &.timeline-item::before { 40 | background: var(--accent_dark); 41 | content: ''; 42 | position: relative; 43 | min-width: 3px; 44 | width: 3px; 45 | left: 26px; 46 | border-radius: 2px; 47 | margin-left: -3px; 48 | margin-bottom: 37px; 49 | top: 56px; 50 | z-index: 1; 51 | pointer-events: none; 52 | } 53 | 54 | .with-header:not(:first-child)::after { 55 | background: var(--accent_dark); 56 | content: ''; 57 | position: relative; 58 | float: left; 59 | min-width: 3px; 60 | width: 3px; 61 | right: calc(100% - 26px); 62 | border-radius: 2px; 63 | margin-left: -3px; 64 | margin-bottom: 37px; 65 | bottom: 10px; 66 | height: 30px; 67 | z-index: 1; 68 | pointer-events: none; 69 | } 70 | 71 | .unavailable::before { 72 | top: 48px; 73 | margin-bottom: 28px; 74 | } 75 | 76 | .more-replies::before { 77 | content: '...'; 78 | background: unset; 79 | color: var(--more_replies_dots); 80 | font-weight: bold; 81 | font-size: 20px; 82 | line-height: 0.25em; 83 | left: 1.2em; 84 | width: 5px; 85 | top: 2px; 86 | margin-bottom: 0; 87 | margin-left: -2.5px; 88 | } 89 | 90 | .earlier-replies { 91 | padding-bottom: 0; 92 | margin-bottom: -5px; 93 | } 94 | } 95 | 96 | .timeline-item.thread-last::before { 97 | background: unset; 98 | min-width: unset; 99 | width: 0; 100 | margin: 0; 101 | } 102 | 103 | .more-replies { 104 | padding-top: 0.3em !important; 105 | } 106 | 107 | .more-replies-text { 108 | @include ellipsis; 109 | display: block; 110 | margin-left: 58px; 111 | padding: 7px 0; 112 | } 113 | 114 | .timeline-item.thread.more-replies-thread { 115 | padding: 0 0.75em; 116 | 117 | &::before { 118 | top: 40px; 119 | margin-bottom: 31px; 120 | } 121 | 122 | .more-replies { 123 | display: flex; 124 | padding-top: unset !important; 125 | margin-top: 8px; 126 | 127 | &::before { 128 | display: inline-block; 129 | position: relative; 130 | top: -1px; 131 | line-height: 0.4em; 132 | } 133 | 134 | .more-replies-text { 135 | display: inline; 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | from seleniumbase import BaseCase 2 | 3 | 4 | class Card(object): 5 | def __init__(self, tweet=''): 6 | card = tweet + '.card ' 7 | self.link = card + 'a' 8 | self.title = card + '.card-title' 9 | self.description = card + '.card-description' 10 | self.destination = card + '.card-destination' 11 | self.image = card + '.card-image' 12 | 13 | 14 | class Quote(object): 15 | def __init__(self, tweet=''): 16 | quote = tweet + '.quote ' 17 | namerow = quote + '.fullname-and-username ' 18 | self.link = quote + '.quote-link' 19 | self.fullname = namerow + '.fullname' 20 | self.username = namerow + '.username' 21 | self.text = quote + '.quote-text' 22 | self.media = quote + '.quote-media-container' 23 | self.unavailable = quote + '.quote.unavailable' 24 | 25 | 26 | class Tweet(object): 27 | def __init__(self, tweet=''): 28 | namerow = tweet + '.tweet-header ' 29 | self.fullname = namerow + '.fullname' 30 | self.username = namerow + '.username' 31 | self.date = namerow + '.tweet-date' 32 | self.text = tweet + '.tweet-content.media-body' 33 | self.retweet = tweet + '.retweet-header' 34 | self.reply = tweet + '.replying-to' 35 | 36 | 37 | class Profile(object): 38 | fullname = '.profile-card-fullname' 39 | username = '.profile-card-username' 40 | protected = '.icon-lock' 41 | verified = '.verified-icon' 42 | banner = '.profile-banner' 43 | bio = '.profile-bio' 44 | location = '.profile-location' 45 | website = '.profile-website' 46 | joinDate = '.profile-joindate' 47 | mediaCount = '.photo-rail-header' 48 | 49 | 50 | class Timeline(object): 51 | newest = 'div[class="timeline-item show-more"]' 52 | older = 'div[class="show-more"]' 53 | end = '.timeline-end' 54 | none = '.timeline-none' 55 | protected = '.timeline-protected' 56 | photo_rail = '.photo-rail-grid' 57 | 58 | 59 | class Conversation(object): 60 | main = '.main-tweet' 61 | before = '.before-tweet' 62 | after = '.after-tweet' 63 | replies = '.replies' 64 | thread = '.reply' 65 | tweet = '.timeline-item' 66 | tweet_text = '.tweet-content' 67 | 68 | 69 | class Poll(object): 70 | votes = '.poll-info' 71 | choice = '.poll-meter' 72 | value = 'poll-choice-value' 73 | option = 'poll-choice-option' 74 | leader = 'leader' 75 | 76 | 77 | class Media(object): 78 | container = '.attachments' 79 | row = '.gallery-row' 80 | image = '.still-image' 81 | video = '.gallery-video' 82 | gif = '.gallery-gif' 83 | 84 | 85 | class BaseTestCase(BaseCase): 86 | def setUp(self): 87 | super(BaseTestCase, self).setUp() 88 | 89 | def tearDown(self): 90 | super(BaseTestCase, self).tearDown() 91 | 92 | def open_nitter(self, page=''): 93 | self.open(f'http://localhost:8080/{page}') 94 | 95 | def search_username(self, username): 96 | self.open_nitter() 97 | self.update_text('.search-bar input[type=text]', username) 98 | self.submit('.search-bar form') 99 | 100 | 101 | def get_timeline_tweet(num=1): 102 | return Tweet(f'.timeline > div:nth-child({num}) ') 103 | -------------------------------------------------------------------------------- /src/nitter.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import asyncdispatch, strformat, logging 3 | import config 4 | from net import Port 5 | from htmlgen import a 6 | from os import getEnv 7 | 8 | import jester 9 | 10 | import types, config, prefs, formatters, redis_cache, http_pool, auth 11 | import views/[general, about] 12 | import routes/[ 13 | preferences, timeline, status, media, search, rss, list, debug, 14 | unsupported, embed, resolver, router_utils] 15 | 16 | const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" 17 | const issuesUrl = "https://github.com/zedeus/nitter/issues" 18 | 19 | let 20 | accountsPath = getEnv("NITTER_ACCOUNTS_FILE", "./guest_accounts.json") 21 | 22 | initAccountPool(cfg, accountsPath) 23 | 24 | if not cfg.enableDebug: 25 | # Silence Jester's query warning 26 | addHandler(newConsoleLogger()) 27 | setLogFilter(lvlError) 28 | 29 | stdout.write &"Starting Nitter at {getUrlPrefix(cfg)}\n" 30 | stdout.flushFile 31 | 32 | updateDefaultPrefs(fullCfg) 33 | setCacheTimes(cfg) 34 | setHmacKey(cfg.hmacKey) 35 | setProxyEncoding(cfg.base64Media) 36 | setMaxHttpConns(cfg.httpMaxConns) 37 | setHttpProxy(cfg.proxy, cfg.proxyAuth) 38 | initAboutPage(cfg.staticDir) 39 | 40 | waitFor initRedisPool(cfg) 41 | stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n" 42 | stdout.flushFile 43 | 44 | createUnsupportedRouter(cfg) 45 | createResolverRouter(cfg) 46 | createPrefRouter(cfg) 47 | createTimelineRouter(cfg) 48 | createListRouter(cfg) 49 | createStatusRouter(cfg) 50 | createSearchRouter(cfg) 51 | createMediaRouter(cfg) 52 | createEmbedRouter(cfg) 53 | createRssRouter(cfg) 54 | createDebugRouter(cfg) 55 | 56 | settings: 57 | port = Port(cfg.port) 58 | staticDir = cfg.staticDir 59 | bindAddr = cfg.address 60 | reusePort = true 61 | 62 | routes: 63 | get "/": 64 | resp renderMain(renderSearch(), request, cfg, themePrefs()) 65 | 66 | get "/about": 67 | resp renderMain(renderAbout(), request, cfg, themePrefs()) 68 | 69 | get "/explore": 70 | redirect("/about") 71 | 72 | get "/help": 73 | redirect("/about") 74 | 75 | get "/i/redirect": 76 | let url = decodeUrl(@"url") 77 | if url.len == 0: resp Http404 78 | redirect(replaceUrls(url, cookiePrefs())) 79 | 80 | error Http404: 81 | resp Http404, showError("Page not found", cfg) 82 | 83 | error InternalError: 84 | echo error.exc.name, ": ", error.exc.msg 85 | const link = a("open a GitHub issue", href = issuesUrl) 86 | resp Http500, showError( 87 | &"An error occurred, please {link} with the URL you tried to visit.", cfg) 88 | 89 | error BadClientError: 90 | echo error.exc.name, ": ", error.exc.msg 91 | resp Http500, showError("Network error occurred, please try again.", cfg) 92 | 93 | error RateLimitError: 94 | const link = a("another instance", href = instancesUrl) 95 | resp Http429, showError( 96 | &"Instance has been rate limited.
Use {link} or try again later.", cfg) 97 | 98 | extend rss, "" 99 | extend status, "" 100 | extend search, "" 101 | extend timeline, "" 102 | extend media, "" 103 | extend list, "" 104 | extend preferences, "" 105 | extend resolver, "" 106 | extend embed, "" 107 | extend debug, "" 108 | extend unsupported, "" 109 | -------------------------------------------------------------------------------- /src/query.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils, strformat, sequtils, tables, uri 3 | 4 | import types 5 | 6 | const 7 | validFilters* = @[ 8 | "media", "images", "twimg", "videos", 9 | "native_video", "consumer_video", "pro_video", 10 | "links", "news", "quote", "mentions", 11 | "replies", "retweets", "nativeretweets", 12 | "verified", "safe" 13 | ] 14 | 15 | emptyQuery* = "include:nativeretweets" 16 | 17 | template `@`(param: string): untyped = 18 | if param in pms: pms[param] 19 | else: "" 20 | 21 | proc initQuery*(pms: Table[string, string]; name=""): Query = 22 | result = Query( 23 | kind: parseEnum[QueryKind](@"f", tweets), 24 | text: @"q", 25 | filters: validFilters.filterIt("f-" & it in pms), 26 | excludes: validFilters.filterIt("e-" & it in pms), 27 | since: @"since", 28 | until: @"until", 29 | near: @"near" 30 | ) 31 | 32 | if name.len > 0: 33 | result.fromUser = name.split(",") 34 | 35 | proc getMediaQuery*(name: string): Query = 36 | Query( 37 | kind: media, 38 | filters: @["twimg", "native_video"], 39 | fromUser: @[name], 40 | sep: "OR" 41 | ) 42 | 43 | 44 | proc getFavoritesQuery*(name: string): Query = 45 | Query( 46 | kind: favorites, 47 | fromUser: @[name] 48 | ) 49 | 50 | proc getReplyQuery*(name: string): Query = 51 | Query( 52 | kind: replies, 53 | fromUser: @[name] 54 | ) 55 | 56 | proc genQueryParam*(query: Query): string = 57 | var 58 | filters: seq[string] 59 | param: string 60 | 61 | if query.kind == users: 62 | return query.text 63 | 64 | for i, user in query.fromUser: 65 | param &= &"from:{user} " 66 | if i < query.fromUser.high: 67 | param &= "OR " 68 | 69 | if query.fromUser.len > 0 and query.kind in {posts, media}: 70 | param &= "filter:self_threads OR -filter:replies " 71 | 72 | if "nativeretweets" notin query.excludes: 73 | param &= "include:nativeretweets " 74 | 75 | for f in query.filters: 76 | filters.add "filter:" & f 77 | for e in query.excludes: 78 | if e == "nativeretweets": continue 79 | filters.add "-filter:" & e 80 | for i in query.includes: 81 | filters.add "include:" & i 82 | 83 | result = strip(param & filters.join(&" {query.sep} ")) 84 | if query.since.len > 0: 85 | result &= " since:" & query.since 86 | if query.until.len > 0: 87 | result &= " until:" & query.until 88 | if query.near.len > 0: 89 | result &= &" near:\"{query.near}\" within:15mi" 90 | if query.text.len > 0: 91 | if result.len > 0: 92 | result &= " " & query.text 93 | else: 94 | result = query.text 95 | 96 | proc genQueryUrl*(query: Query): string = 97 | if query.kind notin {tweets, users}: return 98 | 99 | var params = @[&"f={query.kind}"] 100 | if query.text.len > 0: 101 | params.add "q=" & encodeUrl(query.text) 102 | for f in query.filters: 103 | params.add &"f-{f}=on" 104 | for e in query.excludes: 105 | params.add &"e-{e}=on" 106 | for i in query.includes.filterIt(it != "nativeretweets"): 107 | params.add &"i-{i}=on" 108 | 109 | if query.since.len > 0: 110 | params.add "since=" & query.since 111 | if query.until.len > 0: 112 | params.add "until=" & query.until 113 | if query.near.len > 0: 114 | params.add "near=" & query.near 115 | 116 | if params.len > 0: 117 | result &= params.join("&") 118 | -------------------------------------------------------------------------------- /src/sass/timeline.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | 3 | .timeline-container { 4 | @include panel(100%, 600px); 5 | } 6 | 7 | .timeline { 8 | background-color: var(--bg_panel); 9 | 10 | > div:not(:first-child) { 11 | border-top: 1px solid var(--border_grey); 12 | } 13 | } 14 | 15 | .timeline-header { 16 | width: 100%; 17 | background-color: var(--bg_panel); 18 | text-align: center; 19 | padding: 8px; 20 | display: block; 21 | font-weight: bold; 22 | margin-bottom: 5px; 23 | box-sizing: border-box; 24 | 25 | button { 26 | float: unset; 27 | } 28 | } 29 | 30 | .timeline-banner img { 31 | width: 100%; 32 | } 33 | 34 | .timeline-description { 35 | font-weight: normal; 36 | } 37 | 38 | .tab { 39 | align-items: center; 40 | display: flex; 41 | flex-wrap: wrap; 42 | list-style: none; 43 | margin: 0 0 5px 0; 44 | background-color: var(--bg_panel); 45 | padding: 0; 46 | } 47 | 48 | .tab-item { 49 | flex: 1 1 0; 50 | text-align: center; 51 | margin-top: 0; 52 | 53 | a { 54 | border-bottom: .1rem solid transparent; 55 | color: var(--tab); 56 | display: block; 57 | padding: 8px 0; 58 | text-decoration: none; 59 | font-weight: bold; 60 | 61 | &:hover { 62 | text-decoration: none; 63 | } 64 | 65 | &.active { 66 | border-bottom-color: var(--tab_selected); 67 | color: var(--tab_selected); 68 | } 69 | } 70 | 71 | &.active a { 72 | border-bottom-color: var(--tab_selected); 73 | color: var(--tab_selected); 74 | } 75 | 76 | &.wide { 77 | flex-grow: 1.2; 78 | flex-basis: 50px; 79 | } 80 | } 81 | 82 | .timeline-footer { 83 | background-color: var(--bg_panel); 84 | padding: 6px 0; 85 | } 86 | 87 | .timeline-protected { 88 | text-align: center; 89 | 90 | p { 91 | margin: 8px 0; 92 | } 93 | 94 | h2 { 95 | color: var(--accent); 96 | font-size: 20px; 97 | font-weight: 600; 98 | } 99 | } 100 | 101 | .timeline-none { 102 | color: var(--accent); 103 | font-size: 20px; 104 | font-weight: 600; 105 | text-align: center; 106 | } 107 | 108 | .timeline-end { 109 | background-color: var(--bg_panel); 110 | color: var(--accent); 111 | font-size: 16px; 112 | font-weight: 600; 113 | text-align: center; 114 | } 115 | 116 | .show-more { 117 | background-color: var(--bg_panel); 118 | text-align: center; 119 | padding: .75em 0; 120 | display: block !important; 121 | 122 | a { 123 | background-color: var(--darkest_grey); 124 | display: inline-block; 125 | height: 2em; 126 | padding: 0 2em; 127 | line-height: 2em; 128 | 129 | &:hover { 130 | background-color: var(--darker_grey); 131 | } 132 | } 133 | } 134 | 135 | .top-ref { 136 | background-color: var(--bg_color); 137 | border-top: none !important; 138 | 139 | .icon-down { 140 | font-size: 20px; 141 | display: flex; 142 | justify-content: center; 143 | text-decoration: none; 144 | 145 | &:hover { 146 | color: var(--accent_light); 147 | } 148 | 149 | &::before { 150 | transform: rotate(180deg) translateY(-1px); 151 | } 152 | } 153 | } 154 | 155 | .timeline-item { 156 | overflow-wrap: break-word; 157 | border-left-width: 0; 158 | min-width: 0; 159 | padding: .75em; 160 | display: flex; 161 | position: relative; 162 | } 163 | -------------------------------------------------------------------------------- /src/views/renderutils.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils, strformat 3 | import karax/[karaxdsl, vdom, vstyles] 4 | import ".."/[types, utils] 5 | 6 | const smallWebp* = "?name=small&format=webp" 7 | 8 | proc getSmallPic*(url: string): string = 9 | result = url 10 | if "?" notin url and not url.endsWith("placeholder.png"): 11 | result &= smallWebp 12 | result = getPicUrl(result) 13 | 14 | proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode = 15 | var c = "icon-" & icon 16 | if class.len > 0: c = &"{c} {class}" 17 | buildHtml(tdiv(class="icon-container")): 18 | if href.len > 0: 19 | a(class=c, title=title, href=href) 20 | else: 21 | span(class=c, title=title) 22 | 23 | if text.len > 0: 24 | text " " & text 25 | 26 | template verifiedIcon*(user: User): untyped {.dirty.} = 27 | if user.verifiedType != VerifiedType.none: 28 | let lower = ($user.verifiedType).toLowerAscii() 29 | icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account") 30 | else: 31 | text "" 32 | 33 | proc linkUser*(user: User, class=""): VNode = 34 | let 35 | isName = "username" notin class 36 | href = "/" & user.username 37 | nameText = if isName: user.fullname 38 | else: "@" & user.username 39 | 40 | buildHtml(a(href=href, class=class, title=nameText)): 41 | text nameText 42 | if isName: 43 | verifiedIcon(user) 44 | if user.protected: 45 | text " " 46 | icon "lock", title="Protected account" 47 | 48 | proc linkText*(text: string; class=""): VNode = 49 | let url = if "http" notin text: https & text else: text 50 | buildHtml(): 51 | a(href=url, class=class): text text 52 | 53 | proc hiddenField*(name, value: string): VNode = 54 | buildHtml(): 55 | input(name=name, style={display: "none"}, value=value) 56 | 57 | proc refererField*(path: string): VNode = 58 | hiddenField("referer", path) 59 | 60 | proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNode = 61 | buildHtml(form(`method`=`method`, action=action, class=class)): 62 | refererField path 63 | button(`type`="submit"): 64 | text text 65 | 66 | proc genCheckbox*(pref, label: string; state: bool): VNode = 67 | buildHtml(label(class="pref-group checkbox-container")): 68 | text label 69 | input(name=pref, `type`="checkbox", checked=state) 70 | span(class="checkbox") 71 | 72 | proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode = 73 | let p = placeholder 74 | buildHtml(tdiv(class=("pref-group pref-input " & class))): 75 | if label.len > 0: 76 | label(`for`=pref): text label 77 | input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0)) 78 | 79 | proc genSelect*(pref, label, state: string; options: seq[string]): VNode = 80 | buildHtml(tdiv(class="pref-group pref-input")): 81 | label(`for`=pref): text label 82 | select(name=pref): 83 | for opt in options: 84 | option(value=opt, selected=(opt == state)): 85 | text opt 86 | 87 | proc genDate*(pref, state: string): VNode = 88 | buildHtml(span(class="date-input")): 89 | input(name=pref, `type`="date", value=state) 90 | icon "calendar" 91 | 92 | proc genImg*(url: string; class=""): VNode = 93 | buildHtml(): 94 | img(src=getPicUrl(url), class=class, alt="") 95 | 96 | proc getTabClass*(query: Query; tab: QueryKind): string = 97 | if query.kind == tab: "tab-item active" 98 | else: "tab-item" 99 | 100 | proc getAvatarClass*(prefs: Prefs): string = 101 | if prefs.squareAvatars: "avatar" 102 | else: "avatar round" 103 | -------------------------------------------------------------------------------- /src/experimental/types/unifiedcard.nim: -------------------------------------------------------------------------------- 1 | import std/[options, tables, times] 2 | import jsony 3 | from ../../types import VideoType, VideoVariant, User 4 | 5 | type 6 | Text* = distinct string 7 | 8 | UnifiedCard* = object 9 | componentObjects*: Table[string, Component] 10 | destinationObjects*: Table[string, Destination] 11 | mediaEntities*: Table[string, MediaEntity] 12 | appStoreData*: Table[string, seq[AppStoreData]] 13 | 14 | ComponentType* = enum 15 | details 16 | media 17 | swipeableMedia 18 | buttonGroup 19 | jobDetails 20 | appStoreDetails 21 | twitterListDetails 22 | communityDetails 23 | mediaWithDetailsHorizontal 24 | hidden 25 | unknown 26 | 27 | Component* = object 28 | kind*: ComponentType 29 | data*: ComponentData 30 | 31 | ComponentData* = object 32 | id*: string 33 | appId*: string 34 | mediaId*: string 35 | destination*: string 36 | location*: string 37 | title*: Text 38 | subtitle*: Text 39 | name*: Text 40 | memberCount*: int 41 | mediaList*: seq[MediaItem] 42 | topicDetail*: tuple[title: Text] 43 | profileUser*: User 44 | shortDescriptionText*: string 45 | 46 | MediaItem* = object 47 | id*: string 48 | destination*: string 49 | 50 | Destination* = object 51 | kind*: string 52 | data*: tuple[urlData: UrlData] 53 | 54 | UrlData* = object 55 | url*: string 56 | vanity*: string 57 | 58 | MediaType* = enum 59 | photo, video, model3d 60 | 61 | MediaEntity* = object 62 | kind*: MediaType 63 | mediaUrlHttps*: string 64 | videoInfo*: Option[VideoInfo] 65 | 66 | VideoInfo* = object 67 | durationMillis*: int 68 | variants*: seq[VideoVariant] 69 | 70 | AppType* = enum 71 | androidApp, iPhoneApp, iPadApp 72 | 73 | AppStoreData* = object 74 | kind*: AppType 75 | id*: string 76 | title*: Text 77 | category*: Text 78 | 79 | TypeField = Component | Destination | MediaEntity | AppStoreData 80 | 81 | converter fromText*(text: Text): string = string(text) 82 | 83 | proc renameHook*(v: var TypeField; fieldName: var string) = 84 | if fieldName == "type": 85 | fieldName = "kind" 86 | 87 | proc enumHook*(s: string; v: var ComponentType) = 88 | v = case s 89 | of "details": details 90 | of "media": media 91 | of "swipeable_media": swipeableMedia 92 | of "button_group": buttonGroup 93 | of "job_details": jobDetails 94 | of "app_store_details": appStoreDetails 95 | of "twitter_list_details": twitterListDetails 96 | of "community_details": communityDetails 97 | of "media_with_details_horizontal": mediaWithDetailsHorizontal 98 | of "commerce_drop_details": hidden 99 | else: echo "ERROR: Unknown enum value (ComponentType): ", s; unknown 100 | 101 | proc enumHook*(s: string; v: var AppType) = 102 | v = case s 103 | of "android_app": androidApp 104 | of "iphone_app": iPhoneApp 105 | of "ipad_app": iPadApp 106 | else: echo "ERROR: Unknown enum value (AppType): ", s; androidApp 107 | 108 | proc enumHook*(s: string; v: var MediaType) = 109 | v = case s 110 | of "video": video 111 | of "photo": photo 112 | of "model3d": model3d 113 | else: echo "ERROR: Unknown enum value (MediaType): ", s; photo 114 | 115 | proc parseHook*(s: string; i: var int; v: var DateTime) = 116 | var str: string 117 | parseHook(s, i, str) 118 | v = parse(str, "yyyy-MM-dd hh:mm:ss") 119 | 120 | proc parseHook*(s: string; i: var int; v: var Text) = 121 | if s[i] == '"': 122 | var str: string 123 | parseHook(s, i, str) 124 | v = Text(str) 125 | else: 126 | var t: tuple[content: string] 127 | parseHook(s, i, t) 128 | v = Text(t.content) 129 | -------------------------------------------------------------------------------- /src/routes/status.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import asyncdispatch, strutils, sequtils, uri, options, sugar 3 | 4 | import jester, karax/vdom 5 | 6 | import router_utils 7 | import ".."/[types, formatters, api] 8 | import ../views/[general, status, search] 9 | 10 | export uri, sequtils, options, sugar 11 | export router_utils 12 | export api, formatters 13 | export status 14 | 15 | proc createStatusRouter*(cfg: Config) = 16 | router status: 17 | get "/@name/status/@id/@reactors": 18 | cond '.' notin @"name" 19 | let id = @"id" 20 | 21 | if id.len > 19 or id.any(c => not c.isDigit): 22 | resp Http404, showError("Invalid tweet ID", cfg) 23 | 24 | let prefs = cookiePrefs() 25 | 26 | # used for the infinite scroll feature 27 | if @"scroll".len > 0: 28 | let replies = await getReplies(id, getCursor()) 29 | if replies.content.len == 0: 30 | resp Http404, "" 31 | resp $renderReplies(replies, prefs, getPath()) 32 | 33 | if @"reactors" == "favoriters": 34 | resp renderMain(renderUserList(await getGraphFavoriters(id, getCursor()), prefs), 35 | request, cfg, prefs) 36 | elif @"reactors" == "retweeters": 37 | resp renderMain(renderUserList(await getGraphRetweeters(id, getCursor()), prefs), 38 | request, cfg, prefs) 39 | 40 | get "/@name/status/@id/?": 41 | cond '.' notin @"name" 42 | let id = @"id" 43 | 44 | if id.len > 19 or id.any(c => not c.isDigit): 45 | resp Http404, showError("Invalid tweet ID", cfg) 46 | 47 | let prefs = cookiePrefs() 48 | 49 | # used for the infinite scroll feature 50 | if @"scroll".len > 0: 51 | let replies = await getReplies(id, getCursor()) 52 | if replies.content.len == 0: 53 | resp Http404, "" 54 | resp $renderReplies(replies, prefs, getPath()) 55 | 56 | let conv = await getTweet(id, getCursor()) 57 | if conv == nil: 58 | echo "nil conv" 59 | 60 | if conv == nil or conv.tweet == nil or conv.tweet.id == 0: 61 | var error = "Tweet not found" 62 | if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0: 63 | error = conv.tweet.tombstone 64 | resp Http404, showError(error, cfg) 65 | 66 | let 67 | title = pageTitle(conv.tweet) 68 | ogTitle = pageTitle(conv.tweet.user) 69 | desc = conv.tweet.text 70 | 71 | var 72 | images = conv.tweet.photos 73 | video = "" 74 | 75 | if conv.tweet.video.isSome(): 76 | images = @[get(conv.tweet.video).thumb] 77 | video = getVideoEmbed(cfg, conv.tweet.id) 78 | elif conv.tweet.gif.isSome(): 79 | images = @[get(conv.tweet.gif).thumb] 80 | video = getPicUrl(get(conv.tweet.gif).url) 81 | elif conv.tweet.card.isSome(): 82 | let card = conv.tweet.card.get() 83 | if card.image.len > 0: 84 | images = @[card.image] 85 | elif card.video.isSome(): 86 | images = @[card.video.get().thumb] 87 | 88 | let html = renderConversation(conv, prefs, getPath() & "#m") 89 | resp renderMain(html, request, cfg, prefs, title, desc, ogTitle, 90 | images=images, video=video) 91 | 92 | get "/@name/@s/@id/@m/?@i?": 93 | cond @"s" in ["status", "statuses"] 94 | cond @"m" in ["video", "photo"] 95 | redirect("/$1/status/$2" % [@"name", @"id"]) 96 | 97 | get "/@name/statuses/@id/?": 98 | redirect("/$1/status/$2" % [@"name", @"id"]) 99 | 100 | get "/i/web/status/@id": 101 | redirect("/i/status/" & @"id") 102 | 103 | get "/@name/thread/@id/?": 104 | redirect("/$1/status/$2" % [@"name", @"id"]) 105 | -------------------------------------------------------------------------------- /src/experimental/parser/unifiedcard.nim: -------------------------------------------------------------------------------- 1 | import std/[options, tables, strutils, strformat, sugar] 2 | import jsony 3 | import user, ../types/unifiedcard 4 | from ../../types import Card, CardKind, Video 5 | from ../../utils import twimg, https 6 | 7 | proc getImageUrl(entity: MediaEntity): string = 8 | entity.mediaUrlHttps.dup(removePrefix(twimg), removePrefix(https)) 9 | 10 | proc parseDestination(id: string; card: UnifiedCard; result: var Card) = 11 | let destination = card.destinationObjects[id].data 12 | result.dest = destination.urlData.vanity 13 | result.url = destination.urlData.url 14 | 15 | proc parseDetails(data: ComponentData; card: UnifiedCard; result: var Card) = 16 | data.destination.parseDestination(card, result) 17 | 18 | result.text = data.title 19 | if result.text.len == 0: 20 | result.text = data.name 21 | 22 | proc parseMediaDetails(data: ComponentData; card: UnifiedCard; result: var Card) = 23 | data.destination.parseDestination(card, result) 24 | 25 | result.kind = summary 26 | result.image = card.mediaEntities[data.mediaId].getImageUrl 27 | result.text = data.topicDetail.title 28 | result.dest = "Topic" 29 | 30 | proc parseJobDetails(data: ComponentData; card: UnifiedCard; result: var Card) = 31 | data.destination.parseDestination(card, result) 32 | 33 | result.kind = CardKind.jobDetails 34 | result.title = data.title 35 | result.text = data.shortDescriptionText 36 | result.dest = &"@{data.profileUser.username} · {data.location}" 37 | 38 | proc parseAppDetails(data: ComponentData; card: UnifiedCard; result: var Card) = 39 | let app = card.appStoreData[data.appId][0] 40 | 41 | case app.kind 42 | of androidApp: 43 | result.url = "http://play.google.com/store/apps/details?id=" & app.id 44 | of iPhoneApp, iPadApp: 45 | result.url = "https://itunes.apple.com/app/id" & app.id 46 | 47 | result.text = app.title 48 | result.dest = app.category 49 | 50 | proc parseListDetails(data: ComponentData; result: var Card) = 51 | result.dest = &"List · {data.memberCount} Members" 52 | 53 | proc parseCommunityDetails(data: ComponentData; result: var Card) = 54 | result.dest = &"Community · {data.memberCount} Members" 55 | 56 | proc parseMedia(component: Component; card: UnifiedCard; result: var Card) = 57 | let mediaId = 58 | if component.kind == swipeableMedia: 59 | component.data.mediaList[0].id 60 | else: 61 | component.data.id 62 | 63 | let rMedia = card.mediaEntities[mediaId] 64 | case rMedia.kind: 65 | of photo: 66 | result.kind = summaryLarge 67 | result.image = rMedia.getImageUrl 68 | of video: 69 | let videoInfo = rMedia.videoInfo.get 70 | result.kind = promoVideo 71 | result.video = some Video( 72 | available: true, 73 | thumb: rMedia.getImageUrl, 74 | durationMs: videoInfo.durationMillis, 75 | variants: videoInfo.variants 76 | ) 77 | of model3d: 78 | result.title = "Unsupported 3D model ad" 79 | 80 | proc parseUnifiedCard*(json: string): Card = 81 | let card = json.fromJson(UnifiedCard) 82 | 83 | for component in card.componentObjects.values: 84 | case component.kind 85 | of details, communityDetails, twitterListDetails: 86 | component.data.parseDetails(card, result) 87 | of appStoreDetails: 88 | component.data.parseAppDetails(card, result) 89 | of mediaWithDetailsHorizontal: 90 | component.data.parseMediaDetails(card, result) 91 | of media, swipeableMedia: 92 | component.parseMedia(card, result) 93 | of buttonGroup: 94 | discard 95 | of ComponentType.jobDetails: 96 | component.data.parseJobDetails(card, result) 97 | of ComponentType.hidden: 98 | result.kind = CardKind.hidden 99 | of ComponentType.unknown: 100 | echo "ERROR: Unknown component type: ", json 101 | 102 | case component.kind 103 | of twitterListDetails: 104 | component.data.parseListDetails(result) 105 | of communityDetails: 106 | component.data.parseCommunityDetails(result) 107 | else: discard 108 | -------------------------------------------------------------------------------- /src/sass/inputs.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | @import '_mixins'; 3 | 4 | button { 5 | @include input-colors; 6 | background-color: var(--bg_elements); 7 | color: var(--fg_color); 8 | border: 1px solid var(--accent_border); 9 | padding: 3px 6px; 10 | font-size: 14px; 11 | cursor: pointer; 12 | float: right; 13 | } 14 | 15 | input[type="text"], 16 | input[type="date"], 17 | select { 18 | @include input-colors; 19 | background-color: var(--bg_elements); 20 | padding: 1px 4px; 21 | color: var(--fg_color); 22 | border: 1px solid var(--accent_border); 23 | border-radius: 0; 24 | font-size: 14px; 25 | } 26 | 27 | input[type="text"] { 28 | height: 16px; 29 | } 30 | 31 | select { 32 | height: 20px; 33 | padding: 0 2px; 34 | line-height: 1; 35 | } 36 | 37 | input[type="date"]::-webkit-inner-spin-button { 38 | display: none; 39 | } 40 | 41 | input[type="date"]::-webkit-clear-button { 42 | margin-left: 17px; 43 | filter: grayscale(100%); 44 | filter: hue-rotate(120deg); 45 | } 46 | 47 | input::-webkit-calendar-picker-indicator { 48 | opacity: 0; 49 | } 50 | 51 | input::-webkit-datetime-edit-day-field:focus, 52 | input::-webkit-datetime-edit-month-field:focus, 53 | input::-webkit-datetime-edit-year-field:focus { 54 | background-color: var(--accent); 55 | color: var(--fg_color); 56 | outline: none; 57 | } 58 | 59 | .date-range { 60 | .date-input { 61 | display: inline-block; 62 | position: relative; 63 | } 64 | 65 | .icon-container { 66 | pointer-events: none; 67 | position: absolute; 68 | top: 2px; 69 | right: 5px; 70 | } 71 | 72 | .search-title { 73 | margin: 0 2px; 74 | } 75 | } 76 | 77 | .icon-button button { 78 | color: var(--accent); 79 | text-decoration: none; 80 | background: none; 81 | border: none; 82 | float: none; 83 | padding: unset; 84 | padding-left: 4px; 85 | 86 | &:hover { 87 | color: var(--accent_light); 88 | } 89 | } 90 | 91 | .checkbox { 92 | position: absolute; 93 | top: 1px; 94 | right: 0; 95 | height: 17px; 96 | width: 17px; 97 | background-color: var(--bg_elements); 98 | border: 1px solid var(--accent_border); 99 | 100 | &:after { 101 | content: ""; 102 | position: absolute; 103 | display: none; 104 | } 105 | } 106 | 107 | .checkbox-container { 108 | display: block; 109 | position: relative; 110 | margin-bottom: 5px; 111 | cursor: pointer; 112 | user-select: none; 113 | padding-right: 22px; 114 | 115 | input { 116 | position: absolute; 117 | opacity: 0; 118 | cursor: pointer; 119 | height: 0; 120 | width: 0; 121 | 122 | &:checked ~ .checkbox:after { 123 | display: block; 124 | } 125 | } 126 | 127 | &:hover input ~ .checkbox { 128 | border-color: var(--accent); 129 | } 130 | 131 | &:active input ~ .checkbox { 132 | border-color: var(--accent_light); 133 | } 134 | 135 | .checkbox:after { 136 | left: 2px; 137 | bottom: 0; 138 | font-size: 13px; 139 | font-family: $font_4; 140 | content: '\e803'; 141 | } 142 | } 143 | 144 | .pref-group { 145 | display: inline; 146 | } 147 | 148 | .preferences { 149 | button { 150 | margin: 6px 0 3px 0; 151 | } 152 | 153 | label { 154 | padding-right: 150px; 155 | } 156 | 157 | select { 158 | position: absolute; 159 | top: 0; 160 | right: 0; 161 | display: block; 162 | -moz-appearance: none; 163 | -webkit-appearance: none; 164 | appearance: none; 165 | } 166 | 167 | input[type="text"] { 168 | position: absolute; 169 | right: 0; 170 | max-width: 140px; 171 | } 172 | 173 | .pref-group { 174 | display: block; 175 | } 176 | 177 | .pref-input { 178 | position: relative; 179 | margin-bottom: 6px; 180 | } 181 | 182 | .pref-reset { 183 | float: left; 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/sass/index.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | 3 | @import 'tweet/_base'; 4 | @import 'profile/_base'; 5 | @import 'general'; 6 | @import 'navbar'; 7 | @import 'inputs'; 8 | @import 'timeline'; 9 | @import 'search'; 10 | 11 | body { 12 | // colors 13 | --bg_color: #{$bg_color}; 14 | --fg_color: #{$fg_color}; 15 | --fg_faded: #{$fg_faded}; 16 | --fg_dark: #{$fg_dark}; 17 | --fg_nav: #{$fg_nav}; 18 | 19 | --bg_panel: #{$bg_panel}; 20 | --bg_elements: #{$bg_elements}; 21 | --bg_overlays: #{$bg_overlays}; 22 | --bg_hover: #{$bg_hover}; 23 | 24 | --grey: #{$grey}; 25 | --dark_grey: #{$dark_grey}; 26 | --darker_grey: #{$darker_grey}; 27 | --darkest_grey: #{$darkest_grey}; 28 | --border_grey: #{$border_grey}; 29 | 30 | --accent: #{$accent}; 31 | --accent_light: #{$accent_light}; 32 | --accent_dark: #{$accent_dark}; 33 | --accent_border: #{$accent_border}; 34 | 35 | --play_button: #{$play_button}; 36 | --play_button_hover: #{$play_button_hover}; 37 | 38 | --more_replies_dots: #{$more_replies_dots}; 39 | --error_red: #{$error_red}; 40 | 41 | --verified_blue: #{$verified_blue}; 42 | --verified_business: #{$verified_business}; 43 | --verified_government: #{$verified_government}; 44 | --icon_text: #{$icon_text}; 45 | 46 | --tab: #{$fg_color}; 47 | --tab_selected: #{$accent}; 48 | 49 | --profile_stat: #{$fg_color}; 50 | 51 | background-color: var(--bg_color); 52 | color: var(--fg_color); 53 | font-family: $font_0, $font_1, $font_2, $font_3; 54 | font-size: 14px; 55 | line-height: 1.3; 56 | margin: 0; 57 | } 58 | 59 | * { 60 | outline: unset; 61 | margin: 0; 62 | text-decoration: none; 63 | } 64 | 65 | h1 { 66 | display: inline; 67 | } 68 | 69 | h2, h3 { 70 | font-weight: normal; 71 | } 72 | 73 | p { 74 | margin: 14px 0; 75 | } 76 | 77 | a { 78 | color: var(--accent); 79 | 80 | &:hover { 81 | text-decoration: underline; 82 | } 83 | } 84 | 85 | fieldset { 86 | border: 0; 87 | padding: 0; 88 | margin-top: -0.6em; 89 | } 90 | 91 | legend { 92 | width: 100%; 93 | padding: .6em 0 .3em 0; 94 | border: 0; 95 | font-size: 16px; 96 | font-weight: 600; 97 | border-bottom: 1px solid var(--border_grey); 98 | margin-bottom: 8px; 99 | } 100 | 101 | .preferences .note { 102 | border-top: 1px solid var(--border_grey); 103 | border-bottom: 1px solid var(--border_grey); 104 | padding: 6px 0 8px 0; 105 | margin-bottom: 8px; 106 | margin-top: 16px; 107 | } 108 | 109 | ul { 110 | padding-left: 1.3em; 111 | } 112 | 113 | .container { 114 | display: flex; 115 | flex-wrap: wrap; 116 | box-sizing: border-box; 117 | padding-top: 50px; 118 | margin: auto; 119 | min-height: 100vh; 120 | } 121 | 122 | .icon-container { 123 | display: inline; 124 | } 125 | 126 | .overlay-panel { 127 | max-width: 600px; 128 | width: 100%; 129 | margin: 0 auto; 130 | margin-top: 10px; 131 | background-color: var(--bg_overlays); 132 | padding: 10px 15px; 133 | align-self: start; 134 | 135 | ul { 136 | margin-bottom: 14px; 137 | } 138 | 139 | p { 140 | word-break: break-word; 141 | } 142 | } 143 | 144 | .verified-icon { 145 | color: var(--icon_text); 146 | border-radius: 50%; 147 | flex-shrink: 0; 148 | margin: 2px 0 3px 3px; 149 | padding-top: 3px; 150 | height: 11px; 151 | width: 14px; 152 | font-size: 8px; 153 | display: inline-block; 154 | text-align: center; 155 | vertical-align: middle; 156 | 157 | &.blue { 158 | background-color: var(--verified_blue); 159 | } 160 | 161 | &.business { 162 | color: var(--bg_panel); 163 | background-color: var(--verified_business); 164 | } 165 | 166 | &.government { 167 | color: var(--bg_panel); 168 | background-color: var(--verified_government); 169 | } 170 | } 171 | 172 | @media(max-width: 600px) { 173 | .preferences-container { 174 | max-width: 95vw; 175 | } 176 | 177 | .nav-item, .nav-item .icon-container { 178 | font-size: 16px; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /tests/test_card.py: -------------------------------------------------------------------------------- 1 | from base import BaseTestCase, Card, Conversation 2 | from parameterized import parameterized 3 | 4 | 5 | card = [ 6 | ['nim_lang/status/1136652293510717440', 7 | 'Version 0.20.0 released', 8 | 'We are very proud to announce Nim version 0.20. This is a massive release, both literally and figuratively. It contains more than 1,000 commits and it marks our release candidate for version 1.0!', 9 | 'nim-lang.org', True], 10 | 11 | ['voidtarget/status/1094632512926605312', 12 | 'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too)', 13 | 'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim', 14 | 'gist.github.com', True], 15 | 16 | ['nim_lang/status/1082989146040340480', 17 | 'Nim in 2018: A short recap', 18 | 'There were several big news in the Nim world in 2018 – two new major releases, partnership with Status, and much more. But let us go chronologically.', 19 | 'nim-lang.org', True] 20 | ] 21 | 22 | no_thumb = [ 23 | ['FluentAI/status/1116417904831029248', 24 | 'LinkedIn', 25 | 'This link will take you to a page that’s not on LinkedIn', 26 | 'lnkd.in'], 27 | 28 | ['Thom_Wolf/status/1122466524860702729', 29 | 'facebookresearch/fairseq', 30 | 'Facebook AI Research Sequence-to-Sequence Toolkit written in Python. - GitHub - facebookresearch/fairseq: Facebook AI Research Sequence-to-Sequence Toolkit written in Python.', 31 | 'github.com'], 32 | 33 | ['brent_p/status/1088857328680488961', 34 | 'Hts Nim Sugar', 35 | 'hts-nim is a library that allows one to use htslib via the nim programming language. Nim is a garbage-collected language that compiles to C and often has similar performance. I have become very...', 36 | 'brentp.github.io'], 37 | 38 | ['voidtarget/status/1133028231672582145', 39 | 'sinkingsugar/nimqt-example', 40 | 'A sample of a Qt app written using mostly nim. Contribute to sinkingsugar/nimqt-example development by creating an account on GitHub.', 41 | 'github.com'] 42 | ] 43 | 44 | playable = [ 45 | ['nim_lang/status/1118234460904919042', 46 | 'Nim development blog 2019-03', 47 | 'Arne (aka Krux02)* debugging: * improved nim-gdb, $ works, framefilter * alias for --debugger:native: -g* bugs: * forwarding of .pure. * sizeof union* fe...', 48 | 'youtube.com'], 49 | 50 | ['nim_lang/status/1121090879823986688', 51 | 'Nim - First natively compiled language w/ hot code-reloading at...', 52 | '#nim #c++ #ACCUConfNim is a statically typed systems and applications programming language which offers perhaps some of the most powerful metaprogramming cap...', 53 | 'youtube.com'] 54 | ] 55 | 56 | class CardTest(BaseTestCase): 57 | @parameterized.expand(card) 58 | def test_card(self, tweet, title, description, destination, large): 59 | self.open_nitter(tweet) 60 | c = Card(Conversation.main + " ") 61 | self.assert_text(title, c.title) 62 | self.assert_text(destination, c.destination) 63 | self.assertIn('/pic/', self.get_image_url(c.image + ' img')) 64 | if len(description) > 0: 65 | self.assert_text(description, c.description) 66 | if large: 67 | self.assert_element_visible('.card.large') 68 | else: 69 | self.assert_element_not_visible('.card.large') 70 | 71 | @parameterized.expand(no_thumb) 72 | def test_card_no_thumb(self, tweet, title, description, destination): 73 | self.open_nitter(tweet) 74 | c = Card(Conversation.main + " ") 75 | self.assert_text(title, c.title) 76 | self.assert_text(destination, c.destination) 77 | if len(description) > 0: 78 | self.assert_text(description, c.description) 79 | 80 | @parameterized.expand(playable) 81 | def test_card_playable(self, tweet, title, description, destination): 82 | self.open_nitter(tweet) 83 | c = Card(Conversation.main + " ") 84 | self.assert_text(title, c.title) 85 | self.assert_text(destination, c.destination) 86 | self.assertIn('/pic/', self.get_image_url(c.image + ' img')) 87 | self.assert_element_visible('.card-overlay') 88 | if len(description) > 0: 89 | self.assert_text(description, c.description) 90 | -------------------------------------------------------------------------------- /tests/test_tweet_media.py: -------------------------------------------------------------------------------- 1 | from base import BaseTestCase, Poll, Media 2 | from parameterized import parameterized 3 | from selenium.webdriver.common.by import By 4 | 5 | poll = [ 6 | ['nim_lang/status/1064219801499955200', 'Style insensitivity', '91', 1, [ 7 | ('47%', 'Yay'), ('53%', 'Nay') 8 | ]], 9 | 10 | ['polls/status/1031986180622049281', 'What Tree Is Coolest?', '3,322', 1, [ 11 | ('30%', 'Oak'), ('42%', 'Bonsai'), ('5%', 'Hemlock'), ('23%', 'Apple') 12 | ]] 13 | ] 14 | 15 | image = [ 16 | ['mobile_test/status/519364660823207936', 'BzUnaDFCUAAmrjs'], 17 | #['mobile_test_2/status/324619691039543297', 'BIFH45vCUAAQecj'] 18 | ] 19 | 20 | gif = [ 21 | ['elonmusk/status/1141367104702038016', 'D9bzUqoUcAAfUgf'], 22 | ['Proj_Borealis/status/1136595194621677568', 'D8X_PJAXUAAavPT'] 23 | ] 24 | 25 | video_m3u8 = [ 26 | ['d0m96/status/1078373829917974528', '9q1-v9w8-ft3awgD.jpg'], 27 | ['SpaceX/status/1138474014152712192', 'ocJJj2uu4n1kyD2Y.jpg'] 28 | ] 29 | 30 | gallery = [ 31 | # ['mobile_test/status/451108446603980803', [ 32 | # ['BkKovdrCUAAEz79', 'BkKovdcCEAAfoBO'] 33 | # ]], 34 | 35 | # ['mobile_test/status/471539824713691137', [ 36 | # ['Bos--KNIQAAA7Li', 'Bos--FAIAAAWpah'], 37 | # ['Bos--IqIQAAav23'] 38 | # ]], 39 | 40 | ['mobile_test/status/469530783384743936', [ 41 | ['BoQbwJAIUAA0QCY', 'BoQbwN1IMAAuTiP'], 42 | ['BoQbwarIAAAlaE-', 'BoQbwh_IEAA27ef'] 43 | ]] 44 | ] 45 | 46 | 47 | class MediaTest(BaseTestCase): 48 | @parameterized.expand(poll) 49 | def test_poll(self, tweet, text, votes, leader, choices): 50 | self.open_nitter(tweet) 51 | self.assert_text(text, '.main-tweet') 52 | self.assert_text(votes, Poll.votes) 53 | 54 | poll_choices = self.find_elements(Poll.choice) 55 | for i, (v, o) in enumerate(choices): 56 | choice = poll_choices[i] 57 | value = choice.find_element(By.CLASS_NAME, Poll.value) 58 | option = choice.find_element(By.CLASS_NAME, Poll.option) 59 | choice_class = choice.get_attribute('class') 60 | 61 | self.assert_equal(v, value.text) 62 | self.assert_equal(o, option.text) 63 | 64 | if i == leader: 65 | self.assertIn(Poll.leader, choice_class) 66 | else: 67 | self.assertNotIn(Poll.leader, choice_class) 68 | 69 | @parameterized.expand(image) 70 | def test_image(self, tweet, url): 71 | self.open_nitter(tweet) 72 | self.assert_element_visible(Media.container) 73 | self.assert_element_visible(Media.image) 74 | 75 | image_url = self.get_image_url(Media.image + ' img') 76 | self.assertIn(url, image_url) 77 | 78 | @parameterized.expand(gif) 79 | def test_gif(self, tweet, gif_id): 80 | self.open_nitter(tweet) 81 | self.assert_element_visible(Media.container) 82 | self.assert_element_visible(Media.gif) 83 | 84 | url = self.get_attribute('source', 'src') 85 | thumb = self.get_attribute('video', 'poster') 86 | self.assertIn(gif_id + '.mp4', url) 87 | self.assertIn(gif_id + '.jpg', thumb) 88 | 89 | @parameterized.expand(video_m3u8) 90 | def test_video_m3u8(self, tweet, thumb): 91 | # no url because video playback isn't supported yet 92 | self.open_nitter(tweet) 93 | self.assert_element_visible(Media.container) 94 | self.assert_element_visible(Media.video) 95 | 96 | video_thumb = self.get_attribute(Media.video + ' img', 'src') 97 | self.assertIn(thumb, video_thumb) 98 | 99 | @parameterized.expand(gallery) 100 | def test_gallery(self, tweet, rows): 101 | self.open_nitter(tweet) 102 | self.assert_element_visible(Media.container) 103 | self.assert_element_visible(Media.row) 104 | self.assert_element_visible(Media.image) 105 | 106 | gallery_rows = self.find_elements(Media.row) 107 | self.assert_equal(len(rows), len(gallery_rows)) 108 | 109 | for i, row in enumerate(gallery_rows): 110 | images = row.find_elements(By.CSS_SELECTOR, 'img') 111 | self.assert_equal(len(rows[i]), len(images)) 112 | for j, image in enumerate(images): 113 | self.assertIn(rows[i][j], image.get_attribute('src')) 114 | -------------------------------------------------------------------------------- /src/routes/media.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import uri, strutils, httpclient, os, hashes, base64, re 3 | import asynchttpserver, asyncstreams, asyncfile, asyncnet 4 | 5 | import jester 6 | 7 | import router_utils 8 | import ".."/[types, formatters, utils] 9 | 10 | export asynchttpserver, asyncstreams, asyncfile, asyncnet 11 | export httpclient, os, strutils, asyncstreams, base64, re 12 | 13 | const 14 | m3u8Mime* = "application/vnd.apple.mpegurl" 15 | maxAge* = "max-age=604800" 16 | 17 | proc safeFetch*(url: string): Future[string] {.async.} = 18 | let client = newAsyncHttpClient() 19 | try: result = await client.getContent(url) 20 | except: discard 21 | finally: client.close() 22 | 23 | template respond*(req: asynchttpserver.Request; headers) = 24 | var msg = "HTTP/1.1 200 OK\c\L" 25 | for k, v in headers: 26 | msg.add(k & ": " & v & "\c\L") 27 | 28 | msg.add "\c\L" 29 | yield req.client.send(msg) 30 | 31 | proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} = 32 | result = Http200 33 | let 34 | request = req.getNativeReq() 35 | client = newAsyncHttpClient() 36 | 37 | try: 38 | let res = await client.get(url) 39 | if res.status != "200 OK": 40 | echo "[media] Proxying failed, status: $1, url: $2" % [res.status, url] 41 | return Http404 42 | 43 | let hashed = $hash(url) 44 | if request.headers.getOrDefault("If-None-Match") == hashed: 45 | return Http304 46 | 47 | let contentLength = 48 | if res.headers.hasKey("content-length"): 49 | res.headers["content-length", 0] 50 | else: 51 | "" 52 | 53 | let headers = newHttpHeaders({ 54 | "Content-Type": res.headers["content-type", 0], 55 | "Content-Length": contentLength, 56 | "Cache-Control": maxAge, 57 | "ETag": hashed 58 | }) 59 | 60 | respond(request, headers) 61 | 62 | var (hasValue, data) = (true, "") 63 | while hasValue: 64 | (hasValue, data) = await res.bodyStream.read() 65 | if hasValue: 66 | await request.client.send(data) 67 | data.setLen 0 68 | except HttpRequestError, ProtocolError, OSError: 69 | echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url] 70 | result = Http404 71 | finally: 72 | client.close() 73 | 74 | template check*(code): untyped = 75 | if code != Http200: 76 | resp code 77 | else: 78 | enableRawMode() 79 | break route 80 | 81 | proc decoded*(req: jester.Request; index: int): string = 82 | let 83 | based = req.matches[0].len > 1 84 | encoded = req.matches[index] 85 | if based: decode(encoded) 86 | else: decodeUrl(encoded) 87 | 88 | proc createMediaRouter*(cfg: Config) = 89 | router media: 90 | get "/pic/?": 91 | resp Http404 92 | 93 | get re"^\/pic\/orig\/(enc)?\/?(.+)": 94 | var url = decoded(request, 1) 95 | if "twimg.com" notin url: 96 | url.insert(twimg) 97 | if not url.startsWith(https): 98 | url.insert(https) 99 | url.add("?name=orig") 100 | 101 | let uri = parseUri(url) 102 | cond isTwitterUrl(uri) == true 103 | 104 | let code = await proxyMedia(request, url) 105 | check code 106 | 107 | get re"^\/pic\/(enc)?\/?(.+)": 108 | var url = decoded(request, 1) 109 | if "twimg.com" notin url: 110 | url.insert(twimg) 111 | if not url.startsWith(https): 112 | url.insert(https) 113 | 114 | let uri = parseUri(url) 115 | cond isTwitterUrl(uri) == true 116 | 117 | let code = await proxyMedia(request, url) 118 | check code 119 | 120 | get re"^\/video\/(enc)?\/?(.+)\/(.+)$": 121 | let url = decoded(request, 2) 122 | cond "http" in url 123 | 124 | if getHmac(url) != request.matches[1]: 125 | resp showError("Failed to verify signature", cfg) 126 | 127 | if ".mp4" in url or ".ts" in url or ".m4s" in url: 128 | let code = await proxyMedia(request, url) 129 | check code 130 | 131 | var content: string 132 | if ".vmap" in url: 133 | let m3u8 = getM3u8Url(await safeFetch(url)) 134 | if m3u8.len > 0: 135 | content = await safeFetch(url) 136 | else: 137 | resp Http404 138 | 139 | if ".m3u8" in url: 140 | let vid = await safeFetch(url) 141 | content = proxifyVideo(vid, cookiePref(proxyVideos)) 142 | 143 | resp content, m3u8Mime 144 | -------------------------------------------------------------------------------- /src/views/profile.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils, strformat 3 | import karax/[karaxdsl, vdom, vstyles] 4 | 5 | import renderutils, search 6 | import ".."/[types, utils, formatters] 7 | 8 | proc renderStat(num: int; class: string; text=""): VNode = 9 | let t = if text.len > 0: text else: class 10 | buildHtml(li(class=class)): 11 | span(class="profile-stat-header"): text capitalizeAscii(t) 12 | span(class="profile-stat-num"): 13 | text insertSep($num, ',') 14 | 15 | proc renderUserCard*(user: User; prefs: Prefs): VNode = 16 | buildHtml(tdiv(class="profile-card")): 17 | tdiv(class="profile-card-info"): 18 | let 19 | url = getPicUrl(user.getUserPic()) 20 | size = 21 | if prefs.autoplayGifs and user.userPic.endsWith("gif"): "" 22 | else: "_400x400" 23 | 24 | a(class="profile-card-avatar", href=url, target="_blank"): 25 | genImg(user.getUserPic(size)) 26 | 27 | tdiv(class="profile-card-tabs-name"): 28 | linkUser(user, class="profile-card-fullname") 29 | linkUser(user, class="profile-card-username") 30 | 31 | tdiv(class="profile-card-extra"): 32 | if user.bio.len > 0: 33 | tdiv(class="profile-bio"): 34 | p(dir="auto"): 35 | verbatim replaceUrls(user.bio, prefs) 36 | 37 | if user.location.len > 0: 38 | tdiv(class="profile-location"): 39 | span: icon "location" 40 | let (place, url) = getLocation(user) 41 | if url.len > 1: 42 | a(href=url): text place 43 | elif "://" in place: 44 | a(href=place): text place 45 | else: 46 | span: text place 47 | 48 | if user.website.len > 0: 49 | tdiv(class="profile-website"): 50 | span: 51 | let url = replaceUrls(user.website, prefs) 52 | icon "link" 53 | a(href=url): text url.shortLink 54 | 55 | tdiv(class="profile-joindate"): 56 | span(title=getJoinDateFull(user)): 57 | icon "calendar", getJoinDate(user) 58 | 59 | tdiv(class="profile-card-extra-links"): 60 | ul(class="profile-statlist"): 61 | a(href="/" & user.username): 62 | renderStat(user.tweets, "posts", text="Tweets") 63 | a(href="/" & user.username & "/following"): 64 | renderStat(user.following, "following") 65 | a(href="/" & user.username & "/followers"): 66 | renderStat(user.followers, "followers") 67 | a(href="/" & user.username & "/favorites"): 68 | renderStat(user.likes, "likes") 69 | 70 | proc renderPhotoRail(profile: Profile): VNode = 71 | let count = insertSep($profile.user.media, ',') 72 | buildHtml(tdiv(class="photo-rail-card")): 73 | tdiv(class="photo-rail-header"): 74 | a(href=(&"/{profile.user.username}/media")): 75 | icon "picture", count & " Photos and videos" 76 | 77 | input(id="photo-rail-grid-toggle", `type`="checkbox") 78 | label(`for`="photo-rail-grid-toggle", class="photo-rail-header-mobile"): 79 | icon "picture", count & " Photos and videos" 80 | icon "down" 81 | 82 | tdiv(class="photo-rail-grid"): 83 | for i, photo in profile.photoRail: 84 | if i == 16: break 85 | let photoSuffix = 86 | if "format" in photo.url or "placeholder" in photo.url: "" 87 | else: ":thumb" 88 | a(href=(&"/{profile.user.username}/status/{photo.tweetId}#m")): 89 | genImg(photo.url & photoSuffix) 90 | 91 | proc renderBanner(banner: string): VNode = 92 | buildHtml(): 93 | if banner.len == 0: 94 | a() 95 | elif banner.startsWith('#'): 96 | a(style={backgroundColor: banner}) 97 | else: 98 | a(href=getPicUrl(banner), target="_blank"): genImg(banner) 99 | 100 | proc renderProtected(username: string): VNode = 101 | buildHtml(tdiv(class="timeline-container")): 102 | tdiv(class="timeline-header timeline-protected"): 103 | h2: text "This account's tweets are protected." 104 | p: text &"Only confirmed followers have access to @{username}'s tweets." 105 | 106 | proc renderProfile*(profile: var Profile; cfg: Config; prefs: Prefs; path: string): VNode = 107 | profile.tweets.query.fromUser = @[profile.user.username] 108 | 109 | buildHtml(tdiv(class="profile-tabs")): 110 | if not prefs.hideBanner: 111 | tdiv(class="profile-banner"): 112 | renderBanner(profile.user.banner) 113 | 114 | let sticky = if prefs.stickyProfile: " sticky" else: "" 115 | tdiv(class=("profile-tab" & sticky)): 116 | renderUserCard(profile.user, prefs) 117 | if profile.photoRail.len > 0: 118 | renderPhotoRail(profile) 119 | 120 | if profile.user.protected: 121 | renderProtected(profile.user.username) 122 | else: 123 | renderTweetSearch(profile.tweets, prefs, path, profile.pinned) 124 | -------------------------------------------------------------------------------- /src/views/timeline.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils, strformat, algorithm, uri, options 3 | import karax/[karaxdsl, vdom] 4 | 5 | import ".."/[types, query, formatters] 6 | import tweet, renderutils 7 | 8 | proc getQuery(query: Query): string = 9 | if query.kind != posts: 10 | result = genQueryUrl(query) 11 | if result.len > 0: 12 | result &= "&" 13 | 14 | proc renderToTop*(focus="#"): VNode = 15 | buildHtml(tdiv(class="top-ref")): 16 | icon "down", href=focus 17 | 18 | proc renderNewer*(query: Query; path: string; focus=""): VNode = 19 | let 20 | q = genQueryUrl(query) 21 | url = if q.len > 0: "?" & q else: "" 22 | p = if focus.len > 0: path.replace("#m", focus) else: path 23 | buildHtml(tdiv(class="timeline-item show-more")): 24 | a(href=(p & url)): 25 | text "Load newest" 26 | 27 | proc renderMore*(query: Query; cursor: string; focus=""): VNode = 28 | buildHtml(tdiv(class="show-more")): 29 | a(href=(&"?{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")): 30 | text "Load more" 31 | 32 | proc renderNoMore(): VNode = 33 | buildHtml(tdiv(class="timeline-footer")): 34 | h2(class="timeline-end"): 35 | text "No more items" 36 | 37 | proc renderNoneFound(): VNode = 38 | buildHtml(tdiv(class="timeline-header")): 39 | h2(class="timeline-none"): 40 | text "No items found" 41 | 42 | proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode = 43 | buildHtml(tdiv(class="thread-line")): 44 | let sortedThread = thread.sortedByIt(it.id) 45 | for i, tweet in sortedThread: 46 | # thread has a gap, display "more replies" link 47 | if i > 0 and tweet.replyId != sortedThread[i - 1].id: 48 | tdiv(class="timeline-item thread more-replies-thread"): 49 | tdiv(class="more-replies"): 50 | a(class="more-replies-text", href=getLink(tweet)): 51 | text "more replies" 52 | 53 | let show = i == thread.high and sortedThread[0].id != tweet.threadId 54 | let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: "" 55 | renderTweet(tweet, prefs, path, class=(header & "thread"), 56 | index=i, last=(i == thread.high), showThread=show) 57 | 58 | proc renderUser(user: User; prefs: Prefs): VNode = 59 | buildHtml(tdiv(class="timeline-item")): 60 | a(class="tweet-link", href=("/" & user.username)) 61 | tdiv(class="tweet-body profile-result"): 62 | tdiv(class="tweet-header"): 63 | a(class="tweet-avatar", href=("/" & user.username)): 64 | genImg(user.getUserPic("_bigger"), class=prefs.getAvatarClass) 65 | 66 | tdiv(class="tweet-name-row"): 67 | tdiv(class="fullname-and-username"): 68 | linkUser(user, class="fullname") 69 | linkUser(user, class="username") 70 | 71 | tdiv(class="tweet-content media-body", dir="auto"): 72 | verbatim replaceUrls(user.bio, prefs) 73 | 74 | proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode = 75 | buildHtml(tdiv(class="timeline")): 76 | if not results.beginning: 77 | renderNewer(results.query, path) 78 | 79 | if results.content.len > 0: 80 | for user in results.content: 81 | renderUser(user, prefs) 82 | if results.bottom.len > 0: 83 | renderMore(results.query, results.bottom) 84 | renderToTop() 85 | elif results.beginning: 86 | renderNoneFound() 87 | else: 88 | renderNoMore() 89 | 90 | proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string; 91 | pinned=none(Tweet)): VNode = 92 | buildHtml(tdiv(class="timeline")): 93 | if not results.beginning: 94 | renderNewer(results.query, parseUri(path).path) 95 | 96 | if not prefs.hidePins and pinned.isSome: 97 | let tweet = get pinned 98 | renderTweet(tweet, prefs, path, showThread=tweet.hasThread) 99 | 100 | if results.content.len == 0: 101 | if not results.beginning: 102 | renderNoMore() 103 | else: 104 | renderNoneFound() 105 | else: 106 | var retweets: seq[int64] 107 | 108 | for thread in results.content: 109 | if thread.len == 1: 110 | let 111 | tweet = thread[0] 112 | retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0 113 | 114 | if retweetId in retweets or tweet.id in retweets or 115 | tweet.pinned and prefs.hidePins: 116 | continue 117 | 118 | var hasThread = tweet.hasThread 119 | if retweetId != 0 and tweet.retweet.isSome: 120 | retweets &= retweetId 121 | hasThread = get(tweet.retweet).hasThread 122 | renderTweet(tweet, prefs, path, showThread=hasThread) 123 | else: 124 | renderThread(thread, prefs, path) 125 | 126 | if results.bottom.len > 0: 127 | renderMore(results.query, results.bottom) 128 | renderToTop() 129 | -------------------------------------------------------------------------------- /src/sass/tweet/_base.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | @import '_mixins'; 3 | @import 'thread'; 4 | @import 'media'; 5 | @import 'video'; 6 | @import 'embed'; 7 | @import 'card'; 8 | @import 'poll'; 9 | @import 'quote'; 10 | 11 | .tweet-body { 12 | flex: 1; 13 | min-width: 0; 14 | margin-left: 58px; 15 | pointer-events: none; 16 | z-index: 1; 17 | } 18 | 19 | .tweet-content { 20 | font-family: $font_3; 21 | line-height: 1.3em; 22 | pointer-events: all; 23 | display: inline; 24 | } 25 | 26 | .tweet-bidi { 27 | display: block !important; 28 | } 29 | 30 | .tweet-header { 31 | padding: 0; 32 | vertical-align: bottom; 33 | flex-basis: 100%; 34 | margin-bottom: .2em; 35 | 36 | a { 37 | display: inline-block; 38 | word-break: break-all; 39 | max-width: 100%; 40 | pointer-events: all; 41 | } 42 | } 43 | 44 | .tweet-name-row { 45 | padding: 0; 46 | display: flex; 47 | justify-content: space-between; 48 | } 49 | 50 | .fullname-and-username { 51 | display: flex; 52 | min-width: 0; 53 | } 54 | 55 | .fullname { 56 | @include ellipsis; 57 | flex-shrink: 2; 58 | max-width: 80%; 59 | font-size: 14px; 60 | font-weight: 700; 61 | color: var(--fg_color); 62 | } 63 | 64 | .username { 65 | @include ellipsis; 66 | min-width: 1.6em; 67 | margin-left: .4em; 68 | word-wrap: normal; 69 | } 70 | 71 | .tweet-date { 72 | display: flex; 73 | flex-shrink: 0; 74 | margin-left: 4px; 75 | } 76 | 77 | .tweet-date a, .username, .show-more a { 78 | color: var(--fg_dark); 79 | } 80 | 81 | .tweet-published { 82 | margin: 0; 83 | margin-top: 5px; 84 | color: var(--grey); 85 | pointer-events: all; 86 | } 87 | 88 | .tweet-avatar { 89 | display: contents !important; 90 | 91 | img { 92 | float: left; 93 | margin-top: 3px; 94 | margin-left: -58px; 95 | width: 48px; 96 | height: 48px; 97 | } 98 | } 99 | 100 | .avatar { 101 | &.round { 102 | border-radius: 50%; 103 | -webkit-user-select: none; 104 | } 105 | 106 | &.mini { 107 | position: unset; 108 | margin-right: 5px; 109 | margin-top: -1px; 110 | width: 20px; 111 | height: 20px; 112 | } 113 | } 114 | 115 | .tweet-embed { 116 | display: flex; 117 | flex-direction: column; 118 | justify-content: center; 119 | height: 100%; 120 | background-color: var(--bg_panel); 121 | 122 | .tweet-content { 123 | font-size: 18px; 124 | } 125 | 126 | .tweet-body { 127 | display: flex; 128 | flex-direction: column; 129 | max-height: calc(100vh - 0.75em * 2); 130 | } 131 | 132 | .card-image img { 133 | height: auto; 134 | } 135 | 136 | .avatar { 137 | position: absolute; 138 | } 139 | } 140 | 141 | .attribution { 142 | display: flex; 143 | pointer-events: all; 144 | margin: 5px 0; 145 | 146 | strong { 147 | color: var(--fg_color); 148 | } 149 | } 150 | 151 | .media-tag-block { 152 | padding-top: 5px; 153 | pointer-events: all; 154 | color: var(--fg_faded); 155 | 156 | .icon-container { 157 | padding-right: 2px; 158 | } 159 | 160 | .media-tag, .icon-container { 161 | color: var(--fg_faded); 162 | } 163 | } 164 | 165 | .timeline-container .media-tag-block { 166 | font-size: 13px; 167 | } 168 | 169 | .tweet-geo { 170 | color: var(--fg_faded); 171 | } 172 | 173 | .replying-to { 174 | color: var(--fg_faded); 175 | margin: -2px 0 4px; 176 | 177 | a { 178 | pointer-events: all; 179 | } 180 | } 181 | 182 | .retweet-header, .pinned, .tweet-stats { 183 | align-content: center; 184 | color: var(--grey); 185 | display: flex; 186 | flex-shrink: 0; 187 | flex-wrap: wrap; 188 | font-size: 14px; 189 | font-weight: 600; 190 | line-height: 22px; 191 | 192 | span { 193 | @include ellipsis; 194 | } 195 | } 196 | 197 | .retweet-header { 198 | margin-top: -5px !important; 199 | } 200 | 201 | .tweet-stats { 202 | margin-bottom: -3px; 203 | -webkit-user-select: none; 204 | } 205 | 206 | .tweet-stat { 207 | padding-top: 5px; 208 | min-width: 1em; 209 | margin-right: 0.8em; 210 | pointer-events: all; 211 | } 212 | 213 | .show-thread { 214 | display: block; 215 | pointer-events: all; 216 | padding-top: 2px; 217 | } 218 | 219 | .unavailable-box { 220 | width: 100%; 221 | height: 100%; 222 | padding: 12px; 223 | border: solid 1px var(--dark_grey); 224 | box-sizing: border-box; 225 | border-radius: 10px; 226 | background-color: var(--bg_color); 227 | z-index: 2; 228 | } 229 | 230 | .tweet-link { 231 | height: 100%; 232 | width: 100%; 233 | left: 0; 234 | top: 0; 235 | position: absolute; 236 | -webkit-user-select: none; 237 | 238 | &:hover { 239 | background-color: var(--bg_hover); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /src/views/search.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils, strformat, sequtils, unicode, tables, options 3 | import karax/[karaxdsl, vdom] 4 | 5 | import renderutils, timeline 6 | import ".."/[types, query, config] 7 | 8 | const toggles = { 9 | "nativeretweets": "Retweets", 10 | "media": "Media", 11 | "videos": "Videos", 12 | "news": "News", 13 | "verified": "Verified", 14 | "native_video": "Native videos", 15 | "replies": "Replies", 16 | "links": "Links", 17 | "images": "Images", 18 | "safe": "Safe", 19 | "quote": "Quotes", 20 | "pro_video": "Pro videos" 21 | }.toOrderedTable 22 | 23 | proc renderSearch*(): VNode = 24 | buildHtml(tdiv(class="panel-container")): 25 | tdiv(class="search-bar"): 26 | form(`method`="get", action="/search", autocomplete="off"): 27 | hiddenField("f", "users") 28 | input(`type`="text", name="q", autofocus="", 29 | placeholder="Enter username...", dir="auto") 30 | button(`type`="submit"): icon "search" 31 | 32 | proc renderProfileTabs*(query: Query; username: string; cfg: Config): VNode = 33 | let link = "/" & username 34 | buildHtml(ul(class="tab")): 35 | li(class=query.getTabClass(posts)): 36 | a(href=link): text "Tweets" 37 | li(class=(query.getTabClass(replies) & " wide")): 38 | a(href=(link & "/with_replies")): text "Tweets & Replies" 39 | li(class=query.getTabClass(media)): 40 | a(href=(link & "/media")): text "Media" 41 | li(class=query.getTabClass(favorites)): 42 | a(href=(link & "/favorites")): text "Likes" 43 | li(class=query.getTabClass(tweets)): 44 | a(href=(link & "/search")): text "Search" 45 | 46 | proc renderSearchTabs*(query: Query): VNode = 47 | var q = query 48 | buildHtml(ul(class="tab")): 49 | li(class=query.getTabClass(tweets)): 50 | q.kind = tweets 51 | a(href=("?" & genQueryUrl(q))): text "Tweets" 52 | li(class=query.getTabClass(users)): 53 | q.kind = users 54 | a(href=("?" & genQueryUrl(q))): text "Users" 55 | 56 | proc isPanelOpen(q: Query): bool = 57 | q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or 58 | @[q.near, q.until, q.since].anyIt(it.len > 0)) 59 | 60 | proc renderSearchPanel*(query: Query): VNode = 61 | let user = query.fromUser.join(",") 62 | let action = if user.len > 0: &"/{user}/search" else: "/search" 63 | buildHtml(form(`method`="get", action=action, 64 | class="search-field", autocomplete="off")): 65 | hiddenField("f", "tweets") 66 | genInput("q", "", query.text, "Enter search...", class="pref-inline") 67 | button(`type`="submit"): icon "search" 68 | 69 | input(id="search-panel-toggle", `type`="checkbox", checked=isPanelOpen(query)) 70 | label(`for`="search-panel-toggle"): icon "down" 71 | 72 | tdiv(class="search-panel"): 73 | for f in @["filter", "exclude"]: 74 | span(class="search-title"): text capitalize(f) 75 | tdiv(class="search-toggles"): 76 | for k, v in toggles: 77 | let state = 78 | if f == "filter": k in query.filters 79 | else: k in query.excludes 80 | genCheckbox(&"{f[0]}-{k}", v, state) 81 | 82 | tdiv(class="search-row"): 83 | tdiv: 84 | span(class="search-title"): text "Time range" 85 | tdiv(class="date-range"): 86 | genDate("since", query.since) 87 | span(class="search-title"): text "-" 88 | genDate("until", query.until) 89 | tdiv: 90 | span(class="search-title"): text "Near" 91 | genInput("near", "", query.near, "Location...", autofocus=false) 92 | 93 | proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; 94 | pinned=none(Tweet)): VNode = 95 | let query = results.query 96 | buildHtml(tdiv(class="timeline-container")): 97 | if query.fromUser.len > 1: 98 | tdiv(class="timeline-header"): 99 | text query.fromUser.join(" | ") 100 | 101 | if query.fromUser.len > 0: 102 | renderProfileTabs(query, query.fromUser.join(","), cfg) 103 | 104 | if query.fromUser.len == 0 or query.kind == tweets: 105 | tdiv(class="timeline-header"): 106 | renderSearchPanel(query) 107 | 108 | if query.fromUser.len == 0: 109 | renderSearchTabs(query) 110 | 111 | renderTimelineTweets(results, prefs, path, pinned) 112 | 113 | proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode = 114 | buildHtml(tdiv(class="timeline-container")): 115 | tdiv(class="timeline-header"): 116 | form(`method`="get", action="/search", class="search-field", autocomplete="off"): 117 | hiddenField("f", "users") 118 | genInput("q", "", results.query.text, "Enter username...", class="pref-inline") 119 | button(`type`="submit"): icon "search" 120 | 121 | renderSearchTabs(results.query) 122 | renderTimelineUsers(results, prefs) 123 | 124 | proc renderUserList*(results: Result[User]; prefs: Prefs): VNode = 125 | buildHtml(tdiv(class="timeline-container")): 126 | tdiv(class="timeline-header") 127 | renderTimelineUsers(results, prefs) 128 | -------------------------------------------------------------------------------- /src/routes/rss.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import asyncdispatch, tables, times, hashes, uri 3 | 4 | import jester 5 | 6 | import router_utils, timeline 7 | import ../query 8 | 9 | include "../views/rss.nimf" 10 | 11 | export times, hashes 12 | 13 | proc redisKey*(page, name, cursor: string): string = 14 | result = page & ":" & name 15 | if cursor.len > 0: 16 | result &= ":" & cursor 17 | 18 | proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.} = 19 | var profile: Profile 20 | let 21 | name = req.params.getOrDefault("name") 22 | after = getCursor(req) 23 | names = getNames(name) 24 | 25 | if names.len == 1: 26 | profile = await fetchProfile(after, query, cfg, skipRail=true, skipPinned=true) 27 | else: 28 | var q = query 29 | q.fromUser = names 30 | profile.tweets = await getGraphTweetSearch(q, after) 31 | # this is kinda dumb 32 | profile.user = User( 33 | username: name, 34 | fullname: names.join(" | "), 35 | userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png" 36 | ) 37 | 38 | if profile.user.suspended: 39 | return Rss(feed: profile.user.username, cursor: "suspended") 40 | 41 | if profile.user.fullname.len > 0: 42 | let rss = renderTimelineRss(profile, cfg, multi=(names.len > 1)) 43 | return Rss(feed: rss, cursor: profile.tweets.bottom) 44 | 45 | template respRss*(rss, page) = 46 | if rss.cursor.len == 0: 47 | let info = case page 48 | of "User": " \"" & @"name" & "\" " 49 | of "List": " \"" & @"id" & "\" " 50 | else: " " 51 | 52 | resp Http404, showError(page & info & "not found", cfg) 53 | elif rss.cursor.len == 9 and rss.cursor == "suspended": 54 | resp Http404, showError(getSuspended(@"name"), cfg) 55 | 56 | let headers = {"Content-Type": "application/rss+xml; charset=utf-8", 57 | "Min-Id": rss.cursor} 58 | resp Http200, headers, rss.feed 59 | 60 | proc createRssRouter*(cfg: Config) = 61 | router rss: 62 | get "/search/rss": 63 | cond cfg.enableRss 64 | if @"q".len > 200: 65 | resp Http400, showError("Search input too long.", cfg) 66 | 67 | let query = initQuery(params(request)) 68 | if query.kind != tweets: 69 | resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg) 70 | 71 | let 72 | cursor = getCursor() 73 | key = redisKey("search", $hash(genQueryUrl(query)), cursor) 74 | 75 | var rss = await getCachedRss(key) 76 | if rss.cursor.len > 0: 77 | respRss(rss, "Search") 78 | 79 | let tweets = await getGraphTweetSearch(query, cursor) 80 | rss.cursor = tweets.bottom 81 | rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg) 82 | 83 | await cacheRss(key, rss) 84 | respRss(rss, "Search") 85 | 86 | get "/@name/rss": 87 | cond cfg.enableRss 88 | cond '.' notin @"name" 89 | let 90 | name = @"name" 91 | key = redisKey("twitter", name, getCursor()) 92 | 93 | var rss = await getCachedRss(key) 94 | if rss.cursor.len > 0: 95 | respRss(rss, "User") 96 | 97 | rss = await timelineRss(request, cfg, Query(fromUser: @[name])) 98 | 99 | await cacheRss(key, rss) 100 | respRss(rss, "User") 101 | 102 | get "/@name/@tab/rss": 103 | cond cfg.enableRss 104 | cond '.' notin @"name" 105 | cond @"tab" in ["with_replies", "media", "favorites", "search"] 106 | let 107 | name = @"name" 108 | tab = @"tab" 109 | query = 110 | case tab 111 | of "with_replies": getReplyQuery(name) 112 | of "media": getMediaQuery(name) 113 | of "favorites": getFavoritesQuery(name) 114 | of "search": initQuery(params(request), name=name) 115 | else: Query(fromUser: @[name]) 116 | 117 | let searchKey = if tab != "search": "" 118 | else: ":" & $hash(genQueryUrl(query)) 119 | 120 | let key = redisKey(tab, name & searchKey, getCursor()) 121 | 122 | var rss = await getCachedRss(key) 123 | if rss.cursor.len > 0: 124 | respRss(rss, "User") 125 | 126 | rss = await timelineRss(request, cfg, query) 127 | 128 | await cacheRss(key, rss) 129 | respRss(rss, "User") 130 | 131 | get "/@name/lists/@slug/rss": 132 | cond cfg.enableRss 133 | cond @"name" != "i" 134 | let 135 | slug = decodeUrl(@"slug") 136 | list = await getCachedList(@"name", slug) 137 | cursor = getCursor() 138 | 139 | if list.id.len == 0: 140 | resp Http404, showError("List \"" & @"slug" & "\" not found", cfg) 141 | 142 | let url = "/i/lists/" & list.id & "/rss" 143 | if cursor.len > 0: 144 | redirect(url & "?cursor=" & encodeUrl(cursor, false)) 145 | else: 146 | redirect(url) 147 | 148 | get "/i/lists/@id/rss": 149 | cond cfg.enableRss 150 | let 151 | id = @"id" 152 | cursor = getCursor() 153 | key = redisKey("lists", id, cursor) 154 | 155 | var rss = await getCachedRss(key) 156 | if rss.cursor.len > 0: 157 | respRss(rss, "List") 158 | 159 | let 160 | list = await getCachedList(id=id) 161 | timeline = await getGraphListTweets(list.id, cursor) 162 | rss.cursor = timeline.bottom 163 | rss.feed = renderListRss(timeline.content, list, cfg) 164 | 165 | await cacheRss(key, rss) 166 | respRss(rss, "List") 167 | -------------------------------------------------------------------------------- /src/consts.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import uri, sequtils, strutils 3 | 4 | const 5 | consumerKey* = "3nVuSoBZnx6U4vzUxf5w" 6 | consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys" 7 | 8 | api = parseUri("https://api.twitter.com") 9 | activate* = $(api / "1.1/guest/activate.json") 10 | 11 | photoRail* = api / "1.1/statuses/media_timeline.json" 12 | 13 | timelineApi = api / "2/timeline" 14 | 15 | graphql = api / "graphql" 16 | graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" 17 | graphUserById* = graphql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery" 18 | graphUserTweets* = graphql / "3JNH4e9dq1BifLxAa3UMWg/UserWithProfileTweetsQueryV2" 19 | graphUserTweetsAndReplies* = graphql / "8IS8MaO-2EN6GZZZb8jF0g/UserWithProfileTweetsAndRepliesQueryV2" 20 | graphUserMedia* = graphql / "PDfFf8hGeJvUCiTyWtw4wQ/MediaTimelineV2" 21 | graphTweet* = graphql / "q94uRCEn65LZThakYcPT6g/TweetDetail" 22 | graphTweetResult* = graphql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery" 23 | graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline" 24 | graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId" 25 | graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug" 26 | graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers" 27 | graphListTweets* = graphql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline" 28 | graphFavoriters* = graphql / "mDc_nU8xGv0cLRWtTaIEug/Favoriters" 29 | graphRetweeters* = graphql / "RCR9gqwYD1NEgi9FWzA50A/Retweeters" 30 | graphFollowers* = graphql / "EAqBhgcGr_qPOzhS4Q3scQ/Followers" 31 | graphFollowing* = graphql / "JPZiqKjET7_M1r5Tlr8pyA/Following" 32 | favorites* = graphql / "eSSNbhECHHWWALkkQq-YTA/Likes" 33 | 34 | timelineParams* = { 35 | "include_can_media_tag": "1", 36 | "include_cards": "1", 37 | "include_entities": "1", 38 | "include_profile_interstitial_type": "0", 39 | "include_quote_count": "0", 40 | "include_reply_count": "0", 41 | "include_user_entities": "0", 42 | "include_ext_reply_count": "0", 43 | "include_ext_media_color": "0", 44 | "cards_platform": "Web-13", 45 | "tweet_mode": "extended", 46 | "send_error_codes": "1", 47 | "simple_quoted_tweet": "1" 48 | }.toSeq 49 | 50 | gqlFeatures* = """{ 51 | "android_graphql_skip_api_media_color_palette": false, 52 | "blue_business_profile_image_shape_enabled": false, 53 | "c9s_tweet_anatomy_moderator_badge_enabled": false, 54 | "creator_subscriptions_subscription_count_enabled": false, 55 | "creator_subscriptions_tweet_preview_api_enabled": true, 56 | "freedom_of_speech_not_reach_fetch_enabled": false, 57 | "graphql_is_translatable_rweb_tweet_is_translatable_enabled": false, 58 | "hidden_profile_likes_enabled": false, 59 | "highlights_tweets_tab_ui_enabled": false, 60 | "interactive_text_enabled": false, 61 | "longform_notetweets_consumption_enabled": true, 62 | "longform_notetweets_inline_media_enabled": false, 63 | "longform_notetweets_richtext_consumption_enabled": true, 64 | "longform_notetweets_rich_text_read_enabled": false, 65 | "responsive_web_edit_tweet_api_enabled": false, 66 | "responsive_web_enhance_cards_enabled": false, 67 | "responsive_web_graphql_exclude_directive_enabled": true, 68 | "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false, 69 | "responsive_web_graphql_timeline_navigation_enabled": false, 70 | "responsive_web_media_download_video_enabled": false, 71 | "responsive_web_text_conversations_enabled": false, 72 | "responsive_web_twitter_article_tweet_consumption_enabled": false, 73 | "responsive_web_twitter_blue_verified_badge_is_enabled": true, 74 | "rweb_lists_timeline_redesign_enabled": true, 75 | "rweb_video_timestamps_enabled": true, 76 | "spaces_2022_h2_clipping": true, 77 | "spaces_2022_h2_spaces_communities": true, 78 | "standardized_nudges_misinfo": false, 79 | "subscriptions_verification_info_enabled": true, 80 | "subscriptions_verification_info_reason_enabled": true, 81 | "subscriptions_verification_info_verified_since_enabled": true, 82 | "super_follow_badge_privacy_enabled": false, 83 | "super_follow_exclusive_tweet_notifications_enabled": false, 84 | "super_follow_tweet_api_enabled": false, 85 | "super_follow_user_api_enabled": false, 86 | "tweet_awards_web_tipping_enabled": false, 87 | "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false, 88 | "tweetypie_unmention_optimization_enabled": false, 89 | "unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false, 90 | "verified_phone_label_enabled": false, 91 | "vibe_api_enabled": false, 92 | "view_counts_everywhere_api_enabled": false 93 | }""".replace(" ", "").replace("\n", "") 94 | 95 | tweetVariables* = """{ 96 | "focalTweetId": "$1", 97 | $2 98 | "includeHasBirdwatchNotes": false, 99 | "includePromotedContent": false, 100 | "withBirdwatchNotes": false, 101 | "withVoice": false, 102 | "withV2Timeline": true 103 | }""".replace(" ", "").replace("\n", "") 104 | 105 | # oldUserTweetsVariables* = """{ 106 | # "userId": "$1", $2 107 | # "count": 20, 108 | # "includePromotedContent": false, 109 | # "withDownvotePerspective": false, 110 | # "withReactionsMetadata": false, 111 | # "withReactionsPerspective": false, 112 | # "withVoice": false, 113 | # "withV2Timeline": true 114 | # } 115 | # """ 116 | 117 | userTweetsVariables* = """{ 118 | "rest_id": "$1", $2 119 | "count": 20 120 | }""" 121 | 122 | listTweetsVariables* = """{ 123 | "rest_id": "$1", $2 124 | "count": 20 125 | }""" 126 | 127 | reactorsVariables* = """{ 128 | "tweetId" : "$1", $2 129 | "count" : 20, 130 | "includePromotedContent": false 131 | }""" 132 | 133 | followVariables* = """{ 134 | "userId" : "$1", $2 135 | "count" : 20, 136 | "includePromotedContent": false 137 | }""" 138 | -------------------------------------------------------------------------------- /src/routes/timeline.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import asyncdispatch, strutils, sequtils, uri, options, times 3 | import jester, karax/vdom 4 | 5 | import router_utils 6 | import ".."/[types, redis_cache, formatters, query, api] 7 | import ../views/[general, profile, timeline, status, search] 8 | 9 | export vdom 10 | export uri, sequtils 11 | export router_utils 12 | export redis_cache, formatters, query, api 13 | export profile, timeline, status 14 | 15 | proc getQuery*(request: Request; tab, name: string): Query = 16 | case tab 17 | of "with_replies": getReplyQuery(name) 18 | of "media": getMediaQuery(name) 19 | of "favorites": getFavoritesQuery(name) 20 | of "search": initQuery(params(request), name=name) 21 | else: Query(fromUser: @[name]) 22 | 23 | template skipIf[T](cond: bool; default; body: Future[T]): Future[T] = 24 | if cond: 25 | let fut = newFuture[T]() 26 | fut.complete(default) 27 | fut 28 | else: 29 | body 30 | 31 | proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false; 32 | skipPinned=false): Future[Profile] {.async.} = 33 | let 34 | name = query.fromUser[0] 35 | userId = await getUserId(name) 36 | 37 | if userId.len == 0: 38 | return Profile(user: User(username: name)) 39 | elif userId == "suspended": 40 | return Profile(user: User(username: name, suspended: true)) 41 | 42 | # temporary fix to prevent errors from people browsing 43 | # timelines during/immediately after deployment 44 | var after = after 45 | if query.kind in {posts, replies} and after.startsWith("scroll"): 46 | after.setLen 0 47 | 48 | let 49 | rail = 50 | skipIf(skipRail or query.kind == media, @[]): 51 | getCachedPhotoRail(name) 52 | 53 | user = getCachedUser(name) 54 | 55 | result = 56 | case query.kind 57 | of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after) 58 | of replies: await getGraphUserTweets(userId, TimelineKind.replies, after) 59 | of media: await getGraphUserTweets(userId, TimelineKind.media, after) 60 | of favorites: await getFavorites(userId, cfg, after) 61 | else: Profile(tweets: await getGraphTweetSearch(query, after)) 62 | 63 | result.user = await user 64 | result.photoRail = await rail 65 | 66 | result.tweets.query = query 67 | 68 | proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; 69 | rss, after: string): Future[string] {.async.} = 70 | if query.fromUser.len != 1: 71 | let 72 | timeline = await getGraphTweetSearch(query, after) 73 | html = renderTweetSearch(timeline, prefs, getPath()) 74 | return renderMain(html, request, cfg, prefs, "Multi", rss=rss) 75 | 76 | var profile = await fetchProfile(after, query, cfg, skipPinned=prefs.hidePins) 77 | template u: untyped = profile.user 78 | 79 | if u.suspended: 80 | return showError(getSuspended(u.username), cfg) 81 | 82 | if profile.user.id.len == 0: return 83 | 84 | let pHtml = renderProfile(profile, cfg, prefs, getPath()) 85 | result = renderMain(pHtml, request, cfg, prefs, pageTitle(u), pageDesc(u), 86 | rss=rss, images = @[u.getUserPic("_400x400")], 87 | banner=u.banner) 88 | 89 | template respTimeline*(timeline: typed) = 90 | let t = timeline 91 | if t.len == 0: 92 | resp Http404, showError("User \"" & @"name" & "\" not found", cfg) 93 | resp t 94 | 95 | template respUserId*() = 96 | cond @"user_id".len > 0 97 | let username = await getCachedUsername(@"user_id") 98 | if username.len > 0: 99 | redirect("/" & username) 100 | else: 101 | resp Http404, showError("User not found", cfg) 102 | 103 | proc createTimelineRouter*(cfg: Config) = 104 | router timeline: 105 | get "/i/user/@user_id": 106 | respUserId() 107 | 108 | get "/intent/user": 109 | respUserId() 110 | 111 | get "/@name/?@tab?/?": 112 | cond '.' notin @"name" 113 | cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] 114 | cond @"tab" in ["with_replies", "media", "search", "favorites", "following", "followers", ""] 115 | let 116 | prefs = cookiePrefs() 117 | after = getCursor() 118 | names = getNames(@"name") 119 | tab = @"tab" 120 | 121 | case tab: 122 | of "followers": 123 | resp renderMain(renderUserList(await getGraphFollowers(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs) 124 | of "following": 125 | resp renderMain(renderUserList(await getGraphFollowing(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs) 126 | else: 127 | var query = request.getQuery(@"tab", @"name") 128 | if names.len != 1: 129 | query.fromUser = names 130 | 131 | # used for the infinite scroll feature 132 | if @"scroll".len > 0: 133 | if query.fromUser.len != 1: 134 | var timeline = await getGraphTweetSearch(query, after) 135 | if timeline.content.len == 0: resp Http404 136 | timeline.beginning = true 137 | resp $renderTweetSearch(timeline, prefs, getPath()) 138 | else: 139 | var profile = await fetchProfile(after, query, cfg, skipRail=true) 140 | if profile.tweets.content.len == 0: resp Http404 141 | profile.tweets.beginning = true 142 | resp $renderTimelineTweets(profile.tweets, prefs, getPath()) 143 | 144 | let rss = 145 | if @"tab".len == 0: 146 | "/$1/rss" % @"name" 147 | elif @"tab" == "search": 148 | "/$1/search/rss?$2" % [@"name", genQueryUrl(query)] 149 | else: 150 | "/$1/$2/rss" % [@"name", @"tab"] 151 | 152 | respTimeline(await showTimeline(request, query, cfg, prefs, rss, after)) 153 | -------------------------------------------------------------------------------- /src/views/general.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import uri, strutils, strformat 3 | import karax/[karaxdsl, vdom] 4 | 5 | import renderutils 6 | import ../utils, ../types, ../prefs, ../formatters 7 | 8 | import jester 9 | 10 | const 11 | doctype = "\n" 12 | lp = readFile("public/lp.svg") 13 | 14 | proc toTheme(theme: string): string = 15 | theme.toLowerAscii.replace(" ", "_") 16 | 17 | proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode = 18 | var path = req.params.getOrDefault("referer") 19 | if path.len == 0: 20 | path = $(parseUri(req.path) ? filterParams(req.params)) 21 | if "/status/" in path: path.add "#m" 22 | 23 | buildHtml(nav): 24 | tdiv(class="inner-nav"): 25 | tdiv(class="nav-item"): 26 | a(class="site-name", href="/"): text cfg.title 27 | 28 | a(href="/"): img(class="site-logo", src="/logo.png", alt="Logo") 29 | 30 | tdiv(class="nav-item right"): 31 | icon "search", title="Search", href="/search" 32 | if cfg.enableRss and rss.len > 0: 33 | icon "rss-feed", title="RSS Feed", href=rss 34 | icon "bird", title="Open in Twitter", href=canonical 35 | a(href="https://liberapay.com/zedeus"): verbatim lp 36 | icon "info", title="About", href="/about" 37 | icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path)) 38 | 39 | proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; 40 | video=""; images: seq[string] = @[]; banner=""; ogTitle=""; 41 | rss=""; canonical=""): VNode = 42 | var theme = prefs.theme.toTheme 43 | if "theme" in req.params: 44 | theme = req.params["theme"].toTheme 45 | 46 | let ogType = 47 | if video.len > 0: "video" 48 | elif rss.len > 0: "object" 49 | elif images.len > 0: "photo" 50 | else: "article" 51 | 52 | let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" 53 | 54 | buildHtml(head): 55 | link(rel="stylesheet", type="text/css", href="/css/style.css?v=19") 56 | link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2") 57 | 58 | if theme.len > 0: 59 | link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css")) 60 | 61 | link(rel="apple-touch-icon", sizes="180x180", href="/apple-touch-icon.png") 62 | link(rel="icon", type="image/png", sizes="32x32", href="/favicon-32x32.png") 63 | link(rel="icon", type="image/png", sizes="16x16", href="/favicon-16x16.png") 64 | link(rel="manifest", href="/site.webmanifest") 65 | link(rel="mask-icon", href="/safari-pinned-tab.svg", color="#ff6c60") 66 | link(rel="search", type="application/opensearchdescription+xml", title=cfg.title, 67 | href=opensearchUrl) 68 | 69 | if canonical.len > 0: 70 | link(rel="canonical", href=canonical) 71 | 72 | if cfg.enableRss and rss.len > 0: 73 | link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed") 74 | 75 | if prefs.hlsPlayback: 76 | script(src="/js/hls.light.min.js", `defer`="") 77 | script(src="/js/hlsPlayback.js", `defer`="") 78 | 79 | if prefs.infiniteScroll: 80 | script(src="/js/infiniteScroll.js", `defer`="") 81 | 82 | title: 83 | if titleText.len > 0: 84 | text titleText & " | " & cfg.title 85 | else: 86 | text cfg.title 87 | 88 | meta(name="viewport", content="width=device-width, initial-scale=1.0") 89 | meta(name="theme-color", content="#1F1F1F") 90 | meta(property="og:type", content=ogType) 91 | meta(property="og:title", content=(if ogTitle.len > 0: ogTitle else: titleText)) 92 | meta(property="og:description", content=stripHtml(desc)) 93 | meta(property="og:site_name", content="Nitter") 94 | meta(property="og:locale", content="en_US") 95 | 96 | if banner.len > 0 and not banner.startsWith('#'): 97 | let bannerUrl = getPicUrl(banner) 98 | link(rel="preload", type="image/png", href=bannerUrl, `as`="image") 99 | 100 | for url in images: 101 | let preloadUrl = if "400x400" in url: getPicUrl(url) 102 | else: getSmallPic(url) 103 | link(rel="preload", type="image/png", href=preloadUrl, `as`="image") 104 | 105 | let image = getUrlPrefix(cfg) & getPicUrl(url) 106 | meta(property="og:image", content=image) 107 | meta(property="twitter:image:src", content=image) 108 | 109 | if rss.len > 0: 110 | meta(property="twitter:card", content="summary") 111 | else: 112 | meta(property="twitter:card", content="summary_large_image") 113 | 114 | if video.len > 0: 115 | meta(property="og:video:url", content=video) 116 | meta(property="og:video:secure_url", content=video) 117 | meta(property="og:video:type", content="text/html") 118 | 119 | # this is last so images are also preloaded 120 | # if this is done earlier, Chrome only preloads one image for some reason 121 | link(rel="preload", type="font/woff2", `as`="font", 122 | href="/fonts/fontello.woff2?21002321", crossorigin="anonymous") 123 | 124 | proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; 125 | titleText=""; desc=""; ogTitle=""; rss=""; video=""; 126 | images: seq[string] = @[]; banner=""): string = 127 | 128 | let canonical = getTwitterLink(req.path, req.params) 129 | 130 | let node = buildHtml(html(lang="en")): 131 | renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, 132 | rss, canonical) 133 | 134 | body: 135 | renderNavbar(cfg, req, rss, canonical) 136 | 137 | tdiv(class="container"): 138 | body 139 | 140 | result = doctype & $node 141 | 142 | proc renderError*(error: string): VNode = 143 | buildHtml(tdiv(class="panel-container")): 144 | tdiv(class="error-panel"): 145 | span: verbatim error 146 | -------------------------------------------------------------------------------- /tests/test_tweet.py: -------------------------------------------------------------------------------- 1 | from base import BaseTestCase, Tweet, Conversation, get_timeline_tweet 2 | from parameterized import parameterized 3 | 4 | # image = tweet + 'div.attachments.media-body > div > div > a > div > img' 5 | # self.assert_true(self.get_image_url(image).split('/')[0] == 'http') 6 | 7 | timeline = [ 8 | [1, 'Test account', 'mobile_test', '10 Aug 2016', '763483571793174528', 9 | '.'], 10 | 11 | [3, 'Test account', 'mobile_test', '3 Mar 2016', '705522133443571712', 12 | 'LIVE on #Periscope pscp.tv/w/aadiTzF6dkVOTXZSbX…'], 13 | 14 | [6, 'mobile test 2', 'mobile_test_2', '1 Oct 2014', '517449200045277184', 15 | 'Testing. One two three four. Test.'] 16 | ] 17 | 18 | status = [ 19 | [20, 'jack', 'jack', '21 Mar 2006', 'just setting up my twttr'], 20 | [134849778302464000, 'The Twoffice', 'TheTwoffice', '11 Nov 2011', 'test'], 21 | [105685475985080322, 'The Twoffice', 'TheTwoffice', '22 Aug 2011', 'regular tweet'], 22 | [572593440719912960, 'Test account', 'mobile_test', '3 Mar 2015', 'testing test'] 23 | ] 24 | 25 | invalid = [ 26 | ['mobile_test/status/120938109238'], 27 | ['TheTwoffice/status/8931928312'] 28 | ] 29 | 30 | multiline = [ 31 | [400897186990284800, 'mobile_test_3', 32 | """ 33 | ♔ 34 | KEEP 35 | CALM 36 | AND 37 | CLICHÉ 38 | ON"""], 39 | [1718660434457239868, 'WebDesignMuseum', 40 | """ 41 | Happy 32nd Birthday HTML tags! 42 | 43 | On October 29, 1991, the internet pioneer, Tim Berners-Lee, published a document entitled HTML Tags. 44 | 45 | The document contained a description of the first 18 HTML tags: , <nextid>, <a>, <isindex>, <plaintext>, <listing>, <p>, <h1>…<h6>, <address>, <hp1>, <hp2>…, <dl>, <dt>, <dd>, <ul>, <li>,<menu> and <dir>. The design of the first version of HTML language was influenced by the SGML universal markup language. 46 | 47 | #WebDesignHistory"""] 48 | ] 49 | 50 | link = [ 51 | ['nim_lang/status/1110499584852353024', [ 52 | 'nim-lang.org/araq/ownedrefs.…', 53 | 'news.ycombinator.com/item?id…', 54 | 'teddit.net/r/programming…' 55 | ]], 56 | ['nim_lang/status/1125887775151140864', [ 57 | 'en.wikipedia.org/wiki/Nim_(p…' 58 | ]], 59 | ['hiankun_taioan/status/1086916335215341570', [ 60 | '(hackernoon.com/interview-wit…)' 61 | ]], 62 | ['archillinks/status/1146302618223951873', [ 63 | 'flickr.com/photos/87101284@N…', 64 | 'hisafoto.tumblr.com/post/176…' 65 | ]], 66 | ['archillinks/status/1146292551936335873', [ 67 | 'flickr.com/photos/michaelrye…', 68 | 'furtho.tumblr.com/post/16618…' 69 | ]] 70 | ] 71 | 72 | username = [ 73 | ['Bountysource/status/1094803522053320705', ['nim_lang']], 74 | ['leereilly/status/1058464250098704385', ['godotengine', 'unity3d', 'nim_lang']] 75 | ] 76 | 77 | emoji = [ 78 | ['Tesla/status/1134850442511257600', '🌈❤️🧡💛💚💙💜'] 79 | ] 80 | 81 | retweet = [ 82 | [7, 'mobile_test_2', 'mobile test 2', 'Test account', '@mobile_test', '1234'], 83 | [3, 'mobile_test_8', 'mobile test 8', 'jack', '@jack', 'twttr'] 84 | ] 85 | 86 | 87 | class TweetTest(BaseTestCase): 88 | @parameterized.expand(timeline) 89 | def test_timeline(self, index, fullname, username, date, tid, text): 90 | self.open_nitter(username) 91 | tweet = get_timeline_tweet(index) 92 | self.assert_exact_text(fullname, tweet.fullname) 93 | self.assert_exact_text('@' + username, tweet.username) 94 | self.assert_exact_text(date, tweet.date) 95 | self.assert_text(text, tweet.text) 96 | permalink = self.find_element(tweet.date + ' a') 97 | self.assertIn(tid, permalink.get_attribute('href')) 98 | 99 | @parameterized.expand(status) 100 | def test_status(self, tid, fullname, username, date, text): 101 | tweet = Tweet() 102 | self.open_nitter(f'{username}/status/{tid}') 103 | self.assert_exact_text(fullname, tweet.fullname) 104 | self.assert_exact_text('@' + username, tweet.username) 105 | self.assert_exact_text(date, tweet.date) 106 | self.assert_text(text, tweet.text) 107 | 108 | @parameterized.expand(multiline) 109 | def test_multiline_formatting(self, tid, username, text): 110 | self.open_nitter(f'{username}/status/{tid}') 111 | self.assert_text(text.strip('\n'), Conversation.main) 112 | 113 | @parameterized.expand(emoji) 114 | def test_emoji(self, tweet, text): 115 | self.open_nitter(tweet) 116 | self.assert_text(text, Conversation.main) 117 | 118 | @parameterized.expand(link) 119 | def test_link(self, tweet, links): 120 | self.open_nitter(tweet) 121 | for link in links: 122 | self.assert_text(link, Conversation.main) 123 | 124 | @parameterized.expand(username) 125 | def test_username(self, tweet, usernames): 126 | self.open_nitter(tweet) 127 | for un in usernames: 128 | link = self.find_link_text(f'@{un}') 129 | self.assertIn(f'/{un}', link.get_property('href')) 130 | 131 | @parameterized.expand(retweet) 132 | def test_retweet(self, index, url, retweet_by, fullname, username, text): 133 | self.open_nitter(url) 134 | tweet = get_timeline_tweet(index) 135 | self.assert_text(f'{retweet_by} retweeted', tweet.retweet) 136 | self.assert_text(text, tweet.text) 137 | self.assert_exact_text(fullname, tweet.fullname) 138 | self.assert_exact_text(username, tweet.username) 139 | 140 | @parameterized.expand(invalid) 141 | def test_invalid_id(self, tweet): 142 | self.open_nitter(tweet) 143 | self.assert_text('Tweet not found', '.error-panel') 144 | 145 | #@parameterized.expand(reply) 146 | #def test_thread(self, tweet, num): 147 | #self.open_nitter(tweet) 148 | #thread = self.find_element(f'.timeline > div:nth-child({num})') 149 | #self.assertIn(thread.get_attribute('class'), 'thread-line') 150 | -------------------------------------------------------------------------------- /public/fonts/fontello.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" standalone="no"?> 2 | <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> 3 | <svg xmlns="http://www.w3.org/2000/svg"> 4 | <metadata>Copyright (C) 2020 by original authors @ fontello.com</metadata> 5 | <defs> 6 | <font id="fontello" horiz-adv-x="1000" > 7 | <font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" /> 8 | <missing-glyph horiz-adv-x="1000" /> 9 | <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" /> 10 | 11 | <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" /> 12 | 13 | <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" /> 14 | 15 | <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" /> 16 | 17 | <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" /> 18 | 19 | <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" /> 20 | 21 | <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" /> 22 | 23 | <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" /> 24 | 25 | <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" /> 26 | 27 | <glyph glyph-name="lock" unicode="" d="M0 350q0 207 147 354t353 146 354-146 146-354-146-354-354-146-353 146-147 354z m252-271l496 0 0 314-82 0 0 59q0 33-14 66-19 45-62 74t-94 30-92-29-62-75q-16-35-14-125l-76 0 0-314z m176 314l0 59q2 31 19 49t45 21l4 0q29-2 49-22t21-48l0-59-138 0z" horiz-adv-x="1000" /> 28 | 29 | <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" /> 30 | 31 | <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" /> 32 | 33 | <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" /> 34 | 35 | <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" /> 36 | 37 | <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" /> 38 | 39 | <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" /> 40 | 41 | <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" /> 42 | 43 | <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" /> 44 | </font> 45 | </defs> 46 | </svg> --------------------------------------------------------------------------------