├── 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 |
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: , , , , , , ,
…, , , …, , - ,
- ,
, - ,